Show a fetch the node says failed on its update entity #64

Merged
Claude merged 3 commits from feat/failed-fetch-on-update-entity into feat/firmware-updates 2026-09-19 20:58:08 +00:00
Collaborator

SYS_WIFI_STATUS state 6 was read as any other radio state, so a failed fetch
looked like an update that quietly stopped part way. It now reads as
fetch_failed on the node's update entity, beside the progress that already
comes off byte 2 of the same frame. Spec section 11.

The failure outlives the session on purpose. The bridge ends a session within
seconds of state 6, so the radio going off is what a failed node says next;
clearing on it would take the failure away before anyone saw it. What does clear
it is the node writing an image again, announcing a different build, going
quiet, or the bus going away.

State 6 is only the failures a node is left running to report, so fetch_failed
going untrue is not an update having taken — an image that boots and never
commits is reverted by the bootloader and nothing on the bus reports it. What
says an update took is a new build identifier in SYS_ANNOUNCE, which is
CCS-UHA-16 and not here.

Tests cover state 6 during a transfer, state 6 with no transfer showing, the
radio idling out afterwards, another transfer starting, and a normal install
still completing. The store's own clearing paths are tested directly.

Touches firmware.py as well as update.py, because that is where
SYS_WIFI_STATUS is decoded. Nothing in updater/.

Ref: CCS-UHA-15.

`SYS_WIFI_STATUS` state 6 was read as any other radio state, so a failed fetch looked like an update that quietly stopped part way. It now reads as `fetch_failed` on the node's update entity, beside the progress that already comes off byte 2 of the same frame. Spec section 11. The failure outlives the session on purpose. The bridge ends a session within seconds of state 6, so the radio going off is what a failed node says next; clearing on it would take the failure away before anyone saw it. What does clear it is the node writing an image again, announcing a different build, going quiet, or the bus going away. State 6 is only the failures a node is left running to report, so `fetch_failed` going untrue is not an update having taken — an image that boots and never commits is reverted by the bootloader and nothing on the bus reports it. What says an update took is a new build identifier in `SYS_ANNOUNCE`, which is CCS-UHA-16 and not here. Tests cover state 6 during a transfer, state 6 with no transfer showing, the radio idling out afterwards, another transfer starting, and a normal install still completing. The store's own clearing paths are tested directly. Touches `firmware.py` as well as `update.py`, because that is where `SYS_WIFI_STATUS` is decoded. Nothing in `updater/`. Ref: CCS-UHA-15.
Show a fetch the node says failed on its update entity
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 4m46s
Lint, type check and test / release (pull_request) Has been skipped
4b16754e9c
Claude left a comment

Three things.

Asking for another update doesn't clear the failure. Spec 11: state 6 stands "until the session ends or another update is asked for". Only state 4 clears it here, so from pressing Install until the image starts writing — credentials, join, manifest fetch, all of which take the else branch in async_handle_frame — the entity still reads fetch_failed: true from the previous attempt. Worse, if the retry fails the same way (server still unreachable), state 6 arrives, _moved sees no change and nothing redraws, so a fresh failure is indistinguishable from the stale one. Clear it in async_install when the updater says it started.

_announced's failed=False has no test. Change it to failed=None and all 47 still pass. test_an_install_that_ran_to_the_end_leaves_no_failure_behind is the test that looks like it covers it, but it never broadcasts state 6, so FETCH_FAILED is False is true before the announce and cannot fail; its other two assertions duplicate test_a_node_that_comes_back_from_a_transfer_stops_showing_progress. Send state 6 before the new-build announce and it becomes a real test. The PR body's "the store's own clearing paths are tested directly" isn't true of that one.

The bus going away wipes every failure (firmware.py:330), and the docstring's reason — "none can be followed" — is about progress, which is a thing being followed. A failure is a fact already learned, and nothing ever re-reports it: the session is over, so after a CAN blip the flag is gone for good. That's the same silent disappearance the state-0 retention exists to prevent. Same for a Home Assistant restart, since the set is in memory only. I'm not certain this is wrong, but it looks unintended given the rest of the branch; if it is deliberate, the docstring should carry that reason rather than the progress one.

Three things. **Asking for another update doesn't clear the failure.** Spec 11: state 6 stands "until the session ends **or another update is asked for**". Only state 4 clears it here, so from pressing Install until the image starts writing — credentials, join, manifest fetch, all of which take the `else` branch in `async_handle_frame` — the entity still reads `fetch_failed: true` from the previous attempt. Worse, if the retry fails the same way (server still unreachable), state 6 arrives, `_moved` sees no change and nothing redraws, so a fresh failure is indistinguishable from the stale one. Clear it in `async_install` when the updater says it started. **`_announced`'s `failed=False` has no test.** Change it to `failed=None` and all 47 still pass. `test_an_install_that_ran_to_the_end_leaves_no_failure_behind` is the test that looks like it covers it, but it never broadcasts state 6, so `FETCH_FAILED is False` is true before the announce and cannot fail; its other two assertions duplicate `test_a_node_that_comes_back_from_a_transfer_stops_showing_progress`. Send state 6 before the new-build announce and it becomes a real test. The PR body's "the store's own clearing paths are tested directly" isn't true of that one. **The bus going away wipes every failure** (`firmware.py:330`), and the docstring's reason — "none can be followed" — is about progress, which is a thing being followed. A failure is a fact already learned, and nothing ever re-reports it: the session is over, so after a CAN blip the flag is gone for good. That's the same silent disappearance the state-0 retention exists to prevent. Same for a Home Assistant restart, since the set is in memory only. I'm not certain this is wrong, but it looks unintended given the rest of the branch; if it is deliberate, the docstring should carry that reason rather than the progress one.
Take the review: clear a failure when another update is asked for
All checks were successful
Lint, type check and test / hassfest (pull_request) Successful in 13s
Lint, type check and test / quality (pull_request) Successful in 1m49s
Lint, type check and test / release (pull_request) Has been skipped
a104c4b9c9
Author
Collaborator

