test(dev): emulation + linting harness (Wokwi, native tests, CI, docs)
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:
parent
72633f02c3
commit
f84bbca80b
14 changed files with 445 additions and 56 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
49
firmware/src/httprange.h
Normal 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
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
32
firmware/src/wav.h
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue