Motion light
Turns the hall light on when someone walks by after sunset. Late at night it is dimmed, so it does not wake anyone. A few minutes after the motion stops, it turns off again.
You need a motion sensor and a dimmable light.
<?php
declare(strict_types=1);
namespace App;
use Stewart\Contracts\App;use Stewart\Contracts\Automation;use Stewart\Contracts\HaContext;use Stewart\Contracts\Service\ServiceTarget;use Stewart\Contracts\Sun\SunCalendar;use Stewart\Contracts\Time\Clock;use Stewart\Contracts\Time\Duration;
#[Automation(id: 'night-motion-light')]final readonly class NightMotionLight implements App{ public function __construct( private HaContext $ha, private SunCalendar $sun, private Clock $clock, private string $motionSensor = 'binary_sensor.hall_motion', private string $light = 'light.hall', private int $offAfterMinutes = 3, private int $dayBrightness = 100, private int $nightBrightness = 10, ) {}
public function initialize(): void { $this->ha->watchStateChanges($this->motionSensor) ->whenChangedTo('on') ->filter(fn(): bool => !$this->sun->isSunUp()) ->subscribe(function (): void { $brightness = $this->clock->isWithin('23:00', '06:00') ? $this->nightBrightness : $this->dayBrightness; $this->ha->callAction('light.turn_on', ['brightness_pct' => $brightness], ServiceTarget::forEntities($this->light)); });
$this->ha->watchStateChanges($this->motionSensor) ->whenChangedTo('off', for: Duration::minutes($this->offAfterMinutes)) ->subscribe(function (): void { if ($this->ha->getState($this->light)?->state === 'on') { $this->ha->callAction('light.turn_off', target: ServiceTarget::forEntities($this->light)); } }); }}apps: night-motion-light: options: motionSensor: binary_sensor.hall_motion light: light.hall offAfterMinutes: 3 nightBrightness: 10How it works
Section titled “How it works”filter(fn () => !$this->sun->isSunUp())lets motion through only while the sun is down.Clock::isWithin('23:00', '06:00')picks the brightness. The window may cross midnight.whenChangedTo('off', for: …)turns the light off only once the sensor has reported no motion for the whole time. New motion starts the wait again.- The light is only turned off if it is still on, so a light someone already switched off is left alone.
<?php
declare(strict_types=1);
namespace App\Tests;
use App\NightMotionLight;use DateTimeImmutable;use PHPUnit\Framework\TestCase;use Stewart\Contracts\Time\Duration;use Stewart\Testing\App\AppHarness;
final class NightMotionLightTest extends TestCase{ public function testLateAtNightTheLightIsDim(): void { $harness = $this->startAt('2026-01-01 23:30 UTC');
$harness->ha->pushState('binary_sensor.hall_motion', 'on');
self::assertSame(['brightness_pct' => 10], $harness->ha->getLastCall()->data); }
public function testInTheEveningTheLightIsBright(): void { $harness = $this->startAt('2026-01-01 19:00 UTC');
$harness->ha->pushState('binary_sensor.hall_motion', 'on');
self::assertSame(['brightness_pct' => 100], $harness->ha->getLastCall()->data); }
public function testDuringTheDayNothingHappens(): void { $harness = $this->startAt('2026-01-01 12:00 UTC');
$harness->ha->pushState('binary_sensor.hall_motion', 'on');
self::assertTrue($harness->ha->calls->isEmpty()); }
public function testTheLightTurnsOffAfterTheMotionStops(): void { $harness = $this->startAt('2026-01-01 19:00 UTC'); $harness->ha->pushState('binary_sensor.hall_motion', 'on'); $harness->ha->pushState('light.hall', 'on'); $harness->ha->pushState('binary_sensor.hall_motion', 'off');
$harness->advanceTime(Duration::minutes(3));
self::assertSame('light.turn_off', $harness->ha->getLastCall()->getActionName()); }
private function startAt(string $moment): AppHarness { $harness = new AppHarness(new DateTimeImmutable($moment)); $harness->ha->seedState('binary_sensor.hall_motion', 'off'); $harness->ha->seedState('light.hall', 'off'); $harness->startApp(NightMotionLight::class);
return $harness; }}Make it yours
Section titled “Make it yours”- Skip the dimming when someone is watching TV: add a check of
media_player.living_roomin the handler. - Leave a light alone after someone switched it by hand: see Your own changes.
- Several rooms: see below, or watch an area filter and turn on the lights of the area where the motion was.
Several rooms
Section titled “Several rooms”Mark the class #[AppTemplate] instead of #[Automation], and give each room an entry of its own that names the
class. Each entry is an app with its own ID, options and status. This template leaves out the night dimming to stay
short:
<?php
declare(strict_types=1);
namespace App;
use Stewart\Contracts\App;use Stewart\Contracts\AppTemplate;use Stewart\Contracts\HaContext;use Stewart\Contracts\Service\ServiceTarget;use Stewart\Contracts\Time\Duration;
#[AppTemplate]final readonly class MotionLight implements App{ public function __construct( private HaContext $ha, private string $motionSensor, private string $light, private int $offAfterMinutes = 3, ) {}
public function initialize(): void { $this->ha->watchStateChanges($this->motionSensor) ->whenChangedTo('on') ->subscribe(function (): void { $this->ha->callAction('light.turn_on', target: ServiceTarget::forEntities($this->light)); });
$this->ha->watchStateChanges($this->motionSensor) ->whenChangedTo('off', for: Duration::minutes($this->offAfterMinutes)) ->subscribe(function (): void { $this->ha->callAction('light.turn_off', target: ServiceTarget::forEntities($this->light)); }); }}apps: hall-motion-light: class: App\MotionLight options: motionSensor: binary_sensor.hall_motion light: light.hall landing-motion-light: class: App\MotionLight options: motionSensor: binary_sensor.landing_motion light: light.landing offAfterMinutes: 1A test starts the template under the ID of one entry:
<?php
declare(strict_types=1);
namespace App\Tests;
use App\MotionLight;use PHPUnit\Framework\TestCase;use Stewart\Contracts\Time\Duration;use Stewart\Testing\App\AppHarness;
final class MotionLightTest extends TestCase{ public function testEachRoomSwitchesItsOwnLight(): void { $hall = self::startRoom('hall-motion-light', ['motionSensor' => 'binary_sensor.hall_motion', 'light' => 'light.hall']); $landing = self::startRoom('landing-motion-light', ['motionSensor' => 'binary_sensor.landing_motion', 'light' => 'light.landing']);
$hall->ha->pushState('binary_sensor.hall_motion', 'on'); $landing->ha->pushState('binary_sensor.landing_motion', 'on');
self::assertSame(['light.hall'], $hall->ha->getLastCall()->listTargetedEntityIds()); self::assertSame(['light.landing'], $landing->ha->getLastCall()->listTargetedEntityIds()); }
public function testTheLightGoesOffAfterTheRoomsOwnDelay(): void { $landing = self::startRoom('landing-motion-light', ['motionSensor' => 'binary_sensor.landing_motion', 'light' => 'light.landing', 'offAfterMinutes' => 1]);
$landing->ha->pushState('binary_sensor.landing_motion', 'on'); $landing->ha->pushState('binary_sensor.landing_motion', 'off'); $landing->advanceTime(Duration::minutes(1));
self::assertSame('light.turn_off', $landing->ha->getLastCall()->getActionName()); }
/** @param array{motionSensor: string, light: string, offAfterMinutes?: int} $options */ private static function startRoom(string $appId, array $options): AppHarness { $harness = new AppHarness(); $harness->ha->seedState($options['motionSensor'], 'off'); $harness->startApp(MotionLight::class, $options, appId: $appId);
return $harness; }}