Conclave push channel¶
aioafero 9.1 adds an optional Conclave push client that receives live device
state updates over Afero’s account-scoped TLS socket. REST polling stays the
source of truth for discovery, writes, and slow reconciliation; Conclave is
subscribe-only and updates the same cached models REST polling uses.
Debug frame logging¶
Decoded application frames are logged as JSON on
aioafero.v1.conclave.frames at DEBUG (one object per message; heartbeats
are omitted). Raising that logger — or its parent aioafero — to DEBUG is
enough; no special handlers are required. Home Assistant integrations that
register aioafero under loggers pick this up when the user enables debug
logging for the integration.
Messages may include account and device identifiers — treat debug logs as sensitive.
Buffered file capture¶
ConclaveFrameFileHandler is a logging.Handler
that buffers frame lines in memory and appends them to an NDJSON file when the
buffer fills (or on flush / close):
from aioafero.v1.conclave import FRAME_LOGGER, attach_frame_capture
handler = attach_frame_capture("conclave-frames.ndjson", buffer_size=32)
# …run with enable_conclave=True…
frames = handler.read_frames() # flushes first
FRAME_LOGGER.removeHandler(handler)
handler.close()
With propagate=False (default in attach_frame_capture()),
lines stay off parent loggers. Local scripts/afero_bridge.py enables this via
capture_frames / capture_buffer in afero.yaml.
Enabling Conclave¶
Pass enable_conclave=True when constructing or opening the bridge:
bridge = v1.AferoBridgeV1(
username,
refresh_token,
session=session,
enable_conclave=True,
)
await bridge.initialize()
await bridge.async_block_until_done()
# ``bridge.conclave`` exposes the running client once the first poll completes.
AferoBridgeV1.open(..., enable_conclave=True) forwards the flag through the
shorthand constructor. bridge.close() stops the Conclave client alongside
the rest of the bridge.
Lifecycle¶
initialize()schedules a background task that waits for the first REST discovery poll (the discovery index anddescription.functionssemantics are required to resolve push events).The client requests a short-lived Conclave token via
POST .../v1/accounts/{accountId}/conclaveAccessusing the existing OAuth bearer token.It opens a TLS socket to the returned
host:port(typicallyconclave-stream1.afero.net:443), sends an opening{}frame, and completes the handshake (helloortunnel— often zlib-compressed on first connect). It then logs in with the channel ID and the short-lived Conclave access token from step 2 (not the OAuth bearer) and waits forwelcome.The server sends bare
\nheartbeats (interval advertised inhello/welcome, typically ~60 seconds). The client acknowledges each one. Push frames decode intoattr_change/status_changeevents. Each one merges into the cachedAferoDevice.statesfor the matching physicaldeviceIdand the bridge dispatches the sameEventType.RESOURCE_UPDATEDevents that REST polling produces, so existing subscribers do not need to change.On disconnect the client reconnects with exponential backoff, requesting a fresh
conclaveAccesstoken each time.
Subscribers see push updates through the same bridge.subscribe /
controller.subscribe API as REST updates. Call bridge.subscribe after
await bridge.initialize() (only initialized controllers accept callbacks).
Connection status is exposed on the same EventStream
as REST polling. Subscribe with bridge.events.subscribe and filter for
EventType.CONCLAVE_CONNECTING, CONCLAVE_CONNECTED, CONCLAVE_DISCONNECTED,
and CONCLAVE_RECONNECTED. The first three mirror REST CONNECTED /
DISCONNECTED / RECONNECTED; CONCLAVE_CONNECTING is emitted on each
reconnect attempt (REST tracks CONNECTING internally but does not emit it).
status
and connected mirror the live
session without subscribing.
How pushes are applied¶
Conclave deviceId values are the physical device id (16 hex characters), not
always the metadevice UUID used as the REST cache key.
find_afero_devices_by_conclave_id() matches
device.device_id. Patches apply to the parent metadevice only (entries
without split_identifier). Split clones share that physical id but must not
receive the raw merge — that would duplicate events and corrupt filtered clone
state. After the parent is patched, generate_events_from_update()
refreshes clones from the parent and emits RESOURCE_UPDATE_RESPONSE for each
unique device (the same path REST write echoes and state polls use).
Each attr_change maps attribute.id through that device’s
description.functions semantics, coerces values into REST-shaped
AferoState rows, and merges them into the parent. status_change updates
available, visible, and direct only — not linked, connected, or
rssi.
Unknown attribute keys or unknown devices are logged at DEBUG and skipped; the next discovery poll still reconciles state.
Inventory changes arrive on a separate public / invalidate envelope:
kind: "remove"withtarget: "metadevices"or"devices"emitsRESOURCE_DELETEDfor the matching metadevice and any split clones.kind: "add"withtarget: "metadevices"fetches that metadevice over REST (fetch_metadevice()) and emitsRESOURCE_ADDEDwithout treating other devices as deleted. Atarget: "devices"add is a no-op when the physical device is already cached; otherwise it triggers a discovery poll (and clears discovery tombstones so a prior remove cannot block the import).Other invalidate kinds (
update, …) are ignored for now.
Conclave remove also records short-lived discovery tombstones (about two
discovery intervals, minimum two minutes) so an in-flight or slightly stale
REST discovery body cannot immediately revive a deleted device. Tombstones
expire, and intentional adds clear them (including split-clone ids).
Library layout¶
The aioafero.v1.conclave subpackage splits wire handling from bridge
integration:
access— mintconclaveAccesstokens over RESTprotocol— login frame builder andprivate/publicenvelope parsingframes— incremental decoder (zlib prefix, JSON objects,\nheartbeats)semantics— per-device attribute index and REST value coercionevents— apply pushes to the bridge cache and fan out controller eventsclient— TLS session, reconnect loop, and dispatch toevents
See aioafero.v1.conclave package for the autodoc reference.
Limitations¶
Conclave is subscribe-only. Writes still go through
controllermethods /bridge.send_service_request(REST).The client only resolves attributes that are present in the discovery
description.functionssemantics; unknown attribute IDs are ignored. A subsequent discovery poll picks up the missing semantic without user intervention.public/invalidatecurrently handles inventoryaddandremoveonly; other kinds are ignored until modeled.The channel is best-effort. REST polling on
polling_intervalcontinues so any missed pushes are reconciled at the configured interval.After
ConclaveClient.start(), login must complete within 60 seconds or the bridge logs a warning and retries with backoff.A closed or stalled wire is detected automatically: EOF on read, failed heartbeat writes, or no server bytes for roughly two heartbeat intervals (from
hello/welcome, typically ~120s). The client reconnects with a freshconclaveAccesstoken and, by default, schedules one REST state poll in the background so the TLS loop can keep acking heartbeats.Inventory
public/invalidatehandlers that need REST (metadevice fetch / discovery) are also scheduled off the TLS dispatch loop for the same reason.A zombie session (
welcomeOK, heartbeats continue, but noprivatepushes) is not visible from socket-closed checks alone. Passpush_idle_timeout=…toConclaveClientwhen you need that case (e.g. long reconcile-only REST intervals in HA). The idle clock starts atwelcomeso a session that never receives a private push is detected.push_staleandseconds_since_last_pushexpose push health for diagnostics.