Skip to content

feat(safety): refuse pre-Rev-2.2 shields for VPP-on-line-11 chips (CAP-02) - #45

Merged
henols merged 1 commit into
betafrom
fix/hw-revision-gate-ready-ack
Aug 7, 2026
Merged

feat(safety): refuse pre-Rev-2.2 shields for VPP-on-line-11 chips (CAP-02)#45
henols merged 1 commit into
betafrom
fix/hw-revision-gate-ready-ack

Conversation

@henols

@henols henols commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Host half of the CAP-02 pair. Must land together with henols/firestarter#49 — this host refuses firmware that predates that change.

The safety change

Chips whose bus-config routes VPP to bus line 11 (socket pin 21 on the DIP24_2716 / DIP24_2532 pinouts) need the 3-position JP4 header introduced on RURP shield Rev 2.2. Driving them on an earlier shield is a chip-damage path. The host now refuses at connect time, before the firmware engages the VPP regulator.

Retires the CMD_FW_VERSION pre-probe

_probe_port used to send a dedicated CMD_FW_VERSION command and consume two acks purely to read the version, then send the user's command. Firmware now carries the version and the effective hardware revision in the MSG_OK_READY ack of whatever command it is answering, so the user's command goes first and both gates run off its ack. One fewer command exchange per connect.

Sending the real command before validating is safe by construction, not by luck: init_programmer_framed does run configure_memory before emitting MSG_OK_READY, but every configure_* handler is pure — function-pointer assignment and pulse defaults only. The VPP regulator is not engaged until firestarter_operation_init, which blocks on op_wait_for_ack(). Raising here means that ack is never sent, so the operation never starts and the rail stays down.

The gate is an allowlist, not a comparison

The REVISION_* bytes are not a version-ordered scale:

REVISION_2_2     = 0x04
REVISION_UNKNOWN = 0xFE   # numerically ABOVE 2.2

detected >= REVISION_2_2 would admit precisely the boards whose revision could not be determined. Membership in {REVISION_2_2, REVISION_2_3} fails closed for 0xFE, for the 0xFF override-absent sentinel, for the REVISION_2_0 broad bucket, and for None (pre-CAP-02 firmware).

A >= was planted and confirmed to turn exactly the two trap tests red before the allowlist went back in — the tests are not vacuously green.

The override requirement is the safety mechanism

ADC detection collapses Rev 2.0/2.1/2.2 into REVISION_2_0, so a genuine Rev 2.2 is refused until the operator writes the EEPROM override. That is the design, not a gap: the operator has to look at the physical header and assert it.

The refusal message names the exact byte to write, because config --rev casts through int() and --rev 2.2 silently truncates to 2 — selecting the Rev 2.0 bucket, the opposite of what the operator intends. That truncation is a live trap in config and is not fixed here.

Collateral fixed, not papered over

The Phase-133 SerialError census gate fired on the new subclass and pointed at a genuine defect: chip_test.py::_run_step's re-raise clause named only two subclasses, so a shield-revision refusal would have degraded to a BAD step per remaining operation — reporting a damaged-looking chip when the real cause was the wrong shield. _run_step now re-raises it; the census is updated to four with the reasoning recorded.

firmware_identity / hw_revision are declared at class level because several suites patch __init__ to a no-op and conftest's make_comm builds instances via __new__. Instance-only attributes would AttributeError into _probe_port's broad except Exception and degrade to "no programmer found".

Compatibility — breaking, by design

old firmware new firmware
old host works 3+ byte params fail the len == 2 test → falls to the 512 chunk floor. Correct, halves Leonardo throughput.
new host 2-byte ack → no version, no revision → FirmwareOutdatedError full function

Lockstep host+firmware upgrade, consistent with PROJECT.md's stated constraint.

Tests

1532 pass (baseline 1508). 22 new in tests/test_hw_revision_gate.py covering the policy, the extended/legacy/truncated ack decode, and the gate's coupling to the real pinout data — test_exactly_two_pinouts_emit_the_gated_vpp_line asserts {DIP24_2716, DIP24_2532} against the live database, so a pinout edit that moves chips in or out of the gate's scope fails loudly.

