diff --git a/.gitignore b/.gitignore index 01131a0..def5e3a 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,7 @@ firmware/*/sdkconfig firmware/*/sdkconfig.old firmware/*/managed_components/ firmware/*/dependencies.lock + +# Host test harness build output (firmware/esp32p4-sensor-node/test/) +firmware/*/test/build/ firmware/*/main/device_config.h diff --git a/firmware/esp32p4-sensor-node/README.md b/firmware/esp32p4-sensor-node/README.md index 39ae7f8..14c24bd 100644 --- a/firmware/esp32p4-sensor-node/README.md +++ b/firmware/esp32p4-sensor-node/README.md @@ -39,6 +39,21 @@ specific, itemized breakdown — this mirrors the same convention `frontend/src/lib/sdr.ts`'s `HARDWARE PASS REQUIRED` header comment uses elsewhere in this repo. +**One narrow exception, added deliberately.** The pure logic — frame +parsing, byte order, compensation maths, level maths — has been extracted +into ESP-IDF-free units under `main/` and is now covered by a host test +suite you can run anywhere gcc exists: + +```bash +./run_tests.sh # from the repo root, or test/run_tests.sh from here +``` + +That suite is *machine-verified*, not reasoned about. It is also strictly +about logic: it never touches a bus, a pin, or ESP-IDF, and it cannot tell +you whether the protocols it implements match the real modules. Read the +first subsection of [What's verified vs. not](#whats-verified-vs-not) for +exactly what it does and does not establish. + ## Directory layout ``` @@ -46,6 +61,10 @@ firmware/esp32p4-sensor-node/ ├── CMakeLists.txt top-level ESP-IDF project file ├── sdkconfig.defaults seed config (idf.py generates the real sdkconfig) ├── README.md this file +├── test/ host test harness (gcc only, no ESP-IDF) +│ ├── run_tests.sh build + run; non-zero exit on failure +│ ├── Makefile same build, for `make check` +│ └── test_*.c plain-assert tests, zero dependencies ├── components/ │ ├── README.md │ └── rtlsdr_experimental/ Workstream J — opt-in USB-host RTL-SDR module @@ -59,11 +78,20 @@ firmware/esp32p4-sensor-node/ ├── telemetry_client.{h,c} HTTP POST task -> /api/device/telemetry ├── sensor_driver.h the sensor_driver_t registry interface ├── sensor_registry.{h,c} the concrete list of compiled-in drivers - ├── bmp280.{h,c} temperature/pressure over I2C - ├── rd03e.{h,c} presence/distance/gesture over UART - └── mems_mic.{h,c} EVP-style audio RMS level over I2S + ├── bmp280.{h,c} temperature/pressure over I2C (I2C traffic) + ├── bmp280_compensate.{h,c} PURE: calib/ADC decode + Bosch compensation + ├── rd03e.{h,c} presence/distance/gesture over UART (UART I/O) + ├── rd03e_parse.{h,c} PURE: the 6-byte frame scanner + ├── mems_mic.{h,c} EVP-style audio level over I2S (I2S traffic) + └── mems_level.{h,c} PURE: RMS -> dBFS maths ``` +The units marked PURE include only ``/``/`` — +no ESP-IDF, no FreeRTOS, no logging — so `test/` can compile them with +plain gcc. The drivers alongside them own the bus I/O and call in. Any new +decision that is pure arithmetic or byte handling belongs in a PURE unit, +where it can be tested before it reaches a board. + ## Build instructions Requires an ESP-IDF install (v5.3 or newer — ESP32-P4 target support landed @@ -113,9 +141,66 @@ BLE provisioning) in this build — see the spec's scope boundary. ## What's verified vs. not -**Structurally verified** (reasoned through carefully against ESP-IDF's -documented API surface and each sensor's public protocol docs; internally -consistent; no known syntax errors or obviously-wrong API usage): +### Machine-verified on a host: the pure logic (`test/`) + +Run it with `./run_tests.sh` (from the repo root, or `test/run_tests.sh` +here). It needs **gcc and nothing else** — no ESP-IDF, no toolchain, no +board, no network. It compiles with `-Wall -Wextra -Werror` and exits +non-zero on any failure. Current status: **175 checks, 0 failures.** + +This exists because a real bug shipped in this firmware and sat there +undetected: `RD03E_FRAME_LEN` was `5` for a 6-byte frame, so the footer +check compared the *distance high byte* against `0x55` instead of the +second footer byte. Frames only "validated" when the high byte happened to +be `0x55`, and every distance reading came back as `lo | 0x5500` — roughly +218 metres, always. That was pure arithmetic with zero hardware dependency +and it should have been catchable on a laptop. The three pure units below +were split out of their drivers precisely so that class of bug now is. + +The ESP-IDF-free units, and what the tests actually prove about each: + +- `main/rd03e_parse.c` — the frame scanner. Proven: a well-formed frame + yields the exact expected gesture and distance; `0x2C 0x01` is 300 cm, + little-endian (the shipped bug produced 21804 cm here); `RD03E_FRAME_LEN` + really is 6 and two back-to-back frames occupy exactly 12 bytes without + desynchronising; two frames in one buffer report the **newest**; a wrong + byte in *either* footer position is rejected; a truncated trailing frame + is ignored and never read past; garbage (including a stray `0xAA`) before + a valid frame is skipped; a `0xAA` that is really a payload byte does not + fool the scanner; the full 16-bit distance range decodes with the correct + byte order; NULL/short/empty inputs return "no frame" rather than + crashing. +- `main/bmp280_compensate.c` — calibration/ADC decoding plus the Bosch + §3.11.3 compensation maths. Proven: all twelve calibration coefficients + decode little-endian with signedness preserved; the 20-bit ADC words + decode with pressure first, temperature second, and the XLSB's low nibble + discarded; the datasheet's own worked reference values (adc_T=519888, + adc_P=415148 with the published calibration set) come out at ~25.08 °C + and ~100653 Pa; temperature rises with raw ADC; pressure falls + monotonically across an ADC sweep and stays in a physically plausible + band; the `var1 == 0` guard returns exactly `0.0` rather than `inf`/`NaN` + when the calibration block is all zeros (i.e. a silently-failed I2C read). +- `main/mems_level.c` — RMS → dBFS. Proven: a full-scale block reads + ~0 dBFS; silence reads the −120 floor and is never `-inf` or `NaN` (which + would poison the JSON the backend receives); sub-LSB noise in the padding + bits stays at the floor; halving amplitude costs ~6 dB; negative samples + carry the same energy as positive; the level rises monotonically with + amplitude and stays inside [−120, 0]; empty/NULL blocks do not divide by + zero. + +**What this does NOT prove — and the distinction matters.** These tests +verify the firmware's logic against the *protocol and datasheet as this +repo understands them*. They cannot verify that understanding. If the +RD-03E's real frame format differs from the reconstruction below, every +test still passes and every reading is still wrong. Nothing here touches a +UART, an I2C bus, an I2S clock, a GPIO, or ESP-IDF itself. See the +hardware list further down — it is unchanged by these tests. + +### Structurally verified only + +(Reasoned through carefully against ESP-IDF's documented API surface and +each sensor's public protocol docs; internally consistent; no known syntax +errors or obviously-wrong API usage. Not compiled, not run.) - Project skeleton (`CMakeLists.txt` × 2, `sdkconfig.defaults`, `idf_component_register` call) follows ESP-IDF's standard project layout. @@ -126,24 +211,26 @@ consistent; no known syntax errors or obviously-wrong API usage): - HTTP client (`telemetry_client.c`) builds the exact JSON shape the spec's contract defines and POSTs it via `esp_http_client` with `Authorization: Bearer ` and `Content-Type: application/json`. -- BMP280 driver (`bmp280.c`): register map and the double-precision - compensation formulas are transcribed from Bosch's public BMP280 +- BMP280 driver (`bmp280.c` + `bmp280_compensate.c`): register map and the + double-precision compensation formulas are transcribed from Bosch's public BMP280 datasheet (rev 1.23, §3.11.1–3.11.3) — this is well-trodden, publicly documented territory, and the formulas are checkable line-by-line against the datasheet. Uses ESP-IDF's newer `driver/i2c_master.h` API (the current idiomatic choice; the older `driver/i2c.h` is being phased out). Temperature + pressure only — the part in use (a GY-BMP280 breakout) has no humidity sensor, unlike its BME280 sibling. -- RD-03E driver (`rd03e.c`): the 5-byte "simple report" UART frame - (`0xAA` header, gesture byte, little-endian distance, `0x55 0x55` - footer) is reconstructed from a third-party bring-up write-up, not - Ai-Thinker's own datasheet (not available while writing this) — **the - single least-certain piece of code in this entire firmware.** The +- RD-03E driver (`rd03e.c` + `rd03e_parse.c`): the **6-byte** "simple + report" UART frame (`0xAA` header, gesture byte, little-endian distance + low/high, `0x55 0x55` footer) is reconstructed from a third-party + bring-up write-up, not Ai-Thinker's own datasheet (not available while + writing this) — **the single least-certain piece of this entire + firmware.** The parser itself is now host-tested (above); the *format it + parses* is still a reconstruction, and that is the risk that remains. The gesture byte's exact value-to-meaning mapping is unconfirmed, so the driver reports it as a raw code in `metadata` rather than guessing at a translated label. Cross-confirmed from multiple sources: 256000 baud, 8N1 UART framing. -- I2S MEMS microphone driver (`mems_mic.c`): uses ESP-IDF's current +- I2S MEMS microphone driver (`mems_mic.c` + `mems_level.c`): uses ESP-IDF's current `driver/i2s_std.h` API (standard/Philips mode, mono, 32-bit slot). The 24-bit-in-32-bit-slot right-shift and dBFS reference level are the commonly-documented values for the INMP441 family this module's pinout @@ -156,25 +243,41 @@ consistent; no known syntax errors or obviously-wrong API usage): **NOT verified — requires real hardware bring-up:** +None of the following is touched by `run_tests.sh`. The host tests cover +arithmetic and byte handling; everything in this list is about wiring, +timing, and what the silicon actually does. + - `idf.py build` has never actually been run in this environment (no ESP-IDF toolchain installed here) — there could be a typo, a missing include, or an API signature mismatch against whatever exact ESP-IDF - version you build with that only a real compile will surface. + version you build with that only a real compile will surface. The host + harness deliberately does **not** compile `rd03e.c` / `bmp280.c` / + `mems_mic.c` (they need ESP-IDF headers), so it cannot catch this. +- The firmware has still **never been flashed to a board.** Nothing below + has been observed; it has only been reasoned about. - I2C timing/electricals: pull-up resistor values, bus speed headroom, cable length — none of this has been bench-tested. -- BMP280 compensation formula correctness in practice: the math is - transcribed carefully, but "matches the datasheet" and "produces a - plausible number when this exact C runs on this exact silicon" are - different claims until someone compares a real reading to a reference - thermometer/barometer. -- RD-03E frame parsing, as above — this one especially, since the frame - format itself (not just the implementation) is reconstructed from a - third-party source rather than an official datasheet. Verify against a +- BMP280 readings in practice: the maths now reproduces the datasheet's own + worked reference values on a host, but "matches the datasheet's reference + numbers" and "a real BMP280 on this bus returns the register contents we + assume, and the result matches a reference thermometer/barometer" are + different claims. Also unverified: the forced-mode `ctrl_meas` value, the + status-polling loop, the reset delay, and the burst-read register + addresses — all of that is I2C traffic the host tests never execute. +- The RD-03E **frame format itself** — not the parser, which is now + host-tested, but the reconstruction it implements. This is the gap the + tests cannot close: they assert the parser matches the format in + `rd03e_parse.h`, and that format came from a third-party write-up rather + than an official datasheet. If it is wrong, the tests pass and the + readings are garbage. Verify against a logic analyzer capture before trusting field values, and treat the gesture code's meaning as genuinely unknown until cross-checked. -- I2S mic timing/levels: the BCLK/WS timing relationship, whether the +- I2S mic timing/levels: the BCLK/WS timing relationship, and whether the 24-bit-in-32-bit-slot shift is exactly right for this specific module - revision, and whether the dBFS numbers land in a sane, usable range — + revision. The host tests prove the shift-and-RMS arithmetic is + self-consistent; they say nothing about whether shifting by 8 is the + correct alignment for the real bitstream, or whether the dBFS numbers + land in a sane, usable range once a real mic is feeding them — none of this has been bench-tested. Confirm by talking near the mic and checking the reported level actually rises before trusting it unattended. - Wi-Fi reconnect behavior under real-world conditions (router reboot, diff --git a/firmware/esp32p4-sensor-node/main/CMakeLists.txt b/firmware/esp32p4-sensor-node/main/CMakeLists.txt index 9476895..a275768 100644 --- a/firmware/esp32p4-sensor-node/main/CMakeLists.txt +++ b/firmware/esp32p4-sensor-node/main/CMakeLists.txt @@ -1,5 +1,10 @@ # Quantumancy sensor node — main component. # +# The *_parse / *_compensate / *_level units are ESP-IDF-free pure logic, +# split out of their drivers so they can also be compiled and tested on a +# host with plain gcc — see ../test/run_tests.sh. They are listed here too +# because the on-device build needs them linked in exactly the same way. +# # device_config.h is intentionally NOT listed as a source: it's a header the # seeker generates locally (see device_config.h.example + README.md) and is # gitignored. If it's missing, the build will fail on the #include in @@ -13,8 +18,11 @@ idf_component_register( "telemetry_client.c" "sensor_registry.c" "bmp280.c" + "bmp280_compensate.c" "rd03e.c" + "rd03e_parse.c" "mems_mic.c" + "mems_level.c" INCLUDE_DIRS "." REQUIRES diff --git a/firmware/esp32p4-sensor-node/main/bmp280.c b/firmware/esp32p4-sensor-node/main/bmp280.c index 3934ec5..0aa5522 100644 --- a/firmware/esp32p4-sensor-node/main/bmp280.c +++ b/firmware/esp32p4-sensor-node/main/bmp280.c @@ -4,17 +4,20 @@ // BMP280 datasheet (Bosch Sensortec, document rev 1.23) and ESP-IDF's // documented `driver/i2c_master.h` API surface, and reasoned about carefully, // but never compiled with a real ESP-IDF toolchain nor run against a real -// sensor. Register addresses and the compensation formula below are -// transcribed as directly as possible from the datasheet's section 3.11.1 -// (register map) and 3.11.3 (double-precision compensation formula -// reference implementation) to minimize transcription risk, but a real -// bring-up should sanity-check first readings against a known-good -// reference (e.g. compare to a household thermometer/barometer). +// sensor. Register addresses are transcribed as directly as possible from +// the datasheet's section 3.11.1 (register map) to minimize transcription +// risk, but a real bring-up should sanity-check first readings against a +// known-good reference (e.g. compare to a household thermometer/barometer). +// +// This file owns only the I2C traffic. The calibration/ADC byte decoding +// and the §3.11.3 compensation maths live in bmp280_compensate.h/.c, which +// is ESP-IDF-free and unit-tested on a host with plain gcc (see ../test/). #include #include #include #include "bmp280.h" +#include "bmp280_compensate.h" #include "driver/i2c_master.h" #include "esp_log.h" #include "freertos/FreeRTOS.h" @@ -41,21 +44,6 @@ static const char *TAG = "bmp280"; #define STATUS_MEASURING_BIT 0x08 -typedef struct { - uint16_t dig_T1; - int16_t dig_T2; - int16_t dig_T3; - uint16_t dig_P1; - int16_t dig_P2; - int16_t dig_P3; - int16_t dig_P4; - int16_t dig_P5; - int16_t dig_P6; - int16_t dig_P7; - int16_t dig_P8; - int16_t dig_P9; -} bmp280_calib_t; - static i2c_master_bus_handle_t s_bus = NULL; static i2c_master_dev_handle_t s_dev = NULL; static bmp280_calib_t s_calib; @@ -70,31 +58,13 @@ static esp_err_t read_regs(uint8_t reg, uint8_t *out, size_t len) { return i2c_master_transmit_receive(s_dev, ®, 1, out, len, 1000 /* ms */); } -static int16_t s16(uint8_t lsb, uint8_t msb) { - return (int16_t)((uint16_t)msb << 8 | lsb); -} -static uint16_t u16(uint8_t lsb, uint8_t msb) { - return (uint16_t)((uint16_t)msb << 8 | lsb); -} - static esp_err_t read_calibration(void) { - uint8_t buf[24]; // 0x88..0x9F + uint8_t buf[BMP280_CALIB_LEN]; // 0x88..0x9F esp_err_t err = read_regs(REG_CALIB00, buf, sizeof(buf)); if (err != ESP_OK) return err; - s_calib.dig_T1 = u16(buf[0], buf[1]); - s_calib.dig_T2 = s16(buf[2], buf[3]); - s_calib.dig_T3 = s16(buf[4], buf[5]); - s_calib.dig_P1 = u16(buf[6], buf[7]); - s_calib.dig_P2 = s16(buf[8], buf[9]); - s_calib.dig_P3 = s16(buf[10], buf[11]); - s_calib.dig_P4 = s16(buf[12], buf[13]); - s_calib.dig_P5 = s16(buf[14], buf[15]); - s_calib.dig_P6 = s16(buf[16], buf[17]); - s_calib.dig_P7 = s16(buf[18], buf[19]); - s_calib.dig_P8 = s16(buf[20], buf[21]); - s_calib.dig_P9 = s16(buf[22], buf[23]); + bmp280_calib_from_regs(buf, &s_calib); return ESP_OK; } @@ -162,36 +132,6 @@ fail: return err; } -// Bosch datasheet 3.11.3 double-precision reference compensation formulas, -// transcribed near-verbatim (variable names kept close to the original so -// it's checkable against the datasheet PDF side-by-side). - -static double compensate_temperature(int32_t adc_T, double *out_t_fine) { - double var1 = (((double)adc_T) / 16384.0 - ((double)s_calib.dig_T1) / 1024.0) * ((double)s_calib.dig_T2); - double var2 = ((((double)adc_T) / 131072.0 - ((double)s_calib.dig_T1) / 8192.0) * - (((double)adc_T) / 131072.0 - ((double)s_calib.dig_T1) / 8192.0)) * ((double)s_calib.dig_T3); - *out_t_fine = var1 + var2; - return (var1 + var2) / 5120.0; // degrees C -} - -static double compensate_pressure(int32_t adc_P, double t_fine) { - double var1 = (t_fine / 2.0) - 64000.0; - double var2 = var1 * var1 * ((double)s_calib.dig_P6) / 32768.0; - var2 = var2 + var1 * ((double)s_calib.dig_P5) * 2.0; - var2 = (var2 / 4.0) + (((double)s_calib.dig_P4) * 65536.0); - var1 = (((double)s_calib.dig_P3) * var1 * var1 / 524288.0 + ((double)s_calib.dig_P2) * var1) / 524288.0; - var1 = (1.0 + var1 / 32768.0) * ((double)s_calib.dig_P1); - if (var1 == 0.0) { - return 0.0; // avoid divide-by-zero per datasheet's own guard - } - double p = 1048576.0 - (double)adc_P; - p = (p - (var2 / 4096.0)) * 6250.0 / var1; - var1 = ((double)s_calib.dig_P9) * p * p / 2147483648.0; - var2 = p * ((double)s_calib.dig_P8) / 32768.0; - p = p + (var1 + var2 + ((double)s_calib.dig_P7)) / 16.0; - return p; // Pa -} - esp_err_t bmp280_read(sensor_reading_t *out, size_t max_out, size_t *out_count) { *out_count = 0; if (!s_ready) { @@ -222,16 +162,16 @@ esp_err_t bmp280_read(sensor_reading_t *out, size_t max_out, size_t *out_count) } } - uint8_t raw[6]; + uint8_t raw[BMP280_RAW_LEN]; err = read_regs(REG_PRESS_MSB, raw, sizeof(raw)); if (err != ESP_OK) return err; - int32_t adc_P = ((int32_t)raw[0] << 12) | ((int32_t)raw[1] << 4) | (raw[2] >> 4); - int32_t adc_T = ((int32_t)raw[3] << 12) | ((int32_t)raw[4] << 4) | (raw[5] >> 4); + int32_t adc_P = 0, adc_T = 0; + bmp280_adc_from_regs(raw, &adc_P, &adc_T); double t_fine = 0.0; - double temp_c = compensate_temperature(adc_T, &t_fine); - double press_pa = compensate_pressure(adc_P, t_fine); + double temp_c = bmp280_compensate_temperature(&s_calib, adc_T, &t_fine); + double press_pa = bmp280_compensate_pressure(&s_calib, adc_P, t_fine); size_t n = 0; memset(&out[n], 0, sizeof(out[n])); diff --git a/firmware/esp32p4-sensor-node/main/bmp280_compensate.c b/firmware/esp32p4-sensor-node/main/bmp280_compensate.c new file mode 100644 index 0000000..6253d3b --- /dev/null +++ b/firmware/esp32p4-sensor-node/main/bmp280_compensate.c @@ -0,0 +1,66 @@ +// BMP280 compensation maths — pure logic. See bmp280_compensate.h. + +#include "bmp280_compensate.h" + +static int16_t s16(uint8_t lsb, uint8_t msb) { + return (int16_t)((uint16_t)msb << 8 | lsb); +} +static uint16_t u16(uint8_t lsb, uint8_t msb) { + return (uint16_t)((uint16_t)msb << 8 | lsb); +} + +void bmp280_calib_from_regs(const uint8_t buf[BMP280_CALIB_LEN], bmp280_calib_t *out) { + if (buf == NULL || out == NULL) { + return; + } + out->dig_T1 = u16(buf[0], buf[1]); + out->dig_T2 = s16(buf[2], buf[3]); + out->dig_T3 = s16(buf[4], buf[5]); + out->dig_P1 = u16(buf[6], buf[7]); + out->dig_P2 = s16(buf[8], buf[9]); + out->dig_P3 = s16(buf[10], buf[11]); + out->dig_P4 = s16(buf[12], buf[13]); + out->dig_P5 = s16(buf[14], buf[15]); + out->dig_P6 = s16(buf[16], buf[17]); + out->dig_P7 = s16(buf[18], buf[19]); + out->dig_P8 = s16(buf[20], buf[21]); + out->dig_P9 = s16(buf[22], buf[23]); +} + +void bmp280_adc_from_regs(const uint8_t raw[BMP280_RAW_LEN], int32_t *out_adc_P, int32_t *out_adc_T) { + if (raw == NULL || out_adc_P == NULL || out_adc_T == NULL) { + return; + } + *out_adc_P = ((int32_t)raw[0] << 12) | ((int32_t)raw[1] << 4) | (raw[2] >> 4); + *out_adc_T = ((int32_t)raw[3] << 12) | ((int32_t)raw[4] << 4) | (raw[5] >> 4); +} + +// Bosch datasheet 3.11.3 double-precision reference compensation formulas, +// transcribed near-verbatim (variable names kept close to the original so +// it's checkable against the datasheet PDF side-by-side). + +double bmp280_compensate_temperature(const bmp280_calib_t *c, int32_t adc_T, double *out_t_fine) { + double var1 = (((double)adc_T) / 16384.0 - ((double)c->dig_T1) / 1024.0) * ((double)c->dig_T2); + double var2 = ((((double)adc_T) / 131072.0 - ((double)c->dig_T1) / 8192.0) * + (((double)adc_T) / 131072.0 - ((double)c->dig_T1) / 8192.0)) * ((double)c->dig_T3); + *out_t_fine = var1 + var2; + return (var1 + var2) / 5120.0; // degrees C +} + +double bmp280_compensate_pressure(const bmp280_calib_t *c, int32_t adc_P, double t_fine) { + double var1 = (t_fine / 2.0) - 64000.0; + double var2 = var1 * var1 * ((double)c->dig_P6) / 32768.0; + var2 = var2 + var1 * ((double)c->dig_P5) * 2.0; + var2 = (var2 / 4.0) + (((double)c->dig_P4) * 65536.0); + var1 = (((double)c->dig_P3) * var1 * var1 / 524288.0 + ((double)c->dig_P2) * var1) / 524288.0; + var1 = (1.0 + var1 / 32768.0) * ((double)c->dig_P1); + if (var1 == 0.0) { + return 0.0; // avoid divide-by-zero per datasheet's own guard + } + double p = 1048576.0 - (double)adc_P; + p = (p - (var2 / 4096.0)) * 6250.0 / var1; + var1 = ((double)c->dig_P9) * p * p / 2147483648.0; + var2 = p * ((double)c->dig_P8) / 32768.0; + p = p + (var1 + var2 + ((double)c->dig_P7)) / 16.0; + return p; // Pa +} diff --git a/firmware/esp32p4-sensor-node/main/bmp280_compensate.h b/firmware/esp32p4-sensor-node/main/bmp280_compensate.h new file mode 100644 index 0000000..6d422e0 --- /dev/null +++ b/firmware/esp32p4-sensor-node/main/bmp280_compensate.h @@ -0,0 +1,66 @@ +// Bosch BMP280 compensation maths + calibration/ADC decoding — PURE LOGIC. +// +// ESP-IDF-free by design: no I2C, no esp_err_t, no FreeRTOS, no logging. +// Only /, so it compiles and is testable on a host +// with plain gcc (see ../test/). `bmp280.c` does the I2C traffic and calls +// in here for every byte-order decision and every line of arithmetic. +// +// The formulas are Bosch datasheet (rev 1.23) §3.11.3's double-precision +// reference implementation, transcribed near-verbatim, with the variable +// names kept close to the original so it's checkable against the datasheet +// PDF side-by-side. The register map decoded below is §3.11.1. +// +// What host tests can prove here: byte-order/packing of the calibration +// block and the 20-bit ADC words, and that the arithmetic behaves +// sanely and monotonically. What they CANNOT prove: that this exact +// silicon returns the register contents we assume. + +#pragma once + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Calibration block is 24 bytes at 0x88..0x9F (dig_T1..dig_P9). +#define BMP280_CALIB_LEN 24 +// Burst measurement read is 6 bytes from 0xF7: press(3) + temp(3). +#define BMP280_RAW_LEN 6 + +typedef struct { + uint16_t dig_T1; + int16_t dig_T2; + int16_t dig_T3; + uint16_t dig_P1; + int16_t dig_P2; + int16_t dig_P3; + int16_t dig_P4; + int16_t dig_P5; + int16_t dig_P6; + int16_t dig_P7; + int16_t dig_P8; + int16_t dig_P9; +} bmp280_calib_t; + +// Decode the 24-byte calibration block. Each coefficient is stored +// little-endian (LSB first) in the register map. +void bmp280_calib_from_regs(const uint8_t buf[BMP280_CALIB_LEN], bmp280_calib_t *out); + +// Decode the 6-byte burst read into the two 20-bit ADC words. Pressure +// comes first (0xF7..0xF9), then temperature (0xFA..0xFC); each is +// MSB/LSB/XLSB with the XLSB's top nibble carrying the low 4 bits. +void bmp280_adc_from_regs(const uint8_t raw[BMP280_RAW_LEN], int32_t *out_adc_P, int32_t *out_adc_T); + +// Returns degrees C, and writes the shared `t_fine` intermediate that the +// pressure compensation needs. +double bmp280_compensate_temperature(const bmp280_calib_t *c, int32_t adc_T, double *out_t_fine); + +// Returns Pa. Returns exactly 0.0 when the datasheet's own divide-by-zero +// guard trips (var1 == 0, i.e. an all-zero / unread calibration block). +double bmp280_compensate_pressure(const bmp280_calib_t *c, int32_t adc_P, double t_fine); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/esp32p4-sensor-node/main/mems_level.c b/firmware/esp32p4-sensor-node/main/mems_level.c new file mode 100644 index 0000000..bf50e78 --- /dev/null +++ b/firmware/esp32p4-sensor-node/main/mems_level.c @@ -0,0 +1,26 @@ +// MEMS mic level maths — pure logic. See mems_level.h. + +#include "mems_level.h" + +#include + +double mems_level_rms(const int32_t *samples, size_t n_samples) { + if (samples == NULL || n_samples == 0) { + return 0.0; + } + double sum_sq = 0.0; + for (size_t i = 0; i < n_samples; i++) { + double sample = (double)(samples[i] >> 8); + sum_sq += sample * sample; + } + return sqrt(sum_sq / (double)n_samples); +} + +double mems_level_dbfs(double rms) { + if (rms < 1.0) { + return MEMS_DBFS_FLOOR; // effective noise floor + } + double dbfs = 20.0 * log10(rms / MEMS_FULL_SCALE_24BIT); + if (dbfs < MEMS_DBFS_FLOOR) dbfs = MEMS_DBFS_FLOOR; + return dbfs; +} diff --git a/firmware/esp32p4-sensor-node/main/mems_level.h b/firmware/esp32p4-sensor-node/main/mems_level.h new file mode 100644 index 0000000..3183c6d --- /dev/null +++ b/firmware/esp32p4-sensor-node/main/mems_level.h @@ -0,0 +1,47 @@ +// I2S MEMS microphone level maths (RMS -> dBFS) — PURE LOGIC. +// +// ESP-IDF-free by design: no I2S, no esp_err_t, no logging. Only +// / and in the .c, so it compiles and is +// testable on a host with plain gcc (see ../test/). `mems_mic.c` does the +// I2S read and calls in here for the arithmetic. +// +// The INMP441 family outputs 24-bit signed PCM, MSB-first, left-justified +// in a 32-bit I2S slot (Philips/standard I2S timing). The right-shift-by-8 +// used below to recover the 24-bit sample from the 32-bit slot, and the +// dBFS reference level (2^23, a 24-bit signed sample's full-scale +// magnitude), are the commonly-documented values for this exact mic family +// — but "commonly documented" is not "verified against this specific +// board." Host tests prove the arithmetic (full scale reads ~0 dBFS, +// silence reads the floor and never -inf/NaN); they cannot prove the shift +// amount matches this module revision's real bit alignment. + +#pragma once + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// dBFS reference: full-scale magnitude of a 24-bit signed sample. +#define MEMS_FULL_SCALE_24BIT (8388608.0) // 2^23 + +// Level reported for a true-silent (or sub-LSB) input, and the clamp +// applied to anything quieter. +#define MEMS_DBFS_FLOOR (-120.0) + +// RMS over a block of raw 32-bit I2S slots. The mic's 24-bit sample is +// left-justified in the 32-bit slot -- shift right 8 to recover it before +// squaring, so the magnitude lines up with MEMS_FULL_SCALE_24BIT. +// Returns 0.0 for an empty block. +double mems_level_rms(const int32_t *samples, size_t n_samples); + +// dBFS: 20*log10(rms / full_scale). A true-silent input gives rms=0, +// which is -inf in dB -- clamp to a floor rather than emit a value the +// JSON encoder/backend can't handle. +double mems_level_dbfs(double rms); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/esp32p4-sensor-node/main/mems_mic.c b/firmware/esp32p4-sensor-node/main/mems_mic.c index 8936565..22b1f54 100644 --- a/firmware/esp32p4-sensor-node/main/mems_mic.c +++ b/firmware/esp32p4-sensor-node/main/mems_mic.c @@ -2,13 +2,12 @@ // // UNVERIFIED AGAINST REAL HARDWARE. Written against ESP-IDF's documented // `driver/i2s_std.h` API (the current idiomatic I2S driver, superseding the -// older monolithic `driver/i2s.h`) and the INMP441 family's well-documented -// output format: 24-bit signed PCM, MSB-first, left-justified in a 32-bit -// I2S slot (Philips/standard I2S timing). The right-shift-by-8 used below -// to recover the 24-bit sample from the 32-bit slot, and the dBFS -// reference level (2^23, a 24-bit signed sample's full-scale magnitude), -// are the commonly-documented values for this exact mic family — but -// "commonly documented" is not "verified against this specific board," so +// older monolithic `driver/i2s.h`). This file owns only the I2S traffic; +// the RMS -> dBFS maths, the 24-bit-in-32-bit-slot shift and the honesty +// notes about both live in mems_level.h/.c, which is ESP-IDF-free and +// unit-tested on a host with plain gcc (see ../test/). +// +// "Commonly documented" is not "verified against this specific board," so // treat the very first real readings as a sanity check, not a given: talk // near the mic and confirm the reported level actually rises before // trusting it unattended. @@ -18,6 +17,7 @@ #include #include #include "mems_mic.h" +#include "mems_level.h" #include "driver/i2s_std.h" #include "esp_log.h" #include "freertos/FreeRTOS.h" @@ -25,8 +25,7 @@ static const char *TAG = "mems_mic"; -// dBFS reference: full-scale magnitude of a 24-bit signed sample. -#define FULL_SCALE_24BIT (8388608.0) // 2^23 +// dBFS reference level and noise floor live in mems_level.h. static i2s_chan_handle_t s_rx_chan = NULL; static bool s_ready = false; @@ -119,26 +118,9 @@ esp_err_t mems_mic_read(sensor_reading_t *out, size_t max_out, size_t *out_count return ESP_OK; } - // RMS over the block. The mic's 24-bit sample is left-justified in the - // 32-bit I2S slot -- shift right 8 to recover it before squaring, so - // the magnitude lines up with FULL_SCALE_24BIT below. - double sum_sq = 0.0; - for (size_t i = 0; i < n_samples; i++) { - double sample = (double)(s_sample_buf[i] >> 8); - sum_sq += sample * sample; - } - double rms = sqrt(sum_sq / (double)n_samples); - - // dBFS: 20*log10(rms / full_scale). A true-silent input gives rms=0, - // which is -inf in dB -- clamp to a floor rather than emit a value the - // JSON encoder/backend can't handle. - double dbfs; - if (rms < 1.0) { - dbfs = -120.0; // effective noise floor - } else { - dbfs = 20.0 * log10(rms / FULL_SCALE_24BIT); - if (dbfs < -120.0) dbfs = -120.0; - } + // All level arithmetic is in the pure, host-tested unit. + double rms = mems_level_rms(s_sample_buf, n_samples); + double dbfs = mems_level_dbfs(rms); memset(&out[0], 0, sizeof(out[0])); strncpy(out[0].sensor_type, "evp", SENSOR_READING_TYPE_MAXLEN - 1); diff --git a/firmware/esp32p4-sensor-node/main/rd03e.c b/firmware/esp32p4-sensor-node/main/rd03e.c index 422bbd7..d365ade 100644 --- a/firmware/esp32p4-sensor-node/main/rd03e.c +++ b/firmware/esp32p4-sensor-node/main/rd03e.c @@ -1,32 +1,15 @@ // Ai-Thinker RD-03E driver — see rd03e.h for wiring and honesty notes. // -// UNVERIFIED AGAINST REAL HARDWARE, and the frame format below is -// reconstructed from a third-party bring-up write-up (electroniclinic.com's -// RD-03E/ESP32 tutorial), not Ai-Thinker's own datasheet PDF (not available -// while writing this) — treat this as the least-certain protocol detail in -// this driver. What's cross-confirmed from multiple independent sources: -// UART is 256000 baud / 8N1, and the module also has a separate, more -// complex configuration-frame protocol (0xFD 0xFC 0xFB 0xFA header / -// 0x04 0x03 0x02 0x01 footer) for calibration and firmware queries — this -// driver does NOT implement that; it only reads the module's free-running -// "simple report" output frames, which need no configuration to start -// streaming after power-up. -// -// Simple report frame, as reconstructed (6 bytes total): -// [0] 0xAA frame header -// [1] gesture code raw value, meaning not confirmed against an -// official datasheet — reported as-is in -// metadata rather than translated to a label -// that might be wrong -// [2] distance lo byte distance_cm = lo | (hi << 8), little-endian -// [3] distance hi byte -// [4..5] 0x55 0x55 frame footer -// Real bring-up should verify this against a logic analyzer capture before -// trusting field values, same as the LD2410 driver this replaced. +// UNVERIFIED AGAINST REAL HARDWARE. This file owns only the UART I/O; the +// frame format, the frame scanner, and the honesty notes about how that +// format was reconstructed all live in rd03e_parse.h/.c, which is +// ESP-IDF-free so it can be unit-tested on a host with plain gcc (see +// ../test/). Read rd03e_parse.h before trusting any field value from here. #include #include #include "rd03e.h" +#include "rd03e_parse.h" #include "driver/uart.h" #include "esp_log.h" #include "freertos/FreeRTOS.h" @@ -36,18 +19,12 @@ static const char *TAG = "rd03e"; #define RD03E_RX_BUF_SIZE 512 #define RD03E_SCRATCH_SIZE 256 -#define RD03E_FRAME_LEN 6 -static const uint8_t FRAME_HEADER = 0xAA; -static const uint8_t FRAME_FOOTER[2] = { 0x55, 0x55 }; +// Frame layout, frame length and the header/footer constants live in +// rd03e_parse.h — one definition, host-tested. static bool s_ready = false; -typedef struct { - uint8_t gesture; - uint16_t distance_cm; -} rd03e_frame_t; - esp_err_t rd03e_init(void) { uart_config_t cfg = { .baud_rate = RD03E_UART_BAUD, @@ -107,23 +84,9 @@ esp_err_t rd03e_read(sensor_reading_t *out, size_t max_out, size_t *out_count) { return ESP_OK; // nothing new isn't a driver failure } - bool parsed_any = false; rd03e_frame_t latest = {0}; - - // Scan for the newest complete, validated frame in whatever arrived - // this cycle; keep overwriting `latest` so we report the freshest one. - for (int i = 0; i + RD03E_FRAME_LEN <= len; i++) { - if (buf[i] != FRAME_HEADER) { - continue; - } - if (memcmp(&buf[i + 4], FRAME_FOOTER, 2) != 0) { - continue; // not a real header byte, or a corrupted frame - } - latest.gesture = buf[i + 1]; - latest.distance_cm = (uint16_t)buf[i + 2] | ((uint16_t)buf[i + 3] << 8); - parsed_any = true; - i += RD03E_FRAME_LEN - 1; // loop's i++ moves past this frame - } + // All frame-finding/validation is in the pure, host-tested unit. + bool parsed_any = rd03e_parse_latest(buf, (size_t)len, &latest); if (!parsed_any) { ESP_LOGD(TAG, "no complete/valid RD-03E frame in this read window"); diff --git a/firmware/esp32p4-sensor-node/main/rd03e_parse.c b/firmware/esp32p4-sensor-node/main/rd03e_parse.c new file mode 100644 index 0000000..39c3e6d --- /dev/null +++ b/firmware/esp32p4-sensor-node/main/rd03e_parse.c @@ -0,0 +1,43 @@ +// RD-03E frame scanner — pure logic, host-testable. See rd03e_parse.h. + +#include "rd03e_parse.h" + +bool rd03e_parse_latest(const uint8_t *buf, size_t len, rd03e_frame_t *out) { + if (buf == NULL || out == NULL || len < RD03E_FRAME_LEN) { + return false; + } + + bool parsed_any = false; + rd03e_frame_t latest = {0}; + + // Scan for the newest complete, validated frame in whatever arrived + // this cycle; keep overwriting `latest` so we report the freshest one. + // + // The `i + RD03E_FRAME_LEN <= len` bound is what makes a truncated + // trailing frame get ignored rather than read past the buffer: a + // partial frame at the end simply never satisfies the bound. + for (size_t i = 0; i + RD03E_FRAME_LEN <= len; i++) { + if (buf[i] != RD03E_FRAME_HEADER) { + continue; + } + // Footer lives at [4] and [5] of the frame. If this offset only + // *looks* like a header (a 0xAA that is really a distance byte, a + // gesture code, or line noise), the footer check rejects it and + // the scan resynchronises on the next byte. + if (buf[i + 4] != RD03E_FRAME_FOOTER0 || buf[i + 5] != RD03E_FRAME_FOOTER1) { + continue; // not a real header byte, or a corrupted frame + } + latest.gesture = buf[i + 1]; + // Little-endian: low byte first. Getting this backwards, or + // letting the footer bytes bleed into the high byte, is exactly + // the bug this unit exists to make testable. + latest.distance_cm = (uint16_t)((uint16_t)buf[i + 2] | ((uint16_t)buf[i + 3] << 8)); + parsed_any = true; + i += RD03E_FRAME_LEN - 1; // loop's i++ moves past this frame + } + + if (parsed_any) { + *out = latest; + } + return parsed_any; +} diff --git a/firmware/esp32p4-sensor-node/main/rd03e_parse.h b/firmware/esp32p4-sensor-node/main/rd03e_parse.h new file mode 100644 index 0000000..8013c9e --- /dev/null +++ b/firmware/esp32p4-sensor-node/main/rd03e_parse.h @@ -0,0 +1,77 @@ +// Ai-Thinker RD-03E "simple report" frame scanner — PURE LOGIC. +// +// This unit is deliberately free of ESP-IDF: no UART, no esp_err_t, no +// FreeRTOS, no logging. It includes only // +// so it can be compiled and tested on a host with plain gcc (see +// ../test/). `rd03e.c` does the UART I/O and calls in here to do the +// actual parsing. +// +// The reason this split exists: the first version of this firmware had +// RD03E_FRAME_LEN set to 5 for a 6-byte frame, so the footer check read +// the distance high byte instead of the second footer byte and every +// distance reading came back as `lo | 0x5500` (~218 m). That was pure +// logic with zero hardware dependency and should have been catchable on a +// laptop. Now it is. +// +// UNVERIFIED AGAINST REAL HARDWARE, and the frame format below is +// reconstructed from a third-party bring-up write-up (electroniclinic.com's +// RD-03E/ESP32 tutorial), not Ai-Thinker's own datasheet PDF (not available +// while writing this) — treat this as the least-certain protocol detail in +// this driver. What's cross-confirmed from multiple independent sources: +// UART is 256000 baud / 8N1, and the module also has a separate, more +// complex configuration-frame protocol (0xFD 0xFC 0xFB 0xFA header / +// 0x04 0x03 0x02 0x01 footer) for calibration and firmware queries — this +// driver does NOT implement that; it only reads the module's free-running +// "simple report" output frames, which need no configuration to start +// streaming after power-up. +// +// Simple report frame, as reconstructed (6 bytes total): +// [0] 0xAA frame header +// [1] gesture code raw value, meaning not confirmed against an +// official datasheet — reported as-is in +// metadata rather than translated to a label +// that might be wrong +// [2] distance lo byte distance_cm = lo | (hi << 8), little-endian +// [3] distance hi byte +// [4..5] 0x55 0x55 frame footer +// Real bring-up should verify this against a logic analyzer capture before +// trusting field values, same as the LD2410 driver this replaced. +// +// Host tests prove the SHAPE of the parse (byte order, frame length, +// footer validation, resynchronisation) against this reconstructed spec. +// They cannot prove the reconstructed spec is what the silicon emits. + +#pragma once + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Total bytes in one simple-report frame: header + gesture + 2 distance +// bytes + 2 footer bytes. Six, not five — see the note above. +#define RD03E_FRAME_LEN 6 + +#define RD03E_FRAME_HEADER 0xAAu +#define RD03E_FRAME_FOOTER0 0x55u +#define RD03E_FRAME_FOOTER1 0x55u + +typedef struct { + uint8_t gesture; + uint16_t distance_cm; +} rd03e_frame_t; + +// Scan `buf` (`len` bytes) for complete, footer-validated simple-report +// frames and write the NEWEST one (highest offset) to *out. +// +// Returns true if at least one valid frame was found, false otherwise +// (in which case *out is untouched). A NULL buf, a NULL out, or a buffer +// shorter than one frame are all "no frame", not a crash. +bool rd03e_parse_latest(const uint8_t *buf, size_t len, rd03e_frame_t *out); + +#ifdef __cplusplus +} +#endif diff --git a/firmware/esp32p4-sensor-node/test/Makefile b/firmware/esp32p4-sensor-node/test/Makefile new file mode 100644 index 0000000..d434e25 --- /dev/null +++ b/firmware/esp32p4-sensor-node/test/Makefile @@ -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) diff --git a/firmware/esp32p4-sensor-node/test/run_tests.sh b/firmware/esp32p4-sensor-node/test/run_tests.sh new file mode 100755 index 0000000..fccc128 --- /dev/null +++ b/firmware/esp32p4-sensor-node/test/run_tests.sh @@ -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" diff --git a/firmware/esp32p4-sensor-node/test/test_bmp280_compensate.c b/firmware/esp32p4-sensor-node/test/test_bmp280_compensate.c new file mode 100644 index 0000000..bc7e944 --- /dev/null +++ b/firmware/esp32p4-sensor-node/test/test_bmp280_compensate.c @@ -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 + +// 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); + } +} diff --git a/firmware/esp32p4-sensor-node/test/test_main.c b/firmware/esp32p4-sensor-node/test/test_main.c new file mode 100644 index 0000000..bc2bc22 --- /dev/null +++ b/firmware/esp32p4-sensor-node/test/test_main.c @@ -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; +} diff --git a/firmware/esp32p4-sensor-node/test/test_mems_level.c b/firmware/esp32p4-sensor-node/test/test_mems_level.c new file mode 100644 index 0000000..ecd24d8 --- /dev/null +++ b/firmware/esp32p4-sensor-node/test/test_mems_level.c @@ -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 +#include + +#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"); + } +} diff --git a/firmware/esp32p4-sensor-node/test/test_rd03e_parse.c b/firmware/esp32p4-sensor-node/test/test_rd03e_parse.c new file mode 100644 index 0000000..99c62cc --- /dev/null +++ b/firmware/esp32p4-sensor-node/test/test_rd03e_parse.c @@ -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 + +// 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"); + } +} diff --git a/firmware/esp32p4-sensor-node/test/test_util.h b/firmware/esp32p4-sensor-node/test/test_util.h new file mode 100644 index 0000000..e1d22ad --- /dev/null +++ b/firmware/esp32p4-sensor-node/test/test_util.h @@ -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 +#include + +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); diff --git a/run_tests.sh b/run_tests.sh new file mode 100755 index 0000000..bbbf450 --- /dev/null +++ b/run_tests.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Repo-root entry point for the firmware host tests. +# +# These are the tests that need NOTHING but gcc — no ESP-IDF, no Python +# venv, no node_modules, no database. They cover the sensor firmware's pure +# logic (frame parsing, byte order, compensation and level maths) and +# nothing else; the backend and frontend suites are run separately (see +# README.md). +set -euo pipefail +exec "$(dirname "$0")/firmware/esp32p4-sensor-node/test/run_tests.sh" "$@"