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:
ExceptionBase error for client-level failures (HTTP non-2xx, parse errors).
- exception HTTPError(status, url)[source]¶
Bases:
ClientErrorNon-2xx response after auth retries are exhausted.
- class NodeType(*values)[source]¶
Bases:
StrEnumRequired
nodeTypebody field onPOST /api/nodes/{address}; also the lifecycle-event vocabulary. Legacy XML surface uses numeric codes — seepyisyox.constants.UDHierarchyNodeType.- NODE¶
- GROUP¶
- FOLDER¶
- class VariableField(*values)[source]¶
Bases:
StrEnumBody 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:
objectSubset of
/api/configthat the rest of the load flow needs.
- class NodePropertyValue(id, value, formatted='', uom='', name='', precision=0)[source]¶
Bases:
objectOne live property value (JSON
/api/nodesor XML/rest/status).precision: decimal precision (raw / 10**precision). Wire field isprec; defaults to0when omitted.
- 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:
objectZ-Wave product details from the controller’s
devtypeblock.Surfaces only on Z-Wave / Z-Matter nodes (family ids
"4"/"12"). Sourced from thedevtypeJSON object on/api/nodes:catis the Z-Wave generic-class id (e.g."121"for a multi-channel composite,"155"for a notification sensor);mfgis"<mfr_id>.<prod_type_id>.<product_id>";genis"<basic>.<generic>.<specific>". The split-outbasic_type/generic_type/specific_typeandmfr_id/prod_type_id/product_idare convenience accessors over the same values.Adapted from the legacy
PyISY.helpers.ZWavePropertiesso consumers (notably the hacs-udi-iox device-class lookup) can keep usingnode.zwave_props.categoryinstead of re-parsing the triple.- Parameters:
- classmethod from_devtype(devtype)[source]¶
Parse a
devtypeJSON object — or returnNonefor 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:
objectOne node from
/api/nodes, with property values merged in from/rest/status. The structural fields come from JSON; thepropertiesdict is the merged-in canonical state.- Parameters:
- flag: int¶
Bitfield from the controller’s node table — see
pyisyox.constants.NodeFlagfor the bit meanings (NEW, IN_ERR, DEVICE_ROOT, …). Sourced from theflagfield on/api/nodesJSON (which the controller stringifies — e.g."128");0when the controller didn’t supply one for this node.
- properties: dict[str, NodePropertyValue]¶
- zwave_props: ZWaveProperties | None¶
Parsed Z-Wave
devtypeblock;Nonefor 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:
objectOne scene/group. Commands to
addressbroadcast to every member.Sourced from
<group flag="132">elements; the specialflag="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_addresseswhose<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/groupslink targets: address →"on"|"off"|"discard". Empty when/api/groupswas unavailable or the group wasn’t present there.
- targets_resolved: bool¶
Trueiff the group’s link targets were found and every link resolved to a known intent (no unknown linktype, nonativelink missing itsOLparam). WhenFalse, consumers fall back to the legacy all-memberSTaggregate. A resolved group with no"on"members (fire-only / config-only scene) keeps thisTruewith an empty or all-off/discardmember_intents.
- class FolderRecord(address, name, family_id='13', parent_address=None)[source]¶
Bases:
objectOne folder (organisational, no command surface). Family
"13".
- 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:
objectOne 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 becomeNone.pathis 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.Programexposes them as parsed tz-awaredatetimeinstances.runningis free-form ("idle"/"running then"/ the cookbook<s>byte) — the typedProgram.run_state/Program.eval_stateaccessors decode it.- Parameters:
- class VariableRecord(type_id, id, name, value=0, init=0, precision=0, ts='')[source]¶
Bases:
objectOne entry from
/api/variables/{type}.type_idis"1"(integer) or"2"(state). Wire fieldvalis exposed asvalue;precis exposed asprecision.- Parameters:
- class NetworkResourceRecord(address, name)[source]¶
Bases:
objectOne user-defined HTTP/TCP/UDP fire-trigger from
/rest/networking/resources.addressis the integer id as a string for URL-path symmetry.
- class LoadResult(config, profile, nodes, groups, folders, programs, triggers, variables, network_resources, root_name='')[source]¶
Bases:
objectOutput 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])
variables (dict[str, dict[str, VariableRecord]])
network_resources (dict[str, NetworkResourceRecord])
root_name (str)
- config: ControllerConfig¶
- nodes: dict[str, NodeRecord]¶
- groups: dict[str, GroupRecord]¶
- folders: dict[str, FolderRecord]¶
- programs: dict[str, ProgramRecord]¶
- network_resources: dict[str, NetworkResourceRecord]¶
- class IoXClient(base_url, auth, session)[source]¶
Bases:
objectAuth-aware async HTTP client for IoX 6+ controllers.
- Parameters:
base_url (str)
auth (Auth)
session (aiohttp.ClientSession)
- async connect()[source]¶
Authenticate (if needed) and run the parallel initial load.
- Order:
GET /api/config— synchronous, must succeed before the rest of the calls fire.Authenticate via the auth strategy (no-op for LocalAuth).
Parallel: profiles, nodes, status, programs, triggers, variables/1, variables/2.
Merge
/rest/statusproperties into the node records.
- Returns:
A populated
LoadResult.- Return type:
- 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 bypyisyox.controller.Controller.refresh()(which re-runs the fan-out without re-authenticating).- Parameters:
config (ControllerConfig | None) – Pre-fetched
ControllerConfigto attach to the returned LoadResult. WhenNone, the existing config is re-fetched (cheap — small JSON, no auth).- Returns:
A populated
LoadResult.- Return type:
- 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().addressis URL-quoted.
- 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=Trueswitches to the family-12 path prefix.
- 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-styleCONFIGcommand editor doesn’t model byte size, so this path takes precedence oversend_command("CONFIG", ...)for Z-Wave.
- 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 throughNode.set_zwave_lock_code()’s parser, which raises onsucceeded="false".
- 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.
- 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.
- 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/9VARIABLE_TABLE_CHANGEDinstead of the per-value6/7frames; without an auto-refresh listener wired to that event, downstream consumers won’t notice the precision change until the nextrefresh()).
- 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 theidand echoes the new record back asdata.Note: the eisy controller accepts
init/valuekeys 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 alwaysval=0/init=0regardless of what was sent). Passprechere and follow up withpost_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.
- 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/9VARIABLE_TABLE_CHANGEDframe fires alongside so an auto-refresh listener can drop the entry from the registry.
- 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 (andController.refresh_variables) don’t have to import the private_unwrap_data/parse_api_variables_typehelpers themselves.- Parameters:
- Return type:
- 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}. Seepyisyox.runtime.ProgramCommandfor 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.
- 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.
- 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.nodeTypeis required by the server even though the address already disambiguates.
Returns the parsed response body (a
{successful, data}envelope).
- parse_api_nodes_groups_folders(raw)[source]¶
Decode
/api/nodesJSON into group + folder registries + root name.The JSON payload nests three parallel arrays under
data.nodes—node,group,folder— each entry tagged with anodeTypediscriminator. This walks thegroupandfolderarrays only; nodes are handled byparse_api_nodes().Drop-in replacement for
parse_rest_nodes_groups_folders()(the legacy/rest/nodesXML parser) — same return shape, sameNodeFlag.ROOTfiltering, same controller-vs-respondertype="16"discrimination on group members. Captured live on eisy IoX 6+ confirmed the JSON uses the identical encoding the XML did.The root group (
flagbitNodeFlag.ROOTset — the controller-self pseudo-group whose address is the controller MAC) is filtered out of the returnedgroupsmap; itsnameis 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.
- apply_group_link_targets(groups, api_groups_raw)[source]¶
Enrich
GroupRecord``s in place with ``/api/groupslink targets.For each group the canonical
ctlblock — the one whoseidequals 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_PRECEDENCElink wins when a node appears twice.Sets
member_intents+targets_resolvedon the record. A group not present in/api/groups(older firmware / 404 → empty payload) is lefttargets_resolved=Falseso 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) istargets_resolved=Truewith no"on"members — i.e. reads OFF, matching the admin console.
- parse_api_nodes(raw)[source]¶
Decode the
/api/nodesJSON 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 noproperty[]field — those are filled in bymerge_status_into_nodes().
- parse_rest_status(xml)[source]¶
Decode
/rest/statusXML 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.
- merge_status_into_nodes(nodes, status)[source]¶
Overlay
/rest/statusproperties onto eachNodeRecord.The merge always treats
/rest/statusas authoritative — both native nodes (where Insteon thermostats omit CLISPC/CLISPH/CLIMD/ CLIHCS from/api/nodes) and plugin nodes (which carry noproperty[]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:
nodes (dict[str, NodeRecord])
status (dict[str, dict[str, NodePropertyValue]])
- Return type:
None
- parse_rest_nodes_groups_folders(xml)[source]¶
Decode
/rest/nodesXML into group + folder registries + root name.Node entries (
<node>) in the legacy XML are ignored — the JSON/api/nodesendpoint is the canonical source for those and carries thefamily/instanceshape we need for the nodedef lookup. Only<group>and<folder>elements contribute to the returned dicts.The
flagattribute on<group>/<folder>is the samepyisyox.constants.NodeFlagbitfield used elsewhere (the eisy stringifies it —"12"isIS_A_GROUP | ROOT). The one group withROOTset is the controller-self pseudo-group (its address is the controller MAC, not a user-facing scene) — it’s filtered out of the returnedgroupsmap, 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/getXML 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. Thefamily_id/instance_idare stamped onto eachNodeDefso 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 bypyisyox.schema.editor.Editor.from_encoded_id(); the named ones (ZW_DIM_PERCENT, …) are already in/rest/profilesunder family4.Empty / missing input (no Z-Wave radio) returns
{}. Malformed XML raisesClientError.
- parse_rest_networking_resources(xml)[source]¶
Decode
/rest/networking/resourcesXML 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:
- parse_api_variables_type(raw, type_id)[source]¶
Decode one
/api/variables/{type}datalist 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 asVariableRecord.valueso consumers don’t have to track the wire spelling. Entries without anidare skipped.- Parameters:
- Returns:
Map of variable id (string) →
VariableRecord.- Return type:
- parse_api_programs(raw)[source]¶
Decode the
/api/programsdatalist into typed records.Reconstructs each entry’s
pathby walking theparentIdchain — the wire payload is a flat list, but consumers expect a slash-joined ancestry to drive the legacyHA.<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 asFalse. Empty time strings collapse toNone.Folders inherit
statusfrom the eisy-side aggregation but don’t carryenabled/run_at_startup/running/ timing fields — those stayNoneon the record.