pyisyox.client module

JSON-first HTTP client for IoX 6+ controllers.

Orchestrates the initial load (/api/config/rest/profiles → parallel fan-out of nodes/status/programs/triggers/variables) and exposes mutation methods. Auth-mode-agnostic — accepts any pyisyox.auth.Auth and retries once on 401 if recovery succeeds.

Total: ≤ 6 HTTP + 1 WebSocket regardless of node-server count (/rest/nodes was dropped from the fan-out in #127 — its group / folder data is fully covered by /api/nodes JSON).

The remaining legacy XML surfaces are /rest/status, /rest/nodes/{addr}/cmd/... responses, and /rest/subscribe event frames; stdlib xml.etree.ElementTree covers all three.

exception ClientError[source]

Bases: Exception

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

exception HTTPError(status, url)[source]

Bases: ClientError

Non-2xx response after auth retries are exhausted.

Parameters:
Return type:

None

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.

NODE
GROUP
FOLDER
class VariableField(*values)[source]

Bases: StrEnum

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

VALUE
INIT
NAME
PREC
class ControllerConfig(uuid, version, portal_host=None)[source]

Bases: object

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

Parameters:
  • uuid (str)

  • version (str)

  • portal_host (str | None)

uuid: str
version: str
portal_host: str | None
class NodePropertyValue(id, value, formatted='', uom='', name='', precision=0)[source]

Bases: object

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:
id: str
value: str
formatted: str
uom: str
name: str
precision: int
class ZWaveProperties(category='0', devtype_mfg='0.0.0', devtype_gen='0.0.0', basic_type='0', generic_type='0', specific_type='0', mfr_id='0', prod_type_id='0', product_id='0')[source]

Bases: object

Z-Wave product details from the controller’s devtype block.

Surfaces only on Z-Wave / Z-Matter nodes (family ids "4" / "12"). Sourced from the devtype JSON object on /api/nodes: cat is the Z-Wave generic-class id (e.g. "121" for a multi-channel composite, "155" for a notification sensor); mfg is "<mfr_id>.<prod_type_id>.<product_id>"; gen is "<basic>.<generic>.<specific>". The split-out basic_type / generic_type / specific_type and mfr_id / prod_type_id / product_id are convenience accessors over the same values.

Adapted from the legacy PyISY.helpers.ZWaveProperties so consumers (notably the hacs-udi-iox device-class lookup) can keep using node.zwave_props.category instead of re-parsing the triple.

Parameters:
  • category (str)

  • devtype_mfg (str)

  • devtype_gen (str)

  • basic_type (str)

  • generic_type (str)

  • specific_type (str)

  • mfr_id (str)

  • prod_type_id (str)

  • product_id (str)

category: str
devtype_mfg: str
devtype_gen: str
basic_type: str
generic_type: str
specific_type: str
mfr_id: str
prod_type_id: str
product_id: str
classmethod from_devtype(devtype)[source]

Parse a devtype JSON object — or return None for any non-mapping input (Insteon nodes don’t carry one).

Parameters:

devtype (Any)

Return type:

ZWaveProperties | 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]

Bases: object

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:
address: str
name: str
nodedef_id: str
family_id: str
instance_id: str
type: str
parent_address: str | None
pnode: str | None
enabled: bool
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.

properties: dict[str, NodePropertyValue]
zwave_props: ZWaveProperties | None

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

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]

Bases: object

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:
address: str
name: str
nodedef_id: str
family_id: str
instance_id: str
parent_address: str | None
pnode: str | None
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]

Bases: object

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

Parameters:
  • address (str)

  • name (str)

  • family_id (str)

  • parent_address (str | None)

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]

Bases: object

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)

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]

Bases: object

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:
type_id: str
id: str
name: str
value: int | float
init: int | float
precision: int
ts: str
property address: str

Composite {type_id}.{id} identifier.

class NetworkResourceRecord(address, name)[source]

Bases: object

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:
address: str
name: str
class LoadResult(config, profile, nodes, groups, folders, programs, triggers, variables, network_resources, root_name='')[source]

Bases: object

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

