2323from xml .etree import ElementTree as ET
2424
2525from pyisyox .client import NodePropertyValue
26- from pyisyox .constants import SystemStatus
26+ from pyisyox .constants import PROP_STATUS , SystemStatus
2727
2828if 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