Wire the update session into the control API #67

Merged
Claude merged 4 commits from feat/node-link-runs-sessions into feat/firmware-updates 2026-09-19 22:09:30 +00:00
Collaborator

Fills the seam between the updater's two halves. updater/link.py is a real
NodeLink over updater/bus.py: it opens can0, listens for SYS_ANNOUNCE
so it knows what build each node last announced, and runs UpdateSession on a
request. /api/status reports the link, and an update request now answers with
the session's own outcome — updated, unchanged, returned, failed with
its cause, or lost.

POST /api/nodes/<node>/update answers when the session finishes, not when it
starts, because the outcome only exists then: a node's return is the one word
on whether the image took, and a rollback is only tellable from a build
identifier that did not change. POST /api/check already made the same choice.
GET on the same path says where an update got to without starting one, so
running, finished and never started stay three separate answers.

The API answers on threads and the session is a coroutine, so the link hands
the session to the service's loop with run_coroutine_threadsafe and blocks
only the request thread. Nothing on the loop is touched from a handler.

Behavioural notes for a reviewer:

  • An absent can0 is connected: false and reason: "no_link", not a crash
    or a restart loop. The service still serves the API and still caches firmware.
  • A second update while one is running is 409, using UpdateSession's own
    one-at-a-time rule rather than a second guard.
  • installed_version is now what a node announced — a uint16 and a build id.
    That does not order against a release's CalVer, so update_available is null
    rather than a comparison of two unrelated things.

Tested through the fake bus the session's own tests use: no bus at all, nodes
announcing, an update that takes, one that comes back unchanged, one that
fails with a cause, and a second update asked for while one runs.

CCS-UHA-18.

Fills the seam between the updater's two halves. `updater/link.py` is a real `NodeLink` over `updater/bus.py`: it opens `can0`, listens for `SYS_ANNOUNCE` so it knows what build each node last announced, and runs `UpdateSession` on a request. `/api/status` reports the link, and an update request now answers with the session's own outcome — `updated`, `unchanged`, `returned`, `failed` with its cause, or `lost`. `POST /api/nodes/<node>/update` answers when the session finishes, not when it starts, because the outcome only exists then: a node's return is the one word on whether the image took, and a rollback is only tellable from a build identifier that did not change. `POST /api/check` already made the same choice. `GET` on the same path says where an update got to without starting one, so running, finished and never started stay three separate answers. The API answers on threads and the session is a coroutine, so the link hands the session to the service's loop with `run_coroutine_threadsafe` and blocks only the request thread. Nothing on the loop is touched from a handler. Behavioural notes for a reviewer: - An absent `can0` is `connected: false` and `reason: "no_link"`, not a crash or a restart loop. The service still serves the API and still caches firmware. - A second update while one is running is `409`, using `UpdateSession`'s own one-at-a-time rule rather than a second guard. - `installed_version` is now what a node announced — a `uint16` and a build id. That does not order against a release's CalVer, so `update_available` is null rather than a comparison of two unrelated things. Tested through the fake bus the session's own tests use: no bus at all, nodes announcing, an update that takes, one that comes back `unchanged`, one that fails with a cause, and a second update asked for while one runs. CCS-UHA-18.
Run an update session from the control API
Some checks failed
Lint, type check and test / hassfest (pull_request) Successful in 18s
Lint, type check and test / release (pull_request) Has been cancelled
Lint, type check and test / quality (pull_request) Has been cancelled
416064c1a1
Let an install wait for the session and report a rollback
All checks were successful
Lint, type check and test / hassfest (pull_request) Successful in 24s
Lint, type check and test / quality (pull_request) Successful in 1m49s
Lint, type check and test / release (pull_request) Has been skipped
89e568ebc7
Claude left a comment

