Commit Graph
9 Commits
Author SHA1 Message Date
ruv e0c4207bd5 Merge PR #2152 (aepod) into release integration
# Conflicts:
#	firmware/esp32-csi-node/provision.py
2026-10-05 11:43:47 -04:00
ruv 044a41d2de Merge PR #2153 (aepod) into release integration 2026-10-05 11:43:14 -04:00
Mathew Beaneandclaude-flow d78a221607 feat(provision): read the WiFi password from a file or a prompt
--password puts the WiFi password in argv, where ps and shell history can
see it (#1754). This adds two ways to give it without that:

- --password-file PATH reads it from a file, dropping one trailing newline
  (\n or \r\n). The file must exist and not be empty. If group or others can
  read it, the script refuses and suggests chmod 600.
  --allow-insecure-password-file accepts such a file with a warning, for
  read-only mounts such as 0444 secrets.
- When --ssid is given with no password flag and stdin is a terminal, the
  script asks with getpass. It reuses a saved password only if it was saved
  for the same SSID. Without a terminal (scripts, CI) nothing is asked and
  the existing missing-credentials check applies.

--password still works, but now prints a one-line warning on stderr that
points at --password-file. The two flags are mutually exclusive. The
firmware README examples now use --password-file.

Tests: 10 new tests in tests/test_provision_secrets.py, with placeholder
credentials and getpass and the flasher stubbed. 8 fail on the previous
commit; the other 2 check that nothing is asked without a terminal or when a
saved password matches the SSID. The suite goes from 25 to 35 passing.

Refs #1754

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-04 16:50:43 -04:00
Mathew Beaneandclaude-flow 98c1d4a1e9 fix(provision): key saved state by board MAC, not serial port
provision.py saved each board's settings under the serial port path. Ports
get reused when boards are swapped (a second board on the same hub slot often
comes up as the same /dev/cu.usbmodem*), so the next board inherited the
previous board's node_id, target_port and channel without any sign (#1755).

- On a real flash the script reads the base MAC with `esptool read_mac` and
  keeps state in mac-<aabbccddeeff>.json. The record also stores the MAC and
  the port it was last written from.
- EUI-64 chips (C5, C6, H2) print an 8-byte "MAC:" line before "BASE MAC:";
  the parser takes the base MAC on those and the single MAC line elsewhere.
- Migration: if the board has no record yet and a port-keyed file from an
  earlier version exists, it is used once, with a notice that points at
  --reset, then removed after the save so no other board can pick it up.
- --state and --dry-run still never open the port. --state shows the board
  last provisioned from that port, and --mac selects a board by hand.
  --dry-run without --mac keeps the port-keyed file, which the QEMU swarm test
  relies on (--port /dev/null --dry-run).
- If the MAC can't be read, state stays keyed by port and the script says so.

Tests: tests/test_provision_mac_state.py adds 18 tests, using placeholder
MACs, with esptool, the NVS generator and the flasher stubbed. 15 of them
fail on the base; the other 3 guard behaviour the base already has (no serial
access on --dry-run, --reset, rejecting a bad --mac). The suite goes from 14
to 32 passing.

Not verified on hardware: the real esptool read_mac call. The output shapes
in the tests come from esptool 4.12 and 5.4.0 cmds.py.

Refs #1755

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-04 16:28:47 -04:00
Mathew Beaneandclaude-flow c00deadaf7 fix(provision): keep the WiFi password owner-only and out of --state output
The per-port state file holds the WiFi password and seed token in clear, but
save_state() created the directory and file with the default umask, so they
landed 0755/0644 and any local user could read them (#1754). --state also
printed both values verbatim, and that output gets pasted into issues.

- The state dir is created 0700 and state files 0600. Staging uses mkstemp
  (unique name, O_EXCL, 0600) plus fchmod, so a stale temp file or a planted
  symlink can't receive the secret.
- harden_state_dir() tightens the dir and every .json/.tmp file in it,
  including ones left by older versions. main() runs it before any read or
  early exit, so --state repairs old files too. It skips symlinks and files
  owned by other users, and only warns if chmod fails.
- The --dry-run NVS binary and the generator-failure CSV fallback are written
  0600 even when the file already exists. The generator's CSV and binary now
  live in a private mkdtemp() dir instead of the shared temp dir.
- --state shows password and seed_token as (set)/(empty). --show-secrets
  prints them.

Windows is unchanged: chmod can't set owner-only ACLs there.

Tests: tests/test_provision_secrets.py adds 11 tests; all 11 fail on the
base and the suite goes from 14 to 25 passing.

Refs #1754

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-04 16:25:43 -04:00
Mathew Beaneandclaude-flow 5a36be17d2 fix(provision): make --dry-run and failed --reset leave state intact
--dry-run persisted the merged state record, so the next real run merged
on top of values that never reached the device. It now skips save_state.
The NVS binary is still written to nvs_provision.bin because ADR-061 and
scripts/qemu-mesh-test.sh consume it.

--reset deleted the per-port state file before argument validation, so a
run that then failed (e.g. missing WiFi credentials) still lost the record.
It now only ignores the prior state; a successful flash overwrites the file
as before.

Also fix the module docstring's package name: the PyPI package is
esp-idf-nvs-partition-gen, matching the runtime hint.

Fixes #2096

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 16:03:30 -04:00
rUv dc7f6cd096 fix(provision): additive-by-default — close the #391 full-replace footgun (#647)
Closes #391 (full-replace footgun). Phase 1 of #574 (esp32-csi-node
provisioning UX). The mDNS discovery + USB-CDC pairing work in #574
remains future work; this PR handles only the provision.py-side fix.

Background: provision.py flashed a fresh NVS partition at 0x9000 every
invocation. The previous behaviour built that partition only from the
CLI flags passed on the current run — every key you didn't pass was
silently erased. We hit it ourselves earlier today: --force-partial
only suppressed the safety check but still wiped the SSID.

This PR replaces the full-replace semantic with a per-port state file
that captures every config value previously flashed from this machine.
On each invocation:

  1. Read ~/.config/wifi-densepose/esp32-provision-state/<port>.json
     (or %APPDATA%/... on Windows).
  2. Overlay the new CLI flags on top — CLI wins where set.
  3. Generate + flash NVS from the merged dict.
  4. Persist the merged dict back to the state file.

Net effect: the exact scenario from #391 + today's incident now
passes (test_partial_invocation_does_not_drop_unrelated_keys):

  python provision.py --port COM7 --ssid Net --password p --target-ip 10.0.0.5
  # later:
  python provision.py --port COM7 --seed-url http://10.0.0.99:8080
  # WiFi creds preserved, seed_url added.

New flags:
  --reset       Wipe per-port state before merging (recycled-board path).
  --state-dir   Override per-user state dir (XDG / %APPDATA% by default).
  --state       Print the merged state and exit (debug / inspection).

--force-partial preserved as a deprecation-flagged escape hatch.

State file caveats (in the module docstring): per-machine, atomic
write via .tmp + os.replace, future follow-up to add USB-CDC NVS dump
for device-authoritative merging is tracked in #574.

Tests: tests/test_provision_state.py — 11 tests covering load/save
round-trip, corrupt-JSON resilience, CLI-wins-over-prior, the exact
#391 case, falsy-but-not-None CLI override (node_id=0 must survive),
and serial-port path sanitization for /dev/ttyUSB0. 11/11 pass.

Live-tested end-to-end with --dry-run + --state inspection:
  first run:   ssid + password + target_ip persisted
  second run:  --seed-url added — WiFi creds intact in final state.
2026-05-19 17:31:41 -04:00
NgoQuocViet2001 3439fb1402 fix(provision): recognize swarm/hopping flags as config values (#617) 2026-05-19 10:03:58 -04:00
rUvandClaude 5a7f431b0e ADR-081: Implement 5-layer adaptive CSI mesh firmware kernel (#404)
* ADR-081: adaptive CSI mesh firmware kernel + scaffolding

Introduces a 5-layer firmware kernel that reframes the existing ESP32
modules as components of a chipset-agnostic architecture and authorizes
adaptive control + a compact feature-state stream as the default upstream.

Layers:
  L1 Radio Abstraction Layer  — rv_radio_ops_t vtable + ESP32 binding
  L2 Adaptive Controller      — fast/medium/slow loops (200ms/1s/30s)
  L3 Mesh Sensing Plane       — anchor/observer/relay/coordinator (spec)
  L4 On-device Feature Extr.  — rv_feature_state_t (magic 0xC5110006)
  L5 Rust handoff             — feature_state default; debug raw gated

Files:
  docs/adr/ADR-081-adaptive-csi-mesh-firmware-kernel.md  (new)
  firmware/esp32-csi-node/main/rv_radio_ops.h            (new)
  firmware/esp32-csi-node/main/rv_radio_ops_esp32.c      (new)
  firmware/esp32-csi-node/main/rv_feature_state.{h,c}    (new)
  firmware/esp32-csi-node/main/adaptive_controller.{h,c} (new)
  firmware/esp32-csi-node/main/main.c                    (wire L1+L2)
  firmware/esp32-csi-node/main/CMakeLists.txt            (add 4 sources)
  firmware/esp32-csi-node/main/Kconfig.projbuild         (controller knobs)
  CHANGELOG.md                                           (Unreleased)

Default policy is conservative: enable_channel_switch and
enable_role_change are off, so behavior matches today's firmware
unless an operator opts in via menuconfig. The pure
adaptive_controller_decide() is exposed for offline unit tests.

Reuses (does not rewrite): csi_collector, edge_processing (ADR-039),
swarm_bridge (ADR-066), secure_tdm (ADR-032), wasm_runtime (ADR-040).

* ADR-081: implement Layers 1/2/4 end-to-end + host tests + QEMU hooks

Turns the ADR-081 scaffolding into a working adaptive CSI mesh kernel:
Layer 1 radio abstraction has an ESP32 binding and a mock binding; Layer 2
adaptive controller runs on FreeRTOS timers; Layer 4 feature-state packet
is emitted at 5 Hz by default, replacing raw ADR-018 CSI as the default
upstream.

New files:
  firmware/esp32-csi-node/main/adaptive_controller_decide.c  (pure policy)
  firmware/esp32-csi-node/main/rv_radio_ops_mock.c           (QEMU binding)
  firmware/esp32-csi-node/tests/host/Makefile                (host tests)
  firmware/esp32-csi-node/tests/host/test_adaptive_controller.c
  firmware/esp32-csi-node/tests/host/test_rv_feature_state.c
  firmware/esp32-csi-node/tests/host/esp_err.h               (shim)
  firmware/esp32-csi-node/tests/host/.gitignore

Modified:
  adaptive_controller.c         — includes pure decide.c; emit_feature_state()
                                  wired into fast loop (200 ms = 5 Hz)
  rv_radio_ops_esp32.c          — get_health() fills pkt_yield + send_fail
  csi_collector.{c,h}           — pkt_yield/send_fail accessors (ADR-081 L1)
  rv_feature_state.h            — packed size corrected to 60 bytes
                                  (was incorrectly 80 in initial commit)
  main.c                        — mock binding registered under mock CSI
  CMakeLists.txt                — rv_radio_ops_mock.c under CSI_MOCK_ENABLED
  scripts/validate_qemu_output.py — 3 new ADR-081 checks (17/18/19)
  docs/adr/ADR-081-*.md         — status → Accepted (partial);
                                  implementation-status matrix; measured
                                  benchmarks (decide 3.2 ns, CRC32 614 ns);
                                  bandwidth 300 B/s @ 5 Hz (99.7% vs raw);
                                  verification section
  CHANGELOG.md                  — artifact-level entries

Tests (host, gcc -O2 -std=c11):
  test_adaptive_controller:  18/18 pass, decide() = 3.2 ns/call
  test_rv_feature_state:     15/15 pass, CRC32(56 B) = 614 ns/pkt, 87 MB/s
                             sizeof(rv_feature_state_t) == 60 asserted
                             IEEE CRC32 known vectors verified

Deferred (tracked in ADR-081 roadmap Phase 3/4):
  Layer 3 mesh-plane message types, role-assignment FSM, Rust-side mirror
  trait in crates/wifi-densepose-hardware/src/radio_ops.rs.

* ADR-081: Layer 3 mesh plane + Rust mirror trait — all 5 layers landed

Fully implements the remaining deferred pieces of the adaptive CSI mesh
firmware kernel. All 5 layers (Radio Abstraction, Adaptive Controller,
Mesh Sensing Plane, On-device Feature Extraction, Rust handoff) are
now implemented and host-tested end-to-end.

Layer 3 — Mesh Sensing Plane (firmware/esp32-csi-node/main/rv_mesh.{h,c}):
  * 4 node roles: Unassigned / Anchor / Observer / FusionRelay / Coordinator
  * 7 message types: TIME_SYNC, ROLE_ASSIGN, CHANNEL_PLAN,
    CALIBRATION_START, FEATURE_DELTA, HEALTH, ANOMALY_ALERT
  * 3 auth classes: None / HMAC-SHA256-session / Ed25519-batch
  * Payload types: rv_node_status_t (28 B), rv_anomaly_alert_t (28 B),
    rv_time_sync_t (16 B), rv_role_assign_t (16 B),
    rv_channel_plan_t (24 B), rv_calibration_start_t (20 B)
  * 16-byte envelope + payload + IEEE CRC32 trailer
  * Pure rv_mesh_encode()/rv_mesh_decode() plus typed convenience encoders
  * rv_mesh_send_health() + rv_mesh_send_anomaly() helpers

Controller wiring (adaptive_controller.c):
  * Slow loop (30 s default) now emits HEALTH
  * apply_decision() emits ANOMALY_ALERT on transitions to ALERT /
    DEGRADED
  * Role + mesh epoch tracked in module state; epoch bumps on role
    change

Layer 5 — Rust mirror (crates/wifi-densepose-hardware/src/radio_ops.rs):
  * RadioOps trait mirrors rv_radio_ops_t vtable
  * MockRadio backend for offline tests
  * MeshHeader / NodeStatus / AnomalyAlert types mirror rv_mesh.h
  * Byte-identical IEEE CRC32 (poly 0xEDB88320) verified against
    firmware test vectors (0xCBF43926 for "123456789")
  * decode_mesh / decode_node_status / decode_anomaly_alert / encode_health
  * 8 unit tests, including mesh_constants_match_firmware which asserts
    MESH_MAGIC/VERSION/HEADER_SIZE/MAX_PAYLOAD match rv_mesh.h
    byte-for-byte
  * Exported from lib.rs
  * signal/ruvector/train/mat crates untouched — satisfies ADR-081
    portability acceptance test

Tests (all passing):
  test_adaptive_controller:   18/18   (C, decide() 3.2 ns/call)
  test_rv_feature_state:      15/15   (C, CRC32 87 MB/s)
  test_rv_mesh:               27/27   (C, roundtrip 1.0 µs)
  radio_ops::tests (Rust):     8/8
  --- total:                 68/68 assertions green ---

Docs:
  * ADR-081 status flipped to Accepted
  * Implementation-status matrix updated; L3 + Rust mirror both
    marked Implemented
  * Benchmarks table extended with rv_mesh encode+decode roundtrip
  * Verification section updated with cargo test invocation
  * CHANGELOG: two new entries for L3 mesh plane + Rust mirror

Remaining follow-ups (Phase 3.5 polish, not blocking):
  * Mesh RX path (UDP listener + dispatch) on the firmware
  * Ed25519 signing for CHANNEL_PLAN / CALIBRATION_START
  * Hardware validation on COM7

* Add test_rv_mesh to host-test .gitignore

Fixes an untracked-file warning from the repo stop-hook: the compiled
binary was built by make but the .gitignore update was missed in
8dfb031. No source changes.

* Fix implicit decl of emit_feature_state in adaptive_controller

fast_loop_cb calls emit_feature_state() at line 224, but the static
definition is at line 256. GCC treats the implicit declaration as
non-static, then the real static definition conflicts, and
-Werror=all promotes both to hard build errors.

Add a forward declaration above the first use. Unblocks ESP32-S3
firmware build and all QEMU matrix jobs.

Co-Authored-By: claude-flow <ruv@ruv.net>

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-20 10:38:23 -04:00