diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 954d632..316acad 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -21,48 +21,6 @@ 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 deleted file mode 100644 index 3d14219..0000000 --- a/docs/testing.md +++ /dev/null @@ -1,132 +0,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. diff --git a/firmware/diagram.json b/firmware/diagram.json deleted file mode 100644 index 59ae263..0000000 --- a/firmware/diagram.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "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 21f8e02..879d1a4 100644 --- a/firmware/platformio.ini +++ b/firmware/platformio.ini @@ -34,21 +34,4 @@ build_flags = 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 \ No newline at end of file + -DOPENSCRIBE_BOARD_XIAO=1 \ No newline at end of file diff --git a/firmware/src/api_http.cpp b/firmware/src/api_http.cpp index b824916..e740733 100644 --- a/firmware/src/api_http.cpp +++ b/firmware/src/api_http.cpp @@ -6,7 +6,6 @@ #include #include "config.h" -#include "httprange.h" // pure, host-testable Range parser #include "net_wifi.h" #include "recorder.h" #include "storage.h" @@ -91,18 +90,26 @@ void handleAudio() { const size_t total = f.size(); server.sendHeader("Accept-Ranges", "bytes"); - String rangeHdr = server.header("Range"); - oshttp::RangeResult rr = oshttp::parseByteRange(rangeHdr.c_str(), total); - if (!rr.satisfiable) { + 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) { f.close(); return sendError(416, "Range not satisfiable"); } - const size_t start = rr.start; - const size_t end = rr.end; + if (end >= total) end = total - 1; const size_t len = end - start + 1; f.seek(start); - if (rr.partial) { + if (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 f6d77d0..6882a13 100644 --- a/firmware/src/config.cpp +++ b/firmware/src/config.cpp @@ -27,25 +27,8 @@ bool begin() { String deviceId() { return deviceId_; } -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", ""); -} +String wifiSsid() { return prefs.getString("wifi_ssid", ""); } +String wifiPsk() { 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 deleted file mode 100644 index 29286ff..0000000 --- a/firmware/src/httprange.h +++ /dev/null @@ -1,49 +0,0 @@ -// 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 5f69680..ea5fd4f 100644 --- a/firmware/src/main.cpp +++ b/firmware/src/main.cpp @@ -42,15 +42,13 @@ void setup() { ux::begin(); config::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) { + if (!recorder::begin()) { 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 } - // Networking + REST API. + // Bring up networking + the REST API. Non-fatal: recording still works offline. net_wifi::begin(); api_http::begin(); if (net_wifi::isAccessPoint()) { @@ -62,10 +60,8 @@ 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); - } + 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 100c1c5..e58f0de 100644 --- a/firmware/src/storage.cpp +++ b/firmware/src/storage.cpp @@ -5,13 +5,37 @@ #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 { @@ -38,9 +62,7 @@ bool WavWriter::open(const String &path, uint32_t sampleRate, uint16_t bitsPerSa bits_ = bitsPerSample; channels_ = channels; dataBytes_ = 0; - uint8_t header[44]; - oswav::buildHeader(header, sampleRate_, bits_, channels_, 0); // placeholder sizes - file_.write(header, 44); + writeWavHeader(file_, sampleRate_, bits_, channels_, 0); // placeholder sizes return true; } @@ -53,12 +75,13 @@ size_t WavWriter::write(const uint8_t *data, size_t len) { bool WavWriter::close() { if (!file_) return false; - // Rewrite the 44-byte header with the final sizes now that we know the length. + // Patch RIFF size (offset 4) and data size (offset 40) now that we know the length. file_.flush(); - uint8_t header[44]; - oswav::buildHeader(header, sampleRate_, bits_, channels_, dataBytes_); - file_.seek(0); - file_.write(header, 44); + file_.seek(4); + uint32_t riffSize = 36 + dataBytes_; + file_.write(reinterpret_cast(&riffSize), 4); + file_.seek(40); + file_.write(reinterpret_cast(&dataBytes_), 4); file_.close(); return true; } diff --git a/firmware/src/wav.h b/firmware/src/wav.h deleted file mode 100644 index 03f0406..0000000 --- a/firmware/src/wav.h +++ /dev/null @@ -1,32 +0,0 @@ -// 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 deleted file mode 100644 index ae2ef04..0000000 --- a/firmware/test/test_pure/test_pure.cpp +++ /dev/null @@ -1,88 +0,0 @@ -// 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 deleted file mode 100644 index f6f2708..0000000 --- a/firmware/wokwi.toml +++ /dev/null @@ -1,9 +0,0 @@ -# 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 d9f9c3e..38ef574 100644 --- a/state/NOTES.md +++ b/state/NOTES.md @@ -5,8 +5,6 @@ ## 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 778011c..bcc72b2 100644 --- a/state/TODO.md +++ b/state/TODO.md @@ -5,7 +5,8 @@ ## In progress -- [ ] Dev testing + emulation harness - on `feature/dev-testing`, in PR. +- [ ] M2 On-device WiFi + REST API - code complete on `feature/fw-rest-api`, in PR. + Not compiled locally / hardware-verified; CI builds both profiles. ## Pending (roughly in build order)