Skip to content

Commit 7005ab2

Browse files
shbatmclaude
andauthored
fix(events): re-emit member changes as group-addressed events (pyisy-3.x parity) (#172)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 9d2542d commit 7005ab2

3 files changed

Lines changed: 310 additions & 23 deletions

File tree

pyisyox/controller.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ async def connect(self, *, start_websocket: bool = True) -> None:
192192
self._loaded.nodes,
193193
programs=self._loaded.programs,
194194
variables=self._loaded.variables,
195+
groups=self._loaded.groups,
195196
)
196197
# Auto-refresh the affected variable type whenever the
197198
# controller emits VARIABLE_TABLE_CHANGED (create / delete /
@@ -629,6 +630,11 @@ async def refresh(self) -> ProfileMergeResult:
629630
loaded.nodes.clear()
630631
loaded.nodes.update(fresh.nodes)
631632
loaded.groups = fresh.groups
633+
# groups is replaced (not mutated in place), so the dispatcher's
634+
# member→groups reverse index must be rebuilt or post-reload
635+
# scene-membership changes would be missed.
636+
if self._dispatcher is not None:
637+
self._dispatcher.update_groups(fresh.groups)
632638
loaded.folders = fresh.folders
633639
loaded.programs = fresh.programs
634640
loaded.triggers = fresh.triggers

pyisyox/runtime/events.py

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,10 @@
2323
from xml.etree import ElementTree as ET
2424

2525
from pyisyox.client import NodePropertyValue
26-
from pyisyox.constants import SystemStatus
26+
from pyisyox.constants import PROP_STATUS, SystemStatus
2727

2828
if TYPE_CHECKING:
29-
from pyisyox.client import NodeRecord, ProgramRecord, VariableRecord
29+
from pyisyox.client import GroupRecord, NodeRecord, ProgramRecord, VariableRecord
3030

3131
_LOGGER = logging.getLogger(__name__)
3232

@@ -1164,6 +1164,7 @@ class EventDispatcher:
11641164
"""
11651165

11661166
__slots__ = (
1167+
"_group_members_index",
11671168
"_lifecycle_listeners",
11681169
"_listeners",
11691170
"_nodes",
@@ -1178,22 +1179,52 @@ def __init__(
11781179
nodes: dict[str, NodeRecord],
11791180
programs: dict[str, ProgramRecord] | None = None,
11801181
variables: dict[str, dict[str, VariableRecord]] | None = None,
1182+
groups: dict[str, GroupRecord] | None = None,
11811183
) -> None:
1182-
"""Bind to a node + program + variable registry.
1184+
"""Bind to a node + program + variable + group registry.
11831185
11841186
The dispatcher mutates records in place. Events for unknown
11851187
addresses are dropped silently (subscribe to lifecycle for
11861188
joins). Passing ``None`` for ``programs``/``variables`` makes
11871189
those dispatch paths a no-op.
1190+
1191+
``groups`` restores pyisy-3.x parity: a group/scene carries no
1192+
wire status of its own (the controller never emits an event for
1193+
the group address), so a member node's property change is
1194+
re-emitted as a synthetic event addressed to each containing
1195+
group. Per-address subscribers on the group re-render and re-read
1196+
the (computed-on-access) :attr:`pyisyox.runtime.Group.group_any_on`.
1197+
Passing ``None`` disables the re-emit (legacy behaviour).
11881198
"""
11891199
self._nodes = nodes
11901200
self._programs = programs if programs is not None else {}
11911201
self._variables = variables if variables is not None else {}
1202+
self._group_members_index: dict[str, tuple[str, ...]] = {}
1203+
self.update_groups(groups if groups is not None else {})
11921204
self._listeners: list[EventListener] = []
11931205
self._lifecycle_listeners: list[NodeLifecycleListener] = []
11941206
self._program_status_listeners: list[ProgramStatusListener] = []
11951207
self._variable_table_change_listeners: list[VariableTableChangeListener] = []
11961208

1209+
def update_groups(self, groups: dict[str, GroupRecord]) -> None:
1210+
"""(Re)build the member→groups reverse index from a group registry.
1211+
1212+
Called from ``__init__`` and again by
1213+
:meth:`pyisyox.controller.Controller.refresh` — ``refresh()``
1214+
replaces ``LoadResult.groups`` with a fresh dict (unlike
1215+
``nodes``, which is mutated in place), so the index has to be
1216+
rebuilt or scene-membership changes from a reload lifecycle
1217+
event would be missed (new members never re-emit; removed
1218+
members still would).
1219+
"""
1220+
members_index: dict[str, list[str]] = {}
1221+
for group_address, group_record in groups.items():
1222+
for member_address in group_record.member_addresses:
1223+
members_index.setdefault(member_address, []).append(group_address)
1224+
self._group_members_index = {
1225+
member: tuple(group_addresses) for member, group_addresses in members_index.items()
1226+
}
1227+
11971228
def add_listener(self, callback: EventListener) -> Callable[[], None]:
11981229
"""Register ``callback`` to fire on every parsed event.
11991230
@@ -1330,8 +1361,51 @@ def feed(self, raw_frame: str) -> Event | None:
13301361
listener(event)
13311362
except Exception: # pylint: disable=broad-except
13321363
_LOGGER.exception("event listener raised; suppressing to keep loop alive")
1364+
# pyisy-3.x parity: a member node's property change implies its
1365+
# containing scene(s) may have changed aggregate state. Groups
1366+
# have no wire event of their own, so synthesise one per group.
1367+
if event.is_node_property:
1368+
self._reemit_group_status(event)
13331369
return event
13341370

1371+
def _reemit_group_status(self, event: Event) -> None:
1372+
"""Re-publish a member property change as a group-addressed event.
1373+
1374+
Mirrors ``pyisy.nodes.Group`` re-emitting its own
1375+
``status_events`` when a member changed. The synthetic event is
1376+
a pure *notification*, not a status frame: it is **not** fed
1377+
back through :meth:`feed` (no re-parse, no
1378+
``_apply_property_update`` — groups aren't in the node
1379+
registry) and never recurses (groups can't be members of
1380+
groups). Per-address subscribers fire and re-read the
1381+
computed-on-access :attr:`pyisyox.runtime.Group.group_any_on`.
1382+
1383+
``action`` is deliberately **empty** (and ``uom`` /
1384+
``formatted_*`` left default): a group has no single status
1385+
value, and echoing the triggering member's raw value under
1386+
``control="ST"`` would be misleading to a consumer that reads
1387+
``event.action`` directly. ``control`` stays ``"ST"`` only so
1388+
the event routes on the group's normal per-address/status
1389+
channel; ``seqnum`` / ``timestamp`` are carried for ordering
1390+
and provenance.
1391+
"""
1392+
group_addresses = self._group_members_index.get(event.node_address)
1393+
if not group_addresses:
1394+
return
1395+
for group_address in group_addresses:
1396+
group_event = Event(
1397+
seqnum=event.seqnum,
1398+
timestamp=event.timestamp,
1399+
control=PROP_STATUS,
1400+
action="",
1401+
node_address=group_address,
1402+
)
1403+
for listener in tuple(self._listeners):
1404+
try:
1405+
listener(group_event)
1406+
except Exception: # pylint: disable=broad-except
1407+
_LOGGER.exception("group re-emit listener raised; suppressing to keep loop alive")
1408+
13351409
def _emit_lifecycle(self, event: Event, raw_frame: str) -> None:
13361410
"""Build a :class:`NodeLifecycleEvent` and fan to lifecycle listeners.
13371411

0 commit comments

Comments
 (0)