Skip to content

Commit e9cf954

Browse files
shbatmclaude
andauthored
feat(events): expand IoX-6 system event taxonomy from UDI's UDEvents.h (#176)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 3cee34e commit e9cf954

5 files changed

Lines changed: 199 additions & 19 deletions

File tree

pyisyox/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,9 @@ async def main():
9595
SecuritySystemAction,
9696
StatusListener,
9797
SystemConfigAction,
98+
SystemEditorAction,
9899
SystemEventControl,
100+
SystemUpgradeAction,
99101
TriggerAction,
100102
Variable,
101103
VariableTableChangeEvent,
@@ -171,7 +173,9 @@ async def main():
171173
"SecuritySystemAction",
172174
"StatusListener",
173175
"SystemConfigAction",
176+
"SystemEditorAction",
174177
"SystemEventControl",
178+
"SystemUpgradeAction",
175179
"TLSVersionError",
176180
"TriggerAction",
177181
"Variable",

pyisyox/runtime/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@
2727
ProgressAction,
2828
SecuritySystemAction,
2929
SystemConfigAction,
30+
SystemEditorAction,
3031
SystemEventControl,
32+
SystemUpgradeAction,
3133
TriggerAction,
3234
VariableTableChangeEvent,
3335
VariableTableChangeListener,
@@ -70,7 +72,9 @@
7072
"SecuritySystemAction",
7173
"StatusListener",
7274
"SystemConfigAction",
75+
"SystemEditorAction",
7376
"SystemEventControl",
77+
"SystemUpgradeAction",
7478
"TriggerAction",
7579
"Variable",
7680
"VariableTableChangeEvent",

pyisyox/runtime/events.py

