Skip to content

Triggers

Some moments are neither a state change nor an event: a person entering a zone, a template turning true, a calendar event starting. Home Assistant detects these with triggers, and watchTrigger() lets an app use them. This app turns the hall light on when Anna comes home and the ground floor off when she leaves:

apps/WelcomeHome.php
<?php
declare(strict_types=1);
namespace App;
use Stewart\Contracts\App;
use Stewart\Contracts\Automation;
use Stewart\Contracts\HaContext;
use Stewart\Contracts\Service\ServiceTarget;
use Stewart\Contracts\Trigger\Collection\HaTriggerCollection;
use Stewart\Contracts\Trigger\HaTrigger;
use Stewart\Contracts\Trigger\TriggerEvent;
use Stewart\Contracts\Trigger\ZoneTransition;
#[Automation(id: 'welcome-home')]
final readonly class WelcomeHome implements App
{
public function __construct(
private HaContext $ha,
private string $person = 'person.anna',
) {}
public function initialize(): void
{
$this->ha->watchTrigger($this->createTriggers())->subscribe(function (TriggerEvent $event): void {
match ($event->getTriggerId()) {
'arrived' => $this->ha->callService('light', 'turn_on', target: ServiceTarget::forEntities('light.hall')),
'left' => $this->ha->callService('light', 'turn_off', target: ServiceTarget::forFloors('ground')),
default => null,
};
});
}
public function dispose(): void {}
private function createTriggers(): HaTriggerCollection
{
return HaTriggerCollection::fromList([
HaTrigger::onZoneTransition($this->person, 'zone.home', ZoneTransition::Enter, id: 'arrived'),
HaTrigger::onZoneTransition($this->person, 'zone.home', ZoneTransition::Leave, id: 'left'),
]);
}
}

watchTrigger() returns a stream of TriggerEvents, with the usual operators (filter, debounce, take, …).

Builder Fires
HaTrigger::onZoneTransition('person.anna', 'zone.home', ZoneTransition::Enter) When a person or device tracker enters or leaves a zone
HaTrigger::whenTemplateTrue('{{ … }}', for: Duration::minutes(2)) When a template turns true, optionally after it stayed true that long
HaTrigger::onTimePattern(minutes: '/5') On a clock pattern; hours, minutes and seconds each take '/n', '*' or a number
HaTrigger::atTime('07:30') Every day at that time
HaTrigger::atTime('input_datetime.wake_up') At the time an input_datetime or timestamp sensor holds
HaTrigger::onSunrise() / onSunset(SunOffset::before(…)) At sunrise or sunset

Each builder takes an optional id:, which comes back as $event->getTriggerId().

Prefer Stewart’s own tools where they exist. For times and the sun, a schedule keeps working while Home Assistant is unreachable; a trigger does not. For state changes, watchStateChanges() runs inside Stewart and needs no subscription in Home Assistant.

Every other trigger Home Assistant has (calendar, numeric_state, conversation, device, …) goes in as the array you would write in YAML:

$this->ha->watchTrigger(['trigger' => 'calendar', 'event' => 'start', 'entity_id' => 'calendar.bins']);

A HaTriggerCollection, as in the example, fires when any of its triggers fires. It is one subscription in Home Assistant; the id: tells the triggers apart. A plain list of arrays works the same way.

Template triggers can read values you pass:

$this->ha->watchTrigger(
HaTrigger::whenTemplateTrue('{{ states(sensor) | float(0) > limit }}'),
variables: ['sensor' => 'sensor.hall_temperature', 'limit' => 25],
);
Method Returns
getTriggerId() Your id:, or the trigger’s position in the list
getPlatform() zone, template, time, …
getValue('to_state'), getTriggerData() Home Assistant’s trigger variables
$event->context Who caused it
$event->firedAt When Stewart received it
  • Shared. Apps that watch the same trigger with the same variables share one subscription in Home Assistant.
  • Outages. After Stewart reconnects, every trigger is subscribed again. A trigger that would have fired while the connection was down is lost.
  • Rejected triggers. Home Assistant checks a trigger when Stewart subscribes. If it refuses, Stewart logs Home Assistant’s reason and the subscription ends. The builders catch most mistakes earlier, with a TriggerException.
  • Home Assistant version. The builders need Home Assistant 2024.10 or newer. On older versions, pass arrays with platform: instead of trigger:.

The harness records what an app watches. pushTrigger() fires every watcher of an equal trigger:

tests/WelcomeHomeTest.php
<?php
declare(strict_types=1);
namespace App\Tests;
use App\WelcomeHome;
use PHPUnit\Framework\TestCase;
use Stewart\Contracts\Trigger\Collection\HaTriggerCollection;
use Stewart\Contracts\Trigger\HaTrigger;
use Stewart\Contracts\Trigger\TriggerEvent;
use Stewart\Contracts\Trigger\ZoneTransition;
use Stewart\Testing\App\AppHarness;
final class WelcomeHomeTest extends TestCase
{
public function testArrivingTurnsTheHallLightOn(): void
{
$harness = new AppHarness();
$harness->startApp(WelcomeHome::class);
$watched = HaTriggerCollection::fromList([
HaTrigger::onZoneTransition('person.anna', 'zone.home', ZoneTransition::Enter, id: 'arrived'),
HaTrigger::onZoneTransition('person.anna', 'zone.home', ZoneTransition::Leave, id: 'left'),
]);
$harness->ha->pushTrigger($watched, new TriggerEvent(['platform' => 'zone', 'id' => 'arrived']));
self::assertSame('light.turn_on', $harness->ha->getLastCall()->getServiceName());
self::assertCount(1, $harness->ha->listWatchedTriggers());
}
}

The trigger never fires, and the log shows Home Assistant’s error. Home Assistant rejected the trigger. Try the same trigger in a Home Assistant automation to see the problem.

It missed a moment. The connection was down at that moment; triggers are not replayed. Use a schedule for times of day.