Parameters:
config: ControllerConfig
profile: Profile
nodes: dict[str, NodeRecord]
groups: dict[str, GroupRecord]
folders: dict[str, FolderRecord]
programs: dict[str, ProgramRecord]
triggers: list[dict[str, Any]]
variables: dict[str, dict[str, VariableRecord]]
network_resources: dict[str, NetworkResourceRecord]
root_name: str
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]

parse_api_nodes_groups_folders(raw)[source]

Decode /api/nodes JSON into group + folder registries + root name.

The JSON payload nests three parallel arrays under data.nodesnode, group, folder — each entry tagged with a nodeType discriminator. This walks the group and folder arrays only; nodes are handled by parse_api_nodes().

Drop-in replacement for parse_rest_nodes_groups_folders() (the legacy /rest/nodes XML parser) — same return shape, same NodeFlag.ROOT filtering, same controller-vs-responder type="16" discrimination on group members. Captured live on eisy IoX 6+ confirmed the JSON uses the identical encoding the XML did.

The root group (flag bit NodeFlag.ROOT set — the controller-self pseudo-group whose address is the controller MAC) is filtered out of the returned groups map; its name is surfaced as the third return value so consumers can use the user-assigned controller label (e.g. "Main eisy") for device naming — same source the legacy /rest/config/<configuration><root><name> path carried in PyISY 3.x. Returns an empty string when the root group is absent or unnamed.

Parameters:

raw (dict[str, Any])

Return type:

tuple[dict[str, GroupRecord], dict[str, FolderRecord], str]

Enrich GroupRecord``s in place with ``/api/groups link targets.

For each group the canonical ctl block — the one whose id equals the group address — is the scene’s own responder definition (per-controller blocks describe cross-controller behaviour, not the scene’s resting target, so they’re ignored). Each link there resolves via _link_intent(); the highest _LINK_PRECEDENCE link wins when a node appears twice.

Sets member_intents + targets_resolved on the record. A group not present in /api/groups (older firmware / 404 → empty payload) is left targets_resolved=False so the consumer keeps the legacy all-member behaviour. A present group whose canonical block has no on-target links (fire-only / config-only / the special auto-DR groups) is targets_resolved=True with no "on" members — i.e. reads OFF, matching the admin console.

Parameters:
Return type:

None

parse_api_nodes(raw)[source]

Decode the /api/nodes JSON payload into a map of address → record.

The wire shape is double-nested as data.nodes.node[] (preserved from the legacy XML element layout). Plugin nodes carry no property[] field — those are filled in by merge_status_into_nodes().

Parameters:

raw (dict[str, Any])

Return type:

dict[str, NodeRecord]

parse_rest_status(xml)[source]

Decode /rest/status XML into {address: {prop_id: Property}}.

The shape is a flat <nodes><node id="..."><property id="..." value="..." formatted="..." uom="..." name=""/>...</node>...</nodes>. Empty values (value="") are preserved — callers should treat them as “controller has no value yet” rather than dropping the property.

Parameters:

xml (str)

Return type:

dict[str, dict[str, NodePropertyValue]]

merge_status_into_nodes(nodes, status)[source]

Overlay /rest/status properties onto each NodeRecord.

The merge always treats /rest/status as authoritative — both native nodes (where Insteon thermostats omit CLISPC/CLISPH/CLIMD/ CLIHCS from /api/nodes) and plugin nodes (which carry no property[] field at all). Status properties replace any existing JSON-side properties of the same id; status-only properties are inserted; properties present only in the JSON tree are kept.

Parameters:
Return type:

None

parse_rest_nodes_groups_folders(xml)[source]

Decode /rest/nodes XML into group + folder registries + root name.

Node entries (<node>) in the legacy XML are ignored — the JSON /api/nodes endpoint is the canonical source for those and carries the family / instance shape we need for the nodedef lookup. Only <group> and <folder> elements contribute to the returned dicts.

