Skip to content

fix(events): emit button_pressed/released from aiovantage StatusReceived stream#374

Open
sdhomecode wants to merge 1 commit into
loopj:mainfrom
sdhomecode:fix/button-event-status-stream
Open

fix(events): emit button_pressed/released from aiovantage StatusReceived stream#374
sdhomecode wants to merge 1 commit into
loopj:mainfrom
sdhomecode:fix/button-event-status-stream

Conversation

@sdhomecode
Copy link
Copy Markdown

PR: Fire vantage_button_pressed from StatusReceived("BTN") events

Title (suggested)

fix(events): emit button_pressed/released from aiovantage StatusReceived stream


Description

Problem

vantage_button_pressed and vantage_button_released never fire on the Home Assistant event bus, even when the controller is sending button status messages over the Host Command stream. Automations using event_type: vantage_button_pressed silently never run.

Tested on Home Assistant 2026.5.1, home-assistant-vantage==0.14.7, aiovantage==0.22.8, against an InFusion controller.

With aiovantage: debug, the controller's messages are clearly arriving:

DEBUG [aiovantage] Received message: S:BTN 193 PRESS
DEBUG [aiovantage] Received message: S:BTN 193 RELEASE

…but on_button_updated in events.py is never invoked. I verified this by temporarily replacing the body of on_button_updated with an unconditional logging.warning(...) — zero calls during multiple button presses.

Root cause

events.py subscribes to ObjectUpdated[Button] and guards with if "state" not in event.attrs_changed: return. In aiovantage==0.22.x, button presses are emitted as StatusReceived(category="BTN", vid=…, args=["PRESS"|"RELEASE"]) from the event_stream, not as ObjectUpdated on the buttons controller. So the handler is never reached at all — the issue isn't the state filter, it's the wrong event class.

Fix

Subscribe to the raw status stream for "BTN" and translate it into the existing HA bus events. The button object is still looked up afterwards to enrich the payload with name/station/position, so the public event schema (button_id, button_name, button_text1/2, button_position, station_id, station_name) is unchanged.

def on_button_status(event: StatusReceived) -> None:
    is_press = bool(event.args) and event.args[0].upper() == "PRESS"

    button = vantage.buttons.get(event.vid)
    payload: dict[str, Any] = {"button_id": event.vid}
    if button is not None:
        payload["button_name"] = button.name
        payload["button_text1"] = getattr(button, "text1", None)
        payload["button_text2"] = getattr(button, "text2", None)
        parent = getattr(button, "parent", None)
        if parent is not None:
            payload["button_position"] = getattr(parent, "position", None)
            station = vantage.stations.get(parent.vid)
            if station is not None:
                payload["station_id"] = station.vid
                payload["station_name"] = station.name

    hass.bus.async_fire(
        EVENT_BUTTON_PRESSED if is_press else EVENT_BUTTON_RELEASED,
        payload,
    )

entry.async_on_unload(
    vantage.event_stream.subscribe_status(on_button_status, "BTN")
)

Two robustness improvements that come along for free:

  • PRESS vs RELEASE is derived from event.args[0] (the wire string), not from a Python attribute. The current code reads event.obj.is_down, which has been an unstable target across aiovantage versions.
  • The handler doesn't depend on attrs_changed containing any particular key.

The Task ObjectUpdated path is left untouched — task events come through the controller dispatcher correctly.

Alternative: fix this upstream in aiovantage

You maintain both libraries, so this could equally be fixed in aiovantage by making the Button controller emit ObjectUpdated whenever a BTN status changes the button's in-memory state, which would let the existing ObjectUpdated[Button] subscription here keep working without changes. Happy to move the fix there if you'd prefer — let me know.

I'd argue the StatusReceived path is still slightly better for press events even in that world: presses are inherently transient signals (PRESS/RELEASE), not state transitions, and reading them from the wire stream avoids the "did this attribute actually change?" question entirely. But either fix resolves the user-visible bug.

Verification

After the patch, on a real Vantage system:

DEBUG [aiovantage] Received message: S:BTN 193 PRESS
INFO  [automation.theater_steps_double_click_runs_theater_on] Executing step …
INFO  [script.theater_on] Theater: Running script sequence
DEBUG [aiovantage] Received message: S:BTN 193 RELEASE

A wait_for_trigger-based double-click automation also works end-to-end:

- alias: STEPS double-click runs Theater script
  mode: single
  trigger:
    - platform: event
      event_type: vantage_button_pressed
      event_data:
        button_id: 193
  action:
    - wait_for_trigger:
        - platform: event
          event_type: vantage_button_pressed
          event_data:
            button_id: 193
      timeout: "00:00:01"
      continue_on_timeout: false
    - service: script.theater_on

A second isolated press correctly times out without firing the script.