Lines changed: 112 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,11 @@ class SystemEventControl(StrEnum):
4848
underscore-prefixed codes with an empty ``node_address``.
4949
5050
Codes ``_0``-``_23`` are the full ISY-994 set from the *ISY994
51-
Developer Cookbook* §8.5; ``_28`` is an IoX-6 addition (Matter)
52-
not in that document. Newer IoX firmware emits a few more
53-
undocumented codes — those aren't enumerated; :meth:`label` passes
51+
Developer Cookbook* §8.5; ``_24``-``_28`` are IoX-6 additions
52+
(system editors, the modern Z-Wave / ZigBee / Matter drivers,
53+
system upgrade) not in that document — tracked from UDI's
54+
internal ``UDEvents.h`` taxonomy. Newer IoX firmware may emit
55+
further codes; those aren't enumerated, and :meth:`label` passes
5456
them through verbatim so logs still identify them.
5557
"""
5658

@@ -110,24 +112,53 @@ class SystemEventControl(StrEnum):
110112
UPB_DEVICE_STATUS = "_16"
111113
#: Gas-meter events — ISY994 only.
112114
GAS_METER = "_17"
113-
#: Zigbee events — ISY994 only.
115+
#: Legacy ZigBee events — ISY994-era driver. See :attr:`ZIGBEE_UYB`
116+
#: (``_27``) for the IoX-6+ ZigBee driver used on eisy.
114117
ZIGBEE = "_18"
115118
#: ELK alarm-panel events — requires the ELK module (see the ELK
116119
#: Integration Developer's Manual).
117120
ELK = "_19"
118121
#: Device-linker events — ``<action>`` 1 (status) / 2 (cleared).
119122
#: See :class:`DeviceLinkerAction`.
120123
DEVICE_LINKER = "_20"
121-
#: Z-Wave integration events — requires the Z-Wave module (see the
122-
#: Z-Wave Integration Developer's Manual).
124+
#: Legacy Z-Wave integration events — ISY994-era driver. See
125+
#: :attr:`ZMATTER_ZWAVE` (``_25``) for the IoX-6+ ZMatter Z-Wave
126+
#: driver used on eisy.
123127
ZWAVE = "_21"
124128
#: Billing events — ISY994 ZS-series only.
125129
BILLING = "_22"
126130
#: Portal events — portal socket-connection / account-registration
127131
#: status when a portal module is installed.
128132
PORTAL = "_23"
129-
#: Matter network status — IoX 6+ with the Matter module. Not in
130-
#: the ISY994 cookbook.
133+
#: System editor changed — fired when a "system editor" (e.g.
134+
#: ``_sys_notify_short``) is updated. ``<node>`` carries the editor
135+
#: name. ``<action>`` is :class:`SystemEditorAction`. IoX-6 addition.
136+
SYSTEM_EDITOR = "_24"
137+
#: ZMatter Z-Wave events — IoX-6+ Z-Wave driver on eisy hardware.
138+
#: ``<action>`` is dotted (``"{category}.{type}"``); category
139+
#: numbers are system-status (1), discovery (2), general-status (3),
140+
#: general-error (4), S2 (5), OTA (6), backup/restore (7), device-
141+
#: interview (8), button-detect (9), logger (10). Sub-action details
142+
#: aren't modelled — the dotted string passes through verbatim.
143+
#: Distinct from :attr:`ZWAVE` (``_21``, ISY994-era driver).
144+
ZMATTER_ZWAVE = "_25"
145+
#: System-upgrade lifecycle — ``<action>`` is :class:`SystemUpgradeAction`
146+
#: (active / inactive / available / reboot-required). IoX-6 addition.
147+
SYSTEM_UPGRADE = "_26"
148+
#: ZigBee events — IoX-6+ ZigBee driver on eisy hardware. Same
149+
#: dotted ``"{category}.{type}"`` action shape as :attr:`ZMATTER_ZWAVE`
150+
#: (minus the logger sub-category). Distinct from :attr:`ZIGBEE`
151+
#: (``_18``, ISY994-era driver).
152+
ZIGBEE_UYB = "_27"
153+
#: Matter network status — IoX-6+ Matter driver. ``<action>`` is
154+
#: dotted; active sub-categories are 1 (system status), 2 (discovery),
155+
#: 3 (RX/TX), 8 (device interview). Not in the ISY994 cookbook.
156+
#:
157+
#: **Note:** ``_28`` is also reserved in UDI's source for Profile
158+
#: change events (actions 1-8 — profile/editor/nodedef/linkdef
159+
#: updated/deleted) — but no firmware path fires those today
160+
#: (placeholders since Dec 2024 per UDI). Don't subscribe expecting
161+
#: them.
131162
MATTER_STATUS = "_28"
132163

133164
@classmethod
@@ -281,6 +312,39 @@ def label(cls, value: str) -> str:
281312
return _enum_label(cls, value)
282313

283314

315+
class SystemUpgradeAction(StrEnum):
316+
"""Action codes on :attr:`SystemEventControl.SYSTEM_UPGRADE` (``_26``)
317+
frames — IoX-6 firmware-upgrade lifecycle."""
318+
319+
#: Upgrade in progress.
320+
ACTIVE = "1"
321+
#: Upgrade not active (post-completion or idle).
322+
INACTIVE = "2"
323+
#: A new upgrade is available to install.
324+
AVAILABLE = "3"
325+
#: Upgrade applied; reboot required to take effect.
326+
REBOOT_REQUIRED = "4"
327+
328+
@classmethod
329+
def label(cls, value: str) -> str:
330+
"""Friendly lower-case name, or the raw value."""
331+
return _enum_label(cls, value)
332+
333+
334+
class SystemEditorAction(StrEnum):
335+
"""Action codes on :attr:`SystemEventControl.SYSTEM_EDITOR` (``_24``)
336+
frames. The ``<node>`` slot carries the editor name (e.g.
337+
``_sys_notify_short``)."""
338+
339+
#: A system editor's contents changed.
340+
EDITOR_CHANGED = "1"
341+
342+
@classmethod
343+
def label(cls, value: str) -> str:
344+
"""Friendly lower-case name, or the raw value."""
345+
return _enum_label(cls, value)
346+
347+
284348
class DeviceWriteAction(StrEnum):
285349
"""Device-write sub-codes that ride through on ``_7``
286350
(:attr:`SystemEventControl.PROGRESS`) frames — PyISY 3.x surfaced
@@ -359,6 +423,20 @@ class NodeLifecycleAction(StrEnum):
359423
#: should discard cached info for the node and rebuild it.
360424
#: ``<eventInfo>`` carries the full ``<node>`` structure.
361425
NODE_REVISED = "RV"
426+
#: Supported-type info changed — the node's nodedef assignment was
427+
#: reassigned (e.g. a node server's ``changeNode``, or a device
428+
#: driver detecting new capabilities). The primary signal that a
429+
#: cached nodedef → entity mapping is stale. *Not* fired for
430+
#: ``/rest/profiles`` definition updates or moves, or at startup
431+
#: migration — those rewrite the profile DB without notifying.
432+
NODE_TYPE_INFO_CHANGED = "NI"
433+
#: All nodes for a single device have been added (bulk). Fired
434+
#: after an include / re-pair so consumers can coalesce a single
435+
#: refresh per device instead of per child node.
436+
ALL_NODES_ADDED = "AA"
437+
#: Scene link updated — a link's properties (on-level / ramp rate)
438+
#: changed for an existing scene member.
439+
LINK_UPDATED = "LU"
362440
#: Discovering nodes (linking in progress). No node.
363441
DISCOVERING_NODES = "SN"
364442
#: Node discovery complete. No node.
@@ -419,6 +497,9 @@ def label(cls, value: str) -> str:
419497
NodeLifecycleAction.PENDING_DEVICE_OP: (),
420498
NodeLifecycleAction.PROGRAMMING_DEVICE: (),
421499
NodeLifecycleAction.NODE_REVISED: (), # plus the full <node> structure
500+
NodeLifecycleAction.NODE_TYPE_INFO_CHANGED: (),
501+
NodeLifecycleAction.ALL_NODES_ADDED: (),
502+
NodeLifecycleAction.LINK_UPDATED: (),
422503
NodeLifecycleAction.DISCOVERING_NODES: (),
423504
NodeLifecycleAction.NODE_DISCOVERY_COMPLETE: (),
424505
NodeLifecycleAction.NODE_ERROR: (),
@@ -454,6 +535,8 @@ def label(cls, value: str) -> str:
454535
SystemEventControl.PROGRESS: ProgressAction,
455536
SystemEventControl.SECURITY_SYSTEM: SecuritySystemAction,
456537
SystemEventControl.DEVICE_LINKER: DeviceLinkerAction,
538+
SystemEventControl.SYSTEM_EDITOR: SystemEditorAction,
539+
SystemEventControl.SYSTEM_UPGRADE: SystemUpgradeAction,
457540
}
458541

