Scheduling and waiting
The Scheduler runs code at set times. Ask for it in the constructor. This app starts the coffee machine at 06:45 on
weekdays, and closes the living room covers half an hour before sunset:
<?php
declare(strict_types=1);
namespace App;
use Stewart\Contracts\App;use Stewart\Contracts\Automation;use Stewart\Contracts\HaContext;use Stewart\Contracts\Schedule\DayOfWeek;use Stewart\Contracts\Schedule\Scheduler;use Stewart\Contracts\Service\ServiceTarget;use Stewart\Contracts\Sun\SunEvent;use Stewart\Contracts\Time\Duration;use Stewart\Contracts\Time\SunOffset;
#[Automation(id: 'weekday-morning')]final readonly class WeekdayMorning implements App{ public function __construct( private HaContext $ha, private Scheduler $scheduler, ) {}
public function initialize(): void { $this->scheduler->runDailyAt( '06:45', function (): void { $this->ha->callService('switch', 'turn_on', target: ServiceTarget::forEntities('switch.coffee_machine')); }, DayOfWeek::Monday, DayOfWeek::Tuesday, DayOfWeek::Wednesday, DayOfWeek::Thursday, DayOfWeek::Friday, );
$this->scheduler->runAtSunEvent( SunEvent::Sunset, function (): void { $this->ha->callService('cover', 'close_cover', target: ServiceTarget::forAreas('living_room')); }, SunOffset::before(Duration::minutes(30)), ); }
public function dispose(): void {}}Schedules
Section titled “Schedules”| Method | Runs |
|---|---|
runDailyAt('07:00', $handler) |
Every day at 07:00 |
runDailyAt('07:00', $handler, DayOfWeek::Saturday, DayOfWeek::Sunday) |
Only on the days you list |
runEvery(Duration::minutes(5), $handler) |
Every 5 minutes, starting 5 minutes after the app starts |
runOnCron('0 22 * * *', $handler) |
On a standard five-field cron expression |
runAtSunEvent(SunEvent::Sunset, $handler) |
Every day at a sun event: sunrise, sunset, dawn, dusk, golden hour, … |
runAtSunEvent(SunEvent::Sunrise, $handler, SunOffset::after(Duration::minutes(20))) |
The same, shifted before or after |
runAtEntityTime('input_datetime.wake_up', $handler) |
At the time an input_datetime or a timestamp sensor holds, following its changes |
runAfter(Duration::minutes(20), $handler) |
Once, after 20 minutes |
runAt($moment, $handler) |
Once, at a DateTimeImmutable |
Times are '07:00' or '07:00:30'. Each method returns a ScheduledTask; call cancel() on it to stop it. All
schedules stop when the app stops, so dispose() has nothing to clean up.
The handler may take a ScheduledRun, which tells it when it was due (scheduledFor), how late it started
(getLateness()) and how many runs it missed (missedOccurrences). Most handlers do not need it.
How schedules treat time
Section titled “How schedules treat time”Times follow Home Assistant’s time zone, not the container’s. A 07:00 schedule runs when Home Assistant says it is 07:00.
Daylight saving time is handled. On the night the clocks go forward, a time that does not exist that night (for
example 02:30) does not run. On the night they go back, a time that happens twice runs once. A cron that runs every 15
minutes pauses during the repeated hour; use runEvery() for work that must not pause.
runEvery() and runAfter() count elapsed time. A clock correction does not make them run early or late. The
other methods follow the wall clock.
Missed runs are skipped, not caught up. If Stewart was down at 03:00, the 03:00 run does not happen at 09:00.
Runs never overlap. If a handler is still running when its next run is due, that run is skipped.
Sun events need a location in Home Assistant (Settings → System → General). Stewart computes them itself, so they keep working while Home Assistant restarts. See Sun.
Waiting in a handler
Section titled “Waiting in a handler”Some automations are a sequence of steps: lock the door, then check that it locked. waitForState() pauses the
handler until an entity reaches a state:
<?php
declare(strict_types=1);
namespace App;
use Stewart\Contracts\App;use Stewart\Contracts\Automation;use Stewart\Contracts\HaContext;use Stewart\Contracts\Schedule\Scheduler;use Stewart\Contracts\Service\ServiceTarget;use Stewart\Contracts\Time\Duration;
#[Automation(id: 'night-lock')]final readonly class NightLock implements App{ public function __construct( private HaContext $ha, private Scheduler $scheduler, private string $lock = 'lock.front_door', ) {}
public function initialize(): void { $this->scheduler->runDailyAt('23:00', function (): void { $this->ha->callService('lock', 'lock', target: ServiceTarget::forEntities($this->lock));
if ($this->ha->waitForState($this->lock, 'locked', Duration::seconds(30)) === null) { $this->ha->callService('notify', 'notify', ['message' => 'The front door did not lock.']); } }); }
public function dispose(): void {}}| Method | Waits for |
|---|---|
$this->ha->waitForState('lock.front_door', 'locked', Duration::seconds(30)) |
The entity to be in that state; returns at once if it already is |
$stream->waitForNext(Duration::minutes(1)) |
The next item of a stream, after its operators |
- A timeout returns
null. Without a timeout, the wait lasts until it is met or the app stops. - Other apps keep running while a handler waits. Later events for the same subscription wait their turn.
- Do not wait in
initialize(). The app receives nothing untilinitialize()returns, so the wait could only time out. Add a schedule or a subscription ininitialize(), and wait in its handler. - Do not use
sleep()or any other function that pauses PHP. It holds up every app on the worker, and tests cannot skip it. Use a schedule, a wait, or an operator such aswhenChangedTo(…, for: …).
For “has stayed off for 5 minutes”, use whenChangedTo('off', for: Duration::minutes(5)) from
State changes. Waits are for steps that follow one another.
Testing
Section titled “Testing”Tests run schedules on a virtual clock. advanceTime() moves it, and every schedule due on the way runs:
<?php
declare(strict_types=1);
namespace App\Tests;
use App\NightLock;use PHPUnit\Framework\TestCase;use Stewart\Contracts\Time\Duration;use Stewart\Testing\App\AppHarness;
final class NightLockTest extends TestCase{ private AppHarness $harness;
protected function setUp(): void { $this->harness = new AppHarness(); $this->harness->ha->seedState('lock.front_door', 'unlocked'); $this->harness->startApp(NightLock::class); $this->harness->advanceTime(Duration::hours(23)); }
public function testTheDoorIsLockedAtElevenPm(): void { self::assertSame('lock.lock', $this->harness->ha->getLastCall()->getServiceName()); }
public function testNothingIsSentWhenTheDoorLocks(): void { $this->harness->ha->pushState('lock.front_door', 'locked'); $this->harness->advanceTime(Duration::minutes(1));
self::assertSame('lock.lock', $this->harness->ha->getLastCall()->getServiceName()); }
public function testANotificationIsSentWhenTheDoorStaysUnlocked(): void { $this->harness->advanceTime(Duration::seconds(30));
self::assertSame('notify.notify', $this->harness->ha->getLastCall()->getServiceName()); }}The test harness’s sun rises at 06:00 and sets at 18:00. new AppHarness(new DateTimeImmutable('2026-01-03')) starts
the clock on another day, which is handy for weekday schedules.
Common problems
Section titled “Common problems”A sunset schedule needs a location. Home Assistant has no location set.
A schedule runs at the wrong hour. Check the time zone in Home Assistant under Settings → System → General.
runAt() logs a warning and never runs. The moment is already in the past.