test(dev): emulation + linting harness (Wokwi, native tests, CI, docs)
Some checks failed
ci / openapi (pull_request) Failing after 30s
ci / firmware (pull_request) Failing after 35s
ci / server (pull_request) Failing after 28s
ci / emulator (pull_request) Failing after 36s

Answers "how do we emulate/lint this?" with runnable tooling for every part, and
factors the pure firmware logic out so it is unit-testable on the host.

What changed:
- firmware/src/wav.h, firmware/src/httprange.h: pure, header-only WAV header builder
  and HTTP Range parser (no Arduino deps), so the tricky byte-layout and range logic
  can be tested on a PC. storage.cpp and api_http.cpp refactored to use them.
- firmware/test/test_pure/: Unity tests for the WAV header fields and Range parsing
  (start-end, open-ended, clamped, unsatisfiable, non-bytes). Run: pio test -e native.
- firmware/platformio.ini: add [env:native] (host tests) and [env:esp32s3_wokwi]
  (emulator build with default WiFi = Wokwi-GUEST so the API comes up in the sim).
- firmware/wokwi.toml + firmware/diagram.json: Wokwi emulator harness (ESP32-S3 +
  microSD + button + LED). Note: Wokwi has no I2S mic part, so audio isn't emulated;
  the harness targets boot + WiFi + REST API.
- firmware/src/main.cpp + config.cpp: bring up WiFi + API even if audio/SD init fails
  (device stays reachable, reports the fault via GET /device); compile-time default
  WiFi honoured when NVS is empty (used by the Wokwi build).
- .forgejo/workflows/ci.yml: add native tests + cppcheck to the firmware job; new
  openapi job (openapi-spec-validator) and emulator job (Wokwi build, plus a full run
  when WOKWI_CLI_TOKEN is set).
- docs/testing.md: the full emulate/lint guide for firmware, server, API, app, case.
- state/: NOTES points at the guide; TODO reflects this branch.

Why:
- The firmware can't be flashed yet (no parts) and doesn't build on this dev host, so
  we need host-runnable checks. Pure-logic unit tests + OpenAPI validation run anywhere;
  Wokwi emulates boot/WiFi/API; CI compiles the real firmware. Verified locally: the
  OpenAPI spec validates (12 paths, 10 schemas).

Notes:
- Native tests and cppcheck run in CI (no compiler on the dev host). The Wokwi full run
  is skipped unless a WOKWI_CLI_TOKEN secret is present; the build is still verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Laurence 2026-07-03 12:16:25 +01:00
parent 72633f02c3
commit f84bbca80b
14 changed files with 445 additions and 56 deletions

28
firmware/diagram.json Normal file
View file

@ -0,0 +1,28 @@
{
"version": 1,
"author": "OpenScribe",
"editor": "wokwi",
"comment": "ESP32-S3 + microSD + button + LED. Wokwi has no I2S MEMS mic part, so audio capture is not emulated; this harness exercises boot, WiFi and the REST API (and SD where supported). See ../docs/testing.md.",
"parts": [
{ "type": "board-esp32-s3-devkitc-1", "id": "esp", "top": 0, "left": 0, "attrs": {} },
{ "type": "wokwi-microsd-card", "id": "sd1", "top": -120, "left": 150, "attrs": {} },
{ "type": "wokwi-pushbutton", "id": "btn1", "top": 120, "left": 150, "attrs": { "color": "green" } },
{ "type": "wokwi-led", "id": "led1", "top": 120, "left": -100, "attrs": { "color": "blue" } },
{ "type": "wokwi-resistor", "id": "r1", "top": 120, "left": -180, "attrs": { "value": "330" } }
],
"connections": [
[ "esp:GND.1", "sd1:GND", "black", [] ],
[ "esp:3V3.1", "sd1:VCC", "red", [] ],
[ "esp:12", "sd1:CLK", "yellow", [] ],
[ "esp:11", "sd1:DI", "green", [] ],
[ "esp:13", "sd1:DO", "blue", [] ],
[ "esp:10", "sd1:CS", "orange", [] ],
[ "esp:7", "btn1:1.l", "purple", [] ],
[ "btn1:2.l", "esp:GND.2", "black", [] ],
[ "esp:48", "r1:1", "blue", [] ],
[ "r1:2", "led1:A", "blue", [] ],
[ "led1:C", "esp:GND.3", "black", [] ]
]
}

