Commit graph

39 commits

Author SHA1 Message Date
jedarden
cc24a1cf23 test(runner): document C11 7.13.2.1 setjmp clobber audit at call site (bf-31rd)
Relocate the per-variable setjmp/longjmp clobber analysis to the setjmp call
site and expand it into a full boundary audit per C11 7.13.2.1: enumerate every
automatic in scope (the loop index i; confirm there are no local tallies and
that g_failure_count/g_test_jmp are file-scope static, to which 7.13.2.1 does
not apply), and show why each is determinate — i is written only by the for-init
and for-increment, the latter running after control resumes, so no write lands
between setjmp and a longjmp fired in the body.

volatile on i is retained: the standard does not strictly require it (i is not
changed in the window), but gcc's -Wclobbered is heuristic and ignores that
distinction — empirically verified that the loop warns at -O1..-Os once the
incidental preceding qsort() stops biasing the register heuristic. volatile is
the sanctioned suppression. Compile gate confirmed clean at -O0..-Os with
-Wall -Wextra and explicit -Wclobbered; full suite still 29 RUN / 0 FAIL.

Analysis + comment + build verification only; no behavior change vs bf-22vg.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-04 04:29:59 -04:00
jedarden
f254c8583a test(runner): record per-test RUN-line isolation confirmation (bf-50yh)
Some checks are pending
CI Benchmark - Fusion Loop Timing / Fusion Loop Timing Benchmark (push) Waiting to run
Document the dynamic confirmation that the recovery loop prints exactly one
RUN: line per registered test and drives the whole suite. Captured stdout of a
successful `make -C firmware/test test` run: 29 registered TEST() definitions,
29 RUN lines emitted, bidirectional set diff identical (none skipped, none
duplicated). Emitted order is byte-for-byte strcmp-sorted (qsort-by-name), as
expected for the all-passing suite where every body returns normally — no
FAIL/stderr interleaving. RUN-line set and order unchanged vs the naive
direct-call baseline (bf-1fd4). Satisfies umbrella bf-tof1 / parent bf-22vg.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-04 02:29:01 -04:00
jedarden
7b6a3d89b2 test(runner): volatile-qualify setjmp loop index for -Wclobbered-clean build (bf-2k3o)
The per-test loop in main() spans the setjmp(g_test_jmp)/longjmp recovery
target: i is read (loop test + increment) on the longjmp-return path. gcc's
-Wclobbered (enabled under -Wall) flags exactly this loop-index-across-setjmp
shape. The build was clean today only coincidentally — the preceding qsort()
call biases gcc's register heuristic; an identical loop without it warns at
-O1/-O2 (verified). Qualify i as volatile per C11 7.13.2.1, the sanctioned
remedy (no pragma, no flag downgrade), with no behavior change. This is the
compile-cleanliness gate for the guard (parent bf-22vg).

Verified: gcc -std=c11 -Wall -Wextra -Wclobbered -Werror is clean at
-O0/-O1/-O2/-O3/-Os/-Og; make -C firmware/test test builds warning-free and
runs all 30 tests (exit 0). Only firmware/test/test_runner.c touched.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-04 02:29:01 -04:00
jedarden
b62fa3ed60 test(runner): gate test body on setjmp(g_test_jmp) recovery target (bf-27ud)
Some checks are pending
CI Benchmark - Fusion Loop Timing / Fusion Loop Timing Benchmark (push) Waiting to run
Wrap the per-test g_tests[i].fn() call in if (setjmp(g_test_jmp) == 0),
establishing the longjmp recovery target declared in bf-3id before each
test runs. The RUN: printf stays before the setjmp so the per-test marker
still prints regardless of how the body ends; the non-zero (longjmp) path
falls through with no else body, so the loop's i++ still advances and a
failure in test N never blocks N+1..end.