The flag attribute on <group> / <folder> is the same pyisyox.constants.NodeFlag bitfield used elsewhere (the eisy stringifies it — "12" is IS_A_GROUP | ROOT). The one group with ROOT set is the controller-self pseudo-group (its address is the controller MAC, not a user-facing scene) — it’s filtered out of the returned groups map, but its <name> is surfaced as the third return value so consumers can use the user-assigned controller name (e.g. “Main eisy”) for device naming. Returns an empty string when the root group is absent or unnamed.

Parameters:

xml (str)

Return type:

tuple[dict[str, GroupRecord], dict[str, FolderRecord], str]

parse_zwave_nodedefs(xml, *, family_id, instance_id)[source]

Decode /rest/zwave/node/{addr}/def/get XML into {id: NodeDef}.

The dynamically-generated Z-Wave nodedefs aren’t carried by /rest/profiles; this endpoint serves them in the legacy <nodeDefs><nodedef id="UZW..." nls="..."><sts><st id="ST" editor="..."/></sts><cmds><sends/><accepts><cmd .../></accepts></cmds> <links><ctl/><rsp><link linkdef="..."/></rsp></links></nodedef></nodeDefs> shape. The family_id / instance_id are stamped onto each NodeDef so it joins against the node’s (nodeDefId, family, instance) key. Many referenced editors are encoded ids (_51_0_R_0_101_N_IX_DIM_REP) decoded on demand by pyisyox.schema.editor.Editor.from_encoded_id(); the named ones (ZW_DIM_PERCENT, …) are already in /rest/profiles under family 4.

Empty / missing input (no Z-Wave radio) returns {}. Malformed XML raises ClientError.

Parameters:
  • xml (str)

  • family_id (str)

  • instance_id (str)

Return type:

dict[str, NodeDef]

parse_rest_networking_resources(xml)[source]

Decode /rest/networking/resources XML into a record map.

Wire shape (from eisy / ISY 6+ legacy endpoint, also produced by ISY-994 firmware ≥ 4.x):

<NetConfig>
  <NetRule>
    <id>1</id>
    <name>Reboot Router</name>
    <host>192.0.2.1</host>
    <!-- ...other fields the runtime doesn't surface... -->
  </NetRule>
</NetConfig>

Empty / missing input (controller without networking module enabled) returns {}.

Network resources are an optional, non-critical part of the tree, so a malformed document never aborts the controller load (issue #156). The common eisy firmware bug — an unescaped & in a resource URL/body — is repaired best-effort and the resources are recovered (logged at WARNING). Anything that still cannot be parsed degrades to {} (logged at ERROR); the rest of the controller is unaffected.

Parameters:

xml (str)

Return type:

dict[str, NetworkResourceRecord]

parse_api_variables_type(raw, type_id)[source]

Decode one /api/variables/{type} data list into typed records.

Each wire entry is:

{"id": "<int>", "val": <int>, "init": <int>, "prec": <int>,
 "name": "<str>", "ts": "<ISO8601>"}

The wire field for the current value is val; this surfaces it as VariableRecord.value so consumers don’t have to track the wire spelling. Entries without an id are skipped.

Parameters:
  • raw (list[dict[str, Any]]) – The unwrapped data list from /api/variables/{type}.

  • type_id (str) – "1" (integer) or "2" (state). Stamped onto each record so callers can route writes back to the right /api/variables/{type}/{id} endpoint without carrying the type alongside.

Returns:

Map of variable id (string) → VariableRecord.

Return type:

dict[str, VariableRecord]

parse_api_programs(raw)[source]

Decode the /api/programs data list into typed records.

Reconstructs each entry’s path by walking the parentId chain — the wire payload is a flat list, but consumers expect a slash-joined ancestry to drive the legacy HA.<platform>/<name>/<status|actions> folder convention. The synthetic root folder name ("My Programs" on stock eisy firmware) is dropped from paths so the leading segment is the user’s first folder.

Status comes off the wire as the strings "true" / "false" (legacy XML convention preserved); empty / missing strings are treated as False. Empty time strings collapse to None.

Folders inherit status from the eisy-side aggregation but don’t carry enabled / run_at_startup / running / timing fields — those stay None on the record.

Parameters:

raw (list[dict[str, Any]])

Return type:

dict[str, ProgramRecord]