Exposed entities
An app can create its own entities in Home Assistant: a sensor with a computed value, a switch that turns an automation on and off, a number to tune it. This app publishes the average of two temperature sensors:
<?php
declare(strict_types=1);
namespace App;
use Stewart\Contracts\App;use Stewart\Contracts\Automation;use Stewart\Contracts\Exposure\EntityExposure;use Stewart\Contracts\Exposure\ExposedSensor;use Stewart\Contracts\Exposure\SensorConfig;use Stewart\Contracts\Exposure\SensorDeviceClass;use Stewart\Contracts\Exposure\SensorStateClass;use Stewart\Contracts\HaContext;use Stewart\Contracts\Selector\Selector;use Stewart\Contracts\State\EntityState;
#[Automation(id: 'climate')]final class AverageTemperature implements App{ private const array SOURCES = ['sensor.kitchen_temperature', 'sensor.hall_temperature'];
private ExposedSensor $average;
public function __construct( private readonly HaContext $ha, private readonly EntityExposure $entities, ) {}
public function initialize(): void { $this->average = $this->entities->exposeSensor('average_temperature', new SensorConfig( deviceClass: SensorDeviceClass::Temperature, unit: '°C', stateClass: SensorStateClass::Measurement, displayPrecision: 1, name: 'Average temperature', ));
$this->ha->watchStateChanges(Selector::anyOf(...self::SOURCES))->subscribe(function (): void { $readings = array_filter(array_map(fn(string $id) => $this->ha->getState($id), self::SOURCES), static fn(?EntityState $state) => $state !== null && is_numeric($state->state)); $temperatures = array_map(static fn(EntityState $state) => (float) $state->state, $readings);
$this->average->setValue($temperatures === [] ? null : array_sum($temperatures) / \count($temperatures), ['sources' => self::SOURCES]); }); }
public function dispose(): void {}}Setting it up
Section titled “Setting it up”Exposed entities need the Stewart integration in Home Assistant, of the same version as Stewart:
- In HACS, open the menu, choose Custom repositories, and add
https://github.com/stewart-php/hass-stewartwith the category Integration. Download Stewart. Without HACS, unpackstewart.zipfrom the release that matches your Stewart version intoconfig/custom_components/stewart/. - Restart Home Assistant.
- Open Settings → Devices & services → Add integration, pick Stewart and confirm.
Stewart finds the integration each time it connects; make status shows it on the component row. The integration
needs Home Assistant 2026.4 or newer, and Stewart’s token must belong to an administrator.
How an entity is identified
Section titled “How an entity is identified”- The key (
average_temperature) is the entity’s identity: its unique ID isstewart-<expose.instance>-<app id>-<key>. Renaming the app or the key makes a new entity. Home Assistant picks the entity ID;getEntityId()returns it. - Each app gets a device,
Stewart · <app id>, under one hub device per daemon. Pass aDeviceInfoto put an entity on a device of its own. exposeBinarySensor()takes aBinarySensorConfig; its handle hassetOn()andsetOff().- A sensor value is a number, a string or
null(unknown). With thetimestampordatedevice class, pass aDateTimeInterface. setAttributes(),markAvailable()andmarkUnavailable()change the rest of the state;remove()deletes the entity from Home Assistant.
Switches and buttons
Section titled “Switches and buttons”A switch or a button sends commands back when someone uses it in Home Assistant. Watch them with watchCommands();
Home Assistant waits for your handler’s answer. This switch refuses to turn the heater on while the window is open:
<?php
declare(strict_types=1);
namespace App;
use Stewart\Contracts\App;use Stewart\Contracts\Automation;use Stewart\Contracts\Exception\CommandException;use Stewart\Contracts\Exposure\Command\SwitchCommand;use Stewart\Contracts\Exposure\EntityExposure;use Stewart\Contracts\Exposure\SwitchConfig;use Stewart\Contracts\Exposure\SwitchDeviceClass;use Stewart\Contracts\HaContext;use Stewart\Contracts\Service\ServiceTarget;
#[Automation(id: 'heater')]final readonly class HeaterSwitch implements App{ public function __construct( private HaContext $ha, private EntityExposure $entities, private string $plug = 'switch.heater_plug', private string $window = 'binary_sensor.living_room_window', ) {}
public function initialize(): void { $heater = $this->entities->exposeSwitch('heater', new SwitchConfig(SwitchDeviceClass::Outlet, name: 'Heater'));
$heater->watchCommands()->subscribe(function (SwitchCommand $command): void { if ($command->isTurnOn() && $this->ha->getState($this->window)?->state === 'on') { throw CommandException::rejected('Close the window first.'); }
$this->ha->callService('switch', $command->isTurnOn() ? 'turn_on' : 'turn_off', target: ServiceTarget::forEntities($this->plug)); }); }
public function dispose(): void {}}A button works the same way: exposeButton('boost')->watchCommands() receives a ButtonPress for each press.
- When the handler returns, the command is accepted: the switch takes the
requested state and
getValue()returns it. CommandException::rejected()fails the service call with your message and leaves the state as it was. Any other exception fails it too, with a generic message, and counts as a handler failure.- The handler may await, for example a service call. Commands for one entity run one at a time.
- A switch nobody watches accepts every command, so it works as a stored toggle
your app reads with
getValue(). - A command a stream operator drops (
filter(),debounce(), …) ormap()s away is not answered; Home Assistant gives up afterexpose.command_timeout(default10s, at most300s). - Commands for a paused app, or one that is not running, fail at once.
SwitchCommand::getContext()tells who used the entity, like any other Home Assistant context.- Home Assistant records a button’s press time before your handler runs, so a rejected press still shows as the last press.
Numbers, selects, texts, dates and times
Section titled “Numbers, selects, texts, dates and times”These take a value from Home Assistant the same way a switch takes on and off:
$offset = $this->entities->exposeNumber('target_offset', new NumberConfig(min: -3, max: 3, step: 0.5, unit: '°C', name: 'Target offset'));$offset->watchCommands()->subscribe(fn(NumberCommand $command) => $this->shiftTarget($command->value));
$mode = $this->entities->exposeSelect('mode', new SelectConfig(['eco', 'comfort', 'away'], name: 'Mode'));$mode->watchCommands()->subscribe(fn(SelectCommand $command) => $this->applyMode($command->option));| Method | Config | Value | Command |
|---|---|---|---|
exposeNumber() |
NumberConfig (min, max required; step, mode, deviceClass, unit) |
int|float |
NumberCommand |
exposeSelect() |
SelectConfig (options required) |
getOption(), setOption() |
SelectCommand |
exposeText() |
TextConfig (min, max up to 255, pattern, mode) |
string |
TextCommand |
exposeTime() |
TimeConfig |
TimeOfDay |
TimeCommand |
exposeDate() |
DateConfig |
DateTimeImmutable, midnight UTC |
DateCommand |
exposeDateTime() |
DateTimeConfig |
DateTimeImmutable with its offset |
DateTimeCommand |
Home Assistant checks values against the range, options and pattern, and
throws StateInvalid for one that does not fit. A text pattern is a Python
regular expression matched from the start; anchor it with ^…$ for a full
match.
Actions
Section titled “Actions”An action is a service your app answers, for scripts, automations and the action picker in Home Assistant. It has no entity and no state:
<?php
declare(strict_types=1);
namespace App;
use Stewart\Contracts\App;use Stewart\Contracts\Automation;use Stewart\Contracts\Exposure\Action\ActionCall;use Stewart\Contracts\Exposure\Action\ActionConfig;use Stewart\Contracts\Exposure\Action\ActionField;use Stewart\Contracts\Exposure\Action\ActionFieldType;use Stewart\Contracts\Exposure\EntityExposure;use Stewart\Contracts\HaContext;use Stewart\Contracts\Schedule\Scheduler;use Stewart\Contracts\Service\ServiceTarget;use Stewart\Contracts\Time\Duration;
#[Automation(id: 'heating')]final readonly class BoostAction implements App{ public function __construct( private HaContext $ha, private EntityExposure $entities, private Scheduler $scheduler, ) {}
public function initialize(): void { $boost = $this->entities->exposeAction('boost', new ActionConfig( name: 'Boost', description: 'Heats a room at full power for a while.', fields: [ new ActionField('room', ActionFieldType::Entity, required: true), new ActionField('minutes', ActionFieldType::Number, description: '30 when left out.'), ], ));
$boost->watchCalls()->subscribe(function (ActionCall $call): void { $room = $call->findEntityId('room');
if ($room === null) { return; }
$target = ServiceTarget::forEntities($room); $this->ha->callService('climate', 'set_preset_mode', ['preset_mode' => 'boost'], $target); $this->scheduler->runAfter(Duration::minutes((int) ($call->findNumber('minutes') ?? 30)), function () use ($target): void { $this->ha->callService('climate', 'set_preset_mode', ['preset_mode' => 'comfort'], $target); }); }); }
public function dispose(): void {}}- Home Assistant registers it as
stewart.<app>_<key>, with-in the app ID as_:stewart.heating_boostabove. - Field types are
Text,Number,Boolean,Select(withoptions) andEntity. Home Assistant validates a call against them and shows a form in the UI; read the values withfindString(),findNumber(),findBool()andfindEntityId(). - A call is answered like a switch command: returning accepts it,
CommandException::rejected()fails it with your message, and a call to a paused or stopped app fails at once. - Actions return no response data.
- The service stays registered while Stewart is away, and calls fail until it
is back.
remove(), pruning and unloading the integration remove it. - An action shares its key space with the app’s entities. Two Stewart
instances cannot hold the same action; the second gets
ConfigInvalid.
Changing the configuration
Section titled “Changing the configuration”updateConfig() replaces an entity’s configuration in place: its name, icon,
options, limits and the rest; its device and entity ID stay. getConfig()
returns the current one.
$mode->updateConfig(new SelectConfig(['eco', 'comfort'], name: 'Mode'));When the current value no longer fits, for example an option you removed,
Home Assistant shows it as unknown and getValue() (getOption()) returns
null. A configuration Home Assistant refuses throws ConfigInvalid and leaves
the previous one in place.
Restarts and outages
Section titled “Restarts and outages”Exposing a key does not set a state, so the handle starts with what Home
Assistant restored: getValue() survives a restart of either side. While Home
Assistant is unreachable, expose*() still returns a handle, and its entity ID
is null until the next connect. Stewart keeps the last state of every exposed
entity and sends them all again after each reconnect.
When an app fails or stops, or its worker dies, its entities stay in Home
Assistant with their last state but turn unavailable. They become available
again when the app exposes them after its restart. Calling expose*() makes an
entity available, even if the app marked it unavailable before.
Pruning
Section titled “Pruning”About 30 seconds after every worker is ready, Stewart removes the entities of
this daemon that no running app exposed, with their registry entries and any
device left empty. Entities of apps that failed or did not start stay,
unavailable. Expose entities in initialize(): an entity exposed later, for
example from a schedule, is removed at startup and created again, losing what
was set on it in Home Assistant. Set expose.prune: false to keep every entity.
Errors
Section titled “Errors”Every handle call waits for Stewart’s main process and can throw ExposureException:
| Reason | When |
|---|---|
ComponentMissing, ProtocolMismatch, ComponentRefused |
The integration is not installed, has another version, or refused the session |
ConfigInvalid |
Home Assistant rejects the configuration, for example a unit that does not fit the device class |
StateInvalid |
Home Assistant rejects the value |
KeyInvalid |
The key is not lowercase letters, digits and _, starting with a letter |
KeyTaken |
The app already exposes that key |
Removed |
The handle was removed or its app stopped |
Unreachable, TimedOut |
Stewart is not connected, or Home Assistant did not answer in time |
stewart_app_exposed_commands_total{app,outcome} counts commands per app as
accepted, rejected (by the app), refused (paused or not running) and lost
(the worker died while handling it).
Testing
Section titled “Testing”$harness->entities records what the app exposes. requireSwitch('heater'), requireSensor(), requireNumber(),
requireAction() and the others return the recorded handle. pushCommand() and pushCall() run your handlers as
Home Assistant would; a rejection is thrown into the test:
<?php
declare(strict_types=1);
namespace App\Tests;
use App\HeaterSwitch;use PHPUnit\Framework\TestCase;use Stewart\Contracts\Exception\CommandException;use Stewart\Contracts\Exposure\Command\SwitchAction;use Stewart\Contracts\Exposure\Command\SwitchCommand;use Stewart\Contracts\State\EventContext;use Stewart\Testing\App\AppHarness;
final class HeaterSwitchTest extends TestCase{ private AppHarness $harness;
protected function setUp(): void { $this->harness = new AppHarness(); $this->harness->ha->seedState('binary_sensor.living_room_window', 'off'); $this->harness->startApp(HeaterSwitch::class); }
public function testTurningTheSwitchOnTurnsThePlugOn(): void { $this->harness->entities->requireSwitch('heater')->pushCommand(new SwitchCommand(SwitchAction::TurnOn, new EventContext('ui')));
self::assertSame('switch.turn_on', $this->harness->ha->getLastCall()->getServiceName()); self::assertTrue($this->harness->entities->requireSwitch('heater')->getValue()); }
public function testAnOpenWindowRefusesTheHeater(): void { $this->harness->ha->pushState('binary_sensor.living_room_window', 'on');
$this->expectException(CommandException::class); $this->harness->entities->requireSwitch('heater')->pushCommand(new SwitchCommand(SwitchAction::TurnOn, new EventContext('ui'))); }}seedSnapshot() sets what Home Assistant would have restored, and refuseExposures() makes expose*() throw.
Common problems
Section titled “Common problems”ComponentMissing. The Stewart integration is not installed, or Home Assistant was not restarted after installing
it. The app keeps running without its entities.
ProtocolMismatch. The integration and Stewart have different versions. Update the one that is behind.
Entities appear twice, or keep turning unavailable. Two Stewart installations use the same expose.instance. Give
each its own.