Library Reference

PyISYoX’s public surface is small and layered. From most to least “glue”:

Controller

class Controller(base_url, auth, session=None, ws_path='/rest/subscribe', tls_version=None, verify_ssl=False)[source]

Bases: object

Top-level handle for one IoX 6+ controller (eisy / Polisy).

Construction is cheap and synchronous; the network round-trips happen in connect(). Disconnect is symmetric: stop() closes the WebSocket and (if the controller owns the aiohttp session) closes that too.

Threading: this class is async-only. The WS reader runs as a background asyncio.Task; do not block the event loop in event or status callbacks — schedule heavier work on a separate task.

Parameters:
async connect(*, start_websocket=True)[source]

Authenticate, run the initial load, and (optionally) open the WS.

Builds the IoXClient, calls IoXClient.connect() to fetch /api/config, /rest/profiles, /api/nodes, /rest/status, programs/triggers/variables in parallel, and merges the status overlay. Then constructs the EventDispatcher over the same node registry the runtime Node instances will read from, so WebSocket frames mutate properties in place.

Parameters:

start_websocket (bool) – When True (default), the WS reader starts in the background after the initial load completes. Pass False for one-shot reads (CLI tools, tests) where the consumer doesn’t need live updates.

Return type:

None

:raises Any error from IoXClient.connect() (auth failure,: :raises HTTP failure, malformed payload) propagates unchanged.:

async stop()[source]

Stop the WebSocket, log out, and (if we own it) close the session.

Idempotent — safe to call from cleanup paths even if connect() partially failed.

Return type:

None

property connected: bool

True between connect() returning successfully and stop() being called.

property websocket: WebSocketEventStream | None

The active WebSocket stream, or None.

Returns the live WebSocketEventStream when connect() was called with start_websocket=True and stop() hasn’t run yet. None for one-shot reads (CLI tools, snapshot tests) that opted out of the WS upgrade. Consumers polling stream health (HA system_health, diagnostics) read websocket.status / websocket.last_event_at directly.

property base_url: str

The controller URL passed to __init__.

property config: ControllerConfig

Decoded /api/config slice (uuid, version, portalHost).

property name: str

User-assigned controller name (e.g. "Main eisy").

Sourced from the <name> of the root group in /rest/nodes (the same value the eisy admin UI shows and that the legacy /rest/config <configuration><root><name> path carried). Empty string when the controller hasn’t been named or the legacy endpoint isn’t available.

Consumers driving HA device names should prefer this over the hostname so users see the friendly label they set on the controller, with the hostname as a fallback.

property profile: Profile

The decoded /rest/profiles blob with built nodedef lookup.

property nodes: dict[str, Node]

Map of node address → runtime Node.

Built lazily on first access from the loaded NodeRecord registry; subsequent accesses return the cached dict so identity is stable across calls (consumers can hold references to specific nodes safely).

property groups: dict[str, Group]

Map of group address → runtime Group (IoX scenes).

Sourced from /rest/nodes XML at connect time. The controller-self group (flag="12") is filtered out.

property folders: dict[str, Folder]

Map of folder address → runtime Folder (org tree only).

property programs: dict[str, Program]

Map of program id → runtime Program.

Folders share the same id space but live under program_folders; this map only contains executable programs (is_folder=False).

property program_folders: dict[str, ProgramFolder]

Map of folder id → runtime ProgramFolder.

The synthetic root folder ("My Programs" on stock eisy firmware) is included — consumers walking the tree from the controller can use it as the root anchor.

property triggers: list[dict]

Raw /api/triggers data list — program AST as JSON.

property variables: dict[str, dict[str, Variable]]

Map of variable type → id → typed Variable wrapper.

Outer key is "1" (integer) or "2" (state); inner key is the variable id within that type. Each Variable shares its underlying VariableRecord with the controller’s loaded state — writes via the wrapper’s mutation coroutines update the record in place so subsequent reads reflect the new value without waiting for a WS frame.

Returns an empty inner dict for a type the controller has no variables in.

property network_resources: dict[str, NetworkResource]

Map of resource id → runtime NetworkResource.

Empty when the controller has no networking module enabled — the optional endpoint either 404s or returns an empty <NetConfig/>, both flattened to {} here.

to_dict()[source]

Flatten the full controller state to a JSON-compatible dict.

Aggregates every loaded collection (nodes / groups / folders / programs / program_folders / variables / network_resources) plus the controller’s own config + WebSocket health. Each nested object’s structural fields come from its own to_dict() so the same code path drives the pyisyox -m --dump CLI flag and consumer diagnostics. Raises ControllerNotConnectedError when called before connect() (no loaded state to snapshot).

Return type:

dict[str, Any]

async refresh_profile()[source]

Re-fetch /rest/profiles and merge updates into the live profile.

Designed for PG3 dynamic profile reload — when a plugin updates its nodedefs at runtime, consumers detect the controller-side signal (the WS event control code is plugin- + version-specific; capture it from a real reload to wire up an automatic listener) and call this method to absorb the change.

The live Profile is mutated in place: existing pyisyox.runtime.Node instances that resolved against a NodeDef before the reload now see the new NodeDef on their next attribute access. The returned ProfileMergeResult lists the lookup-key triples that were added vs replaced so consumers can re-classify or invalidate any caches keyed on nodedef.

Returns:

A ProfileMergeResult summarising the diff. Empty (result.changed is False) when the controller’s response was identical to what we had.

Raises:
Return type:

ProfileMergeResult

add_event_listener(callback)[source]

Subscribe to every parsed WebSocket event.

The dispatcher applies the property update before calling listeners, so callbacks observing a property event can read the new value via controller.nodes[address].properties[id] synchronously.

Returns:

An unsubscribe function. Calling it removes callback.

Raises:

ControllerNotConnectedError – When called before connect() or after stop().

Parameters:

callback (EventListener)

Return type:

Callable[[], None]

add_status_listener(callback)[source]

Subscribe to WebSocket lifecycle status changes.

Returns:

An unsubscribe function.

Raises:

ControllerNotConnectedError – When called before connect() (or after stop()), or when connect() was called with start_websocket=False.

Parameters:

callback (StatusListener)

Return type:

Callable[[], None]

add_node_lifecycle_listener(callback)[source]

Subscribe to node-tree lifecycle changes (add / remove / rename).

The eisy emits <control>_3</control> frames when nodes appear or disappear (typically driven by PG3 plugin reloads). The dispatcher does not auto-update the live registry — consumers decide whether to call refresh() or live with a stale view until the user manually reloads the integration.

HA Core’s intended UX is to register a Repair issue on the first lifecycle event with ev.requires_reload is True and clear it once the user-initiated reload completes.

Returns:

An unsubscribe function.

Raises:

ControllerNotConnectedError – When called before connect().

Parameters:

callback (NodeLifecycleListener)

Return type:

Callable[[], None]

add_program_status_listener(callback)[source]

Subscribe to program-status changes (the <control>_1</control> action "0" frames).

The dispatcher mutates the matching ProgramRecord.status / running in place before firing, so consumers reading controller.programs[id].status from the callback see the new value.

Returns:

An unsubscribe function.

Raises:

ControllerNotConnectedError – When called before connect().

Parameters:

callback (ProgramStatusListener)

Return type:

Callable[[], None]

add_variable_table_change_listener(callback)[source]

Subscribe to _1/9 VARIABLE_TABLE_CHANGED frames.

These fire on variable create / delete / rename / precision change — not on per-value writes (those use _1/6 and _1/7). The Controller already wires its own listener that auto-refreshes self.variables[type_id]; consumers add their own listener on top to drive UI invalidation, telemetry, etc.

Returns:

An unsubscribe function.

Raises:

ControllerNotConnectedError – When called before connect().

Parameters:

callback (VariableTableChangeListener)

Return type:

Callable[[], None]

async refresh()[source]

Re-run the parallel load fan-out and merge results into the live LoadResult.

Use after a NodeLifecycleEvent with requires_reload=True to absorb the new node tree without re-authenticating. The live Profile is mutated in place (see Profile.merge()); the nodes / groups / folders / programs / triggers / variables registries on the LoadResult are updated to match the fresh snapshot. The dispatcher’s binding to LoadResult.nodes survives because we mutate the dict in place.

Returns:

The ProfileMergeResult from the schema merge — useful for tracking which nodedefs changed.

Raises:

ControllerNotConnectedError – When called before connect().

Return type:

ProfileMergeResult

async send_program_command(program_id, command)[source]

Send a program / folder command via the legacy REST endpoint.

Wire shape: GET /rest/programs/{id}/{command}. See pyisyox.runtime.ProgramCommand for the typed command set; bare strings are accepted too (the StrEnum members are themselves strings, so ProgramCommand.RUN_THEN == "runThen" — pass either form).

Lower-level than Program.run() etc.; useful for consumers that hold ids without a Program wrapper (e.g. an HA service receiving raw ids).

Parameters:
Return type:

None

async run_network_resource(resource_id)[source]

Fire a network resource by id.

Wire shape: GET /rest/networking/resources/{id}. Treat as fire-and-forget — the controller acknowledges receipt only, not the result of the underlying HTTP / TCP / UDP fire.

Parameters:

