pyisyox.controller module¶
High-level handle for an IoX 6+ controller.
Controller is the single user-facing entry point that
composes the lower layers:
pyisyox.auth.Auth— credentials and token lifecycle.pyisyox.client.IoXClient— JSON-first HTTP client with the initial-load orchestrator.pyisyox.runtime.EventDispatcher— parses/rest/subscribeframes and overlays property updates onto the node registry.pyisyox.runtime.WebSocketEventStream— runs the WS read loop with auto-reconnect.pyisyox.runtime.Node— user handles for individual devices, with editor-validatedNode.send_command().
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:
RuntimeErrorRaised 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:
objectTop-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 theEventDispatcherover the same node registry the runtimeNodeinstances 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. PassFalsefor 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 websocket: WebSocketEventStream | None¶
The active WebSocket stream, or
None.Returns the live
WebSocketEventStreamwhenconnect()was called withstart_websocket=Trueandstop()hasn’t run yet.Nonefor one-shot reads (CLI tools, snapshot tests) that opted out of the WS upgrade. Consumers polling stream health (HA system_health, diagnostics) readwebsocket.status/websocket.last_event_atdirectly.
- property config: ControllerConfig¶
Decoded
/api/configslice (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 nodes: dict[str, Node]¶
Map of node address → runtime
Node.Built lazily on first access from the loaded
NodeRecordregistry; 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/nodesXML at connect time. The controller-self group (flag="12") is filtered out.
- 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 variables: dict[str, dict[str, Variable]]¶
Map of variable type → id → typed
Variablewrapper.Outer key is
"1"(integer) or"2"(state); inner key is the variable id within that type. EachVariableshares its underlyingVariableRecordwith 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 thepyisyox -m … --dumpCLI flag and consumer diagnostics. RaisesControllerNotConnectedErrorwhen called beforeconnect()(no loaded state to snapshot).
- async refresh_profile()[source]¶
Re-fetch
/rest/profilesand 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
Profileis mutated in place: existingpyisyox.runtime.Nodeinstances that resolved against a NodeDef before the reload now see the new NodeDef on their next attribute access. The returnedProfileMergeResultlists the lookup-key triples that were added vs replaced so consumers can re-classify or invalidate any caches keyed on nodedef.- Returns:
A
ProfileMergeResultsummarising the diff. Empty (result.changed is False) when the controller’s response was identical to what we had.- Raises:
ControllerNotConnectedError – When called before
connect().ClientError / HTTPError / AuthError – As with any HTTP round-trip.
- Return type:
- 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 afterstop().- 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 afterstop()), or whenconnect()was called withstart_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 callrefresh()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 Trueand 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/runningin place before firing, so consumers readingcontroller.programs[id].statusfrom 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/9VARIABLE_TABLE_CHANGEDframes.These fire on variable create / delete / rename / precision change — not on per-value writes (those use
_1/6and_1/7). TheControlleralready wires its own listener that auto-refreshesself.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
NodeLifecycleEventwithrequires_reload=Trueto absorb the new node tree without re-authenticating. The liveProfileis mutated in place (seeProfile.merge()); thenodes/groups/folders/programs/triggers/variablesregistries on the LoadResult are updated to match the fresh snapshot. The dispatcher’s binding toLoadResult.nodessurvives because we mutate the dict in place.- Returns:
The
ProfileMergeResultfrom the schema merge — useful for tracking which nodedefs changed.- Raises:
ControllerNotConnectedError – When called before
connect().- Return type:
- 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}. Seepyisyox.runtime.ProgramCommandfor the typed command set; bare strings are accepted too (the StrEnum members are themselves strings, soProgramCommand.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:
program_id (str)
command (ProgramCommand | str)
- 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.
- 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>}.
- 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>}.
- async rename_variable(var_type, var_id, name)[source]¶
Rename a variable.
Wire shape:
POST /api/variables/{type}/{id}with{"name": "<str>"}.
- 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
VariableRecordinto the loaded registry in place (so the dispatcher’s binding survives) and returns aVariablewrapper bound to it. Per issue #125, the controller silently dropsinit/valuekeys on PUT — callVariable.set_value()/Variable.set_init()on the returned wrapper to populate them.- Raises:
ControllerNotConnectedError – When called before
connect().ClientError – When the response payload is missing the new id.
- Parameters:
- Return type:
- async refresh_variables(var_type)[source]¶
Re-fetch one variable type and mutate the registry in place.
Wire shape:
GET /api/variables/{type}. Mutatesself._loaded.variables[type]in place (clear + update) so the dispatcher’s binding to the same dict survives — a fullrefresh()would replace the dict and break per-record WS overlay routing.Used internally by the auto-wired
VARIABLE_TABLE_CHANGEDlistener; also callable directly when a consumer wants to force a re-sync.
- async rename_node(address, name)[source]¶
Rename a node.
Wire shape:
POST /api/nodes/{address}with{"name": "<str>", "nodeType": "node"}.The
nodeTypefield is required by the server. Userename_group()for scenes.
- async rename_group(address, name)[source]¶
Rename a group / scene.
Same endpoint as
rename_node()but withnodeType: "group"so the server applies the change through the scene registry.
- async rename_folder(address, name)[source]¶
Rename a folder (organisational container).
Same endpoint as
rename_node()/rename_group()but withnodeType: "folder". Folders are address-keyed like nodes/groups; their addresses are typically 5-digit integers (family"13").