Skip to content

fix(ws): hold SYNCING through post-connect status replay (#512)#513

Merged
shbatm merged 2 commits into
v3.x.xfrom
fix/512-ws-syncing-replay-gate
May 22, 2026
Merged

fix(ws): hold SYNCING through post-connect status replay (#512)#513
shbatm merged 2 commits into
v3.x.xfrom
fix/512-ws-syncing-replay-gate

Conversation

@shbatm
Copy link
Copy Markdown
Collaborator

@shbatm shbatm commented May 22, 2026

Summary

Fixes #512.

WebSocketClient.websocket() set ES_CONNECTED the instant the aiohttp socket opened, before the controller's post-connect status replay (a burst of ST/DON/DOF frames) drained. connection_events consumers that treat ES_CONNECTED as "the stream is now live" therefore saw the replay as live events, firing spurious triggers on every connect, config-entry reload, and WebSocket reconnect.

This is the direct backport of the already-shipped pyisyox fix (shbatm/pyisyox#170) and unblocks home-assistant/core#169782 (event platform for isy994).

Changes

  • constants.py — add EventStreamStatus.SYNCING (+ ES_SYNCING alias). Pure-additive; no existing ES_* constant renamed or repurposed.
  • events/websocket.py — on socket open, set ES_SYNCING (not ES_CONNECTED) and spawn a _promote_when_quiet() watcher:
    • Promotes to ES_CONNECTED once the replay goes quiet for WS_SYNC_QUIET_SECONDS (1.0s, no frame), with a hard cap WS_SYNC_MAX_SECONDS (10.0s) so a perpetually chatty controller can't stall the stream.
    • Sampled watcher (reads a per-frame counter) keeps the read loop a plain async for; cancelled in the read-loop finally, so a socket that drops before settling never reports CONNECTED.
    • Records still update during SYNCING (the dispatcher keeps feeding) — only the "stream is live" signal is withheld.
    • Re-armed on every reconnect. Status transitions now log at DEBUG.

Compatibility

  • Public API surface unchanged; EventEmitter notifies the same way — consumers just receive one extra transition value during the ~1s sync window.
  • HA Core isy994 only branches on CONNECTED / DISCONNECTED / LOST_STREAM_CONNECTION / RECONNECTING / RECONNECT_FAILED and gates entity availability on node.enabled (not isy.connected), so it is unaffected until it opts into gating event-style entities on connected. isy.connected simply turns true ~1s later.

Known limitation

The quiet window is anchored to socket-open, not to the first replayed frame (matching the validated pyisyox design). The IoX replay is cache-served on the subscribe round-trip, so the first frame is sub-second in practice; a pathologically delayed first frame (> the quiet window) is the accepted trade-off — anchoring on first-frame would regress a genuinely silent controller to the 10s cap.

Tests

tests/test_websocket_lifecycle.py (offline, no live ISY):

  • _promote_when_quiet: silent-controller promote, holds-through-replay-then-promote, hard-cap under constant traffic.
  • End-to-end through websocket(): replay routed (records update) while stream stays SYNCING, CONNECTED only after the quiet window.
  • Socket drop before quiet → watcher cancelled, CONNECTED never emitted.

Full suite: 448 passed. pre-commit (ruff/codespell/prettier): clean.

🤖 Generated with Claude Code

shbatm and others added 2 commits May 22, 2026 15:10
WebSocketClient set ES_CONNECTED the instant the aiohttp socket opened,
before the controller's post-connect status replay (a burst of ST/DON/DOF
frames) drained. connection_events consumers that treat ES_CONNECTED as
"the stream is live" therefore saw the replay as live events, firing
spurious triggers on every connect, config-entry reload, and reconnect.

Add an intermediate ES_SYNCING state held from socket-open until the
replay goes quiet for WS_SYNC_QUIET_SECONDS (1.0s, no frame), then promote
to ES_CONNECTED. A hard cap (WS_SYNC_MAX_SECONDS, 10.0s) keeps a chatty
controller from stalling the stream. A sampled watcher task does the
debounce so the read loop stays a plain `async for`; it is cancelled in
the read-loop finally, so a socket that drops before settling never
reports CONNECTED. Records still update during SYNCING (the dispatcher
keeps feeding); only the "stream is live" signal is withheld. SYNCING is
re-armed on every reconnect. Status transitions now log at DEBUG.

Pure-additive: ES_SYNCING is a new enum value/alias; no existing ES_*
constant is renamed or repurposed. HA Core's isy994 only branches on
CONNECTED/DISCONNECTED/LOST_STREAM_CONNECTION/RECONNECTING/RECONNECT_FAILED
and gates entity availability on node.enabled (not isy.connected), so it
is unaffected until it opts into gating event-style entities on connected.

Backport of pyisyox#170. Unblocks home-assistant/core#169782.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Collapse the replay/SYNCING explanation that was restated in the module
constant comment and the websocket() inline comment down to the canonical
_promote_when_quiet docstring, and document that the quiet window is
anchored to socket-open (the known first-frame-latency limitation,
matching pyisyox). No behaviour change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@shbatm shbatm merged commit 4f34b7e into v3.x.x May 22, 2026
4 checks passed
@shbatm shbatm deleted the fix/512-ws-syncing-replay-gate branch May 22, 2026 20:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

WebSocket reports CONNECTED before initial status replay drains, causing spurious event-stream triggers on every connect

1 participant