Skip to content
These pages describe Stewart 1.x. Read the docs for the latest release, and how to upgrade.

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:

apps/BatteryAlert.php
<?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),
]);
});
}
}

#[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.

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:

apps/EnergyLog.php
<?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.

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 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 options is 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 Logs

If 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 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 check notes one that no entry names. make app NAME=MotionLight TEMPLATE=1 writes one, with a test, and prints the entry that runs it.
  • class may 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 watch does not see changes inside vendor/; restart after composer update.
  • make reload applies added and removed entries.
  • The environment can add one too: STEWART_APPS__PORCH__CLASS='App\MotionLight' creates the app porch. A segment with _ makes an ID with _, because environment names cannot hold -.

The motion light recipe has a complete one.

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:

apps/Support/QuietHours.php
<?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.

The test for the example provides its own QuietHours and moves the clock to check both day and night:

tests/BatteryAlertTest.php
<?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.

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.