Skip to content
These pages describe Stewart 1.x. Read the docs for the latest release, and how to upgrade.

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.

apps/NightMotionLight.php
<?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: 10
  • 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.
tests/NightMotionLightTest.php
<?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;
}
}
  • Skip the dimming when someone is watching TV: add a check of media_player.living_room in 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.

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:

apps/MotionLight.php
<?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: 1

A test starts the template under the ID of one entry:

tests/MotionLightTest.php
<?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;
}
}