fix(events): re-emit member changes as group-addressed events (pyisy-3.x parity)#172
Conversation
…3.x parity) pyisyox Group is pull-only (group_all_on/group_any_on computed on access); unlike pyisy 3.x, nothing notifies subscribers when a member node changes, so consumers subscribed to the group's own address (which the controller never emits) never re-render — scene/group switches freeze at their load-time value. Restore parity: EventDispatcher takes the group registry, builds a member->groups reverse index, and after a member node property update fans out a synthetic ST event addressed to each containing group. It's a notification only — not fed back through feed() (no re-parse, no _apply_property_update; groups aren't in the node registry) and cannot recurse. Per-address subscribers re-render and re-read the computed-on-access group_any_on. Passing no groups keeps legacy behaviour. Controller wires groups=self._loaded.groups into the dispatcher. Consumer-transparent: hacs-udi-iox's existing (group_addr, None) wildcard listener starts firing with no change. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
Claude finished @shbatm's task in 2m 42s —— View job Code Review
The design is clean and the core logic is correct. One medium-severity bug to fix before merging, plus a few smaller observations. Bug: stale
|
… re-emit - Bug: _group_members_index went stale after Controller.refresh() (loaded.groups is replaced, not mutated in place). Add EventDispatcher.update_groups() (also used by __init__, DRY) and call it from refresh() after loaded.groups = fresh.groups. - Synthetic group event is now a pure notification: action="" (uom / formatted_* left default) instead of echoing the member's value under control="ST", which was misleading to consumers reading event.action. control stays "ST" only for routing; seqnum/timestamp kept for ordering/provenance. Docstring updated. - Tests: non-ST (GV1) member change still re-emits; update_groups() rebuilds the index after a refresh-style replace (added member re-emits, removed member doesn't); assert neutral action/uom. 846 passed, mypy clean, pylint 10/10, ruff clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
Thanks for the review — addressed in Bug: stale
Synthetic event carried member Missing non-ST test — Added Cosmetic test reformatting — These collapses are 846 passed · mypy clean · pylint 10/10 · ruff clean. |
|
Claude finished @shbatm's task in 1m 58s —— View job Code Review (Follow-up —
|
_group_members_index is the only state read at dispatch; the full groups dict was never read back. Removed the slot + assignments; update_groups() builds the index directly from its argument. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
Residual nit fixed in |
|
Claude finished @shbatm's task in 49s —— View job Code Review (Follow-up —
|
| Issue | Status |
|---|---|
Stale _group_members_index after refresh() |
Fixed — update_groups() called in Controller.refresh() |
_groups write-only dead state |
Fixed — slot removed entirely |
Synthetic event carries member action under ST |
Fixed — action="", neutral notification |
| Missing non-ST property test | Fixed — test_dispatcher_reemits_for_non_st_member_property added |
Seven new tests pin the contract cleanly. The refresh-regression test (test_update_groups_rebuilds_index_after_refresh_style_replace) is the right shape: it exercises the exact failure mode (old member fires, new one doesn't) rather than just checking index state.
Ready to merge.
Implements the first slice of #174. Composes with #172 (merged) — that delivers the change *notification* (member→group re-emit); this makes the *value* correct. ## Problem `group_any_on` / `group_all_on` were a coarse "any/all stateful member ST non-zero" aggregate. For a radio-style KeypadLinc scene a keypad always has exactly one button lit, so `group_any_on` was permanently True and `group_all_on` structurally impossible — the scene switch never reflected reality. ## Fix Fetch the optional `/api/groups` endpoint during load and resolve each member's scene intent from the **canonical** ctl block (the one whose `id` == the group address — the scene's own responder definition; per-controller cross-link blocks are ignored): | link `type` | intent | |---|---| | `native` | `OL > 0` → on, `OL == 0` → off | | `default` | on (ISY forwards the scene command; state-tracked) | | `cmd` | `DON`/`DFON` → on, `DOF`/`DFOF` → off, anything else (`BL`/`BEEP`/`SETST`/`BRT`/`DIM`/`FD*`) → discard | | `ignore` | not a member | | unknown / `native` w/o `OL` | unresolved → **legacy aggregate** (never regress) | `group_any_on`/`group_all_on` now aggregate over the **on-target** subset. An empty on-set (fire-only / config-only / the auto-DR groups) reads **OFF**, matching the IoX admin console. `Group.has_state_target` exposes whether the scene maintains any on/off state — `False` for a fire-only scene, so a consumer can model it as a momentary **button** rather than a stuck switch (hacs-udi-iox#86). Strictly additive and consumer-transparent: `/api/groups` is best-effort (older firmware 404 → `{}` → groups keep legacy behaviour); exact on-level match (`scene_matches_target`) is deliberately deferred. Validated against real captures: radio scene `8499` (one `OL=100` member, rest `OL=0`), curtain `19359`, the auto-DR `ADR0001` (empty canonical block → OFF), and the `default`+`cmd`/`BL`-only test scenes. ## Changes - `paths`: `GROUPS_PATH` - `client`: `_get_json_or_empty`; `GroupRecord.member_intents` + `targets_resolved`; `apply_group_link_targets()`; wired into `load()` (so `refresh()` re-enriches too) - `runtime/group`: `_on_set()`, `has_state_target`, target-aware `group_any_on`/`group_all_on` with legacy fallback; `to_dict` adds `has_state_target` - tests: 18 parse + aggregation cases; `FakeSession` defaults `/api/groups` to an empty payload so the many full-load tests need no per-test scripting; full-load fan-out asserted 8 → 9 ## Scene HA-platform inputs (`has_state_target` + `has_dimmable_members`) pyisyox exposes two **orthogonal capability booleans**; the consumer composes the platform (capability stays here, the HA light/switch/button decision stays policy): - `Group.has_state_target` — `False` only for a *resolved* fire-only / config scene (→ **button**); `True` otherwise (incl. unresolved: conservative, stays a switch). - `Group.has_dimmable_members` — `True` iff any member node `is_dimmable` (nodedef-derived; **independent of `/api/groups`** so it works on older firmware / unresolved groups too). Consumer mapping: `not has_state_target` → button; else `has_dimmable_members` → **on/off `light`** (lets hacs create the scene natively at the right platform, preserving the `Entity.group` / more-info framework instead of losing it through `switch_as_x`); else → switch. **Scenes have no settable brightness** — ISY groups don't take a scene-level on-level; fade/brt/dim are separate manual commands (reachable via `send_node_command`; scene `accepts` aren't exposed as buttons yet) — so the light is on/off only. Review nits from the first pass are addressed (rank `>` first-seen, `""`-sentinel comment, docstring, +no-ctl / reverse-precedence / all-stateless / has_dimmable tests). 867 passed · mypy clean · pylint 10/10 · ruff clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Closes #173
Problem
pyisyox.runtime.Groupis pull-only —group_all_on/group_any_oncomputed on access, with no event-subscription plumbing. Unlikepyisy3.x (whoseGroupsubscribed to every member'sstatus_eventsand re-published its own), nothing notifies subscribers when a member changes. Consumers subscribe to the group's own address — which the controller never emits — so scene/group switches freeze at load-time value. Regression from pyisy 3.x, not a design choice.Fix
EventDispatchertakes the group registry, builds amember → (group, …)reverse index, and after a member property update fans out a syntheticSTevent addressed to each containing group. Notification only — not fed back throughfeed()(no re-parse /_apply_property_update; groups aren't in the node registry) and cannot recurse. Per-address subscribers re-render and re-read the computed-on-accessgroup_any_on. Nogroups→ legacy behaviour.Controllerwiresgroups=self._loaded.groups. Consumer-transparent: hacs-udi-iox's existing(group_addr, None)wildcard listener starts firing with zero changes.Tests
tests/test_runtime/test_events.py— re-emit on member change; node in multiple scenes → one per scene; non-member → none; no-groupslegacy no-op; system events never re-emit. Full suite: 844 passed · mypy clean · pylint 10/10 · ruff clean.🤖 Generated with Claude Code