Your first app
On this page you build an app that turns the porch light on at sunset and off at bedtime. You test it without waiting for the sun, then run it against your Home Assistant.
You need a project from Install Stewart.
1. Write the app
Section titled “1. Write the app”Create apps/PorchLight.php:
<?php
declare(strict_types=1);
namespace App;
use Psr\Log\LoggerInterface;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\Sun\SunEvent;
#[Automation(id: 'porch-light')]final readonly class PorchLight implements App{ public function __construct( private HaContext $ha, private Scheduler $scheduler, private LoggerInterface $logger, private string $light = 'light.porch', private string $offAt = '23:00', ) {}
public function initialize(): void { $this->scheduler->runAtSunEvent(SunEvent::Sunset, function (): void { $this->logger->info('Sunset, turning the porch light on'); $this->ha->callService('light', 'turn_on', target: ServiceTarget::forEntities($this->light)); });
$this->scheduler->runDailyAt($this->offAt, function (): void { $this->logger->info('Bedtime, turning the porch light off'); $this->ha->callService('light', 'turn_off', target: ServiceTarget::forEntities($this->light)); }); }
public function dispose(): void {}}Here is what each part does:
#[Automation(id: 'porch-light')]marks the class as an app. Stewart finds every app inapps/by this attribute. The ID names the app in logs, instewart.yamland in stored data, so keep it once the app runs. It starts with a lowercase letter and uses only lowercase letters, digits,-and_.implements Appgives the app its two lifecycle methods.initialize()runs once, when the app starts.dispose()runs when it stops; this app has nothing to clean up.- The constructor asks for what the app needs, and Stewart passes it in:
HaContexttalks to Home Assistant: reads states, watches changes, calls services.Schedulerruns code at a time of day, at a sun event, or every few minutes.LoggerInterfacewrites to Stewart’s log, tagged with the app’s ID.$lightand$offAtare options. Their defaults apply unlessstewart.yamlsets them.
initialize()sets up two schedules. At sunset, the app calls thelight.turn_onservice on the porch light. At$offAt, it callslight.turn_off. Both log a line first, so you can see them happen.
Stewart computes sunset from the location set in Home Assistant (Settings → System → General), so the schedule keeps working even while Home Assistant is restarting.
2. Configure it
Section titled “2. Configure it”Your porch light probably has another entity ID. Find it in Home Assistant under Settings → Devices & services →
Entities, then set it in stewart.yaml:
apps: porch-light: options: light: light.front_porch offAt: '22:30'The keys under options are the names of the constructor parameters. Each one replaces that parameter’s default.
3. Test it
Section titled “3. Test it”Waiting for sunset to check your app is slow. A test runs the app on a virtual clock instead. Create
tests/PorchLightTest.php:
<?php
declare(strict_types=1);
namespace App\Tests;
use App\PorchLight;use PHPUnit\Framework\TestCase;use Stewart\Contracts\Time\Duration;use Stewart\Testing\App\AppHarness;
final class PorchLightTest extends TestCase{ private AppHarness $harness;
protected function setUp(): void { // The harness starts at midnight, and its sun sets at 18:00. $this->harness = new AppHarness(); $this->harness->startApp(PorchLight::class, ['light' => 'light.front_porch']); }
public function testTheLightTurnsOnAtSunset(): void { $this->harness->advanceTime(Duration::hours(18));
$call = $this->harness->ha->getLastCall(); self::assertSame('light.turn_on', $call->getServiceName()); self::assertSame(['light.front_porch'], $call->listTargetedEntityIds()); }
public function testTheLightTurnsOffAtBedtime(): void { $this->harness->advanceTime(Duration::hours(23));
self::assertSame('light.turn_off', $this->harness->ha->getLastCall()->getServiceName()); }}AppHarnessbuilds the app the way Stewart does, but with fake versions of Home Assistant, the clock and the scheduler.- Its clock starts at midnight on 1 January 2026 and stands still until the test moves it.
advanceTime(Duration::hours(18))jumps to 18:00, the harness’s sunset. Every schedule due on the way runs.getLastCall()returns the last service call the app made, so the test can check it.
Run the tests:
make testTesting apps covers the harness in full.
4. Try it without touching your lights
Section titled “4. Try it without touching your lights”DRY_RUN=1 runs Stewart with your real Home Assistant, but logs each service call instead of sending it. ONLY
runs just the apps you name:
make run ONLY=porch-light DRY_RUN=1Stewart warns that dry run is on. When a schedule fires, you see the app’s log line, followed by
Service call not sent: service_calls.dry_run is on with the service and the target.
5. Run it for real
Section titled “5. Run it for real”make run ONLY=porch-lightLeave out ONLY to run every app. To keep Stewart running in the background, use make up, then follow its log
with make logs and stop it with make down. For a server, see
Deploying.
You no longer need the example app. Delete apps/HelloApp.php, tests/HelloAppTest.php and its hello: entry in
stewart.yaml.
Common problems
Section titled “Common problems”The app logs A sunset schedule needs a location, and Home Assistant reported none. Home Assistant has no
location. Set one under Settings →
System → General and restart Stewart.
App "porch-light" sets option "…", which matches no parameter of App\PorchLight::__construct(). An option
in stewart.yaml has a name the constructor does not have. Check its spelling against the parameter names.
The light does not react. Run with DRY_RUN=1 and check that the log shows the right entity ID. Then call
the same service in Home Assistant under Developer tools → Actions to rule out the light itself.
- Project tour explains the other files in your project.
- How it works explains the rules every app follows, such as never calling
sleep(). - State changes shows how to react when an entity changes.