spaxel/firmware/test/test_csi_frame.c
jedarden 5e588592f4 test: add firmware host tests for nvs/csi/serial_prov + wire gcc harness into CI
Adds the three firmware host-test modules required by the Testing Strategy as a
plain gcc harness under firmware/test/ — NOT idf.py --target linux. That path was
rejected (docs/notes/firmware-host-test-approach.md, bf-21t): firmware/main
cannot host-link because csi.c pulls in esp_wifi.h and provision.c pulls in
driver/uart.h, and the single `main` component REQUIRES esp_wifi/bt/driver,
which have no linux build — so even nvs_migration.c (hostable in isolation) is
unhostable as part of the component. The harness therefore tests dependency-free
logic extractions and binary-format/wire contracts instead of linking the
firmware source.

- test_nvs_migration.c: fresh-install init to v1, no-downgrade guard, forward
  migration loop dispatch (v→v+1 at index v−1), and the concrete v1→v2 step
  (rename ms_ip→mothership_ip, default ntp_server), driven against an in-memory
  NVS store. Mirrors nvs_migration.c decision-for-decision.
- test_csi_frame.c: 24-byte header field round-trip, explicit little-endian
  timestamp byte order, signed-RSSI (uint8_t) reinterpretation, I/Q payload
  copy, n_sub=0 header-only probe, and the ingestion-side validation rules
  (too-short / payload-mismatch / n_sub>128 / bad channel). Mirrors the
  websocket.c encoder contract (offset/byte for offset/byte).
- test_serial_prov.c: provisioning JSON parser + NVS-mapping mirror of
  provision.c (all four protocol branches + every field mapping), shipping a
  bounded recursive-descent JSON decoder as the fuzz target. The fuzz pass
  (4000 random byte streams, a tricky-input corpus, 500 deep-nesting cases)
  proves the parser never crashes and the protocol always answers a single
  well-formed {"ok":...} line on any UART input.
- Makefile: gcc build/run recipe that globs every test_*.c + test_runner.c.

CI wiring: the Dockerfile firmware-builder stage now runs `make -C test test`
before the expensive ESP-IDF build, so a logic/format-contract regression fails
the image build fast. .gitignore + .dockerignore exclude the regenerable
host_tests binary.

docs/plan/plan.md Testing Strategy updated from the idf.py description to the
gcc harness (matching the decision record).

28 tests, all passing. go test ./... and go vet ./... unchanged (firmware-only).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 13:21:26 -04:00

330 lines
12 KiB
C

