Anatomy of an app
An app is a PHP class in apps/ that implements App and carries #[Automation], or an #[AppTemplate] class
that stewart.yaml runs several times. 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->callAction('notify.' . $this->notifyService, [ 'message' => \sprintf('%s is at %s%%.', $change->to?->getFriendlyName(), $change->to?->state), ]); }); }}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.
Subscriptions and schedules end on their own when the app stops, so most apps need nothing more. An app that opens
something itself, such as a file, also implements Stewart\Contracts\Disposable, whose dispose() runs when the
app stops:
<?php
declare(strict_types=1);
namespace App;
use Stewart\Contracts\App;use Stewart\Contracts\Automation;use Stewart\Contracts\Disposable;use Stewart\Contracts\HaContext;use Stewart\Contracts\State\StateChange;
#[Automation(id: 'energy-log')]final class EnergyLog implements App, Disposable{ /** @var resource|null */ private $file = null;
public function __construct( private readonly HaContext $ha, private readonly string $path = '/app/var/power.log', private readonly string $meter = 'sensor.house_power', ) {}
public function initialize(): void { $this->file = fopen($this->path, 'a') ?: null;
$this->ha->watchStateChanges($this->meter)->subscribe(function (StateChange $change): void { if ($this->file !== null && $change->to !== null) { fwrite($this->file, $change->to->state . "\n"); } }); }
public function dispose(): void { if ($this->file !== null) { fclose($this->file); $this->file = null; } }}Before Stewart 1.5, App declared dispose() too, so older apps have an empty one. Delete it, or add
implements Disposable when it does something. Until Stewart 2.0, a dispose() without Disposable still runs, and
stewart check names the app.
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, actions. See State changes and Call actions |
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. See Schedules or timers |
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, and make reload applies them to a running Stewart. 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. See Configuration.
Next to options, an app’s entry takes five more settings, and class for one class, several apps:
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 worker log_level: debug # log more, or less, for this app than log_level says; see Logs trace: true # log what reaches the app and what it does with it; see LogsIf an entry names an ID that no app has, Stewart logs a warning at start and ignores the entry, so a typo does not go
unnoticed. stewart check prints the same warning and still passes.
One class, several apps
Section titled “One class, several apps”One class can run several times, each time with its own options, such as a motion light per room. Mark it
#[AppTemplate] instead of #[Automation], and give each copy an entry in stewart.yaml that names the class:
apps: hall-motion-light: class: App\MotionLight options: light: light.hall landing-motion-light: class: App\MotionLight options: light: light.landing- Each entry is an app of its own: its ID names it in logs, status and the store, and takes every setting above.
- An
#[AppTemplate]class runs only through such entries.stewart checknotes one that no entry names.make app NAME=MotionLight TEMPLATE=1writes one, with a test, and prints the entry that runs it. classmay also name an#[Automation]class, which then runs once more under the entry’s ID; the entry’s ID must not be another automation’s.- The class may come from a Composer package, so a package can ship apps for others to configure.
make watchdoes not see changes insidevendor/; restart aftercomposer update. make reloadapplies added and removed entries.- The environment can add one too:
STEWART_APPS__PORCH__CLASS='App\MotionLight'creates the appporch. A segment with_makes an ID with_, because environment names cannot hold-.
The motion light recipe has a complete one.
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.