Skip to content

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
Hold "Ctrl" to enable pan & zoom

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:

  • duration must be a positive number; zero or negative raises ValueError.
  • duration cannot be combined with debounce or throttle — raises ValueError. 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 — raises ValueError.

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