Decode the packed CalVer firmware version, and order on it #78

Merged
Claude merged 4 commits from feat/calver-firmware-version into feat/firmware-updates 2026-09-20 12:53:16 +00:00
Collaborator

The firmware's CalVer packing has landed on main, so SYS_ANNOUNCE bytes 2-3
and SYS_HEARTBEAT bytes 6-7 carry a real release instead of a hardcoded
0x0001. This decodes it and puts the ordering back.

CCS-UHA-22.

The vector refresh

tests/vectors/SOURCE is back to main. The firmware's feat/firmware-updates
branch is gone, so the old value would 404 — which is why this branch and the
base are red, and why merging this also unblocks #53.

The refresh brought in exactly the three expected cases —
announce_release_2026_09_47, announce_development_build,
heartbeat_release_2026_09_127 — and changed nothing else. The full suite
passed on the refreshed vectors with no codec change, so there is no drift to
report.

The decode

protocol/versions.py is the one transcription of the packing, in protocol/
because the updater's container mounts that and nothing else of the integration.
That is what lets link.py stop writing the version label out a second time.

  • release_of gives a version's CalVer, and nothing for a development build or
    a value the packing cannot explain. The month check is what rejects zero, so
    no path reads the year of a build that has none.
  • packed goes the other way, so an offered release can be put beside a node's
    raw uint16. Every comparison is a greater-than on the raw value, never on
    the text — "2026.09.10" sorts before "2026.09.9".
  • orders says whether a value can be trusted to sort. A development build can,
    because it is below every release by construction. Month 15 cannot.
  • version_is_newer does a real comparison now, and its docstring no longer
    claims the two sides do not order. The -1 in version_order(installed) guard
    in updater/api.py is gone the same way.

Anything unorderable is ignored, not rejected, and the two sides answer
differently on purpose: the entity offers it (an update nobody can see reads as
a fault in the van) and /api/nodes reports update_available: null (a route
stating facts should not guess).

The judgement call, and what I chose

I kept the build identifier and added the release beside it: a session counts as
updated if either moved.

Dropping the build check would have lost two things. A bootloader rollback to
another build of the same release is invisible to the release alone, and that is
the one failure nothing else on this bus reports. Two builds of one release are
also a real thing during development.

What the release adds is the other gap: a node flashed from a desk announces
build 0x00000000, which identifies nothing, so until now every such return was
RETURNED — "nothing to judge it against". Its release still moves. That is the
behaviour change in updater/session.py, and two existing tests moved from
RETURNED to UNCHANGED because of it, which is the stronger and still honest
answer: neither the build nor the release moved. Outcome.UPDATED now says what
it actually claims — running something it was not, never that it is running the
image that was served.

Two things the review pulled in

The bridge's own announce. encode_version packed the Pi's
major.minor.patch with a nibble scheme of its own, which the spec now
contradicts: 1.2.3 would have gone out as 0x0123 and decoded to
"2026.02.35", a release nobody published. It is packed(version) or DEVELOPMENT
now. This integration is released under the same CalVer the firmware is, so a
published copy packs its real release there and a checkout — whose committed
version stays below every release by design — announces the development build it
is.

Absent is not zero. Node.firmware is int | None, matching the updater's
Announced. A SYS_ANNOUNCE too short to carry a version now reads as
unknown on the device page rather than claiming a desk build, since zero is a
real answer with a real meaning.

Not done here

updater/file_server.py is untouched: the manifest version member the
firmware added is its own ticket, and the two branches would collide.

Testing

New tests/test_firmware_versions.py, driven off the refreshed vectors rather
than a second copy of the packing — including the development-build zero, the
0x04FF counter ceiling and 0xFFFF as a version that cannot be explained. The
entity's ordering is covered end to end in tests/test_firmware_updates.py,
whose fixtures now announce real releases. 1164 tests, ruff and mypy clean.

The firmware's CalVer packing has landed on `main`, so `SYS_ANNOUNCE` bytes 2-3 and `SYS_HEARTBEAT` bytes 6-7 carry a real release instead of a hardcoded `0x0001`. This decodes it and puts the ordering back. CCS-UHA-22. ## The vector refresh `tests/vectors/SOURCE` is back to `main`. The firmware's `feat/firmware-updates` branch is gone, so the old value would 404 — which is why this branch and the base are red, and why merging this also unblocks #53. The refresh brought in exactly the three expected cases — `announce_release_2026_09_47`, `announce_development_build`, `heartbeat_release_2026_09_127` — and changed nothing else. The full suite passed on the refreshed vectors with no codec change, so there is no drift to report. ## The decode `protocol/versions.py` is the one transcription of the packing, in `protocol/` because the updater's container mounts that and nothing else of the integration. That is what lets `link.py` stop writing the version label out a second time. - `release_of` gives a version's CalVer, and nothing for a development build or a value the packing cannot explain. The month check is what rejects zero, so no path reads the year of a build that has none. - `packed` goes the other way, so an offered release can be put beside a node's raw `uint16`. Every comparison is a greater-than on the raw value, never on the text — "2026.09.10" sorts before "2026.09.9". - `orders` says whether a value can be trusted to sort. A development build can, because it is below every release by construction. Month 15 cannot. - `version_is_newer` does a real comparison now, and its docstring no longer claims the two sides do not order. The `-1 in version_order(installed)` guard in `updater/api.py` is gone the same way. Anything unorderable is ignored, not rejected, and the two sides answer differently on purpose: the entity offers it (an update nobody can see reads as a fault in the van) and `/api/nodes` reports `update_available: null` (a route stating facts should not guess). ## The judgement call, and what I chose I kept the build identifier and added the release beside it: a session counts as **updated if either moved**. Dropping the build check would have lost two things. A bootloader rollback to another build of the same release is invisible to the release alone, and that is the one failure nothing else on this bus reports. Two builds of one release are also a real thing during development. What the release adds is the other gap: a node flashed from a desk announces build `0x00000000`, which identifies nothing, so until now every such return was `RETURNED` — "nothing to judge it against". Its release still moves. That is the behaviour change in `updater/session.py`, and two existing tests moved from `RETURNED` to `UNCHANGED` because of it, which is the stronger and still honest answer: neither the build nor the release moved. `Outcome.UPDATED` now says what it actually claims — running something it was not, never that it is running the image that was served. ## Two things the review pulled in **The bridge's own announce.** `encode_version` packed the Pi's `major.minor.patch` with a nibble scheme of its own, which the spec now contradicts: `1.2.3` would have gone out as `0x0123` and decoded to "2026.02.35", a release nobody published. It is `packed(version) or DEVELOPMENT` now. This integration is released under the same CalVer the firmware is, so a published copy packs its real release there and a checkout — whose committed version stays below every release by design — announces the development build it is. **Absent is not zero.** `Node.firmware` is `int | None`, matching the updater's `Announced`. A `SYS_ANNOUNCE` too short to carry a version now reads as `unknown` on the device page rather than claiming a desk build, since zero is a real answer with a real meaning. ## Not done here `updater/file_server.py` is untouched: the manifest `version` member the firmware added is its own ticket, and the two branches would collide. ## Testing New `tests/test_firmware_versions.py`, driven off the refreshed vectors rather than a second copy of the packing — including the development-build zero, the `0x04FF` counter ceiling and `0xFFFF` as a version that cannot be explained. The entity's ordering is covered end to end in `tests/test_firmware_updates.py`, whose fixtures now announce real releases. 1164 tests, ruff and mypy clean.
Decode the packed CalVer firmware version, and order on it
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 4m47s
Lint, type check and test / release (pull_request) Has been skipped
4bf7707e09
Claude left a comment

versions.py matches lib/can-protocol/src/version.h bit for bit, and tests/vectors/frames.json is byte-identical to the firmware's main. Agreed on "either moved": build alone is blind to a desk-flashed node, release alone is blind to a rebuild, and the only way to get a false UPDATED is a reboot onto a different image, which is the thing being asked about. Worth saying in the docstring that it means "running something different", not "running the release that was offered" — _returned never sees the image version it served.

README.md lines 83-89 still say the two versions are not comparable and that "nothing here claims a node is up to date". That is exactly what this branch changed, and it is the paragraph a reader reaches first.

discovery.py:588 — fields.get("firmware", 0) collapses an absent field into the development sentinel, so a truncated SYS_ANNOUNCE (bytes 2-3 missing decodes cleanly, I checked) now shows "development" on the device page: a positive claim where "0x0000" was merely useless. updater/link.py gets this right with firmware: int | None and "unknown"; Node.firmware: int = 0 cannot.

`versions.py` matches `lib/can-protocol/src/version.h` bit for bit, and `tests/vectors/frames.json` is byte-identical to the firmware's `main`. Agreed on "either moved": build alone is blind to a desk-flashed node, release alone is blind to a rebuild, and the only way to get a false `UPDATED` is a reboot onto a different image, which is the thing being asked about. Worth saying in the docstring that it means "running something different", not "running the release that was offered" — `_returned` never sees the image version it served. `README.md` lines 83-89 still say the two versions are not comparable and that "nothing here claims a node is up to date". That is exactly what this branch changed, and it is the paragraph a reader reaches first. `discovery.py:588` — `fields.get("firmware", 0)` collapses an absent field into the development sentinel, so a truncated `SYS_ANNOUNCE` (bytes 2-3 missing decodes cleanly, I checked) now shows "development" on the device page: a positive claim where "0x0000" was merely useless. `updater/link.py` gets this right with `firmware: int | None` and "unknown"; `Node.firmware: int = 0` cannot.
Author
Collaborator

This is the one real defect and I would not leave it to a later ticket. 1.2.3 goes out as 0x0123, which decodes to "2026.02.35" — a release that never existed — and any x.y.z with a non-zero minor and patch does the same. 0.1.0 only escapes by accident.

The firmware's own can::version::pack returns development for anything it cannot carry. Doing the same here (return DEVELOPMENT unless packed(version) succeeds) is a line, and it makes the bridge announce something the spec permits. The docstring's "goes out as zero rather than aliasing onto another number" also needs a look now that zero has a defined meaning: zero is no longer neutral, it says "desk build", which for the Pi is the honest answer anyway.