resource_id (str | int)

Return type:

None

async set_variable_value(var_type, var_id, value)[source]

Set the current value of a controller variable.

Wire shape: POST /api/variables/{type}/{id} with body {"value": <int>}.

Parameters:
  • var_type (int | str) – 1 (integer) or 2 (state). Strings accepted.

  • var_id (int | str) – Variable id within the type.

  • value (int) – New value to write.

Raises:
Return type:

None

async set_variable_init(var_type, var_id, init)[source]

Set the initial / restore-on-startup value of a variable.

Wire shape: POST /api/variables/{type}/{id} with {"init": <int>}.

Parameters:
Return type:

None

async rename_variable(var_type, var_id, name)[source]

Rename a variable.

Wire shape: POST /api/variables/{type}/{id} with {"name": "<str>"}.

Parameters:
Return type:

None

async create_variable(var_type, name, *, prec=0)[source]

Create a new variable on the controller.

Wire shape: PUT /api/variables/{type} with body {"name": "<str>", "prec": <int>}. The controller assigns the id and returns the new record.

Inserts a VariableRecord into the loaded registry in place (so the dispatcher’s binding survives) and returns a Variable wrapper bound to it. Per issue #125, the controller silently drops init / value keys on PUT — call Variable.set_value() / Variable.set_init() on the returned wrapper to populate them.

Raises:
Parameters:
Return type:

Variable

async refresh_variables(var_type)[source]

Re-fetch one variable type and mutate the registry in place.

Wire shape: GET /api/variables/{type}. Mutates self._loaded.variables[type] in place (clear + update) so the dispatcher’s binding to the same dict survives — a full refresh() would replace the dict and break per-record WS overlay routing.

Used internally by the auto-wired VARIABLE_TABLE_CHANGED listener; also callable directly when a consumer wants to force a re-sync.

Parameters:

var_type (int | str)

Return type:

None

async rename_node(address, name)[source]

Rename a node.

Wire shape: POST /api/nodes/{address} with {"name": "<str>", "nodeType": "node"}.

The nodeType field is required by the server. Use rename_group() for scenes.

Parameters:
Return type:

None

async rename_group(address, name)[source]

Rename a group / scene.

Same endpoint as rename_node() but with nodeType: "group" so the server applies the change through the scene registry.

Parameters:
Return type:

None

async rename_folder(address, name)[source]

Rename a folder (organisational container).

Same endpoint as rename_node() / rename_group() but with nodeType: "folder". Folders are address-keyed like nodes/groups; their addresses are typically 5-digit integers (family "13").

Parameters:
Return type:

None

feed_event_frame(raw_frame)[source]

Inject a raw frame into the dispatcher.

Useful in tests and CLIs replaying captured WebSocket data. Production code paths drive the dispatcher through the WebSocketEventStream reader.

Parameters:

raw_frame (str)

Return type:

Event | None

exception ControllerNotConnectedError[source]

Raised when accessing live data before Controller.connect() has populated it.

Runtime objects

class Node(record, nodedef, profile, client)[source]

Bases: object

User-facing handle around one node from a LoadResult.

Construct via Node.from_record() rather than the bare constructor so the editor resolver and nodedef are wired automatically from the parsed Profile.

Parameters:
classmethod from_record(record, profile, client)[source]

Resolve the nodedef for record and construct a Node.

Parameters:
Return type:

Node

property address: str

Wire address — e.g. "3D 7D 87 1" or "n010_84dd4c2c24c3b7".

property name: str

User-assigned label (set in eisy admin UI).

property nodedef_id: str

The nodedef id (e.g. "KeypadDimmer_ADV", "flume2").

property family_id: str

Family id — "1" for native Insteon/Z-Wave, slot id for plugins.

property instance_id: str

Instance id within the family.

property type: str

IoX type triple, e.g. "1.65.69.0" for KeypadLinc dimmer.

Plugin nodes carry a placeholder (Flume reports "1.2.3.4"); consumers should not rely on it for plugin classification — use nodedef instead.

property parent_address: str | None

Tree-hierarchy parent (containing folder). None at root.

Distinct from primary_address: <parent> is the folder, <pnode> is the device primary for multi-button hardware.

property primary_address: str | None

Device primary for sub-button nodes (from <pnode>).

Sub-buttons of multi-button devices (KeypadLinc, RemoteLinc, FanLinc) carry the primary’s address. None for primaries — so primary_address is not None reads as “sub-node” and primary_address or address as the device-grouping address.

property enabled: bool

Whether the eisy considers this node active.

property properties: dict[str, NodePropertyValue]

Live property values, keyed by property id (e.g. "ST").

Each value is UOM-normalised to its nodedef editor’s canonical unit — e.g. an Insteon dimmer reporting OL as a UOM-100 0-255 byte is surfaced as the UOM-51 0-100% the I_OL editor (and the /cmd write surface) uses. Values already matching the editor pass through unchanged.

property status: NodePropertyValue | None

Shortcut for properties[PROP_STATUS] — the node’s primary status reading ("ST"), UOM-normalised the same way properties is.

Returns None when the node hasn’t reported ST yet (common for write-only Insteon controllers and plugin nodes that don’t advertise ST). Consumers that want a scalar should read node.status.value (a string) and parse it themselves; the property keeps the structured shape so callers can also reach .uom, .formatted, etc.

property nodedef: NodeDef | None

The resolved NodeDef, or None if unresolved.

property flag: int

Raw node-flag bitfield from the controller’s node table.

Bit meanings live in pyisyox.constants.NodeFlag (NEW, IN_ERR, DEVICE_ROOT, …). Use has_flag() for individual bit checks rather than reading this directly. Returns 0 when the controller didn’t carry a value for this node — treat 0 as “no bits set” rather than “unknown”.

has_flag(flag)[source]

Return True if every bit in flag is set on this node.

flag may be OR’d; combined values must have every bit set.

Parameters:

flag (NodeFlag)

Return type:

bool

property protocol: Protocol

Transport-protocol classification from family_id.

Returns NODE_SERVER for any non-core family id (PG3 plugin nodes report a slot id here), UNKNOWN for recognised but unmapped core families. Classifies transport, not device class — use is_thermostat etc. for capability.

property is_thermostat: bool

True if the node accepts climate-mode or setpoint commands.

property is_lock: bool

True for door/deadbolt locks.

Two tells: nodedef accepts SECMD (Z-Wave / Insteon I2CS), or nodedef id contains "Lock" (IoX 6+ DoorLock variants that drive via DON/DOF).

property is_fan: bool

True for multi-speed fan controllers (nodedef id contains "Fan").

Fan nodes are a subset of dimmable (FanLincMotor accepts DON with a {0, 25, 75, 100} subset), so platform classification should check is_fan before is_dimmable.

property is_dimmable: bool

True if the node has a multilevel ST state and accepts a parameterized DON.

