Events
Not everything in Home Assistant is a state. A remote’s button press, an automation that ran, or a script’s custom
signal arrive as events on Home Assistant’s event bus. This app toggles a light when a Zigbee remote sends
toggle:
<?php
declare(strict_types=1);
namespace App;
use Stewart\Contracts\App;use Stewart\Contracts\Automation;use Stewart\Contracts\Event\HaEvent;use Stewart\Contracts\HaContext;use Stewart\Contracts\Service\ServiceTarget;
#[Automation(id: 'remote-toggle')]final readonly class RemoteToggle implements App{ public function __construct( private HaContext $ha, private string $remote = '00:12:4b:00:24:c0:ff:ee', private string $light = 'light.living_room', ) {}
public function initialize(): void { $this->ha->watchEvents('zha_event') ->filter(fn(HaEvent $event): bool => $event->getValue('device_ieee') === $this->remote) ->filter(static fn(HaEvent $event): bool => $event->getValue('command') === 'toggle') ->subscribe(function (): void { $this->ha->callService('light', 'toggle', target: ServiceTarget::forEntities($this->light)); }); }
public function dispose(): void {}}Watching events
Section titled “Watching events”watchEvents('zha_event') returns a stream of events of that type. It works like a state stream, with the same
operators (filter, debounce, throttle, take, …). The type accepts the same patterns as entities: an exact type,
*_event, a list, or * for every event.
Each HaEvent holds:
| Property | Content |
|---|---|
$event->type |
The event type, such as zha_event |
$event->data |
The event’s data as an array; $event->getValue('command') reads one key |
$event->firedAt |
When Home Assistant fired it |
$event->context |
Who caused it: the context ID, its parent, and the user |
The data is a plain array, because only the integration that fires an event knows its shape. To see what an event carries, open Developer tools → Events in Home Assistant, enter the type under Listen to events, and press the button.
state_changed is the one type you cannot watch here; watchEvents('state_changed') throws. Use
watchStateChanges(), which is built for it.
Firing events
Section titled “Firing events”fireEvent() puts an event on Home Assistant’s bus, where automations, scripts and Node-RED can react to it:
<?php
declare(strict_types=1);
namespace App;
use Stewart\Contracts\App;use Stewart\Contracts\Automation;use Stewart\Contracts\HaContext;
#[Automation(id: 'doorbell-announcer')]final readonly class DoorbellAnnouncer implements App{ public function __construct( private HaContext $ha, private string $button = 'binary_sensor.doorbell', ) {}
public function initialize(): void { $this->ha->watchStateChanges($this->button) ->whenChangedTo('on') ->subscribe(function (): void { $this->ha->fireEvent('doorbell_pressed', ['door' => 'front']); }); }
public function dispose(): void {}}- The data must be an array keyed by name, holding only strings, numbers, booleans,
null, and arrays of those. Anything else throwsEventFireExceptionbefore the event is sent. - A fired event counts as a call to Home Assistant: it shares the limits of service calls, and with dry run on, it is logged but not sent. See Calling services.
- Home Assistant lets only administrators fire events.
Your own changes, or someone else’s
Section titled “Your own changes, or someone else’s”An automation often needs to know whether a change came from itself or from a person. The classic case is a manual override: someone switches the hall light by hand, so the motion automation should leave it alone for a while.
<?php
declare(strict_types=1);
namespace App;
use Psr\Log\LoggerInterface;use Stewart\Contracts\App;use Stewart\Contracts\Automation;use Stewart\Contracts\HaContext;use Stewart\Contracts\Service\ServiceTarget;use Stewart\Contracts\State\StateChange;use Stewart\Contracts\Time\Clock;use Stewart\Contracts\Time\Duration;use Stewart\Contracts\Time\Instant;
#[Automation(id: 'manual-override')]final class ManualOverride implements App{ private ?Instant $overriddenUntil = null;
public function __construct( private readonly HaContext $ha, private readonly Clock $clock, private readonly LoggerInterface $logger, private readonly string $motionSensor = 'binary_sensor.hall_motion', private readonly string $light = 'light.hall', ) {}
public function initialize(): void { $this->ha->watchStateChanges($this->light) ->filter(fn(StateChange $change): bool => !$this->ha->wasCausedByStewart($change)) ->subscribe(function (): void { $this->overriddenUntil = $this->clock->getNow()->plus(Duration::hours(1)); $this->logger->info('Someone changed the light by hand; leaving it alone for an hour'); });
$this->ha->watchStateChanges($this->motionSensor) ->whenChangedTo('on') ->subscribe(function (): void { if ($this->overriddenUntil?->isAfter($this->clock->getNow()) === true) { return; }
$this->ha->callService('light', 'turn_on', target: ServiceTarget::forEntities($this->light)); }); }
public function dispose(): void {}}$this->ha->wasCausedByStewart($change) is true when Stewart made the change, from any app. It compares the user
behind the change with the user of Stewart’s token, so it needs Stewart to have its own Home Assistant
user. With a token from your own account,
everything you do counts as Stewart’s.
Every callService() returns an EventContext, which identifies that call. $change->wasCausedBy($context) is true
for changes made by that exact call, including through a script or scene it started. Home Assistant often reports
the first change before callService() returns, so you may not have the context yet. To ask “did a person do
this?”, use wasCausedByStewart() instead.
| Method | Answers |
|---|---|
$ha->wasCausedByStewart($change) |
Did any Stewart app cause this? |
$change->wasCausedBy($context) |
Did this call cause it? |
$state->wasLastChangedBy($context) |
Did this call cause the entity’s last change? |
A script that Stewart starts runs as the script, not as Stewart’s user, so its changes are not “caused by Stewart”.
wasCausedBy($context) still matches them.
Messages between apps
Section titled “Messages between apps”Apps can send each other messages without Home Assistant. publish() sends a payload on a topic, and
watchTopic() receives it in any app, on any worker:
$this->ha->publish('alarm/armed', ['by' => 'night-lock']);
$this->ha->watchTopic('alarm/*')->subscribe(function (TopicEvent $message): void { $this->logger->info("{$message->topic} from {$message->publisher?->value}");});The payload may hold strings, numbers, booleans, null and arrays of those. Messages exist only inside Stewart;
Home Assistant does not see them. To reach Home Assistant, use fireEvent().
Testing
Section titled “Testing”<?php
declare(strict_types=1);
namespace App\Tests;
use App\ManualOverride;use PHPUnit\Framework\TestCase;use Stewart\Contracts\State\EventContext;use Stewart\Contracts\Time\Duration;use Stewart\Testing\App\AppHarness;
final class ManualOverrideTest extends TestCase{ private AppHarness $harness;
protected function setUp(): void { $this->harness = new AppHarness(); $this->harness->ha->seedState('light.hall', 'off'); $this->harness->ha->seedState('binary_sensor.hall_motion', 'off'); $this->harness->startApp(ManualOverride::class); }
public function testMotionTurnsTheLightOn(): void { $this->harness->ha->pushState('binary_sensor.hall_motion', 'on');
self::assertSame('light.turn_on', $this->harness->ha->getLastCall()->getServiceName()); }
public function testAChangeByHandPausesTheAppForAnHour(): void { $this->harness->ha->pushState('light.hall', 'on', context: new EventContext('wall-switch', userId: 'resident')); $this->harness->ha->pushState('binary_sensor.hall_motion', 'on');
self::assertTrue($this->harness->ha->calls->isEmpty());
$this->harness->advanceTime(Duration::hours(1)); $this->harness->ha->pushState('binary_sensor.hall_motion', 'off'); $this->harness->ha->pushState('binary_sensor.hall_motion', 'on');
self::assertSame('light.turn_on', $this->harness->ha->getLastCall()->getServiceName()); }}pushState() takes the context a change carries. A context with a userId other than Stewart’s counts as a person.
pushEvent() sends an event, and $harness->ha->firedEvents lists the events the app fired.
Common problems
Section titled “Common problems”AdministratorRequired at start. Stewart watches the whole event bus, which Home Assistant allows only for
administrators. Create the token from an administrator user.
Every change counts as Stewart’s. The token belongs to your own user. Give Stewart its own user.
Events are dropped, with a warning naming the worker. A worker that falls far behind drops its oldest queued
events. Look for a handler that blocks, such as one calling sleep().