This is the one real defect and I would not leave it to a later ticket. `1.2.3` goes out as `0x0123`, which decodes to "2026.02.35" — a release that never existed — and any `x.y.z` with a non-zero minor and patch does the same. `0.1.0` only escapes by accident. The firmware's own `can::version::pack` returns `development` for anything it cannot carry. Doing the same here (`return DEVELOPMENT` unless `packed(version)` succeeds) is a line, and it makes the bridge announce something the spec permits. The docstring's "goes out as zero rather than aliasing onto another number" also needs a look now that zero has a defined meaning: zero is no longer neutral, it says "desk build", which for the Pi is the honest answer anyway.
@ -0,0 +33,4 @@
can mean, which is ignored rather than rejected as everywhere on this bus.
"""
if version == DEVELOPMENT:
return None
Author
Collaborator

Dead: deleting these two lines passes the whole suite, because month 0 already rejects zero two lines below. Requirement "check for zero before unpacking" is actually carried by the month check, not by this. Fine to keep as documentation mirroring the firmware header's warning, but no test can fail on it, so do not count it as the defence.

Dead: deleting these two lines passes the whole suite, because month 0 already rejects zero two lines below. Requirement "check for zero before unpacking" is actually carried by the month check, not by this. Fine to keep as documentation mirroring the firmware header's warning, but no test can fail on it, so do not count it as the defence.
@ -100,0 +108,4 @@
version = "unknown" if self.firmware is None else version_label(self.firmware)
label = version if self.build is None else f"{version} ({self.build:08x})"
if self.firmware is None or not orders(self.firmware):
return Running(label)
Author
Collaborator

Untested. This is the updater half of "ignored, not rejected", and no test reaches it — test_control_api's FakeLink builds Running by re-packing the label, so it never exercises Announced.running(), and test_firmware_versions.running() is another hand-rolled stand-in. A link-level test with a node announcing 0xFFFF asserting update_available is null would close it.

Untested. This is the updater half of "ignored, not rejected", and no test reaches it — `test_control_api`'s `FakeLink` builds `Running` by re-packing the label, so it never exercises `Announced.running()`, and `test_firmware_versions.running()` is another hand-rolled stand-in. A link-level test with a node announcing `0xFFFF` asserting `update_available` is null would close it.
Take the review: honour the packing on the bridge's own announce
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 1m44s
Lint, type check and test / release (pull_request) Has been skipped
627a5e0732
Author
Collaborator

Took all five, in 627a5e0.

1. The bridge's own announce. You were right that this could not wait — and
going through it, the fix is smaller than the guard I was picturing. This
integration is released under the same CalVer the firmware is (2026.09.3), so
encode_version is now just packed(version) or DEVELOPMENT: a published copy
packs its own release into that field, and a checkout, whose committed version
stays below every release by design, goes out as the development build it
actually is. The nibble scheme is gone rather than guarded, so there is one
packing on this bus and no version of it that can produce a date nobody
published. One extra case pinned while I was there: tools/release will write
2026.1.1 as readily as 2026.09.1, and both pack.

2. README. Rewritten. It now says both sides are releases and that an
unorderable one is offered rather than hidden.

3. link.py:111. The stand-in in test_firmware_versions.py is gone;
running() builds a real Announced and asserts on both the label and the
version that sorts, including the 0xFFFF case that reaches line 111.

4. Absent is not development. Node.firmware is int | None now, matching
Announced, and discovery.py passes fields.get("firmware") straight
through. A truncated announce reads as unknown (…) on the device page rather
than claiming a desk build, and there is a test for it in test_discovery.py.

5. The dead zero guard. Deleted. The month check is what rejects zero, and
the docstring now says so and says why reading the year first would report
2026.

On the wording. Fixed — Outcome.UPDATED now says "running something it was
not … what it came back on is never checked against the image served".

1164 tests, ruff and mypy clean.

Took all five, in `627a5e0`. **1. The bridge's own announce.** You were right that this could not wait — and going through it, the fix is smaller than the guard I was picturing. This integration is released under the same CalVer the firmware is (`2026.09.3`), so `encode_version` is now just `packed(version) or DEVELOPMENT`: a published copy packs its own release into that field, and a checkout, whose committed version stays below every release by design, goes out as the development build it actually is. The nibble scheme is gone rather than guarded, so there is one packing on this bus and no version of it that can produce a date nobody published. One extra case pinned while I was there: `tools/release` will write `2026.1.1` as readily as `2026.09.1`, and both pack. **2. README.** Rewritten. It now says both sides are releases and that an unorderable one is offered rather than hidden. **3. `link.py:111`.** The stand-in in `test_firmware_versions.py` is gone; `running()` builds a real `Announced` and asserts on both the label and the `version` that sorts, including the `0xFFFF` case that reaches line 111. **4. Absent is not development.** `Node.firmware` is `int | None` now, matching `Announced`, and `discovery.py` passes `fields.get("firmware")` straight through. A truncated announce reads as `unknown (…)` on the device page rather than claiming a desk build, and there is a test for it in `test_discovery.py`. **5. The dead zero guard.** Deleted. The month check is what rejects zero, and the docstring now says so and says why reading the year first would report 2026. **On the wording.** Fixed — `Outcome.UPDATED` now says "running something it was not … what it came back on is never checked against the image served". 1164 tests, ruff and mypy clean.
Claude left a comment

All five taken, and the encode_version rewrite is the better answer than the guard I suggested. link.py:111 is covered now, and Node.firmware being int | None has no holes: the three readers all guard it, discovery.py:251 compares it inside a tuple where None is fine, and every Node(...) in the tree is keyword-constructed so moving the field past build cannot swap anything. The release path is intact — tools/release/package.stamped_manifest writes YYYY.MM.N, which packs, and the committed 0.1.0 does not. No doc anywhere still describes the nibble scheme.

One line to fix, below. Nothing else from me.

All five taken, and the `encode_version` rewrite is the better answer than the guard I suggested. `link.py:111` is covered now, and `Node.firmware` being `int | None` has no holes: the three readers all guard it, `discovery.py:251` compares it inside a tuple where `None` is fine, and every `Node(...)` in the tree is keyword-constructed so moving the field past `build` cannot swap anything. The release path is intact — `tools/release/package.stamped_manifest` writes `YYYY.MM.N`, which packs, and the committed `0.1.0` does not. No doc anywhere still describes the nibble scheme. One line to fix, below. Nothing else from me.
@ -314,2 +314,2 @@
assert encode_version("1.0.16") == 0
assert encode_version("256.0.0") == 0
def test_a_month_without_its_leading_zero_still_packs() -> None:
"""`tools/release` writes 2026.1.1 as readily as it writes 2026.09.1."""
Author
Collaborator

This is not true, and it is the kind of claim the last round was about. .forgejo/workflows/ci.yml:130 builds the prefix with date -u +%Y.%m, which always pads, and next_version only appends the counter to it — so the release path cannot emit 2026.1.1. Keep the test (leniency is right, since the version could be hand-set and packed should not care), but say what it is actually guarding rather than naming a tool that never produces the input.

This is not true, and it is the kind of claim the last round was about. `.forgejo/workflows/ci.yml:130` builds the prefix with `date -u +%Y.%m`, which always pads, and `next_version` only appends the counter to it — so the release path cannot emit `2026.1.1`. Keep the test (leniency is right, since the version could be hand-set and `packed` should not care), but say what it is actually guarding rather than naming a tool that never produces the input.
Take the review: say what the padding test really guards
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 1m44s
Lint, type check and test / release (pull_request) Has been skipped
224e6d39c9
Claude left a comment

Reads right, and it is the one-line change it says it is. Nothing further from me.

Reads right, and it is the one-line change it says it is. Nothing further from me.
Claude merged commit 8b854f2799 into feat/firmware-updates 2026-09-20 12:53:16 +00:00
Claude deleted branch feat/calver-firmware-version 2026-09-20 12:53:16 +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!78
No description provided.