Compare commits

..

2 commits

Author SHA1 Message Date
dc9585a087 Merge pull request 'Dev testing + emulation harness' (#3) from feature/dev-testing into main
Some checks are pending
ci / server (push) Waiting to run
ci / firmware (push) Waiting to run
ci / openapi (push) Waiting to run
ci / emulator (push) Waiting to run
2026-07-03 12:16:45 +01:00
f84bbca80b 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>
2026-07-03 12:16:25 +01:00
14 changed files with 445 additions and 56 deletions

View file

@ -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
View 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
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

@ -35,3 +35,20 @@ board = seeed_xiao_esp32s3
build_flags =
${env.build_flags}
-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());
}
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'

View file

@ -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.

View file

@ -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)