Three conditions must all hold for a real dimmer:

  1. ST editor reports a multilevel range (not a binary {0, 100} subset). Relay nodedefs accept DON with an ignored level param, so DON’s editor alone is unreliable — ST is the source of truth for “can the node hold a non-binary level”.

  2. The nodedef accepts DON. Some Insteon nodedefs (RemoteLinc2_ADV scene buttons, IMETER_SOLO meters) carry a multilevel ST editor — meaningful for the device’s own bookkeeping — but only accept WDU / QUERY, so they can’t actually be commanded on. Without this check is_dimmable returns True for them and consumers route them onto the LIGHT platform where DON-based turn_on silently fails.

  3. The accepted DON declares at least one parameter (the on-level). HA’s light platform sets brightness with DON <level>; a parameterless DON cannot take one, so the node is on/off-only even with a multilevel ST (some node-server nodedefs set level via a separate SETST / SETOL command instead — issue #64 / Virtual#11). Real Insteon/Z-Wave dimmers declare DON with an optional on-level param, so they still qualify.

property is_battery_node: bool

True if the node reports BATLVL but no ST.

Battery-powered Insteon sensors (motion, leak, open/close) match this — they have no on/off primary state.

property zwave_props: ZWaveProperties | None

Parsed ZWaveProperties for Z-Wave / Z-Matter nodes; None for Insteon and other families.

async send_command(command_id, *params)[source]

Send a command, with editor-codec parameter validation.

Each parameter is sent as /{value}/{uom} using the UOM its editor declares (the eisy web-UI convention — /cmd/DON/75/51). Parameters whose editor carries no real unit (UOM "0" or unset) are sent bare.

When the node has no resolved nodedef (dynamically provisioned Z-Wave/Z-Matter nodes whose UZW* defs aren’t in /rest/profiles), params pass through verbatim (numeric → int, no UOM) so the node stays controllable without validation.

Parameters:
Return type:

None

async set_climate_mode(mode)[source]

Set HVAC mode. Accepts enum names ("Heat", "Cool", "Auto", "Program Auto", …) or raw ints. The editor for CLIMD enforces subset membership (e.g. excludes "Fan Only" on devices that don’t support it).

Parameters:

mode (str | int)

Return type:

None

async set_climate_setpoint_heat(val)[source]

Set the heat setpoint. The codec scales by prec (or doubles for legacy UOM-101 half-degree editors).

Parameters:

val (float)

Return type:

None

async set_climate_setpoint_cool(val)[source]

Set the cool setpoint.

Parameters:

val (float)

Return type:

None

async set_fan_mode(mode)[source]

Set fan mode. Accepts enum names ("Auto", "On", "Auto High", …) or raw ints.

Parameters:

mode (str | int)

Return type:

None

async secure_lock()[source]

Issue a secure-lock command (Z-Wave / Insteon I2CS).

Return type:

None

async secure_unlock()[source]

Issue a secure-unlock command.

Return type:

None

async set_on_level(val)[source]

Set the remembered on-level via OL (0-100 percent).

Parameters:

val (int)

Return type:

None

async set_ramp_rate(val)[source]

Set the device’s ramp rate.

Insteon: 0-31 index into the IoX ramp-rate table. Z-Wave: seconds. The editor enforces the per-device range.

Parameters:

val (int)

Return type:

None

async set_backlight(val)[source]

Set keypad/switch backlight intensity.

Two encodings driven by the BL editor’s UOM: UOM 100 → 0-100%, UOM 25 → integer index (or enum-name string the codec resolves).

Parameters:

val (int | str)

Return type:

None

async start_manual_dimming()[source]

Begin manual dimming (legacy Insteon BMAN).

The IoX docs prefer the FADE_* family for new code.

Return type:

None

async stop_manual_dimming()[source]

End manual dimming (legacy Insteon SMAN).

Return type:

None

async rename(name)[source]

Rename this node. The controller emits a _3 lifecycle frame with action="NN" on success.

Parameters:

name (str)

Return type:

None

to_dict()[source]

Flatten this node to a JSON-compatible dict (record + protocol).

Return type:

dict[str, Any]

async get_zwave_parameter(number)[source]

Request parameter number; return {parameter, size, value}.

Family id picks the wire prefix ("4"/rest/zwave/..., "12"/rest/zmatter/zwave/...). Raises NodeCommandError on non-Z-Wave nodes or controller failure; ISYResponseParseError on malformed bodies.

Parameters:

number (int)

Return type:

dict[str, int]

async set_zwave_parameter(number, value, size)[source]

Write parameter number (size 1/2/4 bytes) on this Z-Wave node.

The post-write report arrives asynchronously on the WS stream. Raises NodeCommandError on rejection so failures aren’t silent.

Parameters:
Return type:

None

async set_zwave_lock_code(user_num, code)[source]

Program a Z-Wave lock’s user-code slot. Raises NodeCommandError on a failed envelope.

Parameters:
Return type:

None

async delete_zwave_lock_code(user_num)[source]

Clear a Z-Wave lock’s user-code slot.

Parameters:

user_num (int)

Return type:

None

async set_enabled(enabled)[source]

Enable or disable this node on the controller.

On success the local enabled flag is updated optimistically; the controller also emits a _3 action="EN" lifecycle.

Parameters:

enabled (bool)

Return type:

None

exception NodeCommandError[source]

Raised when a command can’t be sent — unknown command id, missing parameter, validation failure, or no nodedef resolved for this node.

Defined here (not in node.py) to keep the module dependency one-way: node.py imports from _commands.py, never the reverse.

class Group(record, profile, client, nodes=None)[source]

Bases: object

User-facing handle for one group / scene in the controller.

Parameters:
classmethod from_record(record, profile, client, nodes=None)[source]

Construct a Group from a parsed record.

Pass nodes (the controller’s loaded.nodes dict) to enable the group_all_on derived property. Without it the group is purely command-issuing.

Parameters:
Return type:

Group

property address: str

Group address — usually a 5-digit integer string or "ADR####" for special groups like ~zAuto DR.

property name: str

User-assigned scene name.

property nodedef_id: str

Scene-class label ("InsteonDimmer" etc.). Not a real profile nodedef — see module docstring.

property family_id: str

Family id — usually "6" (Insteon group family).

property instance_id: str

Instance id within the family.

property parent_address: str | None

Address of the parent folder, or None if at the top level.

property member_addresses: tuple[str, ...]

Addresses of the nodes that belong to this group.

Sourced from the <members> element in /rest/nodes XML. Order matches the controller’s declaration order. Includes both controllers and responders; use controller_addresses for the controller subset.

property controller_addresses: tuple[str, ...]

Subset of member_addresses that the controller flags as scene controllers (<link type="16">).

Empty when the group has no explicit controller (e.g. virtual scenes / SmartLinc-style automation groups).

property has_state_target: bool

Whether the scene maintains any member on/off state.

True when link targets resolved and at least one member has an on/off intent. A resolved scene with only fire-only / config links (cmd BL/BEEP/…, or empty) → False: it has no steady state, so a consumer should model it as a momentary button, not a switch. When targets are unresolved we can’t tell, so assume True (the safe default — keep it a stateful scene).

property has_dimmable_members: bool

True iff any member node is a dimmable load.

Nodedef-derived via pyisyox.runtime.Node.is_dimmable, so it’s robust and — unlike has_state_target — does not depend on /api/groups link resolution (works on older firmware too). Consumers pair it with has_state_target to pick the scene’s HA platform: no state target → button; else dimmable members → on/off light (preserving light semantics + the group/more-info framework, no switch_as_x); else → switch. Scenes have no settable brightness — fade/brt/dim are separate manual commands — so “light” here is on/off only.

Returns False without a node-registry reference. Members missing from the registry are skipped (defensive). Memoised on first access (one Node + find_nodedef per member) — the result is static for this record, so repeated reads (e.g. to_dict) don’t rebuild it.

property group_all_on: bool

True iff every on-target member currently reports an “on” state.

Computed on access from the controller’s node registry. Stateless members — motion sensors, RemoteLincs, binary-alarm devices, see _STATELESS_NODEDEF_IDS — are excluded; their ST isn’t a persistent state.

When /api/groups link targets resolved, the aggregate is over the scene’s on-target members only (see _on_set()) — so a radio-style keypad scene (one button on-target, the rest driven off) tracks correctly instead of being structurally never-all-on. Otherwise it falls back to the legacy all-member behaviour. Returns False when the group has no node-registry reference, the (on-target / member) set is empty, a member is missing from the registry, or any counted member’s ST is missing or zero.

Cheap: O(N), computed on read — the underlying ST values mutate in place via the WS dispatcher, so each access reflects the latest state.

property group_any_on: bool

True iff at least one on-target member currently reports “on”.

Companion to group_all_on; this is the aggregation HA scene-switch consumers want for their is_on. When /api/groups link targets resolved it considers only the scene’s on-target members (see _on_set()), so a scene reads on iff a member it actually drives on is on — not merely because some always-lit keypad button is non-zero. Otherwise it falls back to the legacy “any stateful member non-zero” behaviour (what pyisy.Group.status did). Stateless members and members not in the registry are skipped.

Returns False with no node-registry reference, an empty (on-target / member) set, or when every counted member’s ST is missing or zero. Cheap: O(N), computed on read.

async send_command(command_id, *params)[source]

Send a command to every member of this group.

Wire shape: GET /rest/nodes/{group_addr}/cmd/{command_id}[/{p1}...]. The controller broadcasts to each member; results aren’t returned per-member.

Unlike Node.send_command(), parameters are not validated through the editor codec — group nodedefs aren’t profile-resolvable. Pass already-encoded integers; consumers are responsible for sanity checks (e.g. clamp on-level to 0-100). Common usage:

  • await group.send_command("DON") — turn the scene on to its programmed level

  • await group.send_command("DON", 75) — explicit on-level

  • await group.send_command("DOF")

  • await group.send_command("DFON") / "DFOF" — fast

  • await group.send_command("BRT") / "DIM" — manual brighten/dim step

Parameters:
  • command_id (str)

  • params (int)

Return type:

None

async rename(name)[source]

Rename this group / scene.

Wire shape: POST /api/nodes/{address} with {"name": "<str>", "nodeType": "group"}. The nodeType field is required by the server even though the address already disambiguates — without it the call is rejected.

Parameters:

name (str)

Return type:

None

to_dict()[source]

Flatten this scene to a JSON-compatible dict.

Adds the live aggregate flags (group_all_on / group_any_on) on top of the structural record so a snapshot reflects whether the scene is currently active.

Return type:

dict[str, Any]

class Folder(record)[source]

Bases: object

User-facing handle for one folder in the node tree.

Parameters:

record (FolderRecord)

property address: str

Folder id — typically a 5-digit integer string.

property name: str

User-assigned label.

property parent_address: str | None

Address of the parent folder, or None for top-level folders.

property family_id: str

Family id — folders use family "13" (folder family) on IoX.

to_dict()[source]

Flatten this folder to a JSON-compatible dict.

Mirrors the underlying FolderRecord’s fields; useful for the dumper / diagnostics consumers that want a uniform snapshot across the controller’s collections.

Return type:

dict[str, Any]

class Program(record, client)[source]

Bases: _ProgramBase

User-facing handle for one program.

Parameters:
property enabled: bool | None

False when the program is disabled. None if the wire payload omitted the field (defensive — every captured program carries it).

property run_at_startup: bool | None

True if the program is set to run on controller boot.

property running: str | None

Raw runtime-state field as the controller reported it.

Two wire shapes: REST /api/programs emits a human label ("idle" / "running then" / "running else"); the WS event stream emits the cookbook <s> byte (two ASCII hex digits). Use run_state / eval_state for a firmware-agnostic typed view.

property run_state: ProgramRunState | None

Typed run-clause state — one of IDLE / THEN / ELSE.

None when the program errored (ProgramEvalState.NOT_LOADED) or the controller hasn’t reported a running field yet.

property eval_state: ProgramEvalState | None

Typed if-clause evaluation state — disambiguates the three “not really True/False” cases that status collapses. None from REST loads (which only carry the run label) and when the controller hasn’t reported a running field yet.

property last_run_time: datetime | None

Tz-aware datetime of the program’s last run start, or None if it has never run.

REST /api/programs emits the timestamp as ISO 8601 UTC ("2026-05-10T14:49:53.000Z"); we parse on read so the wrapper hands consumers a real datetime rather than the wire string. The raw form remains accessible via self._record.last_run_time for diagnostics / round-trip.

property last_finish_time: datetime | None

Tz-aware datetime of the program’s last run completion, or None.

property next_scheduled_run_time: datetime | None

Tz-aware datetime of the next scheduled run, or None for manual-only programs.

async run_then()[source]

Run the program’s then clause.

Wire: GET /rest/programs/{id}/runThen.

Return type:

None

async run_else()[source]

Run the program’s else clause.

Return type:

None

async run_if()[source]

Re-evaluate the program’s if condition (without running the matching clause’s actions).

Return type:

None

async enable_run_at_startup()[source]

Mark the program as auto-run on controller boot.

Return type:

None

async disable_run_at_startup()[source]

Clear the auto-run-on-boot flag.

Return type:

None

class ProgramFolder(record, client)[source]

Bases: _ProgramBase

Organisational container for programs.

Folders share the program command surface but only run / stop / enable / disable are documented to apply. The eisy aggregates child status into status server-side.

Parameters:
class ProgramCommand(*values)[source]

Bases: StrEnum

Verbs accepted by GET /rest/programs/{id}/{command}.

Members are the camelCase wire strings the eisy expects; consumers building HA-style snake-case service schemas can use the member names (ProgramCommand.RUN_THEN.name == "RUN_THEN") or pull the wire string via .value / direct comparison (StrEnum members compare equal to their underlying string).

Folders only support RUN, STOP, ENABLE, and DISABLEProgram-only verbs raise server-side on a folder target.

RUN = 'run'

Run the program (or every program under a folder). For programs, evaluates the if-clause and runs the matching branch.

RUN_THEN = 'runThen'

Run the program’s then clause directly.

RUN_ELSE = 'runElse'

Run the program’s else clause directly.

RUN_IF = 'runIf'

Re-evaluate the program’s if condition without running the matching clause’s actions.

STOP = 'stop'

Abort an executing program / folder.

ENABLE = 'enable'

Enable the program / folder for evaluation.

DISABLE = 'disable'

Disable the program / folder (status freezes).

ENABLE_RUN_AT_STARTUP = 'enableRunAtStartup'

Mark the program as auto-run on controller boot.

DISABLE_RUN_AT_STARTUP = 'disableRunAtStartup'

Clear the auto-run-on-boot flag.

class Variable(record, client)[source]

Bases: object

User-facing handle for one controller variable.

Parameters:
classmethod from_record(record, client)[source]

Construct a Variable from a parsed record.

Parameters:
Return type:

Variable

property type_id: str

Variable type — "1" (integer) or "2" (state).

property id: str

Variable id within its type (string for ergonomic joins).

property address: str

Composite "{type_id}.{id}" identifier.

property name: str

User-assigned label.

property value: int | float

Current value (wire field val).

Reads reflect the latest write — mutations via set_value() update the underlying record in place after a successful POST.

Type is int | float: most variables read back as int from the wire (/api/variables/{type} parses "val" as int), but a controller may surface float on a fresh write that posted a non-integer (the modern POST /api/variables/{type}/{id} endpoint accepts floats and the wrapper stores whatever was sent on success).

property init: int | float

Restore-on-startup value. Same int-or-float surface as value.

property precision: int

Decimal precision. displayed = raw / 10**precision.

property ts: str

Last-change timestamp as the controller emits it.

ISO 8601 UTC string when present, "" when the controller doesn’t stamp the entry (e.g. freshly created variables before the first change).

async set_value(value)[source]

Set the current value of this variable.

Wire shape: POST /api/variables/{type}/{id} with body {"value": <number>}. The modern endpoint accepts both int and float — for a precision > 0 variable, send the displayed float (e.g. 51.5) and the controller applies the * 10**precision scale on store. Sending an int on the same variable means the controller stores it verbatim (no scale applied), which produces a mismatch between consumer-displayed and controller-internal values — so callers driving displayed-unit UIs should send floats.

Strings are tolerated for legacy callers (parsed as float if they contain a decimal point, else int).

Updates the underlying record on success so subsequent reads of value reflect the new state without waiting for a WS frame.

Parameters:

value (float)

Return type:

None

async set_init(init)[source]

Set the init / restore-on-startup value.

Wire shape: POST /api/variables/{type}/{id} with {"init": <number>}. Same int-or-float semantics as set_value().

Parameters:

init (float)

Return type:

None

async rename(name)[source]

Rename this variable on the controller.

Wire shape: POST /api/variables/{type}/{id} with {"name": "<str>"}.

Parameters:

name (str)

Return type:

None

async set_precision(prec)[source]

Set decimal precision (displayed = raw / 10**precision).

Wire shape: POST /api/variables/{type}/{id} with {"prec": <int>}.

A precision change fires _1/9 (VARIABLE_TABLE_CHANGED) on the WebSocket — not the per-value 6/7 frames — so consumers that only listen on value/init updates won’t see the new precision until they refresh. The pyisyox.Controller auto-refreshes the affected type on this event when its dispatcher is wired.

Parameters:

prec (int)

Return type:

None

async delete()[source]

Delete this variable on the controller.

Wire shape: DELETE /api/variables/{type}/{id}. Fires a VARIABLE_TABLE_CHANGED frame so an auto-refresh listener can drop the entry from the registry; the wrapper itself becomes inert (subsequent mutations would 404 — the wrapper carries no flag for this; consumers should drop their reference).

Return type:

None

to_dict()[source]

Flatten this variable to a JSON-compatible dict.

Return type:

dict[str, Any]

class NetworkResource(record, client)[source]

Bases: object

User-facing handle for one networking module resource.

Parameters:
property address: str

Resource id (string for symmetry with node / group records).

property name: str

User-assigned label.

async run()[source]

Fire this network resource.

Wire shape: GET /rest/networking/resources/{id}. The controller acknowledges receipt only — the response doesn’t carry the result of the underlying HTTP / TCP / UDP fire, and there’s no progress event on the WebSocket. Treat this as fire-and-forget.

Return type:

None

to_dict()[source]

Flatten this resource to a JSON-compatible dict.

Return type:

dict[str, Any]

Authentication

class Auth(*args, **kwargs)[source]

Bases: Protocol

Auth strategy protocol shared by LocalAuth and PortalAuth.

Not @runtime_checkableisinstance(x, Auth) against an unrelated class that happens to share these attribute names would pass without verifying coroutine signatures, which masks bugs. Tests construct the concrete classes directly.

The HTTP client calls authenticate() once during connect, then request_kwargs() before every request to obtain the kwargs to splat into session.get(...)/session.post(...). On a 401 response, the client calls handle_unauthorized(); if it returns True, the client retries the original request once.

async authenticate(session, base_url)[source]

Perform any one-time authentication setup (e.g., login POST).

Parameters:
Return type:

None

async request_kwargs(session, base_url)[source]

Return kwargs for session.request() (auth, headers, etc.).

Parameters:
Return type:

dict[str, Any]

async handle_unauthorized(session, base_url)[source]

Handle a 401 response. Return True if re-auth succeeded and the original request should be retried; False if the auth state cannot recover (caller should propagate the 401 as a permanent error).

Parameters:
Return type:

bool

async close(session, base_url)[source]

Release any auth-held resources (e.g., explicit logout).

session and base_url are passed so implementations can make a final logout call to invalidate server-side state (PortalAuth posts /api/logout); LocalAuth ignores them since basic-auth has no server-side session.

Parameters:
Return type:

None

class PortalAuth(email, password)[source]

Bases: object

JWT bearer auth from POST :443/api/login.

Maintains an in-memory TokenPair with proactive refresh. On 401, attempts one refresh; if refresh fails (or has expired), falls back to a fresh login.

Login URL: {base_url}/api/login. Refresh URL: {base_url}/api/jwt/refresh. Logout URL (optional, on close()): {base_url}/api/jwt/logout. Verified against eisy 1.0.3 — POST /api/jwt/logout returns 200 with {"successful": true, "data": null}. (Pre-2026-05-12 versions of this module used /api/logout, which 404s.)

Parameters:
PROACTIVE_REFRESH_LEEWAY = 60.0

Number of seconds before access-token expiry at which we proactively refresh.

property tokens: TokenPair | None

Currently held tokens, or None if not yet authenticated.

Tests use this to assert state without forcing a real network round-trip.

async authenticate(session, base_url)[source]

Perform POST /api/login and store the returned token pair.

Concurrent calls collapse onto a single login round-trip via the instance lock; the second caller observes the tokens already set and returns without making a network request.

Raises:

AuthError – When the login response is not successful: true or lacks tokens.

Parameters:
Return type:

None

async request_kwargs(session, base_url)[source]

Return Authorization: Bearer <accessToken> headers.

Refreshes the token proactively if it expires within PROACTIVE_REFRESH_LEEWAY seconds, avoiding the cost of an in-flight 401 + refresh + retry round. Concurrent callers that observe an expiring token both queue on the auth lock; the winner refreshes once, the runners-up re-check and skip.

Parameters:
Return type:

dict[str, Any]

async handle_unauthorized(session, base_url)[source]

Handle 401: try refresh, then re-login.

Concurrent 401s from in-flight requests all enter _refresh_or_relogin; the first runs the refresh, subsequent callers re-check the cached token (which has just been updated) and skip the network round-trip. Returns True if re-auth succeeded and the caller should retry the original request; False if both refresh and login failed.

Parameters:
Return type:

bool

async close(session, base_url)[source]

Best-effort logout against POST /api/jwt/logout, then clear the in-memory tokens.

If we don’t tell the eisy we’re done, the refresh token stays live for its full 30-day TTL — useful only to attackers who somehow obtain it. The logout call is best-effort: any error (network down, controller already gone, stale token) is logged at debug level and swallowed. The local token state is cleared regardless so the consumer can construct a fresh PortalAuth and re-authenticate.

Parameters:
Return type:

None

class LocalAuth(username, password)[source]

Bases: object

HTTP basic auth against :8443/rest/* with the local admin account.

No login round-trip is needed — credentials are passed on every request. A 401 on this path means the credentials are wrong, so re-auth cannot recover.

Parameters:
  • username (str)

  • password (str)

async authenticate(session, base_url)[source]

No-op — basic auth attaches per request.

Parameters:
Return type:

None

async request_kwargs(session, base_url)[source]

Return kwargs that attach HTTP basic auth.

Parameters:
Return type:

dict[str, Any]

async handle_unauthorized(session, base_url)[source]

Cannot recover from 401 with basic auth — credentials are wrong.

Parameters:
Return type:

bool

async close(session, base_url)[source]

No-op — basic auth has no server-side session to tear down.

Parameters:
Return type:

None

exception AuthError[source]

Authentication failure (login rejected, refresh failed, etc.).

HTTP client and load records

class IoXClient(base_url, auth, session)[source]

Bases: object

Auth-aware async HTTP client for IoX 6+ controllers.

Parameters:
async connect()[source]

Authenticate (if needed) and run the parallel initial load.

Order:
  1. GET /api/config — synchronous, must succeed before the rest of the calls fire.

  2. Authenticate via the auth strategy (no-op for LocalAuth).

  3. Parallel: profiles, nodes, status, programs, triggers, variables/1, variables/2.

  4. Merge /rest/status properties into the node records.

Returns:

A populated LoadResult.

Return type:

LoadResult

async load(config=None)[source]

Run the parallel load fan-out and return a fresh LoadResult.

Used both by connect() (which prepends config + auth) and by pyisyox.controller.Controller.refresh() (which re-runs the fan-out without re-authenticating).

Parameters:

config (ControllerConfig | None) – Pre-fetched ControllerConfig to attach to the returned LoadResult. When None, the existing config is re-fetched (cheap — small JSON, no auth).

Returns:

A populated LoadResult.

Return type:

LoadResult

async send_node_command(address, command_id, *params)[source]

Issue GET /rest/nodes/{addr}/cmd/{cmd}[/{p1}[/{p2}...]].

Params are stringified and joined as-is — the editor codec runs in Node.send_command(). address is URL-quoted.

Parameters:
Return type:

str

async get_zwave_parameter(address, number, *, zmatter=False)[source]

Issue GET /rest/(zmatter/)?zwave/node/{addr}/config/query/{n}.

Body on success: <config paramNum="N" size="SZ" value="V"/>. Controller failure surfaces as a <RestResponse succeeded="false"> envelope (caller must inspect — HTTPError covers transport only). zmatter=True switches to the family-12 path prefix.

Parameters:
Return type:

str

async set_zwave_parameter(address, number, value, size, *, zmatter=False)[source]

Issue GET /rest/(zmatter/)?zwave/node/{addr}/config/set/{n}/{v}/{sz}.

size (1/2/4 bytes) is carried explicitly; the Insteon-style CONFIG command editor doesn’t model byte size, so this path takes precedence over send_command("CONFIG", ...) for Z-Wave.

Parameters:
Return type:

str

async set_zwave_lock_code(address, user_num, code, *, zmatter=False)[source]

Issue GET /rest/(zmatter/)?zwave/node/{addr}/security/user/{n}/set/code/{c}.

Programs one user-code slot. Returns a <RestResponse> envelope — callers should pass it through Node.set_zwave_lock_code()’s parser, which raises on succeeded="false".

Parameters:
Return type:

str

async delete_zwave_lock_code(address, user_num, *, zmatter=False)[source]

Issue GET /rest/(zmatter/)?zwave/node/{addr}/security/user/{n}/delete.

Clears one user-code slot.

Parameters:
Return type:

str

async set_node_enabled(address, enabled)[source]

Issue GET /rest/nodes/{addr}/{enable|disable}.

A disabled node stays in the table; the controller stops polling and commanding it.

Parameters:
Return type:

str

async post_variable_update(var_type, var_id, body)[source]

Issue POST /api/variables/{type}/{id} with the supplied body.

Four documented body shapes (one key per call; eisy-ui doesn’t mix them):

  • {"value": <int>} — set the current value

  • {"init": <int>} — set the initial/restore value

  • {"name": "<str>"} — rename

  • {"prec": <int>} — set decimal precision (fires _1/9 VARIABLE_TABLE_CHANGED instead of the per-value 6/7 frames; without an auto-refresh listener wired to that event, downstream consumers won’t notice the precision change until the next refresh()).

Parameters:
Return type:

dict[str, Any]

async create_variable(var_type, name, *, prec=0)[source]

Create a new variable on the controller.

Wire shape: PUT /api/variables/{type} with body {"name": "<str>", "prec": <int>}. The controller assigns the id and echoes the new record back as data.

Note: the eisy controller accepts init / value keys in the PUT body and even echoes them in the response, but silently drops them at storage time (issue #125 captures confirm a freshly created variable is always val=0 / init=0 regardless of what was sent). Pass prec here and follow up with post_variable_update() for value / init.

prec=0 (the controller default) is omitted from the request body — there’s no “reset to 0” path, only creation, so sending the default would just bloat the wire.

Parameters:
Return type:

dict[str, Any]

async delete_variable(var_type, var_id)[source]

Delete a variable.

Wire shape: DELETE /api/variables/{type}/{id}. Response is {"successful": true, "data": null} (no record echo); a _1/9 VARIABLE_TABLE_CHANGED frame fires alongside so an auto-refresh listener can drop the entry from the registry.

Parameters:
Return type:

None

async get_variables_type(var_type)[source]

Fetch + parse one variable type as {id: VariableRecord}.

Wire shape: GET /api/variables/{type}. Wrapper over the connect-time fan-out so consumers (and Controller.refresh_variables) don’t have to import the private _unwrap_data / parse_api_variables_type helpers themselves.

Parameters:

var_type (str | int)

Return type:

dict[str, VariableRecord]

async run_program_command(program_id, command)[source]

Send a program / folder command via the legacy REST endpoint.

Wire shape: GET /rest/programs/{id}/{command}. See pyisyox.runtime.ProgramCommand for the typed command set; bare strings (the camelCase wire values) are accepted too.

IoX 6 keeps this legacy path; no /api/programs/{id}/... equivalent has been observed. The controller acknowledges receipt only — status changes flow back over the WebSocket.

Parameters:
  • program_id (str)

  • command (str)

Return type:

str

async run_network_resource(resource_id)[source]

Fire a network resource by id.

Wire shape: GET /rest/networking/resources/{id}. Response is a small <RestResponse status="200"> envelope on success. The controller acknowledges receipt only — it doesn’t return the result of the underlying HTTP / TCP / UDP fire.

Parameters:

resource_id (str | int)

Return type:

str

async post_node_update(address, body)[source]

Issue POST /api/nodes/{address} with the supplied body.

Documented body shape (verified against eisy-ui capture):

  • {"name": "<str>", "nodeType": "node" | "group"} — rename the node or group. nodeType is required by the server even though the address already disambiguates.

Returns the parsed response body (a {successful, data} envelope).

Parameters:
Return type:

dict[str, Any]

class LoadResult(config, profile, nodes, groups, folders, programs, triggers, variables, network_resources, root_name='')[source]

Output of IoXClient.connect(). See attributes for shape.

Parameters:
class ControllerConfig(uuid, version, portal_host=None)[source]

Subset of /api/config that the rest of the load flow needs.

Parameters:
  • uuid (str)

  • version (str)

  • portal_host (str | None)

class NodeRecord(address, name, nodedef_id, family_id, instance_id, type='', parent_address=None, pnode=None, enabled=True, flag=0, properties=<factory>, zwave_props=None)[source]

One node from /api/nodes, with property values merged in from /rest/status. The structural fields come from JSON; the properties dict is the merged-in canonical state.

Parameters:
flag: int

Bitfield from the controller’s node table — see pyisyox.constants.NodeFlag for the bit meanings (NEW, IN_ERR, DEVICE_ROOT, …). Sourced from the flag field on /api/nodes JSON (which the controller stringifies — e.g. "128"); 0 when the controller didn’t supply one for this node.

zwave_props: ZWaveProperties | None

Parsed Z-Wave devtype block; None for non-Z-Wave nodes.

class NodePropertyValue(id, value, formatted='', uom='', name='', precision=0)[source]

One live property value (JSON /api/nodes or XML /rest/status).

precision: decimal precision (raw / 10**precision). Wire field is prec; defaults to 0 when omitted.

Parameters:
class GroupRecord(address, name, nodedef_id, family_id, instance_id='1', parent_address=None, pnode=None, member_addresses=(), controller_addresses=(), member_intents=<factory>, targets_resolved=False)[source]

One scene/group. Commands to address broadcast to every member.

Sourced from <group flag="132"> elements; the special flag="12" controller-self group is filtered out at parse time.

Parameters:
member_addresses: tuple[str, ...]

All member node addresses, in declaration order (controllers + responders).

controller_addresses: tuple[str, ...]

Subset of member_addresses whose <link type="16"> flag marks them as scene controllers (rather than responders). Empty when the group has no explicit controller (e.g. SmartLinc-style virtual scenes).

member_intents: dict[str, str]

Per-member scene intent resolved from /api/groups link targets: address → "on" | "off" | "discard". Empty when /api/groups was unavailable or the group wasn’t present there.

targets_resolved: bool

True iff the group’s link targets were found and every link resolved to a known intent (no unknown link type, no native link missing its OL param). When False, consumers fall back to the legacy all-member ST aggregate. A resolved group with no "on" members (fire-only / config-only scene) keeps this True with an empty or all-off/discard member_intents.

class FolderRecord(address, name, family_id='13', parent_address=None)[source]

One folder (organisational, no command surface). Family "13".

Parameters:
  • address (str)

  • name (str)

  • family_id (str)

  • parent_address (str | None)

class ProgramRecord(address, name, path, parent_address, is_folder, status, enabled=None, run_at_startup=None, running=None, last_run_time=None, last_finish_time=None, next_scheduled_run_time=None)[source]

One program or program-folder from /api/programs.

Programs and folders share the flat list, discriminated by is_folder. Status strings "true"/"false" are decoded to bool; empty time strings become None. path is the slash-joined ancestry (excluding the "My Programs" root) to match the pyisy 3.x convention. Timestamps stay as ISO 8601 strings on the record (wire shape preserved); pyisyox.runtime.Program exposes them as parsed tz-aware datetime instances. running is free-form ("idle" / "running then" / the cookbook <s> byte) — the typed Program.run_state / Program.eval_state accessors decode it.

Parameters:
  • address (str)

  • name (str)

  • path (str)

  • parent_address (str | None)

  • is_folder (bool)

  • status (bool)

  • enabled (bool | None)

  • run_at_startup (bool | None)

  • running (str | None)

  • last_run_time (str | None)

  • last_finish_time (str | None)

  • next_scheduled_run_time (str | None)

class VariableRecord(type_id, id, name, value=0, init=0, precision=0, ts='')[source]

One entry from /api/variables/{type}. type_id is "1" (integer) or "2" (state). Wire field val is exposed as value; prec is exposed as precision.

Parameters:
property address: str

Composite {type_id}.{id} identifier.

class NetworkResourceRecord(address, name)[source]

One user-defined HTTP/TCP/UDP fire-trigger from /rest/networking/resources. address is the integer id as a string for URL-path symmetry.

Parameters:

Wire-vocabulary enums used in mutation request bodies:

class NodeType(*values)[source]

Bases: StrEnum

Required nodeType body field on POST /api/nodes/{address}; also the lifecycle-event vocabulary. Legacy XML surface uses numeric codes — see pyisyox.constants.UDHierarchyNodeType.

class VariableField(*values)[source]

Bases: StrEnum

Body keys accepted by POST /api/variables/{type}/{id}; one key per request.

exception ClientError[source]

Base error for client-level failures (HTTP non-2xx, parse errors).

exception HTTPError(status, url)[source]

Non-2xx response after auth retries are exhausted.

Parameters:
Return type:

None

Endpoint paths

REST / WebSocket endpoint paths are centralised in pyisyox.paths — fixed paths as string constants, parametric paths as .format(...) templates. Consumers rarely need these directly (the Controller and IoXClient use them internally), but they’re public for anyone building against the raw wire surface.

Event pipeline

class Event(seqnum, timestamp, control, action, node_address, formatted_action='', formatted_name='', uom='', precision=None, event_info='')[source]

Bases: object

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]

Bases: object

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 WebSocketEventStream(client, dispatcher, path='/rest/subscribe')[source]

Bases: object

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

System-event control + action codes — the wire vocabulary from the ISY994 Developer Cookbook §8.5 (plus IoX-6 additions):

class SystemEventControl(*values)[source]

Bases: StrEnum

IoX WebSocket “system” control codes (underscore-prefixed).

Property updates use the property id ("ST", "GV1", …) with a populated node_address. System events use one of these underscore-prefixed codes with an empty node_address.

Codes _0-_23 are the full ISY-994 set from the ISY994 Developer Cookbook §8.5; _24-_28 are IoX-6 additions (system editors, the modern Z-Wave / ZigBee / Matter drivers, system upgrade) not in that document — tracked from UDI’s internal UDEvents.h taxonomy. Newer IoX firmware may emit further codes; those aren’t enumerated, and label() passes them through verbatim so logs still identify them.

HEARTBEAT = '_0'

Periodic heartbeat. <action> is the duration in seconds until the next expected heartbeat (use it to detect a stalled stream). No <eventInfo>.

TRIGGER = '_1'

Trigger events — program status, variable change/init, schedule change, key/info-string pushes, “get status” refresh signal. <action> discriminates; see TriggerAction.

DRIVER_SPECIFIC = '_2'

Driver-specific events — payload depends on the underlying protocol driver. Not modelled.

NODE_LIFECYCLE = '_3'

Node / scene / folder lifecycle — add / remove / rename / enable / revise / comm-error / etc. <action> carries the verb; see NodeLifecycleAction and NODE_LIFECYCLE_EVENT_INFO_TAGS.

SYSTEM_CONFIG = '_4'

System configuration updated — time / NTP / notifications / batch-mode / battery-write-mode. <action> 0-6; see SystemConfigAction.

SYSTEM_STATUS = '_5'

Controller-side busy/idle/safe-mode status. <action> 0-3; see pyisyox.constants.SystemStatus.

INTERNET_ACCESS = '_6'

Internet-access status — disabled / enabled (<eventInfo> = external URL) / failed. See InternetAccessStatus.

PROGRESS = '_7'

Progress report during long-running operations (device programming, restore, device-adder). <action> 1 / 2.1 / 2.2 / 2.3; see ProgressAction. The _7A / _7M device-write sub-codes also ride through on this control — see DeviceWriteAction.

SECURITY_SYSTEM = '_8'

Security-system event — connected / disconnected / armed-* / disarmed. See SecuritySystemAction.

SYSTEM_ALERT = '_9'

System alert event — “not implemented and should be ignored” per the cookbook.

OPENADR = '_10'

OpenADR / Flex-Your-Power events — ISY994 Z-Series demand-response.

CLIMATE = '_11'

Climate / weather events — required the ISY994 WeatherBug module; not present on eisy.

AMI_SEP = '_12'

AMI/SEP energy events — ISY994 only (see the Energy Management Developer’s Manual).

ENERGY_MONITORING = '_13'

External energy-monitoring (Brultech) — ISY994 only; on later firmware these are folded into node events instead.

UPB_LINKER = '_14'

UPB linker events — UPB-enabled units only.

UPB_DEVICE_ADDER = '_15'

UPB device-adder state — UPB-enabled units only.

UPB_DEVICE_STATUS = '_16'

UPB device-status events — UPB-enabled units only.

GAS_METER = '_17'

Gas-meter events — ISY994 only.

ZIGBEE = '_18'

Legacy ZigBee events — ISY994-era driver. See ZIGBEE_UYB (_27) for the IoX-6+ ZigBee driver used on eisy.

ELK = '_19'

ELK alarm-panel events — requires the ELK module (see the ELK Integration Developer’s Manual).

DEVICE_LINKER = '_20'

Device-linker events — <action> 1 (status) / 2 (cleared). See DeviceLinkerAction.

ZWAVE = '_21'

Legacy Z-Wave integration events — ISY994-era driver. See ZMATTER_ZWAVE (_25) for the IoX-6+ ZMatter Z-Wave driver used on eisy.

BILLING = '_22'

Billing events — ISY994 ZS-series only.

PORTAL = '_23'

Portal events — portal socket-connection / account-registration status when a portal module is installed.

SYSTEM_EDITOR = '_24'

System editor changed — fired when a “system editor” (e.g. _sys_notify_short) is updated. <node> carries the editor name. <action> is SystemEditorAction. IoX-6 addition.

ZMATTER_ZWAVE = '_25'

ZMatter Z-Wave events — IoX-6+ Z-Wave driver on eisy hardware. <action> is dotted ("{category}.{type}"); category numbers are system-status (1), discovery (2), general-status (3), general-error (4), S2 (5), OTA (6), backup/restore (7), device- interview (8), button-detect (9), logger (10). Sub-action details aren’t modelled — the dotted string passes through verbatim. Distinct from ZWAVE (_21, ISY994-era driver).

SYSTEM_UPGRADE = '_26'

System-upgrade lifecycle — <action> is SystemUpgradeAction (active / inactive / available / reboot-required). IoX-6 addition.

ZIGBEE_UYB = '_27'

ZigBee events — IoX-6+ ZigBee driver on eisy hardware. Same dotted "{category}.{type}" action shape as ZMATTER_ZWAVE (minus the logger sub-category). Distinct from ZIGBEE (_18, ISY994-era driver).

MATTER_STATUS = '_28'

Matter network status — IoX-6+ Matter driver. <action> is dotted; active sub-categories are 1 (system status), 2 (discovery), 3 (RX/TX), 8 (device interview). Not in the ISY994 cookbook.

Note: _28 is also reserved in UDI’s source for Profile change events (actions 1-8 — profile/editor/nodedef/linkdef updated/deleted) — but no firmware path fires those today (placeholders since Dec 2024 per UDI). Don’t subscribe expecting them.

classmethod label(control)[source]

Friendly name for a system control code, or the raw code if unknown — so a log line reads node_lifecycle = ND instead of _3 = ND.

Parameters:

control (str)

Return type:

str

class TriggerAction(*values)[source]

Bases: StrEnum

Action codes carried in SystemEventControl.TRIGGER (_1) frames — ISY994 Developer Cookbook §8.5.3. <action> discriminates what the frame is; pyisyox only routes on PROGRAM_STATUS / VARIABLE_VALUE / VARIABLE_INIT.

PROGRAM_STATUS = '0'

Program status changed — handled by _apply_program_status. <eventInfo> carries the program <id>, enabled/run-at-reboot flags, last run/finish times, and a bitwise <s> status.

GET_STATUS = '1'

“Get status” — the controller is telling subscribers to re-poll everything (e.g. after a config change). No payload.

KEY_CHANGED = '2'

A key changed. node carries the key.

INFO_STRING = '3'

An info string. node carries the key; <eventInfo> is the text.

IR_LEARN_MODE = '4'

IR learn mode toggled. No payload.

SCHEDULE = '5'

A schedule’s status changed. node carries the key.

VARIABLE_VALUE = '6'

Variable value changed — handled by _apply_variable_change. <eventInfo> carries <var type id><val><ts>.

VARIABLE_INIT = '7'

Variable init (restore-on-startup) value changed — same handler / payload shape as VARIABLE_VALUE, applied to init.

KEY = '8'

The current subscription key, sent once right after a new subscription is established. <eventInfo> is the key.

VARIABLE_TABLE_CHANGED = '9'

Variable table structurally changed — fires when a variable is added or removed, or when its precision is changed (a metadata change that the per-value VARIABLE_VALUE / VARIABLE_INIT frames don’t cover). <eventInfo> carries <var><type>N</type><id>0</id></var> (id=0 is the wildcard sentinel — “this whole type’s table changed”). The right response is to re-fetch /api/variables/{type} for the affected type so the registry mirrors the controller’s metadata (precision in particular — the wire val from the per-value frames is raw, and a stale precision will mis-render the value). The dispatcher recognises it and fires EventDispatcher.add_variable_table_change_listener() callbacks; the dispatcher itself does not re-fetch.

classmethod label(value)[source]

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

Parameters:

value (str)

Return type:

str

class ProgressAction(*values)[source]

Bases: StrEnum

Action codes on SystemEventControl.PROGRESS (_7) frames — Cookbook §8.5.9. <eventInfo> is free-text progress detail.

UPDATE = '1'

Generic progress update.

DEVICE_ADDER_INFO = '2.1'

Device-adder info (UPB only).

DEVICE_ADDER_WARN = '2.2'

Device-adder warning (UPB only).

DEVICE_ADDER_ERROR = '2.3'

Device-adder error (UPB only).

classmethod label(value)[source]

Friendly lower-case name, or the raw value.

Parameters:

value (str)

Return type:

str

class SystemConfigAction(*values)[source]

Bases: StrEnum

Action codes on SystemEventControl.SYSTEM_CONFIG (_4) frames — Cookbook §8.5.6.

BATCH_MODE_UPDATED = '5'

Batch mode toggled — <eventInfo><status> is "1"/"0".

BATTERY_WRITE_MODE_UPDATED = '6'

Battery-powered-write mode toggled — <eventInfo><status> is "1"/"0".

classmethod label(value)[source]

Friendly lower-case name, or the raw value.

Parameters:

value (str)

Return type:

str

class InternetAccessStatus(*values)[source]

Bases: StrEnum

Action codes on SystemEventControl.INTERNET_ACCESS (_6) frames — Cookbook §8.5.8.

ENABLED = '1'

Enabled — <eventInfo> is the external URL.

classmethod label(value)[source]

Friendly lower-case name, or the raw value.

Parameters:

value (str)

Return type:

str

class SecuritySystemAction(*values)[source]

Bases: StrEnum

Action codes on SystemEventControl.SECURITY_SYSTEM (_8) frames — Cookbook §8.5.10. node and <eventInfo> are null.

classmethod label(value)[source]

Friendly lower-case name, or the raw value.

Parameters:

value (str)

Return type:

str

class DeviceLinkerAction(*values)[source]

Bases: StrEnum

Action codes on SystemEventControl.DEVICE_LINKER (_20) frames — Cookbook §8.5.22 (udievnts.xsd).

STATUS = '1'

Linking status update — <eventInfo> carries device-linker info.

CLEARED = '2'

The device-linking list was cleared. No payload.

classmethod label(value)[source]

Friendly lower-case name, or the raw value.

Parameters:

value (str)

Return type:

str

class NodeLifecycleAction(*values)[source]

Bases: StrEnum

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 DeviceWriteAction(*values)[source]

Bases: StrEnum

Device-write sub-codes that ride through on _7 (SystemEventControl.PROGRESS) frames — PyISY 3.x surfaced these as NodeChangeAction.DEVICE_WRITING / DEVICE_MEMORY.

Unlike the other action enums, these are control-value sub-codes (they have the _ prefix and arrive in the <control> slot), not <action> values — the dispatcher doesn’t route them; they pass through as plain control events. <eventInfo> child tags per code are in DEVICE_WRITE_PROGRESS_EVENT_INFO_TAGS.

PROGRESS = '_7A'

Device-writing progress message — <eventInfo> carries <message>.

MEMORY = '_7M'

Raw Insteon memory write — <eventInfo> carries <memory> / <cmd1> / <cmd2> / <value>. hacs-udi-iox’s backlight entities subscribe to this to catch memory-write echoes.

classmethod label(value)[source]

Friendly lower-case name, or the raw value.

Parameters:

value (str)

Return type:

str

class NodeLifecycleEvent(action, node_address, raw_action, seqnum, node_xml=None, enabled=None)[source]

Bases: object

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).

pyisyox.NODE_LIFECYCLE_EVENT_INFO_TAGS maps each NodeLifecycleAction verb to the <eventInfo> child element names it carries (empty tuple = the frame carries only the node address); pyisyox.DEVICE_WRITE_PROGRESS_EVENT_INFO_TAGS does the same for the DeviceWriteAction (_7A / _7M) device-write sub-codes that ride through on _7 progress frames. Both are reference metadata — pyisyox itself only parses the <node> element on NODE_ADDED.

describe_system_event(control, action)[source]

Render a <control> / <action> pair from a system event frame as a friendly "<control_label> = <action_label>" string.

Resolves both halves to their enum names where one applies:

  • "_5" / "0""system_status = not_busy"

  • "_1" / "0""trigger = program_status"

  • "_3" / "WH""node_lifecycle = pending_device_op"

  • "_4" / "5""system_config = batch_mode_updated"

  • "_8" / "AW""security_system = armed_away"

  • "_20" / "2""device_linker = cleared"

  • "_0" / "90""heartbeat = 90" (action = seconds to the next heartbeat; not enumerated)

  • "_28" / "1.3""matter_status = 1.3" (no enum)

  • "_26" / "2""system_upgrade = inactive"

  • "_24" / "1""system_editor = editor_changed"

  • "_99" / "x""_99 = x" (control we don’t recognise — both halves pass through verbatim)

Intended for the debug logging consumers do over raw event frames (so a line reads system_status = busy instead of system_status = 1); not part of any dispatch path. Property- update frames (non-underscore control) aren’t system events — this just echoes them back unchanged if you pass one.

Parameters:
Return type:

str

describe_system_event() renders a system-event frame’s <control> / <action> pair into a friendly "control_label = action_label" string (e.g. system_status = busy, trigger = program_status, node_lifecycle = programming_device, security_system = armed_away, device_linker = cleared), translating each half against the enums above where one applies. The .label(value) classmethod on each enum does the single-value lookup if you only need one half.

class ProgramStatusEvent(address, status, running, seqnum, run_state=None, eval_state=None, enabled=None, run_at_startup=None)[source]

Bases: object

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:

The listener type aliases are also exported for typing helpers:

EventListener

alias of Callable[[Event], None]

NodeLifecycleListener

alias of Callable[[NodeLifecycleEvent], None]

ProgramStatusListener

alias of Callable[[ProgramStatusEvent], None]

StatusListener

alias of Callable[[EventStreamStatus], None]

Schema (profile / nodedefs / editors)

class Profile(timestamp='', families=<factory>, nodedef_lookup=<factory>, nls=<factory>)[source]

The decoded result of one /rest/profiles JSON blob.

The nodedef_lookup is the load-bearing artifact callers use to resolve a node (which carries family_id, instance_id, and nodeDefId) to its NodeDef.

Parameters:
nls: NLSTable

Merged NLS string table for any dynamically-loaded families (Z-Wave). Empty unless pyisyox.client fetched per-family NLS during load.

classmethod load_from_json(raw)[source]

Build a Profile from a parsed /rest/profiles response.

Parameters:

raw (dict) – Top-level dict with timestamp and families[].

Returns:

A populated Profile with families, instances, and a built lookup table.

Return type:

Profile

merge(other)[source]

Merge other into self in place; return a diff summary.

Designed for PG3 dynamic profile reload. Existing runtime Node instances hold references to the resolved NodeDef, so a wholesale replace would leave them clinging to stale lookups — instead the merge mutates the existing dicts and replaces individual NodeDef/Editor/LinkDef objects. Additive only: items absent from other are kept.

Parameters:

other (Profile)

Return type:

ProfileMergeResult

register_nodedefs(family_id, instance_id, nodedefs)[source]

Add a batch of nodedefs to family_id/instance_id in place.

Used for dynamic Z-Wave nodedefs (fetched from def/get XML — the Z-Wave families already exist in /rest/profiles with their ZW_* editors but no nodedefs). Overwrites by id.

Parameters:
Return type:

None

find_nodedef(nodedef_id, family_id, instance_id)[source]

Resolve a nodedef by its (id, family, instance) join key.

Returns None when no matching nodedef exists — callers should treat that as the unknown-type case (e.g. fall back to the nodedef-driven HA platform classifier rather than the type-based one).

Parameters:
  • nodedef_id (str)

  • family_id (str)

  • instance_id (str)

Return type:

NodeDef | None

find_editor(editor_id, family_id, instance_id)[source]

Resolve an editor by id within a family/instance scope.

Editors are scoped to their instance — the same id (e.g. "bool") may appear in multiple instances with different ranges, so the family/instance must be supplied.

An encoded editor id — one that fully describes its range, e.g. "_51_0_R_0_101_N_IX_DIM_REP" — is decoded directly via Editor.from_encoded_id(); this is how the dynamically- generated Z-Wave nodedefs spell most of their editors. (The check is “does it parse as an encoding”, not just “starts with _” — UDI also ships named editors that begin with _ such as _sys_notify_full, which fall through to the table lookup.)

Table-lookup fallback chain on miss:

  1. family_id / instance_id (the requested scope)

  2. The "common" family / instance "1" — UDI publishes a shared set of editors there (_sys_notify_full, etc.) that any plugin nodedef can reference

Returns None if it’s neither a valid encoding nor present in either scope.

Parameters:
  • editor_id (str)

  • family_id (str)

  • instance_id (str)

Return type:

Editor | None

to_dict()[source]

Flatten the profile to a JSON-compatible dict.

nodedef_lookup is dropped — its (nodedef_id, family_id, instance_id) tuple keys are not JSON-encodable and the same data lives under families[fam].instances[inst].nodedefs. nodedef_lookup_count is surfaced as a sanity-check counter.

pyisyox.schema.editor.EditorRange carries subset: set[int] which JSON can’t encode either; the walker below normalises every set into a sorted list so the snapshot round-trips through json.dumps.

Return type:

dict[str, Any]

class ProfileMergeResult(nodedefs_added=<factory>, nodedefs_replaced=<factory>, editors_added=<factory>, editors_replaced=<factory>, linkdefs_added=<factory>, linkdefs_replaced=<factory>)[source]

Diff produced by Profile.merge().

Variables:
  • nodedefs_added (list[tuple[str, str, str]]) – (nodedef_id, family_id, instance_id) triples for nodedefs that didn’t exist in the destination profile before the merge.

  • nodedefs_replaced (list[tuple[str, str, str]]) – Same shape, for nodedefs whose existing entry was overwritten with a fresh NodeDef. The old object is no longer in Profile.nodedef_lookup; consumers caching it should refresh.

  • editors_replaced (editors_added /) – (editor_id, family_id, instance_id) triples for editors.

  • linkdefs_replaced (linkdefs_added /) – same for linkdefs.

Parameters:
property changed: bool

True when anything differed — nodedefs, editors, or linkdefs.

See pyisyox.schema for the full schema surface (editors, commands, linkdefs, UOMs); the most common consumer path is through profile.find_nodedef(...) / profile.find_editor(...) — exercised under the hood by Node for command validation.

Classifier

classify(nodedef, find_editor=None)[source]

Classify a nodedef into HA platform contributions.

Parameters:
  • nodedef (NodeDef) – The nodedef to classify. Same shape regardless of native vs PG3 plugin origin.

  • find_editor (Callable[[str], Editor | None] | None) – Optional editor resolver, scoped to nodedef’s family/instance. When provided, property readings are split into sensor vs binary_sensor by editor UOM and tagged is_enum for enum editors. When None (e.g. in unit tests), all readings default to sensor with is_enum=False — callers can still render them, just without device-class hints.

Returns:

A ClassificationResult with controllable / triggers / buttons / parameterized_commands / readings / aux_controls populated. find_editor also drives aux_controls candidate platforms; without it a readable writable control falls back to its property’s read classification, and a write-only control falls back to candidate_platform=None for the consumer to resolve.

Return type:

ClassificationResult

class ClassificationResult(controllable=None, controllable_command_ids=<factory>, triggers=<factory>, buttons=<factory>, parameterized_commands=<factory>, readings=<factory>, aux_controls=<factory>)[source]

The set of HA platform contributions for one nodedef.

Variables:
  • controllable (pyisyox.classifier.ControllablePlatform | None) – The controllable platform, or None for a read-only / event-only node.

  • controllable_command_ids (frozenset[str]) – Command ids that belong to the controllable platform (so they aren’t double-counted as buttons). Empty when controllable is None.

  • triggers (list[pyisyox.schema.cmd.Command]) – Commands the node emits — surface as device_trigger.

  • buttons (list[pyisyox.schema.cmd.Command]) – Accept commands pressable with zero args (parameterless, or all parameters optional) — one fire-and-forget button entity each. Excludes QUERY and controllable-claimed cmds.

  • parameterized_commands (list[pyisyox.schema.cmd.Command]) – Accept commands with at least one required parameter. Not plain buttons; left for consumers that map parameter editors to input entities. Same QUERY / controllable exclusions as buttons.

  • readings (list[pyisyox.classifier.Reading]) – Per-property reading entities.

  • aux_controls (list[pyisyox.classifier.AuxControl]) – Coalesced read/write controls (see the module docstring) — the unified successor to readings / parameterized_commands / buttons.

Parameters:
class ControllablePlatform(*values)[source]

Bases: StrEnum

The single controllable HA platform a nodedef may map to.

class ReadingPlatform(*values)[source]

Bases: StrEnum

HA platform for a property reading entity.

class Reading(property, platform, is_enum=False)[source]

A property surfaced as a sensor or binary_sensor entity.

Variables:
Parameters:

Session helpers and exceptions

build_sslcontext(*, use_https, tls_version=None, verify_ssl=False)[source]

Build an ssl.SSLContext for the connection, or None when the controller is reached over HTTP.

Parameters:
  • use_https (bool) – False short-circuits to None.

  • tls_version (float | None) – None (default) auto-negotiates the highest mutually-supported version. 1.2 or 1.3 pin a specific minimum + maximum. Anything else raises.

  • verify_ssl (bool) – False (default) accepts the controller’s self-signed certificate. True enables strict verification — requires consumers to deploy their own CA.

Raises:

TLSVersionError – When tls_version isn’t None / 1.2 / 1.3.

Return type:

SSLContext | None

exception TLSVersionError[source]

Raised when the requested TLS version isn’t usable on this build.

exception ISYConnectionError[source]

Invalid connection parameters provided.

exception ISYInvalidAuthError[source]

Invalid authorization credentials provided.

exception ISYMaxConnections[source]

The isy has disconnected because it reached maximum connections.

exception ISYResponseParseError[source]

Error parsing a response provided by the ISY.

exception ISYStreamDataError[source]

Invalid data in the isy event stream.

exception ISYStreamDisconnected[source]

The isy has disconnected.