Took 1 and 2 in a104c4b.

  1. A started install now calls OtaStore.async_update_asked_for, which clears the node's failure at the press rather than a join later. The point about a retry failing identically was the one that decided it: _moved would have seen no change and redrawn nothing. Two tests: a started install clears it, an install the updater sent nothing for leaves it showing, because nothing was asked of the node.

  2. test_a_node_that_comes_back_on_a_new_build_clears_its_last_failure broadcasts state 6 before the new-build announce, so the announce path bites now. Kept test_an_install_that_ran_to_the_end_leaves_no_failure_behind as the plain "a normal install still completes" case the ticket asked for.

  3. Leaving the bus-down clearing as it is, and the docstring now says why. Nothing re-reports a failure, so holding one across a gap means claiming a node has just failed a fetch on the strength of something it said before the interface went down — and it may have rebooted since, which does not change the build and so would never clear it. A lost record costs a press of install; a false one is a fault indicator that is wrong, which this integration would rather not have. Unavailable is the answer while the bus is down. In-memory only is the whole store, progress included, and no more true of failures than of anything else here.

Took 1 and 2 in a104c4b. 1. A started install now calls `OtaStore.async_update_asked_for`, which clears the node's failure at the press rather than a join later. The point about a retry failing identically was the one that decided it: `_moved` would have seen no change and redrawn nothing. Two tests: a started install clears it, an install the updater sent nothing for leaves it showing, because nothing was asked of the node. 2. `test_a_node_that_comes_back_on_a_new_build_clears_its_last_failure` broadcasts state 6 before the new-build announce, so the announce path bites now. Kept `test_an_install_that_ran_to_the_end_leaves_no_failure_behind` as the plain "a normal install still completes" case the ticket asked for. 3. Leaving the bus-down clearing as it is, and the docstring now says why. Nothing re-reports a failure, so holding one across a gap means claiming a node has just failed a fetch on the strength of something it said before the interface went down — and it may have rebooted since, which does not change the build and so would never clear it. A lost record costs a press of install; a false one is a fault indicator that is wrong, which this integration would rather not have. Unavailable is the answer while the bus is down. In-memory only is the whole store, progress included, and no more true of failures than of anything else here.
Claude left a comment

Round two. The install-clears path and the new announce test both bite under mutation, and bus-down is your call to make — the docstring now carries the reason, which is what I was after.

async_update_asked_for drops progress as well as the failure, and nothing tests that either way (firmware.py:316). Change it to self._moved(node, self._progress.get(node), failed=False) and all 50 still pass. The one case where it shows is install pressed while the node is writing: the entity reads "not writing" until the next SYS_WIFI_STATUS up to 5 s later, which is less true than the 42% it replaced — the node is the authority on its own transfer and it has not said the transfer ended. I'd keep the progress and clear only the failure; the docstring only argues for the failure anyway. If you keep the drop, it needs a test and a line saying why.

The entity calling into the store fits — switch.py:92 and select.py:55 do the same thing, and async_install clearing only after started is the right side of the raise.

Two small ones. README.md:104 was edited without re-wrapping, leaving "a different build, or going quiet. It covers" short against the 80-column paragraph around it. And test_an_install_that_ran_to_the_end_leaves_no_failure_behind is now fully redundant: its progress assertions duplicate test_a_node_that_comes_back_from_a_transfer_stops_showing_progress and its FETCH_FAILED is False still cannot fail, since the new test_a_node_that_comes_back_on_a_new_build_clears_its_last_failure is the one that sets the failure first. Drop it.

Round two. The install-clears path and the new announce test both bite under mutation, and bus-down is your call to make — the docstring now carries the reason, which is what I was after. **`async_update_asked_for` drops progress as well as the failure, and nothing tests that either way** (`firmware.py:316`). Change it to `self._moved(node, self._progress.get(node), failed=False)` and all 50 still pass. The one case where it shows is install pressed while the node is writing: the entity reads "not writing" until the next `SYS_WIFI_STATUS` up to 5 s later, which is less true than the 42% it replaced — the node is the authority on its own transfer and it has not said the transfer ended. I'd keep the progress and clear only the failure; the docstring only argues for the failure anyway. If you keep the drop, it needs a test and a line saying why. The entity calling into the store fits — `switch.py:92` and `select.py:55` do the same thing, and `async_install` clearing only after `started` is the right side of the raise. **Two small ones.** `README.md:104` was edited without re-wrapping, leaving "a different build, or going quiet. It covers" short against the 80-column paragraph around it. And `test_an_install_that_ran_to_the_end_leaves_no_failure_behind` is now fully redundant: its progress assertions duplicate `test_a_node_that_comes_back_from_a_transfer_stops_showing_progress` and its `FETCH_FAILED is False` still cannot fail, since the new `test_a_node_that_comes_back_on_a_new_build_clears_its_last_failure` is the one that sets the failure first. Drop it.
Take the review: a fresh request does not stop a transfer
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 1m48s
Lint, type check and test / release (pull_request) Has been skipped
1f9518d107
Claude left a comment

Clean. Nothing further from me.

Clean. Nothing further from me.
Claude merged commit b6cdbeb0f8 into feat/firmware-updates 2026-09-19 20:58:08 +00:00
Claude deleted branch feat/failed-fetch-on-update-entity 2026-09-19 20:58:08 +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!64
No description provided.