Skip to content

feat(protocol-v2): protocol core — COBS/CRC-16 framing, claims-gated slots, system commands, board profiles (native-tested, not yet wired)#581

Open
hongquanli wants to merge 14 commits into
masterfrom
feat/firmware-v2-protocol
Open

feat(protocol-v2): protocol core — COBS/CRC-16 framing, claims-gated slots, system commands, board profiles (native-tested, not yet wired)#581
hongquanli wants to merge 14 commits into
masterfrom
feat/firmware-v2-protocol

Conversation

@hongquanli

Copy link
Copy Markdown
Contributor

Summary

Phase B of the firmware-v2 modernization: the protocol-v2 core as pure,
natively-tested modules, plus the mirrored Python codec with cross-language
golden vectors. Implements design doc 2026-07-04-firmware-v2-design.md §4
(protocol)
and §14 (robustness), decisions D1/D3/D5/D6/D8/D9.

Zero runtime behavior change. These modules compile into the firmware
binary but are NOT wired to SerialUSB — the v1 protocol in
serial_communication.cpp remains the only live path. Phase C performs the
single-PR switchover (D4: no dual-stack ships).

What's included

  • Framing — CRC-16/CCITT-FALSE (crc16), COBS codec (cobs), and a COBS
    framer with a guaranteed ≤1-frame-loss resync and non-blocking TX
    (tx_drop on backpressure). All counters observable — no silent drops.
  • Wire contractframes.h is the single source of truth: packed
    little-endian structs with compile-time static_asserts
    (StandardResponse=158, HelloPayload=16, InfoPayload=22,
    DiagPayload=40) enforced on native and ARM builds.
  • Concurrency — resource-claims table + conflict checker, and a 5-slot
    SlotManager with an 8-entry completion ring and RETRY dedup (retry of a
    completed command is answered from the ring, never re-executed).
  • Dispatcher — claims-gated command dispatch with HELLO (session reset +
    nonce/boot-count/reset-cause), GET_INFO, GET_STATE, and DIAG (page 0
    counters + page N fault ring); machine state read via an injected
    StateProvider.
  • Boot/faultboot.{h,cpp} pure core (safe-state-first, reset-cause
    capture, persistent boot count, session nonce, EEPROM fault ring, timing
    watermarks) + a Teensy 4.1 binding (WDOG1 / SRC_SRSR / EEPROM / DWT).
  • Board profiles — compile-time-selected GET_INFO descriptors for Squid
    v1 and v2, with a new teensy41_boardv2 PlatformIO env.
  • Host codecsoftware/control/protocol_v2/ mirrors the C codec
    byte-for-byte; a Client over an injected Transport.
  • CI robustness gates — cppcheck + clang-tidy over src/protocol|boot|hal,
    a 60-second libFuzzer run over the RX→dispatch→TX path, and a golden-vector
    drift check (git diff --exit-code).

⚠️ Requested review: src/protocol/claims_table.h

claims_table.h is the authoritative map of which hardware resources each
command claims
— an incorrect row lets two commands touch the same hardware
concurrently. Phase B populates system commands only (0 claims); motion /
illumination / camera rows land with their handlers in Phase C/D. Please
review this file's contents explicitly.

Verification

Check Result
pio test -e native 147 test cases pass (11 new protocol-v2 suites)
pio run -e teensy41 SUCCESS
pio run -e teensy41_boardv2 SUCCESS
pytest tests/control/test_protocol_v2.py 19 pass
cppcheck / clang-tidy (protocol+boot+hal) clean
libFuzzer / ASAN over framer→dispatch clean
golden-vector generator idempotent; C & Python agree byte-for-byte
black clean

Notes / follow-ups for Phase C

  • Design risk logging: add logging mechanism, hooks to log uncaught crashes, and an example use of the logging tools #1: the boot binding uses WDOG1 (Teensy core doesn't
    expose RTWDOG in imxrt.h). WDOG1_WCR encoding, SRC_SRSR reset-cause mapping,
    and safe_state() output inventory are bench-verified in Phase C — the
    native-tested core is binding-agnostic.
  • v2 board n_ready_inputs = 10 exceeds the 8-bit cam_ready_mask; only the
    ≤ kMaxCameras (8) camera-ready lines map to the mask (the count is
    informational). Revisit when v2 hardware firms up.
  • Open hardware item: Squid v1 shared ready-line pin (kBoardReadyLinePinV1,
    marked TBD) — needed at Phase D.

Stats: 14 commits, 45 files, +4866/−17 on top of ee7897c9.

🤖 Generated with Claude Code

