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>
This commit is contained in:
Indiana
2026-07-31 13:17:35 +00:00
parent 9d42f541e3
commit 0966fa8cfc
20 changed files with 1083 additions and 177 deletions

View File

@@ -0,0 +1,40 @@
# Host build for the ESP-IDF-free firmware logic units.
#
# Requires nothing but gcc and make. No ESP-IDF, no test framework, no
# package manager. `make check` builds and runs; `run_tests.sh` wraps this
# and is the entry point CI (and you) should call.
CC ?= gcc
CFLAGS ?= -std=c11 -O1 -g -Wall -Wextra -Werror
LDLIBS ?= -lm
MAIN_DIR := ../main
BUILD := build
# The pure units under test, moved out of their drivers precisely so they
# can be compiled here without a cross-toolchain.
UNITS := \
$(MAIN_DIR)/rd03e_parse.c \
$(MAIN_DIR)/bmp280_compensate.c \
$(MAIN_DIR)/mems_level.c
TESTS := \
test_main.c \
test_rd03e_parse.c \
test_bmp280_compensate.c \
test_mems_level.c
BIN := $(BUILD)/firmware_tests
.PHONY: all check clean
all: $(BIN)
$(BIN): $(UNITS) $(TESTS) test_util.h $(MAIN_DIR)/rd03e_parse.h $(MAIN_DIR)/bmp280_compensate.h $(MAIN_DIR)/mems_level.h
@mkdir -p $(BUILD)
$(CC) $(CFLAGS) -o $@ $(UNITS) $(TESTS) $(LDLIBS)
check: $(BIN)
./$(BIN)
clean:
rm -rf $(BUILD)

View File

@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Build and run the firmware's host tests. Exits non-zero on any failure.
#
# Dependencies: gcc (and libm, which ships with it). Nothing else — no
# ESP-IDF, no make required (there is a Makefile, but this script does not
# depend on it), no test framework, no package install.
#
# These tests cover PURE LOGIC ONLY: frame parsing, byte order, compensation
# maths, level maths. They do not and cannot verify wiring, timing, or how
# the real silicon behaves. See ../README.md "What's verified vs. not".
set -euo pipefail
cd "$(dirname "$0")"
CC="${CC:-gcc}"
CFLAGS=(-std=c11 -O1 -g -Wall -Wextra -Werror)
BUILD="build"
BIN="$BUILD/firmware_tests"
if ! command -v "$CC" >/dev/null 2>&1; then
echo "run_tests.sh: '$CC' not found; install gcc (or set CC=clang)" >&2
exit 127
fi
mkdir -p "$BUILD"
echo "== building host tests with $CC ${CFLAGS[*]}"
"$CC" "${CFLAGS[@]}" -o "$BIN" \
../main/rd03e_parse.c \
../main/bmp280_compensate.c \
../main/mems_level.c \
test_main.c \
test_rd03e_parse.c \
test_bmp280_compensate.c \
test_mems_level.c \
-lm
echo "== running"
"./$BIN"

View File

