Bus Overview
The event bus connects your apps to Home Assistant and to Hassette itself. It delivers events such as state changes, service calls, or framework updates to any app that subscribes.
Apps register event handlers through self.bus, which is created automatically at app instantiation.
flowchart TD
subgraph ha["Home Assistant"]
HA["Events"]
end
subgraph framework["Framework"]
WS["WebsocketService"]
BUS["BusService"]
WS --> BUS
end
subgraph handlers["App Handlers"]
APP1["Handler 1<br/><i>state_changed</i>"]
APP2["Handler 2<br/><i>call_service</i>"]
APP3["Handler 3<br/><i>custom_event</i>"]
end
HA --> WS
BUS --> APP1 & APP2 & APP3
style ha fill:#f0f0f0,stroke:#999
style framework fill:#fff0e8,stroke:#cc8844
style handlers fill:#e8f0ff,stroke:#6688cc
Subscribing to Events
The Bus provides helper methods for common subscriptions. Each returns a Subscription handle.
Common Methods
on_state_change- Listen for entity state changes.on_attribute_change- Listen for changes to a specific attribute.on_call_service- Listen for service calls.on- Generic subscription to any topic.on_component_loaded- Listen for Home Assistant component load events.
Example
# Subscribe to state changes
sub = await self.bus.on_state_change(
"binary_sensor.motion",
handler=self.on_motion,
changed_to="on",
name="motion_on",
)
# Subscriptions are cleaned up automatically on shutdown.
# Unsubscribe manually only if you need to stop earlier:
# sub.cancel()
Matching Multiple Entities
Most methods accept glob patterns for entity_id, domain, and service.
# Any light
await self.bus.on_state_change("light.*", handler=self.on_any_light, name="any_light")
# Sensors in bedroom
await self.bus.on_state_change("sensor.bedroom_*", handler=self.on_bedroom_sensor, name="bedroom_sensors")
# Specific service calls
await self.bus.on_call_service(
domain="light",
service="turn_*",
handler=self.on_light_service,
name="light_turn_service",
)
Limitation
Glob patterns work for identifiers but not for attribute names or complex data values. For that, use Predicates.
Rate Control
You can rate-limit your handlers directly in the subscription call to handle noisy events.
# Debounce: wait for 2s of silence before calling
await self.bus.on_state_change(
"binary_sensor.motion",
handler=self.on_settled,
debounce=2.0,
name="motion_debounced",
)
# Throttle: call at most once every 5s
await self.bus.on_state_change(
"sensor.temperature",
handler=self.on_temp_log,
throttle=5.0,
name="temp_throttled",
)
# Once: unsubscribe automatically after first trigger
await self.bus.on_component_loaded(
"hue",
handler=self.on_hue_ready,
once=True,
name="hue_ready",
)
Both debounce and throttle must be positive; zero or negative values raise ValueError at registration. Specifying both debounce and throttle together also raises ValueError — only one rate-limiting strategy may be active at a time. Combining once=True with either also raises ValueError.
Immediate Fire
Pass immediate=True to fire your handler right at registration time if the entity already matches your predicates. The handler receives a synthetic event with old_state=None and new_state=<current state>. Without immediate=True, the handler only fires on the next change.
# Fire now if the light is already on, then continue listening
await self.bus.on_state_change(
"light.living_room",
handler=self.on_light_on,
changed_to="on",
immediate=True,
name="living_room_light_on",
)
# Combine with once=True: fire at most once, immediately if already matching
await self.bus.on_state_change(
"input_boolean.setup_complete",
handler=self.on_setup_done,
changed_to="on",
immediate=True,
once=True,
name="setup_complete",
)
immediate=True composes with once=True: the immediate fire counts as the single invocation, so the subscription is automatically cancelled afterward if the entity already matches. It also composes with debounce and throttle — the immediate fire passes through rate limiting the same way a live event does.
Glob patterns not supported
immediate=True cannot be combined with glob entity patterns (for example, "light.*"). Hassette cannot determine which entity to read state from when the pattern matches multiple entities. A ValueError is raised at registration.
Duration Hold
Pass duration=N (seconds) to delay your handler until the entity has remained in the matching state for N continuous seconds. If the entity leaves the matching state before the duration elapses, the timer is cancelled and the handler does not fire.
# Only fire if motion stays on for 30 continuous seconds
await self.bus.on_state_change(
"binary_sensor.motion",
handler=self.on_sustained_motion,
changed_to="on",
duration=30,
name="motion_sustained",
)
# Fire once after the door has been open for 5 minutes
await self.bus.on_state_change(
"binary_sensor.front_door",
handler=self.on_door_left_open,
changed_to="on",
duration=300,
once=True,
name="front_door_open_long",
)
# Restart-resilient: if the light was already on when the app started
# and has been on for more than 10 minutes, fire immediately.
# If it has been on for less, start a timer for the remaining time.
await self.bus.on_state_change(
"light.porch",
handler=self.on_porch_on_too_long,
changed_to="on",
duration=600,
immediate=True,
name="porch_on_too_long",
)
duration composes with once=True: the handler fires at most once after the duration gate passes. It also composes with immediate=True for restart resilience: when the app starts and the entity is already in the target state, Hassette consults last_changed to compute how long it has already been there. If that elapsed time exceeds duration, the handler fires immediately. If not, a timer starts for the remaining time.
Validation rules:
durationmust be a positive number; zero or negative raisesValueError.durationcannot be combined withdebounceorthrottle— raisesValueError. Use duration for the "held for N seconds" pattern; use debounce for the "settled after N seconds of silence" pattern. They are different behaviors that cannot be composed.- Glob entity patterns are not supported with
duration— raisesValueError.
Timeouts
All subscription methods (on, on_state_change, on_attribute_change, on_call_service, on_component_loaded) accept timeout and timeout_disabled parameters to control per-listener execution timeouts.
| Parameter | Type | Default | Description |
|---|---|---|---|
timeout |
float \| None |
None |
Per-listener timeout in seconds. None uses the global event_handler_timeout_seconds default. A positive float overrides it. |
timeout_disabled |
bool |
False |
When True, disables timeout enforcement for this listener regardless of the global default. |
from hassette import App, AppConfig
from hassette.events import RawStateChangeEvent
class MyApp(App[AppConfig]):
async def on_initialize(self) -> None:
# Override the global timeout for a slow handler
await self.bus.on_state_change(
"sensor.weather",
handler=self.fetch_forecast,
timeout=30.0, # 30 seconds instead of the global default
name="weather_forecast",
)
# Disable timeout for a handler that legitimately runs long
await self.bus.on_state_change(
"input_boolean.run_backup",
handler=self.run_full_backup,
timeout_disabled=True,
name="backup_trigger",
)
async def fetch_forecast(self, event: RawStateChangeEvent) -> None: ...
async def run_full_backup(self, event: RawStateChangeEvent) -> None: ...
See Timeouts for global configuration and override semantics.
Handler Exceptions
If a handler raises an exception, Hassette catches it, logs it at ERROR level with the full traceback, and records the failure in the telemetry database. The exception does not propagate — the app keeps running, and the next event dispatches as normal. Other handlers for the same event are not affected.
This is the same behavior as scheduled jobs: unhandled exceptions are logged to error but do not crash anything.
Registration Identity
All bus.on_*() subscription methods require a name= parameter — a stable string identifier that forms the listener's natural key (app_key, instance_index, name, topic). Omitting name= raises ListenerNameRequiredError at call time. Registering a second listener with the same (name, topic) in the same app session raises DuplicateListenerError.
await self.bus.on_state_change(
"binary_sensor.motion",
handler=self.on_motion,
name="motion_sensor_main",
)
await self.bus.on_state_change(
"binary_sensor.motion",
handler=self.on_motion_log,
name="motion_sensor_log",
)
See Subscription and Registration in the Handlers guide for the full error details and upsert semantics across restarts.
Next Steps
- Writing Handlers: Learn how to write handlers using Dependency Injection to extract clean data.
- Filtering & Predicates: Learn how to filter events efficiently using predicates.