Summary
_heartbeat() is the only thing that keeps zone state current, and there are three ways it can stop doing that without anyone noticing. None is newly introduced; they surfaced while reviewing whether the HA integration could stop issuing its own startup refresh (theharshl/htd-home-assistant#31).
Severity is low individually. They share a cause — the heartbeat task's lifecycle is unsupervised — so they are filed together and would likely be fixed in one pass.
1. A dropped reply costs 60 seconds
_heartbeat() (htd_client/base_client.py:152) sends refresh(), sleeps 60, repeats. Nothing retries a query whose reply never arrives:
while self._connected:
try:
await self.refresh()
except HtdConnectionError:
return
await asyncio.sleep(60)
refresh() is a fire-and-forget transport write, so it returns successfully whether or not the controller ever answers. The recovery refresh() in data_received (base_client.py:191) only fires when a parse exception occurs — silence is not an exception.
So if the query on connect is dropped — plausible on the serial path this project already documents as flaky — _ready stays False and every consumer sees no zone data for a full minute.
Direction: if _ready is still False some short interval after a refresh, re-send rather than waiting out the full 60s. A couple of retries at ~2-5s would close the window without adding steady-state traffic.
2. The heartbeat task can die silently
Only HtdConnectionError is caught. Anything else out of refresh() kills the task — created by connection_made (base_client.py:143) with asyncio.create_task and never awaited, so the exception is unretrieved and surfaces only as a log line at GC time.
The realistic path is a transport-level raise from self._connection.write(cmd) (base_client.py:549) on serial. When it happens, _connected is still True, so connection_lost never runs and no reconnect is armed. The client goes quiet permanently — state is frozen and stale, but connected and ready both still report True, so consumers have no way to detect it.
Worth noting this is worse on upstream/main, where _heartbeat has no try at all.
Direction: catch broadly around the refresh, log, and either continue the loop or tear the connection down so the existing reconnect path takes over. Silently exiting is the one outcome to avoid.
3. A reused connected client never starts a heartbeat
async_connect() returns early when already connected (base_client.py:95), and connection_made is what starts the heartbeat. A caller that reconnects an already-connected client therefore gets no heartbeat and no ValueError — it just silently does nothing.
Not reachable today: the HA integration builds a fresh client per config entry setup and disconnects on unload. Filing it because it is the assumption the other two rest on, and because it is invisible if it ever does become reachable.
Direction: cheapest fix is to make connection_made reuse-safe by cancelling any existing _heartbeat_task before creating a new one.
Context
theharshl/htd-home-assistant#31 removes the per-zone refresh fan-out at HA startup but deliberately keeps one integration-owned refresh in async_setup_entry rather than relying on the heartbeat alone — precisely because of #1 and #2. If those are fixed here, that second query becomes redundant and can be dropped.
Summary
_heartbeat()is the only thing that keeps zone state current, and there are three ways it can stop doing that without anyone noticing. None is newly introduced; they surfaced while reviewing whether the HA integration could stop issuing its own startup refresh (theharshl/htd-home-assistant#31).Severity is low individually. They share a cause — the heartbeat task's lifecycle is unsupervised — so they are filed together and would likely be fixed in one pass.
1. A dropped reply costs 60 seconds
_heartbeat()(htd_client/base_client.py:152) sendsrefresh(), sleeps 60, repeats. Nothing retries a query whose reply never arrives:refresh()is a fire-and-forget transport write, so it returns successfully whether or not the controller ever answers. The recoveryrefresh()indata_received(base_client.py:191) only fires when a parse exception occurs — silence is not an exception.So if the query on connect is dropped — plausible on the serial path this project already documents as flaky —
_readystaysFalseand every consumer sees no zone data for a full minute.Direction: if
_readyis stillFalsesome short interval after a refresh, re-send rather than waiting out the full 60s. A couple of retries at ~2-5s would close the window without adding steady-state traffic.2. The heartbeat task can die silently
Only
HtdConnectionErroris caught. Anything else out ofrefresh()kills the task — created byconnection_made(base_client.py:143) withasyncio.create_taskand never awaited, so the exception is unretrieved and surfaces only as a log line at GC time.The realistic path is a transport-level raise from
self._connection.write(cmd)(base_client.py:549) on serial. When it happens,_connectedis stillTrue, soconnection_lostnever runs and no reconnect is armed. The client goes quiet permanently — state is frozen and stale, butconnectedandreadyboth still reportTrue, so consumers have no way to detect it.Worth noting this is worse on
upstream/main, where_heartbeathas notryat all.Direction: catch broadly around the refresh, log, and either continue the loop or tear the connection down so the existing reconnect path takes over. Silently exiting is the one outcome to avoid.
3. A reused connected client never starts a heartbeat
async_connect()returns early when already connected (base_client.py:95), andconnection_madeis what starts the heartbeat. A caller that reconnects an already-connected client therefore gets no heartbeat and noValueError— it just silently does nothing.Not reachable today: the HA integration builds a fresh client per config entry setup and disconnects on unload. Filing it because it is the assumption the other two rest on, and because it is invisible if it ever does become reachable.
Direction: cheapest fix is to make
connection_madereuse-safe by cancelling any existing_heartbeat_taskbefore creating a new one.Context
theharshl/htd-home-assistant#31 removes the per-zone refresh fan-out at HA startup but deliberately keeps one integration-owned refresh in
async_setup_entryrather than relying on the heartbeat alone — precisely because of #1 and #2. If those are fixed here, that second query becomes redundant and can be dropped.