@@ -0,0 +1,167 @@
// BMP280 compensation tests.
//
// Two kinds of check here, and it is worth being clear which is which:
//
// 1. Byte-order / packing checks. These are exact and they are the same
// class of bug as the RD-03E frame-length bug — a swapped LSB/MSB or a
// mis-shifted XLSB nibble is pure logic and needs no sensor to catch.
//
// 2. Arithmetic checks against the calibration/ADC values that appear in
// Bosch's own worked reference example (dig_T1=27504 ... dig_P9=6000,
// adc_T=519888, adc_P=415148, documented as ~25.08 degC / ~100653 Pa).
// These pin the transcription of the datasheet formulas. They prove the
// maths matches the reference — NOT that a real BMP280 wired to this
// board reports these registers.
#include "../main/bmp280_compensate.h"
#include "test_util.h"
#include <string.h>
// The Bosch reference example's calibration set.
static const uint16_t REF_T1 = 27504;
static const int16_t REF_T2 = 26435;
static const int16_t REF_T3 = -1000;
static const uint16_t REF_P1 = 36477;
static const int16_t REF_P2 = -10685;
static const int16_t REF_P3 = 3024;
static const int16_t REF_P4 = 2855;
static const int16_t REF_P5 = 140;
static const int16_t REF_P6 = -7;
static const int16_t REF_P7 = 15500;
static const int16_t REF_P8 = -14600;
static const int16_t REF_P9 = 6000;
// Pack a coefficient the way the register map stores it: LSB then MSB.
static void put16(uint8_t *p, uint16_t v) {
p[0] = (uint8_t)(v & 0xFF);
p[1] = (uint8_t)(v >> 8);
}
static void ref_calib_bytes(uint8_t buf[BMP280_CALIB_LEN]) {
put16(&buf[0], REF_T1);
put16(&buf[2], (uint16_t)REF_T2);
put16(&buf[4], (uint16_t)REF_T3);
put16(&buf[6], REF_P1);
put16(&buf[8], (uint16_t)REF_P2);
put16(&buf[10], (uint16_t)REF_P3);
put16(&buf[12], (uint16_t)REF_P4);
put16(&buf[14], (uint16_t)REF_P5);
put16(&buf[16], (uint16_t)REF_P6);
put16(&buf[18], (uint16_t)REF_P7);
put16(&buf[20], (uint16_t)REF_P8);
put16(&buf[22], (uint16_t)REF_P9);
}
void test_bmp280_compensate(void) {
SUITE("bmp280_compensate");
bmp280_calib_t c;
{
uint8_t buf[BMP280_CALIB_LEN];
ref_calib_bytes(buf);
memset(&c, 0, sizeof(c));
bmp280_calib_from_regs(buf, &c);
// --- calibration decoding: little-endian, signedness preserved ---
CHECK_EQ_U(c.dig_T1, REF_T1, "dig_T1 unsigned little-endian");
CHECK(c.dig_T2 == REF_T2, "dig_T2 signed little-endian");
CHECK(c.dig_T3 == REF_T3, "dig_T3 must stay negative (%d)", (int)c.dig_T3);
CHECK_EQ_U(c.dig_P1, REF_P1, "dig_P1 unsigned little-endian");
CHECK(c.dig_P2 == REF_P2, "dig_P2 must stay negative (%d)", (int)c.dig_P2);
CHECK(c.dig_P3 == REF_P3, "dig_P3");
CHECK(c.dig_P4 == REF_P4, "dig_P4");
CHECK(c.dig_P5 == REF_P5, "dig_P5");
CHECK(c.dig_P6 == REF_P6, "dig_P6 must stay negative (%d)", (int)c.dig_P6);
CHECK(c.dig_P7 == REF_P7, "dig_P7");
CHECK(c.dig_P8 == REF_P8, "dig_P8 must stay negative (%d)", (int)c.dig_P8);
CHECK(c.dig_P9 == REF_P9, "dig_P9");
// dig_T1 = 27504 = 0x6B70, so bytes are 0x70 then 0x6B. A swapped
// decode would give 0x706B = 28779.
CHECK_EQ_U(buf[0], 0x70, "calib byte 0 is the LSB");
CHECK_EQ_U(buf[1], 0x6B, "calib byte 1 is the MSB");
}
// --- 20-bit ADC word decoding ---------------------------------------
{
// adc = MSB<<12 | LSB<<4 | XLSB>>4.
// 519888 = 0x7EED0 -> MSB 0x7E, LSB 0xED, XLSB top nibble 0x0.
// 415148 = 0x655AC -> MSB 0x65, LSB 0x5A, XLSB top nibble 0xC.
const uint8_t raw[BMP280_RAW_LEN] = {
0x65, 0x5A, 0xC0, // pressure (0xF7..0xF9)
0x7E, 0xED, 0x00, // temperature (0xFA..0xFC)
};
int32_t adc_P = 0, adc_T = 0;
bmp280_adc_from_regs(raw, &adc_P, &adc_T);
CHECK_EQ_U(adc_P, 415148, "adc_P: pressure comes FIRST in the burst read");
CHECK_EQ_U(adc_T, 519888, "adc_T: temperature comes SECOND in the burst read");
// The XLSB's low nibble is padding and must be discarded.
const uint8_t raw2[BMP280_RAW_LEN] = {
0x65, 0x5A, 0xCF, // low nibble of XLSB set — must be ignored
0x7E, 0xED, 0x0F,
};
bmp280_adc_from_regs(raw2, &adc_P, &adc_T);
CHECK_EQ_U(adc_P, 415148, "adc_P ignores the XLSB's low nibble");
CHECK_EQ_U(adc_T, 519888, "adc_T ignores the XLSB's low nibble");
}
// --- the reference worked example ------------------------------------
double t_fine = 0.0;
{
double temp_c = bmp280_compensate_temperature(&c, 519888, &t_fine);
CHECK_NEAR(temp_c, 25.08, 0.02, "Bosch reference adc_T yields ~25.08 degC");
CHECK(t_fine > 0.0, "t_fine is written for the pressure stage");
double press_pa = bmp280_compensate_pressure(&c, 415148, t_fine);
CHECK_NEAR(press_pa, 100653.0, 2.0, "Bosch reference adc_P yields ~100653 Pa");
// Sanity in the unit the driver actually reports (hPa).
CHECK(press_pa / 100.0 > 800.0 && press_pa / 100.0 < 1100.0,
"pressure in hPa lands in a physically plausible band (%.2f)", press_pa / 100.0);
}
// --- physical sanity: temperature moves the right way ----------------
{
double tf_cold = 0.0, tf_hot = 0.0;
double cold = bmp280_compensate_temperature(&c, 400000, &tf_cold);
double hot = bmp280_compensate_temperature(&c, 600000, &tf_hot);
CHECK(cold < hot, "a larger raw temperature ADC means a warmer reading");
CHECK(tf_cold < tf_hot, "t_fine tracks temperature");
CHECK(cold > -50.0 && hot < 100.0,
"both readings stay in the sensor's operating band (%.2f, %.2f)", cold, hot);
}
// --- physical sanity: pressure falls monotonically with altitude -----
{
// Raw pressure ADC is inversely related to pressure in this part
// (the formula starts from 1048576 - adc_P), so sweeping adc_P
// upward is a stand-in for climbing. Pressure must fall the whole
// way, with no sign flip or discontinuity.
double prev = 1e18;
for (int32_t adc_P = 380000; adc_P <= 460000; adc_P += 5000) {
double p = bmp280_compensate_pressure(&c, adc_P, t_fine);
CHECK(p < prev, "pressure decreases monotonically at adc_P=%d (%.2f >= %.2f)",
(int)adc_P, p, prev);
CHECK(p > 50000.0 && p < 130000.0,
"pressure stays physically plausible at adc_P=%d (%.2f Pa)", (int)adc_P, p);
prev = p;
}
}
// --- the divide-by-zero guard returns 0, it does not crash -----------
{
// An all-zero calibration block is what you get if the I2C read
// silently failed. dig_P1 == 0 makes var1 == 0.
bmp280_calib_t zero;
memset(&zero, 0, sizeof(zero));
double p = bmp280_compensate_pressure(&zero, 415148, 100000.0);
CHECK(p == 0.0, "var1 == 0 must return exactly 0.0, not inf/NaN (got %.6f)", p);
// Same story if only dig_P1 is zero but the rest is real.
bmp280_calib_t no_p1 = c;
no_p1.dig_P1 = 0;
double p2 = bmp280_compensate_pressure(&no_p1, 415148, t_fine);
CHECK(p2 == 0.0, "dig_P1 == 0 must return exactly 0.0 (got %.6f)", p2);
}
}

