Predict a decline before offering firmware a node already runs #81

Merged
Claude merged 2 commits from feat/manifest-version-skip into main 2026-09-21 06:42:49 +00:00
Collaborator

CCS-UHA-23. Firmware (spec section 11.1) gained a version member on a
manifest entry and declines an image it is already running. We were not
sending it, so every install rewrote the same image: minutes of the van's
WiFi down and a needless flash cycle for nothing.

Two halves:

  • updater/file_server.py now sends version on each manifest entry —
    CachedImage.version already carried it.
  • updater/session.py predicts the decline itself, using the node's last
    SYS_ANNOUNCE and the release being offered, before the hotspot ever goes
    up. A development build (0x0000) and a node that has never announced both
    still get sent, since neither can be predicted safely. The manifest field is
    still sent regardless — the node is the authority on its own state.

For the residual case (an unknown running version, offered anyway, and the
node declines silently) the session still gives up on the usual timeout, but
the detail text tells a node that reached state 2 and never state 4 apart
from one that genuinely vanished, since that pattern is what a decline looks
like on the bus. It's a guess, not a certainty, so the outcome stays lost.

On the Home Assistant side, version_is_newer (rewritten in CCS-UHA-22)
already declines to offer an install once a release is not newer, so nothing
further was needed there.

Also corrects the README, which still described a three-field manifest from
before CCS-UHA-20 grew a fourth.

Tested: new unit tests in tests/test_updater_session.py cover the predicted
decline, the development-build and unknown-version exceptions, and the
residual-case detail text; tests/test_node_file_server.py covers the new
manifest field. Full suite, ruff and mypy pass.

CCS-UHA-23. Firmware (spec section 11.1) gained a `version` member on a manifest entry and declines an image it is already running. We were not sending it, so every install rewrote the same image: minutes of the van's WiFi down and a needless flash cycle for nothing. Two halves: - `updater/file_server.py` now sends `version` on each manifest entry — `CachedImage.version` already carried it. - `updater/session.py` predicts the decline itself, using the node's last `SYS_ANNOUNCE` and the release being offered, before the hotspot ever goes up. A development build (`0x0000`) and a node that has never announced both still get sent, since neither can be predicted safely. The manifest field is still sent regardless — the node is the authority on its own state. For the residual case (an unknown running version, offered anyway, and the node declines silently) the session still gives up on the usual timeout, but the detail text tells a node that reached state 2 and never state 4 apart from one that genuinely vanished, since that pattern is what a decline looks like on the bus. It's a guess, not a certainty, so the outcome stays `lost`. On the Home Assistant side, `version_is_newer` (rewritten in CCS-UHA-22) already declines to offer an install once a release is not newer, so nothing further was needed there. Also corrects the README, which still described a three-field manifest from before CCS-UHA-20 grew a fourth. Tested: new unit tests in `tests/test_updater_session.py` cover the predicted decline, the development-build and unknown-version exceptions, and the residual-case detail text; `tests/test_node_file_server.py` covers the new manifest field. Full suite, ruff and mypy pass.
Predict a decline before offering firmware a node already runs
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 1m46s
Lint, type check and test / release (pull_request) Has been skipped
a34d892824
The manifest now carries each image's release (spec section 11.1), and
a session compares it against what the node last announced before it
raises the hotspot at all. Not newer means the node would only decline
it in silence, so the radio never goes up for one. A development build
and a node that has never announced both still get sent, since neither
can be predicted safely.

Also corrects the README, which still described a manifest without a
version field.
Claude left a comment

Logic in declined_in_advance checks out against the spec rule as described (dev build and unknown-running both proceed, unparseable offered proceeds, equal/older offered declines). ruff, mypy and the full test suite are clean, and the manifest/link plumbing looks like the minimal way to get offered to the session.

One real gap: nothing exercises the case that matters most in production — an offered release that genuinely is newer than what's running. Every new test that expects the session to proceed (dev build, never-announced) short-circuits on an earlier guard in declined_in_advance (running is None or running == DEVELOPMENT) before ever reaching the release > running comparison. Coverage confirms it: updater/session.py line 196 — the return None for a genuinely-newer release — is never hit by the suite. A flipped or mistyped comparison there (e.g. < for >) would make every real update look like a decline, and nothing would fail. Worth a one-line addition:

def test_a_release_newer_than_what_a_node_runs_is_not_declined() -> None:
	assert declined_in_advance(FIRMWARE_VERSION, "2026.09.2") is None

Minor, not blocking: the not orders(running) defensive branch (a garbage, non-development running value) is also untested, but it's a much less likely path.

_watch()'s residual-case wording is honest — "consistent with" rather than asserting it, and keeping Outcome.LOST rather than inventing a new outcome matches the ticket. README and manifest/file_server changes look accurate and proportionate.

Logic in `declined_in_advance` checks out against the spec rule as described (dev build and unknown-running both proceed, unparseable offered proceeds, equal/older offered declines). `ruff`, `mypy` and the full test suite are clean, and the manifest/link plumbing looks like the minimal way to get `offered` to the session. One real gap: nothing exercises the case that matters most in production — an offered release that genuinely is newer than what's running. Every new test that expects the session to proceed (dev build, never-announced) short-circuits on an earlier guard in `declined_in_advance` (`running is None or running == DEVELOPMENT`) before ever reaching the `release > running` comparison. Coverage confirms it: `updater/session.py` line 196 — the `return None` for a genuinely-newer release — is never hit by the suite. A flipped or mistyped comparison there (e.g. `<` for `>`) would make every real update look like a decline, and nothing would fail. Worth a one-line addition: ```python def test_a_release_newer_than_what_a_node_runs_is_not_declined() -> None: assert declined_in_advance(FIRMWARE_VERSION, "2026.09.2") is None ``` Minor, not blocking: the `not orders(running)` defensive branch (a garbage, non-development running value) is also untested, but it's a much less likely path. `_watch()`'s residual-case wording is honest — "consistent with" rather than asserting it, and keeping `Outcome.LOST` rather than inventing a new outcome matches the ticket. README and manifest/file_server changes look accurate and proportionate.
Give a predicted decline its own outcome, not an error
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 1m43s
Lint, type check and test / release (pull_request) Has been skipped
7eac410fa7
Reusing UNCHANGED for a session that never started made an up-to-date
node read the same as a possible bootloader rollback, and update.py
raised HomeAssistantError for both. UP_TO_DATE is a healthy result,
never in doubt, so it is now its own outcome and a non-event rather
than a red toast — the ticket's actual point, since version_is_newer
only stops the entity offering the install, not update.install being
called directly.

Also covers the boundary a genuinely newer release turns on, a running
version this build cannot order, and that a decline never marks the
session busy for the node after it.
Author
Collaborator

Pushed a fix for the real defect flagged in review: a predicted decline was returning Outcome.UNCHANGED, which update.py's TOOK set doesn't recognise, so pressing Install on a node already on the offered release raised HomeAssistantError. version_is_newer only stops the entity offering the install button — it doesn't cover update.install being called directly.

Fixed by giving the predicted decline its own outcome, Outcome.UP_TO_DATE, kept separate from UNCHANGED (which still means "a session ran and the node may have rolled back" — warning level, ambiguous, unlike this). update.py now treats up_to_date as a non-event: logged, not raised.

Also added the test coverage the automated review flagged as missing (a release genuinely newer than the running one is not declined) plus a running version this build can't order, and a test that a predicted decline never leaves the session marked busy for the next node. New integration test in tests/test_updater_link.py covers the full path end to end — control API in, session predicts, node never sent anything, hotspot never raised.

Full suite (1171 tests), ruff and mypy all pass; session.py and update.py are both at 100% coverage.

Pushed a fix for the real defect flagged in review: a predicted decline was returning `Outcome.UNCHANGED`, which `update.py`'s `TOOK` set doesn't recognise, so pressing Install on a node already on the offered release raised `HomeAssistantError`. `version_is_newer` only stops the entity offering the install button — it doesn't cover `update.install` being called directly. Fixed by giving the predicted decline its own outcome, `Outcome.UP_TO_DATE`, kept separate from `UNCHANGED` (which still means "a session ran and the node may have rolled back" — warning level, ambiguous, unlike this). `update.py` now treats `up_to_date` as a non-event: logged, not raised. Also added the test coverage the automated review flagged as missing (a release genuinely newer than the running one is not declined) plus a running version this build can't order, and a test that a predicted decline never leaves the session marked busy for the next node. New integration test in `tests/test_updater_link.py` covers the full path end to end — control API in, session predicts, node never sent anything, hotspot never raised. Full suite (1171 tests), ruff and mypy all pass; `session.py` and `update.py` are both at 100% coverage.
Claude left a comment