PASS/FAIL labels, tallies, summary, and non-zero exit remain sibling scope
(child 3, bf-1na) — main() still returns 0 unconditionally here.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 20:49:56 -04:00
jedarden
6a2d1406c9 test(runner): strip main() to naive direct-call baseline (bf-1fd4) 2026-07-03 20:25:54 -04:00
jedarden
d299306cb0 test: drop stale PASS/FAIL reference in host-harness loop comment
The per-test setjmp loop's doc comment still said it lands back in the
loop "to print PASS/FAIL and advance" — but child 1 (bf-52k2) and
child 2 (bf-344n) already replaced both branches' outcome labels with
the neutral RUN marker family ("RUN: <name>" / "RUN: <name>
(assertion failed)"). The comment now contradicts the code, so align
it with the "neutral marker line" phrasing already used lower in the
same block. No code change; gcc -std=c11 -Wall -Wextra still clean.

Co-Authored-By: Claude <noreply@anthropic.com>
Bead-Id: bf-344n
2026-07-03 14:55:47 -04:00
jedarden
b86cb670a8 test: print neutral RUN marker on direct setjmp==0 path
Some checks are pending
CI Benchmark - Fusion Loop Timing / Fusion Loop Timing Benchmark (push) Waiting to run
The direct branch of main()'s per-test setjmp loop — taken when a test
runs normally without longjmping back — now prints "RUN: <name>" instead
of "PASS: <name>". This is the same neutral marker family child 1
(bf-52k2) chose for the else branch ("RUN: <name> (assertion failed)"),
so a passing test prints "RUN: <name>" and a failing test prints
"RUN: <name> (assertion failed)".

After this child, NEITHER branch emits PASS/FAIL; each test emits exactly
one neutral line. The internal passed++ counter is unchanged — it feeds
the run summary ("N passed, M failed of T"), not the per-test output, so
it is not an "observable label". g_failure_count accounting is likewise
untouched.

Child 2 of 4 for bf-38e9 (split-child 2 of bf-53ut).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 14:01:29 -04:00
jedarden
d9d1048320 Merge origin/main into local test-runner work
Some checks are pending
CI Benchmark - Fusion Loop Timing / Fusion Loop Timing Benchmark (push) Waiting to run
Reconciles divergence: local 8fbc2e8 (volatile doc) and origin's
4b0eaba (same) plus 5e58859 (nvs/csi/serial_prov host tests + CI
wiring) and fbb86fb (gitignore). Clean merge: both branches agree on
test_runner.c content; origin additionally adds host tests and CI.
2026-07-03 13:37:38 -04:00
jedarden
6e454803fb test: print neutral RUN marker on longjmp failure-return branch
The else branch of main()'s per-test setjmp loop — taken when a failed
assertion longjmps back — now prints "RUN: <name> (assertion failed)"
instead of "FAIL: <name>". The marker still names the failing test and
its own per-test `failed` counter, but contains no PASS/FAIL outcome
token (outcome labels are deferred to a later sibling of bf-53ut).

g_failure_count accounting is unchanged: test_record_failure() already
bumped it before the longjmp, so this branch only prints.

Child 1 of 4 for bf-38e9 (split-child 2 of bf-53ut).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 13:37:19 -04:00
jedarden
5e588592f4 test: add firmware host tests for nvs/csi/serial_prov + wire gcc harness into CI
Adds the three firmware host-test modules required by the Testing Strategy as a
plain gcc harness under firmware/test/ — NOT idf.py --target linux. That path was
rejected (docs/notes/firmware-host-test-approach.md, bf-21t): firmware/main
cannot host-link because csi.c pulls in esp_wifi.h and provision.c pulls in
driver/uart.h, and the single `main` component REQUIRES esp_wifi/bt/driver,
which have no linux build — so even nvs_migration.c (hostable in isolation) is
unhostable as part of the component. The harness therefore tests dependency-free
logic extractions and binary-format/wire contracts instead of linking the
firmware source.

- test_nvs_migration.c: fresh-install init to v1, no-downgrade guard, forward
  migration loop dispatch (v→v+1 at index v−1), and the concrete v1→v2 step
  (rename ms_ip→mothership_ip, default ntp_server), driven against an in-memory
  NVS store. Mirrors nvs_migration.c decision-for-decision.
- test_csi_frame.c: 24-byte header field round-trip, explicit little-endian
  timestamp byte order, signed-RSSI (uint8_t) reinterpretation, I/Q payload
  copy, n_sub=0 header-only probe, and the ingestion-side validation rules
  (too-short / payload-mismatch / n_sub>128 / bad channel). Mirrors the
  websocket.c encoder contract (offset/byte for offset/byte).
- test_serial_prov.c: provisioning JSON parser + NVS-mapping mirror of
  provision.c (all four protocol branches + every field mapping), shipping a
  bounded recursive-descent JSON decoder as the fuzz target. The fuzz pass
  (4000 random byte streams, a tricky-input corpus, 500 deep-nesting cases)
  proves the parser never crashes and the protocol always answers a single
  well-formed {"ok":...} line on any UART input.
- Makefile: gcc build/run recipe that globs every test_*.c + test_runner.c.

CI wiring: the Dockerfile firmware-builder stage now runs `make -C test test`
before the expensive ESP-IDF build, so a logic/format-contract regression fails
the image build fast. .gitignore + .dockerignore exclude the regenerable
host_tests binary.

docs/plan/plan.md Testing Strategy updated from the idf.py description to the
gcc harness (matching the decision record).

28 tests, all passing. go test ./... and go vet ./... unchanged (firmware-only).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 13:21:26 -04:00
jedarden
8fbc2e82c3 test: document setjmp volatile-analysis in firmware host harness loop
Add a comment next to the per-test setjmp(g_test_jmp) guard in main()'s
loop recording the C11 7.13.2.1 volatile analysis: the loop index i is
read in the post-longjmp path (g_tests[i].name) but is not modified
between the setjmp() call and a possible longjmp() — the body only reads
i, and the only write is the for-loop increment, which runs after control
returns. So no volatile qualifier is needed. (bf-2ftl)

Co-Authored-By: Claude <noreply@anthropic.com>
Bead-Id: bf-2ftl
2026-07-03 12:44:25 -04:00
jedarden
4b0eaba9a7 test: document setjmp volatile-analysis in firmware host harness loop
Some checks are pending
CI Benchmark - Fusion Loop Timing / Fusion Loop Timing Benchmark (push) Waiting to run
Add a comment next to the per-test setjmp(g_test_jmp) guard in main()'s
loop recording the C11 7.13.2.1 volatile analysis: the loop index i is
read in the post-longjmp path (g_tests[i].name) but is not modified
between the setjmp() call and a possible longjmp() — the body only reads
i, and the only write is the for-loop increment, which runs after control
returns. So no volatile qualifier is needed. (bf-2ftl)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 12:42:09 -04:00
jedarden
c66f62e616 test: land firmware host-test build scaffolding (Makefile, .gitignore, sanity)
Some checks are pending
CI Benchmark - Fusion Loop Timing / Fusion Loop Timing Benchmark (push) Waiting to run
The per-test setjmp/longjmp recovery loop in test_runner.c (main()) was
already delivered by sibling bead bf-bq9 (commit 549dc1f) and verified to
satisfy bf-53ut's contract: a failing assertion longjmps back to main()'s
setjmp, which falls through to advance the loop, so a failure in test N
never blocks tests N+1..end (proven with a temp failing-test followed by a
passing test — both ran and the process completed without aborting).

This commit lands the three harness files that were staged but absent from
HEAD (Makefile, .gitignore, test_sanity.c) so that 'make -C firmware/test
test' — the command bf-53ut's acceptance invokes — actually builds and runs
against the committed tree. Without them the committed test_runner.c could
not be built from a fresh clone.

Verified against the bf-53ut acceptance criteria:
- gcc -std=c11 -Wall -Wextra (-Werror): clean; no -Wclobbered/setjmp warnings
- normal suite: PASS, exit 0
- failing test + later passing test: both per-test lines print, process
  completes (non-fatal assertions)
- no changes to firmware/main/* or firmware/CMakeLists.txt

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 11:56:34 -04:00
jedarden
549dc1f179 test: add firmware host harness main driver — name-sort, setjmp loop, summary, exit code
Some checks are pending
CI Benchmark - Fusion Loop Timing / Fusion Loop Timing Benchmark (push) Waiting to run
Completes the runner (bf-bq9, child of bf-2i4). main() qsort()s the
registered tests by name for deterministic order regardless of constructor
or link order, drives each through the per-test setjmp/longjmp recovery
loop (print PASS on normal return, FAIL on longjmp return so one test's
failure never blocks the rest), prints a passed/failed/total summary, and
returns 1 iff g_failure_count > 0 — the non-zero-on-failure exit code CI
relies on via `make -C firmware/test test`.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 10:12:46 -04:00
jedarden
83250700ef test: add firmware host harness failure recovery — jmp_buf + test_record_failure longjmp
Some checks are pending
CI Benchmark - Fusion Loop Timing / Fusion Loop Timing Benchmark (push) Waiting to run
bf-3id (child of bf-2i4). Adds the per-test failure-recovery machinery to
firmware/test/test_runner.c:

- static jmp_buf g_test_jmp at file scope — the live setjmp() target the
  header ASSERT_* macros longjmp into; declared at file scope so the main()
  landing in bf-bq9 can setjmp() it directly before each test.
- static int g_failure_count — a run-wide counter test_record_failure()
  bumps on each failure, so main() can return non-zero on any failure.
- test_record_failure(file, line, fmt, ...) — prints file:line plus the
  vfprintf-formatted detail to stderr, bumps the counter, and longjmp()s
  into g_test_jmp so the current test aborts but the runner continues.

main() (the setjmp caller + PASS/FAIL reporting + non-zero exit) is
intentionally absent — it lands in the sibling bead bf-bq9. Until then this
TU compiles to an object (gcc -std=c11 -Wall -Wextra -c) but has no live
setjmp target for the longjmp, and does not link into a runnable harness.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 09:51:43 -04:00
jedarden
ef1360ee76 test: add firmware host harness test_register — append + capacity guard
Some checks are pending
CI Benchmark - Fusion Loop Timing / Fusion Loop Timing Benchmark (push) Waiting to run
Implement test_register() in firmware/test/test_runner.c, child 3 of the
bf-lfz sub-split. Appends each test entry at index g_test_count in GCC
constructor order and bumps the count, so the registry is fully populated
before main() runs. On a full registry (g_test_count >= MAX_TESTS) it logs
to stderr naming the skipped test and the cap and returns without writing
past the end — never overflows the array.

The static registry storage (g_tests/g_test_count) is now referenced, so the
__attribute__((unused)) it carried through child 2 (bf-uvv) is dropped.
test_record_failure() and main() remain intentionally absent (siblings
bf-3id and bf-bq9). Compiles cleanly to an object with zero warnings:
gcc -std=c11 -Wall -Wextra -Werror -c (no link, no main).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 09:13:52 -04:00
jedarden
18bce75f9e test: add firmware host harness registry storage — MAX_TESTS array + count
Some checks are pending
CI Benchmark - Fusion Loop Timing / Fusion Loop Timing Benchmark (push) Waiting to run
Child 2 of the bf-lfz sub-split (registry storage). Adds to
firmware/test/test_runner.c: #define MAX_TESTS 128, the static
test_entry_t g_tests[] array, and static g_test_count, with comments on why a
fixed static array is appropriate (GCC constructors from TEST() populate it
before main(); keeps the harness dependency-free; 128 is far more than the
handful of pure-logic host tests this harness targets).

The two statics are marked __attribute__((unused)): nothing reads or writes
the registry until child 3's test_register() lands, and gcc 14 (unlike the
older gcc the original "static file-scope symbols do not warn when unused"
assumption rested on) DOES warn on unused file-scope statics under
-Wall -Wextra. Without the attribute the object emits two -Wunused-variable
warnings and fails the bead's zero-warning build gate.

Compiles cleanly to an object with zero warnings:
  gcc -std=c11 -Wall -Wextra -c firmware/test/test_runner.c
No changes to firmware/main or firmware/CMakeLists.txt.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 09:04:13 -04:00
jedarden
191adbe9ad test: add firmware host harness runner skeleton — comment block + libc includes
Some checks are pending
CI Benchmark - Fusion Loop Timing / Fusion Loop Timing Benchmark (push) Waiting to run
Child 1 of 3 of the bf-lfz sub-split (bf-lfz is itself a child of bf-2i4;
grandparent bf-56v; great-grandparent bf-4ne; header API in bf-1xs). bf-lfz
failed three times as a single big-bang change, so its registry work is
decomposed into three atomic, independently-compilable pieces. This is the
skeleton (child 1):

- A top-of-file comment block documenting the incremental build-out: child 2
  adds the registry storage (the array + count), child 3 adds test_register(),
  and the failure handler + main() arrive in the sibling beads bf-3id and
  bf-bq9.
- Exactly the five includes the full runner will need: "test_runner.h" then
  <setjmp.h>, <stdio.h>, <stdlib.h>, <string.h> — libc only, no includes from
  firmware/main, by design (see test_runner.h's header comment and the bf-21t
  decision record).

With only the skeleton present this TU compiles cleanly to an object
(gcc -std=c11 -Wall -Wextra -c, zero warnings) but is not yet linkable into a
runnable harness: test_register() and test_record_failure() are still undefined
and there is no main(). Those arrive with the later children and siblings. No
registry array, count, or test_register() yet.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 08:37:30 -04:00
jedarden
128024c415 test: add firmware host harness header — TEST() macro + assert macros
Some checks are pending
CI Benchmark - Fusion Loop Timing / Fusion Loop Timing Benchmark (push) Waiting to run
Child 1 of the bf-56v harness split (grandparent bf-4ne). Establishes
the public header API (firmware/test/test_runner.h) that every test unit
and the runner compile against:

- TEST(name): declares a void body plus a GCC constructor
  (__attribute__((constructor))) that self-registers it via
  test_register(name, fn) before main() runs — so a new test_*.c is
  picked up with zero SOURCES edits.
- ASSERT_EQ / ASSERT_TRUE / ASSERT_FALSE: on mismatch, call
  test_record_failure(file, line, fmt...) which longjmp()s back to the
  per-test setjmp (the live jmp target lives in test_runner.c, a later
  child) — aborting only the current test, not the whole suite.
- Prototypes for test_register(const char*, test_fn) and
  test_record_failure(...).

Header comment records the bf-21t decision (gcc host harness, not
ESP-IDF --target linux: csi.c/provision.c are blocked by esp_wifi.h /
driver/uart.h, which have no linux build, and main is one component)
and names the single run command ('make -C firmware/test test') that a
later child makes real.

Self-contained: only libc (setjmp.h, stdbool.h, stdint.h); no
firmware/main/* includes; no main(). A trivial includer using TEST(x){}
compiles cleanly with 'gcc -std=c11 -Wall -Wextra -c'.

test_runner.c, the Makefile, and the sanity test belong to sibling
beads and are intentionally not part of this commit.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 06:57:58 -04:00
jedarden
2483d36642 fix(provision): re-broadcast SPAXEL READY every 1s during window
Some checks are pending
CI Benchmark - Fusion Loop Timing / Fusion Loop Timing Benchmark (push) Waiting to run
Previously the ready signal was sent exactly once at window open, so the
browser had to have the serial port open at that exact millisecond after
reboot. Now the firmware broadcasts SPAXEL READY every second for the
full provisioning window (2 min fresh / 15 s reprov), giving the host
ample time to open the port and catch the handshake.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 18:23:25 -04:00
jedarden
e676694fdc feat(provisioning): carry ms_ip in payload for mDNS-less networks
Add optional mothership IP override to the provisioning flow so nodes
on networks where mDNS is blocked (enterprise WiFi, mesh, VLANs) can
connect on first boot without manual intervention.

- Add ms_ip field to provisioning Payload and request structs
- Firmware writes ms_ip to both NVS_KEY_MS_IP and NVS_KEY_MS_IP_PROV
- Discovery prefers provisioned IP on first attempt, falls back to
  mDNS, then cached IP
- Web Serial wizard adds Mothership IP field in Network Troubleshooting
- Auto-populates IP when browser accesses dashboard by IP address
- Document when/how to use the override in docs/notes/mdns-override.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-24 01:06:05 -04:00
jedarden
982f038e75 fix(firmware): report actual flash chip size in hello message via esp_flash_get_size()
Replaces the hardcoded flash_mb:16 with a runtime query so the mothership
receives the correct physical flash size regardless of the hardware variant.
On the 4MB XMC board this will now report 4; on a 16MB board it reports 16.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 11:19:45 -04:00
jedarden
d83759899f fix(firmware): redesign partition table for 4MB flash; fix flash size in esptool.js
The XMC embedded flash on ESP32-S3 (QFN56) has JEDEC ID 164020 which esptool.js
does not recognise — it returns flash size -1 causing 'File doesn't fit in
available flash' before any bytes are written.

firmware/sdkconfig.defaults:
  CONFIG_ESPTOOLPY_FLASHSIZE 16MB → 4MB
  4MB is the minimum supported flash. The spi_flash check only panics when
  physical < header, so 4MB header is safe on 16MB boards as well.

firmware/partitions.csv:
  Redesigned to fit within 4MB flash:
    factory  0x010000–0x200000  (~1.9MB, fits current 1.7MB binary + 330KB headroom)
    ota_0    0x200000–0x3F0000  (~1.9MB, A/B OTA + rollback preserved)
    otadata  0x3F0000–0x3F2000  (8KB)
  Total flash used: 0x3F2000 (98.6% of 4MB). Drops ota_1 (was at 8MB, unusable
  on 4MB devices anyway); rollback still works factory↔ota_0.

dashboard/js/onboard.js:
  flashSize: 'detect' → '4MB'  (detect returned -1 for this chip's JEDEC ID;
  hardcoding '4MB' correctly sets the binary header for all supported boards)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 10:40:58 -04:00
jedarden
a3dbbf1170 fix: support special characters in WiFi credentials
- SSID input: add autocorrect=off, autocapitalize=none, spellcheck=false to
  prevent mobile browsers from silently altering SSIDs with special chars
- Password input: same attrs for consistency
- Firmware: accept WPA/WPA2 mixed mode (WIFI_AUTH_WPA_WPA2_PSK) so networks
  with special characters in the password connect regardless of WPA version
- Firmware: detect open networks (empty password) and use WIFI_AUTH_OPEN so
  passwordless networks are not rejected by the auth threshold

JSON encoding path (JSON.stringify → TextEncoderStream → cJSON) already
handles all characters correctly; these changes prevent browser-side mangling
and firmware-side connection rejection.
2026-04-17 08:42:20 -04:00
jedarden
460dac8f4d fix(firmware): use %u cast for uint32_t in ESP_LOGI to fix -Wformat build error 2026-04-17 00:31:09 -04:00
jedarden
cc84a6eea8 Add WiFi provisioning observability and extend firmware window
- Show real-time status messages and a collapsible log panel during WiFi provisioning
- Thread addProvLog/setProvStatus callbacks through provisionAndSend and sendPayloadOverSerial
- Log every stage: mothership fetch, payload assembly, port open retries, serial send/response
- All log lines also go to browser console.log/warn/error
- Firmware: extend provisioning window from 10s to 120s for fresh boards (15s for re-provisioning)
- Firmware: include MAC address in SPAXEL READY message for display

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 14:35:39 -04:00
jedarden
d7c1adc260 Fix ESP-IDF 5.x firmware compilation for ESP32-S3 build
Port firmware/main to ESP-IDF 5.2 API:
- Add idf_component.yml with mdns and esp_websocket_client managed deps
- Rename esp_ota → app_update, esp_wifi_csi_info_t → wifi_csi_info_t
- Fix freertos/semphr.h include path rename
- Add missing headers: esp_mac.h, esp_netif.h, driver/temperature_sensor.h, string.h, math.h
- Add led.c to SRCS, fix xTaskCreate arg count (7→6)
- Use IDF 5.x temperature_sensor API in place of stub
- Fix mdns_query_ptr signature (add max_results arg)
- Fix url_decode isxdigit unsigned char cast
- Add flash size config (16MB) to sdkconfig.defaults
- Pin managed component versions in dependencies.lock
- Add sdkconfig (generated) to .gitignore

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 15:35:03 -04:00
jedarden
c79f7ffee4 fix(firmware): declare mdns as managed component dependency
mdns was moved out of the built-in ESP-IDF tree in v5.x and must be
declared via idf_component.yml for the component manager to resolve it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 15:21:37 -04:00
jedarden
ab47adc88a fix(firmware): add led.c to CMakeLists SRCS
led.c was missing from the build, causing undefined reference errors
for led_init, led_stop_blink, and led_blink_identify at link time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 15:15:06 -04:00
jedarden
0aa6046f82 feat: implement firmware LED blink handler for identify command
- Add led_init() call during firmware initialization in app_main()
- Add led_stop_blink() call on WebSocket disconnect
- LED GPIO configurable via CONFIG_SPAXEL_LED_GPIO (default GPIO8)
- Blink runs in FreeRTOS task at ~5Hz (100ms on/100ms off)
- Any running blink is cancelled when new identify message received

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 07:01:42 -04:00
jedarden
a42c5e7ea1 feat: wire NTP client into firmware build and initialization
- Add ntp.c to CMakeLists.txt SRCS so it's compiled and linked
- Load ntp_server from NVS in load_nvs_config() (default: pool.ntp.org)
- Add ntp_server field to spaxel_state_t
- Initialize NTP after WiFi connects with 10s sync timeout, WARN on failure
- Re-sync NTP after WiFi reconnect (WIFI_LOST state)
- Start periodic 10-minute resync timer via esp_timer
- Add ntp_synced boolean to health JSON message
- Handle ntp_server field in downstream config message
- Fix periodic resync callback to properly stop/restart SNTP

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 13:19:07 -04:00
jedarden
e44dd345f6 feat: implement comprehensive /healthz endpoint
Add complete health check implementation for Docker HEALTHCHECK and
Traefik health routing with:

Response fields:
- status: "ok" or "degraded"
- uptime_s: seconds since mothership boot
- version: mothership version string
- nodes_online: count of connected nodes
- db: "ok" or "failing" (SELECT 1 with 100ms timeout)
- load_level: 0-3 from load shedding state
- reason: human-readable explanation (only when degraded)

HTTP status codes:
- 200 for healthy (status="ok")
- 503 for degraded (status="degraded")

Degraded conditions:
- Database unreachable
- Load level 3 sustained for >60 seconds
- No nodes connected after 5 minutes uptime

Docker HEALTHCHECK updated to verify status="ok" response.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 11:09:36 -04:00
jedarden
391ed884e4 feat: implement NVS schema migration on boot
Implement versioned NVS key migration on ESP32-S3 firmware so
OTA-updated firmware gracefully handles NVS written by older versions.

- Add nvs_migration.c/h with migration framework
- On boot, read schema_ver from NVS; initialize to 1 if missing
- Run migrations sequentially if schema_ver < COMPILED_NVS_VERSION
- Each migration commits after each write for durability
- Log all migration steps to UART for debugging
- Example migration v1→v2: rename 'ms_ip' to 'mothership_ip',
  add 'ntp_server' with default 'pool.ntp.org'
- Migration failure leaves NVS in consistent state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 10:27:38 -04:00
jedarden
ee1caf6ed8 feat: dual-partition OTA rollback validation with 60s role timeout
Update partition layout to 4MB factory/ota_0/ota_1 partitions for larger
firmware images. Add a 60-second validation timer that starts after the
hello message is sent on boot from an OTA partition. The partition is only
marked valid (via esp_ota_mark_app_valid_cancel_rollback) after receiving
a role message from the mothership. If the timer expires without role
receipt, the partition stays unconfirmed and the bootloader rolls back to
the previous firmware on next reset.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 01:43:29 -04:00
jedarden
d2e5b4d4a0 feat: implement passive radar auto-detection of router AP
Automatically detect the home router as a passive radar TX source,
eliminating need for a dedicated active TX node.

Firmware changes:
- During hello message, include ap_bssid and ap_channel from esp_wifi_sta_get_ap_info()

Mothership changes:
- On hello: extract ap_bssid; if >=80% of nodes report same BSSID create virtual node entry with virtual=1
- OUI lookup: embedded IEEE OUI registry as Go map compiled via go:embed; display router brand
- Detect AP BSSID change (router reboot/replacement) and emit system alert
- SQLite nodes table: add virtual BOOL, node_type TEXT, ap_bssid TEXT, ap_channel INT columns

Dashboard changes:
- Show "I detected your router (ASUS). Place it on the floor plan..." notification
- Render virtual AP nodes with distinct router icon in 3D view
- Drag-to-place virtual node (distinct router icon) in 3D editor

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 14:03:03 -04:00
jedarden
f76ab62698 feat(firmware): OTA SHA-256 verification and captive portal URL decoding
- Add git-based version header generation for firmware builds
- Implement SHA-256 hash verification for OTA downloads with mbedtls
- Add URL decoding for captive portal form parsing (spaces, special chars)
- Add mbedtls dependency for SHA-256 verification

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 19:56:11 -04:00
jedarden
bcfd1e3f16 feat(protocol): full bidirectional node protocol over WebSocket
- firmware: wire on-device motion hints to websocket_send_motion_hint()
  with 1s rate-limit; csi_set_rate() now writes to g_state.packet_rate
- firmware: websocket_send_motion_hint() sends variance + MAC + timestamp
  to mothership so rate controller ramps ahead of server-side detection
- mothership: RateController.OnMotionHint() preemptively ramps adjacent
  nodes via SetAdjacentNodesFn callback (topology-aware burst propagation)
- mothership: idle timeout extended to 30s; variance_threshold=0 in active
  mode (server handles detection), DefaultVarianceThreshold=1.0 in idle
- mothership: SendRoleToMAC() exposes dynamic role changes post-connect
- mothership: SendOTAToMAC() enables pushing firmware updates to nodes
- mothership: OTA status events are now logged with state and progress %

Protocol is backward-compatible: binary CSI frames work as in Phase 1;
JSON control messages are additive on the same single WebSocket per node.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 23:14:19 -04:00
jedarden
fb691904c6 feat(dashboard): per-link motion presence indicator with amplitude time series
- Dashboard hub broadcasts motion state changes immediately on transition
  (idle↔motion) via BroadcastMotionState; periodic state snapshots include
  motion_states for new client init
- Per-link presence badge (green CLEAR / red MOTION) rendered in link list
  alongside global presence indicator in status bar
- Amplitude mean time-series chart (60 s rolling window) for selected link,
  line segments colored by motion state at each sample
- Fix: links created from JSON link_active/state events now initialize
  ampHistory and lastAmpSample so time-series accumulates from first frame

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 22:55:33 -04:00
jedarden
f178496c83 feat(firmware): ESP32-S3 firmware skeleton with CSI/BLE/WebSocket
- State machine: 7-state lifecycle (BOOT→WIFI→DISCOVERY→CONNECTED,
  degraded modes WIFI_LOST/MOTHERSHIP_UNAVAILABLE/CAPTIVE_PORTAL)
- WiFi: STA connection with exponential backoff, mDNS discovery with
  cached IP fallback, captive portal AP for provisioning
- WebSocket: bidirectional comms, binary CSI frames (24B header + I/Q),
  JSON hello/health/ble/ota_status upstream, role/config/ota/reboot
  downstream, OTA task with progress reporting
- CSI: promiscuous mode capture, queue-based processing, passive BSSID
  filter, on-device variance tracking (Welford's) for motion hints
- BLE: passive scanning on Core 0, 60-device cache, 5s reporting
- NVS: 15-key schema with versioning, role/rate persistence

Complete: ESP32 firmware skeleton, passive radar, BLE scanning
Remaining: Dashboard skeleton, Docker packaging (Phase 1 items 5-6)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 07:20:55 -04:00