hongquanli and others added 14 commits July 4, 2026 22:35
…-phase1 branch)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Standard Cheshire & Baker COBS encode/decode, no heap. Removes all 0x00
bytes so the delimiter is unambiguous. Decode rejects embedded zeros,
truncated input, code bytes past end, and output overflow. Round-trip
verified for lengths 0/1/2/253/254/255/506 with zeros at head/middle/tail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Single source of truth for protocol-v2 frame layout: packed little-endian
structs (FrameHeader, Slot, RingEntry, AxisStateWire, SeqProgressWire,
StandardResponse=158B, HelloPayload=16B, InfoPayload=22B, DiagPayload=40B,
FaultEntryWire=8B), enums (FrameType/Flags/ResponseStatus/CommandType/
ErrorCode), sizing constants (kMaxFrame=512, kMaxPayload=506,
kProtocolVersion=2), and resource-bit helpers. Compile-time static_asserts
pin every size on both x86_64 and ARM.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…and non-blocking TX

RX accumulates bytes to a 0x00 delimiter, COBS-decodes, CRC-16 checks, and
delivers header+payload (no CRC) to a FrameSink. Because 0x00 never appears
inside an encoded frame, every delimiter is a clean resync point. TX appends
CRC, COBS-encodes, and emits frame+0x00 to a ByteSink; drops (tx_drop++) and
returns false when the sink is full — never blocks. Fixed 516-byte buffers,
no heap. Observable counters: crc_err, resync, rx_overflow, tx_drop, frames_ok.

Tests cover happy path, back-to-back frames, CRC-corruption recovery,
truncation resync, garbage-burst recovery, oversize drop, non-blocking TX
with round-trip, and a deterministic single-byte corruption sweep over every
position of every 7th of 200 frames asserting at most one frame lost per
corruption.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
claims_table.h holds one authoritative row per command (static mask or a
computed override for payload-dependent claims, used by the sequencer in
Phase D); Phase B populates system commands only (0 claims). claims_for()
looks up the production table; claims_for_in() takes an explicit table for
tests and Phase D. claims_conflict() returns 0 when compatible, else the
lowest-set conflicting resource bit index + 1.

NOTE: claims_table.h is the security-critical resource map and needs
Hongquan's explicit review (flagged in the PR description).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SlotManager gates concurrency across 5 slots: try_accept() dedups retries,
rejects resource conflicts (RejectBusy + blocking resource id + holder cmd_id)
and slot exhaustion (RejectNoSlots), or reserves a slot. complete() frees the
slot and records the outcome in an 8-entry ring with an advancing head_seq
(wraps keeping the newest 8). RETRY semantics: active cmd_id -> ActiveDuplicate
(live state); completed cmd_id in ring -> CompletedDuplicate (replayed outcome,
never re-executed); unknown cmd_id -> treated as new. A non-retry command
reusing a recently-completed id is new, since cmd_ids recycle faster than the
ring forgets. fill_response() emits the slots+ring wire section; reset() clears
the session for protocol RESET / HELLO.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…antics, DIAG)

build_response() turns a REQUEST frame into a RESPONSE frame: a StandardResponse
(status/error + slots/ring from the SlotManager + machine state from an injected
StateProvider) followed by any command-specific extra payload. Immediate system
queries (GET_STATE/HELLO/GET_INFO/DIAG) answer synchronously without a slot;
slotted commands route through SlotManager with claims_for gating, and a RETRY of
a completed command is answered from the ring without re-invoking its handler.
HELLO appends session info and resets the session (clears slots); DIAG page 0
returns counters, page >=1 returns fault-ring entries. Unknown cmd_type ->
ERR_UNKNOWN_COMMAND, out-of-range payload -> ERR_BAD_LENGTH. The FrameSink glue
sends the built frame through a bound Framer (set_framer breaks the ctor cycle).

Verified: pio test -e native (134 cases) and pio run -e teensy41 both pass — the
protocol core compiles into the ARM binary but is not yet wired to SerialUSB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The layout guarantees lived only in test_frames.cpp (native-only). Placing
them in the header itself enforces them on every target that compiles the wire
contract — native tests, teensy41, the future teensy41_boardv2, and CI — so ARM
size drift breaks the build at the source of truth (design risk #3), not just
the host test. test_frames.cpp keeps its copies as an explicit contract statement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-tested core + teensy41 binding)

Pure boot core (boot.{h,cpp}) drives the power-up sequence against an injected
BootHal: safe_state() FIRST, then reset-cause capture-once (+ register clear),
persistent boot-count increment, session-nonce mint (MurmurHash3 finalizer over
boot_count+cycle_counter — bijective, so distinct boots always differ and it is
never zero), and watchdog arm. A 16-entry fault ring lives in EEPROM and
survives reboots for post-mortem DIAG; loop/ISR timing tracks max-only.

