diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 316acad..954d632 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -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 diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..3d14219 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,132 @@ + +# 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. diff --git a/firmware/diagram.json b/firmware/diagram.json new file mode 100644 index 0000000..59ae263 --- /dev/null +++ b/firmware/diagram.json @@ -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", [] ] + ] +} diff --git a/firmware/platformio.ini b/firmware/platformio.ini index 879d1a4..21f8e02 100644 --- a/firmware/platformio.ini +++ b/firmware/platformio.ini @@ -34,4 +34,21 @@ build_flags = board = seeed_xiao_esp32s3 build_flags = ${env.build_flags} - -DOPENSCRIBE_BOARD_XIAO=1 \ No newline at end of file + -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 \ No newline at end of file diff --git a/firmware/src/api_http.cpp b/firmware/src/api_http.cpp index e740733..b824916 100644 --- a/firmware/src/api_http.cpp +++ b/firmware/src/api_http.cpp @@ -6,6 +6,7 @@ #include #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); diff --git a/firmware/src/config.cpp b/firmware/src/config.cpp index 6882a13..f6d77d0 100644 --- a/firmware/src/config.cpp +++ b/firmware/src/config.cpp @@ -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); diff --git a/firmware/src/httprange.h b/firmware/src/httprange.h new file mode 100644 index 0000000..29286ff --- /dev/null +++ b/firmware/src/httprange.h @@ -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 + +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 diff --git a/firmware/src/main.cpp b/firmware/src/main.cpp index ea5fd4f..5f69680 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -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() { diff --git a/firmware/src/storage.cpp b/firmware/src/storage.cpp index e58f0de..100c1c5 100644 --- a/firmware/src/storage.cpp +++ b/firmware/src/storage.cpp @@ -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(&v), 4); }; - auto u16 = [&](uint16_t v) { f.write(reinterpret_cast(&v), 2); }; - - f.write(reinterpret_cast("RIFF"), 4); - u32(riffSize); - f.write(reinterpret_cast("WAVE"), 4); - f.write(reinterpret_cast("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("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(&riffSize), 4); - file_.seek(40); - file_.write(reinterpret_cast(&dataBytes_), 4); + uint8_t header[44]; + oswav::buildHeader(header, sampleRate_, bits_, channels_, dataBytes_); + file_.seek(0); + file_.write(header, 44); file_.close(); return true; } diff --git a/firmware/src/wav.h b/firmware/src/wav.h new file mode 100644 index 0000000..03f0406 --- /dev/null +++ b/firmware/src/wav.h @@ -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 +#include + +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 diff --git a/firmware/test/test_pure/test_pure.cpp b/firmware/test/test_pure/test_pure.cpp new file mode 100644 index 0000000..ae2ef04 --- /dev/null +++ b/firmware/test/test_pure/test_pure.cpp @@ -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 + +#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(); +} diff --git a/firmware/wokwi.toml b/firmware/wokwi.toml new file mode 100644 index 0000000..f6f2708 --- /dev/null +++ b/firmware/wokwi.toml @@ -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' diff --git a/state/NOTES.md b/state/NOTES.md index 38ef574..d9f9c3e 100644 --- a/state/NOTES.md +++ b/state/NOTES.md @@ -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. diff --git a/state/TODO.md b/state/TODO.md index bcc72b2..778011c 100644 --- a/state/TODO.md +++ b/state/TODO.md @@ -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)