459542

@@ -472,7 +555,9 @@ def describe_system_event(control: str, action: str) -> str:
472555
* ``"_0"`` / ``"90"`` → ``"heartbeat = 90"`` (action = seconds to
473556
the next heartbeat; not enumerated)
474557
* ``"_28"`` / ``"1.3"`` → ``"matter_status = 1.3"`` (no enum)
475-
* ``"_26"`` / ``"2"`` → ``"_26 = 2"`` (control we don't recognise —
558+
* ``"_26"`` / ``"2"`` → ``"system_upgrade = inactive"``
559+
* ``"_24"`` / ``"1"`` → ``"system_editor = editor_changed"``
560+
* ``"_99"`` / ``"x"`` → ``"_99 = x"`` (control we don't recognise —
476561
both halves pass through verbatim)
477562
478563
Intended for the debug logging consumers do over raw event frames
@@ -623,20 +708,27 @@ def requires_reload(self) -> bool:
623708
Reload-worthy: ``ND`` / ``NR`` / ``NN`` (node added/removed/renamed
624709
— the registry's set or display names are stale), ``EN``
625710
(enabled/disabled — the entity's property shape may change),
626-
``RV`` (revised — discard and rebuild this node), ``RG`` (removed
627-
from scene — membership changed), ``SC`` (node-discovery complete
628-
— new nodes may have appeared), and the folder/scene tree verbs
711+
``RV`` (revised — discard and rebuild this node), ``NI``
712+
(supported-type info changed — the node's nodedef assignment was
713+
reassigned, so the cached nodedef→entity mapping is stale; per
714+
UDI's notification taxonomy this is *the* primary signal for
715+
profile-related node changes), ``AA`` (all-nodes-added bulk
716+
signal after a device include), ``RG`` (removed from scene —
717+
membership changed), ``SC`` (node-discovery complete — new
718+
nodes may have appeared), and the folder/scene tree verbs
629719
``FD`` / ``FR`` / ``FN`` / ``GD`` / ``GR`` / ``GN`` (the
630720
``groups`` / ``folders`` registries are stale).
631721
632722
Softer signals — informational, don't trigger reload UX:
633723
``MV`` (added to scene), ``CL`` (link changed — not supported),
634-
``PC`` (parent changed), ``PI`` (power info), ``DI`` (device id —
635-
not implemented), ``DP`` (UPB property), ``WH`` (pending op),
636-
``WD`` (programming device — a property-update event follows),
637-
``SN`` (discovering nodes — wait for ``SC``), ``CE`` / ``NE``
638-
(comm error/cleared — no shape change), ``WR`` (a networking
639-
resource was renamed — doesn't touch nodes).
724+
``LU`` (scene link's on-level/ramp updated — property change,
725+
not shape change), ``PC`` (parent changed), ``PI`` (power
726+
info), ``DI`` (device id — not implemented), ``DP`` (UPB
727+
property), ``WH`` (pending op), ``WD`` (programming device — a
728+
property-update event follows), ``SN`` (discovering nodes —
729+
wait for ``SC``), ``CE`` / ``NE`` (comm error/cleared — no
730+
shape change), ``WR`` (a networking resource was renamed —
731+
doesn't touch nodes).
640732
"""
641733
return self.action in {
642734
NodeLifecycleAction.NODE_ADDED,
@@ -645,6 +737,8 @@ def requires_reload(self) -> bool:
645737
NodeLifecycleAction.NODE_REMOVED_FROM_GROUP,
646738
NodeLifecycleAction.NODE_ENABLED,
647739
NodeLifecycleAction.NODE_REVISED,
740+
NodeLifecycleAction.NODE_TYPE_INFO_CHANGED,
741+
NodeLifecycleAction.ALL_NODES_ADDED,
648742
NodeLifecycleAction.NODE_DISCOVERY_COMPLETE,
649743
NodeLifecycleAction.FOLDER_ADDED,
650744
NodeLifecycleAction.FOLDER_REMOVED,

tests/test_runtime/test_events_internal.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@
2828
ProgressAction,
2929
SecuritySystemAction,
3030
SystemConfigAction,
31+
SystemEditorAction,
3132
SystemEventControl,
33+
SystemUpgradeAction,
3234
TriggerAction,
3335
VariableTableChangeEvent,
3436
_compact_event_info,
@@ -55,6 +57,11 @@
5557
(SecuritySystemAction, SecuritySystemAction.DISARMED),
5658
(DeviceLinkerAction, DeviceLinkerAction.STATUS),
5759
(NodeLifecycleAction, NodeLifecycleAction.NODE_ENABLED),
60+
(NodeLifecycleAction, NodeLifecycleAction.NODE_TYPE_INFO_CHANGED),
61+
(NodeLifecycleAction, NodeLifecycleAction.ALL_NODES_ADDED),
62+
(NodeLifecycleAction, NodeLifecycleAction.LINK_UPDATED),
63+
(SystemEditorAction, SystemEditorAction.EDITOR_CHANGED),
64+
(SystemUpgradeAction, SystemUpgradeAction.AVAILABLE),
5865
],
5966
)
6067
def test_enum_label_known_returns_friendly_name(enum_cls: type, known_value: str) -> None:
@@ -78,6 +85,8 @@ def test_enum_label_known_returns_friendly_name(enum_cls: type, known_value: str
7885
SecuritySystemAction,
7986
DeviceLinkerAction,
8087
NodeLifecycleAction,
88+
SystemEditorAction,
89+
SystemUpgradeAction,
8190
],
8291
)
8392
def test_enum_label_unknown_passes_through_raw_value(enum_cls: type) -> None:

tests/test_runtime/test_lifecycle.py

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,18 @@
4242
("_0", "90", "heartbeat = 90"), # action = seconds; not enumerated
4343
("_23", "1", "portal = 1"), # control known, no action enum
4444
("_28", "1.3", "matter_status = 1.3"), # no enum for matter status
45-
("_26", "2", "_26 = 2"), # unknown control — both halves verbatim
45+
("_24", "1", "system_editor = editor_changed"),
46+
("_25", "1.3", "zmatter_zwave = 1.3"), # dotted action passes through
47+
("_26", "1", "system_upgrade = active"),
48+
("_26", "2", "system_upgrade = inactive"),
49+
("_26", "3", "system_upgrade = available"),
50+
("_26", "4", "system_upgrade = reboot_required"),
51+
("_27", "2.5", "zigbee_uyb = 2.5"), # dotted action passes through
52+
("_99", "x", "_99 = x"), # unknown control — both halves verbatim
53+
# _3 lifecycle additions
54+
("_3", "NI", "node_lifecycle = node_type_info_changed"),
55+
("_3", "AA", "node_lifecycle = all_nodes_added"),
56+
("_3", "LU", "node_lifecycle = link_updated"),
4657
],
4758
)
4859
def test_describe_system_event(control: str, action: str, expected: str) -> None:
@@ -141,6 +152,61 @@ def test_lifecycle_listener_unwraps_action_to_typed_enum() -> None:
141152
assert received[0].node_xml is None # no <eventInfo><node> on removes
142153

143154

155+
def test_lifecycle_listener_fires_on_node_type_info_changed() -> None:
156+
"""``NI`` is the primary signal that a node's nodedef assignment
157+
changed (the cached nodedef→entity mapping is stale). Per UDI's
158+
notification taxonomy it should fan through the lifecycle channel
159+
with ``requires_reload=True``."""
160+
frame = (
161+
'<Event seqnum="55"><control>_3</control><action>NI</action>'
162+
"<node>n009_harmonyctrl</node><eventInfo/></Event>"
163+
)
164+
dispatcher = EventDispatcher({})
165+
received: list[NodeLifecycleEvent] = []
166+
dispatcher.add_lifecycle_listener(received.append)
167+
168+
dispatcher.feed(frame)
169+
170+
assert len(received) == 1
171+
assert received[0].action is NodeLifecycleAction.NODE_TYPE_INFO_CHANGED
172+
assert received[0].node_address == "n009_harmonyctrl"
173+
assert received[0].requires_reload is True
174+
175+
176+
def test_lifecycle_listener_fires_on_all_nodes_added() -> None:
177+
"""``AA`` is the bulk include-complete signal — reload-worthy so
178+
consumers can coalesce one refresh per device instead of per child."""
179+
frame = (
180+
'<Event seqnum="56"><control>_3</control><action>AA</action>'
181+
"<node>n009_harmonyctrl</node><eventInfo/></Event>"
182+
)
183+
dispatcher = EventDispatcher({})
184+
received: list[NodeLifecycleEvent] = []
185+
dispatcher.add_lifecycle_listener(received.append)
186+
187+
dispatcher.feed(frame)
188+
189+
assert received[0].action is NodeLifecycleAction.ALL_NODES_ADDED
190+
assert received[0].requires_reload is True
191+
192+
193+
def test_link_updated_is_not_reload_worthy() -> None:
194+
"""``LU`` is a scene-link property update (on-level / ramp rate) —
195+
softer signal, no shape change; should not flip ``requires_reload``."""
196+
frame = (
197+
'<Event seqnum="57"><control>_3</control><action>LU</action>'
198+
"<node>3D 7D 87 1</node><eventInfo/></Event>"
199+
)
200+
dispatcher = EventDispatcher({})
201+
received: list[NodeLifecycleEvent] = []
202+
dispatcher.add_lifecycle_listener(received.append)
203+
204+
dispatcher.feed(frame)
205+
206+
assert received[0].action is NodeLifecycleAction.LINK_UPDATED
207+
assert received[0].requires_reload is False
208+
209+
144210
def test_lifecycle_listener_passes_through_unknown_actions_as_strings() -> None:
145211
"""A future control-3 verb that isn't in NodeLifecycleAction comes
146212
through with action=raw_string so consumers can still react."""
@@ -233,6 +299,8 @@ def test_lifecycle_requires_reload_taxonomy() -> None:
233299
NodeLifecycleAction.NODE_REMOVED_FROM_GROUP, # RG (scene-edit)
234300
NodeLifecycleAction.NODE_ENABLED, # EN — covers both directions
235301
NodeLifecycleAction.NODE_REVISED, # RV
302+
NodeLifecycleAction.NODE_TYPE_INFO_CHANGED, # NI — nodedef reassigned
303+
NodeLifecycleAction.ALL_NODES_ADDED, # AA — bulk include-complete
236304
NodeLifecycleAction.NODE_DISCOVERY_COMPLETE, # SC — new nodes may have appeared
237305
NodeLifecycleAction.FOLDER_ADDED, # FD
238306
NodeLifecycleAction.FOLDER_REMOVED, # FR
@@ -244,6 +312,7 @@ def test_lifecycle_requires_reload_taxonomy() -> None:
244312
soft_actions = {
245313
NodeLifecycleAction.NODE_MOVED, # MV (added to scene)
246314
NodeLifecycleAction.LINK_CHANGED, # CL — not supported
315+
NodeLifecycleAction.LINK_UPDATED, # LU — scene link on-level/ramp change
247316
NodeLifecycleAction.PARENT_CHANGED, # PC
248317
NodeLifecycleAction.POWER_INFO_CHANGED, # PI
249318
NodeLifecycleAction.DEVICE_ID_CHANGED, # DI — not implemented

0 commit comments

Comments
 (0)