View File

@@ -0,0 +1,23 @@
// Host test runner for the ESP-IDF-free firmware logic units.
// Exit status 0 = all checks passed, 1 = at least one failed.
#include "test_util.h"
int g_tests_run = 0;
int g_tests_failed = 0;
int main(void) {
printf("firmware host tests (pure logic only — no hardware involved)\n\n");
test_rd03e_parse();
test_bmp280_compensate();
test_mems_level();
printf("\n%d checks run, %d failed\n", g_tests_run, g_tests_failed);
if (g_tests_failed != 0) {
printf("FAILED\n");
return 1;
}
printf("OK\n");
return 0;
}

View File

@@ -0,0 +1,109 @@
// MEMS mic RMS -> dBFS tests.
//
// These prove the arithmetic: that a full-scale block reads ~0 dBFS, that
// silence reads the -120 floor rather than -inf or NaN (which would poison
// the JSON payload the backend receives), and that the level rises
// monotonically with amplitude. They prove nothing about whether the
// right-shift-by-8 matches this specific module's real bit alignment —
// that needs a mic.
#include "../main/mems_level.h"
#include "test_util.h"
#include <math.h>
#include <stddef.h>
#define N 256
void test_mems_level(void) {
SUITE("mems_level");
// A 24-bit sample sits left-justified in the 32-bit slot, so the raw
// slot value for full scale is 2^23 << 8.
const int32_t full_scale_slot = (int32_t)(8388607 << 8); // 2^23 - 1, shifted up
// --- full scale reads ~0 dBFS ----------------------------------------
{
int32_t buf[N];
for (size_t i = 0; i < N; i++) buf[i] = full_scale_slot;
double rms = mems_level_rms(buf, N);
CHECK_NEAR(rms, 8388607.0, 1.0, "full-scale slots recover the 24-bit magnitude");
double dbfs = mems_level_dbfs(rms);
CHECK_NEAR(dbfs, 0.0, 0.01, "full-scale input is ~0 dBFS (got %.4f)", dbfs);
CHECK(dbfs <= 0.0, "dBFS never exceeds 0 for an in-range input");
}
// --- silence reads the floor, not -inf or NaN ------------------------
{
int32_t buf[N];
for (size_t i = 0; i < N; i++) buf[i] = 0;
double rms = mems_level_rms(buf, N);
CHECK(rms == 0.0, "an all-zero block has zero RMS");
double dbfs = mems_level_dbfs(rms);
CHECK(dbfs == MEMS_DBFS_FLOOR, "silence clamps to the -120 floor (got %.4f)", dbfs);
CHECK(!isinf(dbfs), "silence must not be -inf");
CHECK(!isnan(dbfs), "silence must not be NaN");
// Sub-LSB dither in the padding bits still counts as silence
// because the >>8 discards it.
int32_t buf2[N];
for (size_t i = 0; i < N; i++) buf2[i] = (int32_t)(i % 256); // padding bits only
double dbfs2 = mems_level_dbfs(mems_level_rms(buf2, N));
CHECK(dbfs2 == MEMS_DBFS_FLOOR, "sub-LSB noise stays at the floor (got %.4f)", dbfs2);
}
// --- halving amplitude drops the level by ~6 dB ----------------------
{
int32_t loud[N], quiet[N];
for (size_t i = 0; i < N; i++) {
loud[i] = (int32_t)(4194304 << 8); // 2^22, i.e. -6 dBFS
quiet[i] = (int32_t)(2097152 << 8); // 2^21, i.e. -12 dBFS
}
double d_loud = mems_level_dbfs(mems_level_rms(loud, N));
double d_quiet = mems_level_dbfs(mems_level_rms(quiet, N));
CHECK_NEAR(d_loud, -6.0206, 0.001, "2^22 is -6 dBFS (got %.4f)", d_loud);
CHECK_NEAR(d_quiet, -12.0412, 0.001, "2^21 is -12 dBFS (got %.4f)", d_quiet);
CHECK_NEAR(d_loud - d_quiet, 6.0206, 0.001, "halving amplitude costs ~6 dB");
}
// --- negative samples contribute the same energy as positive ---------
{
int32_t pos[N], neg[N], alt[N];
for (size_t i = 0; i < N; i++) {
pos[i] = (int32_t)(1000000 << 8);
neg[i] = (int32_t)(-(1000000 << 8));
alt[i] = (i % 2) ? (int32_t)(1000000 << 8) : (int32_t)(-(1000000 << 8));
}
double rp = mems_level_rms(pos, N);
double rn = mems_level_rms(neg, N);
double ra = mems_level_rms(alt, N);
CHECK_NEAR(rp, 1000000.0, 1.0, "positive DC block RMS");
CHECK_NEAR(rn, 1000000.0, 1.0, "negative DC block has the same RMS (sign-independent)");
CHECK_NEAR(ra, 1000000.0, 1.0, "an alternating square wave has the same RMS");
}
// --- level rises monotonically with amplitude ------------------------
{
double prev = -1000.0;
for (int shift = 4; shift <= 23; shift++) {
int32_t buf[N];
int32_t mag = (int32_t)1 << shift;
for (size_t i = 0; i < N; i++) buf[i] = mag << 8;
double dbfs = mems_level_dbfs(mems_level_rms(buf, N));
CHECK(dbfs > prev, "level rises with amplitude at 2^%d (%.4f <= %.4f)", shift, dbfs, prev);
CHECK(dbfs >= MEMS_DBFS_FLOOR && dbfs <= 0.0,
"level stays inside [%.1f, 0] at 2^%d (got %.4f)", MEMS_DBFS_FLOOR, shift, dbfs);
CHECK(!isnan(dbfs) && !isinf(dbfs), "level is finite at 2^%d", shift);
prev = dbfs;
}
}
// --- degenerate inputs ------------------------------------------------
{
int32_t buf[1] = { 0 };
CHECK(mems_level_rms(NULL, 8) == 0.0, "NULL sample buffer yields 0 RMS, not a crash");
CHECK(mems_level_rms(buf, 0) == 0.0, "an empty block yields 0 RMS, not a divide by zero");
CHECK(mems_level_dbfs(mems_level_rms(buf, 0)) == MEMS_DBFS_FLOOR,
"an empty block reports the floor");
}
}

