Let Home Assistant name the updater's CAN interface #75

Merged
Claude merged 5 commits from fix/updater-can-interface into feat/firmware-updates 2026-09-20 08:27:32 +00:00
Collaborator

The updater hardcoded can0. The Pi runs two MCP2515 overlays and talks on can1, so it opened the wrong interface, reported connected: false for ever, and answered every install no_link.

The interface now comes from the integration, which already holds the channel from its config flow. POST /api/interface/<name> moves the updater's bus; GET /api/status reports which one it is on. The integration says it at startup and again every minute, and once more immediately before an install.

Why a beat rather than a declaration at setup: either process can restart, in either order, and neither is told when the other does. The updater keeps no interface on disk, deliberately — a copy there would go stale the moment the integration was reconfigured — so being told again is the only thing that recovers it. A minute bounds how long an updater that restarted sits without a bus; the firmware poll's fifteen minutes is far too slow, and the pre-install call closes the last gap so a press in that window does not come back no_link.

Until it is told, the updater has no bus: it guesses nothing, still serves the API and still caches firmware. A changed channel closes the old interface, opens the new one, forgets what the old bus announced, and takes any running session with it. Listening on can1 goes in the log, which the silent failure here did not have.

Nothing to configure on the Pi beyond the interface the config flow already asks for. No environment variable, and nothing kept in step by hand.

Tested: the suite covers no interface, being told one, being told the same one again, a change, one that will not open and then does, a session cancelled by a change, and the integration's telling at setup, on the beat and before an install. rpi-setup.md and the README say where the interface comes from.

Closes CCS-UHA-19.

The updater hardcoded `can0`. The Pi runs two MCP2515 overlays and talks on `can1`, so it opened the wrong interface, reported `connected: false` for ever, and answered every install `no_link`. The interface now comes from the integration, which already holds the channel from its config flow. `POST /api/interface/<name>` moves the updater's bus; `GET /api/status` reports which one it is on. The integration says it at startup and again every minute, and once more immediately before an install. Why a beat rather than a declaration at setup: either process can restart, in either order, and neither is told when the other does. The updater keeps no interface on disk, deliberately — a copy there would go stale the moment the integration was reconfigured — so being told again is the only thing that recovers it. A minute bounds how long an updater that restarted sits without a bus; the firmware poll's fifteen minutes is far too slow, and the pre-install call closes the last gap so a press in that window does not come back `no_link`. Until it is told, the updater has no bus: it guesses nothing, still serves the API and still caches firmware. A changed channel closes the old interface, opens the new one, forgets what the old bus announced, and takes any running session with it. `Listening on can1` goes in the log, which the silent failure here did not have. Nothing to configure on the Pi beyond the interface the config flow already asks for. No environment variable, and nothing kept in step by hand. Tested: the suite covers no interface, being told one, being told the same one again, a change, one that will not open and then does, a session cancelled by a change, and the integration's telling at setup, on the beat and before an install. `rpi-setup.md` and the README say where the interface comes from. Closes CCS-UHA-19.
Say where the updater's CAN interface comes from
All checks were successful
Lint, type check and test / hassfest (pull_request) Successful in 9s
Lint, type check and test / quality (pull_request) Successful in 4m46s
Lint, type check and test / release (pull_request) Has been skipped
3e7b94cc74
Claude left a comment

The exact failure this fixes is still silent on the Home Assistant side at startup, and the comment volume goes back past what the reduction pass just took out. Details inline.

The exact failure this fixes is still silent on the Home Assistant side at startup, and the comment volume goes back past what the reduction pass just took out. Details inline.
@ -61,6 +62,14 @@ all the same so a press cannot wait for ever.
POLL_INTERVAL = timedelta(minutes=15)
"""The cache only moves when the updater's own six-hourly check finds something."""
CHANNEL_INTERVAL = timedelta(minutes=1)
Author
Collaborator

One fact — Home Assistant holds the channel, the updater holds none and is told again — is now written out in nine places: this docstring, FirmwareUpdates, async_use_channel, link.py's module docstring, BusLink.use_interface, BusLink.run, CanBus, api.py's use_interface, and UpdaterInterface in the tests. A comment pass just cut these files back. Say it once, in link.py's module docstring, and let the rest be one line each.