Reviewer checklist

  • Scope is limited to one file (custom_components/vantage/events.py), one commit, one logical change.
  • No manifest.json / version / dependency bump included — happy to add one, or to leave that for your usual bumpver flow.
  • Manually audited against the rule set in pyproject.tomlD, E, F, UP, W, B007/B014, C, SIM*, T20, TRY004, RUF006, ICN001, PGH004, PLC0414 — no expected violations. No lines exceed 88 cols, all functions/module have docstrings ending in a period, no unused imports.
  • Typed with dict[str, Any] for the heterogeneous payload, no implicit Anys leaking through, no # type: ignore comments — should be clean under typeCheckingMode = "strict" pyright.
  • Not personally run: uv run ruff check / uv run ruff format --check / uv run pyright — I patched the file in place on a running HA add-on and couldn't bring the dev container up here. If you spot a nit on first pass I'll happily fix it.
  • Public event schema (button_id, button_name, button_text1/2, button_position, station_id, station_name) is preserved — existing user automations and blueprints continue to work unchanged.
  • Searched open and closed issues for "button"/"press"/"event"/"release" — no duplicate report found.
  • Tests: the repo doesn't currently have button-event tests, so I left it minimal to keep scope tight. Happy to add one in this PR if you'd like — let me know the preferred shape (real mock controller, monkeypatched event_stream, etc.).

Suggested commit message

fix(events): emit button_pressed/released from StatusReceived stream

Button presses in aiovantage 0.22.x arrive as StatusReceived(category="BTN")
on the event stream rather than as ObjectUpdated on the buttons controller,
so the existing handler was never invoked and vantage_button_pressed never
fired on the Home Assistant event bus. Subscribe to the raw status stream
for "BTN" and translate to the existing HA bus events; payload schema is
unchanged.

Suggested branch name

fix/button-event-status-stream


Diff

--- a/custom_components/vantage/events.py
+++ b/custom_components/vantage/events.py
@@ -1,8 +1,10 @@
-"""Handle forwarding Vantage events to the  Home Assistant event bus."""
+"""Handle forwarding Vantage events to the Home Assistant event bus."""
 
-from aiovantage.events import ObjectUpdated
-from aiovantage.objects import Button, Task
+from typing import Any
 
+from aiovantage.events import ObjectUpdated, StatusReceived
+from aiovantage.objects import Task
+
 from homeassistant.core import HomeAssistant
 
 from .config_entry import VantageConfigEntry
@@ -19,25 +21,31 @@
     """Set up Vantage events from a config entry."""
     vantage = entry.runtime_data.client
 
-    def on_button_updated(event: ObjectUpdated[Button]) -> None:
-        """Handle button press/release events."""
-        if "state" not in event.attrs_changed:
-            return
+    def on_button_status(event: StatusReceived) -> None:
+        """Forward S:BTN status messages to the HA event bus.
 
-        payload = {
-            "button_id": event.obj.vid,
-            "button_name": event.obj.name,
-            "button_position": event.obj.parent.position,
-            "button_text1": event.obj.text1,
-            "button_text2": event.obj.text2,
-        }
+        aiovantage emits button presses as ``StatusReceived(category="BTN", ...)``
+        from the event stream, not as ``ObjectUpdated`` on the buttons controller,
+        so we subscribe to the raw status stream here.
+        """
+        is_press = bool(event.args) and event.args[0].upper() == "PRESS"
 
-        if station := vantage.stations.get(event.obj.parent.vid):
-            payload["station_id"] = station.vid
-            payload["station_name"] = station.name
+        button = vantage.buttons.get(event.vid)
+        payload: dict[str, Any] = {"button_id": event.vid}
+        if button is not None:
+            payload["button_name"] = button.name
+            payload["button_text1"] = getattr(button, "text1", None)
+            payload["button_text2"] = getattr(button, "text2", None)
+            parent = getattr(button, "parent", None)
+            if parent is not None:
+                payload["button_position"] = getattr(parent, "position", None)
+                station = vantage.stations.get(parent.vid)
+                if station is not None:
+                    payload["station_id"] = station.vid
+                    payload["station_name"] = station.name
 
         hass.bus.async_fire(
-            EVENT_BUTTON_PRESSED if event.obj.is_down else EVENT_BUTTON_RELEASED,
+            EVENT_BUTTON_PRESSED if is_press else EVENT_BUTTON_RELEASED,
             payload,
         )
 
@@ -65,6 +73,9 @@ def async_setup_events(hass, entry):
 
             hass.bus.async_fire(EVENT_TASK_STATE_CHANGED, payload)
 
-    # Subscribe to button and task events
-    entry.async_on_unload(vantage.buttons.subscribe(ObjectUpdated, on_button_updated))
+    # Button presses arrive as S:BTN status messages on the event stream.
+    entry.async_on_unload(
+        vantage.event_stream.subscribe_status(on_button_status, "BTN")
+    )
+    # Task events still come through the controller dispatcher.
     entry.async_on_unload(vantage.tasks.subscribe(ObjectUpdated, on_task_updated))

Verified: this diff applies cleanly against loopj/home-assistant-vantage@main at the time of writing (line numbers above match upstream main's events.py).

Button presses in aiovantage 0.22.x arrive as StatusReceived(category="BTN")
on the event stream rather than as ObjectUpdated on the buttons controller,
so the existing handler was never invoked and vantage_button_pressed never
fired on the Home Assistant event bus. Subscribe to the raw status stream
for "BTN" and translate to the existing HA bus events; payload schema is
unchanged.
@loopj
Copy link
Copy Markdown
Owner

loopj commented May 17, 2026

Was this pull request created using AI?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants