Anatomy of an app
An app is a PHP class in apps/ that implements App and carries #[Automation]. This page covers what goes into
one. The example warns you about low batteries, but not at night:
<?php
declare(strict_types=1);
namespace App;
use App\Support\QuietHours;use Stewart\Contracts\App;use Stewart\Contracts\Automation;use Stewart\Contracts\HaContext;use Stewart\Contracts\State\StateChange;
#[Automation(id: 'battery-alert')]final readonly class BatteryAlert implements App{ public function __construct( private HaContext $ha, private QuietHours $quietHours, private int $threshold = 20, private string $notifyService = 'notify', ) {}
public function initialize(): void { $this->ha->watchStateChanges('sensor.*_battery') ->whenBelow($this->threshold) ->subscribe(function (StateChange $change): void { if ($this->quietHours->isNow()) { return; }
$this->ha->callService('notify', $this->notifyService, [ 'message' => \sprintf('%s is at %s%%.', $change->to?->getFriendlyName(), $change->to?->state), ]); }); }
public function dispose(): void {}}The ID
Section titled “The ID”#[Automation(id: 'battery-alert')] gives the app its ID. Stewart finds apps by this attribute, so there is no list
of apps to keep up to date.
The ID names the app in logs, in stewart.yaml, in environment variables, and in the data the app stores. Renaming
the class keeps the same app; changing the ID makes it a new one.
An ID starts with a lowercase letter and contains only lowercase letters, digits, - and _. Environment variables
cannot tell - from _, so battery-alert and battery_alert count as the same ID, and only one of them can exist.
Starting and stopping
Section titled “Starting and stopping”initialize() runs once, when the app starts. Use it to start watching entities or events and to add schedules, then return.
Stewart delivers nothing to the app until initialize() returns, so do not wait for anything in it.
dispose() runs when the app stops. Subscriptions and schedules end on their own, so most apps leave it empty. Use it
to close something the app opened itself, such as a file.
What the constructor can ask for
Section titled “What the constructor can ask for”Stewart fills each constructor parameter by its type:
| Type | What you get |
|---|---|
HaContext |
Home Assistant: states, state changes, events, triggers, service calls. See State changes |
Psr\Log\LoggerInterface |
A logger whose lines carry the app’s ID |
Stewart\Contracts\Schedule\Scheduler |
Schedules: daily, every few minutes, at sunset. See Scheduling and waiting |
Stewart\Contracts\Store\Store |
Storage that survives restarts, for this app only. See Persistence |
Stewart\Contracts\Time\Clock |
The current time in Home Assistant’s time zone, and isWithin('22:00', '07:00') |
Stewart\Contracts\Time\Timers |
Low-level timers; prefer schedules and waits |
Stewart\Contracts\Sun\SunCalendar |
Sunrise, sunset and the sun’s position. See Sun |
Stewart\Contracts\Mqtt\Mqtt |
An MQTT client. See MQTT |
Stewart\Contracts\Exposure\EntityExposure |
Entities your app creates in Home Assistant. See Exposed entities |
Stewart\Contracts\Registry\RegistryEditor |
Changes to Home Assistant’s entity registry. See Areas, floors and labels |
App\Generated\Entities, App\Generated\Services |
Typed classes for your entities. See Typed entities |
A class from services.php |
Your own services; see below |
Parameters with a scalar or array type (int, string, array, …) are options.
Options
Section titled “Options”Options let you change an app without editing its code. Set them in stewart.yaml, under the app’s ID:
apps: battery-alert: options: threshold: 15 notifyService: mobile_app_my_phone- Each key under
optionsis the name of a constructor parameter, and its value replaces that parameter’s default. - A parameter without a default must be set, or the app does not start.
- An option that matches no parameter is an error that names the app and the class.
- Options can also come from the environment:
STEWART_APPS__BATTERY_ALERT__OPTIONS__THRESHOLD=15(this needs the app’s entry instewart.yaml, as above). See Configuration.
Next to options, an app’s entry takes three more settings:
apps: battery-alert: enabled: false # do not load the app at all paused: true # load it, but deliver nothing to it until it is resumed worker: 1 # run it on this workerIf an entry names an ID that no app has, Stewart logs a warning at start, so a typo does not go unnoticed.
Your own classes (services.php)
Section titled “Your own classes (services.php)”Code that several apps share, or that holds a connection to something, goes in a class of its own, called a
service. services.php is not related to Home Assistant services; it tells Stewart which of your classes it may
pass to apps. QuietHours from the example is one:
<?php
declare(strict_types=1);
namespace App\Support;
use Stewart\Contracts\Time\Clock;
final readonly class QuietHours{ public function __construct(private Clock $clock) {}
public function isNow(): bool { return $this->clock->isWithin('22:00', '07:00'); }}Register it in services.php:
return static function (ContainerConfigurator $container): void { $services = $container->services()->defaults()->autowire(); $services->set(App\Support\QuietHours::class);};Any app can now ask for QuietHours in its constructor. A service can ask for the same types an app can, such as
Clock or LoggerInterface. To register every class in a folder at once, use
$services->load('App\\Support\\', __DIR__ . '/apps/Support/');.
Each worker process loads services.php, so register services there but do no work in it. Apps never go in
services.php; Stewart finds those by their attribute.
Testing an app
Section titled “Testing an app”The test for the example provides its own QuietHours and moves the clock to check both day and night:
<?php
declare(strict_types=1);
namespace App\Tests;
use App\BatteryAlert;use App\Support\QuietHours;use PHPUnit\Framework\TestCase;use Stewart\Contracts\Time\Duration;use Stewart\Testing\App\AppHarness;
final class BatteryAlertTest extends TestCase{ private AppHarness $harness;
protected function setUp(): void { $this->harness = new AppHarness(); $this->harness->provideService(QuietHours::class, new QuietHours($this->harness->clock)); $this->harness->ha->seedState('sensor.door_battery', '50', ['friendly_name' => 'Door battery']); $this->harness->startApp(BatteryAlert::class, ['threshold' => 25]); }
public function testALowBatteryIsReportedDuringTheDay(): void { $this->harness->advanceTime(Duration::hours(9)); $this->harness->ha->pushState('sensor.door_battery', '15', ['friendly_name' => 'Door battery']);
self::assertSame(['message' => 'Door battery is at 15%.'], $this->harness->ha->getLastCall()->data); }
public function testNothingIsSentAtNight(): void { $this->harness->ha->pushState('sensor.door_battery', '15', ['friendly_name' => 'Door battery']);
self::assertTrue($this->harness->ha->calls->isEmpty()); }}Testing apps covers the harness.
Common problems
Section titled “Common problems”matches no parameter of …::__construct(). An option name in stewart.yaml does not match a constructor
parameter. Compare the spelling.
needs option "…". A constructor parameter has no default and no option sets it.
The app does not run. At start, Stewart logs a warning for a class in apps/ that implements App without
#[Automation], and an error for a file in apps/ that fails to load. Check those lines first.