View File

@@ -0,0 +1,138 @@
// 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");
}
}

View File

@@ -0,0 +1,56 @@
// Minimal test scaffolding. No frameworks, no dependencies — the whole
// point of this harness is that it runs anywhere gcc runs.
#pragma once
#include <stdio.h>
#include <stdlib.h>
extern int g_tests_run;
extern int g_tests_failed;
#define CHECK(cond, ...) \
do { \
g_tests_run++; \
if (!(cond)) { \
g_tests_failed++; \
printf(" FAIL %s:%d: ", __FILE__, __LINE__); \
printf(__VA_ARGS__); \
printf("\n condition: %s\n", #cond); \
} \
} while (0)
#define CHECK_EQ_U(actual, expected, ...) \
do { \
unsigned long long a_ = (unsigned long long)(actual); \
unsigned long long e_ = (unsigned long long)(expected); \
g_tests_run++; \
if (a_ != e_) { \
g_tests_failed++; \
printf(" FAIL %s:%d: ", __FILE__, __LINE__); \
printf(__VA_ARGS__); \
printf("\n expected %llu, got %llu\n", e_, a_); \
} \
} while (0)
#define CHECK_NEAR(actual, expected, tol, ...) \
do { \
double a_ = (double)(actual); \
double e_ = (double)(expected); \
double d_ = a_ - e_; \
if (d_ < 0) d_ = -d_; \
g_tests_run++; \
if (!(d_ <= (double)(tol))) { \
g_tests_failed++; \
printf(" FAIL %s:%d: ", __FILE__, __LINE__); \
printf(__VA_ARGS__); \
printf("\n expected %.6f +/- %.6f, got %.6f\n", \
e_, (double)(tol), a_); \
} \
} while (0)
#define SUITE(name) printf("[%s]\n", (name))
void test_rd03e_parse(void);
void test_bmp280_compensate(void);
void test_mems_level(void);