One fact — Home Assistant holds the channel, the updater holds none and is told again — is now written out in nine places: this docstring, `FirmwareUpdates`, `async_use_channel`, `link.py`'s module docstring, `BusLink.use_interface`, `BusLink.run`, `CanBus`, `api.py`'s `use_interface`, and `UpdaterInterface` in the tests. A comment pass just cut these files back. Say it once, in `link.py`'s module docstring, and let the rest be one line each.
@ -286,0 +349,4 @@
except UpdaterError as error:
_LOGGER.debug("The updater could not be told which interface: %s", error)
listening = False
if listening != self._listening:
Author
Collaborator

_listening starts False, so the first answer only logs when it is True. Van boots with the updater down, or on an interface that will not open, and Home Assistant logs nothing above debug — ever. That is the silent failure mode this PR is about, reproduced on the other side of the loopback. Start _listening as None so the first answer always logs.

`_listening` starts `False`, so the first answer only logs when it is `True`. Van boots with the updater down, or on an interface that will not open, and Home Assistant logs nothing above debug — ever. That is the silent failure mode this PR is about, reproduced on the other side of the loopback. Start `_listening` as `None` so the first answer always logs.
@ -111,0 +137,4 @@
return LinkStatus(None, False, "The updater is not listening yet.")
try:
return asyncio.run_coroutine_threadsafe(self._use(interface), loop).result()
except RuntimeError, CancelledError:
Author
Collaborator

except RuntimeError, CancelledError: is PEP 758, valid on 3.14 but the only unparenthesised one in the repo — every other site uses except (A, B). Also both new branches here (137 and 140-141) are uncovered, and 137's message is wrong in the only case that reaches it: __init__ now takes the loop, so loop is None means stopping, not "not listening yet".

`except RuntimeError, CancelledError:` is PEP 758, valid on 3.14 but the only unparenthesised one in the repo — every other site uses `except (A, B)`. Also both new branches here (137 and 140-141) are uncovered, and 137's message is wrong in the only case that reaches it: `__init__` now takes the loop, so `loop is None` means stopping, not "not listening yet".
updater/link.py Outdated
@ -171,0 +211,4 @@
"No conversation with the nodes: %s. The updater runs without one.",
failure,
)
return LinkStatus(interface, False, str(failure))
Author
Collaborator

After a failed open self._bus.interface is None (close() cleared it), so POST /api/interface/can9 answers {"interface": "can9", "connected": false} while GET /api/status answers {"interface": null}. Return self._bus.interface here so the two agree.

After a failed open `self._bus.interface` is `None` (`close()` cleared it), so `POST /api/interface/can9` answers `{"interface": "can9", "connected": false}` while `GET /api/status` answers `{"interface": null}`. Return `self._bus.interface` here so the two agree.
Take the review: log the first answer, and agree on the interface
All checks were successful
Lint, type check and test / hassfest (pull_request) Successful in 9s
Lint, type check and test / quality (pull_request) Successful in 1m43s
Lint, type check and test / release (pull_request) Has been skipped
641633b7e9
Claude left a comment

641633b clears the three that mattered. Withdrawing the except parentheses point: ruff format strips them on 3.14 when there is no as, which is why every other tuple-except in the repo keeps theirs — the unparenthesised form here is forced, not a slip.

One thing left, low priority.

641633b clears the three that mattered. Withdrawing the `except` parentheses point: `ruff format` strips them on 3.14 when there is no `as`, which is why every other tuple-except in the repo keeps theirs — the unparenthesised form here is forced, not a slip. One thing left, low priority.
@ -72,3 +77,4 @@
def close(self) -> None:
"""Drop it again."""
if self.connected:
Author
Collaborator

LinkBus.close() clears connected but leaves interface set, where the real CanBus.close() clears both. So the interesting half of the fix — open on can0, then told a can9 that will not open — would report can0 through this fake while production reports None, and no test covers it. Clear self.interface here and the case becomes testable.

`LinkBus.close()` clears `connected` but leaves `interface` set, where the real `CanBus.close()` clears both. So the interesting half of the fix — open on `can0`, then told a `can9` that will not open — would report `can0` through this fake while production reports `None`, and no test covers it. Clear `self.interface` here and the case becomes testable.
Take the review: close the fake bus the way the real one closes
All checks were successful
Lint, type check and test / hassfest (pull_request) Successful in 9s
Lint, type check and test / quality (pull_request) Successful in 1m42s
Lint, type check and test / release (pull_request) Has been skipped
709f904ffa
Claude merged commit b8ec7121e7 into feat/firmware-updates 2026-09-20 08:27:32 +00:00
Claude deleted branch fix/updater-can-interface 2026-09-20 08:27:32 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rob/CampervanHomeAssistant!75
No description provided.