Skip to content

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.

Create apps/PorchLight.php:

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 in apps/ by this attribute. The ID names the app in logs, in stewart.yaml and in stored data, so keep it once the app runs. It starts with a lowercase letter and uses only lowercase letters, digits, - and _.
  • implements App gives 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:
    • HaContext talks to Home Assistant: reads states, watches changes, calls services.
    • Scheduler runs code at a time of day, at a sun event, or every few minutes.
    • LoggerInterface writes to Stewart’s log, tagged with the app’s ID.
    • $light and $offAt are options. Their defaults apply unless stewart.yaml sets them.
  • initialize() sets up two schedules. At sunset, the app calls the light.turn_on service on the porch light. At $offAt, it calls light.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.

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.

Waiting for sunset to check your app is slow. A test runs the app on a virtual clock instead. Create tests/PorchLightTest.php:

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());
}
}
  • AppHarness builds 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:

Terminal window
make test

Testing apps covers the harness in full.

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:

Terminal window
make run ONLY=porch-light DRY_RUN=1

Stewart 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.

Terminal window
make run ONLY=porch-light

Leave 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.

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.