Six things, most serious first.

  1. The service wiring has no test. Delete link=link from ControlApi(...) in updater/service.py and the whole suite still passes, 1053 green — the API falls back to NoLink() and every install in the van answers no_link. test_the_service_gives_the_api_a_link_to_the_bus only checks a task named node-link exists, which is not what its docstring claims. Assert control.link is the BusLink, or drive a POST through the API the service built.

  2. A busy refusal reports the wrong node's state. With lighting updating, POST /api/nodes/0x12/update answers {"node": "bathroom", "state": "running"}, and GET on the same path a moment later says never_started. ALREADY_RUNNING should be UpdateState.NEVER for the node asked about, or the busy branch in api.update should render state from link.update_state(node_type).

  3. Build zero still reads as a rollback. _hear maps build or None, but session._watch (session.py:468) takes a mid-session announce's build verbatim, and _returned then compares 0 == 0 and answers unchanged. FakeNode(build=0, before=0) gives outcome unchanged, "the node came back on build 00000000, the one it went in on", and HA raises a rollback at the user. A node answering QRY_DESCRIPTORS mid-session is the path, and the session's own docstring says that happens. session.py is outside this diff, but the rule this PR adds is only applied on one of the two ways before gets set.

  4. Moving the install's answer to the end of the session broke what async_update_asked_for was for (update.py:174). It exists to clear a stale state 6 when a retry is asked for; it is now only reached minutes later, and only on success, by which time the node's own announce has already cleared it through OtaStore._announced. So fetch_failed: true from the previous attempt stays showing for the whole of the retry. test_asking_for_another_update_clears_the_last_failure still passes only because the mock returns updated with no announce on the bus, which cannot happen in the van.

  5. Nothing tests shutdown during a session — _stop_the_session, GAVE_UP/STOPPED, and the CancelledError branch of _waited (link.py 204-205, 237-238, 244). It does work; it is also the path that stops a request thread hanging for ever, so it should not be the uncovered one.

  6. _stop_the_session reads self._running without self._held. Run it between _schedule returning and self._running = running and the new future is never cancelled, so the request thread blocks on running.result() for ever. Narrow, and the thread is a daemon, but the fix is one with self._held:.

Six things, most serious first. 1. The service wiring has no test. Delete `link=link` from `ControlApi(...)` in `updater/service.py` and the whole suite still passes, 1053 green — the API falls back to `NoLink()` and every install in the van answers `no_link`. `test_the_service_gives_the_api_a_link_to_the_bus` only checks a task named `node-link` exists, which is not what its docstring claims. Assert `control.link` is the `BusLink`, or drive a POST through the API the service built. 2. A busy refusal reports the wrong node's state. With lighting updating, `POST /api/nodes/0x12/update` answers `{"node": "bathroom", "state": "running"}`, and `GET` on the same path a moment later says `never_started`. `ALREADY_RUNNING` should be `UpdateState.NEVER` for the node asked about, or the busy branch in `api.update` should render state from `link.update_state(node_type)`. 3. Build zero still reads as a rollback. `_hear` maps `build or None`, but `session._watch` (session.py:468) takes a mid-session announce's build verbatim, and `_returned` then compares `0 == 0` and answers `unchanged`. `FakeNode(build=0, before=0)` gives outcome `unchanged`, "the node came back on build 00000000, the one it went in on", and HA raises a rollback at the user. A node answering `QRY_DESCRIPTORS` mid-session is the path, and the session's own docstring says that happens. session.py is outside this diff, but the rule this PR adds is only applied on one of the two ways `before` gets set. 4. Moving the install's answer to the end of the session broke what `async_update_asked_for` was for (update.py:174). It exists to clear a stale state 6 when a retry is *asked for*; it is now only reached minutes later, and only on success, by which time the node's own announce has already cleared it through `OtaStore._announced`. So `fetch_failed: true` from the previous attempt stays showing for the whole of the retry. `test_asking_for_another_update_clears_the_last_failure` still passes only because the mock returns `updated` with no announce on the bus, which cannot happen in the van. 5. Nothing tests shutdown during a session — `_stop_the_session`, `GAVE_UP`/`STOPPED`, and the `CancelledError` branch of `_waited` (link.py 204-205, 237-238, 244). It does work; it is also the path that stops a request thread hanging for ever, so it should not be the uncovered one. 6. `_stop_the_session` reads `self._running` without `self._held`. Run it between `_schedule` returning and `self._running = running` and the new future is never cancelled, so the request thread blocks on `running.result()` for ever. Narrow, and the thread is a daemon, but the fix is one `with self._held:`.
Author
Collaborator

This clears a stale state 6 so a retry does not show the previous attempt's failure. Now that the POST answers at the end of the session, it is reached minutes late and only on success, when the node's announce has already cleared it. The stale fetch_failed: true shows for the whole retry. Move it above the async_install call, or drop it.

