Files
qtalker---/firmware/esp32p4-sensor-node/test/test_rd03e_parse.c
Indiana 0966fa8cfc test: make firmware logic bugs catchable without hardware (Workstream F)
The firmware has never been flashed, and a real bug already reached the
repo because of it: RD03E_FRAME_LEN was 5 for a 6-byte frame, so the footer
check collided with the distance high byte and EVERY distance reading was
garbage — always `lo | 0x5500`, about 218 metres, regardless of what the
sensor saw. That was pure logic with no hardware dependency. It should have
been catchable on a laptop, and there was simply no way to run the code.

Extracted the hardware-free logic out of the three drivers — rd03e_parse,
bmp280_compensate, mems_level — as moves rather than rewrites, carrying the
explanatory comments along with the code they explain. The drivers now own
only their bus I/O and call into the pure units, so nothing changes for the
real device.

`./run_tests.sh` builds them with gcc -Wall -Wextra -Werror plus a
dependency-free assert harness: 175 checks, 0 failed, from a clean tree.

Proven to catch the actual bug rather than assumed to: reintroducing
FRAME_LEN 5 fails four checks, including one that reads "a simple-report
frame is 6 bytes, not 5", plus the truncated-frame and 5-byte-window cases.
Restored, green again.

This does NOT make the firmware verified, and the README says so plainly —
it is called a narrow exception and scoped to pure logic. Wiring, timing,
real register behaviour and the reconstructed RD-03E frame format all still
need the physical board.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 13:17:35 +00:00

139 lines
6.5 KiB
C

