Dev testing + emulation harness #3
14 changed files with 445 additions and 56 deletions
|
|
@ -21,6 +21,48 @@ jobs:
|
|||
run: |
|
||||
pio run -e esp32s3_dev
|
||||
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:
|
||||
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
|
||||
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 "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
|
||||
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
|
||||
|
||||
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):
|
||||
|
||||
- Firmware (`firmware/`): PlatformIO.
|
||||
|
|
|
|||
|
|
@ -5,8 +5,7 @@
|
|||
|
||||
## In progress
|
||||
|
||||
- [ ] M2 On-device WiFi + REST API - code complete on `feature/fw-rest-api`, in PR.
|
||||
Not compiled locally / hardware-verified; CI builds both profiles.
|
||||
- [ ] Dev testing + emulation harness - on `feature/dev-testing`, in PR.
|
||||
|
||||
## Pending (roughly in build order)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue