Event Pipeline¶
The eisy emits state changes as <Event> XML frames over a single
WebSocket connection. PyISYoX parses each frame, applies the update
to the appropriate record in pyisyox.LoadResult, and then
fans out to three listener channels via the
EventDispatcher. This document describes the
event taxonomy, the dispatcher contract, and the WebSocket health
surface.
Transport¶
Two WebSocket paths exist:
/rest/subscribe— the legacy path. Raw XML frames. Default for both PortalAuth and LocalAuth./api/events/subscribe— the modern path. JSON envelope ({"type": "event", "data": "<xml>"}) and adds a"spolisy"side channel for PG3 service status. Opt-in for PortalAuth only.
Both deliver the same underlying <Event seqnum=... sid=...
timestamp=...> XML payload, so pyisyox.runtime.parse_event_frame()
accepts either shape and returns a single Event
(or None for keep-alive nulls and non-event JSON frames like
"spolisy" PG3 status updates).
To opt in to the JSON-envelope path, pass ws_path="/api/events/subscribe"
to pyisyox.Controller.
Event control codes¶
Every frame carries a <control> element. The dispatcher routes
on this value:
Property updates¶
When <control> is a property id ("ST", "OL", "GV1",
etc.) and <node> is populated, the frame is a property update.
The dispatcher updates LoadResult.nodes[address].properties[control]
in place — building a NodePropertyValue from the
<action> raw value plus optional uom / prec attributes
and <fmtAct> / <fmtName> siblings.
System control codes¶
Codes starting with an underscore carry system-wide signals. The dispatcher special-cases two:
_3— node-lifecycle.<action>is a lifecycle verb (seeNodeLifecycleAction); the dispatcher emits aNodeLifecycleEventto lifecycle listeners._1— program / variable / system:<action>0</action>is a program-status update — the matchingProgramRecordis mutated in place and aProgramStatusEventis emitted to program-status listeners.<action>6</action>is a variable-value update — the matchingVariableRecordhas itsvalueand timestamp mutated.<action>7</action>is a variable-init update — the matchingVariableRecordhas itsinitmutated.<action>3</action>and other freeform actions are surfaced as plainEventinstances; consumers that care can parse theevent_infopayload themselves.
Other system codes (_5 driver state, _7 controller logs,
_28 Matter status, …) flow through as plain
Event instances. Consumers that want their
structured payload parse the event_info string themselves.
Node lifecycle events¶
The full set of lifecycle verbs is on
NodeLifecycleAction. The most important property is
NodeLifecycleEvent.requires_reload: True for verbs that
invalidate the cached node registry (add / remove / rename /
enabled-toggle / revised / removed-from-group), False for softer
signals (added-to-scene, parent-changed, pending-op, PG3 property /
config reports, comm errors).
HA Core’s intended UX is to register a Repair issue on the first
lifecycle event with requires_reload=True and clear it once the
user-initiated reload completes. PyISYoX does not auto-merge
these into the live registry — consumers decide when to call
refresh().
For ND (added) frames, the inner <node> element is preserved
verbatim in NodeLifecycleEvent.node_xml; consumers can pass
that to pyisyox.runtime.events.parse_lifecycle_node_xml() to
get a structured shape.
Subscribing¶
Three listener channels are exposed on pyisyox.Controller,
each returning an unsubscribe function:
def on_event(ev): # every parsed frame
print(ev.seqnum, ev.control, ev.action, ev.node_address)
def on_lifecycle(ev): # _3 frames only
if ev.requires_reload:
schedule_reload()
def on_program_status(ev): # _1/0 frames only
print("program", ev.address, ev.status)
def on_ws_status(status): # ws lifecycle
print("ws:", status)
unsub_event = controller.add_event_listener(on_event)
unsub_lifecycle = controller.add_node_lifecycle_listener(on_lifecycle)
unsub_program = controller.add_program_status_listener(on_program_status)
unsub_status = controller.add_status_listener(on_ws_status)
The dispatcher applies the property / program / variable update
before calling listeners, so a callback observing a property event
can read the new value via
controller.nodes[address].properties[control] synchronously.
Listener exceptions are isolated: an exception raised by one listener is logged at warning level but does not prevent the other listeners from running, and does not crash the read loop.
WebSocket health¶
pyisyox.WebSocketEventStream exposes three readable
properties for surfacing connection health to the user:
status—pyisyox.constants.EventStreamStatusenum (CONNECTING,CONNECTED,RECONNECTING,DISCONNECTED).connected— bool shortcut forstatus == CONNECTED.last_event_at—datetime(UTC) of the most recently received frame, orNoneif no frame has arrived yet.
Access via controller.websocket (None if the controller was
started with start_websocket=False or after stop()):
ws = controller.websocket
if ws is None:
# one-shot read, or already stopped
...
else:
print(ws.status, ws.connected, ws.last_event_at)
Reconnection¶
On transport error or unexpected close, the reader backs off through
a fixed schedule (1s → 2s → 5s → 10s → 30s → 60s, capped at 60s
thereafter) before reconnecting. The schedule resets after a
successful read. Status listeners see RECONNECTING while we’re in
the backoff loop and CONNECTED once a fresh handshake succeeds.
A 401 during the WebSocket handshake triggers a token refresh via the
auth strategy before the next attempt — PortalAuth refreshes; LocalAuth
returns False from handle_unauthorized so the next attempt’s
basic-auth header carries the (possibly updated) credentials.
Testing without a live controller¶
The dispatcher is decoupled from the WebSocket transport. Tests
can inject synthetic frames via feed_event_frame():
raw = """<?xml version="1.0"?>
<Event seqnum="1" sid="uuid:42" timestamp="2026-05-11T00:00:00Z">
<control>ST</control>
<action prec="0" uom="51">100</action>
<node>3D 7D 87 1</node>
<fmtAct>On</fmtAct>
</Event>"""
controller.feed_event_frame(raw)
assert controller.nodes["3D 7D 87 1"].properties["ST"].value == "100"
This is the same path the WebSocket reader exercises, so listener contracts behave identically.
Reference¶
- class Event(seqnum, timestamp, control, action, node_address, formatted_action='', formatted_name='', uom='', precision=None, event_info='')[source]
One parsed event frame.
- Variables:
seqnum (int) – Event sequence number from the eisy. Monotonic per connection; resets on reconnect.
timestamp (str) – ISO 8601 timestamp string from the frame (preserved verbatim — consumer parses if needed).
control (str) – Property id (
"ST","GV1", …) or system code ("_5","_28", …).action (str) – Raw value as reported (string form preserves the controller’s precision representation).
node_address (str) – Wire address of the affected node, or empty string for system events.
formatted_action (str) – Human-readable display value (e.g.
"0.6839 US gallons"). Empty when the controller didn’t supply one (system events typically don’t).formatted_name (str) – Display name of the property (e.g.
"Current"). Empty when not provided.uom (str) – Unit-of-measure id from
<action uom="...">.precision (int | None) – Decimal precision from
<action prec="...">, orNoneif absent. (Wire keys it as"prec"; Python attribute spells it out.)event_info (str) – Inner
<eventInfo>XML preserved verbatim. Empty string when the frame had no<eventInfo>element or when its content was empty. Consumers that need the structured payload (e.g. variable change frames carrying<var type="..." id="...">, or controller logs in CDATA) parse this themselves — the IoX wire schema differs across system control codes and pyisyox stays neutral.
- Parameters:
- property is_system: bool
True for system control codes (
_5,_28, …).
- property is_node_property: bool
True when this event should overlay onto a node’s property dict.
- class EventDispatcher(nodes, programs=None, variables=None, groups=None)[source]
Routes parsed
Eventinstances into a node registry + listener callbacks.The dispatcher is intentionally not coupled to the WebSocket transport —
feed()accepts a raw frame and does the parse + route + emit dance. The actual WS read loop lives inpyisyox.runtime.ws; tests can drive the dispatcher directly with synthetic frames.- Parameters:
nodes (dict[str, NodeRecord])
programs (dict[str, ProgramRecord] | None)
variables (dict[str, dict[str, VariableRecord]] | None)
groups (dict[str, GroupRecord] | None)
- update_groups(groups)[source]
(Re)build the member→groups reverse index from a group registry.
Called from
__init__and again bypyisyox.controller.Controller.refresh()—refresh()replacesLoadResult.groupswith a fresh dict (unlikenodes, which is mutated in place), so the index has to be rebuilt or scene-membership changes from a reload lifecycle event would be missed (new members never re-emit; removed members still would).- Parameters:
groups (dict[str, GroupRecord])
- Return type:
None
- add_listener(callback)[source]
Register
callbackto fire on every parsed event.
- add_program_status_listener(callback)[source]
Register
callbackto fire on every program-status frame (<control>_1</control>action"0").The dispatcher updates the matching
pyisyox.client.ProgramRecordin place before firing, so consumers readingprogram.statusfrom the callback see the new value.- Returns:
An unsubscribe function.
- Parameters:
callback (Callable[[ProgramStatusEvent], None])
- Return type:
Callable[[], None]
- add_variable_table_change_listener(callback)[source]
Register
callbackto fire on every variable-table-change frame (<control>_1</control>action"9").Fired when a variable is added, removed, or has its precision changed on the controller. The dispatcher itself does not re-fetch the variable table — the listener is the seam where consumers wire in a focused re-fetch (e.g. by calling
Controller.refresh()) so the registry mirrors the new metadata. SeeVariableTableChangeEventfor the payload.- Returns:
An unsubscribe function.
- Parameters:
callback (Callable[[VariableTableChangeEvent], None])
- Return type:
Callable[[], None]
- add_lifecycle_listener(callback)[source]
Register
callbackto fire on every parsedNodeLifecycleEvent(<control>_3</control>frames).Use this to drive reload UX: HA Core typically registers a Repair issue when it sees a lifecycle event with
requires_reload=True, prompting the user to reload the integration when convenient. The dispatcher does not update the node registry on lifecycle events — consumers decide whether to callpyisyox.controller.Controller.refresh()or live with a stale view until manual reload.- Returns:
An unsubscribe function.
- Parameters:
callback (Callable[[NodeLifecycleEvent], None])
- Return type:
Callable[[], None]
- feed(raw_frame)[source]
Parse one frame, apply the property update, fan out to listeners.
Returns the parsed
Eventfor callers that want to peek (e.g. for sequence-number tracking), orNonewhen the frame couldn’t be parsed (malformed XML, non-event envelope, keep-alive null). Never raises on bad input — a single bad frame must not crash the read loop.
- class NodeLifecycleAction(*values)[source]
Verbs the eisy emits via
<control>_3</control>events — ISY994 Developer Cookbook §8.5.5 (“Node Changed/Updated”). PyISY 3.x keeps the same mapping.<eventInfo>child tags per verb are inNODE_LIFECYCLE_EVENT_INFO_TAGS.ENcarries anenabledboolean in<eventInfo>— there’s no separate “disabled” verb; the same code handles both transitions.- NODE_ADDED = 'ND'
Node added.
<eventInfo>carries<nodeName>plus a<nodeType>that is itself the full<node>element — seeNodeLifecycleEvent.node_xml.
- NODE_REMOVED = 'NR'
Node removed (device deleted from the controller).
- NODE_RENAMED = 'NN'
Node renamed (display name changed).
- NODE_MOVED = 'MV'
Node moved into a Scene.
- LINK_CHANGED = 'CL'
Link changed (within a scene). Not supported by the controller — kept for documentation; never observed.
- NODE_REMOVED_FROM_GROUP = 'RG'
Node removed from a Scene.
- PARENT_CHANGED = 'PC'
Parent (primary node) changed.
- NODE_ENABLED = 'EN'
Node enabled/disabled — direction is in
eventInfo.enabled.
- POWER_INFO_CHANGED = 'PI'
Power-info changed —
<eventInfo>carries<deviceClass>/<wattage>/<dcPeriod>.
- DEVICE_ID_CHANGED = 'DI'
Device ID changed. Not implemented by the controller — kept for documentation.
- DEVICE_PROPERTY_CHANGED = 'DP'
Device property changed — UPB only.
- PENDING_DEVICE_OP = 'WH'
Pending device operation queued, awaiting commit. On Insteon a write (e.g. changing backlight level) surfaces
WHfirst, thenPROGRAMMING_DEVICE(WD) while the value is written; a property-update event arrives separately once it lands.
- PROGRAMMING_DEVICE = 'WD'
The controller is carrying out a programming/write operation on this node (follows
PENDING_DEVICE_OP). Cookbook name: “Programming Device”. Not a completion signal — watch the subsequent property-update event for the new value.
- NODE_REVISED = 'RV'
Node revised — drastically changed (UPB-style); the consumer should discard cached info for the node and rebuild it.
<eventInfo>carries the full<node>structure.
- NODE_TYPE_INFO_CHANGED = 'NI'
Supported-type info changed — the node’s nodedef assignment was reassigned (e.g. a node server’s
changeNode, or a device driver detecting new capabilities). The primary signal that a cached nodedef → entity mapping is stale. Not fired for/rest/profilesdefinition updates or moves, or at startup migration — those rewrite the profile DB without notifying.
- ALL_NODES_ADDED = 'AA'
All nodes for a single device have been added (bulk). Fired after an include / re-pair so consumers can coalesce a single refresh per device instead of per child node.
- LINK_UPDATED = 'LU'
Scene link updated — a link’s properties (on-level / ramp rate) changed for an existing scene member.
- DISCOVERING_NODES = 'SN'
Discovering nodes (linking in progress). No node.
- NODE_DISCOVERY_COMPLETE = 'SC'
Node discovery complete. No node.
- NODE_ERROR = 'NE'
Node communication error (device unreachable).
- NODE_ERROR_CLEARED = 'CE'
A previously-reported node communication error was cleared (cookbook: “Clear Node Error / Comm. Errors Cleared”) — the companion to
NODE_ERROR.
- FOLDER_ADDED = 'FD'
Folder added.
- FOLDER_REMOVED = 'FR'
Folder removed.
- FOLDER_RENAMED = 'FN'
Folder renamed —
<eventInfo>carries<newName>.
- GROUP_ADDED = 'GD'
Scene (group) added —
<eventInfo>carries<groupName>/<groupType>.
- GROUP_REMOVED = 'GR'
Scene (group) removed.
- GROUP_RENAMED = 'GN'
Scene (group) renamed —
<eventInfo>carries<newName>.
- NET_RENAMED = 'WR'
A networking-module resource was renamed (
node= the new name). Doesn’t affect the node registry.
- class NodeLifecycleEvent(action, node_address, raw_action, seqnum, node_xml=None, enabled=None)[source]
A high-level summary of a
<control>_3</control>lifecycle frame.Emitted alongside the raw
Eventwhenever the dispatcher sees one of the actions inNodeLifecycleAction. Consumers subscribe viapyisyox.controller.Controller.add_node_lifecycle_listener()to drive their own reload UX (HA Core’s Repair issue, etc.).- Variables:
action (pyisyox.runtime.events.NodeLifecycleAction | str) – The lifecycle verb (typed enum). Unknown verbs come through as a plain string via
raw_action.node_address (str) – Wire address of the affected node. Empty string only for system-wide signals (none observed yet).
raw_action (str) – The string action value verbatim, in case a new verb appears that isn’t yet in
NodeLifecycleAction.node_xml (str | None) – For
NDactions, the inner<node>element text from<eventInfo>.Nonefor verbs that don’t include the full element. Consumers wanting the parsed shape can pass this toparse_lifecycle_node_xml().enabled (bool | None) – For
EN(NODE_ENABLED) actions, the new enabled/disabled state from<eventInfo><enabled>— the same value already written back toNode.enabled.Nonefor every other verb (and forENframes that omit the flag).
- Parameters:
- property requires_reload: bool
True for verbs that invalidate the cached node/group/folder registry.
Reload-worthy:
ND/NR/NN(node added/removed/renamed — the registry’s set or display names are stale),EN(enabled/disabled — the entity’s property shape may change),RV(revised — discard and rebuild this node),NI(supported-type info changed — the node’s nodedef assignment was reassigned, so the cached nodedef→entity mapping is stale; per UDI’s notification taxonomy this is the primary signal for profile-related node changes),AA(all-nodes-added bulk signal after a device include),RG(removed from scene — membership changed),SC(node-discovery complete — new nodes may have appeared), and the folder/scene tree verbsFD/FR/FN/GD/GR/GN(thegroups/foldersregistries are stale).Softer signals — informational, don’t trigger reload UX:
MV(added to scene),CL(link changed — not supported),LU(scene link’s on-level/ramp updated — property change, not shape change),PC(parent changed),PI(power info),DI(device id — not implemented),DP(UPB property),WH(pending op),WD(programming device — a property-update event follows),SN(discovering nodes — wait forSC),CE/NE(comm error/cleared — no shape change),WR(a networking resource was renamed — doesn’t touch nodes).
- class ProgramStatusEvent(address, status, running, seqnum, run_state=None, eval_state=None, enabled=None, run_at_startup=None)[source]
A program toggled true/false on the controller.
Emitted by
EventDispatcherwhenever a<control>_1</control>frame with<action>0</action>arrives carrying a program id in its<eventInfo>. The matchingpyisyox.client.ProgramRecordis mutated in place before listeners fire, so consumers readingprogram.statusfrom a callback see the updated value.- Variables:
address (str) – Program id (4-character hex, zero-padded to match
/api/programs).status (bool) –
Truewhen the cookbook<s>byte’s eval state isProgramEvalState.TRUE(the if-clause matched on the most recent evaluation);FalseforProgramEvalState.FALSE. ForProgramEvalState.UNKNOWN/ProgramEvalState.NOT_LOADED(and frames with no<s>byte) the dispatcher carries forward the priorrecord.statusso a transient unknown doesn’t flip the entity. Wire-shape note: the<on/>/<off/>elements that ride along on the same frame are the enabled flag, not the status — seeenabled.running (int | None) – Raw
<s>byte the eisy sent, orNoneif absent. Cookbook §8.5.3: the byte is a bitwise OR of aProgramRunState(low nibble) and aProgramEvalState(high nibble); userun_state/eval_statefor the typed view.run_state (pyisyox.runtime.events.ProgramRunState | None) – Decoded low nibble —
IDLE/THEN/ELSE, orNonewhen the program isn’t loaded (eval_state == NOT_LOADED) or the wire byte was absent / unrecognised.eval_state (pyisyox.runtime.events.ProgramEvalState | None) – Decoded high nibble —
UNKNOWN/TRUE/FALSE/NOT_LOADED, orNonewhen the wire byte was absent / unrecognised. Disambiguates the three “not really True/False” cases thatstatus: boolcollapses.NOT_LOADEDis the cookbook label for what is in practice the program-errored sentinel — seeProgramEvalState.enabled (bool | None) – New
enabledstate when the frame carried an<on/>/<off/>element —Truefor<on/>,Falsefor<off/>.Nonewhen the frame omitted both (some “ran”-only frames carry only<r>/<f>/<s>— see cookbook §8.5.3). The matching record’spyisyox.client.ProgramRecord.enabledis updated in-place before listeners fire when this is non-None.run_at_startup (bool | None) – New
run_at_startupstate when the frame carried an<rr/>(True) or<nr/>(False) element.Nonewhen the frame omitted both. Mirror of the enabled-flag pattern; the record’spyisyox.client.ProgramRecord.run_at_startupis updated in-place before listeners fire.
- Parameters:
address (str)
status (bool)
running (int | None)
seqnum (int)
run_state (ProgramRunState | None)
eval_state (ProgramEvalState | None)
enabled (bool | None)
run_at_startup (bool | None)
- class WebSocketEventStream(client, dispatcher, path='/rest/subscribe')[source]
Background reader that feeds frames into an
EventDispatcher.Lifecycle:
start()schedules the read task and returns immediately.The task connects, dispatches frames, reconnects on transport errors, and pumps
EventStreamStatusnotifications to any registered status listener. On each connect it holdsSYNCING(notCONNECTED) until the controller’s initial status replay drains, so consumers don’t treat the replay as live events.stop()cancels the task and closes any active WS.
The class deliberately keeps its surface narrow — the consumer is expected to be the top-level
ISYglue object that owns both theIoXClientand the dispatcher.- Parameters:
client (IoXClient)
dispatcher (EventDispatcher)
path (str)
- property status: EventStreamStatus
Most-recent stream status.
Updated on every transition (initialise / connect / reconnect / disconnect / lost). Defaults to
EventStreamStatus.NOT_STARTEDbeforestart(). Useful for system-health pages that want a single readable status string without subscribing to every notification.
- property connected: bool
Truewhile the stream is in theCONNECTEDstate.Convenience over comparing
statusdirectly. Note thatconnectedflippingFalsedoesn’t mean the reader has given up — it may be reconnecting, or inEventStreamStatus.SYNCING(socket open but the controller’s initial status replay hasn’t drained yet — intentionally not “connected” so event consumers don’t treat the replay as live changes).
- property last_event_at: datetime | None
UTC timestamp of the most recent text frame, or
Noneif no frame has been received this lifetime.The eisy emits a heartbeat
<control>_0</control>frame every 30 seconds even when nothing else changes, so a stalelast_event_at(more than ~60 s ago) is a reasonable signal that the connection is broken even when the WS handshake hasn’t returned an error yet.
- add_status_listener(callback)[source]
Register a callback for stream-status changes.
- Returns:
An unsubscribe function.
- Parameters:
callback (Callable[[EventStreamStatus], None])
- Return type:
Callable[[], None]
- start()[source]
Start the background read loop. Idempotent — calling twice returns the existing task.
- Return type:
Task[None]
- async stop()[source]
Stop the read loop and close any active WebSocket.
- Return type:
None
- parse_event_frame(raw)[source]¶
Decode a single WebSocket frame to an
Event.Accepts either:
Raw XML —
<?xml...?><Event...>...</Event>(legacy/rest/subscribe).JSON envelope —
{"type": "event", "data": "<xml>"}(modern/api/events/subscribe). Othertypevalues (e.g."spolisy"PG3 service status) returnNone— they’re not property updates and the dispatcher ignores them.
Returns
Nonefor keep-alive nulls, malformed XML, or non-event JSON envelopes. Does not raise on parse failures so a single bad frame can’t crash the read loop.