Skip to content

feat(tv): drive connection lifecycle through the FSM (spec 001, Phase 2) - #155

Open
zehnm wants to merge 1 commit into
mainfrom
feat/connection-fsm-phase2
Open

feat(tv): drive connection lifecycle through the FSM (spec 001, Phase 2)#155
zehnm wants to merge 1 commit into
mainfrom
feat/connection-fsm-phase2

Conversation

@zehnm

@zehnm zehnm commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements Phase 2 of spec 001 — Connection lifecycle state machine: the runtime connection lifecycle of AndroidTv is now driven by the pure ConnectionFsm merged in Phase 1 (#154), on top of the Stage-1 reconnection fixes (#153).

What changed, for a reviewer who knows the old tv.py

  • The ~20 scattered self._state = DeviceState.* runtime writes in connect(), disconnect(), _is_available_updated() and the invalid-auth callback are gone. They are replaced by _dispatch(Trigger.*) — the single place ConnectionFsm.apply() is called — and a thin, defensive intent executor _execute() that performs all side effects (task starts, keep_reconnecting, Cast reconnect, event emission). Pairing/init keep using DeviceState; the setup flow is untouched.
  • The old inline connect-retry loop is now _initial_connect(), spawned by the START_INITIAL_CONNECT intent and still guarded by _connect_lock; connect() keeps its awaitable bool contract.
  • The racy _is_available_updated callback is reduced to a single trigger dispatch; transport loss arms a 1.5 s grace timer (provisional, connection_fsm.RECONNECT_GRACE) and the Stage-1 IP-rediscovery watcher via intents.
  • New Events.RECONNECTING (emitted on transport loss; driver wiring is Phase 3, harmless no-op until then).
  • New (spec F11): the androidtvremote2 reconnect-owner task is supervised via a done-callback; if it dies silently (unexpected exception, no cancellation, no auth callback), an error is logged and ownership is re-established through the existing transition table (DISCONNECT_REQUESTEDCONNECT_REQUESTED).

Invariants / acceptance criteria implemented and tested

INV-1 single writer (AST audit test), INV-2 single reconnect owner (P0-2 regression test), INV-3 state/event coherence (single emit call site), INV-4 liveness-not-is_on (P0-3 regression test), INV-5 no flicker (grace-window tests), INV-6 terminal AUTH_ERROR/UNREACHABLE, INV-7 deterministic teardown; AC-1…AC-7, AC-9 (new tests/test_tv_connection.py: 18 tests with a fake AndroidTVRemote, no network, no real sleeps — grace tested by dispatching GRACE_ELAPSED directly), AC-11.

Deviations from the spec (fed back as spec edits in this PR, per the specs README)

  1. Already-connected fast path re-emit kept, but routed through the executor's emit helper and gated on FSM state == CONNECTED, so the single-emit-call-site and state/event coherence hold.
  2. Setup-flow DeviceState read after connect(): setup_flow.py:552 also reads .state after connect(timeout), which the spec's analysis missed. Not a regression — on main that read was always masked to DISCONNECTED by the preceding disconnect() write; with Phase 2, init/pairing auth/timeout classification now actually survives to the read (equal or better).

Minor intended behaviour notes for the reviewer: CONNECT_ABORTED now emits DISCONNECTED (old code emitted nothing on abort); an idempotent disconnect() from DISCONNECTED no longer emits; a command during a failed pairing now returns SERVICE_UNAVAILABLE instead of CONFLICT (PAIRING_ERROR is a pairing-lifecycle state, out of FSM scope).

Verification

  • python -m unittest discover tests92/92 pass (74 pre-existing unchanged + 18 new); run 5× consecutively, stable
  • black / isort / flake8 clean, pylint 10.00/10

Phase 3 (driver event-contract cleanup, wiring RECONNECTING to the UI) follows as a small standalone PR.

🤖 Generated with Claude Code

Integrate the pure ConnectionFsm from Phase 1 into AndroidTv: the runtime
connection state is now committed exclusively by ConnectionFsm.apply(),
driven through a single _dispatch(trigger) entry point, with all side
effects (tasks, library calls, event emission) performed by a thin,
defensive intent executor (_execute).

- connect()/disconnect()/_is_available_updated()/invalid-auth callback
  dispatch triggers instead of writing DeviceState (INV-1, INV-3)
- command decorator gates on ConnectionState instead of DeviceState,
  preserving the returned StatusCodes
- Stage-1 IP-rediscovery watcher and the new reconnect grace timer are
  wired through START/CANCEL intents; grace duration comes from
  connection_fsm.RECONNECT_GRACE (INV-5, no DISCONNECTED flicker)
- AUTH_ERROR/UNREACHABLE are terminal until an explicit user action
  (INV-6); disconnect() cancels every owned task via CANCEL_TASKS (INV-7)
- new Events.RECONNECTING emitted on CONNECTED -> RECONNECTING
  (driver-side wiring follows in Phase 3)
- reconnect-owner supervision (spec F11): a done-callback on the library
  _reconnect_task re-establishes ownership via DISCONNECT_REQUESTED +
  CONNECT_REQUESTED if the task dies without cancellation or auth error
- integration test suite tests/test_tv_connection.py with a fake
  AndroidTVRemote covering all nine Test-Plan items incl. the
  single-writer AST audit (AC-9); no network, no real sleeps

Spec: clarify the already-connected re-emit path and the setup-flow
DeviceState read after connect() (masked on main by disconnect();
unchanged or improved with Phase 2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@zehnm
zehnm force-pushed the feat/connection-fsm-phase2 branch from 02cdf2c to 0190126 Compare July 16, 2026 11:18
@zehnm zehnm self-assigned this Jul 16, 2026
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.

1 participant