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
|
|
@ -21,6 +21,48 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
pio run -e esp32s3_dev
|
pio run -e esp32s3_dev
|
||||||
pio run -e xiao_esp32s3
|
pio run -e xiao_esp32s3
|
||||||
|
- name: Native unit tests (pure logic)
|
||||||
|
working-directory: firmware
|
||||||
|
run: pio test -e native
|
||||||
|
- name: Static analysis (cppcheck)
|
||||||
|
working-directory: firmware
|
||||||
|
run: pio check -e esp32s3_dev --fail-on-defect high --skip-packages
|
||||||
|
|
||||||
|
openapi:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
- name: Validate OpenAPI spec
|
||||||
|
run: |
|
||||||
|
pip install openapi-spec-validator pyyaml
|
||||||
|
python -c "from openapi_spec_validator import validate; import yaml; validate(yaml.safe_load(open('api/openapi.yaml'))); print('OpenAPI valid')"
|
||||||
|
|
||||||
|
emulator:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
- name: Build Wokwi firmware
|
||||||
|
working-directory: firmware
|
||||||
|
run: |
|
||||||
|
pip install platformio
|
||||||
|
pio run -e esp32s3_wokwi
|
||||||
|
- name: Run in Wokwi (skipped without a token)
|
||||||
|
working-directory: firmware
|
||||||
|
env:
|
||||||
|
WOKWI_CLI_TOKEN: ${{ secrets.WOKWI_CLI_TOKEN }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$WOKWI_CLI_TOKEN" ]; then
|
||||||
|
echo "No WOKWI_CLI_TOKEN secret set - skipping the emulator run (build still verified)."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
curl -L https://wokwi.com/ci/install.sh | sh
|
||||||
|
~/.wokwi/wokwi-cli --timeout 15000 --expect-text "API at http" .
|
||||||
|
|
||||||
server:
|
server:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
|
||||||
132
docs/testing.md
Normal file
132
docs/testing.md
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
<!-- SPDX-License-Identifier: CC-BY-SA-4.0 -->
|
||||||
|
# Emulating and linting OpenScribe
|
||||||
|
|
||||||
|
How to check each part of the project without (and with) the physical device. Everything
|
||||||
|
here also runs in CI (`.forgejo/workflows/ci.yml`).
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
| Part | Lint / static check | Compile / build | Emulate / run |
|
||||||
|
|------|--------------------|------------------|----------------|
|
||||||
|
| Firmware | `pio check` (cppcheck) | `pio run -e esp32s3_dev` | Wokwi (`pio run -e esp32s3_wokwi` + wokwi-cli) |
|
||||||
|
| Firmware logic | - | `pio test -e native` | native unit tests (host) |
|
||||||
|
| Server | `ruff check app` | - | `uvicorn app.main:app` + hit `/docs` |
|
||||||
|
| API spec | `openapi-spec-validator` | - | Swagger UI at `/docs` |
|
||||||
|
| App | `flutter analyze` | `flutter build` | `flutter run` / emulator |
|
||||||
|
| Case | - | `openscad -o out.stl ...` | OpenSCAD preview (F5) |
|
||||||
|
|
||||||
|
## Firmware (ESP32-S3)
|
||||||
|
|
||||||
|
You do not need the board to catch most problems.
|
||||||
|
|
||||||
|
### 1. Compile-check (the strongest lint for C++)
|
||||||
|
|
||||||
|
Install PlatformIO once (`pip install platformio`), then from `firmware/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pio run -e esp32s3_dev # dev board profile
|
||||||
|
pio run -e xiao_esp32s3 # compact profile
|
||||||
|
```
|
||||||
|
|
||||||
|
A clean build type-checks every file and every library call. This is what CI does.
|
||||||
|
|
||||||
|
### 2. Static analysis
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pio check -e esp32s3_dev --fail-on-defect high --skip-packages
|
||||||
|
```
|
||||||
|
|
||||||
|
Runs cppcheck across the sources. Add clang-tidy locally with `--tool clangtidy` if
|
||||||
|
installed.
|
||||||
|
|
||||||
|
### 3. Native unit tests (no hardware, fast)
|
||||||
|
|
||||||
|
The hardware-independent logic is factored into header-only files (`src/wav.h`,
|
||||||
|
`src/httprange.h`) so it compiles and runs on your PC:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pio test -e native
|
||||||
|
```
|
||||||
|
|
||||||
|
This builds and runs `test/test_pure/` against desktop GCC and checks the WAV header
|
||||||
|
byte layout and the HTTP Range parser. No board, no toolchain download for Xtensa.
|
||||||
|
|
||||||
|
### 4. Emulate the running firmware with Wokwi
|
||||||
|
|
||||||
|
[Wokwi](https://wokwi.com) simulates the ESP32-S3 including virtual WiFi, so you can boot
|
||||||
|
the firmware, watch the serial log, and reach the REST API without hardware.
|
||||||
|
|
||||||
|
Build the emulator profile (its WiFi defaults to the open `Wokwi-GUEST` network so the API
|
||||||
|
comes up):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pio run -e esp32s3_wokwi
|
||||||
|
```
|
||||||
|
|
||||||
|
Then either:
|
||||||
|
|
||||||
|
- **VS Code:** install the "Wokwi Simulator" extension, open `firmware/`, press play. It
|
||||||
|
reads `wokwi.toml` + `diagram.json`.
|
||||||
|
- **CLI / CI:** with a free `WOKWI_CLI_TOKEN`,
|
||||||
|
`wokwi-cli --timeout 15000 --expect-text "API at http" firmware`.
|
||||||
|
|
||||||
|
Wokwi limitations to know: there is no I2S MEMS microphone part, so audio capture is not
|
||||||
|
emulated (the mic reads nothing); microSD support is basic. The harness is for boot, WiFi,
|
||||||
|
and the REST API surface, which is exactly what the emulator profile targets. Because the
|
||||||
|
firmware brings the API up even when audio/SD init fails, `GET /api/v1/device` is reachable
|
||||||
|
in the sim and reports the fault.
|
||||||
|
|
||||||
|
## Server (FastAPI)
|
||||||
|
|
||||||
|
From `server/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv .venv && . .venv/Scripts/activate # or .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
ruff check app # lint
|
||||||
|
uvicorn app.main:app --reload # run; browse http://localhost:8000/docs
|
||||||
|
pytest # tests (added as endpoints gain logic in M5+)
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /health` and the stubbed recording routes are live now; the AI pipeline arrives in
|
||||||
|
M5/M6.
|
||||||
|
|
||||||
|
## API spec (OpenAPI)
|
||||||
|
|
||||||
|
`api/openapi.yaml` is the contract both the device and server implement. Validate it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install openapi-spec-validator pyyaml
|
||||||
|
python -c "from openapi_spec_validator import validate; import yaml; \
|
||||||
|
validate(yaml.safe_load(open('api/openapi.yaml'))); print('OpenAPI valid')"
|
||||||
|
```
|
||||||
|
|
||||||
|
Browse it interactively by running the server (`/docs`) or pasting the file into
|
||||||
|
https://editor.swagger.io.
|
||||||
|
|
||||||
|
## App (Flutter, from M7)
|
||||||
|
|
||||||
|
Once the app project exists:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd app
|
||||||
|
flutter pub get
|
||||||
|
flutter analyze # lint + type-check
|
||||||
|
flutter test # widget/unit tests
|
||||||
|
flutter run # on an Android/iOS emulator or device
|
||||||
|
```
|
||||||
|
|
||||||
|
## Case (OpenSCAD)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openscad -o case_top.stl -D 'part="top"' case/openscribe_case.scad
|
||||||
|
```
|
||||||
|
|
||||||
|
`openscad --check-parameters` and a headless render catch syntax/geometry errors; the GUI
|
||||||
|
preview (F5) is the visual check.
|
||||||
|
|
||||||
|
## What CI runs
|
||||||
|
|
||||||
|
`.forgejo/workflows/ci.yml` on the self-hosted runner: firmware build (both profiles) +
|
||||||
|
native tests + cppcheck; OpenAPI validation; a Wokwi build (and a full emulator run if
|
||||||
|
`WOKWI_CLI_TOKEN` is set); server lint + import check.
|
||||||
28
firmware/diagram.json
Normal file
28
firmware/diagram.json
Normal 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", [] ]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -34,4 +34,21 @@ build_flags =
|
||||||
board = seeed_xiao_esp32s3
|
board = seeed_xiao_esp32s3
|
||||||
build_flags =
|
build_flags =
|
||||||
${env.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
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
#include <WebServer.h>
|
#include <WebServer.h>
|
||||||
|
|
||||||
#include "config.h"
|
#include "config.h"
|
||||||
|
#include "httprange.h" // pure, host-testable Range parser
|
||||||
#include "net_wifi.h"
|
#include "net_wifi.h"
|
||||||
#include "recorder.h"
|
#include "recorder.h"
|
||||||
#include "storage.h"
|
#include "storage.h"
|
||||||
|
|
@ -90,26 +91,18 @@ void handleAudio() {
|
||||||
const size_t total = f.size();
|
const size_t total = f.size();
|
||||||
server.sendHeader("Accept-Ranges", "bytes");
|
server.sendHeader("Accept-Ranges", "bytes");
|
||||||
|
|
||||||
size_t start = 0, end = (total ? total - 1 : 0);
|
String rangeHdr = server.header("Range");
|
||||||
bool partial = false;
|
oshttp::RangeResult rr = oshttp::parseByteRange(rangeHdr.c_str(), total);
|
||||||
String range = server.header("Range");
|
if (!rr.satisfiable) {
|
||||||
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) {
|
|
||||||
f.close();
|
f.close();
|
||||||
return sendError(416, "Range not satisfiable");
|
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;
|
const size_t len = end - start + 1;
|
||||||
f.seek(start);
|
f.seek(start);
|
||||||
|
|
||||||
if (partial) {
|
if (rr.partial) {
|
||||||
server.sendHeader("Content-Range",
|
server.sendHeader("Content-Range",
|
||||||
"bytes " + String(start) + "-" + String(end) + "/" + String(total));
|
"bytes " + String(start) + "-" + String(end) + "/" + String(total));
|
||||||
server.setContentLength(len);
|
server.setContentLength(len);
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,25 @@ bool begin() {
|
||||||
|
|
||||||
String deviceId() { return deviceId_; }
|
String deviceId() { return deviceId_; }
|
||||||
|
|
||||||
String wifiSsid() { return prefs.getString("wifi_ssid", ""); }
|
String wifiSsid() {
|
||||||
String wifiPsk() { return prefs.getString("wifi_psk", ""); }
|
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) {
|
void setWifi(const String &ssid, const String &psk) {
|
||||||
prefs.putString("wifi_ssid", ssid);
|
prefs.putString("wifi_ssid", ssid);
|
||||||
prefs.putString("wifi_psk", psk);
|
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();
|
ux::begin();
|
||||||
config::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");
|
Serial.println("ERROR: audio or SD init failed - check the mic wiring and SD card");
|
||||||
ux::setLed(ux::Led::Error);
|
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();
|
net_wifi::begin();
|
||||||
api_http::begin();
|
api_http::begin();
|
||||||
if (net_wifi::isAccessPoint()) {
|
if (net_wifi::isAccessPoint()) {
|
||||||
|
|
@ -60,8 +62,10 @@ void setup() {
|
||||||
net_wifi::ip().c_str());
|
net_wifi::ip().c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
Serial.println("Ready. Short-press the button to start/stop recording.");
|
if (recOk) {
|
||||||
ux::setLed(ux::Led::Idle);
|
Serial.println("Ready. Short-press the button to start/stop recording.");
|
||||||
|
ux::setLed(ux::Led::Idle);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void loop() {
|
void loop() {
|
||||||
|
|
|
||||||
|
|
@ -5,37 +5,13 @@
|
||||||
|
|
||||||
#include "audio_config.h"
|
#include "audio_config.h"
|
||||||
#include "pins.h"
|
#include "pins.h"
|
||||||
|
#include "wav.h" // pure, host-testable header builder
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
// A dedicated SPI bus for the SD card so it does not clash with other peripherals.
|
// A dedicated SPI bus for the SD card so it does not clash with other peripherals.
|
||||||
SPIClass sdSpi(HSPI);
|
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
|
||||||
|
|
||||||
namespace storage {
|
namespace storage {
|
||||||
|
|
@ -62,7 +38,9 @@ bool WavWriter::open(const String &path, uint32_t sampleRate, uint16_t bitsPerSa
|
||||||
bits_ = bitsPerSample;
|
bits_ = bitsPerSample;
|
||||||
channels_ = channels;
|
channels_ = channels;
|
||||||
dataBytes_ = 0;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,13 +53,12 @@ size_t WavWriter::write(const uint8_t *data, size_t len) {
|
||||||
|
|
||||||
bool WavWriter::close() {
|
bool WavWriter::close() {
|
||||||
if (!file_) return false;
|
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_.flush();
|
||||||
file_.seek(4);
|
uint8_t header[44];
|
||||||
uint32_t riffSize = 36 + dataBytes_;
|
oswav::buildHeader(header, sampleRate_, bits_, channels_, dataBytes_);
|
||||||
file_.write(reinterpret_cast<uint8_t *>(&riffSize), 4);
|
file_.seek(0);
|
||||||
file_.seek(40);
|
file_.write(header, 44);
|
||||||
file_.write(reinterpret_cast<uint8_t *>(&dataBytes_), 4);
|
|
||||||
file_.close();
|
file_.close();
|
||||||
return true;
|
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
|
||||||
88
firmware/test/test_pure/test_pure.cpp
Normal file
88
firmware/test/test_pure/test_pure.cpp
Normal 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
9
firmware/wokwi.toml
Normal 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'
|
||||||
|
|
@ -5,6 +5,8 @@
|
||||||
|
|
||||||
## How to run / build / test
|
## How to run / build / test
|
||||||
|
|
||||||
|
See [`docs/testing.md`](../docs/testing.md) for the full emulate/lint guide (firmware
|
||||||
|
compile-check, `pio check`, native unit tests, Wokwi emulation, server/OpenAPI/app/case).
|
||||||
Per component (see each component's README for detail):
|
Per component (see each component's README for detail):
|
||||||
|
|
||||||
- Firmware (`firmware/`): PlatformIO.
|
- Firmware (`firmware/`): PlatformIO.
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,7 @@
|
||||||
|
|
||||||
## In progress
|
## In progress
|
||||||
|
|
||||||
- [ ] M2 On-device WiFi + REST API - code complete on `feature/fw-rest-api`, in PR.
|
- [ ] Dev testing + emulation harness - on `feature/dev-testing`, in PR.
|
||||||
Not compiled locally / hardware-verified; CI builds both profiles.
|
|
||||||
|
|
||||||
## Pending (roughly in build order)
|
## Pending (roughly in build order)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue