Implement fleet-level sentinel link coordination with zone-aware idle modes:
- Zone membership tracking: Track which nodes belong to which zones and update zone state on motion events
- Zone idle detection: Detect when all nodes in a zone have been idle and designate one sentinel link per zone at 5 Hz
- Sentinel designation: Lexicographically smallest MAC becomes sentinel (5 Hz), others drop to 1 Hz
- Fleet idle coordination: When all zones are idle, only sentinel links run at 5 Hz for minimal bandwidth
- Adjacent zone ramping: When motion detected in a zone, ramp adjacent zones to 5 Hz for preemptive coverage
- RampZone() method: Interface for prediction engine to preemptively ramp zones when P(arrival) > threshold
- Zone-aware tests: Comprehensive tests for zone membership, idle detection, sentinel designation, fleet coordination, and backward compatibility
Co-Authored-By: Claude <noreply@anthropic.com>
Verified that the 5-step interactive onboarding wizard is fully
implemented in dashboard/js/onboard.js:
- Step 1 (30s): Walk phase with amplitude spike highlighting
- Step 2 (10s): Still phase with automatic baseline capture
- Step 3 (15s): Walk-through with Fresnel zone visualization
- Step 4 (15s): Find-me with blob polling and humanoid display
- Step 5 (30s): Interactive placement with GDOP coverage overlay
All narration is jargon-free (no CSI, Fresnel, deltaRMS in user text).
All durations match spec. All 3D integrations working.
Appendix A to ci-test-sim-reference-map.md: line-by-line path inventory
of the spaxel-build WorkflowTemplate. Every Go step runs with cwd=mothership,
so ./cmd/sim (L385), ./cmd/mothership (L392), and ./test/acceptance/ (L400)
all resolve under the mothership module. Confirms the template references
none of: root cmd/sim, root test/acceptance, tests/e2e/run.sh, or
mothership/tests/e2e/e2e_test.go (the latter only transitively compile-
covered by go test ./...).
Co-Authored-By: Claude <noreply@anthropic.com>
Argo Workflows (iad-ci) covers both jobs: the fusion-loop timing benchmark
runs as a FAIL . [setup failed] step in the spaxel-build
WorkflowTemplate, and e2e/acceptance suites run via the spaxel-e2e template.
The two .github/workflows/*.yml files were redundant dead config that
suggested re-enabling GitHub Actions, which are disabled across all repos.
- Delete .github/workflows/benchmark-ci.yml and e2e.yml (and the now-empty
.github/workflows + .github directories)
- docs/ci-benchmark-integration.md: drop the 'GitHub Actions Integration'
section; note the benchmark step already exists in the spaxel-build
template and that Argo is the only CI path
Co-Authored-By: Claude <noreply@anthropic.com>
Discovery only (no source changes). Confirmed dashboard/ is an npm project
(package.json: spaxel-dashboard) with package-lock.json present
(lockfileVersion 3, 361 packages) — npm ci prerequisite met. Toolchain:
node v20.19.2, npm 9.2.0. Recorded in comment #68.
Co-Authored-By: Claude <noreply@anthropic.com>
Add the collinear I/Q edge case (samples on a single line through the
origin, straddling the +-pi unwrap boundary) to the table-driven property
test, covering the last enumerated edge input from the plan's testing
strategy. Tighten the property loop so an unexpected error on a valid
edge case fails the test instead of being silently logged and passed.
No native fuzz target; runs green and fast under plain 'go test ./...'.
Co-Authored-By: Claude <noreply@anthropic.com>
The calibration_complete WebSocket event sent to the guided-troubleshooting
dashboard hardcoded links: 0. Read the actual count of active links from the
ingestion server via GetAllLinksInfo() (the same source the dashboard snapshot
uses for its links list) so the post-calibration display reflects reality.
Co-Authored-By: Claude <noreply@anthropic.com>
Replace the hardcoded "1.0.0" literal in GET /api/status with the
build-time version var. Add a version field to StatusHandler, extend
NewStatusHandler to accept it, and pass the existing main.version
ldflag var at the call site. Add table-driven handler tests.
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>
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>
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>
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>
The bf-18yn umbrella's acceptance-criteria test. Using ONLY the default
node placement (simulator.DefaultNodePositions — the spread geometry a
freshly-onboarded virtual/sim fleet receives with no manual positioning,
bf-3fr6/bf-xrej), seed the 3D fusion engine and assert the accumulation
grid produces non-zero fusion peaks, closing the bf-4q5w symptom (the
engine emitting no / degenerate peaks).
TestEngine_DefaultPlacementProducesPeaks is the fleet->engine counterpart
to TestEngine_SeedNodePositions (bf-6s3d): bf-6s3d locks in the seeding
invariant (distinct, non-(0,0,1) positions); this locks in the downstream
consequence the seeding exists to deliver — that spread nodes actually
let Fuse form blobs. It runs over default placements of 2/3/4/6 nodes,
drives a synthetic walker through the room centre, and asserts
len(blobs) > 0 OR gridMax above threshold. assertPlacementNotCollapsed
fails loudly if default placement ever collapses to (0,0,1).
TestEngine_CoLocatedOriginYieldsNoPeaks is the counter-example pinning
bf-4q5w: nodes left at the (0,0,1) DB default are co-located, every link
is degenerate, the grid stays at zero, and Fuse emits no blobs — proving
the non-zero-peak assertion is meaningful, not trivially satisfiable.
Acceptance: passes with default placement; co-located (0,0,1) collapse
yields no peaks (documented by the counter-example). go build ./... /
go vet ./... / go test ./... all pass across mothership, cmd/sim, and
test/acceptance.
Co-Authored-By: Claude <noreply@anthropic.com>
Bead-Id: bf-1kid
Virtual nodes created without an explicit position carry the nodes-table DB
defaults (pos_x=0, pos_y=0, pos_z=1), so SyncToRegistry/SyncOneNode wrote
co-located origin positions into the fleet registry — and downstream into the
fusion engine — collapsing Fresnel excess paths (core symptom in bf-18yn).
Resolve positions through a new effectivePositions helper: nodes still at the
default origin are reassigned distinct, spread-out geometry from
DefaultNodePositions (sized to the full node count, across the store's space),
while explicitly-placed nodes keep their position. Slot assignment is keyed by
sorted node ID so it is deterministic regardless of map iteration order, and a
single-node sync writes the same position a full sync would. The effective
positions flow through SyncToRegistry, SyncOneNode, and ToRegistryRecords, so
the registry and fusion-engine seeding observe non-degenerate geometry.
Co-Authored-By: Claude <noreply@anthropic.com>
spaxel-sim computed good corner/perimeter geometry in createVirtualNodes but
the hello message sent on WebSocket connect did not include position, so the
mothership never learned the sim node's location and the fleet registry/DB row
was left at the nodes-table schema default (0,0,1) — co-located with every
other node.
Both sim CLIs (cmd/sim and mothership/cmd/sim) now announce pos_x/pos_y/pos_z
in the hello handshake, sourced from createVirtualNodes. HelloMessage gains
PosX/PosY/PosZ as *float64 (not plain float64) so an absent position is
distinguishable from a genuine (0,0,0): a real ESP32 omits these in hello
(its position is user-placed in the dashboard), whereas spaxel-sim announces
its computed geometry.
The connect/register path (server.HandleNodeWS -> FleetNotifier.OnNodeConnected
-> fleet.Manager) now carries the announced position through. The Manager
persists it via registry.SetNodePosition only when all three axes are present,
so a nil/partial announcement preserves any existing user-placed position
rather than clobbering it. The position then flows onward to the fusion engine
through the bf-3p6g connect/register sink (ForwardNodePosition ->
nodePositionSink -> fusionEngine.SetNodePosition), so a freshly connected sim
node is seeded at its announced location instead of (0,0,1).
The FleetNotifier interface change (OnNodeConnected gains three *float64
params) is propagated to every implementer — Manager (persists), FleetHealer
and SelfHealManager (read geometry from the registry, ignore), the guided-
troubleshoot notifier (tracks connect/disconnect only), and the cmd/mothership
multiFleetNotifier fan-out.
Tests: existing OnNodeConnected call sites updated to the new signature.
TestManagerOnNodeConnectedPersistsHelloPosition asserts a node connecting with
an announced position yields a registry row whose pos differs from (0,0,1) and
matches the sent coordinates, and that the same coordinates reach the fusion
engine. TestManagerOnNodeConnectedWithoutHelloPositionPreservesExisting
asserts the real-ESP32 case (nil announce) and partial-announce both leave an
existing user-placed position untouched.
Co-Authored-By: Claude <noreply@anthropic.com>
Forward node 3D position changes to the blob-producing fusion engine so a
node moved at runtime does not keep a stale engine position. The fleet
manager gains a nodePositionSink — the write-side mirror of the read-side
SetNodePositionAccessor used by diagnostics and weather.go — wired to
fusionEngine.SetNodePosition at startup. It fires from both position paths:
PATCH /api/nodes/{mac}/position (handler.updateNodePosition →
ForwardNodePosition) and node connect/register (OnNodeConnected forwards the
registry position). Tests assert the engine's stored position for a MAC
changes after a PATCH with new coordinates and is seeded on connect.
Co-Authored-By: Claude <noreply@anthropic.com>
bf-6s3d added TestEngine_SeedNodePositions, the host/unit-test half of
the node-position invariant (it replays main.go's seeding loop against
the engine and asserts NodeCount()/NodePositions() hold distinct,
non-(0,0,1) positions). This bead adds the complementary half the
NodePositions() docstring (fusion.go:140-159) already advertises:
"a logged startup assertion" exercised in the live path.
Right after the main.go seeding loop (which calls
fusionEngine.SetNodePosition(node.MAC, node.PosX, node.PosY, node.PosZ)
per fleet node), the engine now inspects NodePositions() and logs:
- n == 0 → INFO "no nodes positioned yet" (valid; IO-1 reaches
ready with no nodes attached)
- atDefault == n → WARN "all N seeded nodes collapsed to (0,0,1)"
(fleet never positioned → degenerate fusion)
- otherwise → INFO "N nodes seeded, D distinct, A at (0,0,1)"
The (0,0,1) collapse is the failure mode: when no node has been
positioned, every node retains the nodes-table schema default
(pos_x=0, pos_y=0, pos_z=1) and Fuse emits only degenerate peaks. The
assertion surfaces this without blocking startup (the hard gate stays
the bf-6s3d unit test).
Also gofmt's the file (drive-by): bf-3f6q's "fusion" import sat after
"ingestion" instead of alphabetically after "floorplan".
go build ./... / go vet ./... / go test ./... all pass; gofmt clean.
Co-Authored-By: Claude <noreply@anthropic.com>
Bead-Id: bf-1tsm
The 3D fusion engine's startup node seeding (iterate
fleetReg.GetAllNodes() and call SetNodePosition(node.MAC, node.PosX,
node.PosY, node.PosZ) per node, reading the DB pos_x/pos_y/pos_z
columns) was already wired into the live path by bf-3f6q (main.go
seeding loop sits symmetrically with the selfImprovingLocalizer
seeding). This bead's contribution is a regression test codifying the
bf-6s3d acceptance criteria so a future refactor cannot silently
regress them:
- NodeCount() equals the number of fleet nodes returned by GetAllNodes()
- NodePositions() holds a distinct, non-(0,0,1) position for each
registered node (nodes must NOT collapse to the co-located DB
default of pos_x=0, pos_y=0, pos_z=1)
TestEngine_SeedNodePositions replays the main.go seeding pattern
against the engine and asserts all three invariants (count, exact
coordinate round-trip, distinctness, non-default).
go build ./... / go vet ./... / go test ./... all pass.
Co-Authored-By: Claude <noreply@anthropic.com>
Bead-Id: bf-6s3d
The 3D Fresnel fusion engine (internal/fusion.NewEngine) was only ever
constructed in tests (bf-2fz8), so pm.trackedBlobs was always nil — every
GetTrackedBlobs() reader (live loop, /api/blobs, anomaly adapter, API
wrappers) saw zero blobs and IO-6 could never pass.
Wire the engine into the live 10 Hz loop:
- Construct fusion.NewEngine at startup with a Config mirroring room geometry
(Width/Height/Depth/Origin from the fleet room config), as a function-local
retained for the life of main (mirrors selfImprovingLocalizer) so startup
seeding and the follow-up node-seeding bead can call SetNodePosition on it
and the live-loop closure captures it for Fuse each tick.
- Seed its node registry from the fleet at startup (without positioned nodes,
Fuse skips every link).
- In the tracker goroutine, replace the dead pm.GetTrackedBlobs() read with
fusionEngine.Fuse(gatherFusionLinks(pm)) -> blobTracker.track(result) ->
pm.SetTrackedBlobs(blobs). The new blobTracker bridges the engine's
stateless peaks to stateful sigproc.TrackedBlob IDs via greedy 1.0 m
nearest-neighbour association (plan.md Component 4) and derives velocity
from the previous position of the same tracked ID.
go build ./... / go vet ./... / go test ./... all pass.
Co-Authored-By: Claude <noreply@anthropic.com>
Investigation (prereq for the bf-3gw1 umbrella) determining which fusion
engine produces the blobs consumed by the live 10 Hz loop and the IO-6 gate.
Verified findings (recorded as a doc comment on SetTrackedBlobs):
- pm.trackedBlobs is always nil: SetTrackedBlobs (signal/processor.go:625)
has zero callers, so it is never invoked.
- Every GetTrackedBlobs reader therefore sees zero blobs — IO-6 ("walker
produces a tracked blob") cannot pass. Read sites: live loop
main.go:1866, /api/blobs main.go:4056, anomalyPositionAdapter main.go:4840,
API wrappers internal/api/status.go:84 and internal/api/tracks.go:99.
- internal/fusion.NewEngine (3D Fresnel, internal/fusion/fusion.go:93) is
NEVER constructed in non-test code; the internal/fusion package is imported
by exactly one file, internal/localizer/fusion/timing_budget_test.go (a test).
- A second engine, internal/localization.NewEngine (2D Fresnel,
internal/localization/fusion.go:53), IS constructed live via
NewSelfImprovingLocalizer (main.go:1005) and runs Fuse (main.go:2593), but
the returned *FusionResult is discarded — nothing reads .Peaks and no
signal.TrackedBlob literal exists outside tests.
Conclusion: to satisfy IO-6, internal/fusion.NewEngine MUST be newly
constructed in non-test code and wired (Fuse -> TrackedBlob ->
SetTrackedBlobs), OR the existing localization.Engine FusionResult.Peaks must
be converted to TrackedBlobs and stored via SetTrackedBlobs.
Also adds fusion.Engine NodeCount()/NodePositions() accessors as scaffolding
for the umbrella's node-seeding verification.
Co-Authored-By: Claude <noreply@anthropic.com>
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
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>
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>
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.
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>
The ESP-IDF host-test path (idf.py test --target linux) was explicitly rejected
(decision record: docs/notes/firmware-host-test-approach.md) because firmware/main
builds as one idf_component_register whose REQUIRES names esp_wifi/bt/driver —
components with no host build, so the whole component is unhostable. Firmware host
tests instead run as a plain gcc harness under firmware/test/ (wired into CI via
the Dockerfile's 'RUN make -C test test').
firmware/test_apps/host_tests/ was leftover CMake/ninja build output from an
abandoned idf.py linux experiment (no source files). Gitignore firmware/test_apps/
so the rejected approach's artifacts can't be committed by accident.
Refs bead bf-31bp (host-test intent satisfied by the committed gcc harness).
Co-Authored-By: Claude <noreply@anthropic.com>
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>
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
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>
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>
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>
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>
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>
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>
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>
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>
Decision spike for the firmware/test/ host harness (split of bf-4ne).
Records why the ESP-IDF --target linux / Unity-host path was rejected:
csi.c is blocked by esp_wifi.h and provision.c by driver/uart.h, and
firmware/main builds as one component whose REQUIRES line names
esp_wifi/bt/driver — none of which have a linux build. Falls back to a
plain gcc harness under firmware/test/ that does not link main/*.c.
Reason must be carried into bf-4ne's test-runner header comment.
Co-Authored-By: Claude <noreply@anthropic.com>
Update docs/plan/plan.md to reflect the actual repo (VERSION 0.1.357):
- Go Module Layout: go.work stitches 3 modules (mothership, cmd/sim,
test/acceptance); no test/integration/ — tests live in test/acceptance/,
mothership/test/acceptance/, and tests/e2e/run.sh
- Dockerfile: espressif/idf:v5.2 firmware stage + golang:1.25-bookworm,
GOOS/GOARCH pinned to linux/amd64 (single-arch, deliberate), image
published as ronaldraygun/spaxel via the spaxel-build WorkflowTemplate
- Quality Gates item 4: document the amd64-only build decision (arm64 is
future work) instead of the contradictory multi-arch gate
- Integration Tests: correct location to the real test dirs
- Open Questions: remove the duplicated Multi-installation coordination bullet
- Bump Last updated to 2026-07-03; Status → maintenance mode
Co-Authored-By: Claude <noreply@anthropic.com>
Covers Spaxel overview, realistic CSI capabilities, privacy framing, the
go.work multi-module repo layout, docker-compose quickstart with key env
vars, and pointers to docs/plan, docs/notes, docs/research, and the
dashboard README. Accurate to the current tree (image ronaldraygun/spaxel);
no secrets or host paths.
Co-Authored-By: Claude <noreply@anthropic.com>
Both are implemented (mothership/internal/recording/buffer.go and
mothership/internal/ingestion/ratecontrol.go). Phase 2 is now complete.
Co-Authored-By: Claude <noreply@anthropic.com>
Phase 2 table showed "Dashboard presence indicator" as Pending, but it
is implemented in dashboard/live.html and dashboard/js/app.js
(updatePresenceIndicator, #presence-indicator styling). Update the
table row to Done and drop it from the "Remaining for Phase 2" list.
Co-Authored-By: Claude <noreply@anthropic.com>