View file

@ -34,4 +34,21 @@ build_flags =
board = seeed_xiao_esp32s3
build_flags =
${env.build_flags}
-DOPENSCRIBE_BOARD_XIAO=1
-DOPENSCRIBE_BOARD_XIAO=1
; --- Emulator build: ESP32-S3 in Wokwi, with default WiFi = Wokwi-GUEST (open). ---
; Build then run the emulator: pio run -e esp32s3_wokwi (see ../docs/testing.md)
[env:esp32s3_wokwi]
extends = env:esp32s3_dev
build_flags =
${env:esp32s3_dev.build_flags}
-DOSC_DEFAULT_WIFI_SSID="\"Wokwi-GUEST\""
-DOSC_DEFAULT_WIFI_PSK="\"\""
; --- Host unit tests for the pure logic (wav.h, httprange.h). No hardware needed. ---
; Run: pio test -e native (see ../docs/testing.md)
[env:native]
platform = native
test_framework = unity
build_src_filter = -<*> ; exclude Arduino sources; tests include the pure headers
build_flags = -std=gnu++17 -I src

View file

@ -6,6 +6,7 @@
#include <WebServer.h>
#include "config.h"
#include "httprange.h" // pure, host-testable Range parser
#include "net_wifi.h"
#include "recorder.h"
#include "storage.h"
@ -90,26 +91,18 @@ void handleAudio() {
const size_t total = f.size();
server.sendHeader("Accept-Ranges", "bytes");
size_t start = 0, end = (total ? total - 1 : 0);
bool partial = false;
String range = server.header("Range");
if (range.startsWith("bytes=")) {
partial = true;
range = range.substring(6);
int dash = range.indexOf('-');
start = range.substring(0, dash).toInt();
String es = range.substring(dash + 1);
if (es.length()) end = es.toInt();
}
if (start >= total) {
String rangeHdr = server.header("Range");
oshttp::RangeResult rr = oshttp::parseByteRange(rangeHdr.c_str(), total);
if (!rr.satisfiable) {
f.close();
return sendError(416, "Range not satisfiable");
}
if (end >= total) end = total - 1;
const size_t start = rr.start;
const size_t end = rr.end;
const size_t len = end - start + 1;
f.seek(start);
if (partial) {
if (rr.partial) {
server.sendHeader("Content-Range",
"bytes " + String(start) + "-" + String(end) + "/" + String(total));
server.setContentLength(len);

View file

@ -27,8 +27,25 @@ bool begin() {
String deviceId() { return deviceId_; }
String wifiSsid() { return prefs.getString("wifi_ssid", ""); }
String wifiPsk() { return prefs.getString("wifi_psk", ""); }
String wifiSsid() {
String s = prefs.getString("wifi_ssid", "");
#ifdef OSC_DEFAULT_WIFI_SSID
// Compile-time fallback (used by the Wokwi emulator build); NVS still wins.
if (s.isEmpty()) return OSC_DEFAULT_WIFI_SSID;
#endif
return s;
}
String wifiPsk() {
const String storedSsid = prefs.getString("wifi_ssid", "");
if (storedSsid.isEmpty()) {
#ifdef OSC_DEFAULT_WIFI_PSK
return OSC_DEFAULT_WIFI_PSK;
#else
return "";
#endif
}
return prefs.getString("wifi_psk", "");
}
void setWifi(const String &ssid, const String &psk) {
prefs.putString("wifi_ssid", ssid);
prefs.putString("wifi_psk", psk);

49
firmware/src/httprange.h Normal file
View file

@ -0,0 +1,49 @@
// SPDX-License-Identifier: GPL-3.0-only
// Pure, hardware-independent HTTP Range header parser. Header-only so it compiles both on
// the ESP32 and on the host for native unit tests (see firmware/test, docs/testing.md).
#pragma once
#include <stddef.h>
namespace oshttp {
struct RangeResult {
bool partial; // a "bytes=" range was requested
bool satisfiable; // start is within the file
size_t start; // inclusive
size_t end; // inclusive
};
// Parse a Range header value like "bytes=START-END" (END optional) against a known total
// size. If header is null/empty or not a bytes range, returns partial=false (send full
// body). A start past the end sets satisfiable=false (caller sends 416).
inline RangeResult parseByteRange(const char *header, size_t total) {
RangeResult r{false, true, 0, total ? total - 1 : 0};
if (!header) return r;
const char *prefix = "bytes=";
for (size_t i = 0; prefix[i]; ++i) {
if (header[i] != prefix[i]) return r; // not a bytes range -> full body
}
r.partial = true;
const char *s = header + 6;
size_t start = 0;
while (*s >= '0' && *s <= '9') { start = start * 10 + (*s - '0'); ++s; }
if (*s != '-') { r.partial = false; return r; } // malformed -> full body
++s;
size_t end = total ? total - 1 : 0;
bool anyEnd = false;
size_t ev = 0;
while (*s >= '0' && *s <= '9') { ev = ev * 10 + (*s - '0'); ++s; anyEnd = true; }
if (anyEnd) end = ev;
if (start >= total) { r.satisfiable = false; r.start = start; r.end = end; return r; }
if (end >= total) end = total ? total - 1 : 0;
r.start = start;
r.end = end;
return r;
}
} // namespace oshttp

View file

@ -42,13 +42,15 @@ void setup() {
ux::begin();
config::begin();
if (!recorder::begin()) {
// Recording is best-effort: if audio/SD init fails we still bring up WiFi + the API so
// the device stays reachable and can report the fault via GET /api/v1/device.
const bool recOk = recorder::begin();
if (!recOk) {
Serial.println("ERROR: audio or SD init failed - check the mic wiring and SD card");
ux::setLed(ux::Led::Error);
return; // stay in error state; loop() still services the LED
}
// Bring up networking + the REST API. Non-fatal: recording still works offline.
// Networking + REST API.
net_wifi::begin();
api_http::begin();
if (net_wifi::isAccessPoint()) {
@ -60,8 +62,10 @@ void setup() {
net_wifi::ip().c_str());
}
Serial.println("Ready. Short-press the button to start/stop recording.");
ux::setLed(ux::Led::Idle);
if (recOk) {
Serial.println("Ready. Short-press the button to start/stop recording.");
ux::setLed(ux::Led::Idle);
}
}
void loop() {

View file

@ -5,37 +5,13 @@
#include "audio_config.h"
#include "pins.h"
#include "wav.h" // pure, host-testable header builder
namespace {
// A dedicated SPI bus for the SD card so it does not clash with other peripherals.
SPIClass sdSpi(HSPI);
// Write a 44-byte canonical PCM WAV header. dataBytes may be 0 at open (patched on close).
void writeWavHeader(File &f, uint32_t sampleRate, uint16_t bits, uint16_t channels,
uint32_t dataBytes) {
const uint32_t byteRate = sampleRate * channels * (bits / 8);
const uint16_t blockAlign = channels * (bits / 8);
const uint32_t riffSize = 36 + dataBytes;
auto u32 = [&](uint32_t v) { f.write(reinterpret_cast<uint8_t *>(&v), 4); };
auto u16 = [&](uint16_t v) { f.write(reinterpret_cast<uint8_t *>(&v), 2); };
f.write(reinterpret_cast<const uint8_t *>("RIFF"), 4);
u32(riffSize);
f.write(reinterpret_cast<const uint8_t *>("WAVE"), 4);
f.write(reinterpret_cast<const uint8_t *>("fmt "), 4);
u32(16); // PCM fmt chunk size
u16(1); // audio format = PCM
u16(channels);
u32(sampleRate);
u32(byteRate);
u16(blockAlign);
u16(bits);
f.write(reinterpret_cast<const uint8_t *>("data"), 4);
u32(dataBytes);
}
} // namespace
namespace storage {
@ -62,7 +38,9 @@ bool WavWriter::open(const String &path, uint32_t sampleRate, uint16_t bitsPerSa
bits_ = bitsPerSample;
channels_ = channels;
dataBytes_ = 0;
writeWavHeader(file_, sampleRate_, bits_, channels_, 0); // placeholder sizes
uint8_t header[44];
oswav::buildHeader(header, sampleRate_, bits_, channels_, 0); // placeholder sizes
file_.write(header, 44);
return true;
}
@ -75,13 +53,12 @@ size_t WavWriter::write(const uint8_t *data, size_t len) {
bool WavWriter::close() {
if (!file_) return false;
// Patch RIFF size (offset 4) and data size (offset 40) now that we know the length.
// Rewrite the 44-byte header with the final sizes now that we know the length.
file_.flush();
file_.seek(4);
uint32_t riffSize = 36 + dataBytes_;
file_.write(reinterpret_cast<uint8_t *>(&riffSize), 4);
file_.seek(40);
file_.write(reinterpret_cast<uint8_t *>(&dataBytes_), 4);
uint8_t header[44];
oswav::buildHeader(header, sampleRate_, bits_, channels_, dataBytes_);
file_.seek(0);
file_.write(header, 44);
file_.close();
return true;
}

32
firmware/src/wav.h Normal file
View file

@ -0,0 +1,32 @@
// SPDX-License-Identifier: GPL-3.0-only
// Pure, hardware-independent WAV header builder. Header-only so it compiles both on the
// ESP32 and on the host for native unit tests (see firmware/test, docs/testing.md).
#pragma once
#include <stdint.h>
#include <string.h>
namespace oswav {
// Write a 44-byte canonical PCM WAV header into out (little-endian). dataBytes may be 0 at
// open time and the header rewritten on close once the length is known.
inline void buildHeader(uint8_t out[44], uint32_t sampleRate, uint16_t bits,
uint16_t channels, uint32_t dataBytes) {
const uint32_t byteRate = sampleRate * channels * (bits / 8);
const uint16_t blockAlign = channels * (bits / 8);
const uint32_t riffSize = 36 + dataBytes;
uint8_t *p = out;
auto w32 = [&](uint32_t v) {
p[0] = v; p[1] = v >> 8; p[2] = v >> 16; p[3] = v >> 24; p += 4;
};
auto w16 = [&](uint16_t v) { p[0] = v; p[1] = v >> 8; p += 2; };
auto tag = [&](const char *s) { memcpy(p, s, 4); p += 4; };
tag("RIFF"); w32(riffSize); tag("WAVE");
tag("fmt "); w32(16); w16(1); w16(channels);
w32(sampleRate); w32(byteRate); w16(blockAlign); w16(bits);
tag("data"); w32(dataBytes);
}
} // namespace oswav

View file

@ -0,0 +1,88 @@
// SPDX-License-Identifier: GPL-3.0-only
// Host-side unit tests for the pure firmware logic (no hardware needed).
// Run with: pio test -e native (see docs/testing.md)
#include <unity.h>
#include "httprange.h"
#include "wav.h"
static uint32_t rd32(const uint8_t *p) {
return p[0] | (p[1] << 8) | (p[2] << 16) | ((uint32_t)p[3] << 24);
}
static uint16_t rd16(const uint8_t *p) { return p[0] | (p[1] << 8); }
// --- WAV header ---
void test_wav_header_fields() {
uint8_t h[44];
oswav::buildHeader(h, 16000, 16, 1, 32000); // 1s of 16k mono 16-bit
TEST_ASSERT_EQUAL_UINT8_ARRAY("RIFF", h, 4);
TEST_ASSERT_EQUAL_UINT8_ARRAY("WAVE", h + 8, 4);
TEST_ASSERT_EQUAL_UINT8_ARRAY("fmt ", h + 12, 4);
TEST_ASSERT_EQUAL_UINT8_ARRAY("data", h + 36, 4);
TEST_ASSERT_EQUAL_UINT32(36 + 32000, rd32(h + 4)); // RIFF size
TEST_ASSERT_EQUAL_UINT32(16, rd32(h + 16)); // fmt chunk size
TEST_ASSERT_EQUAL_UINT16(1, rd16(h + 20)); // PCM
TEST_ASSERT_EQUAL_UINT16(1, rd16(h + 22)); // channels
TEST_ASSERT_EQUAL_UINT32(16000, rd32(h + 24)); // sample rate
TEST_ASSERT_EQUAL_UINT32(32000, rd32(h + 28)); // byte rate = 16000*1*2
TEST_ASSERT_EQUAL_UINT16(2, rd16(h + 32)); // block align
TEST_ASSERT_EQUAL_UINT16(16, rd16(h + 34)); // bits
TEST_ASSERT_EQUAL_UINT32(32000, rd32(h + 40)); // data size
}
// --- HTTP Range ---
void test_range_none() {
auto r = oshttp::parseByteRange(nullptr, 1000);
TEST_ASSERT_FALSE(r.partial);
TEST_ASSERT_TRUE(r.satisfiable);
TEST_ASSERT_EQUAL_size_t(0, r.start);
TEST_ASSERT_EQUAL_size_t(999, r.end);
}
void test_range_start_end() {
auto r = oshttp::parseByteRange("bytes=0-1023", 10000);
TEST_ASSERT_TRUE(r.partial);
TEST_ASSERT_TRUE(r.satisfiable);
TEST_ASSERT_EQUAL_size_t(0, r.start);
TEST_ASSERT_EQUAL_size_t(1023, r.end);
}
void test_range_open_ended() {
auto r = oshttp::parseByteRange("bytes=500-", 1000);
TEST_ASSERT_TRUE(r.partial);
TEST_ASSERT_TRUE(r.satisfiable);
TEST_ASSERT_EQUAL_size_t(500, r.start);
TEST_ASSERT_EQUAL_size_t(999, r.end); // clamped to last byte
}
void test_range_end_past_total_clamped() {
auto r = oshttp::parseByteRange("bytes=0-99999", 1000);
TEST_ASSERT_EQUAL_size_t(999, r.end);
}
void test_range_unsatisfiable() {
auto r = oshttp::parseByteRange("bytes=5000-6000", 1000);
TEST_ASSERT_TRUE(r.partial);
TEST_ASSERT_FALSE(r.satisfiable);
}
void test_range_not_bytes() {
auto r = oshttp::parseByteRange("items=0-1", 1000);
TEST_ASSERT_FALSE(r.partial); // unknown unit -> full body
}
int main(int, char **) {
UNITY_BEGIN();
RUN_TEST(test_wav_header_fields);
RUN_TEST(test_range_none);
RUN_TEST(test_range_start_end);
RUN_TEST(test_range_open_ended);
RUN_TEST(test_range_end_past_total_clamped);
RUN_TEST(test_range_unsatisfiable);
RUN_TEST(test_range_not_bytes);
return UNITY_END();
}

9
firmware/wokwi.toml Normal file
View file

@ -0,0 +1,9 @@
# Wokwi emulator config. Build the emulator firmware first:
# pio run -e esp32s3_wokwi
# then run headless with the Wokwi CLI (needs a free WOKWI_CLI_TOKEN):
# wokwi-cli --timeout 20000 --scenario ... firmware (see ../docs/testing.md)
# or open this folder with the Wokwi VS Code extension and press play.
[wokwi]
version = 1
firmware = '.pio/build/esp32s3_wokwi/firmware.bin'
elf = '.pio/build/esp32s3_wokwi/firmware.elf'