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

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