boot_bind_teensy41.cpp implements BootHal on RT1062 (WDOG1 service sequence,
SRC_SRSR reset-cause mapping, EEPROM emulation, DWT CYCCNT) — compiled into the
teensy41 binary only, bench-verified in Phase C (design risk #1: RTWDOG vs
WDOG1; safe_state output inventory is a Phase C stub).

Verified: pio test -e native (143 cases) and pio run -e teensy41 both pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
board.h declares board_descriptor() (const InfoPayload&) plus board-scoped pin
constants (v1 camera triggers 29-32, ready-line pin kBoardReadyLinePinV1 = TBD).
board_squid_v1.cpp: 5 axes (drivers runtime-probed -> 0, Phase M), DAC80508 (8),
5 illum TTL, LED matrix, 4 triggers, 1 ready input, 16 program channels.
board_squid_v2.cpp (stub): 8 triggers, 2+8 ready inputs, TMC2240 drivers.

Exactly one board_*.cpp links per build: platformio env teensy41 (default v1)
and new env teensy41_boardv2 (-DBOARD_SQUID_V2) each exclude the other via the
src filter. CI firmware.yml now builds both envs. Native test_board /
test_board_v2 assert per-board values and that counts fit their wire-response
field widths.

Verified: pio test -e native (145 cases), pio run -e teensy41 and
-e teensy41_boardv2 all pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
software/control/protocol_v2/: pure-Python mirror of the firmware codec —
crc16 (CCITT-FALSE, table computed at import), cobs (Cheshire & Baker),
frames (all wire constants + packed struct formats + encode_frame/decode_frame),
and a Client over an injected Transport (RETRY flag, cmd_id correlation, timeout).

Cross-language integrity is enforced three ways:
- test_protocol_v2.py parses frames.h by regex (extending the FirmwareSimSerial
  pattern) and asserts every Python constant + struct size equals the header's
  (frames.h stays the single source of truth);
- gen_protocol_golden.py emits protocol_v2_golden.json AND test_golden/
  golden_cases.h from one definition of representative frames (system commands,
  158B response, 506B max payload, all-zeros) using the Python codec as
  reference — deterministic, so CI regenerates and git-diffs it;
- the C test_golden test and the Python golden test both reproduce every wire
  vector byte-for-byte (encode) and recover it (decode).

Verified: pytest tests/control/test_protocol_v2.py (19 passed), pio test -e
native (147 cases incl. test_golden), generator idempotent, black clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fuzz/fuzz_framer.cpp feeds arbitrary bytes through the full RX -> dispatch -> TX
path (COBS decode, CRC, command dispatch, slots, COBS encode) with fake state,
letting the input steer TX backpressure. Doubles as a libFuzzer entry (CI) and,
under -DFUZZ_STANDALONE, an ASAN smoke driver for local runs (Apple clang ships
no libFuzzer runtime).

CI (firmware.yml) gains two jobs: static-analysis (cppcheck + clang-tidy over
src/protocol, src/boot, src/hal — .clang-tidy pins a focused, high-signal check
set with WarningsAsErrors) and fuzz (clang -fsanitize=address,fuzzer, 60s).

Zero-initialize the Framer/Dispatcher fixed buffers (write-before-read by
design) so cppcheck's uninitMemberVar is clean and reads are deterministic —
negligible cost (singletons in production).

Verified locally: cppcheck clean, clang-tidy clean (LLVM 22 via pip wheel),
ASAN standalone driver clean over 100k random + structured/corrupted inputs,
147 native tests still green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add src/protocol/, src/boot/, src/hal/, fuzz/, and the new test dirs to the
controller source tree, plus a "Protocol v2 (Phase B)" note stating the modules
compile in but are not wired to SerialUSB — the v1 path stays live until Phase
C's switchover — and pointing to the host codec and golden-vector generator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… comments)

Reword the crc16 header/impl comments to present CRC-16/CCITT-FALSE by its
standard parameters (poly 0x1021, init 0xFFFF, check 0x29B1) and its role as
the wire-integrity contract, rather than as a copy of an earlier branch. The
256-entry table is the standard CCITT table (identical to what any correct
generator emits and to the Python mirror crc16.py).

Also replace the `while (length--)` loop with an explicit index loop: same
computation, but avoids the decrement-in-condition idiom that some clang-tidy
versions false-positive as bugprone-infinite-loop. No behavior change
(0x29B1 check value and golden vectors unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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