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:

  • _3node-lifecycle. <action> is a lifecycle verb (see NodeLifecycleAction); the dispatcher emits a NodeLifecycleEvent to lifecycle listeners.

  • _1program / variable / system:
    • <action>0</action> is a program-status update — the matching ProgramRecord is mutated in place and a ProgramStatusEvent is emitted to program-status listeners.

    • <action>6</action> is a variable-value update — the matching VariableRecord has its value and timestamp mutated.

    • <action>7</action> is a variable-init update — the matching VariableRecord has its init mutated.

    • <action>3</action> and other freeform actions are surfaced as plain Event instances; consumers that care can parse the event_info payload 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:

  • statuspyisyox.constants.EventStreamStatus enum (CONNECTING, CONNECTED, RECONNECTING, DISCONNECTED).

  • connected — bool shortcut for status == CONNECTED.

  • last_event_atdatetime (UTC) of the most recently received frame, or None if 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="...">, or None if 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:
  • seqnum (int)

  • timestamp (str)

  • control (str)

  • action (str)

  • node_address (str)

  • formatted_action (str)

  • formatted_name (str)

  • uom (str)

  • precision (int | None)

  • event_info (str)

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 Event instances 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 in pyisyox.runtime.ws; tests can drive the dispatcher directly with synthetic frames.

Parameters:
update_groups(groups)[source]

(Re)build the member→groups reverse index from a group registry.

Called from __init__ and again by pyisyox.controller.Controller.refresh()refresh() replaces LoadResult.groups with a fresh dict (unlike nodes, 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 callback to fire on every parsed event.

Returns:

An unsubscribe function. Calling it removes callback from the listener list. Safe to call from inside a callback (the dispatcher iterates a snapshot).

Parameters:

callback (Callable[[Event], None])

Return type:

Callable[[], None]

add_program_status_listener(callback)[source]

Register callback to fire on every program-status frame (<control>_1</control> action "0").

The dispatcher updates the matching pyisyox.client.ProgramRecord in place before firing, so consumers reading program.status from 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 callback to 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. See VariableTableChangeEvent for the payload.

Returns:

An unsubscribe function.

Parameters:

callback (Callable[[VariableTableChangeEvent], None])

Return type:

Callable[[], None]

add_lifecycle_listener(callback)[source]

Register callback to fire on every parsed NodeLifecycleEvent (<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 call pyisyox.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 Event for callers that want to peek (e.g. for sequence-number tracking), or None when 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.

Parameters:

raw_frame (str)

Return type:

Event | None

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 in NODE_LIFECYCLE_EVENT_INFO_TAGS.

EN carries an enabled boolean 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 — see NodeLifecycleEvent.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 WH first, then PROGRAMMING_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/profiles definition 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.

classmethod label(value)[source]

Friendly lower-case name for a lifecycle verb, or the raw code if it isn’t one we know.

Parameters:

value (str)

Return type:

str

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 Event whenever the dispatcher sees one of the actions in NodeLifecycleAction. Consumers subscribe via pyisyox.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.

  • seqnum (int) – Sequence number of the underlying Event.

  • node_xml (str | None) – For ND actions, the inner <node> element text from <eventInfo>. None for verbs that don’t include the full element. Consumers wanting the parsed shape can pass this to parse_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 to Node.enabled. None for every other verb (and for EN frames 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 verbs FD / FR / FN / GD / GR / GN (the groups / folders registries 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 for SC), 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 EventDispatcher whenever a <control>_1</control> frame with <action>0</action> arrives carrying a program id in its <eventInfo>. The matching pyisyox.client.ProgramRecord is mutated in place before listeners fire, so consumers reading program.status from a callback see the updated value.

Variables:
  • address (str) – Program id (4-character hex, zero-padded to match /api/programs).

  • status (bool) – True when the cookbook <s> byte’s eval state is ProgramEvalState.TRUE (the if-clause matched on the most recent evaluation); False for ProgramEvalState.FALSE. For ProgramEvalState.UNKNOWN / ProgramEvalState.NOT_LOADED (and frames with no <s> byte) the dispatcher carries forward the prior record.status so 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 — see enabled.

  • running (int | None) – Raw <s> byte the eisy sent, or None if absent. Cookbook §8.5.3: the byte is a bitwise OR of a ProgramRunState (low nibble) and a ProgramEvalState (high nibble); use run_state / eval_state for the typed view.

  • run_state (pyisyox.runtime.events.ProgramRunState | None) – Decoded low nibble — IDLE / THEN / ELSE, or None when 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, or None when the wire byte was absent / unrecognised. Disambiguates the three “not really True/False” cases that status: bool collapses. NOT_LOADED is the cookbook label for what is in practice the program-errored sentinel — see ProgramEvalState.

  • enabled (bool | None) – New enabled state when the frame carried an <on/> / <off/> element — True for <on/>, False for <off/>. None when the frame omitted both (some “ran”-only frames carry only <r> / <f> / <s> — see cookbook §8.5.3). The matching record’s pyisyox.client.ProgramRecord.enabled is updated in-place before listeners fire when this is non-None.

  • run_at_startup (bool | None) – New run_at_startup state when the frame carried an <rr/> (True) or <nr/> (False) element. None when the frame omitted both. Mirror of the enabled-flag pattern; the record’s pyisyox.client.ProgramRecord.run_at_startup is updated in-place before listeners fire.

  • seqnum (int) – Sequence number of the underlying Event.

Parameters:
class WebSocketEventStream(client, dispatcher, path='/rest/subscribe')[source]

Background reader that feeds frames into an EventDispatcher.

Lifecycle:

  1. start() schedules the read task and returns immediately.

  2. The task connects, dispatches frames, reconnects on transport errors, and pumps EventStreamStatus notifications to any registered status listener. On each connect it holds SYNCING (not CONNECTED) until the controller’s initial status replay drains, so consumers don’t treat the replay as live events.

  3. 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 ISY glue object that owns both the IoXClient and the dispatcher.

Parameters:
property status: EventStreamStatus

Most-recent stream status.

Updated on every transition (initialise / connect / reconnect / disconnect / lost). Defaults to EventStreamStatus.NOT_STARTED before start(). Useful for system-health pages that want a single readable status string without subscribing to every notification.

property connected: bool

True while the stream is in the CONNECTED state.

Convenience over comparing status directly. Note that connected flipping False doesn’t mean the reader has given up — it may be reconnecting, or in EventStreamStatus.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 None if 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 stale last_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). Other type values (e.g. "spolisy" PG3 service status) return None — they’re not property updates and the dispatcher ignores them.

Returns None for 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.

Parameters:

raw (str)

Return type:

Event | None