diff --git a/.github/workflows/firmware.yml b/.github/workflows/firmware.yml index 7d0cb3ea8..3cd5fb039 100644 --- a/.github/workflows/firmware.yml +++ b/.github/workflows/firmware.yml @@ -6,10 +6,14 @@ on: paths: - 'firmware/**' - '.github/workflows/firmware.yml' + - 'software/control/protocol_v2/**' + - 'software/tools/gen_protocol_golden.py' pull_request: paths: - 'firmware/**' - '.github/workflows/firmware.yml' + - 'software/control/protocol_v2/**' + - 'software/tools/gen_protocol_golden.py' jobs: build-and-test: @@ -27,10 +31,14 @@ jobs: - name: Install PlatformIO run: pip install platformio - - name: Build controller firmware (Teensy 4.1) + - name: Build controller firmware (Teensy 4.1, Squid v1 board) run: pio run -e teensy41 working-directory: ./firmware/controller + - name: Build controller firmware (Teensy 4.1, Squid v2 board) + run: pio run -e teensy41_boardv2 + working-directory: ./firmware/controller + - name: Build joystick firmware (Teensy LC) run: pio run -e teensyLC working-directory: ./firmware/joystick @@ -38,3 +46,52 @@ jobs: - name: Run unit tests run: pio test -e native working-directory: ./firmware/controller + + - name: Check protocol-v2 golden vectors are up to date + run: | + python software/tools/gen_protocol_golden.py + git diff --exit-code \ + software/tests/data/protocol_v2_golden.json \ + firmware/controller/test/test_golden/golden_cases.h + + static-analysis: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4.2.2 + + - name: Install static-analysis tools + run: sudo apt-get update && sudo apt-get install -y cppcheck clang-tidy + + - name: cppcheck (protocol + boot + hal core) + working-directory: ./firmware/controller + run: | + cppcheck --std=c++11 --enable=warning,performance,portability,style \ + --inline-suppr --suppress=missingIncludeSystem --error-exitcode=1 \ + -I src src/protocol src/boot/boot.cpp src/hal/boards + + - name: clang-tidy (pure protocol + boot + hal sources) + working-directory: ./firmware/controller + run: | + clang-tidy \ + src/protocol/claims.cpp src/protocol/cobs.cpp src/protocol/crc16.cpp \ + src/protocol/framer.cpp src/protocol/slots.cpp src/protocol/dispatch_v2.cpp \ + src/boot/boot.cpp \ + src/hal/boards/board_squid_v1.cpp src/hal/boards/board_squid_v2.cpp \ + -- -std=c++11 -Isrc + + fuzz: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4.2.2 + + - name: Install clang (with libFuzzer) + run: sudo apt-get update && sudo apt-get install -y clang + + - name: Build + run libFuzzer over the RX->dispatch->TX path (60s) + working-directory: ./firmware/controller + run: | + clang++ -std=c++11 -g -fsanitize=address,fuzzer -I src \ + fuzz/fuzz_framer.cpp src/protocol/*.cpp -o fuzz_framer + ./fuzz_framer -max_total_time=60 -timeout=10 -rss_limit_mb=2048 diff --git a/firmware/README.md b/firmware/README.md index 33b191ef7..a4e9a49ed 100644 --- a/firmware/README.md +++ b/firmware/README.md @@ -151,29 +151,58 @@ PLATFORMIO_BUILD_FLAGS="-DDISABLE_LASER_INTERLOCK" pio run -e teensy41 -t upload ``` controller/ -├── main_controller_teensy41.ino # Entry point -├── platformio.ini # PlatformIO config +├── main_controller_teensy41.ino # Entry point (v1 protocol — still the live path) +├── platformio.ini # PlatformIO config (teensy41, teensy41_boardv2, native) +├── fuzz/ +│ └── fuzz_framer.cpp # libFuzzer/ASAN harness for the protocol-v2 path ├── test/ # Unit tests (run with pio test -e native) -│ ├── test_crc8/ # CRC8 checksum tests -│ └── test_protocol/ # Protocol/command ID tests +│ ├── test_crc8/ # CRC8 checksum tests (v1) +│ ├── test_protocol/ # Protocol/command ID tests (v1) +│ ├── test_crc16/ test_cobs/ # protocol-v2 framing codec +│ ├── test_frames/ # protocol-v2 wire-struct layout +│ ├── test_framer/ # protocol-v2 COBS framer +│ ├── test_claims/ test_slots/ # protocol-v2 claims + slot manager +│ ├── test_dispatch/ # protocol-v2 dispatcher + system commands +│ ├── test_boot/ # boot/fault module core +│ ├── test_board/ test_board_v2/ # board descriptors (v1/v2) +│ └── test_golden/ # C<->Python golden vectors (generated) └── src/ - ├── commands/ # Command handlers - │ ├── commands.cpp/h # General commands - │ ├── light_commands.cpp/h # Illumination control - │ └── stage_commands.cpp/h # Motion control - ├── def/ - │ └── def_v1.h # Hardware configuration + ├── commands/ # Command handlers (v1) + ├── def/ # Hardware configuration (v1) ├── tmc/ # TMC stepper driver library - ├── utils/ - │ └── crc8.cpp/h # CRC calculation - ├── init.cpp/h # Initialization routines - ├── operations.cpp/h # Main loop operations - ├── serial_communication.cpp/h # Serial protocol handling + ├── utils/ # crc8 and other pure utilities + ├── protocol/ # protocol-v2 core (NOT yet wired to serial — Phase C) + │ ├── crc16, cobs, frames # CRC-16/CCITT-FALSE, COBS codec, wire contract + │ ├── framer # COBS framer (resync + non-blocking TX) + │ ├── claims, claims_table # resource-claims table + conflict checker + │ ├── slots # 5-slot manager + completion ring (RETRY dedup) + │ └── dispatch_v2 # claims-gated dispatcher + HELLO/GET_INFO/GET_STATE/DIAG + ├── boot/ # boot/fault module (NOT yet wired — Phase C) + │ ├── boot.cpp/h # watchdog/safe-state/reset-cause/nonce/fault-ring (native-tested) + │ └── boot_bind_teensy41.cpp # RT1062 binding (WDOG1/SRC_SRSR/EEPROM/DWT; teensy41 build only) + ├── hal/ # board profiles (compile-time selected) + │ ├── board.h # GET_INFO descriptor + board-scoped pin constants + │ └── boards/ # board_squid_v1.cpp, board_squid_v2.cpp + ├── init.cpp/h # Initialization routines (v1) + ├── operations.cpp/h # Main loop operations (v1) + ├── serial_communication.cpp/h # Serial protocol handling (v1 — the live path) ├── functions.cpp/h # Utility functions ├── globals.cpp/h # Global state variables └── constants.h # Constants and pin definitions ``` +### Protocol v2 (Phase B — native-tested, not yet live) + +`src/protocol/`, `src/boot/`, and `src/hal/` implement the protocol-v2 core +(COBS + CRC-16 framing, claims-gated 5-slot command dispatch with a completion +ring, system commands, and per-board GET_INFO descriptors). These modules +compile into the firmware binary but are **not wired to `SerialUSB`** — the v1 +protocol in `serial_communication.cpp` remains the live path. Phase C performs +the single-PR switchover. The mirrored host codec lives in +`software/control/protocol_v2/`, and C↔Python agreement is enforced by the +golden vectors in `test/test_golden/` (regenerate with +`software/tools/gen_protocol_golden.py`). + ## Joystick Control panel firmware for Teensy LC. Handles: diff --git a/firmware/controller/.clang-tidy b/firmware/controller/.clang-tidy new file mode 100644 index 000000000..e122de8b6 --- /dev/null +++ b/firmware/controller/.clang-tidy @@ -0,0 +1,11 @@ +# Static-analysis gate for the protocol-v2 firmware core (design D9 robustness). +# Focused, high-signal checks; CI runs it over src/protocol, src/boot, src/hal. +# WarningsAsErrors makes any enabled diagnostic fail CI. +Checks: > + clang-analyzer-*, + bugprone-*, + performance-*, + -bugprone-easily-swappable-parameters +WarningsAsErrors: '*' +HeaderFilterRegex: 'src/(protocol|boot|hal)/' +FormatStyle: none diff --git a/firmware/controller/fuzz/fuzz_framer.cpp b/firmware/controller/fuzz/fuzz_framer.cpp new file mode 100644 index 000000000..f27707a53 --- /dev/null +++ b/firmware/controller/fuzz/fuzz_framer.cpp @@ -0,0 +1,144 @@ +/** + * libFuzzer harness for the protocol-v2 RX -> dispatch -> TX path. + * + * Arbitrary bytes are fed into a Framer whose FrameSink is a Dispatcher backed + * by fake state; the dispatcher's responses are re-framed back out. This + * exercises COBS decode, CRC check, command dispatch, slot management, and + * COBS encode against untrusted input. ASAN + libFuzzer assert no crash / UB. + * + * CI runs the coverage-guided libFuzzer build. Locally (Apple clang ships no + * libFuzzer runtime) build with -DFUZZ_STANDALONE for an ASAN smoke driver, + * linking fuzz_framer.cpp against the src/protocol sources under + * -fsanitize=address -I src. + */ + +#include +#include +#include + +#include "protocol/dispatch_v2.h" +#include "protocol/frames.h" +#include "protocol/framer.h" +#include "protocol/slots.h" + +using namespace protocol; + +namespace { + +class ZeroProvider : public StateProvider { +public: + void fill_state(StandardResponse&) override {} + void fill_hello(HelloPayload& h) override { memset(&h, 0, sizeof(h)); } + void fill_info(InfoPayload& i) override { memset(&i, 0, sizeof(i)); } + void fill_diag_page0(DiagPayload& d) override { memset(&d, 0, sizeof(d)); } + uint8_t fill_diag_faults(uint8_t, FaultEntryWire*, uint8_t) override { return 0; } +}; + +class NullByteSink : public ByteSink { +public: + size_t avail; + NullByteSink() : avail(4096) {} + size_t writable() override { return avail; } + void write(const uint8_t*, size_t) override {} +}; + +void noop_handler(Dispatcher&, const uint8_t*, size_t, ResponseWriter&) {} + +} // namespace + +extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) { + ZeroProvider provider; + SlotManager slots; + Dispatcher dispatcher(slots, provider); + dispatcher.register_system_commands(); + dispatcher.register_command(0x01, false, 0, 64, noop_handler); // a slotted command + + NullByteSink out; + Framer framer(dispatcher, out); + dispatcher.set_framer(framer); + + // Let the input steer TX backpressure so send_frame's drop path is reached. + if (size > 0) { + out.avail = (data[0] & 1) ? 0 : 4096; + } + for (size_t i = 0; i < size; ++i) { + framer.feed_rx(data[i]); + } + return 0; +} + +#ifdef FUZZ_STANDALONE +// Local ASAN smoke driver (no libFuzzer runtime on Apple clang). +#include + +#include "protocol/cobs.h" +#include "protocol/crc16.h" + +namespace { + +uint32_t g_lcg = 0x1234567u; + +uint32_t lcg_next() { + g_lcg = g_lcg * 1103515245u + 12345u; + return (g_lcg >> 8) & 0xFFFFFF; +} + +void feed_valid_and_corruptions(uint8_t type, uint8_t id, uint8_t ct, uint8_t fl, + const uint8_t* pl, size_t pn) { + uint8_t frame[128]; + frame[0] = type; + frame[1] = id; + frame[2] = ct; + frame[3] = fl; + if (pn) { + memcpy(frame + 4, pl, pn); + } + size_t flen = 4 + pn; + uint16_t crc = crc16_ccitt(frame, flen); + frame[flen] = (uint8_t)(crc & 0xFF); + frame[flen + 1] = (uint8_t)(crc >> 8); + + uint8_t wire[160]; + size_t enc = cobs_encode(frame, flen + 2, wire, sizeof(wire)); + wire[enc] = 0x00; + size_t wlen = enc + 1; + + LLVMFuzzerTestOneInput(wire, wlen); + for (size_t p = 0; p < wlen; ++p) { // single-byte corruptions + uint8_t save = wire[p]; + wire[p] ^= 0xFF; + LLVMFuzzerTestOneInput(wire, wlen); + wire[p] = save; + } + uint8_t dbl[320]; // back-to-back frames + memcpy(dbl, wire, wlen); + memcpy(dbl + wlen, wire, wlen); + LLVMFuzzerTestOneInput(dbl, 2 * wlen); +} + +} // namespace + +int main() { + LLVMFuzzerTestOneInput(nullptr, 0); + + uint8_t buf[640]; + for (int iter = 0; iter < 100000; ++iter) { + size_t n = lcg_next() % (sizeof(buf) + 1); + for (size_t i = 0; i < n; ++i) { + buf[i] = (uint8_t)lcg_next(); + } + LLVMFuzzerTestOneInput(buf, n); + } + + const uint8_t p_diag[] = {0x00}; + const uint8_t p_cmd[] = {0x11, 0x22, 0x33}; + feed_valid_and_corruptions(REQUEST, 1, GET_STATE, 0, nullptr, 0); + feed_valid_and_corruptions(REQUEST, 2, HELLO, 0, nullptr, 0); + feed_valid_and_corruptions(REQUEST, 3, DIAG, 0, p_diag, sizeof(p_diag)); + feed_valid_and_corruptions(REQUEST, 4, 0x01, 0, p_cmd, sizeof(p_cmd)); + feed_valid_and_corruptions(REQUEST, 4, 0x01, FLAG_RETRY, p_cmd, sizeof(p_cmd)); + + printf("standalone fuzz driver: OK (no crash / no ASAN finding)\n"); + return 0; +} +#endif // FUZZ_STANDALONE diff --git a/firmware/controller/platformio.ini b/firmware/controller/platformio.ini index b2c4b71cd..e6446b35d 100644 --- a/firmware/controller/platformio.ini +++ b/firmware/controller/platformio.ini @@ -15,10 +15,12 @@ build_flags = -D ARDUINO_TEENSY41 -I src -; Exclude test directory from firmware build +; Exclude test directory + the OTHER board profile from this build +; (board_squid_v1.cpp is the default; see env:teensy41_boardv2 for v2). build_src_filter = +<*> - + - ; Library dependencies lib_deps = @@ -31,6 +33,17 @@ upload_protocol = teensy-gui ; Monitor settings monitor_speed = 2000000 +; Squid v2 board: same as teensy41 but selects the v2 board profile. +[env:teensy41_boardv2] +extends = env:teensy41 +build_flags = + ${env:teensy41.build_flags} + -DBOARD_SQUID_V2 +build_src_filter = + +<*> + - + - + ; Native test environment (runs on host machine) [env:native] platform = native diff --git a/firmware/controller/src/boot/boot.cpp b/firmware/controller/src/boot/boot.cpp new file mode 100644 index 000000000..2150c721d --- /dev/null +++ b/firmware/controller/src/boot/boot.cpp @@ -0,0 +1,116 @@ +/** + * Boot / fault-diagnostics core. See boot.h for the contract. + */ + +#include "boot/boot.h" + +namespace boot { + +namespace { + +// 32-bit mix (Knuth multiplicative + MurmurHash3 finalizer). Bijective, so a +// distinct boot_count always yields a distinct nonce even with identical +// cycle-counter entropy. +uint32_t mix_nonce(uint32_t boot_count, uint32_t cycle) { + uint32_t x = boot_count * 2654435761u + cycle; + x ^= x >> 16; + x *= 0x85ebca6bu; + x ^= x >> 13; + x *= 0xc2b2ae35u; + x ^= x >> 16; + return x ? x : 0xA5A5A5A5u; // never zero (0 would look like "no session") +} + +} // namespace + +Boot::Boot(BootHal& hal) + : hal_(hal), + reset_cause_(RESET_UNKNOWN), + boot_count_(0), + session_nonce_(0), + fault_head_(0), + fault_total_(0), + loop_max_us_(0), + isr_max_us_(0) {} + +uint32_t Boot::ee_read_u32(uint16_t addr) const { + return (uint32_t)hal_.eeprom_read(addr) | ((uint32_t)hal_.eeprom_read(addr + 1) << 8) | + ((uint32_t)hal_.eeprom_read(addr + 2) << 16) | + ((uint32_t)hal_.eeprom_read(addr + 3) << 24); +} + +void Boot::ee_write_u32(uint16_t addr, uint32_t value) { + hal_.eeprom_write(addr, (uint8_t)(value & 0xFF)); + hal_.eeprom_write(addr + 1, (uint8_t)((value >> 8) & 0xFF)); + hal_.eeprom_write(addr + 2, (uint8_t)((value >> 16) & 0xFF)); + hal_.eeprom_write(addr + 3, (uint8_t)((value >> 24) & 0xFF)); +} + +void Boot::begin() { + // 1. Safe state FIRST — outputs off, motors disabled — before touching + // EEPROM, the reset-cause register, or the watchdog. + hal_.safe_state(); + + // 2. Capture the reset cause exactly once, then clear it for the next boot. + reset_cause_ = hal_.read_reset_cause(); + hal_.clear_reset_cause(); + + // 3. Initialize EEPROM on first-ever boot; otherwise preserve the persistent + // fault ring across reboots. + bool initialized = (hal_.eeprom_read(kEeMagic) == kMagic0) && + (hal_.eeprom_read(kEeMagic + 1) == kMagic1); + if (!initialized) { + hal_.eeprom_write(kEeMagic, kMagic0); + hal_.eeprom_write(kEeMagic + 1, kMagic1); + ee_write_u32(kEeBootCount, 0); + hal_.eeprom_write(kEeFaultHead, 0); + hal_.eeprom_write(kEeFaultTotal, 0); + } + + // 4. Increment and persist the boot counter. + boot_count_ = ee_read_u32(kEeBootCount) + 1; + ee_write_u32(kEeBootCount, boot_count_); + + // 5. Mint a session nonce (non-zero, differs across boots). + session_nonce_ = mix_nonce(boot_count_, hal_.cycle_counter()); + + // 6. Arm the hardware watchdog. + hal_.watchdog_arm(kWatchdogTimeoutMs); + + // Load the persistent fault-ring cursor. + fault_head_ = hal_.eeprom_read(kEeFaultHead); + fault_total_ = hal_.eeprom_read(kEeFaultTotal); + + loop_max_us_ = 0; + isr_max_us_ = 0; +} + +void Boot::fault_append(uint8_t code, uint8_t detail, uint32_t uptime_ms) { + uint16_t base = kEeFaultRing + (uint16_t)fault_head_ * kFaultEntryBytes; + ee_write_u32(base, uptime_ms); + hal_.eeprom_write(base + 4, code); + hal_.eeprom_write(base + 5, detail); + + fault_head_ = (uint8_t)((fault_head_ + 1) % kFaultRingSize); + if (fault_total_ < 255) { + ++fault_total_; + } + hal_.eeprom_write(kEeFaultHead, fault_head_); + hal_.eeprom_write(kEeFaultTotal, fault_total_); +} + +bool Boot::fault_get(uint8_t k, FaultRecord& out) const { + uint8_t avail = (fault_total_ < kFaultRingSize) ? fault_total_ : kFaultRingSize; + if (k >= avail) { + return false; + } + // Newest is at (head - 1); the k-th newest is (head - 1 - k), modulo size. + uint8_t idx = (uint8_t)((fault_head_ + kFaultRingSize - 1 - k) % kFaultRingSize); + uint16_t base = kEeFaultRing + (uint16_t)idx * kFaultEntryBytes; + out.uptime_ms = ee_read_u32(base); + out.code = hal_.eeprom_read(base + 4); + out.detail = hal_.eeprom_read(base + 5); + return true; +} + +} // namespace boot diff --git a/firmware/controller/src/boot/boot.h b/firmware/controller/src/boot/boot.h new file mode 100644 index 000000000..e930a25bd --- /dev/null +++ b/firmware/controller/src/boot/boot.h @@ -0,0 +1,126 @@ +/** + * Boot / fault-diagnostics module (design D9 robustness package). + * + * On power-up the firmware must, in order: enter a safe state (outputs off, + * motors disabled) BEFORE anything else, capture why it reset, bump a + * persistent boot counter, mint a session nonce so the host can detect a + * mid-session reboot, and arm the hardware watchdog. During the loop it kicks + * the watchdog, records loop/ISR timing watermarks, and appends faults to a + * persistent ring that survives a crash/watchdog reset for post-mortem DIAG. + * + * The core logic here is pure and native-tested against an injected BootHal. + * The RT1062/Arduino specifics (WDOG1 registers, SRC_SRSR interpretation, + * EEPROM emulation, DWT cycle counter) live in boot_bind_teensy41.cpp and are + * bench-verified in Phase C (design risk #1: RTWDOG vs WDOG1). + * + * No heap; EEPROM writes happen only on boot and on fault (rare — no wear + * concern, design risk #2). + */ + +#ifndef BOOT_BOOT_H +#define BOOT_BOOT_H + +#include +#include + +namespace boot { + +enum ResetCause : uint8_t { + RESET_UNKNOWN = 0, + RESET_POWER_ON = 1, + RESET_WATCHDOG = 2, + RESET_SOFTWARE = 3, + RESET_EXTERNAL = 4, + RESET_LOCKUP = 5, +}; + +// Hardware abstraction — the binding provides these; tests fake them. +class BootHal { +public: + virtual ~BootHal() {} + + // Force hardware into a safe state (all outputs off, motors disabled). + virtual void safe_state() = 0; + + virtual uint8_t eeprom_read(uint16_t addr) = 0; + virtual void eeprom_write(uint16_t addr, uint8_t value) = 0; + + // Reset cause for this boot, already mapped from SRC_SRSR by the binding. + virtual uint8_t read_reset_cause() = 0; + virtual void clear_reset_cause() = 0; + + virtual void watchdog_arm(uint32_t timeout_ms) = 0; + virtual void watchdog_kick() = 0; + + // Free-running counter (DWT CYCCNT on hardware) used as nonce entropy. + virtual uint32_t cycle_counter() = 0; +}; + +struct FaultRecord { + uint32_t uptime_ms; + uint8_t code; + uint8_t detail; +}; + +class Boot { +public: + static const uint8_t kFaultRingSize = 16; + static const uint32_t kWatchdogTimeoutMs = 2000; + + explicit Boot(BootHal& hal); + + // Power-up sequence. safe_state() runs FIRST, then reset-cause capture, + // boot-count increment, nonce mint, watchdog arm. Idempotent EEPROM init: + // the persistent fault ring is preserved across reboots. + void begin(); + + uint8_t reset_cause() const { return reset_cause_; } + uint32_t boot_count() const { return boot_count_; } + uint32_t session_nonce() const { return session_nonce_; } + + void kick_watchdog() { hal_.watchdog_kick(); } + + // Append a fault to the persistent ring (advances head, bumps the + // saturating total). uptime_ms is supplied by the caller (millis()). + void fault_append(uint8_t code, uint8_t detail, uint32_t uptime_ms); + uint8_t fault_count() const { return fault_total_; } // saturating at 255 + uint8_t fault_head() const { return fault_head_; } + // Read the k-th newest fault (0 = newest). False if k is out of range. + bool fault_get(uint8_t k, FaultRecord& out) const; + + void note_loop_us(uint32_t us) { + if (us > loop_max_us_) loop_max_us_ = us; + } + void note_isr_us(uint32_t us) { + if (us > isr_max_us_) isr_max_us_ = us; + } + uint32_t loop_max_us() const { return loop_max_us_; } + uint32_t isr_max_us() const { return isr_max_us_; } + +private: + // EEPROM layout (bytes). + static const uint16_t kEeMagic = 0; // 2 bytes + static const uint16_t kEeBootCount = 2; // 4 bytes (LE) + static const uint16_t kEeFaultHead = 6; // 1 byte + static const uint16_t kEeFaultTotal = 7; // 1 byte + static const uint16_t kEeFaultRing = 8; // kFaultRingSize * kFaultEntryBytes + static const uint16_t kFaultEntryBytes = 6; // u32 uptime + u8 code + u8 detail + static const uint8_t kMagic0 = 0xB0; + static const uint8_t kMagic1 = 0x07; + + uint32_t ee_read_u32(uint16_t addr) const; + void ee_write_u32(uint16_t addr, uint32_t value); + + BootHal& hal_; + uint8_t reset_cause_; + uint32_t boot_count_; + uint32_t session_nonce_; + uint8_t fault_head_; + uint8_t fault_total_; + uint32_t loop_max_us_; + uint32_t isr_max_us_; +}; + +} // namespace boot + +#endif // BOOT_BOOT_H diff --git a/firmware/controller/src/boot/boot_bind_teensy41.cpp b/firmware/controller/src/boot/boot_bind_teensy41.cpp new file mode 100644 index 000000000..4bc80de64 --- /dev/null +++ b/firmware/controller/src/boot/boot_bind_teensy41.cpp @@ -0,0 +1,95 @@ +/** + * Teensy 4.1 / i.MX RT1062 binding for the boot module (BootHal). + * + * NOT compiled in the native test build (excluded by the platformio native + * src filter). Exercised on hardware in Phase C/D — NOT natively. + * + * DESIGN RISK #1 (bench-verify in Phase C): this uses WDOG1 (the i.MX windowed + * watchdog exposed by the Teensy core; RTWDOG is not exposed in imxrt.h). The + * exact WDOG1_WCR field encoding, the SRC_SRSR reset-cause bit mapping, and the + * ~2 s timeout must be confirmed on hardware before Phase C wires this to the + * real serial loop. The pure core (boot.cpp) is binding-agnostic and fully + * native-tested; only these register pokes are unverified. + */ + +#if defined(ARDUINO) || defined(ARDUINO_TEENSY41) || defined(__IMXRT1062__) + +#include +#include + +#include "boot/boot.h" + +namespace boot { + +class TeensyBootHal : public BootHal { +public: + void safe_state() override { + // TODO(Phase C): drive all illumination TTL/DAC/LED-matrix outputs low + // and disable the stepper drivers here, using the app's pin map. Left + // as a documented stub until Phase C owns the output inventory. + } + + uint8_t eeprom_read(uint16_t addr) override { return EEPROM.read(addr); } + void eeprom_write(uint16_t addr, uint8_t value) override { + EEPROM.update(addr, value); // update() skips the write if unchanged (less wear) + } + + uint8_t read_reset_cause() override { + const uint32_t srsr = SRC_SRSR; + // Priority: watchdog > software > lockup > power-on/external. + if (srsr & (SRC_SRSR_WDOG_RST_B | SRC_SRSR_WDOG3_RST_B)) { + return RESET_WATCHDOG; + } + if (srsr & SRC_SRSR_IPP_USER_RESET_B) { + return RESET_SOFTWARE; + } + if (srsr & SRC_SRSR_LOCKUP_SYSRESETREQ) { + return RESET_LOCKUP; + } + if (srsr & SRC_SRSR_IPP_RESET_B) { + return RESET_POWER_ON; + } + return RESET_UNKNOWN; + } + + void clear_reset_cause() override { + SRC_SRSR = SRC_SRSR; // SRSR bits are write-1-to-clear + } + + void watchdog_arm(uint32_t timeout_ms) override { + // WDOG1 WCR: WT[15:8] timeout = (WT + 1) * 0.5 s; bit2 = WDE (enable). + // WDE is write-once until the next reset. ~2 s -> WT = 3. + uint32_t wt = (timeout_ms / 500); + if (wt > 0) { + wt -= 1; + } + if (wt > 0xFF) { + wt = 0xFF; + } + WDOG1_WCR = (uint16_t)((wt << 8) | (1 << 2) /* WDE */); + } + + void watchdog_kick() override { + // WDOG1 service (refresh) sequence. + WDOG1_WSR = 0x5555; + WDOG1_WSR = 0xAAAA; + } + + uint32_t cycle_counter() override { + return ARM_DWT_CYCCNT; // enabled below in bind_boot_hal() + } +}; + +// Singleton binding (no heap). Phase C passes this to a Boot instance. +static TeensyBootHal g_boot_hal; + +BootHal& bind_boot_hal() { + // Ensure the DWT cycle counter is running (used for nonce entropy/timing). + ARM_DEMCR |= ARM_DEMCR_TRCENA; + ARM_DWT_CTRL |= 1; // CYCCNTENA + return g_boot_hal; +} + +} // namespace boot + +#endif // ARDUINO / __IMXRT1062__ diff --git a/firmware/controller/src/hal/board.h b/firmware/controller/src/hal/board.h new file mode 100644 index 000000000..95d7a9390 --- /dev/null +++ b/firmware/controller/src/hal/board.h @@ -0,0 +1,47 @@ +/** + * Compile-time board profiles and the GET_INFO descriptor. + * + * Exactly one board_*.cpp is linked per build (selected by the platformio src + * filter and -DBOARD_SQUID_V2); each defines board_descriptor() with its own + * InfoPayload. Board-scoped pin/wiring constants live here for Phase C to + * consume when it configures GPIO. + * + * Pure declarations + POD constants — no Arduino, no heap. + */ + +#ifndef HAL_BOARD_H +#define HAL_BOARD_H + +#include + +#include "protocol/frames.h" + +namespace hal { + +// Values for InfoPayload.axis_driver (mirror the frames.h field comment). +enum AxisDriver : uint8_t { + DRIVER_NONE = 0, + DRIVER_TMC2660 = 1, + DRIVER_TMC2240 = 2, +}; + +// --- Squid v1 board ------------------------------------------------------- +// 4 camera triggers on Teensy pins 29-32; one shared ready line. +static const uint8_t kCamTriggerPinsV1[] = {29, 30, 31, 32}; +static const uint8_t kNumCamTriggersV1 = 4; +static const uint8_t kNumReadyInputsV1 = 1; +static const uint8_t kBoardReadyLinePinV1 = 0; // TBD — Hongquan to assign (last open hw item) + +// --- Squid v2 board (STUB — values provisional, refined in Phase C/D) ----- +static const uint8_t kNumCamTriggersV2 = 8; +// 2 direct + 8 MCP23S17-expander ready inputs. Only up to kMaxCameras (8) +// camera-ready lines map to the u8 cam_ready_mask; n_ready_inputs is the total +// count of physical ready inputs and may exceed 8. +static const uint8_t kNumReadyInputsV2 = 10; + +// The board descriptor returned by GET_INFO. +const protocol::InfoPayload& board_descriptor(); + +} // namespace hal + +#endif // HAL_BOARD_H diff --git a/firmware/controller/src/hal/boards/board_squid_v1.cpp b/firmware/controller/src/hal/boards/board_squid_v1.cpp new file mode 100644 index 000000000..159d6bb28 --- /dev/null +++ b/firmware/controller/src/hal/boards/board_squid_v1.cpp @@ -0,0 +1,30 @@ +/** + * Squid v1 board profile (GET_INFO descriptor). + * Linked into teensy41 (default) and native test_board. + */ + +#include "hal/board.h" + +namespace hal { + +const protocol::InfoPayload& board_descriptor() { + // Positional aggregate init (C++11); field order per protocol::InfoPayload. + static const protocol::InfoPayload d = { + 1, // board_id (Squid v1) + 0, // board_rev + 1, // mcu_id (Teensy 4.1 / RT1062) + 5, // n_axes: X, Y, Z, FILTER1, FILTER2 + // axis_driver[8]: probed at runtime (Phase M) -> 0 for now. + {DRIVER_NONE, DRIVER_NONE, DRIVER_NONE, DRIVER_NONE, DRIVER_NONE, 0, 0, 0}, + 8, // n_dacs (DAC80508) + 5, // n_illum_ttl + 1, // has_led_matrix + kNumCamTriggersV1, // n_cam_triggers (4, pins 29-32) + kNumReadyInputsV1, // n_ready_inputs (1 shared ready line) + 16, // max_program_channels + 0, // feature_bits (TBD) + }; + return d; +} + +} // namespace hal diff --git a/firmware/controller/src/hal/boards/board_squid_v2.cpp b/firmware/controller/src/hal/boards/board_squid_v2.cpp new file mode 100644 index 000000000..521a8a8af --- /dev/null +++ b/firmware/controller/src/hal/boards/board_squid_v2.cpp @@ -0,0 +1,30 @@ +/** + * Squid v2 board profile (GET_INFO descriptor) — STUB, values provisional. + * Linked into teensy41_boardv2 (-DBOARD_SQUID_V2) and native test_board_v2. + * Refined in Phase C/D against the real v2 hardware. + */ + +#include "hal/board.h" + +namespace hal { + +const protocol::InfoPayload& board_descriptor() { + static const protocol::InfoPayload d = { + 2, // board_id (Squid v2) + 0, // board_rev + 1, // mcu_id (Teensy 4.1 / RT1062) + 5, // n_axes (provisional) + // v2 is known TMC2240 on the populated axes (not runtime-probed). + {DRIVER_TMC2240, DRIVER_TMC2240, DRIVER_TMC2240, DRIVER_TMC2240, DRIVER_TMC2240, 0, 0, 0}, + 8, // n_dacs (provisional) + 5, // n_illum_ttl (provisional) + 1, // has_led_matrix + kNumCamTriggersV2, // n_cam_triggers (8) + kNumReadyInputsV2, // n_ready_inputs (2 direct + 8 expander) + 16, // max_program_channels + 0, // feature_bits (TBD) + }; + return d; +} + +} // namespace hal diff --git a/firmware/controller/src/protocol/claims.cpp b/firmware/controller/src/protocol/claims.cpp new file mode 100644 index 000000000..364e0a0c5 --- /dev/null +++ b/firmware/controller/src/protocol/claims.cpp @@ -0,0 +1,41 @@ +/** + * Resource-claims lookup and conflict checking. See claims.h for the contract. + */ + +#include "protocol/claims.h" + +namespace protocol { + +uint64_t claims_for_in(const ClaimsRow* table, size_t count, uint8_t cmd_type, + const uint8_t* payload, size_t len) { + for (size_t i = 0; i < count; ++i) { + if (table[i].cmd_type == cmd_type) { + if (table[i].computed != nullptr) { + return table[i].computed(payload, len); // computed overrides static + } + return table[i].static_claims; + } + } + return 0; // command not in the table claims nothing +} + +uint64_t claims_for(uint8_t cmd_type, const uint8_t* payload, size_t len) { + size_t count = 0; + const ClaimsRow* table = claims_table(&count); + return claims_for_in(table, count, cmd_type, payload, len); +} + +uint8_t claims_conflict(uint64_t wanted, uint64_t inflight_union) { + uint64_t overlap = wanted & inflight_union; + if (overlap == 0) { + return 0; // compatible + } + for (uint8_t bit = 0; bit < 64; ++bit) { + if (overlap & (uint64_t(1) << bit)) { + return (uint8_t)(bit + 1); // lowest-set conflicting resource + 1 + } + } + return 0; // unreachable (overlap != 0 guarantees a set bit) +} + +} // namespace protocol diff --git a/firmware/controller/src/protocol/claims.h b/firmware/controller/src/protocol/claims.h new file mode 100644 index 000000000..178564d02 --- /dev/null +++ b/firmware/controller/src/protocol/claims.h @@ -0,0 +1,33 @@ +/** + * Resource-claims lookup and conflict checking for the command dispatcher. + * + * `claims_for` maps a command to the u32 resource mask it wants (via the + * production table in claims_table.h). `claims_conflict` decides whether a + * wanted mask can coexist with the union of in-flight claims. Pure, no heap. + */ + +#ifndef PROTOCOL_CLAIMS_H +#define PROTOCOL_CLAIMS_H + +#include +#include + +#include "protocol/claims_table.h" + +namespace protocol { + +// Resource mask an incoming command claims, from the production table. +uint64_t claims_for(uint8_t cmd_type, const uint8_t* payload, size_t len); + +// Same lookup against an explicit table (tests and Phase D supply their own). +uint64_t claims_for_in(const ClaimsRow* table, size_t count, uint8_t cmd_type, + const uint8_t* payload, size_t len); + +// 0 if `wanted` and `inflight_union` share no resources; otherwise the +// lowest-set conflicting resource bit index + 1 (so 0 unambiguously means +// "no conflict", and the caller recovers the resource id by subtracting 1). +uint8_t claims_conflict(uint64_t wanted, uint64_t inflight_union); + +} // namespace protocol + +#endif // PROTOCOL_CLAIMS_H diff --git a/firmware/controller/src/protocol/claims_table.h b/firmware/controller/src/protocol/claims_table.h new file mode 100644 index 000000000..2c0f0f8fc --- /dev/null +++ b/firmware/controller/src/protocol/claims_table.h @@ -0,0 +1,48 @@ +/** + * Resource-claims table — THE AUTHORITATIVE MAP of which resources each + * command claims. REVIEW CAREFULLY: an incorrect row lets two commands touch + * the same hardware concurrently (or needlessly blocks compatible commands). + * + * One row per command. When `computed` is non-null it OVERRIDES `static_claims` + * (the sequencer supplies a computed function in Phase D so its claim set can + * depend on the program payload). Resource bits are defined in frames.h. + * + * Phase B: system commands only, all claiming nothing (read-only or + * session-level). Motion / axis-config / illumination / camera / sequencer + * rows are added alongside their handlers in Phase C/D. + */ + +#ifndef PROTOCOL_CLAIMS_TABLE_H +#define PROTOCOL_CLAIMS_TABLE_H + +#include +#include + +#include "protocol/frames.h" + +namespace protocol { + +struct ClaimsRow { + uint8_t cmd_type; + uint64_t static_claims; + uint64_t (*computed)(const uint8_t* payload, size_t len); // null => static_claims +}; + +// The production claims table. Returns the row array; if `out_count` is +// non-null it receives the number of rows. +inline const ClaimsRow* claims_table(size_t* out_count) { + static const ClaimsRow rows[] = { + {HELLO, 0, nullptr}, + {GET_INFO, 0, nullptr}, + {GET_STATE, 0, nullptr}, + {DIAG, 0, nullptr}, + }; + if (out_count) { + *out_count = sizeof(rows) / sizeof(rows[0]); + } + return rows; +} + +} // namespace protocol + +#endif // PROTOCOL_CLAIMS_TABLE_H diff --git a/firmware/controller/src/protocol/cobs.cpp b/firmware/controller/src/protocol/cobs.cpp new file mode 100644 index 000000000..2da707f20 --- /dev/null +++ b/firmware/controller/src/protocol/cobs.cpp @@ -0,0 +1,96 @@ +/** + * Standard COBS (Cheshire & Baker) encode/decode, no heap. + * See cobs.h for the contract. + */ + +#include "protocol/cobs.h" + +namespace protocol { + +size_t cobs_max_encoded_len(size_t len) { + // One code byte per block of up to 254 data bytes, plus a possible + // trailing block: len + 1 + ceil(len / 254). + return len + 1 + (len + 253) / 254; +} + +size_t cobs_encode(const uint8_t* in, size_t len, uint8_t* out, size_t out_cap) { + if (out_cap == 0) { + return 0; // no room even for the first code byte + } + + size_t read = 0; + size_t write = 1; // out[0..] reserved for the running code byte + size_t code_idx = 0; // position of the code byte for the current block + uint8_t code = 1; // 1 + number of non-zero bytes in the current block + + while (read < len) { + uint8_t b = in[read++]; + if (b == 0) { + out[code_idx] = code; // close the current block + code_idx = write; // reserve next code byte + code = 1; + if (write >= out_cap) { + return 0; + } + write++; + } else { + if (write >= out_cap) { + return 0; + } + out[write++] = b; + code++; + if (code == 0xFF) { // block full (254 data bytes) + out[code_idx] = code; + code_idx = write; + code = 1; + if (write >= out_cap) { + return 0; + } + write++; + } + } + } + + out[code_idx] = code; // close the final block + return write; +} + +int32_t cobs_decode(const uint8_t* in, size_t len, uint8_t* out, size_t out_cap) { + size_t read = 0; + size_t write = 0; + + while (read < len) { + uint8_t code = in[read++]; + if (code == 0) { + return -1; // 0x00 must never appear inside an encoded block + } + + // Copy (code - 1) literal data bytes. + for (uint8_t i = 1; i < code; ++i) { + if (read >= len) { + return -1; // code points past end of input (truncated) + } + uint8_t b = in[read++]; + if (b == 0) { + return -1; // embedded zero inside the block + } + if (write >= out_cap) { + return -1; // decoded output would overflow + } + out[write++] = b; + } + + // A block shorter than 0xFF encodes an implicit trailing zero, except + // for the final block (when the input is exhausted). + if (code != 0xFF && read < len) { + if (write >= out_cap) { + return -1; + } + out[write++] = 0; + } + } + + return (int32_t)write; +} + +} // namespace protocol diff --git a/firmware/controller/src/protocol/cobs.h b/firmware/controller/src/protocol/cobs.h new file mode 100644 index 000000000..abe8db4e0 --- /dev/null +++ b/firmware/controller/src/protocol/cobs.h @@ -0,0 +1,35 @@ +/** + * Consistent Overhead Byte Stuffing (COBS) codec for protocol v2. + * + * Removes all 0x00 bytes from a frame so that 0x00 can serve as an + * unambiguous frame delimiter on the wire. Pure C++, no heap, no Arduino. + * + * Standard COBS (Cheshire & Baker): the encoder emits blocks of 1 code byte + * plus up to 254 non-zero data bytes; the worst case adds one overhead byte + * per 254 data bytes plus a possible trailing block. + */ + +#ifndef PROTOCOL_COBS_H +#define PROTOCOL_COBS_H + +#include +#include + +namespace protocol { + +// Upper bound on the encoded length for a payload of `len` bytes +// (excludes the trailing 0x00 delimiter the framer appends). +size_t cobs_max_encoded_len(size_t len); + +// Encodes `len` bytes from `in` into `out`. Returns the encoded length, or 0 +// if `out_cap` is too small. The output never contains a 0x00 byte. +size_t cobs_encode(const uint8_t* in, size_t len, uint8_t* out, size_t out_cap); + +// Decodes `len` COBS bytes from `in` into `out`. Returns the decoded length, +// or -1 on malformed input: an embedded 0x00, a code byte pointing past the +// end (truncated), or an output that would exceed `out_cap`. +int32_t cobs_decode(const uint8_t* in, size_t len, uint8_t* out, size_t out_cap); + +} // namespace protocol + +#endif // PROTOCOL_COBS_H diff --git a/firmware/controller/src/protocol/crc16.cpp b/firmware/controller/src/protocol/crc16.cpp new file mode 100644 index 000000000..5616f5282 --- /dev/null +++ b/firmware/controller/src/protocol/crc16.cpp @@ -0,0 +1,62 @@ +/** + * CRC-16/CCITT-FALSE implementation (polynomial 0x1021, initial value 0xFFFF). + * + * The 256-entry table below is the standard CRC-16/CCITT table for poly 0x1021 + * — the same values any correct generator produces. The Python mirror + * (software/control/protocol_v2/crc16.py) computes an identical table at + * import; the shared check value 0x29B1 and the C<->Python golden vectors + * guarantee the two stay in lockstep. + */ + +#include "protocol/crc16.h" + +namespace protocol { + +// Pre-computed CRC-16/CCITT lookup table (poly 0x1021). +static const uint16_t CRC16_TABLE[256] = { + 0x0000, 0x1021, 0x2042, 0x3063, 0x4084, 0x50A5, 0x60C6, 0x70E7, + 0x8108, 0x9129, 0xA14A, 0xB16B, 0xC18C, 0xD1AD, 0xE1CE, 0xF1EF, + 0x1231, 0x0210, 0x3273, 0x2252, 0x52B5, 0x4294, 0x72F7, 0x62D6, + 0x9339, 0x8318, 0xB37B, 0xA35A, 0xD3BD, 0xC39C, 0xF3FF, 0xE3DE, + 0x2462, 0x3443, 0x0420, 0x1401, 0x64E6, 0x74C7, 0x44A4, 0x5485, + 0xA56A, 0xB54B, 0x8528, 0x9509, 0xE5EE, 0xF5CF, 0xC5AC, 0xD58D, + 0x3653, 0x2672, 0x1611, 0x0630, 0x76D7, 0x66F6, 0x5695, 0x46B4, + 0xB75B, 0xA77A, 0x9719, 0x8738, 0xF7DF, 0xE7FE, 0xD79D, 0xC7BC, + 0x48C4, 0x58E5, 0x6886, 0x78A7, 0x0840, 0x1861, 0x2802, 0x3823, + 0xC9CC, 0xD9ED, 0xE98E, 0xF9AF, 0x8948, 0x9969, 0xA90A, 0xB92B, + 0x5AF5, 0x4AD4, 0x7AB7, 0x6A96, 0x1A71, 0x0A50, 0x3A33, 0x2A12, + 0xDBFD, 0xCBDC, 0xFBBF, 0xEB9E, 0x9B79, 0x8B58, 0xBB3B, 0xAB1A, + 0x6CA6, 0x7C87, 0x4CE4, 0x5CC5, 0x2C22, 0x3C03, 0x0C60, 0x1C41, + 0xEDAE, 0xFD8F, 0xCDEC, 0xDDCD, 0xAD2A, 0xBD0B, 0x8D68, 0x9D49, + 0x7E97, 0x6EB6, 0x5ED5, 0x4EF4, 0x3E13, 0x2E32, 0x1E51, 0x0E70, + 0xFF9F, 0xEFBE, 0xDFDD, 0xCFFC, 0xBF1B, 0xAF3A, 0x9F59, 0x8F78, + 0x9188, 0x81A9, 0xB1CA, 0xA1EB, 0xD10C, 0xC12D, 0xF14E, 0xE16F, + 0x1080, 0x00A1, 0x30C2, 0x20E3, 0x5004, 0x4025, 0x7046, 0x6067, + 0x83B9, 0x9398, 0xA3FB, 0xB3DA, 0xC33D, 0xD31C, 0xE37F, 0xF35E, + 0x02B1, 0x1290, 0x22F3, 0x32D2, 0x4235, 0x5214, 0x6277, 0x7256, + 0xB5EA, 0xA5CB, 0x95A8, 0x8589, 0xF56E, 0xE54F, 0xD52C, 0xC50D, + 0x34E2, 0x24C3, 0x14A0, 0x0481, 0x7466, 0x6447, 0x5424, 0x4405, + 0xA7DB, 0xB7FA, 0x8799, 0x97B8, 0xE75F, 0xF77E, 0xC71D, 0xD73C, + 0x26D3, 0x36F2, 0x0691, 0x16B0, 0x6657, 0x7676, 0x4615, 0x5634, + 0xD94C, 0xC96D, 0xF90E, 0xE92F, 0x99C8, 0x89E9, 0xB98A, 0xA9AB, + 0x5844, 0x4865, 0x7806, 0x6827, 0x18C0, 0x08E1, 0x3882, 0x28A3, + 0xCB7D, 0xDB5C, 0xEB3F, 0xFB1E, 0x8BF9, 0x9BD8, 0xABBB, 0xBB9A, + 0x4A75, 0x5A54, 0x6A37, 0x7A16, 0x0AF1, 0x1AD0, 0x2AB3, 0x3A92, + 0xFD2E, 0xED0F, 0xDD6C, 0xCD4D, 0xBDAA, 0xAD8B, 0x9DE8, 0x8DC9, + 0x7C26, 0x6C07, 0x5C64, 0x4C45, 0x3CA2, 0x2C83, 0x1CE0, 0x0CC1, + 0xEF1F, 0xFF3E, 0xCF5D, 0xDF7C, 0xAF9B, 0xBFBA, 0x8FD9, 0x9FF8, + 0x6E17, 0x7E36, 0x4E55, 0x5E74, 0x2E93, 0x3EB2, 0x0ED1, 0x1EF0 +}; + +uint16_t crc16_ccitt(const uint8_t* data, size_t length) +{ + uint16_t crc = 0xFFFF; // Initial value + + for (size_t i = 0; i < length; ++i) { + crc = (uint16_t)((crc << 8) ^ CRC16_TABLE[((crc >> 8) ^ data[i]) & 0xFF]); + } + + return crc; +} + +} // namespace protocol diff --git a/firmware/controller/src/protocol/crc16.h b/firmware/controller/src/protocol/crc16.h new file mode 100644 index 000000000..89a2774f0 --- /dev/null +++ b/firmware/controller/src/protocol/crc16.h @@ -0,0 +1,32 @@ +/** + * CRC-16/CCITT-FALSE for protocol v2 framing. + * + * Standard CRC-16/CCITT-FALSE (aka CRC-16/IBM-3740): polynomial 0x1021, + * initial value 0xFFFF, no input/output reflection, no final XOR. Table-driven. + * Canonical check value: crc16("123456789") == 0x29B1. + * + * This is the wire-integrity contract: it must agree byte-for-byte with the + * host codec (software/control/protocol_v2/crc16.py). Pure C++, no Arduino + * dependencies, so it is included directly in native unit tests. + */ + +#ifndef PROTOCOL_CRC16_H +#define PROTOCOL_CRC16_H + +#include +#include + +namespace protocol { + +/** + * Calculate CRC-16/CCITT-FALSE over a data buffer. + * + * @param data Pointer to data buffer (may be null iff length == 0) + * @param length Number of bytes to process + * @return 16-bit CRC value (0xFFFF for an empty buffer) + */ +uint16_t crc16_ccitt(const uint8_t* data, size_t length); + +} // namespace protocol + +#endif // PROTOCOL_CRC16_H diff --git a/firmware/controller/src/protocol/dispatch_v2.cpp b/firmware/controller/src/protocol/dispatch_v2.cpp new file mode 100644 index 000000000..05ab170e1 --- /dev/null +++ b/firmware/controller/src/protocol/dispatch_v2.cpp @@ -0,0 +1,161 @@ +/** + * Protocol v2 dispatcher implementation. See dispatch_v2.h for the contract. + */ + +#include "protocol/dispatch_v2.h" + +namespace protocol { + +// --- System command handlers --------------------------------------------- + +// GET_STATE needs no extra payload — the StandardResponse carries everything. +static void h_get_state(Dispatcher&, const uint8_t*, size_t, ResponseWriter&) {} + +// HELLO returns session info and starts a fresh session (clears all slots). +static void h_hello(Dispatcher& d, const uint8_t*, size_t, ResponseWriter& w) { + HelloPayload hp; + memset(&hp, 0, sizeof(hp)); + d.provider().fill_hello(hp); + w.append_struct(hp); + d.slots().reset(); +} + +static void h_get_info(Dispatcher& d, const uint8_t*, size_t, ResponseWriter& w) { + InfoPayload ip; + memset(&ip, 0, sizeof(ip)); + d.provider().fill_info(ip); + w.append_struct(ip); +} + +// DIAG page 0 returns counters; page >= 1 returns fault-ring entries. +static void h_diag(Dispatcher& d, const uint8_t* payload, size_t len, ResponseWriter& w) { + uint8_t page = (len >= 1) ? payload[0] : 0; + if (page == 0) { + DiagPayload dp; + memset(&dp, 0, sizeof(dp)); + d.provider().fill_diag_page0(dp); + dp.page = 0; + w.append_struct(dp); + } else { + FaultEntryWire faults[16]; + memset(faults, 0, sizeof(faults)); + uint8_t n = d.provider().fill_diag_faults(page, faults, 16); + if (n > 16) { + n = 16; + } + for (uint8_t i = 0; i < n; ++i) { + w.append_struct(faults[i]); + } + } +} + +// --- Dispatcher ----------------------------------------------------------- + +Dispatcher::Dispatcher(SlotManager& slots, StateProvider& provider) + : slots_(slots), provider_(provider), framer_(nullptr), resp_buf_{} { + memset(registry_, 0, sizeof(registry_)); +} + +void Dispatcher::register_command(uint8_t cmd_type, bool immediate, uint16_t min_len, + uint16_t max_len, Handler handler) { + Entry& e = registry_[cmd_type]; + e.registered = true; + e.immediate = immediate; + e.min_len = min_len; + e.max_len = max_len; + e.handler = handler; +} + +void Dispatcher::register_system_commands() { + register_command(HELLO, true, 0, 0, h_hello); + register_command(GET_INFO, true, 0, 0, h_get_info); + register_command(GET_STATE, true, 0, 0, h_get_state); + register_command(DIAG, true, 1, 1, h_diag); +} + +size_t Dispatcher::build_response(const uint8_t* req, size_t req_len, uint8_t* out, + size_t out_cap) { + if (req_len < sizeof(FrameHeader) || out_cap < sizeof(FrameHeader) + sizeof(StandardResponse)) { + return 0; // runt request or output can't hold a StandardResponse + } + + const uint8_t cmd_id = req[1]; + const uint8_t cmd_type = req[2]; + const uint8_t flags = req[3]; + const uint8_t* payload = req + sizeof(FrameHeader); + const size_t plen = req_len - sizeof(FrameHeader); + + StandardResponse sr; + memset(&sr, 0, sizeof(sr)); + sr.status = STATUS_OK; + sr.error_code = ERR_NONE; + + // Extra payload is bounded so the whole response fits within kMaxPayload. + uint8_t extra[kMaxPayload - sizeof(StandardResponse)]; + ResponseWriter w(sr, extra, sizeof(extra)); + + const Entry& e = registry_[cmd_type]; + if (!e.registered) { + w.set_status(STATUS_REJECTED, ERR_UNKNOWN_COMMAND, cmd_type, 0); + } else if (plen < e.min_len || plen > e.max_len) { + w.set_status(STATUS_REJECTED, ERR_BAD_LENGTH, (uint8_t)plen, 0); + } else if (e.immediate) { + // Synchronous system query: no slot, no ring entry. + e.handler(*this, payload, plen, w); + } else { + // Slotted async command routed through the SlotManager. + const bool retry = (flags & FLAG_RETRY) != 0; + const uint64_t claims = claims_for(cmd_type, payload, plen); + uint8_t conflict_res = 0, holder = 0, ring_status = 0, ring_error = 0; + AcceptResult ar = slots_.try_accept(cmd_id, cmd_type, claims, retry, &conflict_res, + &holder, &ring_status, &ring_error); + switch (ar) { + case AcceptResult::NewCommand: + w.set_status(STATUS_ACCEPTED); + e.handler(*this, payload, plen, w); // start the command + break; + case AcceptResult::ActiveDuplicate: + w.set_status(STATUS_ACCEPTED); // already in flight + break; + case AcceptResult::CompletedDuplicate: + w.set_status(ring_status, ring_error); // replay outcome, no re-run + break; + case AcceptResult::RejectBusy: + w.set_status(STATUS_REJECTED, ERR_RESOURCE_BUSY, conflict_res, holder); + break; + case AcceptResult::RejectNoSlots: + w.set_status(STATUS_REJECTED, ERR_NO_SLOTS); + break; + } + } + + // Fill machine state and the slots/ring section AFTER dispatch so they + // reflect any accept/complete/reset the handler performed. These touch + // disjoint fields from the status set above. + provider_.fill_state(sr); + slots_.fill_response(sr); + + const size_t extra_len = w.extra_len(); + const size_t total = sizeof(FrameHeader) + sizeof(StandardResponse) + extra_len; + if (total > out_cap) { + return 0; + } + + out[0] = RESPONSE; + out[1] = cmd_id; + out[2] = cmd_type; + out[3] = 0; // response flags + memcpy(&out[sizeof(FrameHeader)], &sr, sizeof(StandardResponse)); + memcpy(&out[sizeof(FrameHeader) + sizeof(StandardResponse)], extra, extra_len); + return total; +} + +void Dispatcher::on_frame(const uint8_t* frame, size_t len) { + size_t n = build_response(frame, len, resp_buf_, sizeof(resp_buf_)); + if (n == 0 || framer_ == nullptr) { + return; + } + framer_->send_frame(resp_buf_, n); +} + +} // namespace protocol diff --git a/firmware/controller/src/protocol/dispatch_v2.h b/firmware/controller/src/protocol/dispatch_v2.h new file mode 100644 index 000000000..61759ad79 --- /dev/null +++ b/firmware/controller/src/protocol/dispatch_v2.h @@ -0,0 +1,139 @@ +/** + * Protocol v2 command dispatcher. + * + * Turns a decoded REQUEST frame (header + payload) into a RESPONSE frame whose + * payload is a StandardResponse (slots + ring + machine state) optionally + * followed by a command-specific extra payload (HELLO/GET_INFO/DIAG). + * + * Two command kinds: + * - immediate: system queries answered synchronously; no slot, no ring entry. + * - slotted: claims-gated async commands routed through the SlotManager; + * RETRY of a completed command is answered from the ring without re-running. + * + * Machine state is read through an injected StateProvider (Phase C binds real + * globals; tests fake it). The core is `build_response`, a pure function; the + * FrameSink glue sends the built frame through a bound Framer. + * + * Pure, no heap; fixed buffers sized by the wire contract. + */ + +#ifndef PROTOCOL_DISPATCH_V2_H +#define PROTOCOL_DISPATCH_V2_H + +#include +#include +#include + +#include "protocol/claims.h" +#include "protocol/frames.h" +#include "protocol/framer.h" +#include "protocol/slots.h" + +namespace protocol { + +class Dispatcher; // forward declaration for the Handler signature + +// Builds the command outcome and any extra payload appended after the +// StandardResponse. Handlers set status via the writer and append extra bytes; +// the dispatcher fills slots/ring and machine state around them. +class ResponseWriter { +public: + ResponseWriter(StandardResponse& sr, uint8_t* extra, size_t extra_cap) + : sr_(sr), extra_(extra), cap_(extra_cap), len_(0) {} + + StandardResponse& std_response() { return sr_; } + + void set_status(uint8_t status, uint8_t error_code = ERR_NONE, uint8_t detail0 = 0, + uint8_t detail1 = 0) { + sr_.status = status; + sr_.error_code = error_code; + sr_.error_detail0 = detail0; + sr_.error_detail1 = detail1; + } + + bool append(const uint8_t* data, size_t n) { + if (len_ + n > cap_) { + return false; + } + for (size_t i = 0; i < n; ++i) { + extra_[len_ + i] = data[i]; + } + len_ += n; + return true; + } + + template + bool append_struct(const T& s) { + return append(reinterpret_cast(&s), sizeof(T)); + } + + size_t extra_len() const { return len_; } + +private: + StandardResponse& sr_; + uint8_t* extra_; + size_t cap_; + size_t len_; +}; + +// Source of live machine state and info/diagnostic payloads. Implementations +// must fill ONLY the machine-state fields of StandardResponse in fill_state +// (mode, axes, dacs, illum, cam, seq, input_states, fw/protocol versions) — +// never status/error/details/slots/ring, which the dispatcher owns. +class StateProvider { +public: + virtual ~StateProvider() {} + virtual void fill_state(StandardResponse& r) = 0; + virtual void fill_hello(HelloPayload& h) = 0; + virtual void fill_info(InfoPayload& i) = 0; + virtual void fill_diag_page0(DiagPayload& d) = 0; + // Fill up to `cap` fault-ring entries for DIAG page >= 1; returns the count. + virtual uint8_t fill_diag_faults(uint8_t page, FaultEntryWire* out, uint8_t cap) = 0; +}; + +typedef void (*Handler)(Dispatcher& d, const uint8_t* payload, size_t len, ResponseWriter& w); + +class Dispatcher : public FrameSink { +public: + Dispatcher(SlotManager& slots, StateProvider& provider); + + // Register a command. `immediate` commands answer synchronously without a + // slot; slotted commands route through the SlotManager. Length bounds are + // inclusive; claims are resolved via claims_for at dispatch time. + void register_command(uint8_t cmd_type, bool immediate, uint16_t min_len, uint16_t max_len, + Handler handler); + + // Register HELLO / GET_INFO / GET_STATE / DIAG. + void register_system_commands(); + + // Pure core: build the RESPONSE frame (header + payload, no CRC) for a + // REQUEST frame. Returns the response length, or 0 if the request is a runt + // or the response would not fit in `out_cap`. + size_t build_response(const uint8_t* req, size_t req_len, uint8_t* out, size_t out_cap); + + // FrameSink: build the response and send it through the bound Framer. + void on_frame(const uint8_t* frame, size_t len) override; + void set_framer(Framer& f) { framer_ = &f; } + + SlotManager& slots() { return slots_; } + StateProvider& provider() { return provider_; } + +private: + struct Entry { + bool registered; + bool immediate; + uint16_t min_len; + uint16_t max_len; + Handler handler; + }; + + SlotManager& slots_; + StateProvider& provider_; + Framer* framer_; + Entry registry_[256]; + uint8_t resp_buf_[kMaxFrame]; // scratch for the on_frame send path +}; + +} // namespace protocol + +#endif // PROTOCOL_DISPATCH_V2_H diff --git a/firmware/controller/src/protocol/framer.cpp b/firmware/controller/src/protocol/framer.cpp new file mode 100644 index 000000000..c81658b7b --- /dev/null +++ b/firmware/controller/src/protocol/framer.cpp @@ -0,0 +1,107 @@ +/** + * COBS frame pump implementation. See framer.h for the contract. + */ + +#include "protocol/framer.h" + +#include "protocol/cobs.h" +#include "protocol/crc16.h" + +namespace protocol { + +Framer::Framer(FrameSink& sink, ByteSink& out) + : sink_(sink), + out_(out), + counters_(), + rx_buf_{}, + rx_len_(0), + rx_overflowed_(false), + dec_buf_{}, + tx_dec_{}, + tx_enc_{} {} + +void Framer::feed_rx(uint8_t byte) { + if (byte == 0x00) { + // Delimiter: end of the current frame (or an idle/duplicate delimiter). + if (rx_overflowed_) { + // The oversize frame was already counted; resync on this boundary. + rx_len_ = 0; + rx_overflowed_ = false; + return; + } + if (rx_len_ != 0) { + process_frame(rx_buf_, rx_len_); + } + rx_len_ = 0; + return; + } + + if (rx_overflowed_) { + return; // discard the rest of the oversize frame until the delimiter + } + if (rx_len_ >= kBufCap) { + // Frame exceeds the worst-case encoded size: too big to be valid. + counters_.rx_overflow++; + rx_overflowed_ = true; + return; + } + rx_buf_[rx_len_++] = byte; +} + +void Framer::process_frame(const uint8_t* enc, size_t enc_len) { + int32_t dec_len = cobs_decode(enc, enc_len, dec_buf_, sizeof(dec_buf_)); + if (dec_len < 0) { + counters_.resync++; // malformed COBS structure + return; + } + if ((size_t)dec_len > kMaxFrame) { + counters_.rx_overflow++; // decoded frame exceeds the protocol max + return; + } + if ((size_t)dec_len < sizeof(FrameHeader) + 2) { + counters_.resync++; // runt: no room for header + CRC + return; + } + + size_t body = (size_t)dec_len - 2; // header + payload (CRC stripped) + uint16_t rx_crc = (uint16_t)(dec_buf_[body] | ((uint16_t)dec_buf_[body + 1] << 8)); + uint16_t calc = crc16_ccitt(dec_buf_, body); + if (rx_crc != calc) { + counters_.crc_err++; + return; + } + + counters_.frames_ok++; + sink_.on_frame(dec_buf_, body); +} + +bool Framer::send_frame(const uint8_t* frame, size_t len) { + if (len + 2 > kMaxFrame) { + return false; // frame + CRC would exceed the protocol max + } + + for (size_t i = 0; i < len; ++i) { + tx_dec_[i] = frame[i]; + } + uint16_t crc = crc16_ccitt(frame, len); + tx_dec_[len] = (uint8_t)(crc & 0xFF); + tx_dec_[len + 1] = (uint8_t)(crc >> 8); + + size_t enc_len = cobs_encode(tx_dec_, len + 2, tx_enc_, sizeof(tx_enc_)); + if (enc_len == 0) { + return false; // unreachable: tx_enc_ is sized for the worst case + } + + size_t wire_len = enc_len + 1; // encoded bytes + 0x00 delimiter + if (out_.writable() < wire_len) { + counters_.tx_drop++; + return false; // never block + } + + out_.write(tx_enc_, enc_len); + const uint8_t delim = 0x00; + out_.write(&delim, 1); + return true; +} + +} // namespace protocol diff --git a/firmware/controller/src/protocol/framer.h b/firmware/controller/src/protocol/framer.h new file mode 100644 index 000000000..7697948db --- /dev/null +++ b/firmware/controller/src/protocol/framer.h @@ -0,0 +1,88 @@ +/** + * COBS frame pump for protocol v2 — RX byte accumulation + TX framing. + * + * RX: bytes are accumulated until a 0x00 delimiter, then COBS-decoded and + * CRC-16 checked; a valid frame (header + payload, WITHOUT the trailing CRC) + * is delivered to a FrameSink. Because 0x00 never occurs inside an encoded + * frame, every delimiter is a clean resync point: a single corrupted byte can + * damage only the frame it lands in — never a neighbour. + * + * TX: send_frame() appends CRC-16, COBS-encodes, and emits frame + 0x00 to a + * ByteSink. It never blocks: if the sink lacks room for the whole wire frame + * it drops the frame and increments tx_drop. + * + * Fixed buffers, no heap, no Arduino. IO and the frame sink are injected so + * the whole class is native-testable. + */ + +#ifndef PROTOCOL_FRAMER_H +#define PROTOCOL_FRAMER_H + +#include +#include + +#include "protocol/frames.h" + +namespace protocol { + +struct FramerCounters { + uint32_t crc_err; // decoded OK but CRC mismatch + uint32_t resync; // malformed COBS / runt frame + uint32_t rx_overflow; // frame exceeded the max size (encoded or decoded) + uint32_t tx_drop; // send dropped because the TX sink was full + uint32_t frames_ok; // valid frames delivered to the sink +}; + +// Receives decoded, CRC-valid frames (header + payload, no CRC). +class FrameSink { +public: + virtual ~FrameSink() {} + virtual void on_frame(const uint8_t* frame, size_t len) = 0; +}; + +// Byte-oriented output (maps to Serial.availableForWrite()/write() in Phase C). +class ByteSink { +public: + virtual ~ByteSink() {} + virtual size_t writable() = 0; // free space, in bytes + virtual void write(const uint8_t* b, size_t n) = 0; // caller guarantees room +}; + +class Framer { +public: + Framer(FrameSink& sink, ByteSink& out); + + // Feed one received byte. On a 0x00 delimiter, decode + CRC-check the + // accumulated frame and, if valid, deliver it to the FrameSink. + void feed_rx(uint8_t byte); + + // Append CRC-16, COBS-encode, and emit frame + 0x00 to the ByteSink. + // Returns false (and increments tx_drop) if the sink lacks room for the + // whole wire frame, or if frame + CRC would exceed the protocol max. + // Never blocks. `len` is the header + payload length (no CRC). + bool send_frame(const uint8_t* frame, size_t len); + + const FramerCounters& counters() const { return counters_; } + + // Worst-case COBS-encoded size of a max decoded frame (kMaxFrame). + static const size_t kBufCap = kMaxFrame + 1 + (kMaxFrame + 253) / 254; // 516 + +private: + void process_frame(const uint8_t* enc, size_t enc_len); + + FrameSink& sink_; + ByteSink& out_; + FramerCounters counters_; + + uint8_t rx_buf_[kBufCap]; // encoded bytes accumulated since last delimiter + size_t rx_len_; + bool rx_overflowed_; // discarding the current oversize frame + + uint8_t dec_buf_[kBufCap]; // RX decode scratch (sized to detect > kMaxFrame) + uint8_t tx_dec_[kMaxFrame]; // TX: frame + CRC before encoding + uint8_t tx_enc_[kBufCap]; // TX: COBS-encoded output +}; + +} // namespace protocol + +#endif // PROTOCOL_FRAMER_H diff --git a/firmware/controller/src/protocol/frames.h b/firmware/controller/src/protocol/frames.h new file mode 100644 index 000000000..4692da0f4 --- /dev/null +++ b/firmware/controller/src/protocol/frames.h @@ -0,0 +1,236 @@ +/** + * Protocol v2 wire contract — the SINGLE SOURCE OF TRUTH for frame layout. + * + * All multi-byte fields are little-endian. Structs are byte-packed so their + * on-wire size is alignment-independent and identical on x86_64 (native tests) + * and ARM Cortex-M7 (Teensy 4.1). The mirrored Python codec + * (software/control/protocol_v2/frames.py) and the golden vectors parse/mirror + * THIS header; never redefine these constants by hand elsewhere. + * + * Frame (decoded, before COBS): + * [type u8][cmd_id u8][cmd_type u8][flags u8][payload...][crc16 LE] + * Wire: COBS_encode(frame) + 0x00. Max decoded frame 512 B -> max payload 506 B. + * + * Pure declarations only — no functions with side effects. No Arduino deps. + */ + +#ifndef PROTOCOL_FRAMES_H +#define PROTOCOL_FRAMES_H + +#include +#include + +#if defined(__GNUC__) || defined(__clang__) +#define PROTO_PACKED __attribute__((packed)) +#else +#define PROTO_PACKED +#endif + +namespace protocol { + +// --- Sizing constants ----------------------------------------------------- + +static const size_t kMaxFrame = 512; // max decoded frame (before COBS) +static const size_t kMaxPayload = 506; // kMaxFrame - sizeof(FrameHeader) - 2 (crc16) +static const uint8_t kProtocolVersion = 2; + +// --- Enumerations (unscoped so members are usable as protocol::HELLO) ----- + +enum FrameType : uint8_t { + REQUEST = 0x01, + RESPONSE = 0x02, + EVENT = 0x03, // reserved, unused in v2.0 +}; + +enum FrameFlags : uint8_t { + FLAG_RETRY = 0x01, // bit0: re-send of the same cmd_id (dedup via slots+ring) +}; + +enum ResponseStatus : uint8_t { + STATUS_OK = 0, + STATUS_ACCEPTED = 1, + STATUS_REJECTED = 2, + STATUS_FAILED = 3, +}; + +// CommandType blocks (values not in the system block are Phase C/D): +// 0x01-0x0F motion | 0x10-0x1F axis config | 0x20-0x2F output/GPIO +// 0x30-0x3F illumination | 0x40-0x4F camera | 0x50-0x5F sequencer +// Phase B implements the system block only. +enum CommandType : uint8_t { + HELLO = 0xF0, + GET_INFO = 0xF1, + GET_STATE = 0xF2, + DIAG = 0xF3, + // Reserved now (declared for code completeness; handlers land later): + ACK_ERROR = 0xF4, + SET_WATCHDOG = 0xF5, + HEARTBEAT = 0xF6, + REBOOT_TO_BOOTLOADER = 0xFD, + INITIALIZE = 0xFE, + RESET = 0xFF, +}; + +enum ErrorCode : uint8_t { + ERR_NONE = 0x00, + // 0x10-0x2F rejection + ERR_UNKNOWN_COMMAND = 0x10, + ERR_INVALID_PARAMETER = 0x11, + ERR_BAD_LENGTH = 0x12, + ERR_RESOURCE_BUSY = 0x15, + ERR_NO_SLOTS = 0x16, + ERR_SYSTEM_IN_ERROR = 0x17, + // 0x40-0x5F hardware faults (Phase C/D) + // 0x60-0x6F comm + ERR_PACKET_CRC = 0x60, + ERR_PACKET_LENGTH = 0x61, +}; + +// --- Resource bits (u32 claim mask, design section 4.5) ------------------- + +// Claim mask is u64 (design doc §15 R2): axes 0..15, DACs 16..31, named 32+. +// Widened from u32 so new PCBs (e.g. 16 DACs) need no re-layout; the mask is +// internal (never on the wire), so this does not touch the response contract. +constexpr uint64_t res_axis(uint8_t n) { return uint64_t(1) << n; } // n in 0..15 +constexpr uint64_t res_dac(uint8_t n) { return uint64_t(1) << (16 + n); } // n in 0..15 +static const uint64_t RES_ILLUM_TTL = uint64_t(1) << 32; +static const uint64_t RES_LED_MATRIX = uint64_t(1) << 33; +static const uint64_t RES_CAM_TRIGGERS = uint64_t(1) << 34; +static const uint64_t RES_GPIO = uint64_t(1) << 35; +static const uint64_t RES_SEQUENCER = uint64_t(1) << 36; +static const uint64_t RES_SYS_CONFIG = uint64_t(1) << 37; + +// --- Packed wire structs -------------------------------------------------- + +struct PROTO_PACKED FrameHeader { + uint8_t type; // FrameType + uint8_t cmd_id; + uint8_t cmd_type; // CommandType + uint8_t flags; // FrameFlags +}; + +struct PROTO_PACKED Slot { + uint8_t cmd_id; + uint8_t cmd_type; + uint8_t state; + uint8_t progress; +}; + +struct PROTO_PACKED RingEntry { + uint8_t cmd_id; + uint8_t cmd_type; + uint8_t final_status; + uint8_t error_code; +}; + +struct PROTO_PACKED AxisStateWire { + int32_t pos; + uint8_t state; + uint8_t error; + uint8_t homed; + uint8_t rsv; +}; + +struct PROTO_PACKED SeqProgressWire { + uint16_t layer; + uint16_t total; + uint8_t ch; + uint8_t total_ch; + uint32_t frames; + uint8_t err; + uint8_t det; +}; + +// Fixed prefix of EVERY response payload (158 bytes). +struct PROTO_PACKED StandardResponse { + uint8_t status; // ResponseStatus + uint8_t error_code; // ErrorCode + uint8_t error_detail0; + uint8_t error_detail1; + Slot slots[5]; + uint8_t ring_head_seq; + RingEntry ring[8]; + uint8_t mode; + AxisStateWire axes[8]; + uint16_t dac_values[8]; + uint8_t illum_ttl_mask; + uint8_t led_pattern; + uint8_t cam_trigger_states; + uint8_t cam_ready_mask; + SeqProgressWire seq; + uint8_t input_states; // bit0 interlock_ok, bit1 power_good, bit2 joystick_btn + uint8_t fw_version_major; + uint8_t fw_version_minor; + uint8_t protocol_version; +}; + +// Appended after StandardResponse in a HELLO response (16 bytes). +struct PROTO_PACKED HelloPayload { + uint8_t protocol_version; + uint8_t fw_major; + uint8_t fw_minor; + uint8_t reset_cause; + uint32_t session_nonce; + uint32_t boot_count; + uint32_t uptime_ms; +}; + +// GET_INFO descriptor, appended after StandardResponse (22 bytes). +struct PROTO_PACKED InfoPayload { + uint8_t board_id; + uint8_t board_rev; + uint8_t mcu_id; + uint8_t n_axes; + uint8_t axis_driver[8]; // 0=none, 1=TMC2660, 2=TMC2240 + uint8_t n_dacs; + uint8_t n_illum_ttl; + uint8_t has_led_matrix; + uint8_t n_cam_triggers; + uint8_t n_ready_inputs; + uint8_t max_program_channels; + uint32_t feature_bits; +}; + +// DIAG page 0 counters, appended after StandardResponse (40 bytes). +struct PROTO_PACKED DiagPayload { + uint32_t loop_max_us; + uint32_t isr_max_us; + uint32_t crc_err; + uint32_t resync; + uint32_t rx_overflow; + uint32_t tx_drop; + uint32_t stack_free_min; + uint32_t uptime_ms; + uint32_t boot_count; + uint8_t fault_count; + uint8_t page; + uint8_t rsv[2]; +}; + +// DIAG page N>=1 fault-ring entry (8 bytes); up to 16 per page. +struct PROTO_PACKED FaultEntryWire { + uint32_t uptime_ms; + uint8_t code; + uint8_t detail; + uint16_t rsv; +}; + +// --- Layout guarantees (enforced on EVERY target that includes this header: +// native tests, teensy41, teensy41_boardv2, CI). Packed structs keep these +// identical on x86_64 and ARM; any drift breaks the build here, at the source +// of truth, rather than only in the native test. +static_assert(sizeof(FrameHeader) == 4, "FrameHeader must be 4 bytes"); +static_assert(sizeof(Slot) == 4, "Slot must be 4 bytes"); +static_assert(sizeof(RingEntry) == 4, "RingEntry must be 4 bytes"); +static_assert(sizeof(AxisStateWire) == 8, "AxisStateWire must be 8 bytes"); +static_assert(sizeof(SeqProgressWire) == 12, "SeqProgressWire must be 12 bytes"); +static_assert(sizeof(StandardResponse) == 158, "StandardResponse must be 158 bytes"); +static_assert(sizeof(HelloPayload) == 16, "HelloPayload must be 16 bytes"); +static_assert(sizeof(InfoPayload) == 22, "InfoPayload must be 22 bytes"); +static_assert(sizeof(DiagPayload) == 40, "DiagPayload must be 40 bytes"); +static_assert(sizeof(FaultEntryWire) == 8, "FaultEntryWire must be 8 bytes"); +static_assert(kMaxFrame == sizeof(FrameHeader) + kMaxPayload + 2, "frame budget: header + payload + crc16"); + +} // namespace protocol + +#endif // PROTOCOL_FRAMES_H diff --git a/firmware/controller/src/protocol/slots.cpp b/firmware/controller/src/protocol/slots.cpp new file mode 100644 index 000000000..46ca64799 --- /dev/null +++ b/firmware/controller/src/protocol/slots.cpp @@ -0,0 +1,171 @@ +/** + * Five-slot command manager + completion ring. See slots.h for the contract. + */ + +#include "protocol/slots.h" + +#include "protocol/claims.h" // claims_conflict + +namespace protocol { + +SlotManager::SlotManager() { reset(); } + +void SlotManager::reset() { + for (size_t i = 0; i < kNumSlots; ++i) { + slots_[i].cmd_id = 0; + slots_[i].cmd_type = 0; + slots_[i].state = SLOT_EMPTY; + slots_[i].progress = 0; + slots_[i].claims = 0; + } + for (size_t i = 0; i < kRingSize; ++i) { + ring_[i].cmd_id = 0; + ring_[i].cmd_type = 0; + ring_[i].final_status = 0; + ring_[i].error_code = 0; + } + completions_ = 0; +} + +int SlotManager::find_slot(uint8_t cmd_id) const { + for (size_t i = 0; i < kNumSlots; ++i) { + if (slots_[i].state != SLOT_EMPTY && slots_[i].cmd_id == cmd_id) { + return (int)i; + } + } + return -1; +} + +int SlotManager::find_free() const { + for (size_t i = 0; i < kNumSlots; ++i) { + if (slots_[i].state == SLOT_EMPTY) { + return (int)i; + } + } + return -1; +} + +const SlotInfo* SlotManager::find(uint8_t cmd_id) const { + int i = find_slot(cmd_id); + return (i >= 0) ? &slots_[i] : nullptr; +} + +uint64_t SlotManager::inflight_claims_union() const { + uint64_t u = 0; + for (size_t i = 0; i < kNumSlots; ++i) { + if (slots_[i].state != SLOT_EMPTY) { + u |= slots_[i].claims; + } + } + return u; +} + +bool SlotManager::ring_lookup(uint8_t cmd_id, uint8_t* out_status, uint8_t* out_error) const { + uint32_t count = (completions_ < kRingSize) ? completions_ : (uint32_t)kRingSize; + // Newest first, so a recycled cmd_id resolves to its latest outcome. + for (uint32_t k = 0; k < count; ++k) { + uint32_t idx = (completions_ - 1 - k) % (uint32_t)kRingSize; + if (ring_[idx].cmd_id == cmd_id) { + if (out_status) *out_status = ring_[idx].final_status; + if (out_error) *out_error = ring_[idx].error_code; + return true; + } + } + return false; +} + +AcceptResult SlotManager::try_accept(uint8_t cmd_id, uint8_t cmd_type, uint64_t claims, bool retry, + uint8_t* out_conflict_res, uint8_t* out_holder_cmd_id, + uint8_t* out_ring_status, uint8_t* out_ring_error) { + // 1. Already in flight? Never double-accept (dedup, retry or not). + if (find_slot(cmd_id) >= 0) { + return AcceptResult::ActiveDuplicate; + } + + // 2. RETRY of a completed command: replay the recorded outcome, no re-run. + if (retry) { + uint8_t st = 0, er = 0; + if (ring_lookup(cmd_id, &st, &er)) { + if (out_ring_status) *out_ring_status = st; + if (out_ring_error) *out_ring_error = er; + return AcceptResult::CompletedDuplicate; + } + // RETRY of an unknown command falls through and is treated as new. + } + + // 3. Resource conflict against the in-flight union. + uint8_t conflict = claims_conflict(claims, inflight_claims_union()); + if (conflict != 0) { + uint8_t res = (uint8_t)(conflict - 1); + if (out_conflict_res) *out_conflict_res = res; + if (out_holder_cmd_id) { + *out_holder_cmd_id = 0; + for (size_t i = 0; i < kNumSlots; ++i) { + if (slots_[i].state != SLOT_EMPTY && + (slots_[i].claims & (uint64_t(1) << res))) { + *out_holder_cmd_id = slots_[i].cmd_id; + break; + } + } + } + return AcceptResult::RejectBusy; + } + + // 4. Reserve a slot. + int free = find_free(); + if (free < 0) { + return AcceptResult::RejectNoSlots; + } + slots_[free].state = SLOT_ACTIVE; + slots_[free].cmd_id = cmd_id; + slots_[free].cmd_type = cmd_type; + slots_[free].progress = 0; + slots_[free].claims = claims; + return AcceptResult::NewCommand; +} + +void SlotManager::complete(uint8_t cmd_id, uint8_t final_status, uint8_t error_code) { + int i = find_slot(cmd_id); + if (i < 0) { + return; // not active — nothing to complete or record + } + uint8_t cmd_type = slots_[i].cmd_type; + + // Free the slot. + slots_[i].state = SLOT_EMPTY; + slots_[i].cmd_id = 0; + slots_[i].cmd_type = 0; + slots_[i].progress = 0; + slots_[i].claims = 0; + + // Record the outcome in the ring, advancing head_seq. + uint32_t idx = completions_ % (uint32_t)kRingSize; + ring_[idx].cmd_id = cmd_id; + ring_[idx].cmd_type = cmd_type; + ring_[idx].final_status = final_status; + ring_[idx].error_code = error_code; + completions_++; +} + +void SlotManager::set_progress(uint8_t cmd_id, uint8_t pct) { + int i = find_slot(cmd_id); + if (i < 0) { + return; + } + slots_[i].progress = (pct > 100) ? 100 : pct; +} + +void SlotManager::fill_response(StandardResponse& r) const { + for (size_t i = 0; i < kNumSlots; ++i) { + r.slots[i].cmd_id = slots_[i].cmd_id; + r.slots[i].cmd_type = slots_[i].cmd_type; + r.slots[i].state = slots_[i].state; + r.slots[i].progress = slots_[i].progress; + } + r.ring_head_seq = (uint8_t)completions_; + for (size_t i = 0; i < kRingSize; ++i) { + r.ring[i] = ring_[i]; + } +} + +} // namespace protocol diff --git a/firmware/controller/src/protocol/slots.h b/firmware/controller/src/protocol/slots.h new file mode 100644 index 000000000..1f1ca67e2 --- /dev/null +++ b/firmware/controller/src/protocol/slots.h @@ -0,0 +1,97 @@ +/** + * Five-slot concurrent command manager with an 8-entry completion ring. + * + * The dispatcher computes a command's resource claims (claims_for) and offers + * it here. try_accept() gates concurrency: it dedups retries, rejects + * resource conflicts (ERR_RESOURCE_BUSY) and slot exhaustion (ERR_NO_SLOTS), + * or reserves a slot. On completion the slot is freed and its outcome recorded + * in the ring so a later RETRY can be answered without re-execution. + * + * Pure, no heap; fixed arrays sized by the wire contract (frames.h). All + * state is software-tracked (filter/rotary axes have no position encoder). + */ + +#ifndef PROTOCOL_SLOTS_H +#define PROTOCOL_SLOTS_H + +#include +#include + +#include "protocol/frames.h" + +namespace protocol { + +enum SlotState : uint8_t { + SLOT_EMPTY = 0, + SLOT_ACTIVE = 1, +}; + +enum class AcceptResult : uint8_t { + NewCommand, // fresh command reserved a slot + ActiveDuplicate, // cmd_id already in flight — do not re-run + CompletedDuplicate, // RETRY of a completed command — answer from the ring + RejectBusy, // a wanted resource is held by an in-flight command + RejectNoSlots, // all slots occupied by compatible commands +}; + +struct SlotInfo { + uint8_t cmd_id; + uint8_t cmd_type; + uint8_t state; // SlotState + uint8_t progress; // 0..100 + uint64_t claims; +}; + +class SlotManager { +public: + static const size_t kNumSlots = 5; + static const size_t kRingSize = 8; + + SlotManager(); + + // Offer a command. On RejectBusy, *out_conflict_res is the blocking + // resource id and *out_holder_cmd_id the cmd_id holding it. On + // CompletedDuplicate, *out_ring_status/*out_ring_error carry the recorded + // outcome. Any out pointer may be null. Never re-runs a duplicate. + AcceptResult try_accept(uint8_t cmd_id, uint8_t cmd_type, uint64_t claims, bool retry, + uint8_t* out_conflict_res, uint8_t* out_holder_cmd_id, + uint8_t* out_ring_status, uint8_t* out_ring_error); + + // Free the slot for cmd_id and record {final_status, error_code} in the + // ring (advancing head_seq). No-op if cmd_id is not active. + void complete(uint8_t cmd_id, uint8_t final_status, uint8_t error_code); + + // Update an active command's progress (clamped to 0..100). No-op if absent. + void set_progress(uint8_t cmd_id, uint8_t pct); + + // Active slot for cmd_id, or nullptr. + const SlotInfo* find(uint8_t cmd_id) const; + + // Newest recorded outcome for cmd_id in the ring, or false if not present. + bool ring_lookup(uint8_t cmd_id, uint8_t* out_status, uint8_t* out_error) const; + + uint64_t inflight_claims_union() const; + + // Monotonic completion counter (mod 256); the wire ring_head_seq. The host + // watches this to detect new completions; the newest ring entry is at + // physical index (ring_head_seq - 1) mod kRingSize. + uint8_t ring_head_seq() const { return (uint8_t)completions_; } + + // Fill the slots + ring section of a StandardResponse. + void fill_response(StandardResponse& r) const; + + // Clear all slots and the ring (protocol RESET / HELLO session start). + void reset(); + +private: + int find_slot(uint8_t cmd_id) const; // active slot index or -1 + int find_free() const; // free slot index or -1 + + SlotInfo slots_[kNumSlots]; + RingEntry ring_[kRingSize]; + uint32_t completions_; // total completions; ring_head_seq is the low 8 bits +}; + +} // namespace protocol + +#endif // PROTOCOL_SLOTS_H diff --git a/firmware/controller/test/test_board/test_board.cpp b/firmware/controller/test/test_board/test_board.cpp new file mode 100644 index 000000000..77c3c5612 --- /dev/null +++ b/firmware/controller/test/test_board/test_board.cpp @@ -0,0 +1,49 @@ +#include + +#include "hal/board.h" +#include "protocol/frames.h" + +// Include the v1 board source directly for native tests. +#include "hal/boards/board_squid_v1.cpp" + +using protocol::InfoPayload; + +void setUp(void) {} +void tearDown(void) {} + +// Fields must fit their wire-response widths (StandardResponse in frames.h). +static void check_wire_invariants(const InfoPayload& d) { + TEST_ASSERT_TRUE_MESSAGE(d.board_id != 0, "board_id must be set"); + TEST_ASSERT_TRUE_MESSAGE(d.n_axes <= 8, "n_axes exceeds axes[8]"); + TEST_ASSERT_TRUE_MESSAGE(d.n_dacs <= 8, "n_dacs exceeds dac_values[8]"); + TEST_ASSERT_TRUE_MESSAGE(d.n_illum_ttl <= 8, "n_illum_ttl exceeds illum_ttl_mask"); + TEST_ASSERT_TRUE_MESSAGE(d.n_cam_triggers <= 8, "n_cam_triggers exceeds cam_trigger_states"); + for (int i = 0; i < 8; ++i) { + TEST_ASSERT_TRUE_MESSAGE(d.axis_driver[i] <= hal::DRIVER_TMC2240, "invalid axis driver id"); + } +} + +void test_v1_descriptor(void) { + const InfoPayload& d = hal::board_descriptor(); + check_wire_invariants(d); + + TEST_ASSERT_EQUAL_UINT8(1, d.board_id); + TEST_ASSERT_EQUAL_UINT8(5, d.n_axes); // X, Y, Z, FILTER1, FILTER2 + TEST_ASSERT_EQUAL_UINT8(8, d.n_dacs); // DAC80508 + TEST_ASSERT_EQUAL_UINT8(5, d.n_illum_ttl); + TEST_ASSERT_EQUAL_UINT8(1, d.has_led_matrix); + TEST_ASSERT_EQUAL_UINT8(4, d.n_cam_triggers); // pins 29-32 + TEST_ASSERT_EQUAL_UINT8(1, d.n_ready_inputs); // one shared ready line + TEST_ASSERT_EQUAL_UINT8(16, d.max_program_channels); + + // v1 stepper driver is probed at runtime (Phase M); descriptor is 0 for now. + for (int i = 0; i < 8; ++i) { + TEST_ASSERT_EQUAL_UINT8(hal::DRIVER_NONE, d.axis_driver[i]); + } +} + +int main(int, char**) { + UNITY_BEGIN(); + RUN_TEST(test_v1_descriptor); + return UNITY_END(); +} diff --git a/firmware/controller/test/test_board_v2/test_board_v2.cpp b/firmware/controller/test/test_board_v2/test_board_v2.cpp new file mode 100644 index 000000000..aa87cd0b6 --- /dev/null +++ b/firmware/controller/test/test_board_v2/test_board_v2.cpp @@ -0,0 +1,46 @@ +#include + +#include "hal/board.h" +#include "protocol/frames.h" + +// Include the v2 board source directly for native tests. (No -DBOARD_SQUID_V2 +// needed: board_squid_v2.cpp is self-contained and always defines the v2 +// descriptor; the firmware build selects it via the platformio src filter.) +#include "hal/boards/board_squid_v2.cpp" + +using protocol::InfoPayload; + +void setUp(void) {} +void tearDown(void) {} + +static void check_wire_invariants(const InfoPayload& d) { + TEST_ASSERT_TRUE_MESSAGE(d.board_id != 0, "board_id must be set"); + TEST_ASSERT_TRUE_MESSAGE(d.n_axes <= 8, "n_axes exceeds axes[8]"); + TEST_ASSERT_TRUE_MESSAGE(d.n_dacs <= 8, "n_dacs exceeds dac_values[8]"); + TEST_ASSERT_TRUE_MESSAGE(d.n_illum_ttl <= 8, "n_illum_ttl exceeds illum_ttl_mask"); + TEST_ASSERT_TRUE_MESSAGE(d.n_cam_triggers <= 8, "n_cam_triggers exceeds cam_trigger_states"); + for (int i = 0; i < 8; ++i) { + TEST_ASSERT_TRUE_MESSAGE(d.axis_driver[i] <= hal::DRIVER_TMC2240, "invalid axis driver id"); + } +} + +void test_v2_descriptor(void) { + const InfoPayload& d = hal::board_descriptor(); + check_wire_invariants(d); + + TEST_ASSERT_EQUAL_UINT8(2, d.board_id); + TEST_ASSERT_EQUAL_UINT8(8, d.n_cam_triggers); // 8 triggers + TEST_ASSERT_EQUAL_UINT8(10, d.n_ready_inputs); // 2 direct + 8 expander + TEST_ASSERT_EQUAL_UINT8(1, d.has_led_matrix); + + // v2 is known TMC2240 (not runtime-probed) on the populated axes. + for (int i = 0; i < d.n_axes; ++i) { + TEST_ASSERT_EQUAL_UINT8(hal::DRIVER_TMC2240, d.axis_driver[i]); + } +} + +int main(int, char**) { + UNITY_BEGIN(); + RUN_TEST(test_v2_descriptor); + return UNITY_END(); +} diff --git a/firmware/controller/test/test_boot/test_boot.cpp b/firmware/controller/test/test_boot/test_boot.cpp new file mode 100644 index 000000000..e0caa78fd --- /dev/null +++ b/firmware/controller/test/test_boot/test_boot.cpp @@ -0,0 +1,236 @@ +#include + +#include +#include +#include + +#include "boot/boot.h" + +// Include source directly for native tests. +#include "boot/boot.cpp" + +using boot::Boot; +using boot::FaultRecord; + +// --- Fake hardware -------------------------------------------------------- + +class FakeBootHal : public boot::BootHal { +public: + uint8_t eeprom[256]; + uint8_t reset_cause_value; + int reset_reads; + bool reset_cleared; + uint32_t cycle; + uint32_t last_wdog_arm_ms; + int wdog_kicks; + std::vector calls; + + FakeBootHal() { + memset(eeprom, 0, sizeof(eeprom)); + reset_cause_value = boot::RESET_POWER_ON; + reset_reads = 0; + reset_cleared = false; + cycle = 0xABCDEF01u; + last_wdog_arm_ms = 0; + wdog_kicks = 0; + } + void safe_state() override { calls.push_back("safe_state"); } + uint8_t eeprom_read(uint16_t a) override { + calls.push_back("ee_read"); + return eeprom[a]; + } + void eeprom_write(uint16_t a, uint8_t v) override { + calls.push_back("ee_write"); + eeprom[a] = v; + } + uint8_t read_reset_cause() override { + ++reset_reads; + return reset_cause_value; + } + void clear_reset_cause() override { + reset_cleared = true; + reset_cause_value = boot::RESET_UNKNOWN; + } + void watchdog_arm(uint32_t ms) override { last_wdog_arm_ms = ms; } + void watchdog_kick() override { ++wdog_kicks; } + uint32_t cycle_counter() override { return cycle; } +}; + +void setUp(void) {} +void tearDown(void) {} + +// --- Ordering: safe_state runs before anything else ----------------------- + +void test_safe_state_called_first(void) { + FakeBootHal hal; + Boot b(hal); + b.begin(); + TEST_ASSERT_TRUE(hal.calls.size() >= 1); + TEST_ASSERT_EQUAL_STRING("safe_state", hal.calls[0]); + // safe_state must precede any EEPROM access. + for (size_t i = 1; i < hal.calls.size(); ++i) { + TEST_ASSERT_TRUE(strcmp(hal.calls[i], "safe_state") != 0); + } +} + +// --- boot_count persists and increments ----------------------------------- + +void test_boot_count_increments_and_persists(void) { + FakeBootHal hal; + Boot b1(hal); + b1.begin(); + TEST_ASSERT_EQUAL_UINT32(1, b1.boot_count()); + + Boot b2(hal); // "reboot": same EEPROM + b2.begin(); + TEST_ASSERT_EQUAL_UINT32(2, b2.boot_count()); + + Boot b3(hal); + b3.begin(); + TEST_ASSERT_EQUAL_UINT32(3, b3.boot_count()); +} + +// --- reset cause captured once, register cleared -------------------------- + +void test_reset_cause_captured_once(void) { + FakeBootHal hal; + hal.reset_cause_value = boot::RESET_WATCHDOG; + Boot b(hal); + b.begin(); + TEST_ASSERT_EQUAL_UINT8(boot::RESET_WATCHDOG, b.reset_cause()); + TEST_ASSERT_EQUAL_INT(1, hal.reset_reads); // read exactly once + TEST_ASSERT_TRUE(hal.reset_cleared); // register cleared for next boot + // Cause is latched: it survives repeated reads. + TEST_ASSERT_EQUAL_UINT8(boot::RESET_WATCHDOG, b.reset_cause()); +} + +// --- session nonce non-zero and differs across boots ---------------------- + +void test_session_nonce_nonzero_and_differs(void) { + FakeBootHal hal; + hal.cycle = 0x11112222u; // identical entropy on both boots + Boot b1(hal); + b1.begin(); + uint32_t n1 = b1.session_nonce(); + + Boot b2(hal); // same EEPROM -> boot_count differs -> nonce must differ + b2.begin(); + uint32_t n2 = b2.session_nonce(); + + TEST_ASSERT_NOT_EQUAL(0, n1); + TEST_ASSERT_NOT_EQUAL(0, n2); + TEST_ASSERT_NOT_EQUAL(n1, n2); +} + +// --- watchdog armed with the configured timeout --------------------------- + +void test_watchdog_armed_on_begin(void) { + FakeBootHal hal; + Boot b(hal); + b.begin(); + TEST_ASSERT_EQUAL_UINT32(Boot::kWatchdogTimeoutMs, hal.last_wdog_arm_ms); + b.kick_watchdog(); + b.kick_watchdog(); + TEST_ASSERT_EQUAL_INT(2, hal.wdog_kicks); +} + +// --- fault ring: append + newest-first read ------------------------------- + +void test_fault_ring_append_and_read(void) { + FakeBootHal hal; + Boot b(hal); + b.begin(); + + b.fault_append(0x41, 0x01, 100); + b.fault_append(0x42, 0x02, 200); + b.fault_append(0x43, 0x03, 300); + TEST_ASSERT_EQUAL_UINT8(3, b.fault_count()); + + FaultRecord r; + TEST_ASSERT_TRUE(b.fault_get(0, r)); // newest + TEST_ASSERT_EQUAL_UINT8(0x43, r.code); + TEST_ASSERT_EQUAL_UINT32(300, r.uptime_ms); + TEST_ASSERT_TRUE(b.fault_get(2, r)); // oldest + TEST_ASSERT_EQUAL_UINT8(0x41, r.code); + TEST_ASSERT_FALSE(b.fault_get(3, r)); // out of range +} + +// --- fault ring wraps at 16 keeping the newest ---------------------------- + +void test_fault_ring_wraps_at_16(void) { + FakeBootHal hal; + Boot b(hal); + b.begin(); + + for (int i = 1; i <= 20; ++i) { + b.fault_append((uint8_t)i, 0, (uint32_t)(i * 10)); + } + TEST_ASSERT_EQUAL_UINT8(20, b.fault_count()); // total (saturating < 255) + + FaultRecord r; + TEST_ASSERT_TRUE(b.fault_get(0, r)); // newest = the 20th + TEST_ASSERT_EQUAL_UINT8(20, r.code); + TEST_ASSERT_TRUE(b.fault_get(15, r)); // oldest kept = the 5th + TEST_ASSERT_EQUAL_UINT8(5, r.code); + TEST_ASSERT_FALSE(b.fault_get(16, r)); // only 16 retained +} + +// --- fault ring survives a reboot ----------------------------------------- + +void test_fault_ring_survives_reboot(void) { + FakeBootHal hal; + Boot b1(hal); + b1.begin(); + b1.fault_append(0x50, 0x11, 111); + b1.fault_append(0x51, 0x22, 222); + + Boot b2(hal); // "reboot": same EEPROM + b2.begin(); + TEST_ASSERT_EQUAL_UINT32(2, b2.boot_count()); // booted again + TEST_ASSERT_EQUAL_UINT8(2, b2.fault_count()); // faults preserved + + FaultRecord r; + TEST_ASSERT_TRUE(b2.fault_get(0, r)); + TEST_ASSERT_EQUAL_UINT8(0x51, r.code); + TEST_ASSERT_EQUAL_UINT32(222, r.uptime_ms); + TEST_ASSERT_TRUE(b2.fault_get(1, r)); + TEST_ASSERT_EQUAL_UINT8(0x50, r.code); + + // A fault appended after the reboot continues the same ring. + b2.fault_append(0x52, 0x33, 333); + TEST_ASSERT_EQUAL_UINT8(3, b2.fault_count()); + TEST_ASSERT_TRUE(b2.fault_get(0, r)); + TEST_ASSERT_EQUAL_UINT8(0x52, r.code); +} + +// --- loop/ISR watermarks update max-only ---------------------------------- + +void test_watermarks_update_max_only(void) { + FakeBootHal hal; + Boot b(hal); + b.begin(); + + b.note_loop_us(100); + b.note_loop_us(50); // lower — ignored + b.note_loop_us(200); + b.note_loop_us(150); // lower — ignored + TEST_ASSERT_EQUAL_UINT32(200, b.loop_max_us()); + + b.note_isr_us(10); + b.note_isr_us(5); + TEST_ASSERT_EQUAL_UINT32(10, b.isr_max_us()); +} + +int main(int, char**) { + UNITY_BEGIN(); + RUN_TEST(test_safe_state_called_first); + RUN_TEST(test_boot_count_increments_and_persists); + RUN_TEST(test_reset_cause_captured_once); + RUN_TEST(test_session_nonce_nonzero_and_differs); + RUN_TEST(test_watchdog_armed_on_begin); + RUN_TEST(test_fault_ring_append_and_read); + RUN_TEST(test_fault_ring_wraps_at_16); + RUN_TEST(test_fault_ring_survives_reboot); + RUN_TEST(test_watermarks_update_max_only); + return UNITY_END(); +} diff --git a/firmware/controller/test/test_claims/test_claims.cpp b/firmware/controller/test/test_claims/test_claims.cpp new file mode 100644 index 000000000..acbe27058 --- /dev/null +++ b/firmware/controller/test/test_claims/test_claims.cpp @@ -0,0 +1,98 @@ +#include + +#include + +#include "protocol/claims.h" +#include "protocol/frames.h" + +// Include source directly for native tests. +#include "protocol/claims.cpp" + +using namespace protocol; + +void setUp(void) {} +void tearDown(void) {} + +// A computed hook that ignores static_claims and returns a fixed mask. +static uint64_t computed_axes_2_3(const uint8_t* payload, size_t len) { + (void)payload; + (void)len; + return res_axis(2) | res_axis(3); +} + +// A computed hook that echoes the payload length (proves args are passed). +static uint64_t computed_echo_len(const uint8_t* payload, size_t len) { + (void)payload; + return (uint64_t)len; +} + +// --- Static lookup against the production table --------------------------- + +void test_system_commands_claim_nothing(void) { + TEST_ASSERT_EQUAL_HEX32(0, claims_for(GET_STATE, nullptr, 0)); + TEST_ASSERT_EQUAL_HEX32(0, claims_for(HELLO, nullptr, 0)); + TEST_ASSERT_EQUAL_HEX32(0, claims_for(GET_INFO, nullptr, 0)); + TEST_ASSERT_EQUAL_HEX32(0, claims_for(DIAG, nullptr, 0)); +} + +void test_command_absent_from_table_claims_nothing(void) { + // 0x07 is not in the Phase-B production table. + TEST_ASSERT_EQUAL_HEX32(0, claims_for(0x07, nullptr, 0)); +} + +// --- Conflict overlap math ------------------------------------------------ + +void test_conflict_reports_lowest_resource_plus_one(void) { + // MOVE_X-style wanted {axis0} vs in-flight {axis0}: conflict on resource 0. + TEST_ASSERT_EQUAL_UINT8(1, claims_conflict(res_axis(0), res_axis(0))); + // vs in-flight {axis1}: compatible. + TEST_ASSERT_EQUAL_UINT8(0, claims_conflict(res_axis(0), res_axis(1))); + // A higher resource: ILLUM_TTL is bit 32 (u64 mask) -> reported as 33. + TEST_ASSERT_EQUAL_UINT8(33, claims_conflict(RES_ILLUM_TTL, RES_ILLUM_TTL | res_axis(5))); + // Lowest set bit wins when several overlap. + TEST_ASSERT_EQUAL_UINT8( + 1, claims_conflict(res_axis(0) | res_axis(3), res_axis(0) | res_axis(3))); + // Disjoint masks never conflict. + TEST_ASSERT_EQUAL_UINT8( + 0, claims_conflict(res_axis(0) | res_axis(1), res_axis(2) | res_dac(0))); + // Nothing wanted -> nothing conflicts. + TEST_ASSERT_EQUAL_UINT8(0, claims_conflict(0, 0xFFFFFFFFu)); +} + +// --- Table-driven lookup (tests + Phase D) -------------------------------- + +void test_static_claims_lookup_in_explicit_table(void) { + const ClaimsRow table[] = { + {0x01, res_axis(0), nullptr}, // MOVE_X-style + {0x02, res_axis(1), nullptr}, // MOVE_Y-style + }; + TEST_ASSERT_EQUAL_HEX32(res_axis(0), claims_for_in(table, 2, 0x01, nullptr, 0)); + TEST_ASSERT_EQUAL_HEX32(res_axis(1), claims_for_in(table, 2, 0x02, nullptr, 0)); + TEST_ASSERT_EQUAL_HEX32(0, claims_for_in(table, 2, 0x03, nullptr, 0)); // absent +} + +void test_computed_hook_overrides_static_claims(void) { + const ClaimsRow table[] = { + {0x50, res_axis(0), computed_axes_2_3}, // static_claims must be ignored + }; + uint32_t got = claims_for_in(table, 1, 0x50, nullptr, 0); + TEST_ASSERT_EQUAL_HEX32(res_axis(2) | res_axis(3), got); + TEST_ASSERT_EQUAL_HEX32(0, got & res_axis(0)); // static did not leak +} + +void test_computed_hook_receives_payload_len(void) { + const ClaimsRow table[] = {{0x51, 0, computed_echo_len}}; + uint8_t payload[3] = {1, 2, 3}; + TEST_ASSERT_EQUAL_HEX32(3, claims_for_in(table, 1, 0x51, payload, 3)); +} + +int main(int, char**) { + UNITY_BEGIN(); + RUN_TEST(test_system_commands_claim_nothing); + RUN_TEST(test_command_absent_from_table_claims_nothing); + RUN_TEST(test_conflict_reports_lowest_resource_plus_one); + RUN_TEST(test_static_claims_lookup_in_explicit_table); + RUN_TEST(test_computed_hook_overrides_static_claims); + RUN_TEST(test_computed_hook_receives_payload_len); + return UNITY_END(); +} diff --git a/firmware/controller/test/test_cobs/test_cobs.cpp b/firmware/controller/test/test_cobs/test_cobs.cpp new file mode 100644 index 000000000..fe4569878 --- /dev/null +++ b/firmware/controller/test/test_cobs/test_cobs.cpp @@ -0,0 +1,145 @@ +#include + +#include +#include + +#include "protocol/cobs.h" + +// Include source directly for native tests. +#include "protocol/cobs.cpp" + +using protocol::cobs_decode; +using protocol::cobs_encode; +using protocol::cobs_max_encoded_len; + +void setUp(void) {} +void tearDown(void) {} + +// Encode `in`, assert the output has no 0x00 and respects the overhead bound, +// then decode and assert the result equals the original bytes. +static void assert_roundtrip(const uint8_t* in, size_t len) { + uint8_t enc[600]; + uint8_t dec[600]; + + size_t n = cobs_encode(in, len, enc, sizeof(enc)); + TEST_ASSERT_TRUE_MESSAGE(n > 0, "encode returned 0 (buffer too small?)"); + TEST_ASSERT_TRUE_MESSAGE(n <= cobs_max_encoded_len(len), "encoded exceeds max bound"); + + // Overhead bound: n <= len + 1 + ceil(len/254). + size_t max_overhead = 1 + (len + 253) / 254; + TEST_ASSERT_TRUE_MESSAGE(n <= len + max_overhead, "overhead exceeds 1 + len/254"); + + // No zero bytes in the encoded stream. + for (size_t i = 0; i < n; ++i) { + TEST_ASSERT_NOT_EQUAL_MESSAGE(0x00, enc[i], "encoded output contains a 0x00 byte"); + } + + int32_t d = cobs_decode(enc, n, dec, sizeof(dec)); + TEST_ASSERT_EQUAL_INT32_MESSAGE((int32_t)len, d, "decoded length mismatch"); + if (len > 0) { + TEST_ASSERT_EQUAL_MEMORY_MESSAGE(in, dec, len, "decoded bytes differ from original"); + } +} + +// --- Round-trip across representative lengths ----------------------------- + +void test_roundtrip_empty(void) { + assert_roundtrip(nullptr, 0); +} + +void test_roundtrip_various_lengths_all_nonzero(void) { + const size_t lengths[] = {1, 2, 253, 254, 255, 506}; + uint8_t buf[506]; + for (size_t li = 0; li < sizeof(lengths) / sizeof(lengths[0]); ++li) { + size_t len = lengths[li]; + for (size_t i = 0; i < len; ++i) { + buf[i] = (uint8_t)((i % 255) + 1); // never 0 + } + assert_roundtrip(buf, len); + } +} + +void test_roundtrip_zeros_head_middle_tail(void) { + const size_t lengths[] = {1, 2, 253, 254, 255, 506}; + uint8_t buf[506]; + for (size_t li = 0; li < sizeof(lengths) / sizeof(lengths[0]); ++li) { + size_t len = lengths[li]; + for (size_t i = 0; i < len; ++i) { + buf[i] = (uint8_t)((i % 255) + 1); + } + buf[0] = 0x00; // head + buf[len / 2] = 0x00; // middle + buf[len - 1] = 0x00; // tail + assert_roundtrip(buf, len); + } +} + +void test_roundtrip_all_zeros(void) { + uint8_t buf[300]; + memset(buf, 0, sizeof(buf)); + assert_roundtrip(buf, 300); +} + +// --- max_encoded_len ------------------------------------------------------ + +void test_max_encoded_len(void) { + // Function is defined as len + 1 + ceil(len/254) (a safe upper bound). + TEST_ASSERT_EQUAL_size_t(1, cobs_max_encoded_len(0)); + TEST_ASSERT_EQUAL_size_t(3, cobs_max_encoded_len(1)); + TEST_ASSERT_EQUAL_size_t(256, cobs_max_encoded_len(254)); + TEST_ASSERT_EQUAL_size_t(509, cobs_max_encoded_len(506)); +} + +// --- encode capacity ------------------------------------------------------ + +void test_encode_returns_zero_when_out_too_small(void) { + uint8_t in[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; + uint8_t out[4]; // needs 11, only 4 available + TEST_ASSERT_EQUAL_size_t(0, cobs_encode(in, sizeof(in), out, sizeof(out))); +} + +// --- decode rejections ---------------------------------------------------- + +void test_decode_rejects_embedded_zero(void) { + // A valid encoding of {0x41,0x42} is {0x03,0x41,0x42}. Inject a 0x00. + uint8_t bad[] = {0x03, 0x41, 0x00}; + uint8_t out[16]; + TEST_ASSERT_EQUAL_INT32(-1, cobs_decode(bad, sizeof(bad), out, sizeof(out))); +} + +void test_decode_rejects_truncated(void) { + // Code byte 0x05 promises 4 data bytes but only 2 follow. + uint8_t bad[] = {0x05, 0x41, 0x42}; + uint8_t out[16]; + TEST_ASSERT_EQUAL_INT32(-1, cobs_decode(bad, sizeof(bad), out, sizeof(out))); +} + +void test_decode_rejects_code_past_end(void) { + // Single code byte pointing far past the end of input. + uint8_t bad[] = {0xFF}; + uint8_t out[16]; + TEST_ASSERT_EQUAL_INT32(-1, cobs_decode(bad, sizeof(bad), out, sizeof(out))); +} + +void test_decode_rejects_output_overflow(void) { + uint8_t in[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; + uint8_t enc[32]; + size_t n = cobs_encode(in, sizeof(in), enc, sizeof(enc)); + uint8_t small_out[3]; + TEST_ASSERT_EQUAL_INT32(-1, cobs_decode(enc, n, small_out, sizeof(small_out))); +} + +int main(int, char**) { + UNITY_BEGIN(); + RUN_TEST(test_roundtrip_empty); + RUN_TEST(test_roundtrip_various_lengths_all_nonzero); + RUN_TEST(test_roundtrip_zeros_head_middle_tail); + RUN_TEST(test_roundtrip_all_zeros); + RUN_TEST(test_max_encoded_len); + RUN_TEST(test_encode_returns_zero_when_out_too_small); + RUN_TEST(test_decode_rejects_embedded_zero); + RUN_TEST(test_decode_rejects_truncated); + RUN_TEST(test_decode_rejects_code_past_end); + RUN_TEST(test_decode_rejects_output_overflow); + return UNITY_END(); +} diff --git a/firmware/controller/test/test_crc16/test_crc16.cpp b/firmware/controller/test/test_crc16/test_crc16.cpp new file mode 100644 index 000000000..54b1b5478 --- /dev/null +++ b/firmware/controller/test/test_crc16/test_crc16.cpp @@ -0,0 +1,73 @@ +#include + +#include +#include + +#include "protocol/crc16.h" + +// Include source directly for native tests (grants access to the file-scope +// CRC16_TABLE for spot-checks). +#include "protocol/crc16.cpp" + +using protocol::crc16_ccitt; + +void setUp(void) {} +void tearDown(void) {} + +// --- Known-answer vectors ------------------------------------------------- + +void test_crc16_empty(void) { + // Empty buffer returns the init value unchanged. + TEST_ASSERT_EQUAL_HEX16(0xFFFF, crc16_ccitt(nullptr, 0)); +} + +void test_crc16_check_value(void) { + // CRC-16/CCITT-FALSE canonical check value over "123456789". + const uint8_t msg[] = {'1', '2', '3', '4', '5', '6', '7', '8', '9'}; + TEST_ASSERT_EQUAL_HEX16(0x29B1, crc16_ccitt(msg, sizeof(msg))); +} + +void test_crc16_single_byte(void) { + // One 0x00 byte: crc = (0xFFFF<<8) ^ table[(0xFF ^ 0x00)] = table[0xFF] + // combined with the high byte shifted in. Just assert determinism + shape. + const uint8_t z = 0x00; + uint16_t a = crc16_ccitt(&z, 1); + uint16_t b = crc16_ccitt(&z, 1); + TEST_ASSERT_EQUAL_HEX16(a, b); + // A single 0x00 must not leave the CRC at the init value. + TEST_ASSERT_NOT_EQUAL(0xFFFF, a); +} + +// --- 506-byte payload (max protocol-v2 payload) --------------------------- + +void test_crc16_max_payload_deterministic(void) { + uint8_t buf[506]; + for (size_t i = 0; i < sizeof(buf); ++i) { + buf[i] = (uint8_t)(i * 31 + 7); // arbitrary deterministic pattern + } + uint16_t first = crc16_ccitt(buf, sizeof(buf)); + uint16_t second = crc16_ccitt(buf, sizeof(buf)); + TEST_ASSERT_EQUAL_HEX16(first, second); + + // A single-bit flip must change the CRC (error detection sanity). + buf[253] ^= 0x01; + TEST_ASSERT_NOT_EQUAL(first, crc16_ccitt(buf, sizeof(buf))); +} + +// --- Lookup table spot-checks -------------------------------------------- + +void test_crc16_table_spot_checks(void) { + TEST_ASSERT_EQUAL_HEX16(0x0000, protocol::CRC16_TABLE[0]); + TEST_ASSERT_EQUAL_HEX16(0x1021, protocol::CRC16_TABLE[1]); + TEST_ASSERT_EQUAL_HEX16(0x1EF0, protocol::CRC16_TABLE[255]); +} + +int main(int, char**) { + UNITY_BEGIN(); + RUN_TEST(test_crc16_empty); + RUN_TEST(test_crc16_check_value); + RUN_TEST(test_crc16_single_byte); + RUN_TEST(test_crc16_max_payload_deterministic); + RUN_TEST(test_crc16_table_spot_checks); + return UNITY_END(); +} diff --git a/firmware/controller/test/test_dispatch/test_dispatch.cpp b/firmware/controller/test/test_dispatch/test_dispatch.cpp new file mode 100644 index 000000000..86c266595 --- /dev/null +++ b/firmware/controller/test/test_dispatch/test_dispatch.cpp @@ -0,0 +1,404 @@ +#include + +#include +#include +#include + +#include "protocol/dispatch_v2.h" +#include "protocol/frames.h" + +// Include sources directly for native tests. +#include "protocol/claims.cpp" +#include "protocol/cobs.cpp" +#include "protocol/crc16.cpp" +#include "protocol/dispatch_v2.cpp" +#include "protocol/framer.cpp" +#include "protocol/slots.cpp" + +using namespace protocol; + +// --- Fake state/boot/board provider --------------------------------------- + +class FakeProvider : public StateProvider { +public: + // Machine-state template copied into fill_state(). + uint8_t mode = 0; + int32_t axis0_pos = 0; + uint16_t dac0 = 0; + uint8_t fw_major = 0, fw_minor = 0, proto = kProtocolVersion; + + HelloPayload hello_data{}; + InfoPayload info_data{}; + DiagPayload diag_data{}; + FaultEntryWire faults[16]{}; + uint8_t n_faults = 0; + + void fill_state(StandardResponse& r) override { + r.mode = mode; + r.axes[0].pos = axis0_pos; + r.dac_values[0] = dac0; + r.fw_version_major = fw_major; + r.fw_version_minor = fw_minor; + r.protocol_version = proto; + } + void fill_hello(HelloPayload& h) override { h = hello_data; } + void fill_info(InfoPayload& i) override { i = info_data; } + void fill_diag_page0(DiagPayload& d) override { d = diag_data; } + uint8_t fill_diag_faults(uint8_t page, FaultEntryWire* out, uint8_t cap) override { + (void)page; + uint8_t n = (n_faults < cap) ? n_faults : cap; + for (uint8_t i = 0; i < n; ++i) out[i] = faults[i]; + return n; + } +}; + +// Slotted command handler that counts invocations (proves RETRY skips it). +static int g_handler_calls = 0; +static void counting_handler(Dispatcher&, const uint8_t*, size_t, ResponseWriter& w) { + ++g_handler_calls; + (void)w; +} + +void setUp(void) { g_handler_calls = 0; } +void tearDown(void) {} + +// --- Helpers -------------------------------------------------------------- + +static size_t make_request(uint8_t cmd_id, uint8_t cmd_type, uint8_t flags, + const uint8_t* payload, size_t plen, uint8_t* out) { + out[0] = REQUEST; + out[1] = cmd_id; + out[2] = cmd_type; + out[3] = flags; + for (size_t i = 0; i < plen; ++i) out[4 + i] = payload[i]; + return 4 + plen; +} + +// Parse a response frame; copies the StandardResponse out for aligned access. +static void parse_response(const uint8_t* resp, size_t n, uint8_t* type, uint8_t* cmd_id, + uint8_t* cmd_type, StandardResponse* sr) { + *type = resp[0]; + *cmd_id = resp[1]; + *cmd_type = resp[2]; + memcpy(sr, &resp[4], sizeof(StandardResponse)); +} + +// --- Unknown command ------------------------------------------------------ + +void test_unknown_command_rejected(void) { + SlotManager slots; + FakeProvider prov; + Dispatcher d(slots, prov); + d.register_system_commands(); + + uint8_t req[8], resp[600]; + size_t rn = make_request(0x77, 0x0E /*unregistered*/, 0, nullptr, 0, req); + size_t n = d.build_response(req, rn, resp, sizeof(resp)); + TEST_ASSERT_TRUE(n > 0); + + uint8_t type, cid, ct; + StandardResponse sr; + parse_response(resp, n, &type, &cid, &ct, &sr); + TEST_ASSERT_EQUAL_UINT8(RESPONSE, type); + TEST_ASSERT_EQUAL_UINT8(0x77, cid); // echoed cmd_id + TEST_ASSERT_EQUAL_UINT8(0x0E, ct); // echoed cmd_type + TEST_ASSERT_EQUAL_UINT8(STATUS_REJECTED, sr.status); + TEST_ASSERT_EQUAL_UINT8(ERR_UNKNOWN_COMMAND, sr.error_code); +} + +// --- Bad payload length --------------------------------------------------- + +void test_bad_length_rejected(void) { + SlotManager slots; + FakeProvider prov; + Dispatcher d(slots, prov); + d.register_system_commands(); + + // GET_STATE expects 0 payload bytes; send 1. + uint8_t extra = 0xAB; + uint8_t req[8], resp[600]; + size_t rn = make_request(0x01, GET_STATE, 0, &extra, 1, req); + size_t n = d.build_response(req, rn, resp, sizeof(resp)); + TEST_ASSERT_TRUE(n > 0); + + uint8_t type, cid, ct; + StandardResponse sr; + parse_response(resp, n, &type, &cid, &ct, &sr); + TEST_ASSERT_EQUAL_UINT8(STATUS_REJECTED, sr.status); + TEST_ASSERT_EQUAL_UINT8(ERR_BAD_LENGTH, sr.error_code); +} + +// --- GET_STATE ------------------------------------------------------------ + +void test_get_state_returns_injected_state(void) { + SlotManager slots; + FakeProvider prov; + prov.mode = 7; + prov.axis0_pos = 123456; + prov.dac0 = 4095; + prov.fw_major = 2; + prov.fw_minor = 3; + Dispatcher d(slots, prov); + d.register_system_commands(); + + uint8_t req[8], resp[600]; + size_t rn = make_request(0x99, GET_STATE, 0, nullptr, 0, req); + size_t n = d.build_response(req, rn, resp, sizeof(resp)); + TEST_ASSERT_TRUE(n > 0); + + uint8_t type, cid, ct; + StandardResponse sr; + parse_response(resp, n, &type, &cid, &ct, &sr); + TEST_ASSERT_EQUAL_UINT8(0x99, cid); // echoed cmd_id + TEST_ASSERT_EQUAL_UINT8(STATUS_OK, sr.status); + TEST_ASSERT_EQUAL_UINT8(7, sr.mode); + TEST_ASSERT_EQUAL_INT32(123456, sr.axes[0].pos); + TEST_ASSERT_EQUAL_UINT16(4095, sr.dac_values[0]); + TEST_ASSERT_EQUAL_UINT8(2, sr.fw_version_major); + TEST_ASSERT_EQUAL_UINT8(kProtocolVersion, sr.protocol_version); + // GET_STATE is immediate: it must not occupy a slot. + TEST_ASSERT_EQUAL_UINT8(SLOT_EMPTY, sr.slots[0].state); +} + +// --- HELLO: extra payload + session reset --------------------------------- + +void test_hello_appends_payload_and_resets_session(void) { + SlotManager slots; + FakeProvider prov; + prov.hello_data.protocol_version = kProtocolVersion; + prov.hello_data.fw_major = 2; + prov.hello_data.reset_cause = 0x03; + prov.hello_data.session_nonce = 0xDEADBEEF; + prov.hello_data.boot_count = 42; + prov.hello_data.uptime_ms = 1000; + Dispatcher d(slots, prov); + d.register_system_commands(); + d.register_command(0x01, false, 0, 8, counting_handler); // slotted + + // Create a stale slot with a slotted command. + uint8_t req[16], resp[600]; + size_t rn = make_request(0x10, 0x01, 0, nullptr, 0, req); + d.build_response(req, rn, resp, sizeof(resp)); + TEST_ASSERT_NOT_NULL(slots.find(0x10)); // slot is occupied + + // HELLO establishes a new session and clears slots. + rn = make_request(0x20, HELLO, 0, nullptr, 0, req); + size_t n = d.build_response(req, rn, resp, sizeof(resp)); + TEST_ASSERT_TRUE(n > 0); + + uint8_t type, cid, ct; + StandardResponse sr; + parse_response(resp, n, &type, &cid, &ct, &sr); + TEST_ASSERT_EQUAL_UINT8(STATUS_OK, sr.status); + TEST_ASSERT_EQUAL_UINT8(SLOT_EMPTY, sr.slots[0].state); // stale slot cleared + TEST_ASSERT_NULL(slots.find(0x10)); + + // The HELLO extra payload follows the StandardResponse. + TEST_ASSERT_EQUAL_size_t(4 + sizeof(StandardResponse) + sizeof(HelloPayload), n); + HelloPayload hp; + memcpy(&hp, &resp[4 + sizeof(StandardResponse)], sizeof(HelloPayload)); + TEST_ASSERT_EQUAL_HEX32(0xDEADBEEF, hp.session_nonce); + TEST_ASSERT_EQUAL_UINT32(42, hp.boot_count); + TEST_ASSERT_EQUAL_UINT8(0x03, hp.reset_cause); +} + +// --- GET_INFO: descriptor passthrough ------------------------------------- + +void test_get_info_passthrough(void) { + SlotManager slots; + FakeProvider prov; + prov.info_data.board_id = 1; + prov.info_data.n_axes = 5; + prov.info_data.n_dacs = 8; + prov.info_data.max_program_channels = 16; + prov.info_data.feature_bits = 0xCAFEF00D; + Dispatcher d(slots, prov); + d.register_system_commands(); + + uint8_t req[8], resp[600]; + size_t rn = make_request(0x05, GET_INFO, 0, nullptr, 0, req); + size_t n = d.build_response(req, rn, resp, sizeof(resp)); + TEST_ASSERT_EQUAL_size_t(4 + sizeof(StandardResponse) + sizeof(InfoPayload), n); + + InfoPayload ip; + memcpy(&ip, &resp[4 + sizeof(StandardResponse)], sizeof(InfoPayload)); + TEST_ASSERT_EQUAL_UINT8(1, ip.board_id); + TEST_ASSERT_EQUAL_UINT8(5, ip.n_axes); + TEST_ASSERT_EQUAL_UINT8(16, ip.max_program_channels); + TEST_ASSERT_EQUAL_HEX32(0xCAFEF00D, ip.feature_bits); +} + +// --- DIAG page 0 counters + page 1 fault entries -------------------------- + +void test_diag_page0_counters(void) { + SlotManager slots; + FakeProvider prov; + prov.diag_data.crc_err = 11; + prov.diag_data.resync = 22; + prov.diag_data.rx_overflow = 33; + prov.diag_data.tx_drop = 44; + prov.diag_data.boot_count = 7; + prov.diag_data.fault_count = 3; + Dispatcher d(slots, prov); + d.register_system_commands(); + + uint8_t page = 0; + uint8_t req[8], resp[600]; + size_t rn = make_request(0x06, DIAG, 0, &page, 1, req); + size_t n = d.build_response(req, rn, resp, sizeof(resp)); + TEST_ASSERT_EQUAL_size_t(4 + sizeof(StandardResponse) + sizeof(DiagPayload), n); + + DiagPayload dp; + memcpy(&dp, &resp[4 + sizeof(StandardResponse)], sizeof(DiagPayload)); + TEST_ASSERT_EQUAL_UINT32(11, dp.crc_err); + TEST_ASSERT_EQUAL_UINT32(44, dp.tx_drop); + TEST_ASSERT_EQUAL_UINT8(3, dp.fault_count); + TEST_ASSERT_EQUAL_UINT8(0, dp.page); +} + +void test_diag_page1_fault_entries(void) { + SlotManager slots; + FakeProvider prov; + prov.n_faults = 2; + prov.faults[0].uptime_ms = 100; + prov.faults[0].code = 0x41; + prov.faults[0].detail = 0x01; + prov.faults[1].uptime_ms = 200; + prov.faults[1].code = 0x42; + Dispatcher d(slots, prov); + d.register_system_commands(); + + uint8_t page = 1; + uint8_t req[8], resp[600]; + size_t rn = make_request(0x06, DIAG, 0, &page, 1, req); + size_t n = d.build_response(req, rn, resp, sizeof(resp)); + TEST_ASSERT_EQUAL_size_t(4 + sizeof(StandardResponse) + 2 * sizeof(FaultEntryWire), n); + + FaultEntryWire f0, f1; + memcpy(&f0, &resp[4 + sizeof(StandardResponse)], sizeof(FaultEntryWire)); + memcpy(&f1, &resp[4 + sizeof(StandardResponse) + sizeof(FaultEntryWire)], + sizeof(FaultEntryWire)); + TEST_ASSERT_EQUAL_UINT32(100, f0.uptime_ms); + TEST_ASSERT_EQUAL_UINT8(0x41, f0.code); + TEST_ASSERT_EQUAL_UINT8(0x42, f1.code); +} + +// --- Every response fits within kMaxPayload ------------------------------- + +void test_all_responses_within_max_payload(void) { + SlotManager slots; + FakeProvider prov; + prov.n_faults = 16; // largest DIAG extra + Dispatcher d(slots, prov); + d.register_system_commands(); + + uint8_t page1 = 1; + struct { uint8_t cmd; const uint8_t* pl; size_t pln; } cases[] = { + {GET_STATE, nullptr, 0}, + {HELLO, nullptr, 0}, + {GET_INFO, nullptr, 0}, + {DIAG, &page1, 1}, + }; + uint8_t req[8], resp[600]; + for (auto& c : cases) { + size_t rn = make_request(0x01, c.cmd, 0, c.pl, c.pln, req); + size_t n = d.build_response(req, rn, resp, sizeof(resp)); + TEST_ASSERT_TRUE(n > 0); + size_t payload_len = n - 4; // exclude the 4-byte frame header + TEST_ASSERT_TRUE_MESSAGE(payload_len <= kMaxPayload, "response payload exceeds kMaxPayload"); + } +} + +// --- RETRY of a completed command answered from the ring, no re-execution -- + +void test_retry_completed_answered_from_ring_no_handler(void) { + SlotManager slots; + FakeProvider prov; + Dispatcher d(slots, prov); + d.register_system_commands(); + d.register_command(0x01, false, 0, 8, counting_handler); // slotted + + uint8_t req[16], resp[600]; + // First dispatch: NewCommand -> handler runs once, status ACCEPTED. + size_t rn = make_request(0x30, 0x01, 0, nullptr, 0, req); + size_t n = d.build_response(req, rn, resp, sizeof(resp)); + TEST_ASSERT_TRUE(n > 0); + uint8_t type, cid, ct; + StandardResponse sr; + parse_response(resp, n, &type, &cid, &ct, &sr); + TEST_ASSERT_EQUAL_UINT8(STATUS_ACCEPTED, sr.status); + TEST_ASSERT_EQUAL_INT(1, g_handler_calls); + + // Simulate asynchronous completion. + slots.complete(0x30, STATUS_FAILED, ERR_INVALID_PARAMETER); + + // RETRY of the completed command: answered from the ring, handler NOT run. + rn = make_request(0x30, 0x01, FLAG_RETRY, nullptr, 0, req); + n = d.build_response(req, rn, resp, sizeof(resp)); + parse_response(resp, n, &type, &cid, &ct, &sr); + TEST_ASSERT_EQUAL_UINT8(STATUS_FAILED, sr.status); + TEST_ASSERT_EQUAL_UINT8(ERR_INVALID_PARAMETER, sr.error_code); + TEST_ASSERT_EQUAL_INT(1, g_handler_calls); // unchanged: no re-execution +} + +// --- Integration: request in -> response out through a real Framer -------- + +class CaptureByteSink : public ByteSink { +public: + std::vector written; + size_t writable() override { return (size_t)1 << 20; } + void write(const uint8_t* b, size_t n) override { written.insert(written.end(), b, b + n); } +}; +class CaptureFrameSink : public FrameSink { +public: + std::vector last; + void on_frame(const uint8_t* f, size_t n) override { last.assign(f, f + n); } +}; + +void test_framer_integration_roundtrip(void) { + SlotManager slots; + FakeProvider prov; + prov.mode = 9; + Dispatcher d(slots, prov); + d.register_system_commands(); + + // The dispatcher sends built responses through this framer. + CaptureFrameSink tx_sink_unused; // TX framer's RX sink is not exercised + CaptureByteSink tx_bytes; + Framer tx(tx_sink_unused, tx_bytes); + d.set_framer(tx); + + // Simulate the RX framer having decoded a GET_STATE request. + uint8_t req[8]; + size_t rn = make_request(0x42, GET_STATE, 0, nullptr, 0, req); + d.on_frame(req, rn); + + // The response was CRC'd + COBS-framed into tx_bytes; decode it back. + CaptureFrameSink got; + CaptureByteSink sink_unused; + Framer dec(got, sink_unused); + for (uint8_t b : tx_bytes.written) dec.feed_rx(b); + + TEST_ASSERT_TRUE(got.last.size() >= 4 + sizeof(StandardResponse)); + uint8_t type, cid, ct; + StandardResponse sr; + parse_response(got.last.data(), got.last.size(), &type, &cid, &ct, &sr); + TEST_ASSERT_EQUAL_UINT8(RESPONSE, type); + TEST_ASSERT_EQUAL_UINT8(0x42, cid); + TEST_ASSERT_EQUAL_UINT8(9, sr.mode); +} + +int main(int, char**) { + UNITY_BEGIN(); + RUN_TEST(test_unknown_command_rejected); + RUN_TEST(test_bad_length_rejected); + RUN_TEST(test_get_state_returns_injected_state); + RUN_TEST(test_hello_appends_payload_and_resets_session); + RUN_TEST(test_get_info_passthrough); + RUN_TEST(test_diag_page0_counters); + RUN_TEST(test_diag_page1_fault_entries); + RUN_TEST(test_all_responses_within_max_payload); + RUN_TEST(test_retry_completed_answered_from_ring_no_handler); + RUN_TEST(test_framer_integration_roundtrip); + return UNITY_END(); +} diff --git a/firmware/controller/test/test_framer/test_framer.cpp b/firmware/controller/test/test_framer/test_framer.cpp new file mode 100644 index 000000000..540e1d00a --- /dev/null +++ b/firmware/controller/test/test_framer/test_framer.cpp @@ -0,0 +1,331 @@ +#include + +#include +#include +#include + +#include "protocol/frames.h" +#include "protocol/framer.h" + +// Include sources directly for native tests. +#include "protocol/cobs.cpp" +#include "protocol/crc16.cpp" +#include "protocol/framer.cpp" + +using protocol::ByteSink; +using protocol::Framer; +using protocol::FrameSink; + +// --- Test doubles --------------------------------------------------------- + +class TestSink : public FrameSink { +public: + std::vector> frames; + void on_frame(const uint8_t* frame, size_t len) override { + frames.emplace_back(frame, frame + len); + } +}; + +class TestByteSink : public ByteSink { +public: + size_t avail = (size_t)1 << 30; // effectively unlimited by default + std::vector written; + size_t writable() override { return avail; } + void write(const uint8_t* b, size_t n) override { + written.insert(written.end(), b, b + n); + } +}; + +// --- Helpers -------------------------------------------------------------- + +static void feed_bytes(Framer& f, const uint8_t* b, size_t n) { + for (size_t i = 0; i < n; ++i) { + f.feed_rx(b[i]); + } +} + +// Assemble header+payload+CRC and COBS-encode into `out`. Returns encoded len. +static size_t make_encoded_frame(const uint8_t* frame, size_t len, uint8_t* out, size_t out_cap) { + uint8_t dec[protocol::kMaxFrame]; + memcpy(dec, frame, len); + uint16_t crc = protocol::crc16_ccitt(frame, len); + dec[len] = (uint8_t)(crc & 0xFF); + dec[len + 1] = (uint8_t)(crc >> 8); + return protocol::cobs_encode(dec, len + 2, out, out_cap); +} + +// Simple deterministic PRNG (fixed seed) for the corruption sweep. +struct Lcg { + uint32_t s; + uint32_t next() { + s = s * 1103515245u + 12345u; + return (s >> 16) & 0x7FFF; + } +}; + +void setUp(void) {} +void tearDown(void) {} + +// --- (a) happy path ------------------------------------------------------- + +void test_happy_path_exact_bytes(void) { + TestSink sink; + TestByteSink out; + Framer f(sink, out); + + const uint8_t frame[] = {protocol::RESPONSE, 0x2A, protocol::GET_STATE, 0x00, 0x10, 0x20, 0x30}; + uint8_t enc[64]; + size_t n = make_encoded_frame(frame, sizeof(frame), enc, sizeof(enc)); + feed_bytes(f, enc, n); + f.feed_rx(0x00); // delimiter + + TEST_ASSERT_EQUAL_UINT32(1, f.counters().frames_ok); + TEST_ASSERT_EQUAL_size_t(1, sink.frames.size()); + TEST_ASSERT_EQUAL_size_t(sizeof(frame), sink.frames[0].size()); + TEST_ASSERT_EQUAL_MEMORY(frame, sink.frames[0].data(), sizeof(frame)); +} + +// --- (b) back-to-back frames --------------------------------------------- + +void test_back_to_back_frames(void) { + TestSink sink; + TestByteSink out; + Framer f(sink, out); + + const uint8_t f1[] = {protocol::RESPONSE, 1, protocol::HELLO, 0, 0xAA}; + const uint8_t f2[] = {protocol::RESPONSE, 2, protocol::DIAG, 0, 0xBB, 0xCC, 0xDD}; + uint8_t e1[64], e2[64]; + size_t n1 = make_encoded_frame(f1, sizeof(f1), e1, sizeof(e1)); + size_t n2 = make_encoded_frame(f2, sizeof(f2), e2, sizeof(e2)); + + feed_bytes(f, e1, n1); + f.feed_rx(0x00); + feed_bytes(f, e2, n2); + f.feed_rx(0x00); + + TEST_ASSERT_EQUAL_UINT32(2, f.counters().frames_ok); + TEST_ASSERT_EQUAL_size_t(2, sink.frames.size()); + TEST_ASSERT_EQUAL_MEMORY(f1, sink.frames[0].data(), sizeof(f1)); + TEST_ASSERT_EQUAL_MEMORY(f2, sink.frames[1].data(), sizeof(f2)); +} + +// --- (c) corrupted CRC dropped, next frame recovers ----------------------- + +void test_corrupted_crc_then_recover(void) { + TestSink sink; + TestByteSink out; + Framer f(sink, out); + + // Structurally valid COBS but a deliberately wrong CRC. + const uint8_t frame[] = {protocol::RESPONSE, 1, protocol::GET_STATE, 0, 0xAA, 0xBB}; + uint8_t dec[protocol::kMaxFrame]; + memcpy(dec, frame, sizeof(frame)); + uint16_t bad = (uint16_t)(protocol::crc16_ccitt(frame, sizeof(frame)) ^ 0xFFFF); + dec[sizeof(frame)] = (uint8_t)(bad & 0xFF); + dec[sizeof(frame) + 1] = (uint8_t)(bad >> 8); + uint8_t enc[64]; + size_t n = protocol::cobs_encode(dec, sizeof(frame) + 2, enc, sizeof(enc)); + + feed_bytes(f, enc, n); + f.feed_rx(0x00); + TEST_ASSERT_EQUAL_UINT32(1, f.counters().crc_err); + TEST_ASSERT_EQUAL_UINT32(0, f.counters().frames_ok); + TEST_ASSERT_EQUAL_size_t(0, sink.frames.size()); + + // A subsequent valid frame is still received. + uint8_t good[64]; + size_t gn = make_encoded_frame(frame, sizeof(frame), good, sizeof(good)); + feed_bytes(f, good, gn); + f.feed_rx(0x00); + TEST_ASSERT_EQUAL_UINT32(1, f.counters().frames_ok); + TEST_ASSERT_EQUAL_size_t(1, sink.frames.size()); + TEST_ASSERT_EQUAL_MEMORY(frame, sink.frames[0].data(), sizeof(frame)); +} + +// --- (d) truncated frame -> resync, next frame recovers ------------------- + +void test_truncated_resync_then_recover(void) { + TestSink sink; + TestByteSink out; + Framer f(sink, out); + + // Code byte 0x04 promises 3 data bytes but only 2 arrive before delimiter. + const uint8_t bad[] = {0x04, 0x11, 0x22}; + feed_bytes(f, bad, sizeof(bad)); + f.feed_rx(0x00); + TEST_ASSERT_EQUAL_UINT32(1, f.counters().resync); + TEST_ASSERT_EQUAL_UINT32(0, f.counters().crc_err); + TEST_ASSERT_EQUAL_UINT32(0, f.counters().frames_ok); + + const uint8_t frame[] = {protocol::RESPONSE, 7, protocol::GET_INFO, 0, 0x01}; + uint8_t enc[64]; + size_t n = make_encoded_frame(frame, sizeof(frame), enc, sizeof(enc)); + feed_bytes(f, enc, n); + f.feed_rx(0x00); + TEST_ASSERT_EQUAL_UINT32(1, f.counters().frames_ok); + TEST_ASSERT_EQUAL_MEMORY(frame, sink.frames[0].data(), sizeof(frame)); +} + +// --- (e) garbage burst then valid frame ----------------------------------- + +void test_garbage_burst_then_valid(void) { + TestSink sink; + TestByteSink out; + Framer f(sink, out); + + uint8_t garbage[50]; + for (size_t i = 0; i < sizeof(garbage); ++i) { + garbage[i] = (uint8_t)((i % 254) + 1); // never 0x00 + } + feed_bytes(f, garbage, sizeof(garbage)); + f.feed_rx(0x00); // terminate the garbage as one (malformed) frame + + const uint8_t frame[] = {protocol::RESPONSE, 9, protocol::GET_STATE, 0, 0x55, 0x66}; + uint8_t enc[64]; + size_t n = make_encoded_frame(frame, sizeof(frame), enc, sizeof(enc)); + feed_bytes(f, enc, n); + f.feed_rx(0x00); + + // The valid frame arrives; at most the garbage "frame" was lost. + TEST_ASSERT_EQUAL_UINT32(1, f.counters().frames_ok); + TEST_ASSERT_EQUAL_size_t(1, sink.frames.size()); + TEST_ASSERT_EQUAL_MEMORY(frame, sink.frames[0].data(), sizeof(frame)); + uint32_t dropped = f.counters().resync + f.counters().crc_err + f.counters().rx_overflow; + TEST_ASSERT_TRUE_MESSAGE(dropped >= 1, "garbage burst should register as a drop"); +} + +// --- (f) oversize frame dropped, next frame recovers ---------------------- + +void test_oversize_dropped_then_recover(void) { + TestSink sink; + TestByteSink out; + Framer f(sink, out); + + // 520 non-zero bytes encode to > kBufCap, tripping the accumulation guard. + uint8_t big[520]; + for (size_t i = 0; i < sizeof(big); ++i) { + big[i] = (uint8_t)((i % 254) + 1); + } + uint8_t enc[600]; + size_t n = protocol::cobs_encode(big, sizeof(big), enc, sizeof(enc)); + feed_bytes(f, enc, n); + f.feed_rx(0x00); + + TEST_ASSERT_TRUE_MESSAGE(f.counters().rx_overflow >= 1, "oversize frame should count rx_overflow"); + TEST_ASSERT_EQUAL_UINT32(0, f.counters().frames_ok); + TEST_ASSERT_EQUAL_size_t(0, sink.frames.size()); + + const uint8_t frame[] = {protocol::RESPONSE, 3, protocol::GET_STATE, 0, 0x77}; + uint8_t gen[64]; + size_t gn = make_encoded_frame(frame, sizeof(frame), gen, sizeof(gen)); + feed_bytes(f, gen, gn); + f.feed_rx(0x00); + TEST_ASSERT_EQUAL_UINT32(1, f.counters().frames_ok); + TEST_ASSERT_EQUAL_MEMORY(frame, sink.frames[0].data(), sizeof(frame)); +} + +// --- (g) non-blocking TX -------------------------------------------------- + +void test_tx_blocked_returns_false_then_sends(void) { + TestSink sink; + TestByteSink out; + Framer f(sink, out); + + const uint8_t frame[] = {protocol::REQUEST, 5, protocol::GET_STATE, 0}; + + out.avail = 0; + bool ok = f.send_frame(frame, sizeof(frame)); + TEST_ASSERT_FALSE(ok); + TEST_ASSERT_EQUAL_UINT32(1, f.counters().tx_drop); + TEST_ASSERT_EQUAL_size_t(0, out.written.size()); + + out.avail = 1024; + ok = f.send_frame(frame, sizeof(frame)); + TEST_ASSERT_TRUE(ok); + TEST_ASSERT_EQUAL_UINT32(1, f.counters().tx_drop); // unchanged + TEST_ASSERT_TRUE(out.written.size() > 0); + TEST_ASSERT_EQUAL_UINT8(0x00, out.written.back()); // delimiter is last + + // Round-trip: the emitted wire bytes decode back to the original frame. + TestSink sink2; + TestByteSink out2; + Framer g(sink2, out2); + feed_bytes(g, out.written.data(), out.written.size()); + TEST_ASSERT_EQUAL_UINT32(1, g.counters().frames_ok); + TEST_ASSERT_EQUAL_size_t(1, sink2.frames.size()); + TEST_ASSERT_EQUAL_size_t(sizeof(frame), sink2.frames[0].size()); + TEST_ASSERT_EQUAL_MEMORY(frame, sink2.frames[0].data(), sizeof(frame)); +} + +// --- (h) deterministic corruption sweep: <= 1 frame lost per corruption --- + +void test_corruption_sweep_single_byte(void) { + const int N = 200; + Lcg rng{0xC0FFEEu}; + + std::vector stream; + std::vector offs(N), elens(N); + + for (int i = 0; i < N; ++i) { + uint8_t frame[64]; + frame[0] = protocol::RESPONSE; + frame[1] = (uint8_t)i; + frame[2] = protocol::GET_STATE; + frame[3] = 0; + size_t plen = rng.next() % 40; // 0..39 + for (size_t k = 0; k < plen; ++k) { + frame[4 + k] = (uint8_t)(rng.next() & 0xFF); + } + size_t flen = 4 + plen; + uint8_t enc[128]; + size_t n = make_encoded_frame(frame, flen, enc, sizeof(enc)); + offs[i] = stream.size(); + elens[i] = n; + stream.insert(stream.end(), enc, enc + n); + stream.push_back(0x00); // delimiter (never corrupted) + } + + // Sanity: the clean stream fully decodes. + { + TestSink s; + TestByteSink o; + Framer f(s, o); + feed_bytes(f, stream.data(), stream.size()); + TEST_ASSERT_EQUAL_UINT32((uint32_t)N, f.counters().frames_ok); + } + + // Every 7th frame, corrupt each byte position in turn; each corruption may + // lose at most that one frame. + int corrupted_runs = 0; + for (int i = 0; i < N; i += 7) { + for (size_t p = 0; p < elens[i]; ++p) { + size_t idx = offs[i] + p; + uint8_t mask = (uint8_t)((rng.next() % 255) + 1); // 1..255 (always changes) + stream[idx] ^= mask; + + TestSink s; + TestByteSink o; + Framer f(s, o); + feed_bytes(f, stream.data(), stream.size()); + TEST_ASSERT_TRUE_MESSAGE(f.counters().frames_ok >= (uint32_t)(N - 1), + "single-byte corruption lost more than one frame"); + + stream[idx] ^= mask; // restore + ++corrupted_runs; + } + } + TEST_ASSERT_TRUE(corrupted_runs > 0); +} + +int main(int, char**) { + UNITY_BEGIN(); + RUN_TEST(test_happy_path_exact_bytes); + RUN_TEST(test_back_to_back_frames); + RUN_TEST(test_corrupted_crc_then_recover); + RUN_TEST(test_truncated_resync_then_recover); + RUN_TEST(test_garbage_burst_then_valid); + RUN_TEST(test_oversize_dropped_then_recover); + RUN_TEST(test_tx_blocked_returns_false_then_sends); + RUN_TEST(test_corruption_sweep_single_byte); + return UNITY_END(); +} diff --git a/firmware/controller/test/test_frames/test_frames.cpp b/firmware/controller/test/test_frames/test_frames.cpp new file mode 100644 index 000000000..8c5868c87 --- /dev/null +++ b/firmware/controller/test/test_frames/test_frames.cpp @@ -0,0 +1,93 @@ +#include + +#include +#include + +// Header-only wire contract. +#include "protocol/frames.h" + +using namespace protocol; + +// --- Compile-time layout guarantees (the whole point of frames.h) --------- +// These fire on both native (x86_64) and ARM builds; packed structs keep them +// identical, so any drift breaks the build immediately. +static_assert(sizeof(FrameHeader) == 4, "FrameHeader must be 4 bytes"); +static_assert(sizeof(Slot) == 4, "Slot must be 4 bytes"); +static_assert(sizeof(RingEntry) == 4, "RingEntry must be 4 bytes"); +static_assert(sizeof(AxisStateWire) == 8, "AxisStateWire must be 8 bytes"); +static_assert(sizeof(SeqProgressWire) == 12, "SeqProgressWire must be 12 bytes"); +static_assert(sizeof(StandardResponse) == 158, "StandardResponse must be 158 bytes"); +static_assert(sizeof(HelloPayload) == 16, "HelloPayload must be 16 bytes"); +static_assert(sizeof(InfoPayload) == 22, "InfoPayload must be 22 bytes"); +static_assert(sizeof(DiagPayload) == 40, "DiagPayload must be 40 bytes"); +static_assert(sizeof(FaultEntryWire) == 8, "FaultEntryWire must be 8 bytes"); + +// Sub-arrays must line up with the documented byte budget. +static_assert(sizeof(Slot) * 5 == 20, "slots[5] budget"); +static_assert(sizeof(RingEntry) * 8 == 32, "ring[8] budget"); +static_assert(sizeof(AxisStateWire) * 8 == 64, "axes[8] budget"); + +void setUp(void) {} +void tearDown(void) {} + +// --- Runtime: sizes reachable as values (mirrors static_asserts) ---------- + +void test_struct_sizes(void) { + TEST_ASSERT_EQUAL_size_t(158, sizeof(StandardResponse)); + TEST_ASSERT_EQUAL_size_t(16, sizeof(HelloPayload)); + TEST_ASSERT_EQUAL_size_t(22, sizeof(InfoPayload)); + TEST_ASSERT_EQUAL_size_t(40, sizeof(DiagPayload)); +} + +void test_frame_capacity_relationship(void) { + // Header + payload + crc16 must fill exactly one max frame. + TEST_ASSERT_EQUAL_size_t(kMaxFrame, sizeof(FrameHeader) + kMaxPayload + 2); + TEST_ASSERT_EQUAL_UINT8(2, kProtocolVersion); +} + +// --- System command codes: unique and within the 0xF0-0xFF block ---------- + +void test_system_command_codes_unique_and_in_block(void) { + std::set codes; + int cmds[] = {HELLO, GET_INFO, GET_STATE, DIAG, ACK_ERROR, + SET_WATCHDOG, HEARTBEAT, REBOOT_TO_BOOTLOADER, INITIALIZE, RESET}; + for (int c : cmds) { + TEST_ASSERT_TRUE_MESSAGE(codes.find(c) == codes.end(), "duplicate command code"); + codes.insert(c); + TEST_ASSERT_TRUE_MESSAGE(c >= 0xF0 && c <= 0xFF, "command outside system block"); + } + // Spot-check the exact wire values the Python side mirrors. + TEST_ASSERT_EQUAL_HEX8(0xF0, HELLO); + TEST_ASSERT_EQUAL_HEX8(0xF1, GET_INFO); + TEST_ASSERT_EQUAL_HEX8(0xF2, GET_STATE); + TEST_ASSERT_EQUAL_HEX8(0xF3, DIAG); +} + +// --- Resource-bit helpers ------------------------------------------------- + +void test_resource_bit_helpers(void) { + // u64 claim mask: axes 0..15, DACs 16..31, named 32+ (design doc §15 R2). + TEST_ASSERT_EQUAL_HEX64(0x0000000000000001ull, res_axis(0)); + TEST_ASSERT_EQUAL_HEX64(0x0000000000008000ull, res_axis(15)); + TEST_ASSERT_EQUAL_HEX64(0x0000000000010000ull, res_dac(0)); + TEST_ASSERT_EQUAL_HEX64(0x0000000080000000ull, res_dac(15)); + TEST_ASSERT_EQUAL_HEX64(0x0000000100000000ull, RES_ILLUM_TTL); + TEST_ASSERT_EQUAL_HEX64(0x0000000200000000ull, RES_LED_MATRIX); + TEST_ASSERT_EQUAL_HEX64(0x0000000400000000ull, RES_CAM_TRIGGERS); + TEST_ASSERT_EQUAL_HEX64(0x0000000800000000ull, RES_GPIO); + TEST_ASSERT_EQUAL_HEX64(0x0000001000000000ull, RES_SEQUENCER); + TEST_ASSERT_EQUAL_HEX64(0x0000002000000000ull, RES_SYS_CONFIG); + + // Distinct axes do not overlap; DAC bank sits above the axis bank. + TEST_ASSERT_EQUAL_HEX64(0ull, res_axis(0) & res_axis(1)); + TEST_ASSERT_EQUAL_HEX64(0ull, res_axis(15) & res_dac(0)); +} + +int main(int, char**) { + UNITY_BEGIN(); + RUN_TEST(test_struct_sizes); + RUN_TEST(test_frame_capacity_relationship); + RUN_TEST(test_system_command_codes_unique_and_in_block); + RUN_TEST(test_resource_bit_helpers); + return UNITY_END(); +} diff --git a/firmware/controller/test/test_golden/golden_cases.h b/firmware/controller/test/test_golden/golden_cases.h new file mode 100644 index 000000000..4d469b25c --- /dev/null +++ b/firmware/controller/test/test_golden/golden_cases.h @@ -0,0 +1,207 @@ +// Generated by software/tools/gen_protocol_golden.py — DO NOT EDIT. +// Cross-language golden vectors; CI regenerates and git-diffs this file. +#ifndef GOLDEN_CASES_H +#define GOLDEN_CASES_H + +#include +#include + +struct GoldenCase { + const char* name; + uint8_t type; + uint8_t cmd_id; + uint8_t cmd_type; + uint8_t flags; + const uint8_t* payload; + size_t payload_len; + const uint8_t* wire; + size_t wire_len; +}; + +static const uint8_t kGoldenPayload0[] = {0}; // empty (len 0) + +static const uint8_t kGoldenWire0[] = { + 0x04, 0x01, 0x01, 0xF0, 0x03, 0x85, 0xD6, 0x00, +}; + +static const uint8_t kGoldenPayload1[] = {0}; // empty (len 0) + +static const uint8_t kGoldenWire1[] = { + 0x04, 0x01, 0x02, 0xF1, 0x03, 0xE4, 0xBC, 0x00, +}; + +static const uint8_t kGoldenPayload2[] = {0}; // empty (len 0) + +static const uint8_t kGoldenWire2[] = { + 0x04, 0x01, 0x03, 0xF2, 0x03, 0x87, 0xDE, 0x00, +}; + +static const uint8_t kGoldenPayload3[] = { + 0x00, +}; + +static const uint8_t kGoldenWire3[] = { + 0x04, 0x01, 0x04, 0xF3, 0x01, 0x03, 0xAE, 0xCB, 0x00, +}; + +static const uint8_t kGoldenPayload4[] = { + 0x01, +}; + +static const uint8_t kGoldenWire4[] = { + 0x08, 0x01, 0x05, 0xF3, 0x01, 0x01, 0x0A, 0x9E, 0x00, +}; + +static const uint8_t kGoldenPayload5[] = { + 0x03, 0x28, 0x4D, 0x72, 0x97, 0xBC, 0xE1, 0x06, 0x2B, 0x50, 0x75, 0x9A, + 0xBF, 0xE4, 0x09, 0x2E, 0x53, 0x78, 0x9D, 0xC2, 0xE7, 0x0C, 0x31, 0x56, + 0x7B, 0xA0, 0xC5, 0xEA, 0x0F, 0x34, 0x59, 0x7E, 0xA3, 0xC8, 0xED, 0x12, + 0x37, 0x5C, 0x81, 0xA6, 0xCB, 0xF0, 0x15, 0x3A, 0x5F, 0x84, 0xA9, 0xCE, + 0xF3, 0x18, 0x3D, 0x62, 0x87, 0xAC, 0xD1, 0xF6, 0x1B, 0x40, 0x65, 0x8A, + 0xAF, 0xD4, 0xF9, 0x1E, 0x43, 0x68, 0x8D, 0xB2, 0xD7, 0xFC, 0x21, 0x46, + 0x6B, 0x90, 0xB5, 0xDA, 0xFF, 0x24, 0x49, 0x6E, 0x93, 0xB8, 0xDD, 0x02, + 0x27, 0x4C, 0x71, 0x96, 0xBB, 0xE0, 0x05, 0x2A, 0x4F, 0x74, 0x99, 0xBE, + 0xE3, 0x08, 0x2D, 0x52, 0x77, 0x9C, 0xC1, 0xE6, 0x0B, 0x30, 0x55, 0x7A, + 0x9F, 0xC4, 0xE9, 0x0E, 0x33, 0x58, 0x7D, 0xA2, 0xC7, 0xEC, 0x11, 0x36, + 0x5B, 0x80, 0xA5, 0xCA, 0xEF, 0x14, 0x39, 0x5E, 0x83, 0xA8, 0xCD, 0xF2, + 0x17, 0x3C, 0x61, 0x86, 0xAB, 0xD0, 0xF5, 0x1A, 0x3F, 0x64, 0x89, 0xAE, + 0xD3, 0xF8, 0x1D, 0x42, 0x67, 0x8C, 0xB1, 0xD6, 0xFB, 0x20, 0x45, 0x6A, + 0x8F, 0xB4, +}; + +static const uint8_t kGoldenWire5[] = { + 0x04, 0x02, 0x06, 0xF2, 0xA1, 0x03, 0x28, 0x4D, 0x72, 0x97, 0xBC, 0xE1, + 0x06, 0x2B, 0x50, 0x75, 0x9A, 0xBF, 0xE4, 0x09, 0x2E, 0x53, 0x78, 0x9D, + 0xC2, 0xE7, 0x0C, 0x31, 0x56, 0x7B, 0xA0, 0xC5, 0xEA, 0x0F, 0x34, 0x59, + 0x7E, 0xA3, 0xC8, 0xED, 0x12, 0x37, 0x5C, 0x81, 0xA6, 0xCB, 0xF0, 0x15, + 0x3A, 0x5F, 0x84, 0xA9, 0xCE, 0xF3, 0x18, 0x3D, 0x62, 0x87, 0xAC, 0xD1, + 0xF6, 0x1B, 0x40, 0x65, 0x8A, 0xAF, 0xD4, 0xF9, 0x1E, 0x43, 0x68, 0x8D, + 0xB2, 0xD7, 0xFC, 0x21, 0x46, 0x6B, 0x90, 0xB5, 0xDA, 0xFF, 0x24, 0x49, + 0x6E, 0x93, 0xB8, 0xDD, 0x02, 0x27, 0x4C, 0x71, 0x96, 0xBB, 0xE0, 0x05, + 0x2A, 0x4F, 0x74, 0x99, 0xBE, 0xE3, 0x08, 0x2D, 0x52, 0x77, 0x9C, 0xC1, + 0xE6, 0x0B, 0x30, 0x55, 0x7A, 0x9F, 0xC4, 0xE9, 0x0E, 0x33, 0x58, 0x7D, + 0xA2, 0xC7, 0xEC, 0x11, 0x36, 0x5B, 0x80, 0xA5, 0xCA, 0xEF, 0x14, 0x39, + 0x5E, 0x83, 0xA8, 0xCD, 0xF2, 0x17, 0x3C, 0x61, 0x86, 0xAB, 0xD0, 0xF5, + 0x1A, 0x3F, 0x64, 0x89, 0xAE, 0xD3, 0xF8, 0x1D, 0x42, 0x67, 0x8C, 0xB1, + 0xD6, 0xFB, 0x20, 0x45, 0x6A, 0x8F, 0xB4, 0x2C, 0xCD, 0x00, +}; + +static const uint8_t kGoldenPayload6[] = { + 0x07, 0x2C, 0x51, 0x76, 0x9B, 0xC0, 0xE5, 0x0A, 0x2F, 0x54, 0x79, 0x9E, + 0xC3, 0xE8, 0x0D, 0x32, 0x57, 0x7C, 0xA1, 0xC6, 0xEB, 0x10, 0x35, 0x5A, + 0x7F, 0xA4, 0xC9, 0xEE, 0x13, 0x38, 0x5D, 0x82, 0xA7, 0xCC, 0xF1, 0x16, + 0x3B, 0x60, 0x85, 0xAA, 0xCF, 0xF4, 0x19, 0x3E, 0x63, 0x88, 0xAD, 0xD2, + 0xF7, 0x1C, 0x41, 0x66, 0x8B, 0xB0, 0xD5, 0xFA, 0x1F, 0x44, 0x69, 0x8E, + 0xB3, 0xD8, 0xFD, 0x22, 0x47, 0x6C, 0x91, 0xB6, 0xDB, 0x00, 0x25, 0x4A, + 0x6F, 0x94, 0xB9, 0xDE, 0x03, 0x28, 0x4D, 0x72, 0x97, 0xBC, 0xE1, 0x06, + 0x2B, 0x50, 0x75, 0x9A, 0xBF, 0xE4, 0x09, 0x2E, 0x53, 0x78, 0x9D, 0xC2, + 0xE7, 0x0C, 0x31, 0x56, 0x7B, 0xA0, 0xC5, 0xEA, 0x0F, 0x34, 0x59, 0x7E, + 0xA3, 0xC8, 0xED, 0x12, 0x37, 0x5C, 0x81, 0xA6, 0xCB, 0xF0, 0x15, 0x3A, + 0x5F, 0x84, 0xA9, 0xCE, 0xF3, 0x18, 0x3D, 0x62, 0x87, 0xAC, 0xD1, 0xF6, + 0x1B, 0x40, 0x65, 0x8A, 0xAF, 0xD4, 0xF9, 0x1E, 0x43, 0x68, 0x8D, 0xB2, + 0xD7, 0xFC, 0x21, 0x46, 0x6B, 0x90, 0xB5, 0xDA, 0xFF, 0x24, 0x49, 0x6E, + 0x93, 0xB8, 0xDD, 0x02, 0x27, 0x4C, 0x71, 0x96, 0xBB, 0xE0, 0x05, 0x2A, + 0x4F, 0x74, 0x99, 0xBE, 0xE3, 0x08, 0x2D, 0x52, 0x77, 0x9C, 0xC1, 0xE6, + 0x0B, 0x30, 0x55, 0x7A, 0x9F, 0xC4, 0xE9, 0x0E, 0x33, 0x58, 0x7D, 0xA2, + 0xC7, 0xEC, 0x11, 0x36, 0x5B, 0x80, 0xA5, 0xCA, 0xEF, 0x14, 0x39, 0x5E, + 0x83, 0xA8, 0xCD, 0xF2, 0x17, 0x3C, 0x61, 0x86, 0xAB, 0xD0, 0xF5, 0x1A, + 0x3F, 0x64, 0x89, 0xAE, 0xD3, 0xF8, 0x1D, 0x42, 0x67, 0x8C, 0xB1, 0xD6, + 0xFB, 0x20, 0x45, 0x6A, 0x8F, 0xB4, 0xD9, 0xFE, 0x23, 0x48, 0x6D, 0x92, + 0xB7, 0xDC, 0x01, 0x26, 0x4B, 0x70, 0x95, 0xBA, 0xDF, 0x04, 0x29, 0x4E, + 0x73, 0x98, 0xBD, 0xE2, 0x07, 0x2C, 0x51, 0x76, 0x9B, 0xC0, 0xE5, 0x0A, + 0x2F, 0x54, 0x79, 0x9E, 0xC3, 0xE8, 0x0D, 0x32, 0x57, 0x7C, 0xA1, 0xC6, + 0xEB, 0x10, 0x35, 0x5A, 0x7F, 0xA4, 0xC9, 0xEE, 0x13, 0x38, 0x5D, 0x82, + 0xA7, 0xCC, 0xF1, 0x16, 0x3B, 0x60, 0x85, 0xAA, 0xCF, 0xF4, 0x19, 0x3E, + 0x63, 0x88, 0xAD, 0xD2, 0xF7, 0x1C, 0x41, 0x66, 0x8B, 0xB0, 0xD5, 0xFA, + 0x1F, 0x44, 0x69, 0x8E, 0xB3, 0xD8, 0xFD, 0x22, 0x47, 0x6C, 0x91, 0xB6, + 0xDB, 0x00, 0x25, 0x4A, 0x6F, 0x94, 0xB9, 0xDE, 0x03, 0x28, 0x4D, 0x72, + 0x97, 0xBC, 0xE1, 0x06, 0x2B, 0x50, 0x75, 0x9A, 0xBF, 0xE4, 0x09, 0x2E, + 0x53, 0x78, 0x9D, 0xC2, 0xE7, 0x0C, 0x31, 0x56, 0x7B, 0xA0, 0xC5, 0xEA, + 0x0F, 0x34, 0x59, 0x7E, 0xA3, 0xC8, 0xED, 0x12, 0x37, 0x5C, 0x81, 0xA6, + 0xCB, 0xF0, 0x15, 0x3A, 0x5F, 0x84, 0xA9, 0xCE, 0xF3, 0x18, 0x3D, 0x62, + 0x87, 0xAC, 0xD1, 0xF6, 0x1B, 0x40, 0x65, 0x8A, 0xAF, 0xD4, 0xF9, 0x1E, + 0x43, 0x68, 0x8D, 0xB2, 0xD7, 0xFC, 0x21, 0x46, 0x6B, 0x90, 0xB5, 0xDA, + 0xFF, 0x24, 0x49, 0x6E, 0x93, 0xB8, 0xDD, 0x02, 0x27, 0x4C, 0x71, 0x96, + 0xBB, 0xE0, 0x05, 0x2A, 0x4F, 0x74, 0x99, 0xBE, 0xE3, 0x08, 0x2D, 0x52, + 0x77, 0x9C, 0xC1, 0xE6, 0x0B, 0x30, 0x55, 0x7A, 0x9F, 0xC4, 0xE9, 0x0E, + 0x33, 0x58, 0x7D, 0xA2, 0xC7, 0xEC, 0x11, 0x36, 0x5B, 0x80, 0xA5, 0xCA, + 0xEF, 0x14, 0x39, 0x5E, 0x83, 0xA8, 0xCD, 0xF2, 0x17, 0x3C, 0x61, 0x86, + 0xAB, 0xD0, 0xF5, 0x1A, 0x3F, 0x64, 0x89, 0xAE, 0xD3, 0xF8, 0x1D, 0x42, + 0x67, 0x8C, 0xB1, 0xD6, 0xFB, 0x20, 0x45, 0x6A, 0x8F, 0xB4, 0xD9, 0xFE, + 0x23, 0x48, 0x6D, 0x92, 0xB7, 0xDC, 0x01, 0x26, 0x4B, 0x70, 0x95, 0xBA, + 0xDF, 0x04, +}; + +static const uint8_t kGoldenWire6[] = { + 0x04, 0x02, 0x07, 0xF3, 0x46, 0x07, 0x2C, 0x51, 0x76, 0x9B, 0xC0, 0xE5, + 0x0A, 0x2F, 0x54, 0x79, 0x9E, 0xC3, 0xE8, 0x0D, 0x32, 0x57, 0x7C, 0xA1, + 0xC6, 0xEB, 0x10, 0x35, 0x5A, 0x7F, 0xA4, 0xC9, 0xEE, 0x13, 0x38, 0x5D, + 0x82, 0xA7, 0xCC, 0xF1, 0x16, 0x3B, 0x60, 0x85, 0xAA, 0xCF, 0xF4, 0x19, + 0x3E, 0x63, 0x88, 0xAD, 0xD2, 0xF7, 0x1C, 0x41, 0x66, 0x8B, 0xB0, 0xD5, + 0xFA, 0x1F, 0x44, 0x69, 0x8E, 0xB3, 0xD8, 0xFD, 0x22, 0x47, 0x6C, 0x91, + 0xB6, 0xDB, 0xFF, 0x25, 0x4A, 0x6F, 0x94, 0xB9, 0xDE, 0x03, 0x28, 0x4D, + 0x72, 0x97, 0xBC, 0xE1, 0x06, 0x2B, 0x50, 0x75, 0x9A, 0xBF, 0xE4, 0x09, + 0x2E, 0x53, 0x78, 0x9D, 0xC2, 0xE7, 0x0C, 0x31, 0x56, 0x7B, 0xA0, 0xC5, + 0xEA, 0x0F, 0x34, 0x59, 0x7E, 0xA3, 0xC8, 0xED, 0x12, 0x37, 0x5C, 0x81, + 0xA6, 0xCB, 0xF0, 0x15, 0x3A, 0x5F, 0x84, 0xA9, 0xCE, 0xF3, 0x18, 0x3D, + 0x62, 0x87, 0xAC, 0xD1, 0xF6, 0x1B, 0x40, 0x65, 0x8A, 0xAF, 0xD4, 0xF9, + 0x1E, 0x43, 0x68, 0x8D, 0xB2, 0xD7, 0xFC, 0x21, 0x46, 0x6B, 0x90, 0xB5, + 0xDA, 0xFF, 0x24, 0x49, 0x6E, 0x93, 0xB8, 0xDD, 0x02, 0x27, 0x4C, 0x71, + 0x96, 0xBB, 0xE0, 0x05, 0x2A, 0x4F, 0x74, 0x99, 0xBE, 0xE3, 0x08, 0x2D, + 0x52, 0x77, 0x9C, 0xC1, 0xE6, 0x0B, 0x30, 0x55, 0x7A, 0x9F, 0xC4, 0xE9, + 0x0E, 0x33, 0x58, 0x7D, 0xA2, 0xC7, 0xEC, 0x11, 0x36, 0x5B, 0x80, 0xA5, + 0xCA, 0xEF, 0x14, 0x39, 0x5E, 0x83, 0xA8, 0xCD, 0xF2, 0x17, 0x3C, 0x61, + 0x86, 0xAB, 0xD0, 0xF5, 0x1A, 0x3F, 0x64, 0x89, 0xAE, 0xD3, 0xF8, 0x1D, + 0x42, 0x67, 0x8C, 0xB1, 0xD6, 0xFB, 0x20, 0x45, 0x6A, 0x8F, 0xB4, 0xD9, + 0xFE, 0x23, 0x48, 0x6D, 0x92, 0xB7, 0xDC, 0x01, 0x26, 0x4B, 0x70, 0x95, + 0xBA, 0xDF, 0x04, 0x29, 0x4E, 0x73, 0x98, 0xBD, 0xE2, 0x07, 0x2C, 0x51, + 0x76, 0x9B, 0xC0, 0xE5, 0x0A, 0x2F, 0x54, 0x79, 0x9E, 0xC3, 0xE8, 0x0D, + 0x32, 0x57, 0x7C, 0xA1, 0xC6, 0xEB, 0x10, 0x35, 0x5A, 0x7F, 0xA4, 0xC9, + 0xEE, 0x13, 0x38, 0x5D, 0x82, 0xA7, 0xCC, 0xF1, 0x16, 0x3B, 0x60, 0x85, + 0xAA, 0xCF, 0xF4, 0x19, 0x3E, 0x63, 0x88, 0xAD, 0xD2, 0xF7, 0x1C, 0x41, + 0x66, 0x8B, 0xB0, 0xD5, 0xFA, 0x1F, 0x44, 0x69, 0x8E, 0xB3, 0xD8, 0xFD, + 0x22, 0x47, 0x6C, 0x91, 0xB6, 0x02, 0xDB, 0xB7, 0x25, 0x4A, 0x6F, 0x94, + 0xB9, 0xDE, 0x03, 0x28, 0x4D, 0x72, 0x97, 0xBC, 0xE1, 0x06, 0x2B, 0x50, + 0x75, 0x9A, 0xBF, 0xE4, 0x09, 0x2E, 0x53, 0x78, 0x9D, 0xC2, 0xE7, 0x0C, + 0x31, 0x56, 0x7B, 0xA0, 0xC5, 0xEA, 0x0F, 0x34, 0x59, 0x7E, 0xA3, 0xC8, + 0xED, 0x12, 0x37, 0x5C, 0x81, 0xA6, 0xCB, 0xF0, 0x15, 0x3A, 0x5F, 0x84, + 0xA9, 0xCE, 0xF3, 0x18, 0x3D, 0x62, 0x87, 0xAC, 0xD1, 0xF6, 0x1B, 0x40, + 0x65, 0x8A, 0xAF, 0xD4, 0xF9, 0x1E, 0x43, 0x68, 0x8D, 0xB2, 0xD7, 0xFC, + 0x21, 0x46, 0x6B, 0x90, 0xB5, 0xDA, 0xFF, 0x24, 0x49, 0x6E, 0x93, 0xB8, + 0xDD, 0x02, 0x27, 0x4C, 0x71, 0x96, 0xBB, 0xE0, 0x05, 0x2A, 0x4F, 0x74, + 0x99, 0xBE, 0xE3, 0x08, 0x2D, 0x52, 0x77, 0x9C, 0xC1, 0xE6, 0x0B, 0x30, + 0x55, 0x7A, 0x9F, 0xC4, 0xE9, 0x0E, 0x33, 0x58, 0x7D, 0xA2, 0xC7, 0xEC, + 0x11, 0x36, 0x5B, 0x80, 0xA5, 0xCA, 0xEF, 0x14, 0x39, 0x5E, 0x83, 0xA8, + 0xCD, 0xF2, 0x17, 0x3C, 0x61, 0x86, 0xAB, 0xD0, 0xF5, 0x1A, 0x3F, 0x64, + 0x89, 0xAE, 0xD3, 0xF8, 0x1D, 0x42, 0x67, 0x8C, 0xB1, 0xD6, 0xFB, 0x20, + 0x45, 0x6A, 0x8F, 0xB4, 0xD9, 0xFE, 0x23, 0x48, 0x6D, 0x92, 0xB7, 0xDC, + 0x01, 0x26, 0x4B, 0x70, 0x95, 0xBA, 0xDF, 0x04, 0xF4, 0x71, 0x00, +}; + +static const uint8_t kGoldenPayload7[] = { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +}; + +static const uint8_t kGoldenWire7[] = { + 0x04, 0x02, 0x08, 0xF2, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, + 0x03, 0xD3, 0xE1, 0x00, +}; + +static const GoldenCase kGoldenCases[] = { + {"hello_request", 0x01, 1, 0xF0, 0x00, kGoldenPayload0, 0, kGoldenWire0, 8}, + {"get_info_request", 0x01, 2, 0xF1, 0x00, kGoldenPayload1, 0, kGoldenWire1, 8}, + {"get_state_request", 0x01, 3, 0xF2, 0x00, kGoldenPayload2, 0, kGoldenWire2, 8}, + {"diag_request_page0", 0x01, 4, 0xF3, 0x00, kGoldenPayload3, 1, kGoldenWire3, 9}, + {"diag_request_retry", 0x01, 5, 0xF3, 0x01, kGoldenPayload4, 1, kGoldenWire4, 9}, + {"std_response_with_zeros", 0x02, 6, 0xF2, 0x00, kGoldenPayload5, 158, kGoldenWire5, 166}, + {"max_payload_response", 0x02, 7, 0xF3, 0x00, kGoldenPayload6, 506, kGoldenWire6, 515}, + {"all_zeros_payload", 0x02, 8, 0xF2, 0x00, kGoldenPayload7, 32, kGoldenWire7, 40}, +}; + +static const size_t kNumGoldenCases = sizeof(kGoldenCases) / sizeof(kGoldenCases[0]); + +#endif // GOLDEN_CASES_H diff --git a/firmware/controller/test/test_golden/test_golden.cpp b/firmware/controller/test/test_golden/test_golden.cpp new file mode 100644 index 000000000..00a3edf77 --- /dev/null +++ b/firmware/controller/test/test_golden/test_golden.cpp @@ -0,0 +1,89 @@ +#include + +#include +#include + +#include "protocol/cobs.h" +#include "protocol/crc16.h" +#include "protocol/frames.h" // kMaxFrame + +// Include sources directly for native tests. +#include "protocol/cobs.cpp" +#include "protocol/crc16.cpp" + +// Generated golden vectors (by software/tools/gen_protocol_golden.py). +#include "golden_cases.h" + +using protocol::cobs_decode; +using protocol::cobs_encode; +using protocol::crc16_ccitt; + +void setUp(void) {} +void tearDown(void) {} + +// Encode header+payload+CRC via COBS and confirm the wire bytes match the +// Python-generated golden vector byte-for-byte. +void test_golden_encode_matches(void) { + for (size_t c = 0; c < kNumGoldenCases; ++c) { + const GoldenCase& g = kGoldenCases[c]; + + uint8_t decoded[protocol::kMaxFrame]; + decoded[0] = g.type; + decoded[1] = g.cmd_id; + decoded[2] = g.cmd_type; + decoded[3] = g.flags; + memcpy(decoded + 4, g.payload, g.payload_len); + size_t frame_len = 4 + g.payload_len; + + uint16_t crc = crc16_ccitt(decoded, frame_len); + decoded[frame_len] = (uint8_t)(crc & 0xFF); + decoded[frame_len + 1] = (uint8_t)(crc >> 8); + size_t decoded_len = frame_len + 2; + + uint8_t enc[protocol::kMaxFrame + 8]; + size_t enc_len = cobs_encode(decoded, decoded_len, enc, sizeof(enc)); + TEST_ASSERT_TRUE_MESSAGE(enc_len > 0, g.name); + + // Wire = encoded + 0x00 delimiter. + TEST_ASSERT_EQUAL_size_t_MESSAGE(g.wire_len, enc_len + 1, g.name); + TEST_ASSERT_EQUAL_MEMORY_MESSAGE(g.wire, enc, enc_len, g.name); + TEST_ASSERT_EQUAL_UINT8_MESSAGE(0x00, g.wire[enc_len], g.name); + } +} + +// Decode each golden wire vector and confirm it reproduces the frame + a valid +// CRC — the mirror of the Python decode path. +void test_golden_decode_matches(void) { + for (size_t c = 0; c < kNumGoldenCases; ++c) { + const GoldenCase& g = kGoldenCases[c]; + + // Strip the trailing 0x00 delimiter before COBS-decoding. + uint8_t dec[protocol::kMaxFrame]; + int32_t dec_len = cobs_decode(g.wire, g.wire_len - 1, dec, sizeof(dec)); + TEST_ASSERT_TRUE_MESSAGE(dec_len >= 6, g.name); + + // Header echoes the case. + TEST_ASSERT_EQUAL_UINT8_MESSAGE(g.type, dec[0], g.name); + TEST_ASSERT_EQUAL_UINT8_MESSAGE(g.cmd_id, dec[1], g.name); + TEST_ASSERT_EQUAL_UINT8_MESSAGE(g.cmd_type, dec[2], g.name); + TEST_ASSERT_EQUAL_UINT8_MESSAGE(g.flags, dec[3], g.name); + + // Payload matches. + TEST_ASSERT_EQUAL_size_t_MESSAGE(g.payload_len, (size_t)dec_len - 6, g.name); + if (g.payload_len > 0) { + TEST_ASSERT_EQUAL_MEMORY_MESSAGE(g.payload, dec + 4, g.payload_len, g.name); + } + + // CRC over the frame (header + payload) validates. + size_t body = (size_t)dec_len - 2; + uint16_t rx_crc = (uint16_t)(dec[body] | ((uint16_t)dec[body + 1] << 8)); + TEST_ASSERT_EQUAL_HEX16_MESSAGE(crc16_ccitt(dec, body), rx_crc, g.name); + } +} + +int main(int, char**) { + UNITY_BEGIN(); + RUN_TEST(test_golden_encode_matches); + RUN_TEST(test_golden_decode_matches); + return UNITY_END(); +} diff --git a/firmware/controller/test/test_slots/test_slots.cpp b/firmware/controller/test/test_slots/test_slots.cpp new file mode 100644 index 000000000..58281e6f1 --- /dev/null +++ b/firmware/controller/test/test_slots/test_slots.cpp @@ -0,0 +1,226 @@ +#include + +#include + +#include "protocol/frames.h" +#include "protocol/slots.h" + +// Include sources directly for native tests (SlotManager uses claims_conflict). +#include "protocol/claims.cpp" +#include "protocol/slots.cpp" + +using namespace protocol; + +void setUp(void) {} +void tearDown(void) {} + +// Convenience wrapper: accept with all out-params captured. +static AcceptResult accept(SlotManager& m, uint8_t id, uint8_t type, uint32_t claims, + bool retry, uint8_t* cres, uint8_t* holder, uint8_t* rstat, + uint8_t* rerr) { + uint8_t a = 0xFF, b = 0xFF, c = 0xFF, d = 0xFF; + AcceptResult r = m.try_accept(id, type, claims, retry, &a, &b, &c, &d); + if (cres) *cres = a; + if (holder) *holder = b; + if (rstat) *rstat = c; + if (rerr) *rerr = d; + return r; +} + +// --- Accept / capacity ---------------------------------------------------- + +void test_accept_five_slots_then_no_slots(void) { + SlotManager m; + // Five compatible (zero-claim) commands fill all slots. + for (uint8_t i = 1; i <= 5; ++i) { + TEST_ASSERT_EQUAL(AcceptResult::NewCommand, + accept(m, i, 0x01, 0, false, nullptr, nullptr, nullptr, nullptr)); + } + // The sixth compatible command has nowhere to go. + TEST_ASSERT_EQUAL(AcceptResult::RejectNoSlots, + accept(m, 6, 0x01, 0, false, nullptr, nullptr, nullptr, nullptr)); +} + +// --- Resource conflict ---------------------------------------------------- + +void test_conflict_reports_resource_and_holder(void) { + SlotManager m; + TEST_ASSERT_EQUAL(AcceptResult::NewCommand, + accept(m, 42, 0x01, res_axis(0), false, nullptr, nullptr, nullptr, nullptr)); + + uint8_t cres = 0xFF, holder = 0xFF; + AcceptResult r = accept(m, 43, 0x02, res_axis(0), false, &cres, &holder, nullptr, nullptr); + TEST_ASSERT_EQUAL(AcceptResult::RejectBusy, r); + TEST_ASSERT_EQUAL_UINT8(0, cres); // resource id 0 (axis 0) + TEST_ASSERT_EQUAL_UINT8(42, holder); // held by cmd 42 + + // A disjoint resource is accepted. + TEST_ASSERT_EQUAL(AcceptResult::NewCommand, + accept(m, 44, 0x02, res_axis(1), false, nullptr, nullptr, nullptr, nullptr)); +} + +// --- Complete -> ring + head_seq, claims released ------------------------- + +void test_complete_frees_slot_and_records_ring(void) { + SlotManager m; + TEST_ASSERT_EQUAL_UINT8(0, m.ring_head_seq()); + accept(m, 3, 0x0A, res_axis(0), false, nullptr, nullptr, nullptr, nullptr); + TEST_ASSERT_NOT_NULL(m.find(3)); + TEST_ASSERT_EQUAL_HEX32(res_axis(0), m.inflight_claims_union()); + + m.complete(3, STATUS_FAILED, ERR_INVALID_PARAMETER); + TEST_ASSERT_NULL(m.find(3)); // slot freed + TEST_ASSERT_EQUAL_HEX32(0, m.inflight_claims_union()); // claims released + TEST_ASSERT_EQUAL_UINT8(1, m.ring_head_seq()); // head_seq advanced + + uint8_t st = 0, er = 0; + TEST_ASSERT_TRUE(m.ring_lookup(3, &st, &er)); + TEST_ASSERT_EQUAL_UINT8(STATUS_FAILED, st); + TEST_ASSERT_EQUAL_UINT8(ERR_INVALID_PARAMETER, er); +} + +// --- Ring wraps at 8 keeping the newest ----------------------------------- + +void test_ring_wraps_keeping_newest(void) { + SlotManager m; + for (uint8_t i = 1; i <= 10; ++i) { + accept(m, i, 0x01, 0, false, nullptr, nullptr, nullptr, nullptr); + m.complete(i, STATUS_OK, ERR_NONE); + } + TEST_ASSERT_EQUAL_UINT8(10, m.ring_head_seq()); + + uint8_t st = 0, er = 0; + // The two oldest (1, 2) were evicted; 3..10 remain. + TEST_ASSERT_FALSE(m.ring_lookup(1, &st, &er)); + TEST_ASSERT_FALSE(m.ring_lookup(2, &st, &er)); + TEST_ASSERT_TRUE(m.ring_lookup(3, &st, &er)); + TEST_ASSERT_TRUE(m.ring_lookup(10, &st, &er)); + TEST_ASSERT_EQUAL_UINT8(STATUS_OK, st); +} + +// --- find() --------------------------------------------------------------- + +void test_find_returns_active_slot(void) { + SlotManager m; + accept(m, 77, 0x30, res_axis(2), false, nullptr, nullptr, nullptr, nullptr); + const SlotInfo* s = m.find(77); + TEST_ASSERT_NOT_NULL(s); + TEST_ASSERT_EQUAL_UINT8(77, s->cmd_id); + TEST_ASSERT_EQUAL_UINT8(0x30, s->cmd_type); + TEST_ASSERT_EQUAL_UINT8(SLOT_ACTIVE, s->state); + TEST_ASSERT_EQUAL_HEX32(res_axis(2), s->claims); + TEST_ASSERT_NULL(m.find(78)); // never accepted +} + +// --- RETRY semantics ------------------------------------------------------ + +void test_retry_of_active_is_active_duplicate(void) { + SlotManager m; + accept(m, 9, 0x01, res_axis(0), false, nullptr, nullptr, nullptr, nullptr); + // A retry of an in-flight command returns its live state, no new slot. + TEST_ASSERT_EQUAL(AcceptResult::ActiveDuplicate, + accept(m, 9, 0x01, res_axis(0), true, nullptr, nullptr, nullptr, nullptr)); + // Still exactly one active claim. + TEST_ASSERT_EQUAL_HEX32(res_axis(0), m.inflight_claims_union()); + // A non-retry duplicate of an active cmd_id is also deduped (never re-run). + TEST_ASSERT_EQUAL(AcceptResult::ActiveDuplicate, + accept(m, 9, 0x01, res_axis(0), false, nullptr, nullptr, nullptr, nullptr)); +} + +void test_retry_of_completed_replays_ring_without_reexec(void) { + SlotManager m; + accept(m, 5, 0x0A, 0, false, nullptr, nullptr, nullptr, nullptr); + m.complete(5, STATUS_FAILED, ERR_RESOURCE_BUSY); + + uint8_t rstat = 0, rerr = 0; + AcceptResult r = accept(m, 5, 0x0A, 0, true, nullptr, nullptr, &rstat, &rerr); + TEST_ASSERT_EQUAL(AcceptResult::CompletedDuplicate, r); + TEST_ASSERT_EQUAL_UINT8(STATUS_FAILED, rstat); + TEST_ASSERT_EQUAL_UINT8(ERR_RESOURCE_BUSY, rerr); + // No re-execution: no new slot was reserved. + TEST_ASSERT_NULL(m.find(5)); + TEST_ASSERT_EQUAL_HEX32(0, m.inflight_claims_union()); +} + +void test_retry_of_unknown_is_treated_as_new(void) { + SlotManager m; + // Never seen cmd 200; a retry is treated as a fresh command. + TEST_ASSERT_EQUAL(AcceptResult::NewCommand, + accept(m, 200, 0x01, res_axis(0), true, nullptr, nullptr, nullptr, nullptr)); + TEST_ASSERT_NOT_NULL(m.find(200)); +} + +void test_non_retry_reuse_of_completed_id_is_new(void) { + SlotManager m; + accept(m, 5, 0x0A, 0, false, nullptr, nullptr, nullptr, nullptr); + m.complete(5, STATUS_OK, ERR_NONE); + // A fresh (non-retry) command reusing a recently-completed id is NEW, not + // a ring replay — cmd_ids recycle faster than the 8-entry ring forgets. + TEST_ASSERT_EQUAL(AcceptResult::NewCommand, + accept(m, 5, 0x0A, 0, false, nullptr, nullptr, nullptr, nullptr)); + TEST_ASSERT_NOT_NULL(m.find(5)); +} + +// --- set_progress + fill_response + reset --------------------------------- + +void test_set_progress_and_fill_response(void) { + SlotManager m; + accept(m, 11, 0x0A, res_axis(0), false, nullptr, nullptr, nullptr, nullptr); + accept(m, 12, 0x0B, res_axis(1), false, nullptr, nullptr, nullptr, nullptr); + m.set_progress(11, 55); + m.set_progress(11, 200); // clamps to 100 + m.complete(12, STATUS_OK, ERR_NONE); + + StandardResponse resp; + m.fill_response(resp); + // Slot holding cmd 11 reflects progress 100 and ACTIVE state. + bool found11 = false; + for (size_t i = 0; i < SlotManager::kNumSlots; ++i) { + if (resp.slots[i].cmd_id == 11 && resp.slots[i].state == SLOT_ACTIVE) { + TEST_ASSERT_EQUAL_UINT8(100, resp.slots[i].progress); + found11 = true; + } + } + TEST_ASSERT_TRUE(found11); + TEST_ASSERT_EQUAL_UINT8(1, resp.ring_head_seq); // one completion (cmd 12) + + // The ring section carries cmd 12's outcome. + bool found12 = false; + for (size_t i = 0; i < SlotManager::kRingSize; ++i) { + if (resp.ring[i].cmd_id == 12) { + TEST_ASSERT_EQUAL_UINT8(STATUS_OK, resp.ring[i].final_status); + found12 = true; + } + } + TEST_ASSERT_TRUE(found12); +} + +void test_reset_clears_everything(void) { + SlotManager m; + accept(m, 1, 0x01, res_axis(0), false, nullptr, nullptr, nullptr, nullptr); + m.complete(1, STATUS_OK, ERR_NONE); + accept(m, 2, 0x01, res_axis(1), false, nullptr, nullptr, nullptr, nullptr); + + m.reset(); + TEST_ASSERT_NULL(m.find(2)); + TEST_ASSERT_EQUAL_HEX32(0, m.inflight_claims_union()); + TEST_ASSERT_EQUAL_UINT8(0, m.ring_head_seq()); + uint8_t st = 0, er = 0; + TEST_ASSERT_FALSE(m.ring_lookup(1, &st, &er)); // ring cleared too +} + +int main(int, char**) { + UNITY_BEGIN(); + RUN_TEST(test_accept_five_slots_then_no_slots); + RUN_TEST(test_conflict_reports_resource_and_holder); + RUN_TEST(test_complete_frees_slot_and_records_ring); + RUN_TEST(test_ring_wraps_keeping_newest); + RUN_TEST(test_find_returns_active_slot); + RUN_TEST(test_retry_of_active_is_active_duplicate); + RUN_TEST(test_retry_of_completed_replays_ring_without_reexec); + RUN_TEST(test_retry_of_unknown_is_treated_as_new); + RUN_TEST(test_non_retry_reuse_of_completed_id_is_new); + RUN_TEST(test_set_progress_and_fill_response); + RUN_TEST(test_reset_clears_everything); + return UNITY_END(); +} diff --git a/software/control/protocol_v2/__init__.py b/software/control/protocol_v2/__init__.py new file mode 100644 index 000000000..d7d489f00 --- /dev/null +++ b/software/control/protocol_v2/__init__.py @@ -0,0 +1,27 @@ +"""Protocol v2 host-side codec and client. + +Mirrors firmware/controller/src/protocol/ (crc16, cobs, frames) byte-for-byte; +cross-language agreement is enforced by the golden vectors and the frames.h +parser test in software/tests/control/test_protocol_v2.py. +""" + +from . import cobs, crc16, frames +from .client import Client, Response, Timeout, Transport +from .cobs import cobs_decode, cobs_encode +from .crc16 import crc16_ccitt +from .frames import decode_frame, encode_frame + +__all__ = [ + "cobs", + "crc16", + "frames", + "Client", + "Response", + "Timeout", + "Transport", + "cobs_decode", + "cobs_encode", + "crc16_ccitt", + "decode_frame", + "encode_frame", +] diff --git a/software/control/protocol_v2/client.py b/software/control/protocol_v2/client.py new file mode 100644 index 000000000..0685ee54a --- /dev/null +++ b/software/control/protocol_v2/client.py @@ -0,0 +1,85 @@ +"""Protocol v2 host client. + +Client.request() frames a REQUEST, writes it to a Transport, reads the matching +RESPONSE, and returns it. Phase B tests drive this over an in-memory transport; +a live pyserial/simulator loopback lands with Phase C. +""" + +from . import frames + + +class Timeout(Exception): + """No matching response arrived within the deadline.""" + + +class Transport: + """Byte transport for framed protocol-v2 traffic.""" + + def write(self, data: bytes) -> None: + raise NotImplementedError + + def read_frame(self, timeout: float) -> bytes: + """Return one wire frame (COBS bytes + 0x00), or raise Timeout.""" + raise NotImplementedError + + +class Response: + def __init__(self, ftype: int, cmd_id: int, cmd_type: int, flags: int, payload: bytes): + self.type = ftype + self.cmd_id = cmd_id + self.cmd_type = cmd_type + self.flags = flags + self.payload = payload + + @property + def status(self): + return self.payload[0] if len(self.payload) >= 1 else None + + @property + def error_code(self): + return self.payload[1] if len(self.payload) >= 2 else None + + def __repr__(self): + return ( + f"Response(cmd_id={self.cmd_id}, cmd_type=0x{self.cmd_type:02X}, " + f"status={self.status}, error_code={self.error_code}, len={len(self.payload)})" + ) + + +class Client: + def __init__(self, transport: Transport): + self._transport = transport + self._next_id = 1 + + def _alloc_cmd_id(self) -> int: + cmd_id = self._next_id + self._next_id += 1 + if self._next_id > 255: + self._next_id = 1 # cmd_id 0 reserved as "unassigned" + return cmd_id + + def request( + self, + cmd_type: int, + payload: bytes = b"", + retry: bool = False, + timeout: float = 1.0, + cmd_id: int = None, + ) -> Response: + """Send a REQUEST and return the correlated RESPONSE. + + Raises Timeout if no response with the request's cmd_id arrives before + ``timeout``; skips any frame whose cmd_id does not match. + """ + if cmd_id is None: + cmd_id = self._alloc_cmd_id() + flags = frames.FLAG_RETRY if retry else 0 + wire = frames.encode_frame(frames.REQUEST, cmd_id, cmd_type, flags, payload) + self._transport.write(wire) + + while True: + resp_wire = self._transport.read_frame(timeout) # raises Timeout + rtype, rid, rct, rflags, rpayload = frames.decode_frame(resp_wire) + if rid == cmd_id: + return Response(rtype, rid, rct, rflags, rpayload) + # Not ours (a stale/other response); keep waiting. diff --git a/software/control/protocol_v2/cobs.py b/software/control/protocol_v2/cobs.py new file mode 100644 index 000000000..f334b9cf1 --- /dev/null +++ b/software/control/protocol_v2/cobs.py @@ -0,0 +1,52 @@ +"""COBS codec — Python mirror of firmware/controller/src/protocol/cobs. + +Standard Consistent Overhead Byte Stuffing (Cheshire & Baker). Encoded output +never contains 0x00, so 0x00 can serve as an unambiguous frame delimiter. +""" + + +def cobs_encode(data: bytes) -> bytes: + """Encode ``data``; the result contains no 0x00 byte.""" + out = bytearray() + code_idx = len(out) + out.append(0) # placeholder for the running code byte + code = 1 + for b in data: + if b == 0: + out[code_idx] = code + code_idx = len(out) + out.append(0) + code = 1 + else: + out.append(b) + code += 1 + if code == 0xFF: # block full (254 data bytes) + out[code_idx] = code + code_idx = len(out) + out.append(0) + code = 1 + out[code_idx] = code + return bytes(out) + + +def cobs_decode(data: bytes) -> bytes: + """Decode COBS ``data``; raise ValueError on malformed input.""" + out = bytearray() + i = 0 + n = len(data) + while i < n: + code = data[i] + if code == 0: + raise ValueError("embedded 0x00 in COBS stream") + i += 1 + for _ in range(1, code): + if i >= n: + raise ValueError("truncated COBS block") + b = data[i] + i += 1 + if b == 0: + raise ValueError("embedded 0x00 inside COBS block") + out.append(b) + if code != 0xFF and i < n: + out.append(0) + return bytes(out) diff --git a/software/control/protocol_v2/crc16.py b/software/control/protocol_v2/crc16.py new file mode 100644 index 000000000..0db6ef108 --- /dev/null +++ b/software/control/protocol_v2/crc16.py @@ -0,0 +1,27 @@ +"""CRC-16/CCITT-FALSE — Python mirror of firmware/controller/src/protocol/crc16. + +Polynomial 0x1021, initial value 0xFFFF, no reflection, no final XOR. The table +is computed at import and is byte-identical to the C lookup table. +""" + + +def _build_table() -> list: + table = [] + for i in range(256): + crc = i << 8 + for _ in range(8): + crc = ((crc << 1) ^ 0x1021) if (crc & 0x8000) else (crc << 1) + crc &= 0xFFFF + table.append(crc) + return table + + +TABLE = _build_table() + + +def crc16_ccitt(data: bytes) -> int: + """CRC-16/CCITT-FALSE over ``data`` (0xFFFF for an empty buffer).""" + crc = 0xFFFF + for b in data: + crc = ((crc << 8) ^ TABLE[((crc >> 8) ^ b) & 0xFF]) & 0xFFFF + return crc diff --git a/software/control/protocol_v2/frames.py b/software/control/protocol_v2/frames.py new file mode 100644 index 000000000..e4d671b9e --- /dev/null +++ b/software/control/protocol_v2/frames.py @@ -0,0 +1,121 @@ +"""Protocol v2 wire contract — Python mirror of +firmware/controller/src/protocol/frames.h. + +frames.h is the single source of truth; test_protocol_v2.py parses it and +asserts every constant here matches. All multi-byte fields are little-endian +and packed (struct '<' format = standard sizes, no alignment padding). +""" + +from . import cobs, crc16 + +# --- Sizing constants ----------------------------------------------------- +K_MAX_FRAME = 512 +K_MAX_PAYLOAD = 506 +K_PROTOCOL_VERSION = 2 + +# --- FrameType ------------------------------------------------------------ +REQUEST = 0x01 +RESPONSE = 0x02 +EVENT = 0x03 + +# --- FrameFlags ----------------------------------------------------------- +FLAG_RETRY = 0x01 + +# --- ResponseStatus ------------------------------------------------------- +STATUS_OK = 0 +STATUS_ACCEPTED = 1 +STATUS_REJECTED = 2 +STATUS_FAILED = 3 + +# --- CommandType (system block) ------------------------------------------- +HELLO = 0xF0 +GET_INFO = 0xF1 +GET_STATE = 0xF2 +DIAG = 0xF3 +ACK_ERROR = 0xF4 +SET_WATCHDOG = 0xF5 +HEARTBEAT = 0xF6 +REBOOT_TO_BOOTLOADER = 0xFD +INITIALIZE = 0xFE +RESET = 0xFF + +# --- ErrorCode ------------------------------------------------------------ +ERR_NONE = 0x00 +ERR_UNKNOWN_COMMAND = 0x10 +ERR_INVALID_PARAMETER = 0x11 +ERR_BAD_LENGTH = 0x12 +ERR_RESOURCE_BUSY = 0x15 +ERR_NO_SLOTS = 0x16 +ERR_SYSTEM_IN_ERROR = 0x17 +ERR_PACKET_CRC = 0x60 +ERR_PACKET_LENGTH = 0x61 + +# --- Resource bits (u64 claim mask: axes 0..15, DACs 16..31, named 32+) ---- +RES_ILLUM_TTL = 1 << 32 +RES_LED_MATRIX = 1 << 33 +RES_CAM_TRIGGERS = 1 << 34 +RES_GPIO = 1 << 35 +RES_SEQUENCER = 1 << 36 +RES_SYS_CONFIG = 1 << 37 + + +def res_axis(n: int) -> int: + return 1 << n + + +def res_dac(n: int) -> int: + return 1 << (16 + n) + + +# --- Packed struct formats (little-endian, no padding) -------------------- +FRAME_HEADER = " bytes: + """Build one wire frame: COBS(header + payload + CRC-16 LE) + 0x00.""" + frame = bytes([ftype, cmd_id, cmd_type, flags]) + bytes(payload) + crc = crc16.crc16_ccitt(frame) + frame_crc = frame + bytes([crc & 0xFF, (crc >> 8) & 0xFF]) + return cobs.cobs_encode(frame_crc) + b"\x00" + + +def decode_frame(wire: bytes): + """Parse one wire frame; return (type, cmd_id, cmd_type, flags, payload). + + Raises ValueError on malformed COBS, a runt frame, or a CRC mismatch. + """ + if wire.endswith(b"\x00"): + wire = wire[:-1] + frame_crc = cobs.cobs_decode(wire) + if len(frame_crc) < 6: # header(4) + crc(2) + raise ValueError("frame too short") + frame = frame_crc[:-2] + rx_crc = frame_crc[-2] | (frame_crc[-1] << 8) + if rx_crc != crc16.crc16_ccitt(frame): + raise ValueError("CRC mismatch") + return frame[0], frame[1], frame[2], frame[3], bytes(frame[4:]) diff --git a/software/tests/control/test_protocol_v2.py b/software/tests/control/test_protocol_v2.py new file mode 100644 index 000000000..45ccdb600 --- /dev/null +++ b/software/tests/control/test_protocol_v2.py @@ -0,0 +1,268 @@ +"""Tests for the protocol-v2 Python codec/client and its agreement with the C +firmware wire contract. + +Covers: +- crc16 / cobs mirror the firmware vectors (B1/B2), +- frames.py struct sizes match the wire contract, +- every frames.py constant equals the value in frames.h (regex parser, the + FirmwareSimSerial pattern), and +- the C<->Python golden vectors round-trip byte-identically. +""" + +import json +import re +import struct +from pathlib import Path + +import pytest + +from control.protocol_v2 import Client, Timeout, Transport, cobs, crc16, frames + + +def repo_root() -> Path: + # software/tests/control/ -> repo root + return Path(__file__).resolve().parent.parent.parent.parent + + +def frames_header_path() -> Path: + return repo_root() / "firmware" / "controller" / "src" / "protocol" / "frames.h" + + +def golden_path() -> Path: + return Path(__file__).resolve().parent.parent / "data" / "protocol_v2_golden.json" + + +# --- crc16 mirrors the C module ------------------------------------------- + + +def test_crc16_check_value(): + assert crc16.crc16_ccitt(b"") == 0xFFFF + assert crc16.crc16_ccitt(b"123456789") == 0x29B1 # CCITT-FALSE check value + + +def test_crc16_table_spot_checks(): + assert crc16.TABLE[0] == 0x0000 + assert crc16.TABLE[1] == 0x1021 + assert crc16.TABLE[255] == 0x1EF0 + + +# --- cobs mirrors the C module -------------------------------------------- + + +@pytest.mark.parametrize("length", [0, 1, 2, 253, 254, 255, 506]) +def test_cobs_roundtrip(length): + data = bytearray(((i % 255) + 1) for i in range(length)) + # Place zeros at head / tail / middle to exercise COBS boundaries. + if length >= 1: + data[0] = 0 + if length >= 2: + data[-1] = 0 + if length >= 3: + data[length // 2] = 0 + data = bytes(data) + enc = cobs.cobs_encode(data) + assert 0 not in enc # no delimiter byte inside the encoded stream + assert cobs.cobs_decode(enc) == data + + +def test_cobs_decode_rejects_embedded_zero(): + with pytest.raises(ValueError): + cobs.cobs_decode(b"\x03\x41\x00") + + +def test_cobs_decode_rejects_truncated(): + with pytest.raises(ValueError): + cobs.cobs_decode(b"\x05\x41\x42") + + +# --- frames.py struct sizes match the wire contract ----------------------- + + +def test_struct_sizes(): + assert struct.calcsize(frames.FRAME_HEADER) == 4 + assert struct.calcsize(frames.SLOT) == 4 + assert struct.calcsize(frames.RING_ENTRY) == 4 + assert struct.calcsize(frames.AXIS_STATE) == 8 + assert struct.calcsize(frames.SEQ_PROGRESS) == 12 + assert struct.calcsize(frames.STANDARD_RESPONSE) == 158 + assert struct.calcsize(frames.HELLO_PAYLOAD) == 16 + assert struct.calcsize(frames.INFO_PAYLOAD) == 22 + assert struct.calcsize(frames.DIAG_PAYLOAD) == 40 + assert struct.calcsize(frames.FAULT_ENTRY) == 8 + + +# --- frames.py constants equal frames.h (the single source of truth) ------ + + +def parse_frames_header(path: Path): + text = path.read_text() + consts = {} + # Enum members / simple constants: NAME = 0xHH or NAME = decimal (uppercase). + for m in re.finditer(r"\b([A-Z][A-Z0-9_]+)\s*=\s*(0x[0-9A-Fa-f]+|\d+)\s*[,;]", text): + consts[m.group(1)] = int(m.group(2), 0) + # kCamelCase sizing constants: static const ... kMaxFrame = 512; + for m in re.finditer(r"\b(k[A-Za-z0-9_]+)\s*=\s*(0x[0-9A-Fa-f]+|\d+)\s*;", text): + consts[m.group(1)] = int(m.group(2), 0) + # Resource bits: RES_X = uint64_t(1) << N; (u32 pre-2026-07-11) + for m in re.finditer(r"\b(RES_[A-Z0-9_]+)\s*=\s*uint(?:32|64)_t\(1\)\s*<<\s*(\d+)", text): + consts[m.group(1)] = 1 << int(m.group(2)) + # Layout sizes from static_assert(sizeof(TYPE) == N, ...). + sizes = {} + for m in re.finditer(r"sizeof\((\w+)\)\s*==\s*(\d+)", text): + sizes[m.group(1)] = int(m.group(2)) + return consts, sizes + + +# Constant names shared verbatim between frames.h and frames.py. +_SAME_NAME_CONSTS = [ + "REQUEST", + "RESPONSE", + "EVENT", + "FLAG_RETRY", + "STATUS_OK", + "STATUS_ACCEPTED", + "STATUS_REJECTED", + "STATUS_FAILED", + "HELLO", + "GET_INFO", + "GET_STATE", + "DIAG", + "ACK_ERROR", + "SET_WATCHDOG", + "HEARTBEAT", + "REBOOT_TO_BOOTLOADER", + "INITIALIZE", + "RESET", + "ERR_NONE", + "ERR_UNKNOWN_COMMAND", + "ERR_INVALID_PARAMETER", + "ERR_BAD_LENGTH", + "ERR_RESOURCE_BUSY", + "ERR_NO_SLOTS", + "ERR_SYSTEM_IN_ERROR", + "ERR_PACKET_CRC", + "ERR_PACKET_LENGTH", + "RES_ILLUM_TTL", + "RES_LED_MATRIX", + "RES_CAM_TRIGGERS", + "RES_GPIO", + "RES_SEQUENCER", + "RES_SYS_CONFIG", +] + + +def test_frames_py_constants_match_header(): + header = frames_header_path() + if not header.exists(): + pytest.skip(f"frames.h not found: {header}") + consts, sizes = parse_frames_header(header) + + for name in _SAME_NAME_CONSTS: + assert name in consts, f"{name} not found in frames.h" + assert consts[name] == getattr(frames, name), f"{name} mismatch" + + # Renamed sizing constants (C kCamelCase -> Python UPPER_SNAKE). + assert consts["kMaxFrame"] == frames.K_MAX_FRAME + assert consts["kMaxPayload"] == frames.K_MAX_PAYLOAD + assert consts["kProtocolVersion"] == frames.K_PROTOCOL_VERSION + + # Layout sizes agree with the header static_asserts. + assert sizes["StandardResponse"] == struct.calcsize(frames.STANDARD_RESPONSE) + assert sizes["HelloPayload"] == struct.calcsize(frames.HELLO_PAYLOAD) + assert sizes["InfoPayload"] == struct.calcsize(frames.INFO_PAYLOAD) + assert sizes["DiagPayload"] == struct.calcsize(frames.DIAG_PAYLOAD) + assert sizes["FaultEntryWire"] == struct.calcsize(frames.FAULT_ENTRY) + + +def test_resource_bit_helpers(): + assert frames.res_axis(0) == 1 + assert frames.res_axis(15) == (1 << 15) + assert frames.res_dac(0) == (1 << 16) + assert frames.res_dac(15) == (1 << 31) + assert frames.RES_ILLUM_TTL == (1 << 32) + + +# --- C<->Python golden vectors round-trip byte-identically ---------------- + + +def load_golden(): + path = golden_path() + if not path.exists(): + pytest.skip(f"golden vectors not found: {path}") + return json.loads(path.read_text()) + + +def test_golden_vectors_encode_and_decode(): + cases = load_golden() + assert len(cases) > 0 + for c in cases: + payload = bytes.fromhex(c["payload"]) + wire = bytes.fromhex(c["wire"]) + decoded = bytes.fromhex(c["decoded"]) + + built = frames.encode_frame(c["type"], c["cmd_id"], c["cmd_type"], c["flags"], payload) + assert built == wire, f"{c['name']}: encode mismatch" + + ftype, cmd_id, cmd_type, flags, pl = frames.decode_frame(wire) + assert (ftype, cmd_id, cmd_type, flags) == (c["type"], c["cmd_id"], c["cmd_type"], c["flags"]) + assert pl == payload, f"{c['name']}: decoded payload mismatch" + + # The COBS body (frame + CRC) matches the recorded decoded bytes. + assert cobs.cobs_decode(wire[:-1]) == decoded, f"{c['name']}: decoded bytes mismatch" + + +# --- Client request/response ---------------------------------------------- + + +class LoopTransport(Transport): + def __init__(self): + self.written = [] + self.responses = [] + + def write(self, data): + self.written.append(data) + + def read_frame(self, timeout): + if not self.responses: + raise Timeout("no response queued") + return self.responses.pop(0) + + +def test_client_request_matches_response(): + t = LoopTransport() + t.responses.append(frames.encode_frame(frames.RESPONSE, 1, frames.GET_STATE, 0, b"\x00\x00\x00\x00")) + c = Client(t) + resp = c.request(frames.GET_STATE, b"", cmd_id=1) + + sent = frames.decode_frame(t.written[0]) + assert sent[:4] == (frames.REQUEST, 1, frames.GET_STATE, 0) + assert resp.cmd_id == 1 + assert resp.cmd_type == frames.GET_STATE + assert resp.status == frames.STATUS_OK + + +def test_client_retry_sets_flag(): + t = LoopTransport() + t.responses.append(frames.encode_frame(frames.RESPONSE, 2, frames.DIAG, 0, b"\x00\x00\x00\x00")) + c = Client(t) + c.request(frames.DIAG, b"\x00", retry=True, cmd_id=2) + + sent = frames.decode_frame(t.written[0]) + assert sent[3] & frames.FLAG_RETRY + + +def test_client_skips_unmatched_then_matches(): + t = LoopTransport() + # A stale response (wrong cmd_id) precedes the real one. + t.responses.append(frames.encode_frame(frames.RESPONSE, 99, frames.GET_STATE, 0, b"\x00\x00\x00\x00")) + t.responses.append(frames.encode_frame(frames.RESPONSE, 3, frames.GET_STATE, 0, b"\x00\x00\x00\x00")) + c = Client(t) + resp = c.request(frames.GET_STATE, cmd_id=3) + assert resp.cmd_id == 3 + + +def test_client_timeout(): + t = LoopTransport() # nothing queued + c = Client(t) + with pytest.raises(Timeout): + c.request(frames.GET_STATE, cmd_id=4) diff --git a/software/tests/data/protocol_v2_golden.json b/software/tests/data/protocol_v2_golden.json new file mode 100644 index 000000000..a4b31813e --- /dev/null +++ b/software/tests/data/protocol_v2_golden.json @@ -0,0 +1,82 @@ +[ + { + "name": "hello_request", + "type": 1, + "cmd_id": 1, + "cmd_type": 240, + "flags": 0, + "payload": "", + "decoded": "0101f00085d6", + "wire": "040101f00385d600" + }, + { + "name": "get_info_request", + "type": 1, + "cmd_id": 2, + "cmd_type": 241, + "flags": 0, + "payload": "", + "decoded": "0102f100e4bc", + "wire": "040102f103e4bc00" + }, + { + "name": "get_state_request", + "type": 1, + "cmd_id": 3, + "cmd_type": 242, + "flags": 0, + "payload": "", + "decoded": "0103f20087de", + "wire": "040103f20387de00" + }, + { + "name": "diag_request_page0", + "type": 1, + "cmd_id": 4, + "cmd_type": 243, + "flags": 0, + "payload": "00", + "decoded": "0104f30000aecb", + "wire": "040104f30103aecb00" + }, + { + "name": "diag_request_retry", + "type": 1, + "cmd_id": 5, + "cmd_type": 243, + "flags": 1, + "payload": "01", + "decoded": "0105f301010a9e", + "wire": "080105f301010a9e00" + }, + { + "name": "std_response_with_zeros", + "type": 2, + "cmd_id": 6, + "cmd_type": 242, + "flags": 0, + "payload": "03284d7297bce1062b50759abfe4092e53789dc2e70c31567ba0c5ea0f34597ea3c8ed12375c81a6cbf0153a5f84a9cef3183d6287acd1f61b40658aafd4f91e43688db2d7fc21466b90b5daff24496e93b8dd02274c7196bbe0052a4f7499bee3082d52779cc1e60b30557a9fc4e90e33587da2c7ec11365b80a5caef14395e83a8cdf2173c6186abd0f51a3f6489aed3f81d42678cb1d6fb20456a8fb4", + "decoded": "0206f20003284d7297bce1062b50759abfe4092e53789dc2e70c31567ba0c5ea0f34597ea3c8ed12375c81a6cbf0153a5f84a9cef3183d6287acd1f61b40658aafd4f91e43688db2d7fc21466b90b5daff24496e93b8dd02274c7196bbe0052a4f7499bee3082d52779cc1e60b30557a9fc4e90e33587da2c7ec11365b80a5caef14395e83a8cdf2173c6186abd0f51a3f6489aed3f81d42678cb1d6fb20456a8fb42ccd", + "wire": "040206f2a103284d7297bce1062b50759abfe4092e53789dc2e70c31567ba0c5ea0f34597ea3c8ed12375c81a6cbf0153a5f84a9cef3183d6287acd1f61b40658aafd4f91e43688db2d7fc21466b90b5daff24496e93b8dd02274c7196bbe0052a4f7499bee3082d52779cc1e60b30557a9fc4e90e33587da2c7ec11365b80a5caef14395e83a8cdf2173c6186abd0f51a3f6489aed3f81d42678cb1d6fb20456a8fb42ccd00" + }, + { + "name": "max_payload_response", + "type": 2, + "cmd_id": 7, + "cmd_type": 243, + "flags": 0, + "payload": "072c51769bc0e50a2f54799ec3e80d32577ca1c6eb10355a7fa4c9ee13385d82a7ccf1163b6085aacff4193e6388add2f71c41668bb0d5fa1f44698eb3d8fd22476c91b6db00254a6f94b9de03284d7297bce1062b50759abfe4092e53789dc2e70c31567ba0c5ea0f34597ea3c8ed12375c81a6cbf0153a5f84a9cef3183d6287acd1f61b40658aafd4f91e43688db2d7fc21466b90b5daff24496e93b8dd02274c7196bbe0052a4f7499bee3082d52779cc1e60b30557a9fc4e90e33587da2c7ec11365b80a5caef14395e83a8cdf2173c6186abd0f51a3f6489aed3f81d42678cb1d6fb20456a8fb4d9fe23486d92b7dc01264b7095badf04294e7398bde2072c51769bc0e50a2f54799ec3e80d32577ca1c6eb10355a7fa4c9ee13385d82a7ccf1163b6085aacff4193e6388add2f71c41668bb0d5fa1f44698eb3d8fd22476c91b6db00254a6f94b9de03284d7297bce1062b50759abfe4092e53789dc2e70c31567ba0c5ea0f34597ea3c8ed12375c81a6cbf0153a5f84a9cef3183d6287acd1f61b40658aafd4f91e43688db2d7fc21466b90b5daff24496e93b8dd02274c7196bbe0052a4f7499bee3082d52779cc1e60b30557a9fc4e90e33587da2c7ec11365b80a5caef14395e83a8cdf2173c6186abd0f51a3f6489aed3f81d42678cb1d6fb20456a8fb4d9fe23486d92b7dc01264b7095badf04", + "decoded": "0207f300072c51769bc0e50a2f54799ec3e80d32577ca1c6eb10355a7fa4c9ee13385d82a7ccf1163b6085aacff4193e6388add2f71c41668bb0d5fa1f44698eb3d8fd22476c91b6db00254a6f94b9de03284d7297bce1062b50759abfe4092e53789dc2e70c31567ba0c5ea0f34597ea3c8ed12375c81a6cbf0153a5f84a9cef3183d6287acd1f61b40658aafd4f91e43688db2d7fc21466b90b5daff24496e93b8dd02274c7196bbe0052a4f7499bee3082d52779cc1e60b30557a9fc4e90e33587da2c7ec11365b80a5caef14395e83a8cdf2173c6186abd0f51a3f6489aed3f81d42678cb1d6fb20456a8fb4d9fe23486d92b7dc01264b7095badf04294e7398bde2072c51769bc0e50a2f54799ec3e80d32577ca1c6eb10355a7fa4c9ee13385d82a7ccf1163b6085aacff4193e6388add2f71c41668bb0d5fa1f44698eb3d8fd22476c91b6db00254a6f94b9de03284d7297bce1062b50759abfe4092e53789dc2e70c31567ba0c5ea0f34597ea3c8ed12375c81a6cbf0153a5f84a9cef3183d6287acd1f61b40658aafd4f91e43688db2d7fc21466b90b5daff24496e93b8dd02274c7196bbe0052a4f7499bee3082d52779cc1e60b30557a9fc4e90e33587da2c7ec11365b80a5caef14395e83a8cdf2173c6186abd0f51a3f6489aed3f81d42678cb1d6fb20456a8fb4d9fe23486d92b7dc01264b7095badf04f471", + "wire": "040207f346072c51769bc0e50a2f54799ec3e80d32577ca1c6eb10355a7fa4c9ee13385d82a7ccf1163b6085aacff4193e6388add2f71c41668bb0d5fa1f44698eb3d8fd22476c91b6dbff254a6f94b9de03284d7297bce1062b50759abfe4092e53789dc2e70c31567ba0c5ea0f34597ea3c8ed12375c81a6cbf0153a5f84a9cef3183d6287acd1f61b40658aafd4f91e43688db2d7fc21466b90b5daff24496e93b8dd02274c7196bbe0052a4f7499bee3082d52779cc1e60b30557a9fc4e90e33587da2c7ec11365b80a5caef14395e83a8cdf2173c6186abd0f51a3f6489aed3f81d42678cb1d6fb20456a8fb4d9fe23486d92b7dc01264b7095badf04294e7398bde2072c51769bc0e50a2f54799ec3e80d32577ca1c6eb10355a7fa4c9ee13385d82a7ccf1163b6085aacff4193e6388add2f71c41668bb0d5fa1f44698eb3d8fd22476c91b602dbb7254a6f94b9de03284d7297bce1062b50759abfe4092e53789dc2e70c31567ba0c5ea0f34597ea3c8ed12375c81a6cbf0153a5f84a9cef3183d6287acd1f61b40658aafd4f91e43688db2d7fc21466b90b5daff24496e93b8dd02274c7196bbe0052a4f7499bee3082d52779cc1e60b30557a9fc4e90e33587da2c7ec11365b80a5caef14395e83a8cdf2173c6186abd0f51a3f6489aed3f81d42678cb1d6fb20456a8fb4d9fe23486d92b7dc01264b7095badf04f47100" + }, + { + "name": "all_zeros_payload", + "type": 2, + "cmd_id": 8, + "cmd_type": 242, + "flags": 0, + "payload": "0000000000000000000000000000000000000000000000000000000000000000", + "decoded": "0208f2000000000000000000000000000000000000000000000000000000000000000000d3e1", + "wire": "040208f2010101010101010101010101010101010101010101010101010101010101010103d3e100" + } +] diff --git a/software/tools/gen_protocol_golden.py b/software/tools/gen_protocol_golden.py new file mode 100644 index 000000000..e398d1f93 --- /dev/null +++ b/software/tools/gen_protocol_golden.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Generate the protocol-v2 cross-language golden vectors. + +Emits two artifacts from ONE definition of representative frames, using the +Python codec (control.protocol_v2) as the reference: + + * software/tests/data/protocol_v2_golden.json — consumed by the pytest suite + * firmware/controller/test/test_golden/golden_cases.h — embedded by the C test + +Both C and Python must reproduce the recorded wire bytes byte-for-byte. CI runs +this script and `git diff --exit-code`s the outputs, so a codec change on either +side that drifts from the vectors fails the build. + +Deterministic: no timestamps or randomness, so re-running yields identical files. +""" + +import json +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent +SOFTWARE_DIR = REPO_ROOT / "software" +sys.path.insert(0, str(SOFTWARE_DIR)) + +from control.protocol_v2 import frames # noqa: E402 + +JSON_OUT = SOFTWARE_DIR / "tests" / "data" / "protocol_v2_golden.json" +HEADER_OUT = REPO_ROOT / "firmware" / "controller" / "test" / "test_golden" / "golden_cases.h" + + +def _pattern(n: int, seed: int) -> bytes: + """Deterministic byte pattern that includes 0x00 bytes (exercises COBS).""" + return bytes((i * 37 + seed) % 256 for i in range(n)) + + +def golden_cases(): + """Representative frames: each system command + responses of varied size.""" + return [ + ("hello_request", frames.REQUEST, 1, frames.HELLO, 0, b""), + ("get_info_request", frames.REQUEST, 2, frames.GET_INFO, 0, b""), + ("get_state_request", frames.REQUEST, 3, frames.GET_STATE, 0, b""), + ("diag_request_page0", frames.REQUEST, 4, frames.DIAG, 0, b"\x00"), + ("diag_request_retry", frames.REQUEST, 5, frames.DIAG, frames.FLAG_RETRY, b"\x01"), + # StandardResponse-sized payload with embedded zeros. + ("std_response_with_zeros", frames.RESPONSE, 6, frames.GET_STATE, 0, _pattern(158, 3)), + # Max payload -> multi-block COBS. + ("max_payload_response", frames.RESPONSE, 7, frames.DIAG, 0, _pattern(frames.K_MAX_PAYLOAD, 7)), + # All-zeros payload -> COBS worst case for zero runs. + ("all_zeros_payload", frames.RESPONSE, 8, frames.GET_STATE, 0, b"\x00" * 32), + ] + + +def build_records(): + records = [] + for name, ftype, cmd_id, cmd_type, flags, payload in golden_cases(): + frame = bytes([ftype, cmd_id, cmd_type, flags]) + payload + crc = frames.crc16.crc16_ccitt(frame) + decoded = frame + bytes([crc & 0xFF, (crc >> 8) & 0xFF]) + wire = frames.encode_frame(ftype, cmd_id, cmd_type, flags, payload) + records.append( + { + "name": name, + "type": ftype, + "cmd_id": cmd_id, + "cmd_type": cmd_type, + "flags": flags, + "payload": payload.hex(), + "decoded": decoded.hex(), + "wire": wire.hex(), + } + ) + return records + + +def write_json(records): + JSON_OUT.parent.mkdir(parents=True, exist_ok=True) + with open(JSON_OUT, "w") as f: + json.dump(records, f, indent=2) + f.write("\n") + + +def _c_byte_array(name, data): + if not data: + return f"static const uint8_t {name}[] = {{0}}; // empty (len 0)\n" + lines = [f"static const uint8_t {name}[] = {{"] + for i in range(0, len(data), 12): + chunk = ", ".join(f"0x{b:02X}" for b in data[i : i + 12]) + lines.append(f" {chunk},") + lines.append("};") + return "\n".join(lines) + "\n" + + +def write_header(records): + HEADER_OUT.parent.mkdir(parents=True, exist_ok=True) + out = [] + out.append("// Generated by software/tools/gen_protocol_golden.py — DO NOT EDIT.") + out.append("// Cross-language golden vectors; CI regenerates and git-diffs this file.") + out.append("#ifndef GOLDEN_CASES_H") + out.append("#define GOLDEN_CASES_H") + out.append("") + out.append("#include ") + out.append("#include ") + out.append("") + out.append("struct GoldenCase {") + out.append(" const char* name;") + out.append(" uint8_t type;") + out.append(" uint8_t cmd_id;") + out.append(" uint8_t cmd_type;") + out.append(" uint8_t flags;") + out.append(" const uint8_t* payload;") + out.append(" size_t payload_len;") + out.append(" const uint8_t* wire;") + out.append(" size_t wire_len;") + out.append("};") + out.append("") + + for i, r in enumerate(records): + payload = bytes.fromhex(r["payload"]) + wire = bytes.fromhex(r["wire"]) + out.append(_c_byte_array(f"kGoldenPayload{i}", payload)) + out.append(_c_byte_array(f"kGoldenWire{i}", wire)) + + out.append("static const GoldenCase kGoldenCases[] = {") + for i, r in enumerate(records): + payload_len = len(bytes.fromhex(r["payload"])) + wire_len = len(bytes.fromhex(r["wire"])) + out.append( + f' {{"{r["name"]}", 0x{r["type"]:02X}, {r["cmd_id"]}, 0x{r["cmd_type"]:02X}, ' + f"0x{r['flags']:02X}, kGoldenPayload{i}, {payload_len}, kGoldenWire{i}, {wire_len}}}," + ) + out.append("};") + out.append("") + out.append("static const size_t kNumGoldenCases = sizeof(kGoldenCases) / sizeof(kGoldenCases[0]);") + out.append("") + out.append("#endif // GOLDEN_CASES_H") + + with open(HEADER_OUT, "w") as f: + f.write("\n".join(out) + "\n") + + +def main(): + records = build_records() + write_json(records) + write_header(records) + print(f"Wrote {len(records)} golden cases:") + print(f" {JSON_OUT}") + print(f" {HEADER_OUT}") + + +if __name__ == "__main__": + main()