/*
* ============================================================================
* Host test: CSI binary frame serialization roundtrip
* ============================================================================
*
* Covers the plan's Testing-Strategy requirement:
* `csi` — Binary frame serialization: verify frame header fields and
* little-endian encoding.
*
* This is a gcc host test (see test_runner.h's header comment + the decision
* record docs/notes/firmware-host-test-approach.md, bead bf-21t, for why this
* is plain gcc and NOT ESP-IDF --target linux: firmware/main cannot be
* host-linked because csi.c → esp_wifi.h and provision.c → driver/uart.h, and
* the single `main` component REQUIRES esp_wifi/bt/driver which have no linux
* build). The harness therefore pins the wire-format CONTRACT rather than
* linking the firmware source.
*
* The reference encoder below mirrors — byte for byte, offset for offset — the
* production serializer in firmware/main/websocket.c `websocket_send_csi()`
* (websocket.c lines 236-252):
*
* frame_len = 24 + n_sub*2
* memcpy(frame+0, node_mac, 6)
* memcpy(frame+6, peer_mac, 6)
* memcpy(frame+12, &timestamp_us, 8) // little-endian on Xtensa
* frame[20] = (uint8_t)rssi // int8 reinterpreted
* frame[21] = (uint8_t)noise_floor
* frame[22] = channel
* frame[23] = n_sub
* memcpy(frame+24, iq_data, n_sub*2) // int8 I,Q pairs
*
* The reference decoder mirrors the byte layout the Go ingestion server parses
* (plan §Ingestion "Binary CSI frame validation"). Round-tripping the two
* against each other is the cross-system contract guard: if the firmware ever
* changes the layout, the offset table here documents what every consumer must
* match, and the real end-to-end check lives in the Go spaxel-sim acceptance
* suite.
*
* The ingestion-side validator (csi_validate) reproduces the plan's ordered
* validation rules so we can assert malformed frames are flagged at the right
* stage — connecting the firmware encoder contract to the mothership decoder
* contract, which is the real cross-system value of this test.
* ============================================================================
*/
#include "test_runner.h"
#include <stdint.h>
#include <string.h>
/* ---- Wire-format constants (mirror firmware/main/spaxel.h) ---------------- */
#define CSI_HEADER_SIZE 24u /* SPAXEL_FRAME_HEADER_SIZE */
#define CSI_MAX_SUB 128u /* ingestion safety margin (ESP32-S3 ships 64) */
#define CSI_MIN_FRAME_LEN CSI_HEADER_SIZE
/* Offsets within the 24-byte header. */
#define OFF_NODE_MAC 0
#define OFF_PEER_MAC 6
#define OFF_TIMESTAMP 12
#define OFF_RSSI 20
#define OFF_NOISE 21
#define OFF_CHANNEL 22
#define OFF_N_SUB 23
/* Decoded view of a frame — what the mothership reads back. */
typedef struct {
uint8_t node_mac[6];
uint8_t peer_mac[6];
uint64_t timestamp_us;
int8_t rssi;
int8_t noise_floor;
uint8_t channel;
uint8_t n_sub;
const int8_t *iq; /* points into the frame buffer; valid only while it lives */
} csi_frame_view_t;
/* Ingestion-side validation result (plan §"Binary CSI frame validation"). */
typedef enum {
CSI_OK,
CSI_TOO_SHORT, /* len < 24 (rule 1) */
CSI_PAYLOAD_MISMATCH,/* 24 + n_sub*2 != len (rule 3) */
CSI_N_SUB_TOO_BIG, /* n_sub > 128 (rule 4) */
CSI_BAD_CHANNEL, /* channel == 0 or > 14 (rules 6,7) */
} csi_valid_t;
/*
* Reference encoder — same layout/offsets as websocket.c:websocket_send_csi().
* `out` must point to at least CSI_HEADER_SIZE + n_sub*2 bytes. Returns the
* number of bytes written. n_sub==0 produces a header-only probe (24 bytes),
* which the plan explicitly allows.
*/
static size_t csi_encode(const uint8_t node_mac[6], const uint8_t peer_mac[6],
uint64_t timestamp_us, int8_t rssi, int8_t noise_floor,
uint8_t channel, uint8_t n_sub, const int8_t *iq,
uint8_t *out)
{
size_t frame_len = CSI_HEADER_SIZE + (size_t)n_sub * 2u;
memcpy(out + OFF_NODE_MAC, node_mac, 6);
memcpy(out + OFF_PEER_MAC, peer_mac, 6);
memcpy(out + OFF_TIMESTAMP, &timestamp_us, 8); /* LE on ESP32 + x86-64 gcc */
out[OFF_RSSI] = (uint8_t)rssi;
out[OFF_NOISE] = (uint8_t)noise_floor;
out[OFF_CHANNEL] = channel;
out[OFF_N_SUB] = n_sub;
if (n_sub > 0 && iq != NULL) {
memcpy(out + CSI_HEADER_SIZE, iq, (size_t)n_sub * 2u);
}
return frame_len;
}
/* Reference decoder — reads back the byte layout the Go ingestion server sees. */
static void csi_decode(const uint8_t *frame, size_t len, csi_frame_view_t *v)
{
/* Caller is expected to have validated len >= CSI_HEADER_SIZE first. */
memcpy(v->node_mac, frame + OFF_NODE_MAC, 6);
memcpy(v->peer_mac, frame + OFF_PEER_MAC, 6);
memcpy(&v->timestamp_us, frame + OFF_TIMESTAMP, 8);
v->rssi = (int8_t)frame[OFF_RSSI];
v->noise_floor = (int8_t)frame[OFF_NOISE];
v->channel = frame[OFF_CHANNEL];
v->n_sub = frame[OFF_N_SUB];
v->iq = (len > CSI_HEADER_SIZE)
? (const int8_t *)(frame + CSI_HEADER_SIZE) : NULL;
}
/*
* Ingestion-side validation, mirroring the plan's ordered rules exactly.
* Order matters: a frame is dropped at the FIRST rule it violates.
*/
static csi_valid_t csi_validate(const uint8_t *frame, size_t len)
{
if (len < CSI_MIN_FRAME_LEN) { /* rule 1 */
return CSI_TOO_SHORT;
}
uint8_t n_sub = frame[OFF_N_SUB]; /* rule 2 */
if (CSI_HEADER_SIZE + (size_t)n_sub * 2u != len) { /* rule 3 */
return CSI_PAYLOAD_MISMATCH;
}
if (n_sub > CSI_MAX_SUB) { /* rule 4 */
return CSI_N_SUB_TOO_BIG;
}
/* rule 5: rssi == 0 is allowed (invalid-RSSI flag), not a drop. */
uint8_t channel = frame[OFF_CHANNEL];
if (channel == 0 || channel > 14) { /* rules 6, 7 */
return CSI_BAD_CHANNEL;
}
return CSI_OK;
}
/* ---- Tests ---------------------------------------------------------------- */
/* A 64-subcarrier frame is 24 + 64*2 = 152 bytes; n_sub==0 is 24 (probe). */
TEST(csi_frame_header_size)
{
uint8_t buf[CSI_HEADER_SIZE + CSI_MAX_SUB * 2u];
uint8_t mac[6] = {0};
uint8_t peer[6] = {0};
ASSERT_EQ(csi_encode(mac, peer, 0, 0, 0, 6, 0, NULL, buf), 24);
ASSERT_EQ(csi_encode(mac, peer, 0, 0, 0, 6, 64, NULL, buf), 152);
}
/* n_sub==0 is a valid header-only probe (plan: "n_sub=0 is valid"). */
TEST(csi_frame_header_only_probe)
{
uint8_t buf[CSI_HEADER_SIZE];
uint8_t mac[6] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF};
uint8_t peer[6] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66};
size_t len = csi_encode(mac, peer, 42, -52, -95, 6, 0, NULL, buf);
ASSERT_EQ(len, 24);
ASSERT_EQ(csi_validate(buf, len), CSI_OK);
csi_frame_view_t v;
csi_decode(buf, len, &v);
ASSERT_EQ(v.n_sub, 0);
ASSERT_TRUE(v.iq == NULL);
}
/* Round-trip every header field through encode → decode. */
TEST(csi_frame_roundtrip_fields)
{
uint8_t buf[CSI_HEADER_SIZE + 64 * 2u];
uint8_t node[6] = {0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF};
uint8_t peer[6] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66};
int8_t iq[64 * 2];
for (int i = 0; i < 64 * 2; i++) {
iq[i] = (int8_t)((i % 51) - 25); /* some negatives */
}
size_t len = csi_encode(node, peer, 0x1122334455667788ULL, -52, -95, 6, 64,
iq, buf);
ASSERT_EQ(len, 152);
csi_frame_view_t v;
csi_decode(buf, len, &v);
ASSERT_EQ(memcmp(v.node_mac, node, 6), 0);
ASSERT_EQ(memcmp(v.peer_mac, peer, 6), 0);
ASSERT_EQ(v.timestamp_us, 0x1122334455667788ULL);
ASSERT_EQ(v.rssi, -52);
ASSERT_EQ(v.noise_floor, -95);
ASSERT_EQ(v.channel, 6);
ASSERT_EQ(v.n_sub, 64);
ASSERT_EQ(memcmp(v.iq, iq, 64 * 2), 0);
}
/*
* Explicitly pin LITTLE-ENDIAN byte order of the 8-byte timestamp, independent
* of host endianness. ts = 0x0102030405060708 must land as
* bytes {08,07,06,05,04,03,02,01} at offset 12. This is the plan's "verify
* little-endian encoding" requirement, made into a concrete byte assertion.
*/
TEST(csi_frame_timestamp_is_little_endian)
{
uint8_t buf[CSI_HEADER_SIZE];
uint8_t mac[6] = {0};
uint8_t peer[6] = {0};
csi_encode(mac, peer, 0x0102030405060708ULL, 0, 0, 6, 0, NULL, buf);
ASSERT_EQ(buf[OFF_TIMESTAMP + 0], 0x08);
ASSERT_EQ(buf[OFF_TIMESTAMP + 1], 0x07);
ASSERT_EQ(buf[OFF_TIMESTAMP + 2], 0x06);
ASSERT_EQ(buf[OFF_TIMESTAMP + 3], 0x05);
ASSERT_EQ(buf[OFF_TIMESTAMP + 4], 0x04);
ASSERT_EQ(buf[OFF_TIMESTAMP + 5], 0x03);
ASSERT_EQ(buf[OFF_TIMESTAMP + 6], 0x02);
ASSERT_EQ(buf[OFF_TIMESTAMP + 7], 0x01);
/* And decoding reconstructs the original 64-bit value. */
csi_frame_view_t v;
csi_decode(buf, sizeof(buf), &v);
ASSERT_EQ(v.timestamp_us, 0x0102030405060708ULL);
}
/*
* RSSI / noise_floor are signed dBm carried as raw bytes. A negative value
* (e.g. -52 dBm) must survive the (uint8_t) cast on encode and reinterpret as
* int8 on decode. Validates the firmware's `frame[20] = (uint8_t)rssi` trick.
*/
TEST(csi_frame_signed_rssi_roundtrip)
{
uint8_t buf[CSI_HEADER_SIZE];
uint8_t mac[6] = {0};
uint8_t peer[6] = {0};
csi_encode(mac, peer, 0, -1, -128, 11, 0, NULL, buf);
csi_frame_view_t v;
csi_decode(buf, sizeof(buf), &v);
ASSERT_EQ(v.rssi, -1);
ASSERT_EQ(v.noise_floor, -128);
csi_encode(mac, peer, 0, -52, -95, 1, 0, NULL, buf);
csi_decode(buf, sizeof(buf), &v);
ASSERT_EQ(v.rssi, -52);
ASSERT_EQ(v.noise_floor, -95);
}
/* I/Q payload bytes are copied verbatim — verify a small known payload. */
TEST(csi_frame_iq_payload)
{
uint8_t buf[CSI_HEADER_SIZE + 4 * 2u];
uint8_t mac[6] = {0};
uint8_t peer[6] = {0};
int8_t iq[8] = {10, -10, 20, -20, 30, -30, 40, -40};
size_t len = csi_encode(mac, peer, 0, -40, -90, 6, 4, iq, buf);
ASSERT_EQ(len, 32);
csi_frame_view_t v;
csi_decode(buf, len, &v);
ASSERT_EQ(v.n_sub, 4);
ASSERT_EQ(v.iq[0], 10);
ASSERT_EQ(v.iq[1], -10);
ASSERT_EQ(v.iq[2], 20);
ASSERT_EQ(v.iq[3], -20);
ASSERT_EQ(v.iq[4], 30);
ASSERT_EQ(v.iq[5], -30);
ASSERT_EQ(v.iq[6], 40);
ASSERT_EQ(v.iq[7], -40);
}
/*
* Ingestion-side validation: malformed frames are dropped at the right rule,
* matching the plan's ordered checks. This ties the firmware encoder contract to
* the mothership decoder contract.
*/
TEST(csi_frame_ingestion_validation)
{
/* Zeroed up front: the first sub-test passes len=23, and although csi_validate
* returns CSI_TOO_SHORT before reading any byte, zeroing removes any
* -Wmaybe-uninitialized ambiguity under differing opt levels. */
uint8_t buf[CSI_HEADER_SIZE + CSI_MAX_SUB * 2u];
memset(buf, 0, sizeof(buf));
uint8_t mac[6] = {0};
uint8_t peer[6] = {0};
/* Rule 1: too short to contain a header. */
ASSERT_EQ(csi_validate(buf, 23), CSI_TOO_SHORT);
/* Rule 3: payload length mismatch — 24-byte frame claims n_sub=5 (→34 B). */
memset(buf, 0, sizeof(buf));
csi_encode(mac, peer, 0, -50, -95, 6, 5, NULL, buf); /* claims 34 B */
ASSERT_EQ(csi_validate(buf, 24), CSI_PAYLOAD_MISMATCH);
/* Rule 4: n_sub > 128 with a length that otherwise matches. n_sub=130 → 284 B. */
memset(buf, 0, sizeof(buf));
buf[OFF_N_SUB] = 130;
buf[OFF_CHANNEL] = 6;
ASSERT_EQ(csi_validate(buf, 24 + 130u * 2u), CSI_N_SUB_TOO_BIG);
/* Rule 6: channel == 0 is invalid. */
memset(buf, 0, sizeof(buf)); /* n_sub=0, channel=0 */
ASSERT_EQ(csi_validate(buf, 24), CSI_BAD_CHANNEL);
/* Rule 7: channel > 14 is invalid. */
memset(buf, 0, sizeof(buf));
buf[OFF_CHANNEL] = 15;
ASSERT_EQ(csi_validate(buf, 24), CSI_BAD_CHANNEL);
/* Valid: channel 1..14, n_sub=0. rssi==0 is allowed (rule 5, not a drop). */
memset(buf, 0, sizeof(buf));
buf[OFF_CHANNEL] = 6;
ASSERT_EQ(csi_validate(buf, 24), CSI_OK);
/* Valid 64-subcarrier frame. */
size_t len = csi_encode(mac, peer, 0, -52, -95, 11, 64, NULL, buf);
ASSERT_EQ(csi_validate(buf, len), CSI_OK);
}