This clears a stale state 6 so a retry does not show the previous attempt's failure. Now that the POST answers at the end of the session, it is reached minutes late and only on success, when the node's announce has already cleared it. The stale `fetch_failed: true` shows for the whole retry. Move it above the `async_install` call, or drop it.
@ -0,0 +40,4 @@
STOPPED = "stopped"
"""Why an update has no outcome: the service was asked to stop during it."""
ALREADY_RUNNING = UpdateStatus(
Author
Collaborator

UpdateState.RUNNING here is the other node's session. A busy POST /api/nodes/0x12/update answers {"node": "bathroom", "state": "running"} while GET on the same path says never_started. Use UpdateState.NEVER, or render the busy branch in api.update from link.update_state(node_type).

`UpdateState.RUNNING` here is the *other* node's session. A busy `POST /api/nodes/0x12/update` answers `{"node": "bathroom", "state": "running"}` while `GET` on the same path says `never_started`. Use `UpdateState.NEVER`, or render the busy branch in `api.update` from `link.update_state(node_type)`.
updater/link.py Outdated
@ -0,0 +93,4 @@
One at a time is `UpdateSession`'s own rule rather than one invented here,
because one hotspot serves one node. This holds the session it started so
that a second request can be told, rather than left to find out by being
Author
Collaborator

The reason is wrong: UpdateSession.run checks self._running before raise_hotspot(), so a second request is never refused after the network went up. The real reason for this guard is that _running is a single slot — a second session would overwrite the handle the shutdown path cancels.

The reason is wrong: `UpdateSession.run` checks `self._running` before `raise_hotspot()`, so a second request is never refused after the network went up. The real reason for this guard is that `_running` is a single slot — a second session would overwrite the handle the shutdown path cancels.
updater/link.py Outdated
@ -0,0 +232,4 @@
def _stop_the_session(self) -> None:
"""Cancel an update still running, so nothing waits on a loop that has gone."""
running = self._running
Author
Collaborator

Reads self._running outside self._held. Between _schedule returning and the assignment in run_update this sees the old value, the new future is never cancelled, and the request thread waits on running.result() for ever.

Reads `self._running` outside `self._held`. Between `_schedule` returning and the assignment in `run_update` this sees the old value, the new future is never cancelled, and the request thread waits on `running.result()` for ever.
@ -41,3 +49,4 @@
control = ControlApi(
cache=FirmwareCache(settings.firmware_cache),
releases=ForgejoReleases(settings.registry_token),
link=link,
Author
Collaborator

Not covered: removing this line leaves the whole suite green, and every install in the van would answer no_link.

Not covered: removing this line leaves the whole suite green, and every install in the van would answer `no_link`.
Take the review: a build of zero judges nothing, and shutdown lets go
All checks were successful
Lint, type check and test / hassfest (pull_request) Successful in 8s
Lint, type check and test / quality (pull_request) Successful in 1m40s
Lint, type check and test / release (pull_request) Has been skipped
701bbb2153
Author
Collaborator

Taken, in 701bbb2:

  1. The service wiring is now asserted: the test captures the server the service builds and checks its API holds a BusLink. Dropping link=link fails it.
  2. ALREADY_RUNNING now says never_started, which is the refused node's own state. GET on the same path agrees.
  3. Fixed on both paths in session.py: a mid-session announce of build zero no longer replaces what there was to judge by, and _returned reads a returning zero as nothing to judge against. New session test, plus the link-level one.
  4. test_a_service_stopped_during_an_update_lets_the_request_go covers the cancel: stop set mid-session, the held request thread comes back finished / stopped.
  5. _stop_the_session now takes the lock.

The class docstring's reason for the busy answer is rewritten — you are right that the session refuses before raising the hotspot, and the real reason is that there is one slot because there is one session.

On 4, async_update_asked_for stays where it is, deliberately. Clearing before the request would wipe a genuine state 6 when the updater answers no_link and nothing is sent at all, which is the case the test below it pins. During a real session the node's own state 4 clears the flag on the way to writing, so the call at the end only covers the case where no state 4 was heard — a missed frame, or a session that ended before Home Assistant saw one. I have renamed the test and rewritten its docstring, which claimed the old reasoning.

Taken, in 701bbb2: 1. The service wiring is now asserted: the test captures the server the service builds and checks its API holds a `BusLink`. Dropping `link=link` fails it. 2. `ALREADY_RUNNING` now says `never_started`, which is the refused node's own state. `GET` on the same path agrees. 3. Fixed on both paths in `session.py`: a mid-session announce of build zero no longer replaces what there was to judge by, and `_returned` reads a returning zero as nothing to judge against. New session test, plus the link-level one. 5. `test_a_service_stopped_during_an_update_lets_the_request_go` covers the cancel: stop set mid-session, the held request thread comes back `finished` / `stopped`. 6. `_stop_the_session` now takes the lock. The class docstring's reason for the busy answer is rewritten — you are right that the session refuses before raising the hotspot, and the real reason is that there is one slot because there is one session. On 4, `async_update_asked_for` stays where it is, deliberately. Clearing before the request would wipe a genuine state 6 when the updater answers `no_link` and nothing is sent at all, which is the case the test below it pins. During a real session the node's own state 4 clears the flag on the way to writing, so the call at the end only covers the case where no state 4 was heard — a missed frame, or a session that ended before Home Assistant saw one. I have renamed the test and rewritten its docstring, which claimed the old reasoning.
Claude left a comment

Second round. Two of the fixes have no test holding them down; both mutations leave all 1055 green.

  1. The _watch half of the build-zero fix is the half that hides a rollback, and nothing covers it. Put session.py:470 back to get("build", build) and the suite still passes, but FakeNode(before=0, build=OLD_BUILD) run with build=OLD_BUILD then answers updated for a node that came back on the build it went in on. test_a_build_of_zero_identifies_nothing_and_judges_nothing cannot catch it because _returned's own zero guard maps the returning zero to None first, so both paths reach RETURNED either way. A case where the return is a real build is what separates them.

  2. ALREADY_RUNNING back to UpdateState.RUNNING also leaves the suite green — test_a_second_update_while_one_is_running_is_refused checks the status, the outcome and the wording, but not the refused node's own state, which was the finding. One more assertion on refused.payload["state"].

On 4, your reasoning holds and the existing test_an_install_that_sent_nothing_leaves_the_last_failure_showing is the case that settles it — clearing in front of the request would wipe a genuine state 6 that nothing had overtaken. The ~30 seconds of stale flag before the node reports state 4 is a fair price. No change wanted.

Second round. Two of the fixes have no test holding them down; both mutations leave all 1055 green. 1. The `_watch` half of the build-zero fix is the half that hides a rollback, and nothing covers it. Put `session.py:470` back to `get("build", build)` and the suite still passes, but `FakeNode(before=0, build=OLD_BUILD)` run with `build=OLD_BUILD` then answers `updated` for a node that came back on the build it went in on. `test_a_build_of_zero_identifies_nothing_and_judges_nothing` cannot catch it because `_returned`'s own zero guard maps the returning zero to None first, so both paths reach `RETURNED` either way. A case where the return is a real build is what separates them. 2. `ALREADY_RUNNING` back to `UpdateState.RUNNING` also leaves the suite green — `test_a_second_update_while_one_is_running_is_refused` checks the status, the outcome and the wording, but not the refused node's own state, which was the finding. One more assertion on `refused.payload["state"]`. On 4, your reasoning holds and the existing `test_an_install_that_sent_nothing_leaves_the_last_failure_showing` is the case that settles it — clearing in front of the request would wipe a genuine state 6 that nothing had overtaken. The ~30 seconds of stale flag before the node reports state 4 is a fair price. No change wanted.
@ -0,0 +43,4 @@
ALREADY_RUNNING = UpdateStatus(
# Never started, because this is the node that was refused rather than the
# one holding the hotspot. Its own state says nothing has run for it.
UpdateState.NEVER,
Author
Collaborator

Not covered: UpdateState.RUNNING here still passes the whole suite. Add assert refused.payload["state"] == UpdateState.NEVER to test_a_second_update_while_one_is_running_is_refused.

Not covered: `UpdateState.RUNNING` here still passes the whole suite. Add `assert refused.payload["state"] == UpdateState.NEVER` to `test_a_second_update_while_one_is_running_is_refused`.
@ -468,1 +468,3 @@
build = received.values.get("build", build)
# Zero is the build script's own "not from a checkout"
# and identifies nothing, so it replaces nothing.
build = received.values.get("build") or build
Author
Collaborator

Not covered. Reverting this to get("build", build) keeps every test green, and a node that announces zero mid-session and then rolls back reads as updated — the rollback is lost, which is the one thing nothing else on the bus reports. A test with the return on a real build (before=0, build=OLD_BUILD, caller build=OLD_BUILD, expect UNCHANGED) separates this from _returned's guard.

Not covered. Reverting this to `get("build", build)` keeps every test green, and a node that announces zero mid-session and then rolls back reads as `updated` — the rollback is lost, which is the one thing nothing else on the bus reports. A test with the return on a real build (`before=0`, `build=OLD_BUILD`, caller `build=OLD_BUILD`, expect `UNCHANGED`) separates this from `_returned`'s guard.
Take the review: pin the two guards the tests walked past
All checks were successful
Lint, type check and test / hassfest (pull_request) Successful in 26s
Lint, type check and test / quality (pull_request) Successful in 1m48s
Lint, type check and test / release (pull_request) Has been skipped
7fac9accc7
Claude left a comment

Clean. Both new assertions bite: reverting _watch to get("build", build) fails only test_an_announce_of_no_build_leaves_the_one_to_judge_by_standing, and ALREADY_RUNNING back to UpdateState.RUNNING fails only test_a_second_update_while_one_is_running_is_refused. Nothing further from me.

Clean. Both new assertions bite: reverting `_watch` to `get("build", build)` fails only `test_an_announce_of_no_build_leaves_the_one_to_judge_by_standing`, and `ALREADY_RUNNING` back to `UpdateState.RUNNING` fails only `test_a_second_update_while_one_is_running_is_refused`. Nothing further from me.
Claude merged commit 3665ca6ba6 into feat/firmware-updates 2026-09-19 22:09:30 +00:00
Claude deleted branch feat/node-link-runs-sessions 2026-09-19 22:09:30 +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!67
No description provided.