Typed entities
make generate reads your Home Assistant once and writes a PHP class for every domain, entity and service into
generated/. Your editor then completes entity IDs, attributes and service fields, and PHPStan checks them:
<?php
declare(strict_types=1);
namespace App;
use App\Generated\Entities;use App\Generated\LightState;use App\Generated\Services;use Psr\Log\LoggerInterface;use Stewart\Contracts\App;use Stewart\Contracts\Automation;use Stewart\Contracts\State\StateChange;
#[Automation(id: 'typed-hall-light')]final readonly class TypedHallLight implements App{ public function __construct( private Entities $entities, private Services $services, private LoggerInterface $logger, ) {}
public function initialize(): void { $hall = $this->entities->light->getEntity('light.hall');
$this->entities->binarySensor->getEntity('binary_sensor.hall_motion')->watchStateChanges() ->whenChangedTo('on', from: 'off') ->subscribe(function () use ($hall): void { $hall->turnOn(brightnessPct: 70); $this->services->notify->mobileAppPhone(message: 'Motion in the hall'); });
$hall->watchStateChanges()->subscribe(function (StateChange $change): void { $light = LightState::fromNullableState($change->to);
if ($light?->isOn() === true) { $this->logger->info('Hall light is on', ['brightness' => $light->getBrightness()]); } }); }}Entitieshas one property per domain:$this->entities->light,$this->entities->binarySensor.getEntity('light.hall')returns a typed handle. Its methods are that domain’s services:turnOn()takes the light’s real fields, such asbrightnessPctandcolorTempKelvin.Serviceshas one property per domain, with every service of it:$this->services->notify->mobileAppPhone().LightState::fromNullableState($change->to)turns a state into a typed one, withisOn()and an accessor per attribute, such asgetBrightness().
Both classes are ordinary constructor parameters, and calls through them appear in the log under the app’s ID, like
calls through HaContext.
Generating
Section titled “Generating”make generateRun it again after you add devices, and commit generated/. A build or a server then never needs Home Assistant to
create the classes.
If Home Assistant has entities the classes do not, or the classes have entities Home Assistant no longer has, Stewart logs a warning:
… WARNING [broker] Generated entity classes no longer match Home Assistant; run stewart generate {"added":["light.landing"],"removed":[]}It checks at start, after each reconnect to Home Assistant, after entities are added or removed in Home Assistant, and
after each reload, so make generate followed by make reload shows the new classes match. The
warning comes again only when the list changes, and one line says when the classes match again. make status shows
the same on its generated row (up to date, 2 added, 1 removed; run stewart generate, or a format from an older
Stewart), and so do the metrics stewart_generated_entities_drift{change} and stewart_generated_format_current.
make generate ARGS=--check reports what would change without writing anything, and fails if something would. When
more than half of the files would be deleted, make generate stops, in case it reached the wrong Home Assistant;
ARGS=--allow-shrink lets it go ahead.
Entity IDs from options
Section titled “Entity IDs from options”getEntity() takes a string, so an ID from the app’s options works like one written in the code:
<?php
declare(strict_types=1);
namespace App;
use App\Generated\Entities;use Stewart\Contracts\App;use Stewart\Contracts\Automation;use Stewart\Contracts\Schedule\Scheduler;
#[Automation(id: 'lights-out')]final readonly class LightsOut implements App{ /** @param list<string> $lights */ public function __construct( private Entities $entities, private Scheduler $scheduler, private array $lights = ['light.hall', 'light.porch'], ) {}
public function initialize(): void { $this->scheduler->runDailyAt('23:30', function (): void { foreach ($this->lights as $id) { $this->entities->light->getEntity($id)->turnOff(); } }); }}apps: lights-out: options: lights: [light.hall, light.porch]getEntity() accepts only IDs of its domain that the last make generate saw. Any other throws IdentifierException
with the reason EntityNotGenerated. Until you generate again, a new entity is still reachable through
$this->ha->getEntity('light.new').
Because IDs are written in full, searching your project for light.hall finds every place that uses it.
What gets generated
Section titled “What gets generated”| Class | Holds |
|---|---|
Entities |
One property per domain with entities |
LightEntities |
getEntity('light.…') for the domain |
LightEntity |
One entity: getState(), requireState(), watchStateChanges(), waitForState(), getHistory(), and the domain’s services as methods |
LightState |
A typed state: isOn(), isOff(), isUnavailable(), getFriendlyName(), one accessor per attribute, and raw for the plain EntityState |
Services |
One property per domain with services |
LightServices |
Every service of the domain |
Manifest |
What Home Assistant looked like: its entity IDs and action names, so Stewart can notice changes |
.phpstorm.meta.php |
Completion in PhpStorm: entity IDs wherever a method takes one (getState(), watchStateChanges(), ServiceTarget::forEntities(), runAtEntityTime(), …), action names for callAction(), and area, floor and label IDs |
A few naming rules:
- Domains become camelCase properties:
binary_sensoris$entities->binarySensor. - A service field with a fixed list of values accepts only those, so PHPStan rejects a value Home Assistant would.
- A service named like an existing method gets a
Servicesuffix, and an attribute named like an existing accessor gets anAttributesuffix.
Choosing what to generate
Section titled “Choosing what to generate”Everything is optional; these are the defaults:
codegen: namespace: App\Generated output_dir: generated include: ['*'] exclude: []include and exclude take entity IDs and patterns, such as light.debug_*. Exclude wins.
An attribute gets an accessor when Home Assistant defines it for the domain (such as brightness on a light), or
when you include it:
codegen: attributes: sensor: exclude: ['moon_*'] vacuum: include: [mop_pad]Accessors are nullable, because Home Assistant drops an attribute when it stops applying. Numbers are always ?float,
since 21 today can be 21.5 tomorrow. An attribute that was null on every entity when you generated (every light
off, for example) is mixed until you generate again.
Snapshots for CI
Section titled “Snapshots for CI”--snapshot-out saves what generate read, and --snapshot-in generates from that file without connecting. A
snapshot is a copy of your home’s state, with locations, IP addresses and access tokens of cameras, so a new
project’s CI leaves this check out. In a private repository, you can commit one and check the committed classes
against it:
make generate ARGS=--snapshot-out=snapshot.json # on your machine; commit the filemake generate ARGS="--check --snapshot-in=snapshot.json" # in CIGenerating from the same snapshot always gives the same files. Keep the snapshot out of public repositories; the same
goes for generated/, which names your entities.
Typos caught by PHPStan
Section titled “Typos caught by PHPStan”stewart-php/phpstan checks the entity IDs and action names written in your code against what the last
make generate saw, so make stan stops a typo before it reaches Home Assistant:
------ --------------------------------------------------------------------------------------------- Line apps/PorchLight.php ------ --------------------------------------------------------------------------------------------- 24 Entity ID "light.porhc" was not in Home Assistant at the last stewart generate. Did you mean "light.porch"? 🪪 stewart.entityId ------ ---------------------------------------------------------------------------------------------- It checks every method that takes an entity ID, such as
getState(),watchStateChanges(),ServiceTarget::forEntities(),runAtEntityTime()andnew EntityId(…), and the action names ofcallAction(), including$ha->getEntity('light.hall')->callAction('turn_on'). - Only IDs written as literals are checked. Globs such as
light.*, times such as07:30, and IDs from options are left alone, and so is code in your tests, which seed made-up entities. - An ID left out of the generated classes (excluded, disabled or hidden) is fine for
$ha->getState(), but$this->entities->light->getEntity()throws for it, so PHPStan reports that call. - Before the first
make generatethere is nothing to check against, so nothing is reported.
Projects from the skeleton have it. Another project adds it, together with the installer that loads PHPStan
extensions, from make sh:
composer config allow-plugins.phpstan/extension-installer truecomposer require --dev stewart-php/phpstan phpstan/extension-installerWith a codegen.namespace other than App\Generated, tell it where the manifest is in phpstan.neon.dist:
parameters: stewart: manifest: Home\Generated\ManifestAn entity that exists only at run time, such as one an app creates, can be silenced with PHPStan’s
ignoreErrors and the identifier stewart.entityId (stewart.actionName for actions).
In production
Section titled “In production”Projects from the skeleton require stewart-php/codegen like any other package, so stewart generate also exists on a
server. That is what STEWART_BOOT_GENERATE=1 (or the Helm chart’s code.generateOnStart) uses to regenerate the
classes on every start. The generated classes themselves need only stewart-php/contracts; a project that always
commits them can move stewart-php/codegen to require-dev.
Common problems
Section titled “Common problems”Entity ID "…" is not a generated … entity. The ID has a typo, belongs to another domain, or was added after the last
make generate.
The first make generate stops and prints a composer.json snippet. Composer does not know where the classes
go. Add the snippet under autoload, run make install, then make generate again. Projects from the skeleton
already have it.
Class "Stewart\Contracts\App\Generated\…" not found. An app imports Stewart\Contracts\App, so inside the app,
App\Generated\Entities means Stewart\Contracts\App\Generated\Entities. Import the generated classes with
use App\Generated\Entities; instead.