// RD-03E frame scanner tests.
//
// The headline case is `distance_little_endian_0x2C_0x01`: this is exactly
// the class of bug that actually shipped in this firmware. RD03E_FRAME_LEN
// was 5 for a 6-byte frame, so the footer comparison read bytes [3..4]
// (the distance HIGH byte and the first footer byte) instead of [4..5].
// Frames still "validated" whenever the high byte happened to be 0x55, and
// every distance came back as `lo | 0x5500` — about 218 metres, always.
// Pure logic, no hardware needed to catch it. It just was never run.
#include "../main/rd03e_parse.h"
#include "test_util.h"
#include <string.h>
// header, gesture, dist_lo, dist_hi, footer, footer
#define FRAME(g, lo, hi) 0xAA, (g), (lo), (hi), 0x55, 0x55
void test_rd03e_parse(void) {
SUITE("rd03e_parse");
// --- a well-formed frame parses to the exact expected fields ---------
{
const uint8_t buf[] = { FRAME(0x03, 0x2C, 0x01) };
rd03e_frame_t f = {0};
CHECK(rd03e_parse_latest(buf, sizeof(buf), &f), "valid frame must parse");
CHECK_EQ_U(f.gesture, 0x03, "gesture byte is frame[1] verbatim");
// THE REGRESSION TEST: 0x2C 0x01 little-endian is 0x012C = 300 cm.
// The shipped bug produced 0x552C = 21804 cm here.
CHECK_EQ_U(f.distance_cm, 300, "0x2C 0x01 must be 300cm (little-endian)");
}
// --- frame length really is 6 bytes ---------------------------------
{
CHECK_EQ_U(RD03E_FRAME_LEN, 6, "a simple-report frame is 6 bytes, not 5");
// Two back-to-back frames with NO padding. If the scanner consumed
// 5 bytes per frame it would desynchronise here and the second
// frame's fields would be misread (or missed entirely).
const uint8_t buf[] = {
FRAME(0x01, 0x0A, 0x00), // 10 cm
FRAME(0x02, 0xD0, 0x07), // 2000 cm
};
CHECK_EQ_U(sizeof(buf), 12, "two frames occupy exactly 12 bytes");
rd03e_frame_t f = {0};
CHECK(rd03e_parse_latest(buf, sizeof(buf), &f), "two-frame buffer must parse");
CHECK_EQ_U(f.gesture, 0x02, "two frames in one buffer: NEWEST gesture wins");
CHECK_EQ_U(f.distance_cm, 2000, "two frames in one buffer: NEWEST distance wins");
}
// --- a bad footer is rejected ----------------------------------------
{
// Correct header, correct length, footer byte [5] wrong.
const uint8_t buf[] = { 0xAA, 0x03, 0x2C, 0x01, 0x55, 0x56 };
rd03e_frame_t f = { .gesture = 0xEE, .distance_cm = 4242 };
CHECK(!rd03e_parse_latest(buf, sizeof(buf), &f), "bad footer byte [5] must be rejected");
CHECK_EQ_U(f.distance_cm, 4242, "rejected frame must leave *out untouched");
// Footer byte [4] wrong instead.
const uint8_t buf2[] = { 0xAA, 0x03, 0x2C, 0x01, 0x54, 0x55 };
CHECK(!rd03e_parse_latest(buf2, sizeof(buf2), &f), "bad footer byte [4] must be rejected");
}
// --- a truncated trailing frame is ignored ---------------------------
{
// One good frame, then five bytes of a second frame that never
// finished arriving. The good frame must still be reported and the
// scanner must not read past the end of the buffer.
const uint8_t buf[] = {
FRAME(0x07, 0x64, 0x00), // 100 cm
0xAA, 0x09, 0xFF, 0x03, 0x55, // truncated: 5 of 6 bytes
};
rd03e_frame_t f = {0};
CHECK(rd03e_parse_latest(buf, sizeof(buf), &f), "must still find the complete frame");
CHECK_EQ_U(f.gesture, 0x07, "truncated trailing frame must not be reported");
CHECK_EQ_U(f.distance_cm, 100, "truncated trailing frame must not be reported");
// A buffer holding nothing but a truncated frame yields nothing.
const uint8_t only_partial[] = { 0xAA, 0x09, 0xFF, 0x03, 0x55 };
CHECK(!rd03e_parse_latest(only_partial, sizeof(only_partial), &f),
"a lone truncated frame must not parse");
}
// --- garbage before a valid frame is skipped -------------------------
{
const uint8_t buf[] = {
0x00, 0xFF, 0x12, 0x55, 0x55, 0xAA, 0xAA, 0x01, // noise, incl. stray 0xAA
FRAME(0x05, 0xC8, 0x00), // 200 cm
};
rd03e_frame_t f = {0};
CHECK(rd03e_parse_latest(buf, sizeof(buf), &f), "must resynchronise past garbage");
CHECK_EQ_U(f.gesture, 0x05, "gesture after resync");
CHECK_EQ_U(f.distance_cm, 200, "distance after resync");
}
// --- a 0xAA that is really a payload byte must not fool the scanner ---
{
// First frame's distance low byte is 0xAA. If the scanner treated
// that as a header it would misparse; the footer check saves it.
const uint8_t buf[] = {
FRAME(0x01, 0xAA, 0x00), // 170 cm
FRAME(0x02, 0x01, 0x00), // 1 cm (newest)
};
rd03e_frame_t f = {0};
CHECK(rd03e_parse_latest(buf, sizeof(buf), &f), "0xAA payload byte must not break parsing");
CHECK_EQ_U(f.distance_cm, 1, "newest frame after a 0xAA payload byte");
}
// --- byte-order coverage across the full 16-bit range ----------------
{
struct { uint8_t lo, hi; uint16_t want; } cases[] = {
{ 0x2C, 0x01, 300 }, // the shipped-bug case
{ 0x00, 0x00, 0 },
{ 0xFF, 0x00, 255 },
{ 0x00, 0x01, 256 }, // lo/hi swapped would give 1
{ 0x01, 0x00, 1 }, // lo/hi swapped would give 256
{ 0xFF, 0xFF, 65535 },
};
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
const uint8_t buf[] = { 0xAA, 0x00, cases[i].lo, cases[i].hi, 0x55, 0x55 };
rd03e_frame_t f = {0};
CHECK(rd03e_parse_latest(buf, sizeof(buf), &f), "byte-order case %zu parses", i);
CHECK_EQ_U(f.distance_cm, cases[i].want,
"byte-order case %zu: 0x%02X 0x%02X", i, cases[i].lo, cases[i].hi);
}
}
// --- degenerate inputs are handled, not crashed on -------------------
{
rd03e_frame_t f = {0};
const uint8_t buf[] = { FRAME(0x01, 0x01, 0x00) };
CHECK(!rd03e_parse_latest(NULL, 6, &f), "NULL buffer is 'no frame'");
CHECK(!rd03e_parse_latest(buf, sizeof(buf), NULL), "NULL out is 'no frame'");
CHECK(!rd03e_parse_latest(buf, 0, &f), "empty buffer is 'no frame'");
CHECK(!rd03e_parse_latest(buf, 5, &f), "a 5-byte window cannot hold a frame");
CHECK(rd03e_parse_latest(buf, 6, &f), "a 6-byte window can");
}
}