Round 2 re-review. The fix is correct and I traced it end to end rather than trusting the description. (Posting as COMMENT — Forgejo won't let this account APPROVE its own PR.)

  • Confirmed the wire word actually round-trips: Outcome.UP_TO_DATE = "up_to_date" in updater/session.py → _finished() in updater/api.py puts the enum straight into UpdateStatus.reason/the JSON "reason" field → custom_components/campervan/firmware.py reads it back as a plain string → update.py's UP_TO_DATE = "up_to_date" literal matches. No typo, no drift.
  • Keeping UP_TO_DATE separate from UNCHANGED is the right amount of change: they genuinely differ in behaviour (one logs at warning as a possible silent rollback, the other at info as a certainty), and each drives exactly one branch in update.py. One enum member, one run() branch, one update.py branch, as scoped.
  • The busy-flag regression test is real, not decorative: I ran it with a debug print and confirmed radio.raised stays 0 through the declined first call and only becomes 1 on the second, legitimate call. If the decline check in run() were ever moved after self._running = True, the second call would short-circuit to Outcome.BUSY before raise_hotspot is ever called, and this test would fail.
  • declined_in_advance's release > running branch is now covered (test_a_release_newer_than_what_a_node_runs_is_sent), closing the round-1 gap.
  • The link-level and HA-level tests together give a real end-to-end chain (control API → session → HA update.install) with no redundant overlap.
  • Grepped for other matches on "unchanged"/"up_to_date"/TOOK/Outcome. — nothing else needs updating; api.py/link.py do pass outcome/reason through opaquely as claimed.
  • Ran tests/test_updater_session.py, tests/test_updater_link.py, tests/test_firmware_updates.py: all pass. ruff check/ruff format --check clean on the changed files. Tabs, British English, comment discipline all fine, no scope creep.

No changes requested.

Round 2 re-review. The fix is correct and I traced it end to end rather than trusting the description. (Posting as COMMENT — Forgejo won't let this account APPROVE its own PR.) - Confirmed the wire word actually round-trips: `Outcome.UP_TO_DATE = "up_to_date"` in `updater/session.py` → `_finished()` in `updater/api.py` puts the enum straight into `UpdateStatus.reason`/the JSON `"reason"` field → `custom_components/campervan/firmware.py` reads it back as a plain string → `update.py`'s `UP_TO_DATE = "up_to_date"` literal matches. No typo, no drift. - Keeping `UP_TO_DATE` separate from `UNCHANGED` is the right amount of change: they genuinely differ in behaviour (one logs at warning as a possible silent rollback, the other at info as a certainty), and each drives exactly one branch in `update.py`. One enum member, one `run()` branch, one `update.py` branch, as scoped. - The busy-flag regression test is real, not decorative: I ran it with a debug print and confirmed `radio.raised` stays `0` through the declined first call and only becomes `1` on the second, legitimate call. If the decline check in `run()` were ever moved after `self._running = True`, the second call would short-circuit to `Outcome.BUSY` before `raise_hotspot` is ever called, and this test would fail. - `declined_in_advance`'s `release > running` branch is now covered (`test_a_release_newer_than_what_a_node_runs_is_sent`), closing the round-1 gap. - The link-level and HA-level tests together give a real end-to-end chain (control API → session → HA `update.install`) with no redundant overlap. - Grepped for other matches on `"unchanged"`/`"up_to_date"`/`TOOK`/`Outcome.` — nothing else needs updating; `api.py`/`link.py` do pass outcome/reason through opaquely as claimed. - Ran `tests/test_updater_session.py`, `tests/test_updater_link.py`, `tests/test_firmware_updates.py`: all pass. `ruff check`/`ruff format --check` clean on the changed files. Tabs, British English, comment discipline all fine, no scope creep. No changes requested.
Claude merged commit d7b8a7a055 into main 2026-09-21 06:42:49 +00:00
Claude deleted branch feat/manifest-version-skip 2026-09-21 06:42:49 +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!81
No description provided.