pyisyox.controller module

High-level handle for an IoX 6+ controller.

Controller is the single user-facing entry point that composes the lower layers:

A typical consumer (HA Core, hacs-isy994, a CLI) constructs one Controller, await``s :meth:`connect`, then drives nodes through ``controller.nodes[address].send_command(...) and subscribes to event/status callbacks. WebSocket frames mutate controller.nodes[...].properties in place, so attribute reads always reflect the latest controller state.

exception ControllerNotConnectedError[source]

Bases: RuntimeError

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

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