Skip to content

Propagate unexpected API disconnects to the ESPHome async transport - #113

Closed
balloobbot wants to merge 3 commits into
puddly:devfrom
balloobbot:fix-esphome-connection-lost
Closed

Propagate unexpected API disconnects to the ESPHome async transport#113
balloobbot wants to merge 3 commits into
puddly:devfrom
balloobbot:fix-esphome-connection-lost

Conversation

@balloobbot

Copy link
Copy Markdown

Problem

ESPHomeSerialTransport connects with APIClient.connect(login=True) — no on_stop callback — and _call_protocol_connection_lost() is only ever dispatched from a local close(). When the ESPHome device restarts (or the API connection drops for any other reason), aioesphomeapi cleans up its connection internally and nobody tells the transport:

  • protocol.connection_lost() never fires, so a StreamReader blocks in read() forever.
  • Writes keep "succeeding": serial_proxy_write is scheduled with call_soon_threadsafe, and the APIConnectionError it raises inside the client loop is swallowed by the loop exception handler.

Consumers see a healthy port that never produces data again. For request/response protocols on top (e.g. the samsung-exlink / lg-rs232-tv Home Assistant integrations), every command just times out — indistinguishable from a device in standby — and their reconnect logic never triggers, even after the proxy comes back.

Fix

Pass on_stop to APIClient.connect() for connections the transport owns, and translate a stop into connection_lost() on the transport's loop (marshalled cross-loop the same way as _on_data):

  • unexpected drop → connection_lost(SerialException(...)), and the serial is marked broken — matching how the descriptor transport reports fatal errors;
  • clean device-initiated disconnect (ESPHome sends DisconnectRequest before a reboot, e.g. OTA) → connection_lost(None), i.e. EOF.

The handler is a no-op when close() already ran (_closing guard), and close() after a remote stop still works as before. Externally-passed APIClient instances are untouched: their owner called connect() and owns the lifecycle.

The sync ESPHomeSerial API is intentionally unchanged (the hook defaults to doing nothing there).

Testing

New test test_daemon_death_propagates_connection_lost kills the host daemon mid-connection and asserts the reader unblocks (EOF or SerialException) and the port reports closed. It times out after 10s of a blocked reader without the fix, and passes with it. Existing ESPHome transport tests (18) pass; prek (ruff, mypy, pylint, etc.) is clean.

🤖 Generated with Claude Code

balloob and others added 3 commits August 18, 2026 10:43
The ESPHome transport connected without an `on_stop` callback, so when
the device restarted (or the API connection dropped for any other
reason) the transport was never told: `connection_lost()` was never
dispatched, readers blocked forever, and writes were silently discarded
inside the client loop. Consumers saw a healthy port that simply never
produced data again.

Wire `APIClient.connect(on_stop=...)` for connections the transport
owns and translate a stop into `connection_lost()`: a clean
device-initiated disconnect surfaces as EOF and an unexpected drop as a
`SerialException`, matching how the descriptor transport reports fatal
errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The callback is now a constructor argument passed straight through to
`APIClient.connect` (which accepts None), and the transport handler runs
directly as the on_stop coroutine: it is only wired for owned API
connections, which are created on the transport's loop, so no cross-loop
dispatch is needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@puddly

puddly commented Aug 18, 2026

Copy link
Copy Markdown
Owner

I believe #112 handles the same problem but more directly.

@balloobbot balloobbot closed this Aug 18, 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.

3 participants