Quickstart

This is a guided tour of the public API. Every example assumes you are inside an async def and that pyisyox is installed.

Installation

pip install pyisyox

PyISYoX needs Python 3.11+ and connects to an eisy or Polisy controller running IoX 6.0.0+.

Pick an auth mode

The controller exposes two HTTP endpoints with different auth shapes and different feature sets. Pick once at construction time:

Portal mode (recommended) — port :443 with the my.universal-devices.com portal email and password. PyISYoX trades those credentials for a short-lived JWT (POST /api/login) and refreshes it automatically. The eisy validates the portal credentials and signs the JWT locally; there is no my.isy.io round-trip during steady-state operation. Unlocks the modern /api/* JSON endpoints (triggers AST, variable names, program metadata).

from pyisyox import PortalAuth
auth = PortalAuth("me@example.com", "portal-password")

Local mode — port :8443 with the local admin username and password. HTTP basic on every request; no login round-trip. No portal account required, but feature-degraded: no /api/triggers AST, no /api/variables names; must fall back to the legacy /rest/nodes XML for structure. Recommended only if the user refuses to use a portal account.

from pyisyox import LocalAuth
auth = LocalAuth("admin", "admin-password")

Connect

pyisyox.Controller is the one entry point. Construction is cheap and synchronous; network round-trips happen in connect().

from pyisyox import Controller, PortalAuth

controller = Controller(
    "https://eisy.local:443",          # :443 for portal, :8443 for local
    PortalAuth("me@example.com", "pw"),
)
await controller.connect()             # auth + 7 parallel HTTP calls + WS upgrade
try:
    ...                                # use controller.nodes, .programs, etc.
finally:
    await controller.stop()            # symmetric: cancels WS, closes session

connect() does three things in order:

  1. GET /api/config to retrieve uuid / version / portal host.

  2. Authenticate (login + cache JWT, or no-op for LocalAuth).

  3. Run a parallel fan-out across /rest/profiles, /api/nodes, /rest/status, /api/programs, /api/triggers, /api/variables/1, /api/variables/2 and merge the status overlay into the node registry.

By default it then opens a WebSocket against /rest/subscribe and runs a background reader that mutates the same node / program / variable records in place as events arrive. Pass start_websocket=False for one-shot reads (CLI tools, snapshot tests).

See Connection Flow for the full sequence (endpoints, retries, event routing).

What you can read after connect

Once connect() returns, the public accessors are populated:

  • controller.configpyisyox.ControllerConfig (uuid, version, portal_host).

  • controller.nodesdict[str, Node] keyed by wire address.

  • controller.groupsdict[str, Group] (IoX scenes).

  • controller.folders — organisational folders (no command surface).

  • controller.programs / controller.program_folders — typed Program / ProgramFolder wrappers, keyed by 4-character hex id.

  • controller.variablesdict[str, dict[str, Variable]]; outer key is type id ("1" integer, "2" state), inner key is variable id.

  • controller.network_resources — network-module fire triggers.

  • controller.triggers — raw /api/triggers JSON list (program AST). Programs themselves are wrapped; the AST stays raw for consumers that want to introspect the rule logic.

  • controller.profile — the decoded pyisyox.Profile (nodedefs + editors + linkdefs) with lookup helpers.

Accessing any of these before connect() (or after stop()) raises pyisyox.ControllerNotConnectedError.

Controlling a node

Use the wire address (the value of the <address> element in /api/nodes). Insteon addresses look like "3D 7D 87 1"; Z-Wave addresses are short hex; plugin addresses are the plugin slot prefix ("n010_84dd4c2c24c3b7").

node = controller.nodes["3D 7D 87 1"]
await node.send_command("DON")            # turn fully on
await node.send_command("DON", 75)        # turn on at 75 %
await node.send_command("DOF")            # turn off

send_command() validates the parameters through the editor codec on the node’s nodedef before hitting HTTP — out-of-range levels, unknown command ids, or wrong parameter counts raise pyisyox.NodeCommandError with no traffic sent. Enum names work alongside integers:

thermostat = controller.nodes["1A 2B 3C 1"]
await thermostat.send_command("CLIMD", "Heat")     # accepts enum name
await thermostat.send_command("CLISPC", 72.0)      # codec scales by precision

Thin ergonomic wrappers are available for the common Insteon / Z-Wave commands — set_climate_mode(), set_climate_setpoint_heat(), set_on_level(), set_ramp_rate(), secure_lock(), etc. — each is a one-liner over send_command() with the wire-level command id baked in.

Reading live state

Every node carries a properties dict (keyed by property id — "ST", "OL", "RR", …) that the WebSocket dispatcher updates in place:

node = controller.nodes["3D 7D 87 1"]
st = node.status                          # shortcut for properties["ST"]
print(st.value, st.formatted, st.uom)     # raw, display, UOM id

Derived introspection helpers — protocol, is_dimmable, is_thermostat, is_lock, is_fan, is_battery_node — let consumers branch on capability without hardcoding type strings.

Sub-buttons (KeypadLinc, RemoteLinc, FanLinc) carry the device primary’s address in primary_address; primaries return None. node.primary_address or node.address gives the canonical device-grouping key.

Controlling a scene (group)

Scenes use the same wire shape as nodes (GET /rest/nodes/{addr}/cmd/...); addresses are typically 5-digit integer strings. Group commands are not editor-validated (there’s no nodedef-level codec for scene commands), so encode parameters as integers up-front:

scene = controller.groups["28614"]
await scene.send_command("DON")
await scene.send_command("DON", 100)
await scene.send_command("DOF")

Programs and program folders

Both share the controller’s flat program list and the /rest/programs/{id}/{command} endpoint, but folders only support a subset of verbs (run / stop / enable / disable). The typed split means consumers can branch on isinstance rather than an is_folder flag:

from pyisyox import ProgramCommand

program = controller.programs["005E"]
await program.run()                                # → ProgramCommand.RUN
await program.run_then()                           # → ProgramCommand.RUN_THEN
await program.send_command(ProgramCommand.STOP)

folder = controller.program_folders["0061"]
await folder.run()                                 # whole folder

Variables

Variable type 1 is integer; type 2 is state. Both are typed pyisyox.Variable wrappers with three mutation coroutines (set_value / set_init / rename) that route through POST /api/variables/{type}/{id}. The wrapper’s record is mutated in place after a successful write, so reads reflect the new value without waiting for the corresponding WebSocket frame:

var = controller.variables["2"]["14"]
await var.set_value(6)
print(var.value)        # 6

state_var = controller.variables["1"]["3"]
await state_var.set_init(0)

Network resources

Network resources are user-defined HTTP / TCP / UDP fire-triggers on the controller. Fire by id:

await controller.network_resources["5"].run()
# equivalently:
await controller.run_network_resource(5)

The controller acknowledges receipt only — it does not report the result of the underlying fire.

Subscribing to events

The WebSocket reader pushes every parsed frame through three listener channels. Each add_*_listener call returns an unsubscribe function:

def on_event(ev):
    print(ev.seqnum, ev.control, ev.action, ev.node_address)

def on_status(status):
    print("WS:", status)

def on_node_lifecycle(ev):
    if ev.requires_reload:
        print("reload needed:", ev.action, ev.node_address)

unsub_event = controller.add_event_listener(on_event)
unsub_status = controller.add_status_listener(on_status)
unsub_life = controller.add_node_lifecycle_listener(on_node_lifecycle)
# ...later
unsub_event()

Dispatcher semantics: property updates are applied to the underlying node record before listeners fire, so callbacks can read controller.nodes[address].properties[id] synchronously and see the new value. The same applies to program-status and variable-change frames — the record is mutated first, then listeners are notified.

Node-tree changes (add / remove / rename) are not auto-merged into the live registry. The dispatcher emits a pyisyox.NodeLifecycleEvent; consumers decide whether to call refresh() to absorb the change. See Event Pipeline for the full taxonomy.

CLI smoke test

The package ships a small CLI for connectivity checks:

python3 -m pyisyox https://eisy.local:443 me@example.com portal-password
python3 -m pyisyox https://eisy.local:8443 admin admin-password --no-events

The URL determines the auth mode — port :443 triggers PortalAuth, :8443 triggers LocalAuth (the choice is also keyed on whether the username looks like an email). Pass --no-events to skip the WebSocket reader; useful when you only want a one-shot inventory.

Cleanly shutting down

stop() is idempotent and symmetric. It:

  1. Cancels the WebSocket reader and closes the underlying connection.

  2. Best-effort posts /api/logout to invalidate the portal session (so the long-lived refresh token can’t be reused — LocalAuth no-ops here).

  3. Closes the aiohttp session if the controller owns it (when session=None was passed to the constructor; consumers that inject their own session are responsible for closing it).

It is safe to call from cleanup paths even if connect() partially failed.

What’s next

  • Library Reference — full reference of the public types in this module.

  • Connection Flow — endpoint-by-endpoint connect sequence, retry logic, and the WebSocket state machine.

  • Event Pipeline — the event taxonomy, dispatcher contract, and WebSocket health surface.

  • API Reference — the auto-generated module-level API reference.