ruff lint + format clean. The mypy watermark gate could not be run locally — it exits 2 on numpy stubs under the devcontainer's py3.12 versus the pinned CI version, identically with these changes stashed. CI runs it on the pinned version; that result is the one that counts. Direct mypy over the 8 strict modules is clean apart from a pre-existing submit.py:666 error.

Reviewer attention, please

  • No bench validation. This is a safety gate whose purpose is preventing chip damage, verified only by reading both sides plus unit tests mirroring the firmware's packing. It wants a real run before shipping.
  • Is the gate too broad? It catches all 16 chips on those two pinouts, including 15 ordinary Intel 2716s. If only the TI 25xx parts genuinely need Rev 2.2+, this stops chips that work today on a Rev 2.0. If the capability really is keyed on the VPP routing rather than the part, it is exactly right. That question is open and decides whether this ships as-is.

🤖 Generated with Claude Code

CAP-02 host side. Chips whose bus-config routes VPP to bus line 11 (socket
pin 21 on the DIP24_2716 / DIP24_2532 pinouts) need the 3-position JP4 header
introduced on RURP shield Rev 2.2. Driving them on an earlier shield is a
chip-damage path. The host now refuses at connect time, before the firmware
engages the VPP regulator.

Retires the CMD_FW_VERSION pre-probe
--------------------------------------
_probe_port used to send a dedicated CMD_FW_VERSION command and consume two
acks purely to read the version, THEN send the user's command. The firmware
now carries both the version and the effective hardware revision in the
MSG_OK_READY ack of whatever command it is answering, so the user's command
goes first and both gates run off its ack. One fewer command exchange per
connect.

Sending the real command before validating is safe by construction, not by
luck: init_programmer_framed does run configure_memory before emitting
MSG_OK_READY, but every configure_* handler is pure (function-pointer
assignment and pulse defaults only). The VPP regulator is not engaged until
firestarter_operation_init, which blocks on op_wait_for_ack(). Raising here
means that ack is never sent, so the operation never starts and the rail
stays down.

The gate is an ALLOWLIST, not a comparison
------------------------------------------
The REVISION_* bytes are not a version-ordered scale. REVISION_UNKNOWN is
0xFE -- numerically ABOVE REVISION_2_2 (0x04) -- so the obvious
`detected >= REVISION_2_2` spelling would admit precisely the boards whose
revision could not be determined. Membership in {REVISION_2_2, REVISION_2_3}
fails closed for 0xFE, for the 0xFF override-absent sentinel, for the
REVISION_2_0 broad bucket, and for None (pre-CAP-02 firmware). A planted
`>=` was confirmed to turn the two trap tests red before the allowlist went
back in.

ADC detection collapses Rev 2.0/2.1/2.2 into REVISION_2_0, so a genuine
Rev 2.2 is refused until the operator writes the EEPROM override. That is
the design: the operator must look at the physical header and assert it, and
that assertion is the safety mechanism. The refusal message names the exact
byte to write, because `config --rev` casts through int() and '--rev 2.2'
silently truncates to the Rev 2.0 bucket.

Collateral fixed, not papered over
----------------------------------
The Phase-133 SerialError census gate (test_chip_test_sdp_leg.py) fired on
the new subclass and surfaced a genuine defect: chip_test.py::_run_step's
re-raise clause named only two subclasses, so a shield-revision refusal would
have degraded to a BAD step per remaining operation -- reporting a
damaged-looking chip when the real cause was the wrong shield. _run_step now
re-raises it; the census is updated to four with the reasoning recorded.

firmware_identity / hw_revision are declared at CLASS level because several
suites patch __init__ to a no-op and conftest's make_comm builds instances
via __new__; instance-only attributes would AttributeError into _probe_port's
broad `except Exception` and degrade to "no programmer found".

No catalog change: MSG_OK_READY is already declared `params = [{type =
"bytes"}]`, so codegen drift stays clean (verified).

Tests: 1532 pass (was 1508). 22 new in test_hw_revision_gate.py covering the
policy, the extended/legacy/truncated ack decode, and the gate's coupling to
the real pinout data. ruff clean. The mypy watermark gate cannot run in this
devcontainer (py3.12 vs the pinned CI version -- numpy stubs fail to parse);
that failure is pre-existing and identical with these changes stashed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@henols henols closed this Aug 7, 2026
@henols henols reopened this Aug 7, 2026
@henols
henols merged commit 344905f into beta Aug 7, 2026
4 of 7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant