Connection Flow¶
This document describes how PyISYoX establishes and maintains a connection to an eisy / Polisy controller running IoX 6.0.0+. It covers the wire-level endpoint sequence, the parallel load orchestration, authentication, and the WebSocket reader’s state machine.
If you only want to use the library, start with the
quickstart; this document is for understanding what
Controller.connect() does under the hood, debugging connection
issues, or porting the protocol to another language.
Architectural layers¶
┌────────────────────────────────────────────────────────────┐
│ pyisyox.Controller (controller.py) │
│ glue: lifecycle, listener registration, helper mutations │
└──────┬──────────────────────────────────────┬──────────────┘
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────────────────┐
│ IoXClient (client.py)│ │ WebSocketEventStream │
│ parallel load fan- │ │ (runtime/ws.py) │
│ out, JSON-first; │ │ read loop + reconnect │
│ retries on 401 │ │ + status listeners │
└──────┬──────────┬────┘ └─────────────┬────────────┘
│ │ │
▼ ▼ ▼
┌──────────┐ ┌───────────┐ ┌──────────────────────┐
│ Auth │ │ Profile │ │ EventDispatcher │
│ (auth.py)│ │ (schema/) │ │ (runtime/events.py)│
│ Portal / │ │ nodedefs, │ │ parses frames, │
│ LocalAuth│ │ editors │ │ applies to records, │
│ │ │ │ │ fires listeners │
└──────────┘ └───────────┘ └──────────────────────┘
The layers are deliberately decoupled. IoXClient is auth-mode-
agnostic — it takes any Auth implementation. EventDispatcher is
transport-agnostic — WebSocketEventStream is one feeder; tests inject
synthetic frames directly via Controller.feed_event_frame.
Picking an auth mode¶
The eisy exposes two listeners with different feature sets:
Mode |
Port |
Credentials |
Wire auth |
Surface |
|---|---|---|---|---|
PortalAuth |
|
Portal email + password |
JWT bearer (auto-refresh) |
|
LocalAuth |
|
Local admin user + pass |
HTTP basic |
|
PortalAuth is the recommended default. It unlocks the modern JSON
/api/triggers AST and /api/variables (with names + timestamps), and
the eisy validates the credentials and signs the JWT locally — no
my.isy.io round-trip during steady-state operation. Verified
offline-safe on 2026-05-07.
LocalAuth exists for users who refuse to use a portal account. It has no login round-trip (basic on every request) but cannot read the modern JSON endpoints; PyISYoX falls back to legacy XML where needed.
The smoke-test CLI picks the mode based on the username (anything
with @ is treated as a portal email):
python3 -m pyisyox https://eisy.local:443 me@example.com portal-pw
python3 -m pyisyox https://eisy.local:8443 admin admin-pw
PortalAuth lifecycle¶
Login —
POST /api/loginbody{"username": "<email>", "password": "<password>"}. Response is{"successful": true, "data": {"accessToken": "<es256-jwt>", "refreshToken": "<es256-jwt>", "ssl": {…}, …}}. The library decodes the JWTs’expclaims for proactive refresh scheduling.Security note: the login response leaks the PG3 MQTT TLS keypair under
data.ssl. PyISYoX’sredact_sensitive()helper scrubs it before any debug logging.Per-request — every HTTP call carries
Authorization: Bearer <accessToken>. If the token expires within the next 60 seconds (PROACTIVE_REFRESH_LEEWAY), the client refreshes before the request to avoid the cost of an in-flight 401 + refresh + retry.Refresh —
POST /api/jwt/refreshbody{"refreshToken": "<rt>"}, same response shape as login.401 recovery — on a 401, the client asks the auth strategy to recover (
handle_unauthorized). PortalAuth tries refresh; if that fails, it falls back to a full login. Concurrent 401s collapse onto a single refresh round-trip via an internal lock.Logout —
Controller.stop()best-effort postsPOST /api/logoutto invalidate the server-side session. Any error is logged at debug level and swallowed; the long-lived refresh token will expire naturally on its TTL.
LocalAuth lifecycle¶
No login round-trip — every request carries Authorization: Basic …
attached via aiohttp.BasicAuth. A 401 means the credentials are
wrong, so re-auth cannot recover and the client raises
AuthError to the caller.
The connect() call¶
await controller.connect() runs three phases:
Phase 1 — Config¶
GET /api/config → {"data": {"uuid": "...", "version": "6.0.0", "portalHost": "..."}}
Cheap, unauthenticated. Confirms the controller is reachable and is
running an IoX 6+ firmware. The returned ControllerConfig is
attached to the LoadResult.
Phase 2 — Authenticate¶
auth.authenticate(session, base_url) runs exactly once across
concurrent first-use callers (lock-then-recheck inside IoXClient).
LocalAuth — no-op.
PortalAuth —
POST /api/login, stores theTokenPair.
Phase 3 — Parallel load fan-out¶
asyncio.gather(...) fires the next nine requests in parallel:
# |
Endpoint |
Shape |
Used for |
|---|---|---|---|
1 |
|
JSON |
The |
2 |
|
JSON |
Node structure (family/instance, addresses, parent/pnode, flags). Plugin nodes have no |
3 |
|
XML |
Group / folder structure ( |
4 |
|
XML |
Canonical property table for every node — including plugin nodes. Merged into the JSON node records to fill the missing |
5 |
|
JSON |
Programs and program-folders (one flat list, discriminated by |
6 |
|
JSON |
Program AST. Stays raw for consumers. |
7 |
|
JSON |
Integer variables with names + timestamps. |
8 |
|
JSON |
State variables. |
9 |
|
XML |
Network resources, if the module is enabled. A 404 / 503 here is tolerated — the load doesn’t abort if the module isn’t installed. |
Total: 8–9 HTTP requests in parallel, plus the config call from Phase 1, plus (if requested) the WebSocket upgrade.
After the gather:
Profile.load_from_json(profile_raw)parses the family/instance tree and builds the(nodedef_id, family_id, instance_id) → NodeDeflookup.The
/rest/statusoverlay is merged into the/api/nodesrecords viamerge_status_into_nodes. Native nodes get any missing properties filled in; plugin nodes (which carry noproperty[]in the JSON) get all their properties from the overlay.Programs, variables, network resources, and groups/folders are parsed into their record types.
The result is one LoadResult dataclass with all the data the
runtime wrappers need. Each wrapper holds a reference to its record,
so WebSocket frames that mutate the record in place are visible to the
wrapper without an explicit notification path.
401 recovery during load¶
IoXClient._get_text retries the request once on 401 if
auth.handle_unauthorized returns True. After the retry attempt is
spent (or recovery returns False), the next 401 raises AuthError.
Any non-2xx (after the optional retry) raises HTTPError with the
status and URL — the parallel asyncio.gather propagates the first
exception.
WebSocket upgrade¶
Controller.connect(start_websocket=True) (the default) constructs a
WebSocketEventStream and calls start(), which schedules a
background asyncio.Task.
Connect handshake¶
Build the WS URL by rewriting the base URL’s scheme (
https://→wss://) and appending the configured path (default/rest/subscribe; the modern JSON-envelope path/api/events/subscribeis opt-in).Pull
auth.request_kwargs(session, base_url)and pass them tosession.ws_connect(...).LocalAuthreturns{"auth": aiohttp.BasicAuth(...)}— aiohttp’sws_connectacceptsauthdirectly.PortalAuthreturns{"headers": {"Authorization": "Bearer …"}}, passed through verbatim.On success, transition to
EventStreamStatus.CONNECTEDand notify any status listeners.
Read loop¶
async for msg in ws:
if msg.type == aiohttp.WSMsgType.TEXT:
event = dispatcher.feed(msg.data)
last_event_at = now()
elif msg.type in (CLOSE, ERROR):
break
Each dispatcher.feed(raw):
Parses the frame (XML or JSON-envelope; non-event JSON frames like PG3
spolisyare ignored).Applies the update to the underlying record in
LoadResult— property dict, program status, or variable record — before firing listeners.Emits the parsed
Event(and, if applicable, aNodeLifecycleEventorProgramStatusEvent) to subscribers.
See events.rst for the full event taxonomy.
Reconnection¶
On transport error or unexpected close, the reader backs off through a fixed schedule and tries again:
backoff: 1s → 2s → 5s → 10s → 30s → 60s (capped at 60s thereafter)
The schedule resets after a successful read. Status listeners see
EventStreamStatus.RECONNECTING while we’re in the backoff loop and
EventStreamStatus.CONNECTED once a fresh handshake succeeds.
A 401-class WebSocket handshake failure triggers auth.handle_unauthorized
to refresh tokens before the next reconnect attempt — PortalAuth
recovers in the background; LocalAuth’s handle_unauthorized returns
False, which lets the next attempt’s basic-auth header carry the
(possibly updated) credentials.
WebSocket health surface¶
For consumers wanting to surface stream health to the user (HA’s
system_health card, diagnostics dumps, etc.), the live stream is
exposed via Controller.websocket:
ws = controller.websocket
if ws is not None:
print(ws.status) # CONNECTING / CONNECTED / RECONNECTING / DISCONNECTED
print(ws.connected) # bool
print(ws.last_event_at) # datetime in UTC, or None
Controller.websocket is None for one-shot reads
(connect(start_websocket=False)) and after stop().
Refresh and dynamic profile reload¶
Two methods on Controller let consumers absorb controller-side
changes without re-authenticating:
refresh()— re-runs Phase 3 (the parallel load fan-out) and merges the fresh data into the liveLoadResult. The dispatcher’s binding toLoadResult.nodessurvives because the dict is mutated in place. Call this after aNodeLifecycleEvent.requires_reloadsignal.refresh_profile()— re-fetches just/rest/profilesand merges the result into the liveProfile. Designed for PG3 dynamic profile reload (a plugin updates its nodedefs at runtime). Returns aProfileMergeResultlisting the added vs replaced nodedef keys so consumers can re-classify or invalidate caches.
Shutdown¶
await controller.stop() is symmetric and idempotent:
Stop the WebSocket reader (cancel the task, close the WS).
auth.close(session, base_url)— PortalAuth posts/api/logout; LocalAuth no-ops.Close the aiohttp session if the controller owns it (when
session=Nonewas passed to the constructor). Sessions injected by the consumer are not closed.Drop the loaded snapshot so any post-stop accessor raises
ControllerNotConnectedErrorinstead of returning stale data.
Errors during the auth.close step are swallowed at debug level — shutdown should never raise.
TLS¶
The eisy ships with a self-signed certificate. Controller’s default
verify_ssl=False accepts it; pass verify_ssl=True if the user has
installed their own CA. Pass tls_version=1.2 or tls_version=1.3 to
pin the negotiated version (default: auto-negotiate; TLS 1.0 / 1.1 are
rejected by the eisy regardless).
The tls_version and verify_ssl parameters apply only when the
controller creates its own aiohttp.ClientSession. Consumers that
inject their own session are responsible for configuring SSL on it.