Skip to content

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:

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->callService('notify', $this->notifyService, [
'message' => \sprintf('%s is at %s%%.', $change->to?->getFriendlyName(), $change->to?->state),
]);
});
}
public function dispose(): void {}
}

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

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.

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 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 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 (this needs the app’s entry in stewart.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 worker

If an entry names an ID that no app has, Stewart logs a warning at start, so a typo does not go unnoticed.

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.