Compare commits

...
127 Commits
Author SHA1 Message Date
rUv 0ef6b96fe1 docs: describe RuView-focused README animation 2026-10-06 09:25:36 -07:00
rUv 2d5472372f docs: focus animation on RuView sensing and spatial awareness 2026-10-06 09:25:34 -07:00
rUv efdf97ecc1 docs: add ruOS animation below RuView header 2026-10-06 09:12:38 -07:00
rUv 46d80ffc3a docs: upload Ruflo-powered ruOS desktop animation 2026-10-06 09:12:36 -07:00
rUv 0b15c0ebc9 Merge pull request #2163 from ruvnet/release/aepod-integration
release: integrate aepod PRs #2100-#2157 (38 PRs, review fixes, validated)
2026-10-05 13:52:42 -07:00
ruv 682a6f4538 docs(changelog): community batch PRs #2100-#2157
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-05 12:21:07 -04:00
ruv a72174c571 fix: review findings and cross-PR integration fixes for aepod release set
- mediatek: cap per-device maps at 64 with oldest-first eviction; clamp --csi-ring
- training: mark live-buffer fallback runs degraded (data_source/degraded) instead of plain "completed"
- multistatic: skip CIR blend when any frame in the cohort has a placeholder phase
- groundtruth: median count baseline; radar labels carry evidence=CLAIMED
- spatial-evidence: default proof CODE; MEASURED requires a reproducer
- scripts: collect-ground-truth guards non-JSON replies and unreadable token files
- docker: entrypoint accepts 1/true/yes/on for RUVIEW_UDP_INSECURE_LAN
- adaptive model: untrack v2/data/adaptive_model.json (#2135)
- docs: ADR-379 evidence tags, RUNBOOK/README OTA wording, NOTICE for vendored MediaTek patch
- tests: fresh timestamps for privacy tests (#2119 staleness), plan_source Result handling,
  drop obsolete qualcomm-binds-nothing test (#2126), ignore slow live-binary test (#2157)

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-05 12:08:21 -04:00
ruv fd323e483e Merge PR #2128 (aepod) into release integration
# Conflicts:
#	.github/workflows/ci.yml
2026-10-05 11:48:40 -04:00
ruv 9f0821cf69 Merge PR #2104 (aepod) into release integration 2026-10-05 11:48:25 -04:00
ruv 69fc9efff7 Merge PR #2145 (aepod) into release integration 2026-10-05 11:48:25 -04:00
ruv 20386cf313 Merge PR #2141 (aepod) into release integration
# Conflicts:
#	docs/adr/README.md
2026-10-05 11:48:24 -04:00
ruv c4fd6d9b36 Merge PR #2101 (aepod) into release integration
# Conflicts:
#	v2/crates/wifi-densepose-sensing-server/src/training_api.rs
2026-10-05 11:48:16 -04:00
ruv e223d86ba2 Merge PR #2144 (aepod) into release integration 2026-10-05 11:47:35 -04:00
ruv 52e5ebefbd Merge PR #2143 (aepod) into release integration 2026-10-05 11:47:35 -04:00
ruv ca44cd1a7b Merge PR #2117 (aepod) into release integration
# Conflicts:
#	docs/integrations/home-assistant.md
#	v2/crates/wifi-densepose-sensing-server/src/main.rs
2026-10-05 11:47:34 -04:00
ruv 5fe0a6a48d Merge PR #2105 (aepod) into release integration
# Conflicts:
#	v2/crates/wifi-densepose-sensing-server/src/main.rs
2026-10-05 11:46:33 -04:00
ruv da1ef6a17d Merge PR #2102 (aepod) into release integration
# Conflicts:
#	docs/adr/README.md
2026-10-05 11:46:15 -04:00
ruv b6c2fb5b7d Merge PR #2116 (aepod) into release integration 2026-10-05 11:46:08 -04:00
ruv b8b70b7df7 Merge PR #2115 (aepod) into release integration 2026-10-05 11:46:07 -04:00
ruv bb5d903724 Merge PR #2114 (aepod) into release integration
# Conflicts:
#	v2/crates/wifi-densepose-sensing-server/src/main.rs
2026-10-05 11:46:07 -04:00
ruv 1e2348abef Merge PR #2113 (aepod) into release integration
# Conflicts:
#	v2/crates/wifi-densepose-sensing-server/src/main.rs
2026-10-05 11:45:52 -04:00
ruv 2493185ab8 Merge PR #2112 (aepod) into release integration 2026-10-05 11:45:33 -04:00
ruv 678f63f4f3 Merge PR #2125 (aepod) into release integration
# Conflicts:
#	v2/crates/wifi-densepose-sensing-server/src/cli.rs
#	v2/crates/wifi-densepose-sensing-server/src/main.rs
2026-10-05 11:45:26 -04:00
ruv 0aba0fc467 Merge PR #2127 (aepod) into release integration
# Conflicts:
#	v2/crates/wifi-densepose-sensing-server/src/main.rs
2026-10-05 11:44:53 -04:00
ruv 6f550c39ed Merge PR #2157 (aepod) into release integration 2026-10-05 11:44:34 -04:00
ruv e9d0cef610 Merge PR #2133 (aepod) into release integration 2026-10-05 11:44:34 -04:00
ruv bb22f1b185 Merge PR #2109 (aepod) into release integration 2026-10-05 11:44:33 -04:00
ruv 7bc5fe1a53 Merge PR #2103 (aepod) into release integration 2026-10-05 11:44:33 -04:00
ruv 51e1788c7e Merge PR #2129 (aepod) into release integration 2026-10-05 11:44:32 -04:00
ruv 7b62297f3e Merge PR #2126 (aepod) into release integration 2026-10-05 11:44:31 -04:00
ruv 890e521a3a Merge PR #2132 (aepod) into release integration 2026-10-05 11:44:31 -04:00
ruv 444184f9ef Merge PR #2119 (aepod) into release integration 2026-10-05 11:44:30 -04:00
ruv 7955af5bc0 Merge PR #2107 (aepod) into release integration 2026-10-05 11:44:29 -04:00
ruv 273a2a8536 Merge PR #2108 (aepod) into release integration 2026-10-05 11:44:29 -04:00
ruv add0755958 Merge PR #2134 (aepod) into release integration 2026-10-05 11:44:28 -04:00
ruv de5f7edd16 Merge PR #2100 (aepod) into release integration 2026-10-05 11:44:22 -04:00
ruv 69e0729c61 Merge PR #2156 (aepod) into release integration 2026-10-05 11:44:22 -04:00
ruv 9baaf8434f Merge PR #2155 (aepod) into release integration 2026-10-05 11:44:21 -04:00
ruv 27eefb6a7f Merge PR #2140 (aepod) into release integration 2026-10-05 11:44:20 -04:00
ruv c6f7e8ca0d Merge PR #2120 (aepod) into release integration 2026-10-05 11:44:20 -04:00
ruv 474f1a904e Merge PR #2118 (aepod) into release integration 2026-10-05 11:44:19 -04:00
ruv ddaa54eba0 Merge PR #2110 (aepod) into release integration 2026-10-05 11:44:19 -04:00
ruv b3d31510ee Merge PR #2130 (aepod) into release integration 2026-10-05 11:44:18 -04:00
ruv bce512cb8d Merge PR #2131 (aepod) into release integration 2026-10-05 11:44:18 -04:00
ruv 9950ecc340 fix(provision): refuse to write credentials through a symlink with a clear error
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-05 11:44:17 -04:00
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
ruv 8d243a67f4 Merge PR #2111 (aepod) into release integration 2026-10-05 11:43:13 -04:00
Mathew Beaneandclaude-flow ee6717f035 fix(sensing-server): lock the CSI grid gate on the majority grid and report a stalled pipeline on /health (#1894)
The per-node grid gate locked onto the densest CSI grid a node had ever
sent and rejected every other grid. Real ESP32-S3 nodes interleave
several grids: one node sent 192 bins on 88% of frames and 306 on 6%, so
the gate locked onto the 306 minority and dropped about 90% of that
node's frames. A node whose radio moved to a sparser grid for good was
rejected forever; when every node did this, `tick` froze while /health
still reported ok.

The gate now votes over each node's last 64 frames and locks onto the
grid the node sends most. A challenger must outnumber the active grid
3:2 to take over, so interleaved grids don't flap the lock. A switch
clears the window as before. On an ESP32-C6 the HE-SU majority still
wins and the HT minority is still rejected.

/health keeps `status: "ok"` (the UI's server probe keys on it) and adds
a `processing` block: a live/stalled/no_input/idle state, the age of the
last published update, and lock-free UDP counters (datagrams, age of the
last datagram, datagrams dropped by the source allowlist). A frozen
pipeline can now be told apart from input that stopped arriving.
Allowlist drops also log a warning on the 1st, 2nd, 4th, 8th... drop
instead of only at debug level.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-04 17:31:47 -04:00
Mathew Beaneandclaude-flow f5a36666fc fix(hardware): accept ESP32 frames up to the firmware buffer size
Esp32CsiParser capped n_subcarriers at 256. Real ESP32-S3 nodes on
firmware 0.8.12 send 1-antenna frames with 306 subcarriers (612 I/Q
bytes), so the parser dropped about a third of live frames. Pointcloud
started hitting that cap once it switched to this parser.

The bound now comes from the firmware: frames are serialized into
CSI_MAX_FRAME_SIZE = 20 + 4 * 256 * 2 bytes, so a frame carries at most
1024 I/Q pairs across all antennas. The check is n_antennas *
n_subcarriers <= 1024.

HT/legacy bandwidth no longer maps subcarrier counts above 128 to 80 or
160 MHz. ESP32 radios use 20/40 MHz channels, and the larger counts
(192, 306) come from merged LLTF/HT-LTF buffers, so anything above 64
is reported as 40 MHz.

The pointcloud UDP receive buffer grows from 2048 to 4096 bytes so the
largest legal frame (2068 bytes) is not truncated.

Refs #1348

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-04 16:52:10 -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 db81dce7ba fix(multistatic): gate phase combining on a declared shared reference
The sensing-server bridge builds every node frame with a zero phase
placeholder and the fuser combined those phases across nodes as if they
were measured against one reference. ESP32 nodes each run an independent
crystal with a random PLL phase after reset; mesh time sync aligns frames,
not RF phase, so there is nothing to combine.

- Add PhaseReference (Unavailable / NodeLocal / Shared{id}) to
  MultiBandCsiFrame. The server bridge declares Unavailable.
- MultistaticFuser combines phase only when every frame declares the same
  Shared id. Otherwise fused_phase is empty and FusedSensingFrame carries
  PhaseFusion::AmplitudeOnly(reason). Amplitude fusion is unchanged.
- The ADR-134 CIR blend is skipped when the phase is a placeholder.
- TrustedOutput and /health/ready (trust.phase_fusion) report the mode
  and reason.
- ADR-029 status note updated.

Refs #1752, #1710, #1750.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-04 16:42:03 -04:00
Mathew Beaneandclaude-flow 3a8ec8139e fix(pointcloud): parse ESP32 CSI frames with the reference ADR-018 parser
The pointcloud parser read channel from byte 8, RSSI from byte 9, noise
floor from byte 10 and a "timestamp" from bytes 16..19. The firmware
(csi_serialize_frame) writes freq_mhz as u32 at 8..11, sequence at
12..15, RSSI at 16, noise floor at 17 and PPDU type/flags at 18..19. So
RSSI came out as the high byte of the frequency (+9 for 2437 MHz), and
the tomography attenuation was built from it.

parse_adr018 now delegates to wifi_densepose_hardware::Esp32CsiParser,
the same parser the rest of the workspace uses, and only reshapes its
output. CsiFrame.channel/timestamp_us become freq_mhz/sequence, which
are what the header actually carries. ADR-081 feature-state packets
(0xC5110006) are no longer decoded as CSI frames.

build_test_frame (used by csi-test) now emits the real layout. Golden
byte-by-byte tests cover single and two-antenna frames, truncation and
the feature-state packet; a cross-check test compares against the
reference parser.

Refs #1348

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-04 16:30:45 -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
Dragan Spiridonov 7c8aeacea2 Merge pull request #2147 from ruvnet/codex/calibrated-vitals-empty-20261004
fix(sensing): gate vital outputs on fresh calibrated occupancy
2026-10-04 15:07:58 +02:00
Dragan Spiridonov c3764a429f fix(sensing): require calibrated occupancy before publishing vitals 2026-10-04 11:50:13 +02:00
Mathew Beaneandclaude-flow 985e5d9449 feat(groundtruth): radar + CSI occupancy auto-labeller with session split and baseline
auto_label cuts a RuView .csi.jsonl recording at a radar log's window
boundaries and labels each cut empty/occupied with the radar's person
count and count interval, plus a small CSI summary (frame count, mean
RSSI, mean and variance of per-frame amplitude). The radar-to-recording
clock offset is a required, reported parameter. Windows are dropped with
a counted reason when the radar window is UNKNOWN, when too few CSI
frames fall inside it, or when an "empty" comes within empty_hold_ms of
an occupied window (a motion tracker can lose someone who sits still).

session_disjoint_split splits by session only and refuses a split where
either side would be empty or a named test session has no windows.
majority_baseline fits the no-CSI model on the training side (majority
occupancy class, mean count) and scores it on the test side, so any
occupancy accuracy built on these labels has a baseline next to it.

examples/radar_autolabel.rs is a small CLI over the three. Tests use
synthetic data only; nothing here has been run on a real capture yet.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-03 23:55:11 -04:00
Mathew Beaneandclaude-flow b033fd309e feat(groundtruth): radar window adapter for presence, count and range labels
ruview-groundtruth had an MmWave reference modality but no way to read a
radar log. RadarLabels reads one JSON summary per window (the shape a
UART radar reader writes: timestamp_ms, window_start_ms, health, source
flags, presence, target_count, max_target_count, distance_cm, targets
x/y) and turns each window into presence, person-count and nearest-range
labels.

Each label carries its basis and an uncertainty: a count interval from
the counts the window reports, the configured range sigma (a datasheet
figure, so CLAIMED), and the reader's quality. Windows from a simulated
or unverified source, a no_source reader, or with a presence flag that
disagrees with the count are UNKNOWN with a reason rather than guessed.
Labelled windows convert to ReferenceSeries for the existing alignment
and agreement code. Malformed lines are row-numbered InvalidRow errors.

serde_json moves from a dev-dependency to a dependency for the parser.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-03 23:55:10 -04:00
Mathew Beaneandclaude-flow 76af08a9b0 docs(adr): ADR-381 node self-survey from pairwise ranging
Proposed. Defines the RangeObservation record, a warn-only check mode
against --node-positions with tolerances from MEASURED ESP32-S3 FTM
bench data, per-boot calibration, and a fill mode gated off until a
source shows MEASURED p90 link error <= 0.3 m (expected from UWB).
Records the FTM bias between responder starts plainly and parks FTM
for check mode, coarse gating and time sync rather than dropping it.
Links ADR-144, ADR-281, rufield ADR-261, #2109 and #2133.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-03 23:08:04 -04:00
Mathew Beaneandclaude-flow 951ffcc254 feat(survey): add check-mode node self-survey crate (ADR-381)
Adds ruview-survey, a host-side check that compares pairwise node ranges
with the configured --node-positions and warns, never writes. It reports
per-link residuals, a fitted scale with feet/inch/cm/mm unit detection,
a suggested id swap (exhaustive up to 8 nodes, fewest-changes rule,
explicit ambiguity for symmetric layouts), moved nodes, and unknown or
unranged ids.

RangeObservation is source-agnostic (FTM, UWB, BLE CS, manual). Per-boot
calibration keys offsets by (initiator, responder, responder_boot)
because the measured FTM bias moved with every responder start. Fill
mode is not implemented; fill_gate keeps it off until a source has
MEASURED p90 link error <= 0.3 m over >= 6 links.

FTM tolerances come from a two-board ESP32-S3 bench (20 MHz, ch 1):
spread within a start <= 0.31 m, bias between starts -0.5 to +1.9 m at
1.14 m, clamp to 0 below ~0.8 m. Tests are SYNTHETIC.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-03 23:08:04 -04:00
Mathew Beaneandclaude-flow cbd84acf6c fix(unified): stop GaussianMap::decay compounding across calls
decay(now) multiplied confidence by exp(-(now - timestamp_ns)/tau_eff)
on every call, but timestamp_ns is the last-seen time and does not move
when decay runs. Calling decay(t1) then decay(t2) applied
exp(-(t1-ts)/tau) * exp(-(t2-ts)/tau) instead of exp(-(t2-ts)/tau), so
confidence fell faster the more often the caller ticked.

Track how far each Gaussian has already been decayed (decayed_to_ns)
and only apply the interval since max(decayed_to_ns, timestamp_ns).
A fresh observation still restarts the clock, and the lifetime-stretched
tau_eff is unchanged. Fusion and the merge pass keep the later of the
two decayed_to_ns values. The new field is #[serde(default)], so maps
serialized before this change load with 0 and decay from timestamp_ns
as before.

Adds two tests: one-shot vs. per-second decay must agree to 1e-12, and
the wall-vs-transient persistence case must hold when decay runs every
10 s.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-03 22:58:13 -04:00
Mathew Beaneandclaude-flow a1d7eb0bfd docs(spatial-evidence): ADR-382 for the RF wire contract, pin the v1 yaw convention
The spatial.evidence.v1 format is specified in WeftOS ADR-107 section 7.
ADR-382 records the part RuView emits (envelope, room_enu frame,
rf_gaussian, rf_link_observation, validation ranges, versioning) so the
contract is readable from this repository, and names ADR-107 as its origin.

ADR-107 was amended on 2026-10-03: one yaw convention (degrees
counter-clockwise from room +x), new tof_depth and radar_range types, and
an optional sensor block on radar_track_point. The two RF bodies did not
change. The crate now documents the yaw sense of an rf_gaussian
orientation and tests it (+90 degrees about +z maps local x onto room +y),
and tests that the amended example lines are rejected as unknown types by
this RF-only mirror.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-03 22:57:08 -04:00
Mathew Beaneandclaude-flow 3741e7ba43 feat(spatial-evidence): emit RF Gaussians and links as spatial.evidence.v1
New leaf crate ruview-spatial-evidence converts ruview-unified output
into the spatial.evidence.v1 JSONL wire format (WeftOS ADR-107 section 7):

- RfGaussian / GaussianMap -> rf_gaussian records. A Gaussian with
  synthetic provenance is always tagged SYNTHETIC; the caller can lower
  the proof tag, never raise it.
- Link measurements (tx/rx, measured vs free-space amplitude) ->
  rf_link_observation with excess_loss_db = 20 log10(free / measured).
  The Friis reference comes from ruview-unified's channel model.

The v1 envelope and the two RF bodies are mirrored with local serde
structs, so there is no WeftOS dependency. Validation follows the v1
rules: schema version, id charset and length, finite values, coordinate,
frequency, scale, occupancy, confidence and excess-loss ranges, and the
16 KiB line limit. Golden tests compare against the ADR example lines.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-03 18:18:41 -04:00
Mathew Beaneandclaude-flow fabb0c8233 fix(sensing-server): refuse a routable HTTP bind when API auth is off
The UDP receiver already refuses a routable bind without a source
allowlist (ADR-296), and the Docker entrypoint refuses a routable bind
without RUVIEW_API_TOKEN (#864). Running the binary directly with
--bind-addr 0.0.0.0 and no auth only logged that auth was off.

The server now exits at startup when the HTTP/WebSocket bind is not
loopback and API auth (RUVIEW_API_TOKEN or RUVIEW_OAUTH_ISSUER) is off,
unless RUVIEW_ALLOW_UNAUTHENTICATED is set, in which case it starts and
logs a warning. Loopback binds are unaffected. The conditions match the
entrypoint's, so a container that starts today still starts.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 22:26:26 -04:00
Mathew Beaneandclaude-flow da2304985a fix(sensing): stop absent nodes reporting a person count of 1
Every person count is already gated on presence, but two presence
decisions disagreed with the motion level published next to them:

- smooth_and_classify[_node] set presence = smoothed_motion > 0.03 while
  the debounced level only leaves "absent" above 0.04. A quiet room that
  settles in (0.03, 0.04] read motion_level "absent" with presence true,
  so the count stayed at 1.
- The raw-CSI per-node count was gated on the per-frame classification,
  which an adaptive-model label overrides. /api/v1/nodes could show a
  node as "absent" with person_count 1 while the ADR-297 room vote (which
  reads the node's debounced level) already said the room was empty.

Presence now follows the debounced level, as the room vote already does,
and the per-node count is gated on the node's own published level.
score_to_person_count keeps its floor of 1: it only runs when presence is
true, and an empty room reports 0 through the gate.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 22:21:48 -04:00
Mathew Beaneandclaude-flow 8c6d29bf83 fix(sensing-server): report measured stream fps and real WebSocket client count
/api/v1/stream/status returned "fps": 10 whenever tick > 1, whatever the
real rate, and both it and /health counted every broadcast subscriber
(recorder, MQTT bridge) as a client. The dashboard Streaming card read
"IDLE, 0 client(s)" for up to 30 s while the page was streaming, because
its first /health poll lands before the page's socket connects; with auth
on, the ticket round-trip widens that gap.

- New stream_stats module: UpdateRateMeter (sliding 5 s window, bounded at
  1024 samples) and WsClientCounter with an RAII guard.
- AppStateInner::advance_tick() replaces the five `s.tick += 1` sites and
  records each sensing update in the meter.
- /api/v1/stream/status now returns the measured update rate as `fps`
  (0 when nothing flows), `active` from that rate, `clients` from the
  WebSocket counter, `nodes` with each fresh node's measured csi_fps (null
  until its EMA has 5 samples) and `csi_fps_total`.
- /health and the /health/health stream component count WebSocket clients
  on /ws/sensing and /api/v1/stream/pose; the stream component also carries
  the measured rate.
- DashboardTab re-reads health 500 ms after the sensing socket connects or
  drops, so the card follows the socket instead of waiting for the next
  30 s poll.

Fixes #2087

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 22:15:23 -04:00
Mathew Beaneandclaude-flow 5cffe9e7db fix(sensing-server): accept vendor --source values and bind UDP for them
The previous commit limited --source to auto/esp32/wifi/simulated, which
rejected the vendor names the server already keys on: mediatek, qualcomm,
realtek (RTL8720F radar) and realtek_csi (RTL8721Dx CSI). They are the
labels the UDP receiver writes and effective_source() ages.

On 03a89793 those names fell into the catch-all plan, which bound no UDP
receiver, so vendor frames were never ingested. They now plan like esp32
(UDP receiver bound) without the ESP32-shaped simulator. Anything else,
including case variants and runtime-only labels like "mediatek:simulated",
is still rejected.

Refs #2097

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 22:13:38 -04:00
Mathew Beaneandclaude-flow c51a1cf888 fix(sensing-server): apply --privacy-mode to REST, WebSocket and recordings
--privacy-mode was declared in the MQTT arg group and read only by the
MQTT publisher, so /api/v1/sensing/latest, /api/v1/pose/current, the
/ws/sensing and /api/v1/stream/pose streams and recordings kept serving
pose keypoints (and heart/breathing rate when vitals are published).

- Move --privacy-mode (env RUVIEW_PRIVACY_MODE) out of MqttArgs to a
  top-level server flag. Spelling and env var are unchanged. The MQTT
  config takes its value from the server flag.
- Add privacy_filter: one key filter for vital_signs, HR/BR leaves,
  enhanced_breathing, keypoints, pose_keypoints and joints_m.
- REST: middleware strips those keys from every JSON response on the
  HTTP and WS ports. Unparseable JSON is withheld with a 500.
- WebSocket: /ws/sensing frames and /api/v1/stream/pose messages are
  filtered; the pose physics assessment is withheld.
- /api/v1/pose/current?view=both|refined returns 403 privacy_mode.
- Recordings write filtered frames.
- Default (flag off) behaviour is unchanged; the middleware is a no-op.

Fixes #2094

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 22:12:45 -04:00
Mathew Beaneandclaude-flow 522374584c fix(ui): stop retrying and ask for the API token when the server requires one
With RUVIEW_API_TOKEN set on the server and no token in the browser, the
sensing socket was refused, closed with 1006, and retried forever while the
header said "Connecting...". A browser cannot see the 401 on a refused
upgrade, so the service could not tell this from an outage.

- ws-ticket.js: resolveWsUrl() reports a stored token the server refuses
  (401/403) as 'rejected'. probeWsAuthRequired() repeats the ticket request
  with no bearer after an unticketed socket fails to open; a 401 means the
  server wants a token this browser does not have ('missing'). The probe
  only runs on the failure path, so auth-off servers see no extra request.
  withWsTicket() keeps its string contract for the other callers.
- sensing.service.js: new 'auth-required' state and dataSource. Entering it
  clears the retry timer; reconnect() leaves it.
- The header widget shows "Token required", the global banner explains it,
  and both open QuickSettings on API Access (once automatically per page
  load, then on click). Tab banners and the dashboard indicator get labels.
- Tests: ws-ticket.test.mjs (+6) and a new sensing.service.test.mjs (5),
  added to the CI UI test step.

Fixes #2099

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 22:12:23 -04:00
Mathew Beaneandclaude-flow 5755d9f11f fix(sensing-server): status waits for frames; reject unknown --source
/api/v1/status reported source_state "live_unverified" for a configured
live source before any frame had arrived, because source_state() fed a
fixed zero frame age into the ADR-295 resolver. It now uses the real age
of the last accepted frame for the current source, so the states are:

- disconnected (waiting_for_frames: true) until the first frame
- live_unverified while frames arrive within ESP32_OFFLINE_TIMEOUT (5 s)
- stale once they stop for longer than that

The response also carries waiting_for_frames and last_frame_age_ms.
ADR-270 vendor events get their own receive clock so they age the same
way.

An unknown --source value (for example "macos") used to start a server
with no data task. Clap now rejects it with the list of valid values
(auto, esp32, wifi, simulated, simulate), and plan_source returns an
error for anything else.

Docs: user guide lists the valid --source values and status states, the
macOS section uses --source wifi, and the Linux section says there is no
Linux RSSI source in the server yet.

Refs #2097

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 22:09:50 -04:00
Mathew Beaneandclaude-flow 3bf993275e fix(sensing-server): give the governed fuser the configured node positions
EngineBridge built its StreamingEngine without ever calling
set_node_positions, so the governed fuser placed every node at the
origin even when --node-positions was set. Startup now passes the same
keyed map the sibling multistatic_fuser gets, via
EngineBridge::with_node_positions. The fuser resolves positions by
node_id per frame, so nodes discovered later need no extra call.

/api/v1/status (and /health/ready) now reports the map under
trust.node_positions so the configuration can be checked at runtime.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 22:08:58 -04:00
Mathew Beaneandclaude-flow 05ea4feaed fix(sensing-server): keep recordings and models under --data-dir
Recordings, the recording list/delete routes, the trainer's dataset
loader and model export, the models routes and the adaptive classifier
all used paths relative to the process cwd (`data/recordings`,
`data/models`, `data/adaptive_model.json`), so `--data-dir` only covered
config and the session secret.

Add `recordings_dir(data_dir)` and `models_dir(data_dir)` and use them
everywhere. `MODELS_DIR` still overrides the models directory, and the
trainer now honours it too. With the default `--data-dir data` the paths
are unchanged.

Fixes #2088

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 22:07:11 -04:00
Mathew Beaneandclaude-flow 7af5477f04 fix(scripts): parse the ADR-018 header layout in the UDP capture scripts
Both capture scripts decoded a header layout the firmware does not send.
csi_collector.c and esp32_parser.rs use: magic u32 @0, node_id @4,
n_antennas @5, n_subcarriers u16 @6, freq_mhz u32 @8, sequence u32 @12,
rssi i8 @16, noise_floor i8 @17, ppdu_type @18, flags @19, then
n_antennas * n_subcarriers int8 I/Q pairs from @20.

- collect-training-data.py read a 16-byte header and int16 I/Q, so its
  length check rejected every real single-antenna frame. It now reads the
  20-byte header and int8 pairs, records freq/channel/seq/ppdu/flags, and
  lets the recorder stamp receive time (the header has no timestamp).
- record-csi-udp.py read rssi from byte 6 and channel from byte 7 (the
  n_subcarriers bytes) and treated bytes 8-19 as I/Q, so a 64-subcarrier
  frame came out as 70 subcarriers with header bytes in the first six
  amplitudes. It now checks the magic, reads the real fields, and writes
  only the I/Q block to iq_hex.
- Add scripts/tests/test_capture_adr018.py, which builds frames with the
  esp32_parser.rs test layout and checks the decoded fields.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 22:01:22 -04:00
Mathew Beaneandclaude-flow 84f5e19e9d fix(scripts): run the proof with python3 and report INCOMPLETE when a gate is skipped
prove.sh looked only for `python`, so on macOS (python3 only) it skipped
the deterministic proof and still printed RESULT: PASS with exit 0.

- Pick $PYTHON, then python3, then python.
- Check numpy/scipy import before running verify.py and print the
  lockfile install command when they are missing; warn when installed
  versions differ from archive/v1/requirements-lock.txt.
- Run verify.py single-threaded, as CI does.
- Hard gates that cannot run (no cargo, no Python, missing deps) are
  reported as [NOT RUN] and the run ends INCOMPLETE with exit 2.
  Data/hardware-gated claims are unchanged.
- Tell people to stop any local server on UDP 5005 first (#2086).

Fixes #2095

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 21:57:49 -04:00
Mathew Beaneandclaude-flow 1a50162598 fix(sensing-server): stop serving a frozen /sensing/latest as current
latest_update is only rewritten when a source produces a new update. When
every source went quiet, /api/v1/sensing/latest kept returning the last
classification indefinitely, with no marker that it was old. A one-off
bogus frame could leave "absent 0.97" frozen there.

Past 10 s, the endpoint now returns
{"status":"stale","age_ms":...,"source":effective_source()} instead of
the old payload. Fresh payloads gain an age_ms field. "no data yet" is
unchanged. This is separate from #2060, which excludes stale nodes from
the room vote: that changes what a new update says, while this bounds how
long an old update is served.

Developed in whitsentry, a RuView-based project.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 19:22:26 -04:00
Mathew Beaneandclaude-flow 0eb952a9c5 fix(sensing-server): evict stale node_states on a timer, not only on frame arrival
Stale node eviction (60 s) lived inside the ESP32 receive path and ran
every 100 ticks, so it only happened while ESP32 frames kept arriving.
After a burst of bogus node ids, or once every node went quiet, nothing
ever removed the entries, and they stayed listed in /api/v1/nodes for the
life of the process.

Move the existing rule unchanged into evict_stale_node_states (60 s,
nodes that never reported included). Call it from the receive path as
before, and from a new node_states_eviction_task that sweeps every 30 s
alongside the UDP receiver.

Developed in whitsentry, a RuView-based project.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 19:22:26 -04:00
Mathew Beaneandclaude-flow 08f0f8859b fix(sensing-server): reject ESP32 CSI frames with zero antennas or subcarriers
parse_esp32_frame accepted any datagram that started with the 0xC511_0001
magic and had a 20-byte header. With n_antennas or n_subcarriers at zero,
the expected length collapses to the header itself, so the length check
passed and the frame minted a node carrying empty amplitude/phase arrays.
A non-ESP32 sender on the shared UDP port produced exactly that: bogus
nodes in /api/v1/nodes.

Return None when either count is zero. Well-formed frames parse as before.
The new tests cover zero antennas, zero subcarriers, and a bare header,
and check that a well-formed frame still parses.

Developed in whitsentry, a RuView-based project.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 19:22:26 -04:00
Mathew Beaneandclaude-flow 8c63d7b558 docs(mqtt): document the published entity set and add an ADR-115 P4.5 status note
home-assistant.md now describes current behaviour: the 15 announced
entities (13 with --privacy-mode) with their source and availability
rule, the entities that are not announced and why, publish rates and
retention, the persisted client id, and what privacy mode does and does
not cover. The guide no longer documents --semantic* flags that
sensing-server does not accept, and it drops the HIPAA/GDPR/CCPA
claims.

ADR-115 §11 records what P4.5 wired and what it did not, with a
before/after measurement against a real broker (MEASURED, one live
node).

Refs #2085, #2093

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 18:46:21 -04:00
Mathew Beaneandclaude-flow e351d0fce1 feat(mqtt): wire semantic states into the publisher, announce only sourced entities
Discovery announced 20 entities per device, but only 6 ever received
state (#2085). presence/state was published on every CSI frame, and the
per-process default client id gave Home Assistant new devices on every
restart (#2093).

- mqtt::planner maps each snapshot to availability, state and events.
  It runs one SemanticBus per node at 1 Hz (ADR-115 P4.5) and gates
  binary states on change with a 60 s retained heartbeat. Sensors keep
  their configured rates. Each entity is `online` only while the server
  has a source for it; otherwise it is `offline` and gets no state.
- Entities with no source on the sensing broadcast are no longer
  announced: zone_occupancy, bathroom_occupied, bed_exit,
  multi_room_transition (no zones), meeting_in_progress (motion is
  three-level) and pose (pose_keypoints is never set). 15 remain, 13
  with --privacy-mode.
- mqtt::bridge, moved from main.rs, reads only sensing_update. That
  removes the phantom aggregate device that edge_vitals frames created.
  It takes fall events from the edge_vitals rising edge and
  motion_energy from motion_band_power (previously always 0.0), and maps
  present_still to 0 % motion.
- The default client id is wifi-densepose-<8 hex>, persisted in
  <data-dir>/mqtt_client_id. --mqtt-client-id still overrides it.

Refs #2085, #2093

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 18:46:11 -04:00
Mathew Beaneandclaude-flow ea6da967d2 docs(mediatek): OpenWrt WN586X3 CSI patch set, router CSI guide, ADR-266 amendment
- firmware/openwrt-wn586x3/: the patch set that made a Wavlink WL-WN586X3
  Rev A (MT7981B + MT7976C) emit CSI on OpenWrt 24.10.8. It contains:
  MediaTek's mt7915 CSI driver patch rebased onto the mt76 revision 24.10.8
  pins (BSD-3-Clause-Clear, MediaTek author line kept); the Rev A board
  patch (GPL-2.0); a buffer-overrun fix for `mt76-vendor dump csi`; and
  an attribute-enum fix for MtkCSIdump. Each patch keeps the licence of
  the code it modifies. There are no images, firmware blobs, or captures.
- docs/mediatek-router-csi.md: an operator guide covering supported
  silicon, the image build route that was tested, arming CSI, the
  bridge, `--source mediatek`, endpoints and provenance labels, what the
  data is not, evidence, and troubleshooting. Every claim is tagged
  MEASURED, CODE-DERIVED, CLAIMED, or SYNTHETIC.
- scripts/mediatek-csi-phase-stability.py: a read-only reproducer for
  the one MEASURED offline result. On one MT7976C radio, the cross-chain
  phase arg(H0*conj(H1)) moves 0.014-0.039 rad (p50) between adjacent
  frames for well-sampled transmitters, against about 1.56 rad for raw
  per-chain phase, which is what random phase gives. It never prints a
  transmitter address. The captures it was run on are not in the tree.
- ADR-266 Amendment 1: vendor CSI exists on MT7981 through MediaTek's
  public feed patch, demonstrated on one board model, and still
  `mediatek:physical-unvalidated`. The hardware gates remain open.
- user-guide.md and the bridge README link to the guide.

Developed in whitsentry, a RuView-based project.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 18:32:25 -04:00
Mathew Beaneandclaude-flow f44f25d596 feat(mtk-bridge): bridge MT7981 vendor CSI into MTC1
Adds `wifi-densepose-mtk-bridge`. It converts MediaTek mt76 vendor CSI
into ADR-267 MTC1 frames for the sensing server's UDP ingest.

Inputs:
- `--replay`: an `mt76-vendor dump csi` JSON file, or a `.mtkcap`
  recording. Records are grouped into frames by the driver's last-chain
  bit, per transmitter address, so HT 2x2 and legacy 1x2 runs from one
  radio each close with their real dimensions. Concatenated and duplicate
  dumps (the vendor tool appends) are reported, not hidden.
- `--listen`: MtkCSIdump UDP datagrams. They carry no packet identity, so
  each datagram becomes one 1x1 frame rather than a guessed pairing.
  Missing RSSI/noise is the -128 dBm sentinel, and PPDU type is `Unknown`.

Provenance:
- The bridge never sets CALIBRATED.
- A replayed file must be either attested (`--captured-on
  <model>/<firmware>`) or declared `--synthetic`, or the bridge refuses to
  start.
- Synthetic is sticky. It can come from a sidecar, a `.synthetic.` file
  name, or a capture header, and none of these can be overridden.
- `--listen` requires a `--listen-allow` CIDR list. Other sources are
  dropped and counted, never decoded.

Fixtures are fabricated and use locally administered addresses. The
assembler tests that model the two live dump loops use stand-in addresses,
not the real clients'. Captures written under the bridge's earlier format
tag still load.

Tests: 51 unit and 39 integration tests, plus clippy -D warnings.

Developed in whitsentry, a RuView-based project.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 18:30:00 -04:00
Mathew Beaneandclaude-flow 8ecac18978 feat(sensing-server): labelled MediaTek amplitude heuristic and activity index
Real MediaTek frames now produce a room update, but from a heuristic that
is labelled as one on every output. The validated ESP32 pipeline needs
per-subcarrier phase history this path does not feed it, so it is not
used here and is never impersonated.

- `mediatek-amplitude-heuristic-v0` (CLAIMED, no labelled evaluation):
  presence and motion from per-device mean and peak amplitude over a 10 s
  time window (not a sample count, because arrivals are bursty).
  Hysteresis means one dip cannot flip the room to absent. Below a
  confidence of 0.3, a device reports `unknown` and abstains from the
  room vote rather than asserting "absent".
  `SensingUpdate.classifier` names it; `nodes[].device_id` and
  `nodes[].mediatek_diagnostics` say why each device reads as it does.
- `activity-index-v0` (CLAIMED): a fast/slow fluctuation index against
  each device's own baseline, plus an absolute level against a learned
  quiet floor. It is served in `SensingUpdate.activity` and
  `GET /api/v1/csi/mediatek/activity`, with
  `is_occupancy_estimate: false` on every block. `--activity-state <path>`
  persists learned floors across restarts.
- The summary endpoint adds the heuristic's window statistics.
- The MQTT per-node topics key on `device_id` when it is present, so two
  64-bit receiver ids cannot collide on `node_id` 0.

Developed in whitsentry, a RuView-based project.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 18:28:26 -04:00
Mathew Beaneandclaude-flow e5f88b88fe feat(sensing-server): per-device MediaTek CSI ingest and inspection
The server kept one MediaTek snapshot in total, so two MT7981 receivers
overwrote each other, and `--source mediatek` fell through to the
catch-all source plan and bound no UDP socket at all.

- `--source mediatek` binds the UDP receiver and does not start the
  ESP32-shaped simulator, which has no MediaTek branch and would otherwise
  publish a simulated room classification next to real frames.
- Snapshots are kept per `device_id`.
  `GET /api/v1/csi/mediatek/devices` lists them with `age_ms` and drops
  any device silent for over 30 s.
- A bounded per-device ring (`--csi-ring`, default 256 frames, about
  0.5 MiB for a 2x2 device at 64 subcarriers) keeps per-chain,
  per-subcarrier amplitude and phase. It serves
  `/devices/:device_id/frames` and `/devices/:device_id/summary`. Chains
  are keyed by (tx, rx) identity, so a device that interleaves 1x2 legacy
  and 2x2 HT frames is reported correctly.
- New provenance label `mediatek:physical-unvalidated` for real-silicon
  frames that are not calibrated. `SYNTHETIC` still takes precedence over
  every other flag. Plain `mediatek` stays reserved for calibrated frames.

No classifier changes in this commit.

Developed in whitsentry, a RuView-based project.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 18:27:19 -04:00
Mathew Beaneandclaude-flow ebb303cbb8 feat(hardware): carry legacy and unknown PPDU types in MediaTek MTC1
A real MT7981 (WL-WN586X3, MT7981B + MT7976C) CSI report arrived with
rx_mode = MT_PHY_TYPE_OFDM for a 2.4 GHz client. The MTC1 PpduType had no
pre-HT value, so a bridge had to drop the frame. MediaTek's CSI driver
treats legacy OFDM as its own tone-mask group, and association traffic is
legacy-rate, so these frames are routine rather than a corner case.

Add PpduType::Legacy = 6 and PpduType::Unknown = 7. Values 1-5 and the
72-byte header are unchanged. A decoder built before this change rejects
6 and 7 as UnknownPpduType (fails safe, drops the frame), so MTC1
consumers need a rebuild.

ADR-267 gains Amendment 1 recording the change, its compatibility, and the
observed 1x1x64 dimensions of the motivating capture. The capture is not
in the tree: it holds a client transmitter address and raw CSI.

Developed in whitsentry, a RuView-based project.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 18:17:36 -04:00
Mathew Beaneandclaude-flow c80f618119 fix(sensing-server): show which nodes use the default position (#1804)
Nodes not named in --node-positions report [2.0, 0.0, 1.5], and the boot
line counts parsed entries whether or not any matches a live node id. A
config keyed by the wrong ids looked applied and did nothing.

- WARN once per node id when a fresh node has no configured position,
  naming the configured ids (INFO when no positions are configured).
- /api/v1/nodes: add position_configured per node.
- /api/v1/status: add node_positions with configured_node_ids,
  default_position and default_position_node_ids.
- At boot, WARN that entries without a node_id: prefix are keyed by list
  index from 0, with the entry -> node mapping. Semantics are unchanged.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 16:17:25 -04:00
Mathew Beaneandclaude-flow 3b542f8a85 fix(docker): make UDP CSI ingest posture explicit in the container
Since ADR-296 the sensing server binds its UDP receiver to 127.0.0.1 by
default, and docker-entrypoint.sh never set a UDP bind. Inside a
container that address cannot receive from the published 5005/udp port,
so the default image never shows a node (#2089, follow-up to #1394).

The entrypoint now binds UDP on 0.0.0.0 only when the operator has set
an ADR-296 source guard (RUVIEW_UDP_ALLOW, RUVIEW_UDP_INSECURE_LAN, or
the matching CLI flags). Without a guard it keeps the loopback bind and
prints a note explaining why no node will appear and what to set,
including the macOS bridge-gateway allowlist. An explicit RUVIEW_UDP_BIND
or --udp-bind still wins.

I chose this over defaulting the bind to 0.0.0.0 unconditionally. With
no guard that would make the server refuse to start (exit 1) in auto
mode, which binds UDP, and the guard would still have to be supplied. The
change keeps ADR-296's fail-closed rule as is: a routable bind never
happens without an allowlist or the explicit insecure opt-in. Auth
defaults are unchanged.

The entrypoint also normalizes RUVIEW_UDP_INSECURE_LAN=1/yes/on to true,
since clap rejects 1 for that flag.

The compose service passes RUVIEW_API_TOKEN, the RUVIEW_UDP_* variables
and SENSING_ALLOWED_HOSTS through from the shell, with notes on the
exit-64 auth check, the macOS gateway allowlist and the 421 Host check.
The README and user-guide Docker quickstarts now set a token, and the
user guide gains a "Receiving ESP32 frames in Docker" section.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 16:13:10 -04:00
Mathew Beaneandclaude-flow ef01c9e2be fix(sensing-server): accept 1/0/yes/no/on/off for boolean env flags
clap's default bool parser only takes true/false, so
RUVIEW_UDP_INSECURE_LAN=1 exited 2 with "invalid value '1'". Add a
shared parse_env_bool value parser (1/0, true/false, yes/no, on/off,
case-insensitive; anything else is still an error) and apply it to every
bool+env flag: RUVIEW_UDP_INSECURE_LAN, RUVIEW_NO_MDNS,
RUVIEW_NO_EDGE_REGISTRY, RUVIEW_MQTT, RUVIEW_MQTT_TLS,
RUVIEW_PRIVACY_MODE and RUVIEW_MATTER. The bare --flag form is
unchanged.

The Docker entrypoint now accepts RUVIEW_ALLOW_UNAUTHENTICATED=
true/yes/on (any case) as well as 1.

Fixes #2091

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 16:07:20 -04:00
Mathew Beaneandclaude-flow abeca0f0ed fix(sensing-server): send multi-node test frames to an ephemeral port
The multi_node_test UDP smoke tests sent synthetic ADR-018 CSI and
vitals frames to 127.0.0.1:5005, the sensing server's default port.
Because the tests are not ignored, a routine `cargo test` (and
scripts/prove.sh) could inject up to 255 fake node IDs into a locally
running server, and the hardcoded port could collide with other
processes.

Each test now binds its own receiver on 127.0.0.1:0 and sends to the
OS-assigned address, keeping the receiver alive for the test's
duration. The tests still only exercise the sender and frame-builder
path; no server is involved.

Ported from whitsentry, a RuView-based project, which carries the same fix.

Closes #2086

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 16:04:54 -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
Mathew Beaneandclaude-flow 559528155f fix(sensing-server): train the adaptive classifier per node, as it runs
Runtime classification is per node, from that node's own features and
amplitudes. The trainer instead built one sample per recorded line from the
fused frame-level features plus the first node's amplitudes, so a model
trained on a multi-node recording learned a different input distribution
from the one it is later asked to classify.

- load_recording now emits one sample per node per new frame, from that
  node's node_features entry and amplitude vector. A node contributes only
  when its amplitude vector changed since the previous line (every broadcast
  snapshots all nodes), and stale nodes are skipped. Lines without
  node_features (older single-node recordings) fall back to the frame-level
  features.
- Runtime subcarrier statistics use the same 56-subcarrier window that
  sensing_update, and therefore every recording, stores
  (RECORDED_AMPLITUDE_LEN, now shared with the broadcast in main.rs).
- The trainer stamps model version 2 (TRAINED_MODEL_VERSION), so a freshly
  trained model consumes the measured temporal dominant frequency (ADR-356)
  instead of being switched to the legacy subcarrier proxy at runtime.
- The training accuracy log line is labelled in-sample and printed as a
  percentage (it printed the 0..1 fraction with a % sign).

Tests: cargo test -p wifi-densepose-sensing-server --no-default-features:
bin 337 passed (5 new node_sample_tests), lib 539 passed,
integration tests pass. Clippy reports nothing on the changed lines.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 03:15:05 -04:00
Mathew Beaneandclaude-flow f161abf582 feat(sensing-server): loopback UDP tee for second consumers (ADR-380)
Add --udp-tee / RUVIEW_UDP_TEE: every datagram the source allowlist admits
is copied unchanged to up to four loopback targets, so a fusion host or
Cognitum cog can read full I/Q, sequence numbers and sync packets without
binding the CSI port. Targets must be loopback and may not reuse the
listener port or port 0. Sends are non-blocking and drops are counted. Off
by default.

Tests: cargo test -p wifi-densepose-sensing-server --no-default-features
--lib udp_tee (6 passed) and --bin sensing-server (332 passed). Loopback
smoke test of the debug binary: a 288-byte datagram sent to the UDP port
arrived byte for byte at the tee target, and a non-loopback --udp-tee
target made the server exit 1 with "must be a loopback address".

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 03:12:19 -04:00
Mathew Beaneandclaude-flow a4f80e63a2 docs(adr): ADR-379 OTA first-boot health check, with hardware evidence
Records the decision behind the two preceding firmware commits: why an
OTA'd image has to confirm itself, the health signals (STA IP plus one CSI
frame accepted by the stream sender), the 30 s minimum uptime and 120 s
deadline, the FORCE_FAIL test hook, ota_state in /ota/status, and the httpd
stack sizing.

MEASURED 2026-09-29:
- ESP32-S3 (16 MB, rollback bootloader): OTA push to ota_1, HTTP 200;
  "httpd stack after POST /ota: 4900 of 8192 bytes never used";
  pending_verify then valid; still valid on ota_1 after a hard reset.
  A FORCE_FAIL build pushed to ota_0 rolled back to ota_1 after ~30 s.
- ESP32-C6 (4 MB layout, rollback bootloader): push to ota_1, HTTP 200;
  pending_verify then valid ~28 s later; still valid after a hard reset.

Not yet verified: the 120 s no-IP timeout path, a true power cycle (an RTS
hard reset was used), and forced rollback on the C6.

Numbered 379: main has up to ADR-378 and open PRs claim nothing higher (2026-10-02).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 03:12:12 -04:00
Mathew Beaneandclaude-flow 43f5f99108 fix(scripts): send the API token from collect-ground-truth and check the reply
With authentication enabled, collect-ground-truth.py could not start a CSI
recording: it sent no bearer token. It also treated any HTTP 200 as success,
but the recording endpoints answer 200 with {"success": false, "error": ...}
for refusals such as "recording already in progress". Either way it went on
capturing camera keypoints with no paired CSI and only printed a warning.

- Read the token from --token-file, else RUVIEW_API_TOKEN, and send it as
  Authorization: Bearer on start and stop.
- post_json() returns False when the body says success: false.
- Name the CSI recording gt_<timestamp> so it pairs with
  keypoints_<timestamp>.jsonl.
- Exit with an error when the recording cannot start, unless
  --allow-no-csi is given.

Tests: py_compile; scripts/tests (15 passed); a local smoke test against a
stub HTTP server checked the bearer header, the request id, the
success:false refusal, and token precedence (file, then env).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 03:11:08 -04:00
Mathew Beaneandclaude-flow f8ab204711 fix(wifiscan): keep the redacted macOS connected link; auto+wifi keeps UDP bound
On macOS 27, without Location Services, CoreWLAN redacts both the SSID and
the BSSID, while rssi, noise and channel stay real. resolve_bssid abstained
when both identities were blank, so every macOS scan was dropped and
`--source wifi` silently produced nothing.

- mac_wifi --scan-once marks its sample `"connected": true`. It is always the
  single connected link, so a synthetic id cannot merge two networks.
- A redacted connected sample gets a stable locally administered synthetic
  BSSID; the SSID stays empty. Unmarked redacted lines still abstain.
- `--source auto` that resolves to host WiFi now also binds UDP, and wifi_task
  yields while the source is a live esp32, so ESP32 nodes that start after
  the boot probe still promote the source (the #1004 rule). Without this, the
  fix would make stock Macs ignore late-starting nodes.
- A missing helper is reported once, with the build command.
- ADR-025: Updated row + Amendment 1 recording the measured behaviour.

Measured on macOS 27: --source wifi gives source wifi:unnamed with sensing
ticks advancing; --source auto on a quiet port starts on wifi with
udp_receiver=true and promotes to esp32 (5 nodes) when real frames arrive.
Tests: wifiscan 158+1, sensing-server bin 332, all passing.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 03:11:08 -04:00
Mathew Beaneandclaude-flow 0df5069831 fix(training): train from real sensing recordings, per node
training_api loaded `{id}.csi.jsonl` in a flat {subcarriers, rssi} schema,
but the recorder writes `{id}.jsonl` sensing_update lines (nodes[] amplitude,
node_features[]). Training on a real recording therefore saw zero frames
(#1703).

- Accept sensing_update lines as well as legacy flat frames; try `{id}.jsonl`
  first, then `{id}.csi.jsonl`.
- Emit one frame per node per update, skip repeated snapshots and nodes
  without amplitudes, and keep frames grouped by node (stable sort).
- RecordedFrame gains an optional node_id; temporal features (window, prev)
  only use frames from the same node.
- Malformed lines are counted, not fatal.

Tests: recording_format_tests (4 new);
cargo test -p wifi-densepose-sensing-server --no-default-features
--bin sensing-server: 336 passed, 0 failed.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 03:11:07 -04:00
Mathew Beaneandclaude-flow f6a3a3579f fix(firmware): confirm OTA'd images with a bounded health check
Rollback-enabled nodes (CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y) boot an
OTA'd image as PENDING_VERIFY, but nothing called
esp_ota_mark_app_valid_cancel_rollback(), so every OTA reverted at the next
reset, a second OTA was refused until then, and a bad image was never
deliberately rolled back.

- main/ota_health.{c,h}: at boot, if the running image is PENDING_VERIFY,
  poll for STA got-IP and >=1 CSI frame accepted by the stream sender.
  Confirm after CONFIG_OTA_HEALTH_MIN_UPTIME_S (30 s) once both are in;
  roll back via esp_ota_mark_app_invalid_rollback_and_reboot() if they are
  not in by CONFIG_OTA_HEALTH_TIMEOUT_S (120 s from boot). VALID, UNDEFINED,
  NEW and no-otadata/factory images are left alone. The decision is a pure
  header function, host-tested in test/test_ota_health.c (make host_tests).
- CONFIG_OTA_HEALTH_FORCE_FAIL (test only) rolls back where it would have
  confirmed, for proving rollback on hardware.
- GET /ota/status reports ota_state so durability is visible remotely.
- RUNBOOK: replace the nonexistent ota_rollback_boot_check() claim and
  document the behaviour and the hardware check (2.1).

Hardware-verified on an ESP32-S3 (push, confirm, hard reset, forced
rollback) and an ESP32-C6 (push, confirm, hard reset); see ADR-379, next in
this series. Host tests: make host_tests passes, including run_ota_health.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 03:11:07 -04:00
Mathew Beaneandclaude-flow d25fb8192e fix(firmware): stop POST /ota overflowing the httpd stack
MEASURED 2026-09-29 on an ESP32-S3 (v0.8.12, 16 MB): every POST /ota of a
valid 1,228,672 B image died with "stack overflow in task httpd" and
RTC_SW_CPU_RST, so no OTA had ever completed on this firmware.

- ota_update.c: set config.stack_size = CONFIG_OTA_HTTPD_STACK_SIZE (new
  Kconfig, default 8192; HTTPD_DEFAULT_CONFIG gave 4096). Move the 1 KB
  receive buffer off the stack into a static (handlers are serialised on
  the one httpd task). Integer progress percent instead of %f.
  recv_wait_timeout stays 30 s.
- Log the httpd stack high-water mark after every POST /ota and
  POST /wasm/upload so the size rests on device data.
- wasm_upload.c: GET /wasm/list builds its 2 KB JSON on the heap.
- RUNBOOK §3 (over the air) records the failure and the high-water-mark
  log line.

Device check after the fix (same S3): "httpd stack after POST /ota: 4900 of
8192 bytes never used", i.e. about 3.3 KB peak. The ADR with the full
hardware evidence follows in this series (ADR-379).

This overlaps PR #1594, which raises the same stack. If #1594 lands first,
drop this commit; the rest of the series does not depend on it.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 03:11:07 -04:00
rUv 03a897938f Merge pull request #2062 from ruvnet/claude/ruview-npm-package-9uc5x3
feat(npm): RuView npm toolkit @ruvnet/ruview 0.9.1 — kernel, SDK, flashing, device access, MCP Apps, live Claude Code views (ADR-368..378)
2026-10-01 20:53:26 -04:00
ruv 15d48d4481 style(homecore-hap): rustfmt the new verifier test
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 19:55:28 -04:00
ruv c764c8916b fix(homecore-hap): pad the SRP verifier to 384 bytes (flaky provisioning)
compute_verifier returns the minimal big-endian encoding, so about one
provisioning in 256 produced a verifier with a zero top byte, encoded to
383 bytes, and failed the store's own 384-byte check
("SRP verifier must contain exactly 384 bytes"). Seen in CI on
first_provisioning_returns_code_but_restart_does_not. Left-pad to the
3072-bit group width at creation: the same number, the fixed length.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 19:55:05 -04:00
rUv 5efc7e2c2c Merge pull request #2073 from ruvnet/feat/ruview-mcp-apps-ui
feat(npm): live sensor views in Claude Code, MCP Apps console, ESPHome radar, ruview umbrella — @ruvnet/ruview 0.9.1 (ADR-375..378)
2026-10-01 19:23:17 -04:00
ruv 39b4ae9ae1 release: @ruvnet/ruview 0.9.1 and ruview umbrella 0.9.1
Bump the harness and the umbrella (which pins it), point README and the
user guide at 0.9.1, sync the stale lockfile header (0.5.1, old bin name)
with package.json, and add a plain-language changelog entry for the
live sensing views in Claude Code (ADR-377, ADR-378).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 18:57:49 -04:00
ruv 4a472926e1 fix(ruview-live): code-review findings on the waterfall (ADR-378)
- Stale frames shown as live: spectrumWith now keeps only nodes in the
  latest capture, so a node that stops streaming (or a failed capture)
  leaves the waterfall and the "no CSI frames" card returns.
- Multi-antenna ESP32 nodes never reached the waterfall: with --spectrum
  the parser adds chain-0 amplitudes (ADR-018 antenna-major) in a separate
  field the SpectrumCollector reads; the kernel path is unchanged.
- Frames from different CSI layouts could be joined: spectrum results
  carry their shape, and frames merge only when shape and bins match.
- Animation ran on assumed time: frames read the real clock (the one the
  full render uses), so pulses keep the reported rates and the replay
  keeps up when a frame fires late; replay lag is pruned with the nodes.

Tests: regression tests for each; npm test 193 pass, test:security 91
pass, engine tests 4 pass, validate passes.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 18:47:18 -04:00
ruv c4f9d95e11 fix(ruview-live): open as a dialog that takes the keys, sized per view
Hotkeys reach a pane only while it holds the keyboard, and `focus` alone
was not taking it. The engine's documented dialog form does: open with
focus + closeOnEscape + holdToasts (Escape closes, as originally). Each
open also requests `rows` for its view (overview 16, waterfall 30,
radar 26) so the inline pane opens tall enough for the bordered design
instead of a third of the screen; switching views re-requests the size,
and picture heights leave room for the chrome (rows - 15).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 18:35:19 -04:00
ruv bf21c8c7a9 Revert "fix(ruview-live): fit the inline pane; actions on top; show focus state"
The compact inline layout read as broken next to the bordered cards the
user prefers; restore the previous design (78a147405). This reverts
commit 629e1b9c6.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 18:33:28 -04:00
ruv 629e1b9c6c fix(ruview-live): fit the inline pane; actions on top; show focus state
Outside fullscreen the pane sits inline above the prompt with a few rows,
so the bordered cards, rule and footer were cut off (Refresh/Close never
visible). Inline (or under 18 rows) the pane is now compact: views and
actions share the top row, no borders/rule/gaps, pictures sized to the
rows left, radar readings beside a small fan. The top row always says
whether the pane holds the keyboard ("keys active · Esc returns them" or
"click the pane or press ctrl+x tab to use the keys").

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 18:32:12 -04:00
ruv 78a1474057 fix(ruview-live): pane hotkeys need the keyboard — ask for focus, say how
Button hotkeys reach a Pane only while it holds the keyboard; the pane
opened without it, so 1/2/3/r/c went to the prompt. Open (and
/ruview waterfall|radar) now request `focus: true`; closeOnEscape is
dropped so Escape hands the keys back without closing; an unfocused pane
shows "click the pane or press ctrl+x tab to use the keys".

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 18:27:52 -04:00
ruv 751b7ae17b feat(ruview-live): showcase views — CSI waterfall, radar fan, animated cells (ADR-378)
The /ruview pane gains three views (keys 1/2/3) drawn in terminal Rasters
and animated in place with $.ui.blit (~12 fps, no redraws):

- CSI waterfall: per-subcarrier amplitude over time from the new
  `esp32 --spectrum` capture, two frames per row via the half block,
  5-95th percentile colour limits, replayed at the frames' arrival rate,
  labelled MEASURED or SYNTHETIC from the packets' own flag; n cycles nodes.
- Radar: a 120-degree range fan with rings, a fading distance trail and a
  sonar ping to the measured range; braille heart/breathing/distance charts
  scaled to the data; a heart and breathing gauge that pulse at the
  device-reported rates ("a metronome, not a waveform").
- A shimmer rule under the title; surfaces without Raster get a note.

Harness: captureEsp32 / ruview_esp32_capture take spectrum,
spectrum_bins (8-128) and spectrum_frames (8-256): a bounded ring of
binned amplitude frames per node, dominant shape, rate and synthetic flag.

The mod is split into model/raster/anim/views/register (own-file imports
only). Tests: 11 showcase + 11 mod Node tests, a --spectrum loopback test,
4 engine tests (waterfall tab sends --spectrum, draws the Raster, blits);
npm test 189 pass, test:security 90 pass, manifest verified, validate passes.

Evidence: MEASURED on an ESP32-C6 (COM11, release c6-adr110 v0.7.0, node 6)
streaming 7-18 Hz CSI to this host; a 1x256 capture renders the occupied
band against guard/null tones. Unpacked budget 384 -> 448 KiB (+~37 KB).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 18:22:40 -04:00
ruv c4d7ef8086 feat(ruview-live): redesigned pane — LIVE badge, cards, trends, plausibility flags
- Header: RuView + LIVE/STALE badge (fresh within 2 refresh periods),
  "updated <time> (Ns ago)", a 5 s redraw tick keeps the age current.
- Bordered cards for the radar and the CSI nodes, side by side from 100
  columns, stacked below.
- Radar: PRESENCE DETECTED (+ target count) in green; distance, heart and
  breathing with per-refresh sparklines; device-reported vitals outside
  resting ranges (heart 40-180, breathing 4-40 bpm) are flagged in yellow
  instead of shown as fact (the live kit reported breathing 1.0 bpm).
- Nodes: per-node rate sparkline; "no packets" is a short hint in the card
  rather than a long top alert (status no longer counts it as an alert);
  an unconfigured radar says how to configure it.
- Buttons show their hotkeys.

Tests: 11 Node unit tests, 3 engine tests (mounted pane validates on the
terminal surface), validate passes.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 17:50:09 -04:00
ruv bd7c506076 fix(ruview-live): a reloaded pane waited forever for its first capture
A hot reload (or a resumed session) re-runs register with fresh variables
while the engine keeps the pane open, so no timer ran and the pane stayed
on "waiting for the first capture". Drawing the pane now resumes polling
when no timer is running. Engine test mounts the pane with no /ruview in
the module instance: fails without the fix, passes with it.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 17:44:00 -04:00
ruv 8e12606c6a fix(ruview-live): pane showed "updated Invalid Date"
$.clock.now() resolves a promise of epoch milliseconds; refresh passed the
promise into the model. Await it, and have the pane print "—" for any
non-finite timestamp. Seen live in Claude Code 2.1.287 (/ruview pane).
Regression tests fail without the fix (2/2) and pass with it.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 17:40:59 -04:00
ruv 8b857e41cd docs: README and user guide for @ruvnet/ruview 0.9.0 (devices, radar, MCP/ChatGPT, Claude Code mod)
- README MetaHarness section pinned to 0.9.0 with the hardware, MCP-over-HTTP,
  mod, Homecore and kernel commands; plugin section lists the ruview-live mod.
- User guide: new "RuView npm toolkit" section (hardware CLI, alert meanings,
  ESPHome kits, MCP/ChatGPT connection, the /ruview mod, Homecore + kernel),
  TOC entry, 0.5.0 pins updated.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 17:26:40 -04:00
ruv 093e3bbeae feat(ruview): ruview-live Claude Code mod shipped in the npm package (ADR-377)
- mod/: a Claude Code mod (function hooks). /ruview opens a pane beside
  the transcript with CSI nodes (rate, loss, RSSI, shape, SYNTHETIC) and an
  optional ESPHome radar (presence, distance, device-reported vitals),
  alerts (heartbeat-only boards + buffer-starvation hint), Refresh/Close
  buttons and a one-line status; refreshed on $.clock.every.
- Reads only through the harness CLI beside it (fixed read-only argv,
  --json) located via $.plugin.root (mods may import nothing else).
- Packaged in @ruvnet/ruview (mod/.claude-plugin, mod/hooks), listed in the
  repo marketplace as ruview-live@ruview; `ruview mod` prints how to load it.
- @ruvnet/ruview 0.9.0; umbrella ruview 0.9.0 (still unpublished).

Verified: claude plugin validate (mod + marketplace); claude plugin test
2/2 x3 runs; harness 172 tests + 89 security; real Claude Code headless
`/ruview refresh` from an installed tarball and with the live radar kit.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 17:12:53 -04:00
ruv 4128661789 docs(adr-376): record release outcome — components published, unscoped ruview refused by npm (403, similar to iview)
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 16:45:20 -04:00
ruv b6f3abd35e feat(npm): ruview umbrella package + ESPHome radar source (ADR-376, ADR-373 A3)
- harness/ruview-umbrella -> npm `ruview` 0.8.0: one CLI and one MCP server
  for the operator harness, Homecore and the vitals kernel. `ruview homecore
  ...`, `ruview kernel <doctor|info|selftest|parity|synth|analyze|bench>`,
  `ruview capabilities`; everything else delegates to @ruvnet/ruview. MCP
  serves ruview_* + homecore_* tools and the ui:// console (stdio + HTTP).
- @ruvnet/ruview 0.8.0: bin renamed `ruview-harness` (the umbrella owns
  `ruview`; a clean tarball install showed the bin collision). New exports
  (cli, cli-ui, mcp, mcp-http, ui, esphome, kernel); injectable MCP handler;
  kernel as optional peer + setKernelImporter so the kernel resolves from
  the umbrella's tree under linked/pnpm installs.
- mmwave --source esphome: dependency-free read-only ESPHome native API
  client (private hosts only; encrypted/password APIs reported). Live Seeed
  MR60BHA2 kit: presence, distance, device-reported HR/BR (MEASURED).
- monitor detects Ameba CSI buffer starvation; radar views in terminal UI
  and console widget.
- homecore 0.1.0 and @ruvnet/ruview-kernel 0.1.0 gain ./cli exports.

Gates: harness 166 + 89 security, homecore 27 + 23, kernel 21 + 12,
umbrella 4; clean-dir tarball install smoke incl. live radar over HTTP MCP.
Local publish is a one-off exception to ADR-265 D2, recorded in ADR-376.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 16:30:23 -04:00
ruv 45796e4e3e feat(ruview): MCP Apps console widget, HTTP transport, terminal UI (ADR-375)
- ui://ruview/console-v1.html: one self-contained widget for MCP Apps hosts
  and ChatGPT (ui.resourceUri + openai/outputTemplate). Renders node
  streams, device scans and doctor results; Refresh re-runs the tool via
  window.openai.callTool or an MCP Apps tools/call. Empty CSP allowlist, no
  network, textContent only, parent-frame messages only.
- MCP: protocol negotiation (2025-06-18/2025-03-26/2024-11-05),
  structuredContent + compact JSON text (17-33% smaller, MEASURED),
  resources/list|read, tool titles, canonical tool name in result _meta.
  Transport-neutral handleRpc + shared FIFO dispatcher.
- `mcp start --http`: Streamable HTTP for ChatGPT/remote clients. Bearer or
  secret-path token (constant-time), loopback default, Origin allowlist,
  256 KiB cap, no batches, write grants never honoured over HTTP.
- CLI: formatted TTY output (tables, rate bars, alerts first, NO_COLOR),
  JSON for pipes/--json, capture countdown, `esp32 --watch` with per-node
  sparklines, lazy module loading (--help ~20% faster, MEASURED).
- monitor detects Ameba CSI report-buffer starvation ("lack of csi buf"),
  the root cause of the Realtek heartbeat-only stall seen live.
- Size budget 320 -> 384 KiB (package ~339 kB).

Verified: 160 tests + 83 security tests; headless-Chrome e2e of the MCP
Apps flow against live boards (refresh round trip 108 -> 105 packets);
HTTP security probes; clean tarball smoke (stdio + HTTP).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 15:52:26 -04:00
ruv 3cd360f9e2 fix(ruview): CSI loss survives stray and reordered sequence numbers
Live RTL8721Dx capture (node 3, COM10) reported 97% loss: one stray
sequence value (+52,835, then straight back) was counted as 52,834 lost
frames, and every late UDP packet re-counted its gap. The tracker now
treats a single out-of-window value as a stray, needs two agreeing frames
to accept a counter reset, and lets a late packet fill its own gap
(seqReordered / seqStrays / seqResyncs in the summary). Against ground
truth on the live board (unique sequence numbers over the span): 4,783 of
4,783 present, loss 0, 9 strays; the tracker matched both.

ADR-373 Amendment 2 records the live Realtek run: devices -> realtek,
7,404/7,404 packets decoded, monitor at 1500000 baud without a reset, and
12,549 live frames analyzed by the WASM kernel.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 15:20:21 -04:00
Claudeandclaude-flow d31d9daba2 fix(nightly-sota): ignore stdin EPIPE from children that never read input
runProcess() ends the child's stdin with no error handler. A child that
never reads stdin (git check-ignore) can exit and close the pipe before the
write flushes; the unhandled EPIPE then crashes `agent.mjs validate`. This
failed harness/ruview (node 22) on PR #2062 and reproduces 12/12 locally
under CPU load (0/12 after this change). The command's real outcome is
still reported by the 'close' handler; this mirrors the existing handler in
harness/ruview/src/process-runner.js.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_015H55jmprfz4oHWXSFbX5rf
2026-10-01 19:07:40 +00:00
ruv b25729acd6 fix(ruview): boot-log capture aborted on non-ASCII firmware logs on Windows
The flash flow's boot-log capture decoded serial lines and wrote text to a
stdout that the scrubbed child env leaves on cp1252. The ESP32-C6 release
firmware logs U+2192, so the capture died mid-boot (bootLog.captured=false);
an earlier abort would have hidden the CSI evidence and reported a good
flash as unvalidated. Write raw bytes instead. Regression test forces
PYTHONIOENCODING=cp1252 through a stand-in serial module (fails without
the fix). ADR-373 Amendment 2 records the real C6 flash evidence.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 15:02:09 -04:00
ruv 8d2be98ed7 feat(ruview): Realtek RAC1 nodes, honest capture, live CSI analysis (ADR-373 Amendment 2)
Findings from a hardware run of this branch against an RTL8721Dx board
(PL2303GC) and an ESP32-C6 (CP210x) on Windows 11:

- esp32 capture decodes Realtek RAC1 (ADR-323, CRC-verified, 8/16-bit
  tones) and RHB1 heartbeats, ADR-110 sync and ADR-081 mesh packets, and
  reports every CSI shape a node sends.
- Capture fails (no_decodable_packets / heartbeat_only) instead of
  returning ok:true + MEASURED when nothing decodes; names
  heartbeat-only senders.
- --analyze runs live single-antenna CSI through @ruvnet/ruview-kernel at
  the measured frame rate with a bounded buffer.
- monitor opens the port with DTR/RTS deasserted (no node reboot), takes
  --baud, and explains the C6/S3 USB-Serial/JTAG console.
- devices classifies Prolific 067B:23A3 as a Realtek board.
- Windows: owner-only ACL on the hosts file (fails closed); symlink test
  skips only where Windows refuses symlinks.
- kernel build names uninitialized submodules when cargo fails.
- Parser ~3.2x faster (MEASURED, 0.42-0.52M -> 1.33-1.67M packets/s).

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-01 14:40:32 -04:00
Claudeandclaude-flow 43f16e5638 perf(npm): binary kernel transport, faster device e2e, and CI fixes (ADR-368/373 amendments)
CI fixes (both failures on PR #2062 were in this branch):
- harness/ruview: remove `2.11.0` literals from training remedies; the
  ADR-265 D3 version-literal gate rejects them.
- ruview-kernel workflow: install wasm32 for the toolchain pinned by
  v2/rust-toolchain (the action only added it to `stable`); build.mjs now
  fails fast with the exact `rustup target add` remedy.

Kernel optimization (ADR-368 amendment):
- New `analyze_flat` / `session_push_flat` ops via `call_f64`: WASM
  `rvk_call_f64` (alignment-agnostic bytes) and napi `callF64(Float64Array)`.
  `analyze()`/`session.push()` pack uniform frames automatically; irregular
  input keeps the JSON path and its per-frame errors. `analyzeFlat()` is the
  zero-copy entry. Shared `Session::step` makes both paths byte-identical on
  identical f64 inputs (Rust test), and the binary path avoids serde_json's
  non-correctly-rounded float parsing.
- MEASURED (`ruview-kernel bench --seconds 120`, one Linux x64 host):
  2.82x (wasm) and 2.45x (napi) faster than the JSON transport.
- Profiled remaining cost (examples/profile_stages.rs): heart-rate ACF in
  wifi-densepose-vitals dominates; left for a separate, output-reviewed
  change. Rejected WASM +simd128 and opt-level=s after three measured runs
  (bench/wasm-variants.mjs).

Device e2e (ADR-373 amendment):
- test/e2e/devices.e2e.mjs + `npm run test:e2e:devices` + new workflow
  ruview-device-e2e.yml: pty-emulated MR60BHA2 and RPLIDAR through real
  pyserial, a UDP ESP32 node, and the real iPhone relay, via the CLI.
- mmWave auto-detect keeps probe frames and reads only the remainder;
  iPhone LiDAR gains max_frames and returns immediately when refused.
  Suite wall time 19.6 s -> 5.6 s (MEASURED, dev container).

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_015H55jmprfz4oHWXSFbX5rf
2026-10-01 14:45:55 +00:00
Claudeandclaude-flow 6112a185df feat(npm): host device + SSH remote access — ESP32, 60/24 GHz mmWave, LiDAR (ADR-373/374)
@ruvnet/ruview 0.7.0 gives CLI, MCP, and SDK access to RuView sensing
hardware on the machine it is attached to, and to other machines over SSH.

Devices (ADR-373, src/devices/):
- ruview_devices_scan: pyserial port list classified by USB VID:PID
  (Espressif native USB, CP210x, CH34x, FTDI) into ESP32 / mmWave / RPLIDAR
  roles with the command that confirms each.
- ruview_esp32_capture: receive-only UDP listener parsing ADR-018 CSI
  frames, vitals, and the other 0xC511000x packets per node: rate,
  sequence loss, RSSI, subcarriers, frequency, device vitals.
- ruview_mmwave_read: MR60BHA2 (60 GHz) and LD2410 (24 GHz) parsers ported
  byte-for-byte from firmware mmwave_sensor.c; baud/frame auto-detect.
- ruview_lidar_read: RPLIDAR SCAN over serial (start/stop, DTR best-effort)
  and the iPhone LiDAR relay as a WebSocket client (token only via
  RUVIEW_LIDAR_TOKEN; statistics only, never raw depth).
- Dependency-free serial via a fixed pyserial pump (argv-only, bounded,
  hex output immune to secret redaction). Schema-level port pattern
  validation added to the policy validator.
- All device tools require the new `device-access` MCP grant.

Remote hosts (ADR-374, src/remote.js):
- ~/.config/ruview/hosts.json (0600), CLI-only `hosts add`; strict
  name/target validation against ssh option and shell injection.
- `ssh -o BatchMode=yes -o StrictHostKeyChecking=yes -- <target>` running a
  version-pinned `ruview call <tool> --read-only`, every token single-quoted;
  read-only enforced locally and remotely; no mutations, credentialed reads,
  or host hopping. MCP needs `remote-host` plus the remote tool's grant.

Also: device-access skill, doctor `devices`/`remote` groups, SDK
`devices.*`/`hosts.*` with types, generic `ruview call` verb, guidance
capability, 320 KiB package budget, CI smoke expects 22 tools.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_015H55jmprfz4oHWXSFbX5rf
2026-09-30 17:01:15 +00:00
Claudeandclaude-flow 888222277e feat(npm): RuView operator surface — SDK, cross-platform flashing, training, doctor (ADR-369..372)
@ruvnet/ruview 0.6.0 turns the harness into a complete npm operator
surface: one policy-checked tool registry behind the CLI, the MCP server,
and a new typed SDK.

- SDK (ADR-369): `@ruvnet/ruview/sdk` createRuView() with doctor,
  firmware.{ports,plan,flash,monitor}, training.{plan,run,gate,calibrate},
  kernel.{selfTest,load}, guidance/claimCheck/verify/spaces; strict mode
  throws RuViewError; TypeScript declarations in src/sdk.d.ts.
- Firmware (ADR-370): cross-platform `python -m esptool` flashing replaces
  the Windows-only stub. SHA256SUMS verification (mismatch always fatal),
  bundle containment, variant/partition checks, argv-only command, chip_id
  preflight that refuses a mismatched chip, boot-log capture;
  hardwareValidated only when CSI callbacks appear without a panic.
  Read-only ruview_firmware_plan / ruview_firmware_ports.
- Training (ADR-371): ruview_train / ruview_train_plan run pose-smoke,
  pose (MM-Fi), or room training from the trusted checkout with in-repo
  paths only, logs/checkpoints under v2/target/ruview-train, and libtorch
  remedies. ruview_train_gate enforces mean-pose baseline, leakage-free
  splits, subject/time disjointness, and evidence tagging.
- Doctor (ADR-372): structured pass/warn/fail/skip checks with remedies
  across runtime, integrity, hosts, repo, Rust, Python/esptool, firmware
  bundles, serial, sensing server, and the compute kernel; --json/--group;
  hardware probe and network checks are CLI/SDK-only.
- Plan tools cannot take `confirm`; acting tools keep grant+confirm over
  MCP. Policy files and TOOL_POLICY are drift-tested.
- Unpacked budget 160 KiB → 224 KiB in both npm workflows; CI tarball smoke
  now checks the SDK and the training gate.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_015H55jmprfz4oHWXSFbX5rf
2026-09-30 16:47:30 +00:00
Claudeandclaude-flow 1938f0251f feat(npm): @ruvnet/ruview-kernel — WASM + napi-rs compute kernel with metaharness e2e (ADR-368)
Adds a portable RuView compute kernel for JavaScript, built from the Rust
source of truth and wired into the @ruvnet/ruview metaharness.

Rust
- v2/crates/ruview-kernel: bounded single-function JSON ABI
  (call(op, json) -> envelope) over the ADR-021 wifi-densepose-vitals
  pipeline: streaming sessions, batch analyze, deterministic SplitMix64
  synthetic CSI (SYNTHETIC), bounded session registry, strict input
  validation (deny_unknown_fields, width/finite/magnitude checks, size and
  count limits). wasm32-only zero-import C ABI (rvk_alloc/free/call).
- v2/crates/ruview-kernel-napi: napi-rs transport exposing only
  abiVersion()/call(); standalone workspace (excluded from v2) so napi never
  enters `cargo test --workspace`; panic=unwind + catch_unwind.

npm (harness/ruview-kernel, zero runtime deps)
- loadKernel({backend: wasm|napi|auto}); WASM default (no host authority),
  napi never silently falls back, auto reports fallbackReason.
- SHA256SUMS integrity verification; mismatch is always fatal; loader
  rejects any WASM module that imports host functions.
- Browser-safe ./wasm entry; CLI doctor/info/selftest/parity/synth/
  analyze/bench; build.mjs builds artifacts from source (no shell).
- Unit, security, and e2e tests (CLI round trip, cross-backend parity).

Metaharness
- New read-only tool ruview_kernel_selftest / `ruview kernel`: imports the
  fixed optional package, fails closed with kernel_not_installed; policy,
  claims, MCP policy, guidance capability, and manifest updated.

CI: .github/workflows/ruview-kernel-npm.yml builds both transports on
Linux/macOS/Windows, requires napi in tests, runs parity, verifies
artifacts, packs, and runs the cross-package e2e. No publish step.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_015H55jmprfz4oHWXSFbX5rf
2026-09-30 16:23:29 +00:00
rUv b90b592d4f fix(vitals): reject respiratory peaks and cap filtered confidence (#2061) 2026-09-30 10:20:41 -04:00
rUv e1fbbede61 fix(sensing): exclude stale nodes from room occupancy (#2060) 2026-09-30 09:25:20 -04:00
283 changed files with 39228 additions and 1919 deletions

No files matched your search

+5
View File
@@ -10,6 +10,11 @@
"name": "ruview",
"source": "./plugins/ruview",
"description": "End-to-end RuView toolkit: getting started, ESP32 hardware setup, configuration, sensing applications (presence / vitals / pose / sleep / MAT), camera-free + camera-supervised model training, advanced multistatic sensing, CLI / API / WASM, mmWave radar, and witness verification"
},
{
"name": "ruview-live",
"source": "./harness/ruview/mod",
"description": "Claude Code mod: /ruview opens a live pane with your CSI nodes (ESP32, Realtek) and ESPHome radar (presence, distance, device-reported vitals), refreshed on a timer, plus a one-line status. Read-only; ships inside @ruvnet/ruview. Mods are early access (CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1)."
}
]
}
+4
View File
@@ -135,6 +135,10 @@ async function runProcess(command, args, {
resolve(result);
});
});
// A child that never reads stdin (e.g. `git check-ignore`) may exit and
// close the pipe before this write flushes; the resulting EPIPE is not a
// failure of the command, whose real outcome the 'close' handler reports.
child.stdin.on('error', () => {});
child.stdin.end(input);
});
}
+1 -1
View File
@@ -204,7 +204,7 @@ jobs:
node-version: '22'
- name: Run UI unit tests
run: node --test ui/sw.test.mjs ui/services/ws-ticket.test.mjs ui/services/websocket.service.test.mjs v2/crates/wifi-densepose-desktop/ui/build-config.test.mjs
run: node --test ui/sw.test.mjs ui/services/ws-ticket.test.mjs ui/services/websocket.service.test.mjs ui/services/sensing.service.test.mjs ui/components/DashboardTab.test.mjs v2/crates/wifi-densepose-desktop/ui/build-config.test.mjs
# Unit and Integration Tests
# Python pytest matrix — runs against the archived v1 Python tree.
+15 -5
View File
@@ -41,8 +41,12 @@ jobs:
build: false
publishable: true
# ADR-283/325: brain + local hosts + replay assets + guarded Spaces OAuth adapter;
# still runtime-dependency-free. 160 KiB is the reviewed hard ceiling.
unpacked_budget: 163840
# still runtime-dependency-free. ADR-369..374 add the SDK, firmware
# flashing, training runner/gate, doctor, device access, and SSH
# hosts. ADR-375 adds the MCP Apps console widget (~12 KB), the
# Streamable HTTP transport and the terminal UI (384 KiB); ADR-378 the
# ruview-live showcase views (~37 KB): 448 KiB reviewed ceiling.
unpacked_budget: 458752
- dir: harness/homecore
build: false
publishable: true
@@ -131,13 +135,19 @@ jobs:
npm i --no-fund --no-audit "$TGZ"
case "${{ matrix.package.dir }}" in
harness/ruview)
./node_modules/.bin/ruview --version
./node_modules/.bin/ruview doctor
./node_modules/.bin/ruview-harness --version
./node_modules/.bin/ruview-harness doctor
# the honesty gate must fail closed on empty input (ADR-263 F1)
if ./node_modules/.bin/ruview claim-check; then
if ./node_modules/.bin/ruview-harness claim-check; then
echo 'claim-check passed with no input — fail-open regression'; exit 1
fi
node --input-type=module -e "const m = await import('@ruvnet/ruview'); if (!m.TOOLS) process.exit(1);"
# ADR-369: the typed SDK resolves and the evidence gate refuses a baseline-free score
node --input-type=module -e "const { createRuView } = await import('@ruvnet/ruview/sdk'); const r = createRuView(); if (r.tools().length < 22) process.exit(1); const g = await r.training.gate({ model_score: 0.93 }); if (g.verdict !== 'FAIL') process.exit(1);"
./node_modules/.bin/ruview-harness doctor --json --group runtime,harness,firmware > doctor.json
if ./node_modules/.bin/ruview-harness train-gate --model-score 0.93 > /dev/null; then
echo 'train-gate passed without a baseline'; exit 1
fi
;;
harness/homecore)
./node_modules/.bin/homecore --version
+68
View File
@@ -0,0 +1,68 @@
# ADR-373/374 — device-access end-to-end through the real @ruvnet/ruview CLI.
# MR60BHA2 (60 GHz) and RPLIDAR are emulated on pseudo-terminals and read via
# real pyserial; an ESP32 node is emulated over UDP; the iPhone LiDAR path
# uses the repository's real relay. All results are SYNTHETIC: this proves
# the transports and parsers, not hardware behaviour.
name: ruview device e2e
on:
push:
branches: [main]
paths:
- 'harness/ruview/src/**'
- 'harness/ruview/bin/**'
- 'harness/ruview/test/e2e/**'
- 'integrations/iphone-lidar/web/**'
- '.github/workflows/ruview-device-e2e.yml'
pull_request:
paths:
- 'harness/ruview/src/**'
- 'harness/ruview/bin/**'
- 'harness/ruview/test/e2e/**'
- 'integrations/iphone-lidar/web/**'
- '.github/workflows/ruview-device-e2e.yml'
permissions:
contents: read
concurrency:
group: ruview-device-e2e-${{ github.ref }}
cancel-in-progress: true
jobs:
devices:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
with:
python-version: '3.12'
- name: Install pyserial
run: python -m pip install pyserial==3.5
- name: Install the iPhone LiDAR relay
working-directory: integrations/iphone-lidar/web
run: npm ci --ignore-scripts
- name: Install harness
working-directory: harness/ruview
run: npm ci --ignore-scripts
# Missing prerequisites fail instead of skipping (RUVIEW_E2E_REQUIRE).
# The suite links each emulator pty to /dev/ttyRUVIEWE2E<n> with
# `sudo -n`, because the harness refuses /dev/pts/* port names.
- name: Device e2e (SYNTHETIC devices)
working-directory: harness/ruview
env:
RUVIEW_E2E_REQUIRE: '1'
run: npm run test:e2e:devices
+132
View File
@@ -0,0 +1,132 @@
# ADR-368 — @ruvnet/ruview-kernel: build the WASM and napi-rs transports from
# source on every OS, run the Rust ABI tests, the package unit/security/e2e
# tests with the native addon REQUIRED, the wasm↔napi parity check, artifact
# verification, and the cross-package e2e through the @ruvnet/ruview
# metaharness. No publish step: publication stays CI-only via the release
# workflow and requires explicit maintainer approval (ADR-265).
name: ruview-kernel npm
on:
push:
branches: [main]
paths:
- 'v2/crates/ruview-kernel/**'
- 'v2/crates/ruview-kernel-napi/**'
- 'v2/crates/wifi-densepose-vitals/**'
- 'harness/ruview-kernel/**'
- 'harness/ruview/src/kernel.js'
- 'harness/ruview/test/kernel.test.mjs'
- '.github/workflows/ruview-kernel-npm.yml'
pull_request:
paths:
- 'v2/crates/ruview-kernel/**'
- 'v2/crates/ruview-kernel-napi/**'
- 'v2/crates/wifi-densepose-vitals/**'
- 'harness/ruview-kernel/**'
- 'harness/ruview/src/kernel.js'
- 'harness/ruview/test/kernel.test.mjs'
- '.github/workflows/ruview-kernel-npm.yml'
permissions:
contents: read
concurrency:
group: ruview-kernel-${{ github.ref }}
cancel-in-progress: true
jobs:
kernel:
name: kernel (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# The v2 workspace lists submodule crates as members; cargo must be
# able to load every member manifest.
submodules: recursive
- name: Install Rust + wasm32 target
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4
with:
targets: wasm32-unknown-unknown
# v2/rust-toolchain pins its own channel; the target must be installed
# for that toolchain, not only for the action's default `stable`.
- name: Add wasm32 target to the pinned v2 toolchain
working-directory: v2
run: rustup target add wasm32-unknown-unknown
- name: Cache cargo
uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32
with:
workspaces: |
v2 -> target
v2/crates/ruview-kernel-napi -> target
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'
- name: Rust ABI tests
run: cargo test --manifest-path v2/Cargo.toml -p ruview-kernel --locked
- name: Build WASM + napi-rs artifacts from source
working-directory: harness/ruview-kernel
run: npm run build
- name: Verify artifacts (checksums, zero WASM imports, ABI)
working-directory: harness/ruview-kernel
run: node scripts/verify-artifacts.mjs --require-wasm
- name: Package tests (napi required)
working-directory: harness/ruview-kernel
env:
RUVIEW_KERNEL_REQUIRE_NAPI: '1'
run: npm test
- name: Doctor, self-test, and wasm↔napi parity (SYNTHETIC)
working-directory: harness/ruview-kernel
run: |
node bin/cli.js doctor --strict-napi
node bin/cli.js selftest --backend wasm
node bin/cli.js selftest --backend napi
node bin/cli.js parity
- name: Pack
working-directory: harness/ruview-kernel
run: |
npm pack --dry-run
# Relative destination: Windows runner temp paths contain
# backslashes that bash globbing would mangle.
mkdir -p dist-pack
npm pack --pack-destination dist-pack
- name: Cross-package e2e through the @ruvnet/ruview metaharness
working-directory: harness/ruview
run: |
npm ci --ignore-scripts
npm install --no-save --ignore-scripts ../ruview-kernel/dist-pack/ruvnet-ruview-kernel-*.tgz
node --test test/kernel.test.mjs
node bin/cli.js kernel --backend auto
- name: Upload built artifacts
if: matrix.os == 'ubuntu-latest'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ruview-kernel-linux-x64
path: |
harness/ruview-kernel/wasm/ruview_kernel.wasm
harness/ruview-kernel/wasm/SHA256SUMS
harness/ruview-kernel/native/*.node
harness/ruview-kernel/native/SHA256SUMS
retention-days: 14
+8 -6
View File
@@ -104,8 +104,10 @@ jobs:
run: |
set -euo pipefail
case "${{ inputs.package }}" in
# ADR-283/325: brain + hosts + replay + guarded Spaces OAuth; no runtime deps.
harness/ruview) export UNPACKED_BUDGET=163840 ;;
# ADR-283/325/369-374: brain + hosts + replay + Spaces OAuth + SDK,
# flashing, training, doctor, device access, SSH hosts; ADR-375 console
# widget, HTTP transport, terminal UI; ADR-378 mod showcase; no runtime deps.
harness/ruview) export UNPACKED_BUDGET=458752 ;;
# ADR-285: CLI + MCP + reviewed brain + WASM-kernel adapter.
harness/homecore) export UNPACKED_BUDGET=180000 ;;
# ADR-264 O2: map-free tarball (was 188 kB with maps).
@@ -140,12 +142,12 @@ jobs:
npm i --no-fund --no-audit "$TGZ"
case "${{ inputs.package }}" in
harness/ruview)
./node_modules/.bin/ruview --version
./node_modules/.bin/ruview doctor
./node_modules/.bin/ruview guidance --topic homecore --query restore --limit 1 \
./node_modules/.bin/ruview-harness --version
./node_modules/.bin/ruview-harness doctor
./node_modules/.bin/ruview-harness guidance --topic homecore --query restore --limit 1 \
| grep -q '"homecore-runtime-restore"'
# the honesty gate must fail closed on empty input (ADR-263 F1)
if ./node_modules/.bin/ruview claim-check; then
if ./node_modules/.bin/ruview-harness claim-check; then
echo 'claim-check passed with no input — fail-open regression'; exit 1
fi
node --input-type=module -e "const m = await import('@ruvnet/ruview'); if (!m.TOOLS) process.exit(1);"
+3
View File
@@ -322,3 +322,6 @@ v2/crates/ruview-offaxis/pkg/
scripts/fal-visual-teacher/training-data/
scripts/fal-visual-teacher/training-data.zip
scripts/fal-visual-teacher/output/
# Trained adaptive classifier (auto-created at runtime; #2135)
/v2/data/adaptive_model.json
+21
View File
@@ -7,8 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Community batch (PRs #2100-#2157 by aepod, reviewed and integrated)
Integrated as one set: conflicts resolved, review findings fixed, validated on the integrated branch. Hardware figures cited by the PRs are the author's own live runs on ESP32-S3 nodes and were not independently reproduced here.
- **Security and honest status.** Refuse a routable HTTP bind when API auth is off (#2134). `--privacy-mode` now strips biometrics from REST, WebSocket and recordings, not only MQTT (#2125; the stripped-key list is an exact-match allowlist, so a new biometric field must be added to it). `/api/v1/status` waits for a real frame and unknown `--source` values are rejected (#2126; breaking for `--source macos|linux|windows`). A stale `/sensing/latest` reports `{"status":"stale"}` after 10 s (#2119). Measured stream fps and real WebSocket client counts (#2127). Absent nodes no longer count as one person (#2132). ESP32 frames with zero antennas or subcarriers are dropped (#2118). Dead nodes are evicted on a timer (#2120). Recordings and models stay under `--data-dir` (#2129). Boolean env flags accept 1/0/yes/no/on/off (#2108). Docker UDP ingest posture is explicit (#2107).
- **CSI pipeline.** The grid gate locks on the majority frame shape and `/health` reports a stalled pipeline (#2157; a partial fix, mixed 192/306 frame shapes still drop frames, so it does not close #1894). Phase is combined across nodes only when they declare a shared reference (#2156). Point-cloud and hardware parsers use the ADR-018 layout and accept more than 256 subcarriers (#2155; the bandwidth rule for counts above 64 is CLAIMED, inferred from the datasheet). Per-node adaptive training from real recordings (#2101, #2103); a run that fell back to the live buffer now reports `degraded: true`. Gaussian decay no longer compounds across calls (#2140).
- **MediaTek (ADR-266/267).** Legacy and unknown PPDU types in MTC1 (#2112), per-device ingest capped at 64 devices (#2113), a labelled amplitude heuristic and activity index (#2114; CLAIMED, not an occupancy estimate), an MT7981 bridge (#2115), and the OpenWrt WN586X3 patch set and guide (#2116; the vendored CSI patch is BSD-3-Clause-Clear, see `NOTICE`).
- **MQTT / Home Assistant.** Semantic states wired into the publisher, entities announced only when sourced, persisted client id (#2117). Existing Home Assistant installs keep retained discovery configs for the six removed entities until they are cleared manually.
- **Provisioning and firmware.** Wi-Fi password kept owner-only and out of argv and `--state` (#2153); state keyed by board MAC (#2152); `--dry-run` and a failed `--reset` leave state intact (#2111); OTA images confirmed by a bounded health check, which means only "reached the network and sent CSI" (#2100, ADR-379).
- **Ground truth and spatial.** Radar window adapter and auto-labeller with a session-disjoint split and a median count baseline; radar labels carry `evidence: CLAIMED` (#2143, #2144). Node self-survey, check-only (#2145, ADR-381). The `spatial.evidence.v1` export defaults to the CODE proof grade and MEASURED requires a reproducer (#2141, ADR-382). Loopback UDP tee (#2102, ADR-380).
- **Tooling.** `prove.sh` reports INCOMPLETE instead of PASS when a gate is skipped (#2130); ADR-018 capture scripts (#2131); API token on the ground-truth script (#2104); the UI asks for the API token instead of retrying forever (#2128); macOS redacted-link handling (#2105).
- **Not included:** #2106 (MacWifi.app, draft) and #2142 (MR60 probe change, needs a captured MR60BHA2 boot log). `v2/data/adaptive_model.json` (41.5% training accuracy) is no longer tracked (#2135).
### Added
- **`@ruvnet/ruview` 0.9.1: live sensing views inside Claude Code (ADR-377, ADR-378).** The package now ships `ruview-live`, a Claude Code mod. Type `/ruview` and a pane opens next to the conversation showing what your sensors see right now, refreshed automatically. It has three views, switched with keys `1` `2` `3` or with `/ruview overview|waterfall|radar`:
- **Overview**: the CSI nodes streaming to your machine (rate, packet loss, signal strength) and an optional 60 GHz radar kit (presence, distance, heart and breathing rates).
- **CSI waterfall**: the Wi-Fi signal itself, the strength of every subcarrier over time, drawn in colour and scrolling as frames arrive. The new `ruview esp32 --spectrum` option supplies the data.
- **Radar**: a top-down range fan with an animated ping out to the measured distance, plus heart, breathing and distance charts.
Every reading is labelled honestly: MEASURED or SYNTHETIC from the packets themselves; radar vitals as "device-reported, not validated"; range-only radar as "distance, not direction". The pane only reads; it never flashes or configures a device.
Verified on real hardware: an ESP32-C6 streaming CSI and a Seeed MR60BHA2 radar kit (MEASURED; reproducer `npx @ruvnet/ruview esp32 --seconds 4 --spectrum --json`). The package's size ceiling rises from 384 KiB to 448 KiB; it still has no runtime dependencies.
- **`wifi-densepose-sar` — coherent wideband RF tomography research crate (ADR-287).** New standalone leaf crate (the `nvsim` pattern; zero coupling to `wifi-densepose-hardware` or any real ingestion path) implementing the synthetic-aperture-radar reconstruction primitive a handheld through-wall RF imaging device would need — motivated by comparison against Applied Electrodynamics' "WaveSight" launch, and explicitly scoped below ADR-278's RISE/DiffRadar/GeRaF reproduction gates. Ships: (1) a stepped-frequency, multi-position complex forward measurement simulator (`y_{m,k} = Σ σ_j/R² · exp(-i·4π·f·R/c) + noise`, deterministic ChaCha20 seeding); (2) delay-and-sum backprojection reconstruction onto a 3D voxel grid, rayon-parallelized over voxels; (3) threshold + local-maximum point-cloud extraction; (4) closed-form range/cross-range resolution and antenna-pose coherence-budget formulas (`ΔR=c/2B`, `δ_CR≈λR/2L`, `Δp≤λ/8`) checked against the reconstruction's *actual* behavior in `tests/physics_validation.rs` rather than merely documented — forward-simulating two targets at controlled separations and proving they resolve or merge exactly where the formulas predict, and that reconstructed focus at a known target degrades as injected antenna-pose error grows. Every number is SYNTHETIC/L0 (ADR-282) — no real wideband RF hardware backs this crate; see the crate README and `docs/tutorials/coherent-rf-tomography-backprojection.md` for the full honesty boundary and a worked walkthrough. `focus_at_point` exploits the evenly-spaced-by-construction frequency sweep (an arithmetic progression in per-term phase) to evaluate each pose's phasor once and advance it by a fixed complex-multiply step per frequency instead of one `sin`/`cos` pair per frequency — **MEASURED ~4.4-4.5x faster** (criterion regression detection, p < 0.001) than the first-shipped direct-computation version, proven equivalent (not just faster) to an independently reimplemented reference across four sweep sizes and on-/off-target points. 25 tests (22 unit + 3 integration), 0 failed, clippy-clean; MEASURED backprojection throughput ~1.7-2.3M voxels/sec (criterion, 21 poses × 32 freq steps).
- **HOMECORE platform runtime completion — secure native/Wasmtime plugins, authenticated HAP IP, expanded Home Assistant APIs, durable restoration/migration, and voice protocols.** `homecore-server` now owns deterministic compiled-in native plugin registration plus explicitly configured, path-bounded, Ed25519 publisher-verified Wasm packages executed through Wasmtime with setup/state-change/teardown lifecycle; arbitrary native dynamic libraries remain intentionally unsupported. The optional HAP server implements persisted accessory identity and controller records, SRP-6a Pair-Setup M1–M6, X25519/Ed25519 Pair-Verify M1–M4, HKDF-SHA512/ChaCha20-Poly1305 record framing, authenticated/admin endpoint gates, replay/tamper closure, live entity synchronization, and paired-state `_hap._tcp` mDNS updates (45 focused tests; external Apple certification is not claimed). Startup restores device/entity registries and deterministic latest recorder states before plugins, and migration now atomically preserves forward-compatible device/config-entry fields. The HA-compatible surface adds events, templates, config checks, components, registries, history/logbook with SQL-enforced global response bounds, calendar/camera provider routes, and modern WebSocket negotiation while retaining a machine-readable limitations matrix for integration-specific behavior. Assist adds bounded PCM16, async STT/TTS contracts, an end-to-end speech pipeline, and an authenticated satellite session protocol; real deployments still provide the speech engines.
- **`ruview-unified` increment 3 — Gaussian update-loop completion, separable delay-Doppler, and property-tested boundary hardening.** (1) `GaussianMap::merge_overlapping` (ADR-275 step 5: mutual-Mahalanobis + semantic-compatibility dedup catching drift the insert-time gate misses) and lifetime-aware decay (`τ_eff = τ·(1+ln(1+lifetime/τ))` — confirmed structures outlive transients at equal nominal τ). (2) `delay_doppler_map` reimplemented separably (`O(B²S+S²B)`), proven equivalent to the direct reference to <1e-10 and **measured 8.3× faster** (520 µs vs 4.34 ms at 56×8). (3) `tests/security_boundaries.rs` — 8 `proptest` properties over the boundary surfaces (arbitrary values incl. NaN/±inf via `f64::from_bits`) that found and fixed three input-controlled defects: a BLE-CS phase-unwrap infinite loop on non-finite phases and an ~1e299-iteration loop on finite-huge phases (now O(1) modular unwrap + plausibility bound), and a subnormal Gaussian scale overflowing `1/σ²` to NaN density (now physical σ/occupancy bounds). (4) New criterion benches for all increment-2 hot paths (`to_canonical` 38 µs, `ble_cs_range` 481 ns, AoI planner 647 ns/200 regions, coherent fusion 1.5 µs/32 members, factorized pose 521 ns). ruview-unified now 98 tests (87 lib + 3 acceptance + 8 security), 0 failed, clippy-clean.
+22
View File
@@ -0,0 +1,22 @@
RuView third-party notices
This repository is licensed MIT OR Apache-2.0 (see LICENSE). The items below
are vendored third-party material that keeps its own licence and is not
covered by that dual licence.
firmware/openwrt-wn586x3/patches/1001-mtk-mt76-mt7915-csi-implement-csi-support-REBASED.patch
What: MediaTek's mt76 mt7915 CSI patch, rebased onto openwrt/mt76
eb567bc7 (the revision pinned by OpenWrt 24.10.8).
Author: Bo Jiao <Bo.Jiao@mediatek.com> (original author line kept in the
patch header); copyright MediaTek Inc.
Origin: mediatek/mtk-openwrt-feeds @ a15454c888f4f4144c50e33b5feef4f247c5f78b
Licence: BSD-3-Clause-Clear (SPDX identifier,
https://spdx.org/licenses/BSD-3-Clause-Clear.html). The patch
carries the SPDX tags and MediaTek copyright lines in its added
files; the licence text is not reproduced here.
Scope: A vendored patch applied to an OpenWrt/mt76 build. It is not part
of the Rust crates, is not compiled by the Cargo workspace, and is
not linked into any RuView binary.
See firmware/openwrt-wn586x3/README.md for the other patches in that
directory and the licence each one keeps.
+58 -16
View File
@@ -6,6 +6,12 @@
</a>
</p>
<p align="center">
<a href="https://ruos.cognitum.one">
<img src="assets/ruos-animated.svg" alt="RuView — WiFi becomes spatial awareness, with Ruflo coordination and a ruOS sensing workspace" width="100%">
</a>
</p>
## **See through walls with WiFi** ##
@@ -40,29 +46,56 @@ Every WiFi router already fills your space with radio waves. When people move, b
<details>
<summary><strong>RuView MetaHarness</strong> — guided operation for humans and AI agents</summary>
The RuView-specific metaharness we created is published as [`@ruvnet/ruview`](harness/ruview/README.md). It provides source-cited guidance, guarded Claude Code/Codex agents, deterministic verification, an honesty check for accuracy claims, and an explicitly granted OAuth-only Cognitum Spaces read.
The RuView-specific metaharness we created is published as [`@ruvnet/ruview`](harness/ruview/README.md). It provides:
- source-cited guidance;
- device access for CSI nodes, radar and LiDAR;
- firmware flashing with boot evidence;
- one MCP server, usable over stdio or HTTP for ChatGPT, with a live console widget;
- a Claude Code mod;
- guarded Claude Code/Codex agents;
- deterministic verification;
- an honesty check for accuracy claims.
Companion packages: [`homecore`](harness/homecore/README.md) (Homecore developer metaharness) and [`@ruvnet/ruview-kernel`](harness/ruview-kernel/README.md) (the vitals pipeline as WASM).
```bash
# Check the local setup and get source-cited guidance
npx @ruvnet/ruview@0.4.0 doctor
npx @ruvnet/ruview@0.4.0 guidance --topic sensing --query "model loading"
npx @ruvnet/ruview@0.9.1 doctor
npx @ruvnet/ruview@0.9.1 guidance --topic sensing --query "model loading"
# Hardware: what is plugged in, live CSI from your nodes, a 60 GHz radar kit
npx @ruvnet/ruview@0.9.1 devices
npx @ruvnet/ruview@0.9.1 esp32 --watch # ESP32 + Realtek RAC1 nodes, live view
npx @ruvnet/ruview@0.9.1 esp32 --seconds 45 --analyze # live CSI through the vitals kernel
npx @ruvnet/ruview@0.9.1 mmwave --source esphome --host <kit-ip>
# Agents: MCP over stdio, or over HTTP for ChatGPT (token-protected, read tools only)
npx @ruvnet/ruview@0.9.1 mcp start
RUVIEW_MCP_GRANTS=device-access npx @ruvnet/ruview@0.9.1 mcp start --http
# Claude Code: a live sensing pane (/ruview), shipped in the package
npx @ruvnet/ruview@0.9.1 mod
# Run a read-only RuView agent through Codex
npx @ruvnet/ruview@0.4.0 agent run --host codex --repo . \
npx @ruvnet/ruview@0.9.1 agent run --host codex --repo . \
--prompt "Find the nearest tests and cite the source files"
# Search or verify the reviewed contributor brain
npx @ruvnet/ruview@0.4.0 brain search --query "calibration"
npx @ruvnet/ruview@0.4.0 brain verify --repo .
# Check claims, replay the deterministic proof, or expose the MCP server
npx @ruvnet/ruview@0.4.0 claim-check --file REPORT.md
npx @ruvnet/ruview@0.4.0 verify
npx @ruvnet/ruview@0.4.0 spaces
npx @ruvnet/ruview@0.4.0 mcp start
# Check claims, replay the deterministic proof, search the reviewed brain
npx @ruvnet/ruview@0.9.1 claim-check --file REPORT.md
npx @ruvnet/ruview@0.9.1 verify
npx @ruvnet/ruview@0.9.1 brain search --query "calibration"
```
Agent runs are read-only by default. Workspace writes require both `--allow-write` and `--confirm`; retrieved brain content is evidence, not authority.
**Safety:**
- Agent runs are read-only by default. Workspace writes require both `--allow-write` and `--confirm`.
- Hardware reads need the `device-access` grant.
- Flashing and calibration never run over the HTTP transport.
- Device-reported vitals are labelled as unvalidated.
- Retrieved brain content is evidence, not authority.
A single `npx ruview` package bundling all of this is ready (ADR-376). It is waiting on npm to release the unscoped name.
Full walkthrough: [user guide → RuView npm toolkit](docs/user-guide.md#ruview-npm-toolkit-ruvnetruview).
</details>
@@ -114,8 +147,11 @@ RuView turns ordinary WiFi into a contactless sensor. A $9 ESP32 board reads the
```bash
# Option 1: Docker (simulated data, no hardware needed)
docker pull ruvnet/wifi-densepose:latest
docker run -p 3000:3000 ruvnet/wifi-densepose:latest
export RUVIEW_API_TOKEN=$(openssl rand -hex 32) # required; the container exits 64 without it
docker run -p 127.0.0.1:3000:3000 -e RUVIEW_API_TOKEN ruvnet/wifi-densepose:latest
# Open http://localhost:3000
# ESP32 nodes in Docker also need a UDP source guard (RUVIEW_UDP_ALLOW);
# see docs/user-guide.md "Receiving ESP32 frames in Docker".
# Option 2a: Live sensing with ESP32-S3 hardware ($9)
# Flash firmware, provision WiFi, and start sensing:
@@ -694,7 +730,13 @@ claude --plugin-dir ./plugins/ruview
Verify the plugin structure: `bash plugins/ruview/scripts/smoke.sh`. Full details: [`plugins/ruview/README.md`](plugins/ruview/README.md).
For the portable RuView MetaHarness, use `npx @ruvnet/ruview@0.4.0`; the quick commands and fuller explanation are in the collapsed MetaHarness section near the top of this README and in [`harness/ruview/`](harness/ruview/README.md).
**Live sensing pane (Claude Code mod):** the same marketplace also lists `ruview-live`, a Claude Code mod.
- Install it with `/plugin install ruview-live@ruview`, or run `npx @ruvnet/ruview@0.9.1 mod` for its path inside the npm package.
- `/ruview` opens a pane beside the transcript with your CSI nodes and radar, refreshed on a timer, plus a one-line status.
- It is read-only.
- Mods are early access; if `/ruview` is missing, start Claude Code with `CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1`. See [ADR-377](docs/adr/ADR-377-ruview-live-claude-code-mod.md).
For the portable RuView MetaHarness, use `npx @ruvnet/ruview@0.9.1`. The quick commands and a fuller explanation are in the collapsed MetaHarness section near the top of this README, in [`harness/ruview/`](harness/ruview/README.md), and in the [user guide](docs/user-guide.md#ruview-npm-toolkit-ruvnetruview).
</details>
+4
View File
@@ -20,6 +20,10 @@ func main() {
exit(1)
}
let sample: [String: Any] = [
// Always the connected link. macOS redacts ssid/bssid without
// Location Services; rssi/noise/channel stay real, so mark the
// sample as the connected link for the Rust adapter.
"connected": true,
"ssid": interface.ssid() ?? "",
"bssid": interface.bssid() ?? "00:00:00:00:00:00",
"channel": channel.channelNumber,
+87
View File
@@ -0,0 +1,87 @@
<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360" viewBox="0 0 960 540" role="img" aria-labelledby="title desc">
<title id="title">RuView · WiFi becomes spatial awareness.</title><desc id="desc">RuView-focused animation showing WiFi sensors, spatial perception, Ruflo coordination and an illustrative ruOS sensing workspace. Room occupancy and actions are synthetic examples, not live measurements.</desc>
<defs><radialGradient id="bg"><stop stop-color="#151518" /><stop offset="1" stop-color="#07080b" /></radialGradient><radialGradient id="aura"><stop stop-color="#edc65a" stop-opacity=".23" /><stop offset=".4" stop-color="#ba8823" stop-opacity=".07" /><stop offset="1" stop-color="#ba8823" stop-opacity="0" /></radialGradient><linearGradient id="gold" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#fff4b1" /><stop offset=".5" stop-color="#ebc456" /><stop offset="1" stop-color="#96702f" /></linearGradient><linearGradient id="panel" x2="0" y2="1"><stop stop-color="#191a20" /><stop offset="1" stop-color="#0d0e13" /></linearGradient><pattern id="grid" width="36" height="36" patternUnits="userSpaceOnUse"><circle cx="18" cy="18" r=".65" fill="#d4c78e" opacity=".22" /></pattern><filter id="bloom" x="-100%" y="-100%" width="300%" height="300%"><feGaussianBlur stdDeviation="4" /></filter><clipPath id="frame"><rect width="960" height="540" rx="18" /></clipPath><clipPath id="line1"><rect x="64" y="155" width="850" height="93" /></clipPath><clipPath id="line2"><rect x="64" y="244" width="850" height="100" /></clipPath><g id="crown" fill="url(#gold)"><path d="M0 5l8 7L15 0l7 12 8-7-4 23H4z" /><rect x="4" y="31" width="22" height="3" rx="1" /></g>
<style>
text{font-family:Arial,Helvetica,sans-serif;fill:#f4f2ed}.mono{font-family:monospace}.muted{fill:#888993}.gold{fill:#e8c565}.scene{opacity:0}.end{opacity:1}.wire{fill:none;stroke:#c2a155;stroke-width:1}.all{animation-duration:18s;animation-iteration-count:infinite;animation-timing-function:cubic-bezier(.22,.65,.3,1)}
@keyframes intro{0%,17%{opacity:1;transform:translateY(0)}22%,94%{opacity:0;transform:translateY(-12px)}100%{opacity:1;transform:translateY(0)}}
@keyframes network{0%,20%{opacity:0;transform:translateY(18px) scale(.98)}25%,44%{opacity:1;transform:translateY(0) scale(1)}49%,100%{opacity:0;transform:translateY(-15px) scale(1.02)}}
@keyframes work{0%,46%{opacity:0;transform:translateY(18px)}51%,66%{opacity:1;transform:translateY(0)}71%,100%{opacity:0;transform:translateY(-14px)}}
@keyframes finale{0%,69%{opacity:0}74%,92%{opacity:1}97%,100%{opacity:0}}
.intro{animation-name:intro}.network{animation-name:network;transform-origin:480px 270px}.work{animation-name:work}.end{animation-name:finale}
@keyframes reveal1{0%,69%{transform:translateY(100px)}77%,100%{transform:translateY(0)}}@keyframes reveal2{0%,73%{transform:translateY(110px)}81%,100%{transform:translateY(0)}}.headline1{animation-name:reveal1}.headline2{animation-name:reveal2}
@keyframes breathe{50%{opacity:.6;transform:scale(1.12)}}.breathe{animation:breathe 4.5s ease-in-out infinite;transform-origin:480px 239px}
@keyframes spin{to{transform:rotate(360deg)}}.orbit{animation:spin 18s linear infinite;transform-origin:480px 239px}
@keyframes send{0%,9%{transform:translate(0,0)}14%,100%{transform:translate(-14px,-19px)}}.cursor{animation-name:send}
@keyframes type{0%,2%{clip-path:inset(0 100% 0 0)}12%,100%{clip-path:inset(0 0 0 0)}}.typing{animation-name:type;animation-timing-function:steps(26,end)}
@keyframes task{0%,49%{transform:translateY(25px);opacity:0}55%,100%{transform:translateY(0);opacity:1}}.task{animation-name:task}.task2{animation-delay:.3s}.task3{animation-delay:.6s}
@keyframes grow{0%,54%{transform:scaleX(0)}63%,100%{transform:scaleX(1)}}.grow{animation-name:grow;transform-box:fill-box;transform-origin:left}
@keyframes tick{0%,62%{opacity:0}65%,100%{opacity:1}}.tick{animation-name:tick}
@keyframes trace{0%,22%{stroke-dashoffset:1}38%,100%{stroke-dashoffset:0}}.trace{stroke-dasharray:1;animation-name:trace}
@keyframes drift{50%{transform:translateY(-10px);opacity:.8}}.dust{animation:drift 9s ease-in-out infinite}
@media(prefers-reduced-motion:reduce){*{animation:none!important}.scene{opacity:0}.end{opacity:1}.moving{display:none}}
@keyframes signal{0%,13%{transform:translate(480px,205px)}20%{transform:translate(708px,301px)}26%,43%{transform:translate(480px,239px)}49%{transform:translate(215px,155px)}55%{transform:translate(479px,155px)}61%{transform:translate(743px,155px)}72%,93%{transform:translate(782px,307px)}100%{transform:translate(480px,205px)}}
.signal{animation-name:signal}.signal-glow{animation:shimmer 3s ease-in-out infinite}@keyframes shimmer{50%{opacity:.45}}
@keyframes haloExpand{0%,12%{opacity:0;transform:scale(.5)}16%{opacity:.7}23%,100%{opacity:0;transform:scale(2.5)}}.clickring{animation-name:haloExpand;transform-origin:708px 301px}
@keyframes aurora{0%,100%{transform:translate(-30px,10px) rotate(-4deg)}50%{transform:translate(30px,-10px) rotate(4deg)}}.aurora{animation:aurora 18s ease-in-out infinite;transform-origin:480px 270px}
@keyframes dash{to{stroke-dashoffset:-80}}.stream{stroke-dasharray:2 18;animation:dash 3s linear infinite}
@keyframes count{0%,52%{opacity:0;transform:translateY(8px)}58%,100%{opacity:1;transform:translateY(0)}}.detail{animation-name:count}
@keyframes buildLine{0%,51%{stroke-dashoffset:1}64%,100%{stroke-dashoffset:0}}.buildLine{stroke-dasharray:1;animation-name:buildLine}
@keyframes underline{0%,78%{transform:scaleX(0)}85%,100%{transform:scaleX(1)}}.underline{animation-name:underline;transform-origin:66px 350px}
@keyframes dock{0%,49%{opacity:0;transform:translateY(14px)}55%,100%{opacity:1;transform:translateY(0)}}.dock{animation-name:dock}
@media(prefers-reduced-motion:reduce){.signal,.clickring{display:none}}
@keyframes vortexCW{to{transform:rotate(360deg)}}@keyframes vortexCCW{to{transform:rotate(-360deg)}}
.vortex{transform-origin:480px 270px;animation:vortexCW 36s linear infinite}.counter{transform-origin:480px 270px;animation:vortexCCW 54s linear infinite}
@keyframes current{to{stroke-dashoffset:-1200}}.current{animation:current 9s linear infinite}
@keyframes bgEnergy{0%,12%,100%{opacity:.22}22%,40%{opacity:.6}52%,65%{opacity:.4}74%,84%{opacity:.7}94%{opacity:.28}}.bgEnergy{animation:bgEnergy 18s ease-in-out infinite}
@keyframes wave{0%{opacity:0;transform:scale(.3)}20%{opacity:.35}80%{opacity:.07}100%{opacity:0;transform:scale(1.9)}}.wave{transform-origin:480px 270px;animation:wave 9s cubic-bezier(.15,.35,.5,1) infinite}
@keyframes glimmer{0%,100%{opacity:.08}50%{opacity:.72}}.glimmer{animation:glimmer 6s ease-in-out infinite}
@keyframes comet{0%{opacity:0;transform:translate(-160px,70px)}12%{opacity:.65}58%{opacity:.25}75%,100%{opacity:0;transform:translate(1060px,-300px)}}.comet{animation:comet 9s linear infinite}
@keyframes beam{0%,100%{transform:translate(-150px,0) rotate(-12deg);opacity:0}35%{opacity:.18}65%{opacity:.07;transform:translate(1050px,0) rotate(-12deg)}80%{opacity:0}}.beam{animation:beam 18s ease-in-out infinite;transform-origin:center}
@media(prefers-reduced-motion:reduce){.dynamic-bg{display:none}}
.all{animation-duration:24s}
@keyframes work{0%,46%{opacity:0;transform:translateY(18px)}50%,72%{opacity:1;transform:translateY(0)}76%,100%{opacity:0;transform:translateY(-14px)}}
@keyframes finale{0%,75%{opacity:0}79%,93%{opacity:1}97%,100%{opacity:0}}
@keyframes reveal1{0%,75%{transform:translateY(100px)}82%,100%{transform:translateY(0)}}
@keyframes reveal2{0%,78%{transform:translateY(110px)}85%,100%{transform:translateY(0)}}
@keyframes signal{0%,13%{transform:translate(480px,205px)}20%{transform:translate(708px,301px)}26%,43%{transform:translate(480px,239px)}49%{transform:translate(803px,182px)}55%{transform:translate(803px,235px)}63%,72%{transform:translate(803px,288px)}80%,93%{transform:translate(782px,307px)}100%{transform:translate(480px,205px)}}
@keyframes browserOpen{0%,48%{opacity:0;transform:translateY(20px) scale(.96)}53%,100%{opacity:1;transform:translateY(0) scale(1)}}.browserOpen{animation-name:browserOpen;transform-origin:380px 230px}
@keyframes terminalOpen{0%,53%{opacity:0;transform:translateY(14px)}58%,100%{opacity:1;transform:translateY(0)}}.terminalOpen{animation-name:terminalOpen}
@keyframes terminalText{0%,57%{opacity:0}60%,100%{opacity:1}}.terminalText{animation-name:terminalText}
@keyframes done{0%,65%{opacity:0}69%,100%{opacity:1}}.done{animation-name:done}
@keyframes pointerMove{0%,50%{transform:translate(350px,275px)}55%{transform:translate(529px,267px)}59%{transform:translate(570px,325px)}66%,100%{transform:translate(738px,346px)}}.desktopPointer{animation-name:pointerMove}
@keyframes chartGrow{0%,53%{transform:scaleY(.05)}63%,100%{transform:scaleY(1)}}.chartGrow{animation-name:chartGrow;transform-box:fill-box;transform-origin:bottom}
</style><radialGradient id="readability"><stop stop-color="#08090e" stop-opacity=".58" /><stop offset=".65" stop-color="#08090e" stop-opacity=".38" /><stop offset="1" stop-color="#08090e" stop-opacity="0" /></radialGradient><linearGradient id="beamGold"><stop stop-color="#e7bf56" stop-opacity="0" /><stop offset=".5" stop-color="#efd57f" stop-opacity=".45" /><stop offset="1" stop-color="#e7bf56" stop-opacity="0" /></linearGradient></defs><g clip-path="url(#frame)"><rect width="960" height="540" fill="url(#bg)" /><rect width="960" height="540" fill="url(#grid)" opacity=".35" /><ellipse cx="490" cy="250" rx="460" ry="320" fill="url(#aura)" />
<g><g class="aurora" fill="none" stroke="#c6a457" opacity=".11"><ellipse cx="480" cy="260" rx="470" ry="156" transform="rotate(-18 480 260)" /><ellipse cx="480" cy="260" rx="480" ry="166" transform="rotate(-18 480 260)" /><ellipse cx="480" cy="260" rx="495" ry="180" transform="rotate(-18 480 260)" /></g></g><g><g class="dynamic-bg"><g class="bgEnergy"><rect class="beam" x="0" y="-120" width="160" height="850" fill="url(#beamGold)" /><ellipse cx="480" cy="270" rx="280" ry="150" fill="none" stroke="#dcb55e" stroke-width=".9" class="wave" style="animation-delay:-0s" /><ellipse cx="480" cy="270" rx="280" ry="150" fill="none" stroke="#dcb55e" stroke-width=".9" class="wave" style="animation-delay:-3s" /><ellipse cx="480" cy="270" rx="280" ry="150" fill="none" stroke="#dcb55e" stroke-width=".9" class="wave" style="animation-delay:-6s" /><g class="vortex" fill="none"><ellipse cx="480" cy="270" rx="355" ry="160" transform="rotate(0 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="355" ry="160" transform="rotate(0 480 270)" stroke="#f7d884" stroke-width="2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-0.0s" opacity=".65" /><ellipse cx="480" cy="270" rx="392" ry="184" transform="rotate(19 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="392" ry="184" transform="rotate(19 480 270)" stroke="#f7d884" stroke-width="1.2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-1.5s" opacity=".65" /><ellipse cx="480" cy="270" rx="429" ry="208" transform="rotate(38 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="429" ry="208" transform="rotate(38 480 270)" stroke="#f7d884" stroke-width="2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-3.0s" opacity=".65" /><ellipse cx="480" cy="270" rx="466" ry="232" transform="rotate(57 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="466" ry="232" transform="rotate(57 480 270)" stroke="#f7d884" stroke-width="1.2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-4.5s" opacity=".65" /><ellipse cx="480" cy="270" rx="503" ry="256" transform="rotate(76 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="503" ry="256" transform="rotate(76 480 270)" stroke="#f7d884" stroke-width="2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-6.0s" opacity=".65" /><ellipse cx="480" cy="270" rx="540" ry="280" transform="rotate(95 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="540" ry="280" transform="rotate(95 480 270)" stroke="#f7d884" stroke-width="1.2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-7.5s" opacity=".65" /></g><g class="counter" fill="none"><ellipse cx="480" cy="270" rx="355" ry="160" transform="rotate(0 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="355" ry="160" transform="rotate(0 480 270)" stroke="#f7d884" stroke-width="2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-0.0s" opacity=".65" /><ellipse cx="480" cy="270" rx="392" ry="184" transform="rotate(-19 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="392" ry="184" transform="rotate(-19 480 270)" stroke="#f7d884" stroke-width="1.2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-1.5s" opacity=".65" /><ellipse cx="480" cy="270" rx="429" ry="208" transform="rotate(-38 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="429" ry="208" transform="rotate(-38 480 270)" stroke="#f7d884" stroke-width="2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-3.0s" opacity=".65" /><ellipse cx="480" cy="270" rx="466" ry="232" transform="rotate(-57 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="466" ry="232" transform="rotate(-57 480 270)" stroke="#f7d884" stroke-width="1.2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-4.5s" opacity=".65" /><ellipse cx="480" cy="270" rx="503" ry="256" transform="rotate(-76 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="503" ry="256" transform="rotate(-76 480 270)" stroke="#f7d884" stroke-width="2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-6.0s" opacity=".65" /><ellipse cx="480" cy="270" rx="540" ry="280" transform="rotate(-95 480 270)" stroke="#b89342" stroke-width=".6" opacity=".3" /><ellipse cx="480" cy="270" rx="540" ry="280" transform="rotate(-95 480 270)" stroke="#f7d884" stroke-width="1.2" stroke-dasharray="18 6 4 420 65 800" class="current" style="animation-delay:-7.5s" opacity=".65" /></g><circle cx="10.1" cy="303.9" r="1.3" fill="#ffe4a0Line truncated
<g class="scene intro all"><circle cx="480" cy="205" r="125" fill="url(#aura)" /><circle cx="480" cy="205" r="28" stroke="#f3d576" stroke-width="1" fill="none" /><text x="480" y="126" text-anchor="middle" font-size="32" letter-spacing="-1">Your room is already sending signals.</text>
<rect x="215" y="269" width="530" height="64" rx="12" fill="url(#panel)" stroke="#554b31" /><text x="239" y="307" font-size="17" class="mono typing all">Sense. Understand. Act.</text><rect x="689" y="282" width="38" height="38" rx="9" fill="url(#gold)" /><path d="M708 307v-13m-5 5 5-5 5 5" fill="none" stroke="#222018" stroke-width="2" stroke-linecap="round" />
<path class="cursor all" d="M731 323l0 27 7-8 8 1z" fill="#faf8e7" stroke="#151515" stroke-width="1.4" />
<text x="480" y="382" text-anchor="middle" font-size="11" letter-spacing="3" class="mono muted">WIFI SENSING. NO CAMERAS.</text><g><circle class="clickring all" cx="708" cy="301" r="25" fill="none" stroke="#fae29b" /></g><g><g fill="none" stroke="#e7c467" opacity=".65"><path d="M437 214Q480 161 523 214" /><path d="M420 205Q480 133 540 205" opacity=".4" /><path d="M404 196Q480 108 556 196" opacity=".2" /></g></g></g>
<g class="scene network all"><text x="480" y="107" text-anchor="middle" font-size="30" letter-spacing="-.7">From radio signals to useful actions.</text>
<g><path d="M480 239C548 323 639 357 735 350" class="stream" fill="none" stroke="#f1d98f" stroke-width="1.5" opacity=".45" /></g><g><path d="M480 239C420 328 311 358 225 350" class="stream" fill="none" stroke="#f1d98f" stroke-width="1.5" opacity=".45" /></g><g><path d="M480 239C520 143 640 148 714 193" class="stream" fill="none" stroke="#f1d98f" stroke-width="1.5" opacity=".45" /></g><g><path d="M480 239C440 148 322 143 247 193" class="stream" fill="none" stroke="#f1d98f" stroke-width="1.5" opacity=".45" /></g><path d="M480 239C440 148 322 143 247 193" class="wire" opacity=".2" /><path d="M480 239C440 148 322 143 247 193" class="wire trace all" pathLength="1" /><g class="moving"><circle r="7" fill="#f6cf63" opacity=".5" filter="url(#bloom)"><animateMotion path="M480 239C440 148 322 143 247 193" dur="2.4s" begin="-0.0s" repeatCount="indefinite" /></circle><circle r="2.6" fill="#ffeaa1"><animateMotion path="M480 239C440 148 322 143 247 193" dur="2.4s" begin="-0.0s" repeatCount="indefinite" /></circle></g><g class="moving"><circle r="7" fill="#f6cf63" opacity=".5" filter="url(#bloom)"><animateMotion path="M480 239C440 148 322 143 247 193" dur="2.4s" begin="-1.2s" repeatCount="indefinite" /></circle><circle r="2.6" fill="#ffeaa1"><animateMotion path="M480 239C440 148 322 143 247 193" dur="2.4s" begin="-1.2s" repeatCount="indefinite" /></circle></g><path d="M480 239C520 143 640 148 714 193" class="wire" opacity=".2" /><path d="M480 239C520 143 640 148 714 193" class="wire trace all" pathLength="1" /><g class="moving"><circle r="7" fill="#f6cf63" opacity=".5" filter="url(#bloom)"><animateMotion path="M480 239C520 143 640 148 714 193" dur="2.4s" begin="-0.2s" repeatCount="indefinite" /></circle><circle r="2.6" fill="#ffeaa1"><animateMotion path="M480 239C520 143 640 148 714 193" dur="2.4s" begin="-0.2s" repeatCount="indefinite" /></circle></g><g class="moving"><circle r="7" fill="#f6cf63" opacity=".5" filter="url(#bloom)"><animateMotion path="M480 239C520 143 640 148 714 193" dur="2.4s" begin="-1.4s" repeatCount="indefinite" /></circle><circle r="2.6" fill="#ffeaa1"><animateMotion path="M480 239C520 143 640 148 714 193" dur="2.4s" begin="-1.4s" repeatCount="indefinite" /></circle></g><path d="M480 239C420 328 311 358 225 350" class="wire" opacity=".2" /><path d="M480 239C420 328 311 358 225 350" class="wire trace all" pathLength="1" /><g class="moving"><circle r="7" fill="#f6cf63" opacity=".5" filter="url(#bloom)"><animateMotion path="M480 239C420 328 311 358 225 350" dur="2.4s" begin="-0.4s" repeatCount="indefinite" /></circle><circle r="2.6" fill="#ffeaa1"><animateMotion path="M480 239C420 328 311 358 225 350" dur="2.4s" begin="-0.4s" repeatCount="indefinite" /></circle></g><g class="moving"><circle r="7" fill="#f6cf63" opacity=".5" filter="url(#bloom)"><animateMotion path="M480 239C420 328 311 358 225 350" dur="2.4s" begin="-1.6s" repeatCount="indefinite" /></circle><circle r="2.6" fill="#ffeaa1"><animateMotion path="M480 239C420 328 311 358 225 350" dur="2.4s" begin="-1.6s" repeatCount="indefinite" /></circle></g><path d="M480 239C548 323 639 357 735 350" class="wire" opacity=".2" /><path d="M480 239C548 323 639 357 735 350" class="wire trace all" pathLength="1" /><g class="moving"><circle r="7" fill="#f6cf63" opacity=".5" filter="url(#bloom)"><animateMotion path="M480 239C548 323 639 357 735 350" dur="2.4s" begin="-0.6000000000000001s" repeatCount="indefinite" /></circle><circle r="2.6" fill="#ffeaa1"><animateMotion path="M480 239C548 323 639 357 735 350" dur="2.4s" begin="-0.6000000000000001s" repeatCount="indefinite" /></circle></g><g class="moving"><circle r="7" fill="#f6cf63" opacity=".5" filter="url(#bloom)"><animateMotion path="M480 239C548 323 639 357 735 350" dur="2.4s" begin="-1.8s" repeatCount="indefinite" /></circle><circle r="2.6" fill="#ffeaa1"><animateMotion path="M480 239C548 323 639 357 735 350" dur="2.4s" begin="-1.8s" repeatCount="indefinite" /></circle></g><circle cx="480" cy="239" r="98" fill="url(#aura)" class="breathe" /><circle cx="480" cy="239" r="57" fill="#111115" stroke="#6c5830" /><circle cx="480" cy="239" r="66" fill="none" stroke="#eac55e" stroke-opacity=".6" stroke-dasharray="38 170" class="orbit" /><use href="#crown" transform="translate(465 221)" /><rect x="147" y="169" width="200" height="55" rx="10" fill="url(#panel)" stroke="#51462e" /><circle cx="164" cy="191" r="3" fill="#e8c667" /><text x="177" y="195" font-size="18">WiFi sensors</text><text x="177" y="212" font-size="8" class="mono muted" letter-spacing="1.8">CAPTURE</text><rect x="614" y="169" width="200" height="55" rx="10" fill="url(#panel)" stroke="#51462e" /><circle cx="631" cy="191" r="3" fill="#e8c667" /><text x="644" y="195" font-size="18">RuView</text><text x="644" y="212" font-size="8" class="mono muted" letter-spacing="1.8">PERCEIVE</text><rect x="125" y="326" width="200" height="55" rx="10" fill="url(#panel)" stroke="#51462e" /><circle cx="142" cy="348" r="3" fill="#e8c667" /><text x="155" y="352" font-sizeLine truncated
<g class="scene work all"><g><text x="480" y="92" text-anchor="middle" font-size="28" letter-spacing="-.6">A sensing workspace, powered by RuView.</text>
<rect x="58" y="113" width="844" height="331" rx="12" fill="#090c12" stroke="#6a5931" />
<path d="M70 144H890" stroke="#292b31" /><circle cx="76" cy="129" r="3" fill="#dcbf65" /><circle cx="87" cy="129" r="3" fill="#5b5c64" /><circle cx="98" cy="129" r="3" fill="#5b5c64" /><text x="480" y="133" text-anchor="middle" class="mono muted" font-size="9">ruOS / Desktops / ruview-workspace</text>
<rect x="59" y="145" width="125" height="287" fill="#0c0e13" />
<use href="#crown" transform="translate(72 158) scale(.4)" /><text x="91" y="170" font-size="15" font-weight="700">ruOS</text>
<rect x="67" y="209" width="107" height="25" rx="5" fill="#302819" stroke="#63512c" />
<rect x="76" y="194" width="7" height="6" rx="1" fill="none" stroke="#686b79" /><text x="91" y="201" font-size="10" class="muted">Overview</text><rect x="76" y="222" width="7" height="6" rx="1" fill="none" stroke="#e1c577" /><text x="91" y="229" font-size="10" class="gold">Desktops</text><rect x="76" y="250" width="7" height="6" rx="1" fill="none" stroke="#686b79" /><text x="91" y="257" font-size="10" class="muted">Terminal</text><rect x="76" y="278" width="7" height="6" rx="1" fill="none" stroke="#686b79" /><text x="91" y="285" font-size="10" class="muted">Swarms</text><rect x="76" y="306" width="7" height="6" rx="1" fill="none" stroke="#686b79" /><text x="91" y="313" font-size="10" class="muted">Network</text><rect x="76" y="334" width="7" height="6" rx="1" fill="none" stroke="#686b79" /><text x="91" y="341" font-size="10" class="muted">Usage</text><rect x="76" y="362" width="7" height="6" rx="1" fill="none" stroke="#686b79" /><text x="91" y="369" font-size="10" class="muted">Settings</text><text x="200" y="169" font-size="14" font-weight="700">RuView workspace</text><circle cx="364" cy="165" r="3" fill="#b9cb91" /><text x="374" y="169" font-size="9" class="muted">Running</text>
<text x="882" y="167" text-anchor="end" font-size="8" class="mono gold">SYNTHETIC DEMO</text>
<rect x="197" y="183" width="469" height="244" rx="7" fill="#14131a" stroke="#32313d" /><path d="M197 373Q370 180 666 285M197 330Q440 390 666 210" fill="none" stroke="#b49548" opacity=".18" />
<g class="browserOpen all"><rect x="209" y="195" width="308" height="163" rx="7" fill="#191d26" stroke="#5a5961" />
<rect x="209" y="195" width="308" height="24" rx="7" fill="#262a33" /><text x="221" y="211" font-size="8" class="mono muted">RuView / Room overview</text><circle cx="503" cy="207" r="2" fill="#e3c477" />
<text x="225" y="241" font-size="13" font-weight="700">Room activity</text><text x="225" y="257" font-size="9" class="muted">Synthetic scene · Presence and motion</text>
<rect x="224" y="329" width="83" height="16" rx="4" fill="#4a3d22" /><text x="233" y="340" font-size="8" class="gold">Sensor connected</text><g><path d="M229 270H494V318H229zM390 270v18m0 12v18" fill="#121921" stroke="#727781" stroke-width="1.5" /><path d="M235 278h34v15h-34zM464 280h22v24h-22z" fill="#303843" stroke="#707786" /><circle cx="285" cy="300" r="4" fill="#f0cd70" /><circle cx="285" cy="300" r="16" fill="none" stroke="#d8b353" opacity=".4" /><circle cx="326" cy="287" r="5" fill="#b7cbd0" /><path d="M326 293v12m-7-8h14m-7 8-5 8m5-8 5 8" fill="none" stroke="#b7cbd0" stroke-width="2" /><path d="M292 290Q310 273 326 287M294 306Q312 312 326 302" fill="none" stroke="#e1bd5f" stroke-dasharray="3 5" class="stream" /></g></g>
<g class="terminalOpen all"><rect x="366" y="287" width="284" height="117" rx="6" fill="#090c12" stroke="#777061" /><rect x="367" y="288" width="282" height="21" rx="5" fill="#25272f" /><text x="380" y="302" font-size="9" class="mono">Terminal</text>
<text x="380" y="324" font-size="9" class="mono gold">› Room event received</text><g class="terminalText all"><text x="380" y="343" font-size="9" class="mono muted">✓ Presence detected (demo)</text><text x="380" y="358" font-size="9" class="mono muted">✓ Rule checked</text></g><text x="380" y="384" font-size="10" class="mono gold done all">✓ Action ready for review</text></g>
<rect x="680" y="183" width="207" height="244" rx="7" fill="#12151d" stroke="#33333c" /><text x="695" y="204" font-size="12" font-weight="700">Ruflo agent team</text><path d="M694 216h178" stroke="#34323a" />
<circle cx="699" cy="235" r="5" fill="#272317" stroke="#be9f54" /><text x="713" y="239" font-size="11">Sense</text><text x="713" y="254" font-size="8" class="muted">Read room events from RuView</text><circle cx="699" cy="288" r="5" fill="#272317" stroke="#be9f54" /><text x="713" y="292" font-size="11">Evaluate</text><text x="713" y="307" font-size="8" class="muted">Check the configured rule</text><circle cx="699" cy="341" r="5" fill="#272317" stroke="#be9f54" /><text x="713" y="345" font-size="11">Act</text><text x="713" y="360" font-size="8" class="muted">Request an approved action</text><g class="done all"><rect x="693" y="381" width="181" height="29" rx="5" fill="#332b1b" stroke="#89713b" /><text x="784" y="400" text-anchor="middle" font-size="10" class="gold">✓ Room event processed</text></g>
<g class="desktopPointer all"><path d="M0 0v20l5-6 8 1z" fill="#fff2ce" stroke="#18181a" stroke-width="1" /></g>
<text x="480" y="469" text-anchor="middle" class="gold" font-size="14">Example: room activity → rule check → action review.</text></g></g><g class="scene end all"><ellipse cx="810" cy="290" rx="260" ry="240" fill="url(#aura)" />
<text x="66" y="133" class="mono gold" font-size="11" letter-spacing="3">RUVIEW PERCEPTION. RUFLO COORDINATION.</text>
<g clip-path="url(#line1)"><text class="headline1 all" x="61" y="233" font-size="72" font-weight="700" letter-spacing="-3">WiFi becomes</text></g>
<g clip-path="url(#line2)"><text class="headline2 all" x="61" y="322" font-size="72" font-weight="700" letter-spacing="-3">spatial awareness<tspan class="gold">.</tspan></text></g>
<path d="M66 382H894" stroke="#3b3528" /><text x="66" y="420" class="mono gold" font-size="17">ruos.cognitum.one</text><text x="892" y="420" text-anchor="end" font-size="10" class="mono muted" letter-spacing="2">BY COGNITUM</text><g><path class="underline all" d="M66 350h310" stroke="url(#gold)" stroke-width="2" /></g></g>
<path d="M40 490H920" stroke="#26242a" /><text x="40" y="513" class="mono muted" font-size="9" letter-spacing="1.5">RUVIEW + RUFLO + RUOS</text><circle cx="853" cy="509" r="2.5" fill="#dfbf67" /><text x="866" y="512" class="mono gold" font-size="9">READY</text><g><g class="signal all"><circle r="45" fill="url(#aura)" class="signal-glow" /><circle r="10" fill="#ebc65d" opacity=".5" filter="url(#bloom)" /><circle r="4.5" fill="#ffe5a1" /><circle r="8" fill="none" stroke="#e9c56c" opacity=".3" /></g></g></g></svg>

After

Width:  |  Height:  |  Size: 37 KiB

+25
View File
@@ -37,6 +37,31 @@ services:
# volumes: ["/path/to/models:/app/models"]
# MODELS_DIR=/app/models
- MODELS_DIR=${MODELS_DIR:-data/models}
# Auth (issue #864). The entrypoint refuses to start (exit 64) unless
# RUVIEW_API_TOKEN is set. Export it before `docker compose up`:
# export RUVIEW_API_TOKEN=$(openssl rand -hex 32)
# Bare names pass the host value through only when it is set.
- RUVIEW_API_TOKEN
# ESP32 UDP ingest (ADR-296, issue #2089). The UDP receiver defaults to
# 127.0.0.1, which cannot receive from the published 5005/udp port.
# Set ONE source guard and the entrypoint binds 0.0.0.0:
# RUVIEW_UDP_ALLOW=<node-ip-or-cidr> restrict sources (preferred)
# RUVIEW_UDP_INSECURE_LAN=true accept the spoofing risk
# Docker Desktop and OrbStack on macOS rewrite the source address to the
# bridge gateway, so a LAN-subnet allowlist drops every frame. There,
# allow the gateway instead:
# docker network inspect <project>_default \
# --format '{{(index .IPAM.Config 0).Gateway}}'
# RUVIEW_UDP_ALLOW=<gateway>/32
# The gateway admits anything that reaches the published port, so keep
# 5005/udp off untrusted networks.
- RUVIEW_UDP_ALLOW
- RUVIEW_UDP_INSECURE_LAN
- RUVIEW_UDP_BIND
# Browsing the UI from another machine also needs the host name or IP
# in SENSING_ALLOWED_HOSTS (otherwise 421), and the TCP ports above
# published beyond 127.0.0.1.
- SENSING_ALLOWED_HOSTS
security_opt:
- no-new-privileges:true
cap_drop:
+56 -3
View File
@@ -22,6 +22,10 @@
# Default is `auto`. Set CSI_SOURCE=simulated when you want
# fake data tagged as such; never set it implicitly.
# MODELS_DIR — directory to scan for .rvf model files (default: data/models)
# RUVIEW_UDP_ALLOW / RUVIEW_UDP_INSECURE_LAN — ADR-296 UDP source guard.
# Setting either makes the entrypoint bind UDP on 0.0.0.0 so
# ESP32 frames on the published 5005/udp port can arrive.
# RUVIEW_UDP_BIND — explicit UDP bind; overrides the choice above.
set -e
# ── Issue #864: fail-closed on default posture ───────────────────────────────
@@ -34,8 +38,8 @@ set -e
#
# Escape hatches (operator must opt in explicitly):
# * Set RUVIEW_API_TOKEN to a strong secret → auth enabled on /api/v1/*.
# * Set RUVIEW_ALLOW_UNAUTHENTICATED=1 → preserves the pre-fix behaviour;
# only safe on an isolated trust boundary.
# * Set RUVIEW_ALLOW_UNAUTHENTICATED=1 (or true/yes/on, any case) →
# preserves the pre-fix behaviour; only safe on an isolated trust boundary.
# * Set RUVIEW_BIND_ADDR to a loopback / private interface → unauth is fine
# when the socket isn't reachable. The auto-bind nudges toward 127.0.0.1.
#
@@ -45,7 +49,12 @@ set -e
case "${1:-}" in
cog-ha-matter|ha-matter|homecore|homecore-server) ;;
*)
if [ -z "${RUVIEW_API_TOKEN:-}" ] && [ "${RUVIEW_ALLOW_UNAUTHENTICATED:-}" != "1" ]; then
# Same spellings the sensing-server accepts for its boolean env flags.
case "${RUVIEW_ALLOW_UNAUTHENTICATED:-}" in
1|[Tt][Rr][Uu][Ee]|[Yy][Ee][Ss]|[Oo][Nn]) __allow_unauth=1 ;;
*) __allow_unauth=0 ;;
esac
if [ -z "${RUVIEW_API_TOKEN:-}" ] && [ "$__allow_unauth" != "1" ]; then
# If the operator hasn't overridden the bind, refuse outright on
# the default 0.0.0.0. If they've nailed it to loopback (or a
# specific private address they trust), let it run.
@@ -97,6 +106,50 @@ esac
# server binary so users can just pass flags:
# docker run <image> --source esp32 --tick-ms 500
if [ "${1#-}" != "$1" ] || [ -z "$1" ]; then
# ── Issue #2089: UDP CSI ingest posture inside a container ───────────────
# ADR-296 defaults the UDP receiver to 127.0.0.1. Inside a container that
# bind can never receive from a published port, so no node ever appears.
# We bind 0.0.0.0 only when the operator has configured an ADR-296 guard
# (RUVIEW_UDP_ALLOW or RUVIEW_UDP_INSECURE_LAN); without one the server
# would refuse a routable bind, so we keep loopback and say why instead.
# An explicit RUVIEW_UDP_BIND or --udp-bind always wins.
# The binary parses RUVIEW_UDP_INSECURE_LAN itself (1/yes/on/true, any
# case); here we only need to know whether it is truthy to pick the bind.
__insecure_lan=0
case "$(printf '%s' "${RUVIEW_UDP_INSECURE_LAN:-}" | tr 'A-Z' 'a-z')" in
1|true|yes|on) __insecure_lan=1 ;;
esac
__udp_bind_arg=0
__udp_guard_arg=0
for __arg in "$@"; do
case "$__arg" in
--udp-bind|--udp-bind=*) __udp_bind_arg=1 ;;
--udp-allow|--udp-allow=*|--udp-insecure-lan) __udp_guard_arg=1 ;;
esac
done
if [ -z "${RUVIEW_UDP_BIND:-}" ] && [ "$__udp_bind_arg" = 0 ]; then
if [ -n "${RUVIEW_UDP_ALLOW:-}" ] || [ "$__insecure_lan" = 1 ] \
|| [ "$__udp_guard_arg" = 1 ]; then
export RUVIEW_UDP_BIND=0.0.0.0
echo "[entrypoint] UDP CSI receiver: binding 0.0.0.0 inside the container" >&2
echo "[entrypoint] (a source guard is set: RUVIEW_UDP_ALLOW or RUVIEW_UDP_INSECURE_LAN)." >&2
else
case "${CSI_SOURCE:-auto}" in
simulated|simulate) ;;
*)
echo "[entrypoint] NOTE: UDP CSI receiver stays on 127.0.0.1 (ADR-296 default)." >&2
echo "[entrypoint] Inside a container that bind cannot receive ESP32 frames from" >&2
echo "[entrypoint] a published 5005/udp port, so no node will appear. To ingest:" >&2
echo "[entrypoint] -e RUVIEW_UDP_ALLOW=<node-ip-or-cidr> (restrict sources), or" >&2
echo "[entrypoint] -e RUVIEW_UDP_INSECURE_LAN=true (accept spoofing risk)" >&2
echo "[entrypoint] Docker Desktop/OrbStack on macOS rewrite the source to the bridge" >&2
echo "[entrypoint] gateway, so the allowlist must name <gateway>/32, not the LAN subnet." >&2
echo "[entrypoint] See https://github.com/ruvnet/RuView/issues/2089" >&2
;;
esac
fi
fi
set -- /app/sensing-server \
--source "${CSI_SOURCE:-auto}" \
--tick-ms 100 \
@@ -4,6 +4,7 @@
|-------|-------|
| **Status** | Proposed |
| **Date** | 2026-03-01 |
| **Updated** | 2026-10-01: macOS 27 also redacts SSID without Location Services; the connected link is kept with a synthetic id, and `auto`+wifi keeps UDP bound (see Amendment 1) |
| **Deciders** | ruv |
| **Codename** | **ORCA** — OS-native Radio Channel Acquisition |
| **Relates to** | ADR-013 (Feature-Level Sensing Commodity Gear), ADR-022 (Windows WiFi Enhanced Fidelity), ADR-014 (SOTA Signal Processing), ADR-018 (ESP32 Dev Implementation) |
@@ -313,3 +314,22 @@ All verification on Mac Mini (M2 Pro, macOS 26.3).
- ADR-022: Windows WiFi Enhanced Fidelity (analogous platform adapter)
- ADR-013: Feature-Level Sensing from Commodity Gear
- Issue [#56](https://github.com/ruvnet/wifi-densepose/issues/56): macOS support request
---
## Amendment 1 (2026-10-01): SSID is also redacted; keep the connected link
**Observed on macOS 27 (M-series Mac, CoreWLAN, unsigned CLI helper).** Without Location Services, `mac_wifi --scan-once` returns `"ssid":""` and `"bssid":"00:00:00:00:00:00"`, while `rssi`, `noise`, `channel` and `tx_rate` are real. The §1 assumption that "SSID + RSSI + channel" survive no longer holds. An unsigned CLI helper never calls `CLLocationManager`, so it never appears in Location Services and cannot be granted access.
Before this amendment, `resolve_bssid` abstained when both identities were blank (to avoid merging unrelated networks), so every macOS scan was dropped and `--source wifi` produced nothing, with no error.
**Change.**
1. `--scan-once` marks its single sample `"connected": true`. It is always the one connected link, so it cannot collide with another network.
2. A redacted sample marked `connected` gets a stable, locally administered synthetic BSSID derived from a reserved key plus the channel. The SSID stays empty; none is invented. Unmarked redacted lines still abstain.
3. `--source auto` that resolves to host WiFi now also binds the UDP receiver, and `wifi_task` yields while the source is a live `esp32`. ESP32 nodes that start after the 2 s boot probe still promote the source, matching the #1004 rule for the simulator.
4. A missing helper logs one clear error with the build command instead of a warning on every tick.
**Measured (2026-10-01, this change):** `--source wifi` → source `wifi:unnamed`, sensing ticks advance. `--source auto` on a quiet port → `wifi` with `udp_receiver=true`; relaying real ADR-018 frames promoted the source to `esp32`, with all 5 nodes listed.
**Still open:** real BSSID/SSID would need a signed `.app` helper with `NSLocationWhenInUseUsageDescription` that requests authorization. The helper still lives under `archive/v1` with no build step.
@@ -4,6 +4,7 @@
|-------|-------|
| **Status** | Proposed |
| **Date** | 2026-03-02 |
| **Updated** | 2026-10-04 — multistatic fusion combines phase across nodes only when every frame declares the same shared phase reference; independent ESP32 nodes and the amplitude-only server bridge fuse amplitude only and report the reason (`phase_fusion` in `/health/ready`, issue #1752). |
| **Deciders** | ruv |
| **Codename** | **RuvSense** -- RuVector-Enhanced Sensing for Multistatic Fidelity |
| **Relates to** | ADR-012 (ESP32 Mesh), ADR-014 (SOTA Signal Processing), ADR-016 (RuVector Training), ADR-017 (RuVector Signal+MAT), ADR-018 (ESP32 Implementation), ADR-024 (AETHER Embeddings), ADR-026 (Survivor Track Lifecycle), ADR-027 (MERIDIAN Generalization) |
+55 -2
View File
@@ -3,7 +3,7 @@
| Field | Value |
|-------|-------|
| **Status** | **Accepted** (MQTT track P1–P7 + P8a + P9 + P10 shipped 2026-05-23 in PR #778, 410 lib tests, witness bundle VERIFIED) / **Proposed** (Matter SDK wiring P8b deferred to v0.7.1 per §9.10) |
| **Date** | 2026-05-23 |
| **Date** | 2026-05-23 (amended 2026-10-02: P4.5 status note, §11) |
| **Deciders** | ruv |
| **Codename** | **HA-DISCO** (MQTT) + **HA-FABRIC** (Matter) + **HA-MIND** (semantic primitives) |
| **Relates to** | ADR-018 (CSI binary frame format), ADR-021 (ESP32 vitals), ADR-031 (RuView sensing-first), ADR-039 (edge vitals packet 0xC511_0002), ADR-079 (camera ground-truth), ADR-103 (cog-person-count), ADR-110 (ESP32-C6 firmware), ADR-114 (cog-quantum-vitals) |
@@ -440,7 +440,7 @@ This means: **adding a new primitive is one file change**. No MQTT schema rev, n
| **P2** | HA discovery message emitter. New module `mqtt_discovery.rs`. Emits all entity `config` topics on connect + every `--mqtt-refresh-secs`. Schema-validated against HA's published JSON schema. | pending |
| **P3** | State publication. Subscribe to internal `tokio::broadcast` channel (the one `tx.send(json)` writes to on line 3983 of `main.rs`). Translate `edge_vitals` / `sensing_update` / `pose_data` messages into per-entity state payloads. Apply rate-limit + privacy-mode filters. | pending |
| **P4** | Integration tests: dockerised mosquitto in CI (extend `.github/workflows/firmware-qemu.yml` pattern), schema-validate every emitted config against HA's `homeassistant/components/mqtt` JSON schemas (pin to a tested HA version). Add a smoke test that brings up sensing-server in `--source mock --mqtt`, subscribes with `paho-mqtt` test client, asserts on entity creation. | pending |
| **P4.5** | **Semantic inference layer (HA-MIND).** New module `semantic_inference.rs` implementing the 10 v1 primitives from §3.12. Output broadcast channel consumed by both MQTT publisher (P3) and Matter bridge (P8). Per-primitive precision/recall baselines published to `docs/integrations/semantic-primitives-metrics.md`. Unit tests per FSM + integration tests via replay of ADR-079 paired captures. | pending |
| **P4.5** | **Semantic inference layer (HA-MIND).** New module `semantic_inference.rs` implementing the 10 v1 primitives from §3.12. Output broadcast channel consumed by both MQTT publisher (P3) and Matter bridge (P8). Per-primitive precision/recall baselines published to `docs/integrations/semantic-primitives-metrics.md`. Unit tests per FSM + integration tests via replay of ADR-079 paired captures. | partial — 6 of 10 primitives on the live MQTT publisher; see §11 |
| **P5** | Docs: new `docs/integrations/home-assistant.md` with screenshots of the HA UI after auto-discovery completes, example HA dashboard YAML (Lovelace card configs), 8 starter blueprints from §3.12.2 (distress notify, wake routine, hallway dim, elderly anomaly alert, meeting lights, bathroom fan, fall-risk escalate, auto-arm security), and the raw-channel example automations: "turn on hall light when presence ON", "send notification on fall_detected event", "log HR/BR to InfluxDB". | pending |
| **P6** | Ship `--mqtt` in the next sensing-server release (target: v0.7.0). Demo end-to-end on `cognitum-v0` against a Mosquitto add-on running on a Home Assistant OS install. Update README hardware-options table with "Works with Home Assistant" badge. | pending |
| **P7** | Matter Bridge spike: build a throwaway prototype with `matter-rs` exposing one `OccupancySensor` endpoint + one `GenericSwitch` for fall. Pair against Apple Home, Google Home, and HA's Matter integration. Decision gate: if pairing works on all three, proceed to P8; if blocked, switch to chip-tool FFI and re-spike. | pending |
@@ -667,4 +667,57 @@ Empty as of 2026-05-23. New questions discovered during implementation will be f
---
## 11. Status note: P4.5 semantic states on the live publisher (2026-10-02)
Issues [#2085](https://github.com/ruvnet/RuView/issues/2085) and [#2093](https://github.com/ruvnet/RuView/issues/2093) found that discovery announced 20 entities per device while only 6 ever received state, that `presence/state` was published on every CSI frame, and that HA device ids changed on every restart. The FSMs in `crate::semantic` existed but had no caller outside their tests. This note records what is now wired and what is not.
### 11.1 What changed
- **Semantic primitives run on the live path.** `mqtt::planner` holds one `SemanticBus` per node, sampled at 1 Hz (the rate the FSM constants assume). The bus feeds only the MQTT publisher. Matter does not consume it yet, so the "output broadcast channel shared with Matter" in P4.5 is not built.
- **Announce only what has a source.** Six primitives are announced: `room_active`, `no_movement`, `elderly_inactivity_anomaly`, `someone_sleeping`, `possible_distress` and `fall_risk_elevated`. Four are not: `meeting_in_progress` needs a 1–20 % motion level, but per-node motion is a three-level classification; `bathroom_occupied`, `bed_exit` and `multi_room_transition` need zones, and the broadcast carries none (the §3.12.5 zone file is not implemented). For the same reason `zone_occupancy` (§3.1) and `pose` are not announced: `pose_keypoints` is never populated on the broadcast, and `persons[].keypoints` are synthesized.
- **Per-entity availability (§3.6).** An announced entity is `online` only while its input exists: vitals while the calibration-gated estimate is published, `fall` while the node sends `edge_vitals`, `motion_energy` and `rssi` while present, and semantic primitives after the 60 s warmup (sleeping and distress also need breathing or heart rate, fall risk needs the fall detector). Otherwise it is `offline` and no state is sent. Transitions publish at once; all availability topics are re-sent every 30 s.
- **No default values.** `motion_energy` was always `0.0` (MEASURED: 176 of 176 samples) because the bridge never set it. It now carries the node's `motion_band_power`, or the room value, or nothing. `fall` is driven by the rising edge of `edge_vitals.fall_detected`. Only `sensing_update` produces snapshots, which removes the phantom aggregate device that `edge_vitals` frames created. `present_still` now maps to 0 % motion instead of 100 %.
- **Rates (§3.7).** Binary states publish on change, plus a 60 s retained heartbeat, as §3.5 already specified. Sensors keep their configured rates.
- **Stable device identity (§3.8).** The default client id is now `wifi-densepose-<8 hex>`, persisted in `<data-dir>/mqtt_client_id`. §3.8 specified `wifi-densepose-<hostname>`; a persisted random id avoids a hostname dependency and survives hostname changes. `--mqtt-client-id` still overrides it.
### 11.2 Evidence
Unit tests (CODE-DERIVED, run with `cargo test -p wifi-densepose-sensing-server --features mqtt --lib -- mqtt:: semantic::`): `mqtt::planner::tests` covers the entity → state mapping, unavailable-without-source, warmup, on-change with heartbeat at a 73 frame/s input, sensor rates, reset and privacy mode. `mqtt::bridge::tests` covers message-type filtering, fall edges and motion energy. `mqtt::config::tests` covers client-id persistence across two "restarts" in a temp dir, override and malformed-file recovery.
Live measurement (MEASURED, 2026-10-02): one ESP32 node streaming CSI and `edge_vitals` over UDP to a macOS host; `sensing-server --source esp32 --mqtt` against `eclipse-mosquitto:2` in Docker on a non-default port; every topic captured with a `paho-mqtt` subscriber on `#`. "Before" is `03a89793` (main), "after" is this change, built `--release --features mqtt`, same command and window.
```bash
# Broker: mosquitto.conf = "listener 1883 0.0.0.0 / allow_anonymous true / persistence false"
docker run -d --rm -p 127.0.0.1:18830:1883 \
-v "$PWD/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro" eclipse-mosquitto:2
# Run A (180 s, then SIGTERM) and run B (restart, 45 s) share one --data-dir.
# A paho-mqtt subscriber on '#' starts 2 s before each run.
RUVIEW_API_TOKEN=<random> sensing-server --source esp32 --udp-port 5006 \
--udp-bind 0.0.0.0 --udp-allow <node-subnet> --http-port 3000 --ws-port 3001 \
--no-mdns --data-dir <dir> --mqtt --mqtt-host 127.0.0.1 --mqtt-port 18830
```
| Measure (180 s window unless noted) | Before (`03a89793`) | After |
|---|---|---|
| HA devices announced in run A | 2 (one phantom aggregate + `-node1`) | 1 (`-node1`) |
| Entities announced per device | 20 | 15 |
| Entities that received state (node device) | 6 | 10 |
| Announced entities without state that still reported `online` | 14 | 1 (`fall`: an event, and no fall occurred) |
| `presence/state` messages (node device) | 6,814 (37.9 / s) | 4 (one per change, plus 60 s heartbeat) |
| `motion_energy` values | `0.0` in 176 of 176 | node band power, 177 samples |
| Device id, run A → run B (restart, same `--data-dir`) | `wifi-densepose-<pid A>` → `<pid B>` (4 devices on the broker after B) | same persisted id in both runs (1 device) |
| `offline` published on SIGTERM | no | no (unchanged; see 11.3) |
| Same, plus `--privacy-mode` (75 s, fresh `--data-dir`) | — | 13 entities announced; no `heart_rate`, `breathing_rate` or `pose` topic for that device |
After the change, five announced entities had no state: `heart_rate` and `breathing_rate`, because no calibration had been run; `someone_sleeping` and `possible_distress`, which depend on them; and `fall`, which is an event and no fall occurred. The first four were `offline`. `fall` was `online`, because the node sent `edge_vitals`. Before the change all 14 entities without state still reported `online`. Only one node was streaming during this measurement. #2085 used five nodes and measured about 73 `presence` messages per second per node.
### 11.3 Not done
- Semantic precision/recall on real captures. The figures in `semantic-primitives-metrics.md` have no reproducer in the repository and stay CLAIMED.
- Zone and threshold files, `--semantic*` / `--no-semantic` flags on `sensing-server`, the `reason` attribute (§3.12.4), and `semantic_events.jsonl`.
- MQTT last-will and offline-on-shutdown (§3.6). The server registers no LWT and SIGTERM does not publish `offline`, so availability stays as last published after the server stops.
- Matter consumption of the semantic states.
---
*ADR-115 is the integration story that turns RuView from "another sensing platform" into "drop-in upgrade for any HA install **and** any Matter-controller home." MQTT carries the rich, differentiated telemetry; Matter carries the standardised subset across every controller ecosystem. Numbers 111 and 112 remain reserved per the project ADR-numbering policy.*
@@ -66,10 +66,64 @@ hardware claims.
timestamps, sequence behavior, calibration, maximum size, and report rate.
- Validate OpenWrt One first, then BPI-R3 4x4, before considering MT7996/MLO.
## Amendment 1 — vendor CSI on MT7981, still unvalidated
- **Date**: 2026-09-19, recorded here 2026-10-01
- **Status**: proposed. One board model (two units). The hardware gates below are still open.
The 2026-07-18 decision said the public `mt76` tree has no supported CSI export,
and that a firmware change might be required before physical CSI exists. Both
statements need a narrower reading.
Mainline `openwrt/mt76` still has no CSI export. MediaTek's public OpenWrt feed
does: vendor id `0x0ce7`, MCU event `0xc2`, patch
`1001-wifi-mt76-mt7915-csi-implement-csi-support`. On a Wavlink WL-WN586X3
Rev A (MT7981BA + MT7976CN + MT7531AE) that patch, rebased onto the `mt76`
revision pinned by OpenWrt 24.10.8, produced complex samples without a firmware
blob swap. The `0xc2` hook is already in the MT7981 firmware that the OpenWrt
24.10.8 image carries. The
rebased patch and the two userspace fixes are in `firmware/openwrt-wn586x3/`.
What the first capture was: one record, 2.4 GHz, 20 MHz, legacy OFDM, 64
subcarriers, a single chain. CSI arrived only while a client sent frames.
The rate is client traffic, not a sensor sample clock. The capture is not in
this repository.
What it was not:
- Not a calibration, a sequence check, a timestamp check, or a repeatability run.
The label on the wire is `mediatek:physical-unvalidated`. Plain `mediatek` stays
reserved for a frame that has passed those gates.
- Not a 4x4 array. Each radio is two chains. The four antennas are a 2.4 GHz pair
and a 5 GHz pair. Two routers are two receivers. Cross-box phase is not a
shared local oscillator, so it is not a beam.
- Not the OpenWrt One or the BPI-R3. Those remain the upstream-friendly targets
in the original decision. The WN586X3 is the board this path was actually run on.
`wifi-densepose-mtk-bridge` translates `mt76-vendor` JSON, or an MtkCSIdump
datagram, into ADR-267 MTC1 and sends it to the sensing server. The server keeps
one snapshot and a bounded ring of per-subcarrier amplitude and phase per
`device_id`. It runs `mediatek-amplitude-heuristic-v0` on mean and peak amplitude,
and `activity-index-v0` on per-subcarrier fluctuation. Both are `CLAIMED`. Neither
is the ESP32 vital-sign path.
One measured property supports further work, and nothing more. Offline, on six
local dump captures from two units (2.4 GHz, HT20, chains rx0 and rx1 of transmit
stream 0), the cross-chain phase `arg(H0·conj(H1))`, summed over subcarriers,
moved 0.014-0.039 rad (p50) between adjacent frames for well-sampled
transmitters. Raw per-chain phase moved about 1.56 rad (p50), which is what
uniformly random phase gives (MEASURED; reproducer
`scripts/mediatek-csi-phase-stability.py`). That shows a stable observable inside
one radio. It does not show that the observable tracks people.
The operator guide is [`docs/mediatek-router-csi.md`](../mediatek-router-csi.md).
## Links
- [ADR-123: BFLD capture path](ADR-123-bfld-capture-path-nexmon-and-esp32.md)
- [ADR-264: RTL8720F radar wire protocol](ADR-264-rtl8720f-radar-wire-protocol.md)
- [ADR-267: MediaTek MIMO CSI wire protocol](ADR-267-mediatek-mimo-csi-wire-protocol.md)
- [MediaTek router CSI guide](../mediatek-router-csi.md)
- [upstream mt76](https://github.com/openwrt/mt76)
- [OpenWrt One](https://openwrt.org/toh/openwrt/one)
- [MediaTek OpenWrt feed](https://git01.mediatek.com/openwrt/feeds/mtk-openwrt-feeds/)
@@ -34,6 +34,81 @@ Define `MTC1` version 1 as a little-endian, self-delimiting envelope:
- Flags distinguish calibrated, saturated, time-synchronized, dropped-predecessor,
and synthetic frames. Synthetic provenance cannot be cleared by downstream code.
## Amendment 1 — PPDU types `Legacy` and `Unknown`
- **Date**: 2026-09-19
- **Status**: proposed. Demonstrated on one WL-WN586X3 capture. Not a maintainer acceptance.
- **Scope**: `PpduType` discriminants only. The 72-byte header layout, CRC,
payload ordering, flags, and caps are unchanged.
### What changed
`PpduType` gains two variants:
| Value | Name | Meaning |
|---|---|---|
| 6 | `Legacy` | Pre-HT: 802.11b CCK or 802.11a/g OFDM |
| 7 | `Unknown` | A PPDU format the producer could not classify |
Values 1-5 (`Ht`, `Vht`, `HeSu`, `HeMu`, `Eht`) are untouched.
### Why
The original set assumed every CSI report would be HT or later. The first real
MT7981 capture disproved that. A Wavlink WL-WN586X3 (MT7981B + MT7976C) running
an OpenWrt 24.10.8 image built with the patches in `firmware/openwrt-wn586x3/`
reported `rx_mode = 1` (`MT_PHY_TYPE_OFDM`) for a client associated on 2.4 GHz
channel 6 at 20 MHz. The bridge had no way to express it and dropped the frame.
That was a real data loss, not a corner case. MediaTek's CSI driver treats legacy
OFDM as a first-class mode: `mt7915_vendor_csi_tone_mask` skips only
`MT_PHY_TYPE_CCK`, and its `mode_map` is keyed by `MT_PHY_TYPE_OFDM`, `_HT`,
`_VHT` and `_HE_SU`, giving OFDM its own tone-mask group
(`1001-wifi-mt76-mt7915-csi-implement-csi-support.patch:1070-1088`). Association
and management exchanges are legacy-rate by design, so a sensing deployment will
see these continuously.
`Unknown` exists so the enum's gaps (5-7 and 12 in `mt76_phy_type`) and future
modes are carried explicitly rather than guessed at or dropped. It also covers
transports that omit the field: the MtkCSIdump UDP datagram carries no `rx_mode`,
and reporting `Unknown` there is honest where defaulting to a concrete format was
not.
### Compatibility
- **Forward, not backward.** A decoder built before this amendment rejects
discriminants 6 and 7 with `UnknownPpduType`, because the original parser
rejects unknown types by design. `TryFrom<u8>` returns `Err` rather than
panicking, so an old decoder fails safely — it just drops legacy frames, and
does so silently. **Rebuild MTC1 consumers after this codec change.**
- **Scope is narrower than "the workspace".** `wifi-densepose-hardware` defines
three independent `PpduType` enums — `csi_frame.rs` (ESP32),
`qualcomm_csi.rs`, and `mediatek_csi.rs` — and this amendment touches only the
MediaTek one. Neither of the others changes, so no other vendor bridge is
affected. The complete set of `mediatek_csi::PpduType` consumers is:
`wifi-densepose-hardware`'s own `mediatek-csi-sim` binary,
`wifi-densepose-mtk-bridge`, and `wifi-densepose-sensing-server`, which renders the
value with `{:?}` and so needed no code change. In particular the
`ppdu_type > 3` bound in `bootstrap_baseline.rs` is fed from the **ESP32**
enum via `CsiGridKey::from_frame(&Esp32Frame)`, not from MediaTek frames, so
`Legacy` and `Unknown` cannot reach it.
- Because the variants were added rather than renumbered, and Rust requires
exhaustive matches, the compiler locates any consumer that must be updated.
Nothing outside the files above needed one.
- Producers must emit 6 only for frames that genuinely are pre-HT, and 7 only
where the format is truly unclassified. Neither is a dumping ground for a
mapping the producer has not done.
- The mapping from `rx_mode` to `PpduType` is now infallible, so no MTC1 producer
may drop a frame on PPDU grounds.
- Unchanged: synthetic provenance still cannot be cleared by downstream code.
### Observed dimensions
The same capture assembled to 1x1x64. That is the client's behaviour, not a
protocol limit: the handset sent single-stream frames to a 2.4 GHz 20 MHz AP.
Expect 1x2 or 2x2 on 5 GHz, and size consumers for the chipset profile's
`max_chains` rather than for what one capture happened to show.
## Consequences
### Positive
@@ -59,3 +134,8 @@ Define `MTC1` version 1 as a little-endian, self-delimiting envelope:
- [ADR-266: MediaTek Filogic CSI platform](ADR-266-mediatek-filogic-csi-platform.md)
- [ADR-018: ESP32 binary CSI framing](ADR-018-esp32-csi-frame-protocol.md)
- [ADR-264: RTL8720F radar wire protocol](ADR-264-rtl8720f-radar-wire-protocol.md)
- Amendment 1 implementation: `v2/crates/wifi-densepose-hardware/src/mediatek_csi.rs`
(codec) and `v2/crates/wifi-densepose-mtk-bridge` (`ppdu_from_rx_mode`). The
motivating capture is a lab record and is not in this tree: it contains a
client transmitter address and raw CSI. The in-repo check is a fabricated
legacy-OFDM record in `wifi-densepose-mtk-bridge/tests/hardware_capture.rs`.
@@ -0,0 +1,187 @@
# ADR-368: RuView compute kernel as a WASM + napi-rs npm package
- **Status**: proposed
- **Date**: 2026-09-30
- **Deciders**: RuView maintainers; acceptance pending
- **Tags**: npm, wasm, napi-rs, metaharness, vitals, supply-chain
- **Related**: ADR-021 (vitals), ADR-263/265 (npm harness review and
distribution), ADR-283 (community metaharness), ADR-285 (WASM-first Homecore
metaharness)
## Context
`@ruvnet/ruview` (ADR-283) is a dependency-free operator/contributor harness:
it guides, verifies, and delegates, but it cannot run RuView signal processing.
JavaScript consumers (MCP hosts, dashboards, browser demos, agents) that want
real RuView computation must today start the Rust sensing server or re-implement
DSP in JS, which drifts from the Rust source of truth.
We want one Rust implementation reachable from npm with:
1. a portable default that works on every Node platform and in browsers;
2. an optional native path for hosts that want it;
3. the metaharness trust posture: least authority by default, the backend that
actually ran is always reported, fail-closed on missing or tampered
artifacts, and no accuracy claim without an evidence tag.
## Decision
### One core, one ABI, two transports
- **`v2/crates/ruview-kernel`** (workspace member) wraps the ADR-021 pipeline
from `wifi-densepose-vitals` (EMA preprocessing, breathing 0.1–0.5 Hz, heart
0.8–2.0 Hz, anomaly z-score) in a streaming `Session`, a deterministic
SplitMix64 synthetic-CSI generator, and a bounded session registry.
- The entire ABI is one function: `call(op, request_json) -> envelope_json`,
returning `{"ok":true,"result":…}` or
`{"ok":false,"error":{"code","message"}}`. Operations: `info`,
`validate_config`, `analyze`, `synthesize`, `session_open|push|summary|close`.
Keeping the ABI to a single string function makes backend parity a property
of the transport rather than of duplicated bindings.
- **WASM transport** (`src/wasm_abi.rs`, `wasm32-unknown-unknown` only):
`rvk_alloc`/`rvk_free`/`rvk_call`/`rvk_abi_version`. The module has **zero
imports** — no filesystem, network, clock, or randomness. The JS loader
refuses any module that imports host functions.
- **napi-rs transport** (`v2/crates/ruview-kernel-napi`): exports only
`abiVersion()` and `call(op, json)`. It is a standalone Cargo workspace
(listed in the v2 `exclude`) so napi never enters
`cargo test --workspace`, and it builds with `panic = "unwind"` plus
`catch_unwind` so a defect becomes an error envelope, not a Node crash.
### Input is untrusted at the ABI
Requests are bounded before processing: 16 MiB per request, 512 subcarriers,
100 000 frames per call or synthesis, 64 concurrent sessions. Unknown JSON
fields are rejected (`deny_unknown_fields`); frame widths must match the
configured subcarrier count; values must be finite with magnitude ≤ 1e9; all
frames of a batch are validated before any is processed. The core returns an
error envelope instead of panicking on caller data (release builds of the WASM
module use `panic = "abort"`, which the loader surfaces as a `trap` and
recovers from with a fresh instance).
### npm package `@ruvnet/ruview-kernel` (`harness/ruview-kernel`)
- Zero runtime dependencies. `loadKernel({backend})` with
`backend ∈ {wasm (default), napi, auto}` or `RUVIEW_KERNEL_BACKEND`.
WASM is the default because it carries no host authority. `napi` never
silently falls back; `auto` prefers napi and records `fallbackReason`.
- Artifacts are built from source by `npm run build` (`scripts/build.mjs`,
no shell) into `wasm/` and `native/ruview_kernel.<triple>.node`, with
`SHA256SUMS`. The loader verifies checksums; a mismatch is always fatal and
never an `auto` fallback trigger; `requireIntegrity` also refuses
unchecksummed artifacts. Built artifacts are git-ignored and never committed.
- `src/wasm.js` is filesystem-free, so browsers can use
`loadWasmKernelFromBytes(bytes)`.
- CLI `ruview-kernel`: `doctor`, `info`, `selftest`, `parity`, `synth`,
`analyze --file`, `bench`. `synth --out` refuses to overwrite files;
`analyze` reads only a bounded regular file.
- `selfTest()` runs synthesize → analyze → chunked streaming session → hostile
input, and asserts known-tone recovery. `parity()` diffs every output of
both backends with a 1e-9 relative tolerance, because `sin`/`exp` come from
different libm implementations on wasm32 and native targets; exact JSON
equality is reported separately and is not required.
### Metaharness integration
`@ruvnet/ruview` gains the read-only tool `ruview_kernel_selftest`
(CLI: `ruview kernel [--backend …]`). It dynamically imports the fixed
specifier `@ruvnet/ruview-kernel`, so `@ruvnet/ruview` keeps zero runtime
dependencies, and it returns `kernel_not_installed` when the package is absent.
MCP arguments are limited to `backend` and `seconds`; they cannot choose a
module or artifact path. The capability appears in `ruview_guidance` as
`portable-compute-kernel`.
## Evidence
- **SYNTHETIC** (`cd harness/ruview-kernel && node bin/cli.js selftest`):
60 s at 20 Hz, 56 subcarriers, 15 bpm breathing and 72 bpm heart tones →
recovered 15.0 bpm respiratory (valid) and 70.6 bpm heart (degraded
confidence 0.44). Tolerances asserted: ±3 bpm respiratory, ±8 bpm heart.
- **SYNTHETIC** parity (`node bin/cli.js parity`): 900 frames / 45 readings,
no difference above 1e-9 relative between wasm32 and linux-x64-gnu napi;
outputs are not byte-identical.
- **MEASURED** on one Linux x64 development container
(`node bin/cli.js bench --backend wasm|napi --seconds 120`): about 78 ms
(wasm) and 63 ms (napi) per 2 400-frame `analyze`, including JSON transfer.
Single-host timing, not a cross-platform claim.
- The WASM release artifact is about 230 KB and imports nothing
(`node scripts/verify-artifacts.mjs --require-wasm`).
None of these numbers says anything about real-world vital-sign accuracy.
That still requires the ADR-293 ground-truth protocol with a reference device.
## Amendment 1 (2026-10-01): binary transport and optimization evidence
**Profile (MEASURED on one Linux x64 development container, 2 400 frames ×
56 subcarriers).** JSON was the dominant cost:
- JS `JSON.stringify` of the request alone took 52–78 ms against
107–124 ms for a whole `analyze`.
- Rust then re-parsed about 270 000 numbers.
**Decision.** Add a binary fast path without changing the ABI version.
- New operations `analyze_flat` and `session_push_flat`, carried by
`call_f64(op, json, &[f64])`:
- WASM: `rvk_call_f64`, which reads bytes so alignment does not matter;
- napi: `callF64(op, json, Float64Array)`.
- `analyze()` and `session.push()` pack uniform frames into one
`Float64Array` (all amplitudes, then all phases) and use the binary path
automatically. Irregular input still takes the JSON path so its error names
the bad frame.
- `analyzeFlat(Float64Array, …)` is the zero-copy entry.
- `Session::step` is shared by both paths.
**Evidence.**
- **Byte-identical outputs.** On identical `f64` inputs the two paths
produce byte-identical JSON (`tests/abi.rs`).
- **A JSON precision finding.** serde_json's default float parser is not
correctly rounded, so the JSON transport perturbs inputs by ULPs (34 of 160
amplitude values in a 2 s, 4-subcarrier SYNTHETIC sample). The binary path is bit-exact.
- **Speed.** MEASURED, `ruview-kernel bench --seconds 120`, same host:
| Backend | JSON transport | Binary transport | Speed-up |
|---|---|---|---|
| wasm | 134.8 ms | 47.8 ms (~50 000 frames/s) | 2.82× |
| napi | 109.7 ms | 44.8 ms (~53 600 frames/s) | 2.45× |
- **Remaining cost is DSP.** Native timing gives heart-rate extraction
20.8–22.9 ms of 30.6–38.3 ms (about 60–68%, MEASURED; reproducer:
`cargo run --release -p ruview-kernel --example profile_stages`): the per-frame autocorrelation over the 15 s window
in `wifi-densepose-vitals`. That crate is shared with the production sensing
server, and an incremental ACF would change floating-point summation order
and therefore outputs. It is left as a follow-up that needs its own review
and regression evidence.
**Rejected after measurement.** MEASURED, three runs on the same host;
reproducer: `node bench/wasm-variants.mjs` in `harness/ruview-kernel`. Each
run is the median of 15 `analyzeFlat` calls on 2 400 frames. All variants
produced byte-identical outputs.
| Variant | Size | Median ms (3 runs) | Decision |
|---|---|---|---|
| baseline (`opt-level = 3`) | 244 KB | 38.3–40.6 | kept |
| `+simd128` | 242 KB | 37.4–54.7 | rejected: no consistent gain, and it needs SIMD-capable engines |
| `opt-level = "s"` | 227 KB | 40.8–70.1 | rejected: consistently slower for a 17 KB size saving |
## Consequences
- JavaScript consumers get real RuView DSP without a server, in Node or the
browser, with one Rust source of truth.
- A second native build matrix exists. Until per-platform packages
(`@ruvnet/ruview-kernel-<triple>`) are published by CI, only WASM is
portable; the loader already resolves those package names.
- JSON transfer dominates the cost of small calls; a typed-array fast path can
be added later behind a new ABI version.
## Rollout and rollback
1. CI (`ruview-kernel-npm.yml`) builds both transports from source, runs the
Rust tests, the package tests with `RUVIEW_KERNEL_REQUIRE_NAPI=1`, parity,
artifact verification, and `npm pack --dry-run`.
2. Publication stays CI-only with provenance (ADR-265) and requires explicit
maintainer approval; this ADR does not add a publish job.
3. Rollback: remove the `ruview_kernel_selftest` tool (the harness has no
dependency on the package) and deprecate the npm version; the Rust crates
are additive.
@@ -0,0 +1,74 @@
# ADR-369: npm RuView operator surface — CLI, MCP, and SDK over one registry
- **Status**: proposed
- **Date**: 2026-09-30
- **Deciders**: RuView maintainers; acceptance pending
- **Tags**: npm, mcp, sdk, cli, metaharness, least-authority
- **Related**: ADR-263/265 (npm harness review and distribution), ADR-283
(community metaharness), ADR-368 (compute kernel), ADR-370 (flashing),
ADR-371 (training), ADR-372 (doctor)
## Context
`@ruvnet/ruview` 0.5.x could onboard, lint claims, verify the proof, monitor a
node, and read Cognitum Spaces. Three operator jobs still left the package:
- **Flashing** was a Windows-only stub that returned `manual_step_required`.
- **Training** had only a skill document; nothing ran a trainer or checked
results mechanically.
- **Diagnostics**: `doctor` printed six PASS/FAIL lines without remedies.
There was also no programmatic API. Integrators had to shell out to the CLI or
speak MCP.
## Decision
1. **One registry, three front ends.** Every capability is a `ruview_*` tool
in `src/tools.js`, with a JSON schema, a `TOOL_POLICY` class, and a
structured, fail-closed result. The CLI verbs, the MCP server, and the new
SDK all go through `runTool`. There is one argument validator and one
authorization path.
2. **SDK** (`@ruvnet/ruview/sdk`, typed by `src/sdk.d.ts`). `createRuView()`
returns a frozen object with `doctor`, `firmware.{ports,plan,flash,monitor}`,
`training.{plan,run,gate,calibrate}`, `kernel.{selfTest,load}`, `guidance`,
`claimCheck`, `verify`, `spaces`, `memorySearch`, `call`, `tools`, and
`startMcpServer`.
- Calls run with `source: 'sdk'`: trusted in-process code, like the CLI.
Mutating tools still refuse to act without `confirm: true`.
- `{ strict: true }` turns `ok:false` into a `RuViewError`.
3. **Plan/act split for every mutation.**
- `ruview_firmware_plan` and `ruview_train_plan` are read-only and their
schemas do not accept `confirm`. An MCP client without write grants can
review the exact command.
- The acting tools (`ruview_node_flash`, `ruview_train`) keep the existing
grant-plus-confirm gate: `hardware-write` and `workspace-write`.
4. **New tools**: `ruview_doctor`, `ruview_firmware_plan`,
`ruview_firmware_ports`, `ruview_train`, `ruview_train_plan`,
`ruview_train_gate`. `ruview_node_flash` is re-implemented (ADR-370).
`TOOL_POLICY`, `.harness/claims.json`, and `.harness/mcp-policy.json` are
kept in lock-step, and a test fails on any drift.
5. **Process execution** for operator tools goes through `src/exec.js`:
- argv only, never a shell;
- a scrubbed environment: the base allowlist plus toolchain locations such
as `LIBTORCH`, `CARGO_HOME`, `VIRTUAL_ENV` and `IDF_PATH`, and no
credentials;
- bounded output, and secrets redacted.
6. **Zero runtime dependencies stays.** The optional compute kernel
(ADR-368) is loaded dynamically. Version 0.6.0. The unpacked-size ceiling
rises from 160 KiB to 224 KiB for the new modules, with the budget recorded
in both npm workflows.
## Consequences
- Agents, scripts, and humans get the same behaviour and the same refusals.
- The package now drives real hardware and training. Keeping the plan/act
split and argv-only execution is essential, and tests pin both.
- The surface is larger (16 MCP tools). Hosts that cap tool counts can
filter by annotations (`readOnlyHint`).
## Acceptance
- `cd harness/ruview && npm test && npm run test:security` (drift test in
`test/sdk.test.mjs`).
- `npx @ruvnet/ruview tools` lists 16 tools. `node -e "import('@ruvnet/ruview/sdk')"`
resolves from an installed tarball.
@@ -0,0 +1,83 @@
# ADR-370: Cross-platform, verified ESP32 firmware flashing from npm
- **Status**: proposed
- **Date**: 2026-09-30
- **Deciders**: RuView maintainers; acceptance pending
- **Tags**: firmware, esp32, esptool, supply-chain, hardware-evidence
- **Related**: ADR-028 (capability audit / witness), ADR-369, firmware
`README.md` §0 and §2
## Context
The documented flash procedure is four `esptool write_flash` offsets. It is
typed by hand, and a wrong chip, wrong flash size, or tampered image can brick
a board or silently run the wrong firmware. The previous `ruview_node_flash`
was Windows-only and never flashed. Project rules also say that a successful
build or write is **not** hardware validation; that requires a captured
boot/runtime log from real silicon.
## Decision
`src/firmware.js` implements plan → verify → preflight → write → evidence:
1. **Variants**:
- `s3-8mb` → `esp32s3` / 8MB
- `s3-4mb` → `esp32s3` / 4MB; prefers `*-4mb.bin` images
- `c6` → `esp32c6` / 4MB
Offsets follow the shipped partition tables: bootloader `0x0`, partition
table `0x8000`, optional OTA data `0xf000`, app `0x20000`. NVS is never
written, so WiFi and node configuration survive.
2. **Bundle verification**. A bundle is an extracted release flash bundle,
`release_bins/<variant>`, or an ESP-IDF `build/` directory.
- Every image must match `SHA256SUMS` or `SHA256SUMS.txt`. A mismatch is
always fatal.
- Missing checksums are refused unless `allow_unverified` is set, which is
for local builds only.
- Images must be regular, non-empty files of at most 16 MiB, inside the
bundle; symlinks that escape the bundle are refused.
- An 8 MB variant refuses a 4 MB partition table.
- A bundle without OTA data produces an explicit warning (a node on `ota_1`
keeps booting `ota_1`).
3. **Argv, not shell.**
- The port must match `COM<n>` or `/dev/(tty|cu)*`.
- Baud comes from a fixed set.
- The command is `python -m esptool --chip … --port … --baud … write_flash
--flash_mode dio --flash_size … <offset> <file>…`. The underscore forms
work in both esptool 4.x and 5.x.
4. **Chip preflight.** `esptool chip_id` must report the variant's chip. The
parser covers the 4.x "Chip is" and 5.x "Chip type:" / "Detecting chip
type…" formats. A mismatch or missing response stops the flow before any
write.
5. **Evidence.** After a write, a read-only pyserial capture (default 15 s,
port and duration passed as argv) is summarized: line count, CSI
callbacks, MGMT+DATA upgrade, panic markers, firmware version.
- `hardwareValidated: true` only when CSI callbacks appear with no panic;
the result's `evidence` is then `MEASURED` with a boot-log reason.
- Otherwise the result states the board is not validated.
6. **Authority.** Without `confirm` the result is a plan. MCP needs the
`hardware-write` grant plus `confirm`. `ruview_firmware_plan` and
`ruview_firmware_ports` are read-only.
7. **Provisioning stays out.** WiFi credentials remain in `provision.py`, so
they never enter harness arguments, logs, or MCP transcripts.
## Evidence
- `npx @ruvnet/ruview doctor --group firmware` verified all four checked-in
`release_bins` bundles (c6-adr110, c6-onboarding, s3-adr110,
s3-fair-adr110) against their `SHA256SUMS.txt` in this change's development
container.
- The write and preflight paths are covered by tests with injected fake
hardware: mismatch refusal, probe failure, flash failure, boot-log
validation, and a silent log that yields no validation.
- **No real board was flashed** during this change, so real-silicon validation
is outstanding.
## Consequences
- Operators on any OS get one verified command. Agents can review plans
without hardware authority.
- The workflow depends on Python, esptool, and pyserial. `doctor` reports each
one with a fix.
- Follow-up: an ADR-028 witness record could store the boot-log summary and
image digests.
@@ -0,0 +1,91 @@
# ADR-371: Harness training runner and mean-pose evidence gate
- **Status**: proposed
- **Date**: 2026-09-30
- **Deciders**: RuView maintainers; acceptance pending
- **Tags**: training, evaluation, evidence, pose, honesty
- **Related**: ADR-079 (camera-supervised pose), ADR-151 (room calibration),
ADR-181 (baseline-relative PCK), ADR-369
## Context
RuView has a retracted pose result (a CLAIMED 92.9%/100% that did not survive
baseline and leakage review). The project rule is that pose
PCK is quoted only with the mean-pose baseline, on a leakage-free held-out
split, with an evidence tag. The `train-pose` skill says this in prose, but
nothing enforces it. Running the trainers also needs repository knowledge:
the `tch-backend` feature, a libtorch version match, and where checkpoints go.
## Decision
### Gate (`ruview_train_gate`, pure, read-only)
The input is a small report: metric, model and baseline score, split,
train/test subjects, train end and test start, `n_test`, reproducer, and data
source.
**Hard failures:**
- no baseline;
- a random-frame split, or any split outside chronological / blocked-gap /
grouped-subject / grouped-session;
- subject overlap in a grouped-subject split;
- missing or overlapping time bounds for chronological or blocked-gap splits;
- no improvement over the baseline.
**Warnings:**
- subjects not reported;
- subjects overlapping in non-subject splits;
- `n_test` < 100;
- a near-perfect score: at or above the `implausible_score` threshold in
`src/training.js`.
**Evidence tag:** `SYNTHETIC` when `data: synthetic`, `MEASURED` only with a
reproducer, otherwise `CLAIMED`.
On PASS the gate returns the one acceptable sentence:
`Held-out <metric> X% vs Y% mean-pose baseline = +Z pp (<tag>, …, <split> split).`
### Runner (`ruview_train`, `ruview_train_plan`)
Modes:
- `pose-smoke`: `wifi-densepose-train` `train --dry-run` on the synthetic
dataset.
- `pose`: `--data-dir`, MM-Fi recordings.
- `room`: `wifi-densepose train-room`, or `cargo run -p wifi-densepose-cli`
when the binary is absent.
Rules:
- All caller paths (config, data, checkpoints, enrollment, output) must
resolve inside the trusted checkout. Realpaths are checked for inputs that
already exist.
- Pose runs use `cargo run --manifest-path v2/Cargo.toml` with the working
directory set to the checkpoint directory, default
`v2/target/ruview-train/<mode>`. The trainer's relative `logs/` therefore
never lands in the source tree.
- Without `confirm` it returns a plan. MCP requires `workspace-write` plus
`confirm`.
- Failures are classified with remedies:
- libtorch absent;
- libtorch version mismatch (tch 0.24 expects torch 2.11.0);
- link failure.
## Evidence
- **SYNTHETIC** (`npx @ruvnet/ruview train --mode pose-smoke --samples 16
--confirm`, CPU PyTorch 2.11.0, `LIBTORCH_USE_PYTORCH=1`, Linux x64): the
real trainer compiled, ran 20 epochs with early stopping, and wrote
safetensors checkpoints and `logs/training_log.csv` under
`v2/target/ruview-train/pose-smoke`. The trainer labels its own smoke
metrics "DO NOT REPORT". None are reported here.
- With PyTorch 2.14.1 the tch 0.24 C++ shim failed to compile. The runner
classified this as a version mismatch rather than a missing install.
- The gate is covered by `test/training.test.mjs`, including the leakage
patterns behind the retraction.
## Consequences
- A number can pass through the harness only with its baseline and split
attached. The gate does not verify the arithmetic that produced the input;
reviewers must still re-run the reproducer.
- Training needs the Rust toolchain and a matching libtorch. `doctor` covers
Rust; libtorch is detected at run time.
@@ -0,0 +1,66 @@
# ADR-372: Structured debugging doctor
- **Status**: proposed
- **Date**: 2026-09-30
- **Deciders**: RuView maintainers; acceptance pending
- **Tags**: diagnostics, onboarding, support, mcp
- **Related**: ADR-369, ADR-370, ADR-371, docs/TROUBLESHOOTING.md
## Context
Most failed RuView setups come down to a missing driver, pyserial, esptool, a
wrong port, uninitialized submodules, a missing wasm32 target, or a sensing
server that is not running. The old `doctor` checked only the harness itself,
gave no remedies, and had no machine-readable output for agents.
## Decision
`src/doctor.js` returns
`{ok, summary{pass,warn,fail,skip}, checks[{group,id,status,detail,remedy}], nextSteps}`.
Groups:
| Group | Checks |
|---|---|
| `runtime` | Node ≥ 20 |
| `harness` | packaged-file manifest integrity, claim-guardrail self-test, reviewed brain load |
| `hosts` | `claude` / `codex` on PATH |
| `repo` | checkout, workspace submodules |
| `rust` | cargo, wasm32 target (run inside `v2/` so the pinned toolchain is used), `wifi-densepose` binary |
| `python` | interpreter, pyserial, esptool version |
| `firmware` | checksum verification of every `release_bins` bundle |
| `serial` | pyserial port enumeration; optional named port; optional chip probe |
| `sensing` | optional sensing-server `/health` |
| `kernel` | optional `@ruvnet/ruview-kernel` WASM/napi status |
Rules:
- `fail` is reserved for a requested capability that cannot work. Absent
optional tooling is `warn`. Every non-pass check carries a remedy. The CLI
exit code is non-zero only on `fail`.
- The doctor is read-only. Two exceptions exist, both CLI/SDK options that are
absent from the MCP schema:
- `--probe` runs `esptool chip_id`, which resets the board but writes
nothing.
- `--url` makes an HTTP(S) request.
- Output is human-readable by default. `--json` gives the structured report.
`--group a,b` limits the scope.
## Evidence
Development container (Linux x64, Node 22):
- Before `pip install pyserial esptool`, the doctor reported them as `warn`
with the exact pip commands.
- After installation it showed `esptool 5.4.0` and enumerated `/dev/ttyS0`.
- All four firmware bundles verified.
- It caught manifest drift during development, and a missing wasm32 target
when run outside `v2/`. That second case led to the `v2/` working-directory
rule.
## Consequences
- Support threads can ask for `npx @ruvnet/ruview doctor --json`, and agents
can act on `nextSteps`.
- Checks run real subprocesses, so a full run takes seconds; `--group` keeps
targeted runs fast.
@@ -0,0 +1,244 @@
# ADR-373: Host device access layer — ESP32, mmWave, LiDAR
- **Status**: proposed
- **Date**: 2026-09-30
- **Deciders**: RuView maintainers; acceptance pending
- **Tags**: hardware, esp32, mmwave, lidar, serial, udp, mcp, least-authority
- **Related**: ADR-018 (CSI frame), ADR-063 (mmWave fusion), ADR-320
(sensor HAL), ADR-369 (npm operator surface), ADR-370 (flashing),
ADR-374 (remote hosts), `integrations/iphone-lidar`
## Context
Operators and agents need to answer basic hardware questions, such as "is the
node streaming?", "is the radar talking?" and "does the LiDAR see the room?",
before calibration, fusion or training. Until now each modality had its own
tool:
- the ESP32 serial monitor;
- `scripts/mmwave_fusion_bridge.py`;
- the iPhone LiDAR web relay;
- the sensing server itself.
None of these was reachable through the npm harness, its MCP server or its
SDK.
The package must stay free of runtime dependencies (ADR-263). Node has no
serial API. Hardware access is also authority: an MCP client should not get
it by default.
## Decision
Add `src/devices/` to `@ruvnet/ruview` (0.7.0) with four read-oriented tools.
| Tool | Transport | What it returns |
|---|---|---|
| `ruview_devices_scan` | pyserial port list | ports with USB VID:PID, bridge chip, likely roles (esp32 / mmwave / rplidar), and the command that confirms each role |
| `ruview_esp32_capture` | `node:dgram` UDP, receive-only | per node: packet counts by kind (magics `0xC5110001`–`0xC5110007`), CSI rate, sequence loss, RSSI, antennas/subcarriers/frequency, latest device vitals |
| `ruview_mmwave_read` | serial | MR60BHA2 (60 GHz, 115200) and LD2410 (24 GHz, 256000) frames, checksum errors, presence, distance, device-reported heart and breathing rate |
| `ruview_lidar_read` | serial or WebSocket | RPLIDAR SCAN summary (points, revolutions, range, angular coverage), or iPhone relay depth statistics |
### Rules
1. **Firmware-identical parsing.**
- The MR60BHA2 and LD2410 parsers are ports of
`firmware/esp32-csi-node/main/mmwave_sensor.c`: same SOF, `~XOR` header
and data checksums, 30-byte payload cap, and F4F3F2F1…F8F7F6F5 framing.
- ESP32 packets are parsed as the little-endian structs in `csi_collector`
and `edge_processing.h`.
- Host and node therefore agree on what a valid frame is.
2. **Serial without dependencies.** A fixed Python script (the pyserial
"pump") reads the port for a bounded time and number of bytes.
- Port, baud, duration and command bytes are passed as argv and validated
first: the port regex, a fixed baud set, and command bytes of at most 64
hex bytes.
- Output is lowercase hex, so the secret-redaction layer cannot corrupt
binary data.
- DTR control is best-effort and reported when a port does not support it.
3. **Receive-only network.**
- The ESP32 capture binds a local UDP port (bind address from a fixed set,
port ≥ 1024) and never sends to a node.
- `EADDRINUSE` is reported as "sensing server already bound".
4. **Minimal actuation.** An RPLIDAR scan sends `A5 20` (start) and `A5 25`
(stop), and drives DTR low (the A1 motor). It spins the motor but writes no
configuration. Nothing else is written to any device.
5. **No raw sensor data in results.**
- iPhone depth frames are validated with the same rules as
`integrations/iphone-lidar/web/codec.mjs` and reduced to statistics.
- The relay token comes only from `RUVIEW_LIDAR_TOKEN`. It is rejected in
the URL and never echoed.
6. **Authority.**
- All four tools require the new `device-access` MCP grant.
- The CLI and SDK run them directly, as trusted local code.
- Serial port names are pattern-checked in the schema and again in the
driver. `/dev/pts/*` is deliberately excluded: it could read another
terminal's input.
7. **Evidence.**
- A successful read is tagged `MEASURED` as a *reading* on this host.
- mmWave vitals are labelled as the radar's own estimates.
- None of these outputs is an accuracy claim.
## Evidence
Development container, Linux x64, Node 22. No physical sensors were attached.
- **ESP32 UDP**: a simulated node sent 60 ADR-018 CSI frames plus 3 vitals
packets to `127.0.0.1:5005` through `ruview esp32`.
- Result: 1 node, CSI rate 15 Hz, zero sequence loss, vitals decoded
(15.2 bpm / 69 bpm, SYNTHETIC).
- Unit test on real loopback: 2 dropped sequence numbers out of 7 produce a
loss fraction of 0.2857.
- **MR60BHA2**, emulated on a pseudo-terminal and read through real pyserial:
- auto-detected `mr60bha2`;
- 200 frames, 0 checksum errors;
- presence 1.0, distance 87 cm, 14.8 / 68.5 bpm (the emulator's values,
SYNTHETIC).
- **RPLIDAR**, emulated on a pseudo-terminal that answers `A5 20`:
- 3564 points, 99 revolutions, full angular coverage;
- the missing DTR support was reported.
- **iPhone LiDAR**, the real relay (`integrations/iphone-lidar/web/relay.mjs`)
with a simulated phone:
- 30 frames at 10 fps, median depth 1.824 m (SYNTHETIC);
- a wrong token gives `connect_failed` with a remedy.
- **Not yet done**: real ESP32-S3/C6, MR60BHA2, LD2410, RPLIDAR and iPhone
captures. Each modality needs one real capture before its parser is called
hardware-validated.
## Amendment 1 (2026-10-01): e2e suite and latency
- `harness/ruview/test/e2e/devices.e2e.mjs` (`npm run test:e2e:devices`, CI
workflow `ruview-device-e2e.yml`) automates the emulated-device runs above
through the real CLI:
- MR60BHA2 and RPLIDAR on pseudo-terminals, read via real pyserial;
- an ESP32 UDP node;
- the real iPhone relay.
- The harness still refuses `/dev/pts/*` port names. The suite links each pty
to `/dev/ttyRUVIEWE2E<n>` with root or `sudo -n`.
- `RUVIEW_E2E_REQUIRE=1` turns missing prerequisites into failures.
- mmWave auto-detect keeps its probe frames (≤ 2 s per model) and reads only
the remainder of the window. A 5 s request previously cost about 9 s.
- `ruview_lidar_read` (iPhone) gains `max_frames` and returns immediately on
a refused connection.
- MEASURED wall time for the device e2e suite on the development container:
19.6 s before these changes, 5.6 s after (concurrent cases, no redundant
re-reads).
## Amendment 2 (2026-10-01): Realtek nodes, honest capture, live analysis
Driven by a hardware run of this branch against an RTL8721Dx board (PL2303GC,
COM10) and an ESP32-C6 (CP210x, COM16) on Windows 11:
- **Realtek RAC1/RHB1 (ADR-323).** `ruview_esp32_capture` decodes the 49-byte
RAC1 envelope (8- and 16-bit tones, CRC-32 verified, SYNTHETIC flag kept)
and counts RHB1 heartbeats per sender. Before this, 3,439 live RAC1 frames
in 15 s were all reported as unknown.
- **No false success.** The capture now fails with `no_decodable_packets`
or `heartbeat_only` when packets arrive but none decode. It previously
returned `ok: true` with a MEASURED label for zero decoded packets.
`heartbeatOnlySenders` names boards that are alive but deliver no CSI — the
stall observed on a Realtek board before a reset.
- **More ESP32 packet kinds.** ADR-110 sync (`0xC511A110`) and ADR-081 mesh
envelopes (`0xC5118100`, counted by message type, never a phantom node).
A node sending several CSI shapes (the C6 sends 1x256, 1x64 and 1x128)
reports each in `csiShapes` instead of only the last one.
- **Live CSI → kernel.** `--analyze` runs the busiest (or `--node-id`) node's
dominant single-antenna shape through `@ruvnet/ruview-kernel` at the
measured arrival rate, with a bounded frame buffer (`analyze_max_frames`,
default 6,000). Results are signal-processing estimates with no reference.
- **Serial monitor no longer reboots nodes.** The monitor opens the port with
DTR/RTS deasserted. MEASURED on the C6: uptime kept rising across a monitor
run (1,612 s → 1,625 s); before, opening the port reset it (uptime ~15 s).
It takes `--baud` (Realtek logs at 1,500,000) and explains the C6/S3
USB-Serial/JTAG console when the UART is silent.
- **Device scan.** Prolific `067B:23A3` (PL2303GC) is classified `realtek`.
- **Windows.** The hosts file gets an owner-only ACL (`icacls`), failing
closed; POSIX `0o600` is ignored on Windows. The symlink-escape test skips
only where Windows refuses to create symlinks.
- **Parser speed.** Int8Array view and `sqrt` instead of per-byte reads and
`Math.hypot`: MEASURED 0.42–0.52 M → 1.33–1.67 M packets/s (about 3.2x;
three interleaved runs, median of seven each, Windows 11 x64, Node 24).
Hardware evidence (MEASURED, this host): C6 node 42, 45 s, 1,160 packets,
1,157 decoded, 0 sequence loss, 541 1x256 frames at 12.1 Hz analyzed by the
napi kernel (integrity verified). The RAC1 decoder is cross-checked against
the Rust `realtek-csi-sim` encoder (200/200 frames at 8- and 16-bit tones);
a live RAC1 re-run is pending reconnection of the Realtek board.
Real flash (MEASURED, same C6, COM16): `ruview flash --variant c6 --confirm`
with the checksum-verified `release_bins/c6-adr110` bundle wrote 4 images
(hash verified by esptool, NVS kept: node 42 rejoined). The boot log showed
13 CSI callbacks, the MGMT+DATA upgrade and no panic. After boot, `monitor`
saw CSI without a reset (uptime 36 s → 62 s) and the UDP capture decoded
183/183 packets with zero loss. The run exposed one more Windows bug, now
fixed: the boot-log capture wrote decoded text to a cp1252 stdout (the
scrubbed child env) and aborted on the firmware's U+2192 log character, so
`bootLog.captured` was false and an earlier abort would have hidden the CSI
evidence. It now writes raw bytes; a regression test forces cp1252.
Live RTL8721Dx re-run (MEASURED, board 4 on COM10, node 3, alongside the C6):
`devices` classified COM10 as `realtek`; a 20 s capture decoded 7,404/7,404
packets (6,959 RAC1 frames at 348 Hz, RSSI −38.9 dBm, 200 RHB1 heartbeats);
`monitor --baud 1500000` saw 62 CSI log lines and did not reset the board
(sequence kept rising, 80,856 → 88,257). `--analyze --node-id 3` ran 12,549
live frames at a measured 313.7 Hz through the WASM kernel (integrity
verified). The board also exposed a loss-accounting bug carried over from the
original code: one stray sequence value (+52,835, then straight back) was
counted as 52,834 lost frames, giving 97% "loss". The tracker now treats a
single out-of-window value as a stray, needs two agreeing frames to accept a
counter reset, and lets a late packet fill its own gap. Against ground truth
(unique sequence numbers over the span, 15 s): 4,783/4,783 present, true loss
0, 9 strays — the tracker reported loss 0 and 9 strays.
## Amendment 3 (2026-10-01): ESPHome radar kits and CSI buffer starvation
- **ESPHome source for radar.** The Seeed MR60BHA2 kit (a XIAO ESP32-C6 with
the 60 GHz radar) runs ESPHome, which owns the radar UART. Raw frames never
reach USB, so `mmwave --model auto` correctly returned `no_valid_frames`.
`ruview_mmwave_read` now takes `source: "esphome"` with a `host`.
- **Client:** a dependency-free, read-only client for the plaintext ESPHome
native API (TCP 6053) runs hello → connect → device info → list entities
→ subscribe states for a bounded window. It never sends a command.
- **Roles:** presence, heart, breathing, distance and target count are
found by entity name. The result uses the same summary fields as the
serial path.
- **Decoding:** ESPHome's `missing_state` flag and proto3 zero defaults are
honoured, so absent readings are excluded rather than averaged as 0.
- **Address policy:** hosts must resolve to private, link-local, CGNAT
(Tailscale) or loopback addresses.
- **Unsupported cases:** encrypted (Noise) and password-protected APIs are
reported as unsupported.
- **Live kit (MEASURED, 192.168.1.102, ESPHome 2026.9.0, project
`seeedstudio.mr60bha2_kit` `spaces-1.0`).**
- The kit had been configured for an out-of-range SSID. It was moved to
the operator's network through its own captive portal, without
reflashing.
- A 15 s read returned 154 state updates:
- presence: detected, 1 target;
- mean distance: 40.0 cm;
- device-reported heart rate: mean 75.3 bpm;
- device-reported breathing rate: mean 10.7 bpm.
- These are firmware-computed values, not validated against a reference.
- **Realtek heartbeat-only stall: root cause.** Reading the stalled board's
console without a reset showed continuous `[WLAN-W] lack of csi buf!` /
`csi buf not enough`.
- The radio produces CSI reports, but the firmware does not return report
buffers, so CSI stops while heartbeats continue.
- `ruview monitor` now counts these lines and fails with
`csi_buffer_starvation`. Observed live: 399 lines in 8 s, 0 CSI.
- The lasting fix belongs in the RTL8721Dx firmware (outside this
repository).
- **Views.** Radar results render in the terminal UI and in the console
widget (`ruview_mmwave_read` is now a UI tool). Both label vitals
"device-reported, not validated".
## Consequences
- One command per modality works across CLI, MCP and SDK, with actionable
failures (`pyserial_missing`, `port_open_failed`, `no_valid_frames`,
`port_in_use`, `connect_failed`).
- Python and pyserial become the serial dependency. `doctor` reports both.
- The package's unpacked-size ceiling rises to 320 KiB (from 224 KiB) in
both npm workflows, with the published package still free of runtime
dependencies.
- Follow-up: feed these readers into `ruview-hal` observations (ADR-320)
for fusion.
@@ -0,0 +1,78 @@
# ADR-374: Remote host access over SSH (read-only)
- **Status**: proposed
- **Date**: 2026-09-30
- **Deciders**: RuView maintainers; acceptance pending
- **Tags**: remote, ssh, hardware, least-authority, mcp
- **Related**: ADR-369, ADR-373
## Context
RuView hardware usually hangs off a different machine from the one the
operator or agent works on: a Raspberry Pi next to the ESP32s, a NUC running
the sensing server, a Cognitum Seed. Cloud agent sessions cannot reach local
USB at all.
"Host access" should not mean opening a new network service with its own
credentials: that adds attack surface and secret handling. Operators already
have SSH.
## Decision
1. **Where work runs.**
- Hardware tools always execute on the machine the hardware is attached
to.
- Interactive work runs in a local Claude Code session on that machine. It
can be driven from the Claude app through Remote Control
(`claude remote-control` in the repo).
- Other machines are reached over SSH.
2. **Hosts registry.**
- Stored in `~/.config/ruview/hosts.json` (directory 0700, file 0600).
- Written only by the CLI: `ruview hosts add --name --ssh [--port]
[--version]`. MCP can list hosts but never add them.
- Names must match `^[a-z0-9][a-z0-9-]{0,31}$`. Targets must be
`[user@]hostname`: no options, spaces, shell metacharacters or leading
dash.
3. **Transport.**
- Local ssh arguments: `ssh -o BatchMode=yes -o ConnectTimeout=10 -o
StrictHostKeyChecking=yes -T [-p N] -- <target> <command>`.
- No password prompts, and no trust-on-first-use from the tool. The
operator pins the host key once interactively.
- The remote command is `npx -y @ruvnet/ruview@<exact version> call <tool>
--read-only --args-json <json>`, with every token single-quoted for the
remote POSIX shell.
4. **Read-only, twice.**
- Locally, `ruview_host_run` accepts only tools that are read-only, use no
credentials, and are not `ruview_host_*` (no host hopping). It validates
arguments against the tool schema before sending.
- Remotely, `call --read-only` repeats the check and schema validation.
- Flash, train, calibrate and Cognitum Spaces reads are never forwarded.
5. **Grants.** Over MCP, `ruview_host_run` needs `remote-host` plus the
remote tool's own grant (for example `device-access`).
6. **Failures** are classified with remedies: `host_key_unverified`,
`ssh_auth_failed`, `host_unreachable`, `remote_node_missing`,
`unknown_host`, `remote_tool_not_allowed`.
## Evidence
Tests in `harness/ruview/test/remote.test.mjs` cover:
- host-entry injection cases (`-oProxyCommand=…`, `;`, spaces, `$(…)`,
backticks);
- quoting, verified by running the quoted command through a real `sh`;
- the refusal list;
- grant composition;
- the file mode;
- `call --read-only` refusing `ruview_node_flash` through the real CLI.
No SSH server was available in the development container, so a live
round-trip to a real host is still to be done.
## Consequences
- Remote hosts get the same tools with no new daemon, port or credential
store.
- The remote needs Node 20+ and npm registry access (or a pre-installed
version), and `ssh` must be on the local PATH. `doctor --group remote`
checks the local side.
- Remote mutations stay manual by design.
@@ -0,0 +1,202 @@
# ADR-375: MCP Apps console, HTTP transport and terminal UI for `@ruvnet/ruview`
- **Status**: proposed
- **Date**: 2026-10-01
- **Deciders**: RuView maintainers; acceptance pending
- **Tags**: npm, mcp, mcp-apps, chatgpt, ui, cli, http, least-authority, performance
- **Related**: ADR-263 (harness hardening), ADR-265 (distribution and size
budget), ADR-369 (operator surface and SDK), ADR-373 (device access),
ADR-374 (remote hosts)
## Context
The harness exposes one tool registry through the CLI, an MCP stdio server and
an SDK (ADR-369). Hardware testing of the device tools (ADR-373 Amendment 2)
showed three gaps in how results reach people and models:
- **MCP results were text only.** Every tool returned pretty-printed JSON in a
text block, with no `structuredContent`. The server declared protocol
`2024-11-05`, and `resources/list` was always empty, so MCP Apps hosts and
ChatGPT had nothing to render.
- **ChatGPT could not connect.** ChatGPT connectors reach MCP servers over
HTTPS. The harness spoke stdio only.
- **The CLI printed raw JSON to humans.** A live capture of two nodes was a
1.2 kB JSON object in the terminal, with alerts such as heartbeat-only
senders easy to miss.
Reference implementations for the target hosts (the `web-based-chatgpt-mcp-starter`
and `signal-to-swarm` sites) declare a `ui://` HTML resource and point UI tools
at it through both the MCP Apps key (`_meta.ui.resourceUri`) and the ChatGPT
keys (`openai/outputTemplate`, `openai/widgetAccessible`).
The package must stay free of runtime dependencies (ADR-263) and inside a
reviewed size budget (ADR-265).
## Decision
### 1. One console widget, two host families
`src/ui/console-widget.js` holds one self-contained HTML resource,
`ui://ruview/console-v1.html`, with mimeType `text/html;profile=mcp-app`.
- **Tools.** `ruview_esp32_capture`, `ruview_devices_scan` and `ruview_doctor`
carry `_meta.ui.resourceUri`, `ui/resourceUri`, `openai/outputTemplate` and
`openai/widgetAccessible`.
- **Data intake.** The widget reads results from either host family:
- MCP Apps: a `ui/initialize` handshake over `postMessage`, then
`ui/notifications/tool-input` and `ui/notifications/tool-result`;
- ChatGPT: `window.openai.toolOutput` and the `openai:set_globals` event.
- **Refresh.** A Refresh button re-runs the same tool with the same
arguments, through `window.openai.callTool` or an MCP Apps `tools/call`
request.
- **Safety.**
- The resource declares an empty CSP allowlist and makes no network
requests.
- Every value reaches the DOM through `textContent`; there is no
`innerHTML`.
- Messages are accepted only from the parent frame.
- A test pins all of this.
- **Design.** The visual language follows the reference sites: dark ground,
`#d0ff72` accent, monospace labels, and plain wording about what each value
is. Device vitals are labelled "device-reported (not validated)". Kernel
outputs carry the "no reference measurement" note, and SYNTHETIC frames
raise a warning.
### 2. Structured results and protocol negotiation
- **Protocol negotiation.** `initialize` negotiates `2025-06-18`,
`2025-03-26` or `2024-11-05`, and advertises `resources`.
- **Result shape.** `tools/call` returns `structuredContent` (always an
object), a text block with the same JSON in compact form, and
`_meta['ruview/tool']` set to the canonical tool name (so an aliased call
still refreshes correctly).
- **Compact text.** Compact rather than pretty-printed JSON cuts each result
by 17–33% (MEASURED on this host: guidance 17.5%, device scan 20.7%, live
capture 33.4%). Clients that read only `content` still receive the
complete result.
- **Titles.** Tools gain `title` and `annotations.title`.
`handleRpc` is transport-neutral, and `createDispatcher` holds the FIFO tool
chain both transports share (ADR-263 O2).
### 3. Streamable HTTP transport
`ruview mcp start --http [--host 127.0.0.1] [--port 8790] [--allow-origin URL]`
starts `src/mcp-http.js`.
- **Authentication on every request.** A request must carry either
`Authorization: Bearer <token>`, or the secret-path form `/mcp/<token>`.
The second exists because ChatGPT connectors cannot send a static header.
- Tokens are compared in constant time and must be at least 16 characters.
- The token comes from `RUVIEW_MCP_TOKEN`, never argv. If unset, one is
generated and printed once to stderr.
- **Least authority.**
- It binds to loopback by default.
- Browser `Origin`s are refused unless allowlisted (DNS-rebinding defence).
Server-to-server clients send no Origin.
- `workspace-write` and `hardware-write` are never honoured over HTTP, even
when granted in `RUVIEW_MCP_GRANTS`. Flashing and calibration stay
stdio/CLI-only.
- **Protocol surface.**
- POST only; single JSON responses with no SSE stream and no sessions.
- Batches and unsupported `MCP-Protocol-Version` values are refused.
- Bodies are capped at 256 KiB.
- Notifications return 202.
- `/healthz` reports only the name and version.
Exposing the port to ChatGPT (a tunnel or reverse proxy with TLS) is the
operator's choice. The startup banner says so and warns on a non-loopback
bind.
### 4. Terminal UI
`src/cli-ui.js` renders results for a TTY:
- aligned tables, with unicode rate bars;
- colour that respects `NO_COLOR` and `TERM=dumb`;
- alerts and fixes ahead of the data;
- a stderr countdown for timed captures.
Pipes and `--json` keep receiving exact JSON. `ruview call` always prints
JSON, because remote hosts parse it.
`ruview esp32 --watch [--seconds 3]` redraws repeated capture windows with a
per-node rate sparkline.
`ruview monitor` now flags Ameba CSI report-buffer starvation (`lack of csi
buf` / `csi buf not enough`). This is the root cause of the Realtek
heartbeat-only stall, observed live: 399 starvation lines in 8 s, 0 CSI.
### 5. Startup
- **Launcher split.** `bin/cli.js` is a thin launcher, and `src/cli.js` loads
the tool registry, doctor, brain, host adapters and transports on demand.
- **Measured effect.**
- `--help`/`--version`/`skills` skip the registry: about 20% faster, median
of 9 interleaved runs against the previous build on this host (MEASURED).
- The MCP path needs the whole registry to list tools, so its start time is
unchanged within noise.
- **Compile cache rejected.** Node's on-disk compile cache
(`module.enableCompileCache`) was evaluated: module load was 27.7 ms with
it and 28.5 ms without (25 interleaved runs, MEASURED). That is within
noise, so it was not adopted.
### 6. Size budget
The additions cost about 38 kB:
- widget, 12.6 kB;
- HTTP transport;
- terminal UI;
- CLI split.
The `@ruvnet/ruview` unpacked budget rises from 320 KiB to 384 KiB in
`npm-packages.yml` and `ruview-npm-release.yml`. The package is about 338 kB.
## Evidence
All on Windows 11 x64, Node 24, with an ESP32-C6 (CP210x, COM16) and an
RTL8721Dx (PL2303GC, COM10) attached. All results MEASURED unless noted.
**Browser e2e.** A real MCP Apps host flow in headless Chrome:
- The host lists tools and reads the `ui://` resource over HTTP MCP, then
loads it in a sandboxed iframe (`allow-scripts` only).
- It answers `ui/initialize` and delivers a live `ruview_esp32_capture`
result.
- The widget's Refresh button triggered a second real capture through the
host (108 → 105 packets).
- The devices and doctor views rendered, in dark and light themes. The only
console error was the test host's own missing favicon.
**Security probes** against the live HTTP server:
- no token: 401;
- wrong token: 401;
- foreign Origin: 403;
- GET: 405;
- `ruview_node_flash` with `hardware-write` granted in the environment:
`authority_denied`.
**Tests.**
- `npm test`: 160 pass, 1 skip.
- `test:security`: 83 pass, 1 skip.
- New suites: `mcp-apps.test.mjs` (protocol, metadata, widget safety, HTTP
auth/origin/size/write denial) and `cli-ui.test.mjs` (renderers, colour
rules, the starvation detector run against a stand-in serial module).
**Tarball smoke** from a clean install:
- CLI and SDK work;
- stdio MCP negotiates `2025-06-18` and serves the widget and structured
results;
- HTTP MCP lists 22 tools, 3 with `ui://` metadata.
## Consequences
- MCP Apps hosts and ChatGPT render node streams, device scans and
diagnostics instead of raw JSON, and can refresh them in place.
- Remote MCP clients can use the read tools over authenticated HTTP. Write
tools remain local by construction.
- Each tool result costs fewer model tokens.
- The widget is a second place where result shapes are interpreted. Changes
to the capture, devices or doctor result shapes must update it, and its
version suffix (`console-v1`) changes when the contract does.
- Host behaviour for MCP Apps and ChatGPT keys is followed as documented by
the reference implementations. A live ChatGPT connector session (public
HTTPS URL) has not been run yet: CLAIMED, pending.
@@ -0,0 +1,142 @@
# ADR-376: `ruview` — one npm install for every RuView component
- **Status**: proposed
- **Date**: 2026-10-01
- **Deciders**: rUv (requested and approved the release); RuView maintainers
- **Tags**: npm, packaging, umbrella, mcp, homecore, kernel, release
- **Related**: ADR-263 (harness hardening), ADR-265 (distribution, D2 CI-only
publishing, D4 bin ownership), ADR-285 (Homecore metaharness), ADR-368
(kernel), ADR-369 (operator surface), ADR-373 (device access), ADR-375 (MCP
Apps console, HTTP transport, terminal UI)
## Context
RuView is positioned as an ambient intelligence platform rather than a sensor.
Its sensing is ahead of comparable packaged options; packaging is the
bottleneck. Today the capabilities are split across three packages:
| Package | Contents | Published before this ADR |
|---|---|---|
| `@ruvnet/ruview` | operator harness | 0.5.1 |
| `homecore` | Homecore developer metaharness | never |
| `@ruvnet/ruview-kernel` | WASM vitals kernel | never |
The unscoped name `ruview` was unclaimed. A user has to know all three
packages, install them separately, and run three CLIs and two MCP servers.
## Decision
1. **The umbrella.** Publish an unscoped `ruview` package
(`harness/ruview-umbrella`). It depends on exact versions of the three
component packages and adds no capability of its own:
- **CLI.** One binary, `ruview`:
- `ruview homecore …` → the Homecore CLI;
- `ruview kernel <doctor|info|selftest|parity|synth|analyze|bench>` → the
kernel CLI;
- bare `ruview kernel [--backend …]` keeps its harness meaning (the
SYNTHETIC self-test);
- `ruview capabilities` lists every component, version and tool;
- everything else goes to the `@ruvnet/ruview` CLI unchanged.
- **MCP server.** One server, `ruview mcp start [--http]`. It serves the
harness tools (`ruview_*`, including the `ui://` console) plus the
Homecore tools (`homecore_*`, already prefixed, so no collision).
- Non-Homecore traffic goes to the harness handler, so the harness
authority model, grants and HTTP rules (ADR-375) apply unchanged.
- Homecore calls go to Homecore's `runTool`, with Homecore's own
policy.
2. **Bin ownership (amends ADR-265 D4).** The `ruview` command name moves to
the umbrella. `@ruvnet/ruview`'s bin is renamed `ruview-harness`.
- **Why:** a clean-directory tarball install showed the collision. With
both packages declaring `ruview`, `node_modules/.bin/ruview` linked to
the harness CLI, so `ruview capabilities` and `ruview homecore` failed.
Which package wins that link is not guaranteed.
- **Unaffected:** `npx @ruvnet/ruview …` still works, because npx runs a
package's only bin whatever its name. The umbrella delegates every
non-umbrella verb to the harness, so `ruview <verb>` behaves as before.
- **Changed:** a global install of `@ruvnet/ruview` alone now provides
`ruview-harness`.
3. **Harness changes needed to embed it** (no new runtime dependencies):
- **New exports:** `./cli`, `./cli-ui`, `./mcp`, `./mcp-http`, `./ui`,
`./esphome` and `./kernel`.
- **Injectable MCP handler:** `run(args, { mcpHandler })` and
`startMcpServer({ handler })`, so the merged server reuses the stdio and
HTTP transports.
- **Kernel declared as an optional peer:** `@ruvnet/ruview-kernel`.
- **`setKernelImporter`:** lets the umbrella resolve the kernel from its
own dependency tree. Node resolves bare specifiers from the importing
file's real path, which misses a sibling dependency under linked or
pnpm-style installs.
- Found while testing: `kernel_not_installed` through a linked umbrella.
- Only embedding code can call it; MCP arguments cannot choose the
module.
- **Homecore** gains a `./cli` export; **the kernel** gains `./cli`.
4. **Versions for this release.**
| Package | Version | Notes |
|---|---|---|
| `@ruvnet/ruview` | 0.8.0 | 0.7.0 was never published |
| `homecore` | 0.1.0 | first publish |
| `@ruvnet/ruview-kernel` | 0.1.0 | first publish; WASM only |
| `ruview` | 0.8.0 | umbrella |
The kernel ships WASM only. The napi backend stays optional and absent,
because a workstation build would cover only win32-x64. `loadKernel({
backend: 'auto' })` reports the honest fallback.
5. **Publishing exception (one-off, amends ADR-265 D2 for this release).**
- rUv explicitly chose a local `npm publish` from the operator
workstation (ruvzen) over the CI-only release workflow.
- Packages are published in dependency order: kernel → homecore →
@ruvnet/ruview → ruview.
- Each is published only after its full gate passes locally (tests,
security tests, manifest, version-literal and pack-content/size gates),
plus a clean-directory tarball install and smoke test of the umbrella.
- These versions carry no npm provenance attestation.
- Subsequent releases return to the CI workflow. Adding `ruview` and
`@ruvnet/ruview-kernel` to `ruview-npm-release.yml` is follow-up work.
## Release outcome (2026-10-01)
**Published from ruvzen** (exact tarballs that passed the clean-directory
smoke test; sha256 in the release thread):
| Package | Version |
|---|---|
| `@ruvnet/ruview-kernel` | 0.1.0 |
| `homecore` | 0.1.0 |
| `@ruvnet/ruview` | 0.8.0 |
All three were re-installed from the registry and smoke-tested:
- `npx -y @ruvnet/ruview@0.8.0` works;
- the harness self-test resolves the published kernel;
- a live ESPHome radar read succeeded.
**`ruview` was refused by npm:** `403 Forbidden — Package name too similar to
existing package iview` (typosquat protection).
- The umbrella is ready in `harness/ruview-umbrella`. Until npm support grants
the name, it is unpublished.
- If the request is declined, the fallback is a scoped name
(`@ruvnet/ruview-platform`).
## Consequences
- `npx ruview` is the single entry point for operators and agents. One MCP
connector gives an agent, or ChatGPT, every RuView and Homecore tool.
- The component packages stay independently installable and testable.
`@ruvnet/ruview` remains free of runtime dependencies.
- The umbrella pins exact component versions, so each component release needs
an umbrella release to reach `npx ruview` users.
- The local publish trades provenance for speed for this release only. The
exception is recorded here and in the #swarm coordination thread.
## Evidence
- **Umbrella tests:** 4/4, covering CLI dispatch to all three components,
merged MCP tool listing and routing, the harness authority model through
the merged server, and stdio and HTTP transports.
- **Component gates:**
- harness: 166 tests, 89 security tests;
- Homecore: 27 tests, 23 security tests;
- kernel: 21 tests, 3 skipped native cases; 12 security tests;
- all manifests verified, 0 audit findings.
- **Clean-directory tarball smoke:** recorded in the release commit and the
#development post.
@@ -0,0 +1,101 @@
# ADR-377: `ruview-live` — a Claude Code mod shipped inside `@ruvnet/ruview`
- **Status**: proposed
- **Date**: 2026-10-01
- **Deciders**: rUv (requested integration into the npm package); RuView maintainers
- **Tags**: claude-code, mods, function-hooks, ui, npm, least-authority
- **Related**: ADR-373 (device access), ADR-375 (MCP Apps console, terminal UI),
ADR-376 (umbrella package); Claude Code mods (function hooks): the
claude-code issue 91870 and the `mods/` built-ins (`diff` is the closest
analogue)
## Context
Claude Code mods are plugins whose behaviour is a function-hooks module: one
`register(on, options)` that hooks engine events as `($, e, next)`. They can:
- register slash commands;
- open panes beside the transcript and draw them with the surface's elements;
- set a status line;
- run processes through `$.process.run`.
That fits RuView's goal of putting the live state of the sensors where an
operator or agent already works.
RuView already has:
- a read-only CLI with `--json` results (ADR-373, ADR-375);
- an MCP server and the ChatGPT/MCP Apps console;
- a plugin marketplace in this repository.
## Decision
1. **Ship a mod, `ruview-live`, inside `@ruvnet/ruview`** (`mod/`, packaged
files `mod/.claude-plugin/`, `mod/hooks/`).
- It is listed in the repository marketplace as `ruview-live@ruview`, with
source `./harness/ruview/mod`.
- One copy serves both installs: the npm package and the marketplace.
2. **Behaviour.**
- **`/ruview`** toggles a pane (`ruview-live`). While it is open, a
`$.clock.every` timer (default 15 s, minimum 5 s) runs one read-only
capture.
- **`/ruview refresh`** runs one capture and returns the summary.
- **`/ruview off`** closes the pane.
- **The pane** shows nodes (rate, loss, RSSI, shape, SYNTHETIC flag), the
optional ESPHome radar (presence, distance, device-reported vitals), and
alerts, including heartbeat-only boards with the CSI-buffer-starvation
hint. It has Refresh (`r`) and Close (`c`) buttons.
- **The status line** is set on every refresh.
3. **Data comes only from the harness CLI beside the mod.**
- The mod runs `node <plugin root>/../bin/cli.js esp32 … --json` and
`mmwave --source esphome … --json`, as fixed argv arrays, through
`$.process.run`. So the pane shows what the tested tools return.
- Mods may import nothing but their own files and `claude-code` (the
validator refuses `node:url`). The CLI is therefore located through
`$.plugin.root`.
4. **Least authority.**
- The mod never runs a write verb (flash, calibrate, train). A unit test
pins the argv.
- The radar host is validated before use.
- Options are bounded: ports 1024–65535, refresh ≥ 5 s, capture 1–10 s.
5. **Discovery.** `ruview mod` (and `ruview mod --json`) prints the installed
path, the `claude --plugin-dir` command, the marketplace commands, usage,
settings, and the early-access note.
## Evidence
All on Claude Code 2.1.286, Windows 11 x64.
- **Validation:** `claude plugin validate harness/ruview/mod` passes. It lists
hooks `session.start`, `command.run{ruview}`, `ui.render{Pane}`,
`ui.close{ruview-live}` and `session.end`, and calls `$.clock.every`,
`$.clock.now`, `$.command.register`, `$.process.run` and `$.ui.*`.
`claude plugin validate .` passes for the marketplace.
- **Engine tests:** `claude plugin test harness/ruview/mod`, with
`CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1`, passed 2/2 in three consecutive
runs. They check:
- `/ruview` opens the pane and runs the exact read-only argv;
- the status reads `RuView · 2 nodes · 1 alert`;
- the timer refreshes while open and **never after close**;
- an honest `no_packets` status.
- **Harness unit tests:** `test/mod.test.mjs`, 6/6, under plain Node:
settings bounds, host validation, argv, failure parsing, model/status, pane
drawing and button wiring, and the no-imports rule.
- **Real engine, headless (MEASURED):**
`claude --plugin-dir ./mod -p "/ruview refresh"` answered
`RuView · 0 nodes · 1 alert` (no CSI node was streaming). With `radarHost`
set to the live Seeed MR60BHA2 kit, it answered
`RuView · 0 nodes · radar clear · 1 alert`.
- **Note:** under Git Bash a leading `/ruview` is rewritten into a Windows path
unless `MSYS_NO_PATHCONV=1` is set. This is not an issue inside the Claude
Code prompt.
## Consequences
- An operator sees node and radar health beside the conversation without
asking for it. Agents see the same facts through MCP.
- **Mods are early access.** The API may change between Claude Code releases,
and hooks modules load only where function hooks are enabled. The mod is
therefore optional, and nothing else in the package depends on it.
- **Trust.** Like every mod, it runs inside Claude Code with Claude Code's
access. It is read-only by construction, and the README says to install it
only from a trusted source.
- A refresh binds UDP 5005 for the capture window. While the sensing server
holds the port, the pane reports `port_in_use` instead of reading.
@@ -0,0 +1,144 @@
# ADR-378: `ruview-live` showcase views — CSI waterfall, radar fan, animated cells
- **Status**: proposed
- **Date**: 2026-10-01
- **Deciders**: rUv ("make this a showcase … the art of the possible for mods"); RuView maintainers
- **Tags**: claude-code, mods, ui, raster, animation, csi, radar, honesty
- **Related**: ADR-377 (the `ruview-live` mod), ADR-373 (device access),
ADR-375 (terminal UI), ADR-018 / ADR-323 (CSI formats)
## Context
ADR-377 shipped `/ruview`: a pane of cards built from text and sparklines. The
mod engine can do much more on the terminal surface:
- `Raster` is a grid of cells, each with a glyph and a 24-bit foreground and
background.
- `$.ui.blit` repaints a mounted Raster in place, up to 60 frames a second,
without redrawing the tree.
- Buttons take hotkeys.
The pane only showed summaries. It never showed the signal itself: per-subcarrier
CSI amplitude over time, or the radar's range as geometry.
## Decision
### Three views, keys `1` `2` `3`
| View | What it draws | Data |
|---|---|---|
| Overview | the ADR-377 cards | unchanged |
| CSI waterfall | subcarrier bins across, time down; two frames per text row through `▀` (foreground = upper frame, background = lower); 5th–95th percentile colour limits with a colour bar | `ruview esp32 --spectrum` (new) |
| Radar | top-down 120° fan: range rings, a fading trail of past distances, the current distance as an arc; braille line charts of heart, breathing and distance | ESPHome radar kit (ADR-373) |
`n` cycles nodes in the waterfall when more than one streams. The live views
refresh every `liveRefreshSeconds` (default 4, range 2–60); the overview keeps
`refreshSeconds`.
### Harness: `--spectrum`
`captureEsp32` and the `ruview_esp32_capture` tool take three new arguments:
- `spectrum`: boolean;
- `spectrum_bins`: 8–128, default 48;
- `spectrum_frames`: 8–256, default 64.
A `SpectrumCollector` keeps a ring of the newest frames for each node and CSI
shape. It averages each frame's amplitudes into equal bins, and reports the
dominant shape per node with its arrival rate and `synthetic` flag. The
collector is read-only and bounded (8 nodes), and it adds nothing to a capture
that does not ask for it.
### Animation with `$.ui.blit`
One 80 ms timer (about 12 fps) repaints only these keyed Rasters:
- **shimmer**: a highlight sweeping along the rule under the title. Pure
decoration.
- **pulse**: a ♥ that beats at the device-reported heart rate and a gauge that
fills and empties at the device-reported breathing rate. It is labelled *a
metronome, not a waveform*.
- **fan**: a sonar ring that leaves the sensor and reaches the measured range.
- **waterfall replay**: frames arrive a capture at a time, so the view drains
them at their measured arrival rate instead of jumping every refresh. It is
labelled *replayed at the arrival rate*.
The first paint and every frame come from the same function (`picturesOf`). A
test asserts that each picture keeps its size across frames, because a blit of
another size is refused.
Blits are fire-and-forget: a blit resolves only once a frame is painted, and
the surface folds blits between frames anyway.
### Honesty rules (CLAUDE.md)
- The waterfall says **MEASURED — live CSI amplitude received on this host** or
**SYNTHETIC — simulator frames**, from the packets' own flag.
- The radar view states *range only: this kit reports distance, not bearing*.
The arc covers every bearing at that range; nothing implies a position.
- Vitals stay *device-reported, not validated*. Values outside 40–180 bpm
(heart) or 4–40 bpm (breathing) are flagged.
- Chart scales fit the data so a rhythm is visible. The plausible band is
shaded only where it overlaps the scale.
- Surfaces without `Raster` (desktop, VS Code, mobile) get a note in the
showcase views instead of a tree the surface would refuse. The overview works
everywhere.
- Only nodes in the latest capture stay in the waterfall. A node that stops
streaming, or a failed capture, clears its frames, so old frames are never
shown as live. Frames join across captures only when the CSI shape and bin
count both match.
- Multi-antenna ESP32 nodes are drawn from their first chain. ADR-018 lays the
tones out antenna-major; this applies to the waterfall only, and the vitals
kernel still takes single-chain frames.
- Animation runs on the real clock, the same one the full render reads. The
pulses keep the reported rates even when a frame fires late.
### Structure
Mods may import only their own files, so the hooks module is split into:
- `model.mjs`: settings, argv, results, model and history (pure);
- `raster.mjs`: Grid, base64, colour map, waterfall, braille, radar fan, line
chart (pure);
- `anim.mjs`: frame functions of time (pure);
- `views.mjs`: drawing;
- `register.mjs`: wiring, timers and blits. It re-exports the rest so tests
import one module.
## Evidence
- **MEASURED** (real silicon, 2026-10-01):
- Hardware: an ESP32-C6 on COM11, flashed with the hardware-verified
`release_bins/c6-adr110` image (v0.7.0; SHA-256 checked) and provisioned to
ruv.net, node 6.
- Boot log: `Got IP: 192.168.1.103` and `UDP sender initialized:
192.168.1.45:5005`.
- Captures: `ruview esp32 --spectrum` received 29–50 frames per 2 s at 7–18
Hz and −37 to −78 dBm, in 1×64 and 1×256 shapes.
- Waterfall: a 1×256 capture rendered as 114 bins showing the occupied band
against guard and null tones.
- Reproducer: `npx @ruvnet/ruview esp32 --seconds 4 --spectrum --json` with
a node targeting the host.
- **MEASURED** (device-reported): the Seeed MR60BHA2 kit at 192.168.1.102 drives
the radar view.
- **SYNTHETIC**: the `realtek-csi-sim` simulator exercises the waterfall in
development; its frames carry the synthetic flag and the view says so.
- **Tests**:
- 11 mod tests and 11 showcase tests: Grid/base64 against Node's encoder,
colour map monotone, waterfall ordering, braille bits, fan scaling and
ping, chart scaling, frame functions, constant picture sizes, MEASURED and
SYNTHETIC labels, surfaces without Raster;
- a loopback test of `--spectrum`;
- 4 engine tests (`claude plugin test mod`), including the waterfall tab
sending `--spectrum`, drawing the Raster and animating it with blits.
## Consequences
- The package grows by about 37 KB. The reviewed unpacked ceiling rises from
384 KiB to 448 KiB in `npm-packages.yml` and `ruview-npm-release.yml`. There
are still no runtime dependencies.
- The live views poll more often: a 2 s capture every 4 s by default. A
capture binds the UDP port briefly, so a sensing server on the same port
still reports `port_in_use`, as in ADR-377.
- The animation timer runs only while the pane is open, and stops on close and
session end.
@@ -0,0 +1,199 @@
# ADR-379: An OTA'd image confirms itself with a bounded health check, or rolls back
**Status:** Proposed. Hardware-verified on 2026-09-29 on an ESP32-S3 (node 4) and an ESP32-C6 (node 5).
**Date:** 2026-09-29
**Numbering:** On 2026-10-02 `main` had up to ADR-378 and open PRs claimed
nothing higher. If another branch also takes 379, renumber whichever merges
second.
## Context
Every 16 MB node was flashed with `CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y`
(`sdkconfig.defaults.16mb`). With that option the bootloader starts a freshly
OTA'd image in `ESP_OTA_IMG_PENDING_VERIFY`. The app must then call
`esp_ota_mark_app_valid_cancel_rollback()`. If it doesn't, the bootloader marks
the image `ABORTED` at the next reset and boots the previous slot.
Nothing in `firmware/esp32-csi-node/main` made that call, and `nm` on the ELF
confirmed the symbol was not linked. RUNBOOK.md said an
`ota_rollback_boot_check()` "sits in the app", but no such function existed
anywhere in the tree. The consequences:
- An image delivered by `POST /ota` ran until the next reset, then reverted
silently. OTA updates were not durable.
- While the image was pending, `esp_ota_begin()` refused a second OTA with
`ESP_ERR_OTA_ROLLBACK_INVALID_STATE`.
- Nothing checked health, so a bad image was never rolled back on purpose. It
only reverted if it happened to crash or be power-cycled.
A second defect meant none of this was ever reached: **OTA never succeeded
on this firmware before this change.** MEASURED on 2026-09-29, on node 4 (an
ESP32-S3 running the v0.8.12 16 MB build, with the OTA key provisioned):
- A `POST /ota` of a valid 1,228,672 B image logged `OTA update started`, then
`stack overflow in task httpd`, then `rst:0xc RTC_SW_CPU_RST`. The node came
back on its old slot. Both attempts failed the same way.
- The cause: the server used `HTTPD_DEFAULT_CONFIG()`, which gives a 4096 B
task stack. The handler held a 1 KB receive buffer on that stack, while
calling `esp_ota_*` and formatting a float progress log.
## Decision
At boot, `ota_health_start()` (in `main/ota_health.c`, called early in
`app_main`) reads the running partition's state:
| state | meaning here | action |
|---|---|---|
| `PENDING_VERIFY` | just OTA'd, rollback armed | run the health check |
| `VALID` | USB-flashed with `ota_data_initial.bin` (the bootloader writes VALID), or already confirmed | nothing |
| `UNDEFINED` | selected by a build without rollback; boots unconditionally | nothing |
| `NEW` | bootloader lacks rollback, so it never promoted the image | nothing |
| error (`NOT_FOUND`/`NOT_SUPPORTED`) | no otadata entry, or factory partition | nothing |
For this table I read the ESP-IDF v5.5 source: `bootloader_utility.c`
(`set_actual_ota_seq`, the NEW to PENDING_VERIFY to ABORTED transitions) and
`esp_ota_ops.c`.
**The health check.** A low-priority task polls once a second and feeds
`ota_health_step()`, which is a pure function in `ota_health.h`:
- **Signals, both latched:** the STA got an IP (`IP_EVENT_STA_GOT_IP`, via
`ota_health_note_got_ip()` in `main.c`'s handler), and the stream sender
accepted at least one CSI frame (`csi_collector_get_send_ok_count() > 0`).
The second signal covers driver callback, serialization and `sendto()`
together.
- **Minimum uptime** `CONFIG_OTA_HEALTH_MIN_UPTIME_S` (default 30 s): the image
is not confirmed before this point even with both signals present. An image
that crashes soon after starting its pipelines still reverts, because a crash
while pending is a rollback.
- **Deadline** `CONFIG_OTA_HEALTH_TIMEOUT_S` (default 120 s, measured from
boot). Both signals are required by then. The minimum uptime is clamped to
the deadline.
- **Pass:** `esp_ota_mark_app_valid_cancel_rollback()`, which logs
`marked valid after <reason> in <ms> ms`.
- **Fail:** logs `health check failed: <reason>, rolling back`, then calls
`esp_ota_mark_app_invalid_rollback_and_reboot()`. If no other bootable slot
exists, that call returns an error. The node then logs and stays up rather
than reboot-looping.
- **Terminal verdict:** once the check has confirmed or rolled back, it never
changes.
**Negative-test hook.** `CONFIG_OTA_HEALTH_FORCE_FAIL` (default n, test only)
makes the check roll back at the point where it would have confirmed. Use it to
prove rollback end to end.
**Remote visibility.** `GET /ota/status` now includes `ota_state` (`valid`,
`pending_verify`, `new`, `undefined`, `other`, or `none`). An operator can
confirm durability without a serial console.
**The httpd stack.** The OTA/WASM HTTP server now sets `config.stack_size =
CONFIG_OTA_HTTPD_STACK_SIZE`, which defaults to 8192. Three smaller changes go
with it:
- The OTA receive buffer is static rather than on the stack. Handlers run one
at a time on the single httpd task, so one buffer is enough.
- The progress log prints an integer percent instead of a float.
- `GET /wasm/list` keeps its 2 KB JSON buffer on the heap, not the stack.
`recv_wait_timeout` stays at 30 s. Each `POST /ota` and `POST /wasm/upload` now
logs `httpd stack after <handler>: N of M bytes never used`, so the size can
be re-tuned from device measurements.
Sizing: this is an estimate from static data, not a device measurement. It
comes from a `-fstack-usage` build of the S3 16 MB configuration with IDF v5.5.
- **Frame sizes along the OTA path** (bytes):
| function | bytes |
|---|---|
| `httpd_thread` | 144 |
| `httpd_sess_process` | 32 |
| `httpd_req_new` | 176 |
| `httpd_uri` | 64 |
| `ota_upload_handler` | 192 (1,216 with the old 1 KB buffer) |
| `esp_ota_end` | 48 |
| `image_validate` | 304 |
| `esp_image_verify` | 32 |
| `image_load` | 80 |
| `process_segments` | 112 |
| SHA-256 | about 110 |
| mmap/flash read | about 190 |
- That chain comes to about 1.5 KB now, and about 2.5 KB with the old buffer.
- Two things are not in the static data:
- libc's `vfprintf` behind every `ESP_LOG`. This build uses full newlib,
not nano, and the old progress line formatted a float.
- The flash-chip driver, which is reached through function pointers.
Together they plausibly add 1 to 1.5 KB. That explains an overflow at 4096
with the old handler.
- The WASM upload path adds `wasm_upload_process` (336) and
`rvf_verify_signature` (352), plus a wasm3 module load that wasn't measured.
- 8192 leaves roughly 4 to 5 KB above the estimated worst case, and costs
4 KB of internal RAM. The high-water-mark log is the check on that estimate.
When the build has no `CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE` (the 8 MB S3 and
4 MB C6 CI lanes), both entry points compile to no-ops.
## Consequences
- OTA updates become durable on rollback-enabled nodes once the image passes
the check. A second OTA is accepted after confirmation, which can come as
early as `MIN_UPTIME_S`.
- A bad image now reverts instead of limping, but "bad" only means it failed
to reach the network or deliver CSI. An image that passes and then
misbehaves later is not caught.
- If the AP is down when a node boots an OTA'd image, a good image rolls back.
That is the safe direction: push it again later.
- Mock/QEMU builds that skip Wi-Fi can never pass. They must not enable
bootloader rollback, and none do today.
- Bootloader capability is still invisible remotely (RUNBOOK §2). A node
whose bootloader lacks rollback reports `ota_state: "new"` after an OTA. That
is the first remote hint of which bootloader a board has.
## Evidence
- Host test `firmware/esp32-csi-node/test/test_ota_health.c` runs in
`make host_tests`, which CI runs. It covers pass, each timeout reason, the
terminal verdict, force-fail, the clamp, and zero soak. SYNTHETIC: it
exercises the decision function, not a device.
- ESP-IDF v5.5 S3 and C6 builds with rollback enabled link
`esp_ota_mark_app_valid_cancel_rollback` (verified with `nm`). A build is not
hardware evidence.
- The httpd overflow is MEASURED on hardware (node 4, above).
- Hardware verification, MEASURED on 2026-09-29 on node 4 (ESP32-S3,
16 MB, rollback bootloader, OTA key provisioned). The
build from this branch was first written over USB. The pushes were made from
a Pi 5 with the `sensor-ota-push` cog.
1. Positive (MEASURED, S3). An image built from this branch was pushed to `ota_1`: HTTP 200,
1,230,848 B in 22 s.
- The node logged "httpd stack after POST /ota: 4900 of 8192 bytes never
used", so the upload's peak stack use is about 3.3 KB. With the old 1 KB
on-stack buffer that is over 4 KB, which is consistent with the
overflow.
- It rebooted and logged "OTA image pending verify on ota_1".
- `/ota/status` then read `ota_state: valid`.
- After a hard reset it came back on the same build in `ota_1`, still
`valid`, so the update is durable.
2. Negative (MEASURED, S3 forced-fail rollback). A `CONFIG_OTA_HEALTH_FORCE_FAIL=y` build was pushed to `ota_0`:
HTTP 200.
- `/ota/status` showed it running as `pending_verify` for about 30 s.
- The node then rebooted and came back on the previous build in `ota_1`,
`valid`, so rollback works.
3. One push attempt failed mid-upload when a serial capture was opened on the
node's USB-Serial-JTAG port at the same moment. The node reset and was
unharmed. Don't hold the USB console open while pushing OTA to a
USB-attached node.
- C6 positive, MEASURED on 2026-09-29 on node 5 (ESP32-C6, 8 MB
flash, 4 MB layout, rollback bootloader).
- The branch build was first written over USB and the OTA key provisioned.
- A second build was pushed from the Pi 5 cog to `ota_1`: HTTP 200 in 11 s.
- `/ota/status` showed `pending_verify`, then `valid` about 28 s later.
- After a hard reset it stayed on the new build, `valid`.
- Not yet verified, so CLAIMED (no log supports these):
- the 120 s no-IP timeout path (CLAIMED);
- a true power cycle (CLAIMED; a hard reset via RTS was used instead);
- the C6 negative/rollback test (CLAIMED; run on the S3 only).
- A pass of this check means only that the node reached the network and sent
CSI. It does not validate sensing quality, calibration, or anything else the
image does.
+58
View File
@@ -0,0 +1,58 @@
# ADR 380: Loopback UDP tee for second consumers of the CSI stream
## Status
Proposed. Unit tested, and smoke tested on loopback: a datagram sent to the
server arrived byte for byte at the tee target. Not yet exercised against a
live multi node fleet.
Date: 2026-09-28
Numbering: On 2026-10-02 `main` had up to ADR 378 and open PRs claimed nothing
higher; ADR 379 is the OTA first-boot health check. If another branch also
takes 380, renumber whichever merges second.
## Context
A five node deployment (four ESP32 S3, one ESP32 C6) streams ADR 018 frames to
one sensing server. A second consumer cannot see the raw stream.
Each node sends to a single `target_ip` (`firmware/esp32-csi-node/main/stream_sender.c`).
The server's WebSocket and REST outputs carry per node amplitude only: no
phase, no I/Q, no sequence number, and no `0xC511A110` sync packets. A fusion
host that aligns CSI with camera, radar, or lidar needs all of those. Binding a
second listener to the CSI port is not an option, and an external relay in
front of the port would make every source loopback, which the ADR 296
allowlist always admits.
## Decision
`--udp-tee` (env `RUVIEW_UDP_TEE`) copies every datagram the source allowlist
admits, byte for byte, to up to four `ip:port` targets.
- Targets must be loopback, so raw CSI never leaves the host through the tee.
- A target may not use port 0 or the listener's own port, which would loop.
- Sends are non blocking. A copy that cannot be queued is dropped and counted,
so a slow consumer never stalls the receive loop.
- The copy happens after the ADR 296 allowlist check and before any parsing,
so consumers see exactly what the server admitted, in every wire format.
- The tee is off unless configured.
## Consequences
A fusion host or Cognitum cog can subscribe to the full data plane by reading
a loopback port. A consumer that today binds the CSI port itself can keep its
code and move to the tee port instead.
The tee is byte transparent, so it needs no change when the ADR 018 header or
a later wire version changes.
Each target costs one `send_to` per admitted datagram. Four targets is the
limit.
## Validation
```bash
cd v2
cargo test -p wifi-densepose-sensing-server --no-default-features --lib udp_tee
```
+272
View File
@@ -0,0 +1,272 @@
# ADR-381: Node self-survey from pairwise ranging (check mode first)
- **Status**: Proposed
- **Date**: 2026-10-04
- **Deciders**: RuView maintainers; acceptance pending
- **Tags**: node-positions, ranging, ftm, uwb, multistatic, least-authority
- **Related**: ADR-018 (ESP32 wire format), ADR-110 (ESP-NOW time sync),
ADR-141 (privacy control plane), ADR-144 (UWB range-constraint fusion),
ADR-281 (BLE Channel Sounding ranging evidence), ADR-308 (placement
optimizer), rufield ADR-261 (BLE evidence and Channel Sounding adapter
boundary, `vendor/rufield/docs/ADR-261-ble-evidence-and-channel-sounding.md`)
- **Issues and PRs**: #1804, #1866, #2109 (show which nodes use the default
position), #2133 (give the governed fuser the configured node positions)
## Context
Node positions reach the sensing server only through `--node-positions`. The
operator types it by hand, keyed by node id (`id:x,y,z;...`) or by list index
when the prefix is omitted. Three kinds of mistake pass silently today:
- **Wrong ids.** A config keyed by ids that do not match the live nodes looks
applied and does nothing (#1804, #1866). #2109 makes the fallback to the
default position visible, and #2133 makes sure both fusers receive the
map. Neither can tell whether a correctly-keyed position is *right*.
- **Wrong units.** Positions measured in feet and typed as metres are 3.28×
too large. Nothing checks the scale.
- **Stale positions.** A node moved after setup keeps its old coordinates.
Each one degrades multistatic fusion without an error. The repository has no
node-to-node ranging and no anchor-free solver. ADR-144 models
anchor-to-*tag* UWB ranges for people tracking
(`wifi-densepose-mat/src/localization/range_constraint.rs`), not ranges
between the fixed nodes themselves. ADR-281 computes BLE Channel Sounding
ranging evidence, but there is no CS-capable radio in the fleet: rufield
ADR-261 notes that the ESP32-S3 does not implement CS.
The ESP32-S3 and C6 nodes do support Wi-Fi Fine Timing Measurement (802.11mc
RTT, station initiator ↔ SoftAP responder) in mainline ESP-IDF. So a ranging
source exists on hardware we already have. The question is how good it is.
### What FTM measured
Bench, 2026-10-04 (UTC): two ESP32-S3 boards (rev v0.2), ESP-IDF v5.5
`examples/wifi/ftm`, SoftAP responder on channel 1 at 20 MHz, 32 FTM frames
per session, line of sight, tape ground truth. All rows MEASURED; the
reproducer is in Appendix A.
| Tape (m) | Result |
|---|---|
| 0.00 | median 0.00 m |
| 0.76 | median 0.00 m. Readings clamp to 0 below about 0.8 m. The first three sessions after a responder start read 1.8 to 3.0 m |
| 1.14 | five responder starts: medians 0.60, 1.50, 2.25 and 3.08 m, plus a 7.7 m run at −68 dBm with a 1.92 m session spread (placement outlier) |
| 3.71 | median 7.80 m (one start) |
- **Precision within one responder start:** ±0.2 to 0.4 m (standard
deviation 0.19 to 0.31 m). A 40-session run over about two minutes showed
no drift beyond that.
- **Bias between responder starts:** −0.5 to +1.9 m at 1.14 m, with the
boards untouched. A plain-versus-auto-start responder A/B test showed the
responder build is not the cause.
- **Quantisation:** every distance is a multiple of 0.15 m (1 ns of RTT).
- **Not tested:** 40 MHz, other channels, `ftm -R -o` offset calibration, a
second pair, non-line-of-sight, CSI coexistence, raw t1..t4 timestamps.
FTM is precise inside one start and inaccurate across starts. It can catch
mistakes worth metres. It cannot place a node to decimetres. UWB (Qorvo
DW3000 family) is the expected accuracy path: about 0.1 m line of sight is
CLAIMED by the vendor, and we have no measurement of our own yet.
## Decision
### 1. A source-agnostic `RangeObservation` record
```text
RangeObservation {
initiator: u8, // node_id, as in --node-positions
responder: u8,
range_m: f64, // session median
sigma_m: f64, // session spread, 0 if unknown
method: Ftm | Uwb | BleCs | Manual,
samples: u16, // valid frames / exchanges
rssi_dbm: Option<i16>,
bandwidth_mhz: Option<u16>,
at_us: u64, // host receive time
responder_boot: Option<u32>, // boot counter or nonce of the responder
calibrated: bool, // set only by the host calibration step
}
```
`responder_boot` is required for calibration. The FTM bias moved with every
responder start, so an offset is only valid within the boot it was measured
in.
On the wire, nodes report ranges either in a new ADR-018-style packet with
its own magic, or as an extension of the existing sync packet. This ADR does
not choose between them. The host-side record and solver come first. The
wire format follows the first firmware that emits ranges.
### 2. Host-side check mode (warn-only)
Given the configured positions and a batch of observations, check mode
reports:
- **Per-link residuals.** `measured − ‖p_a − p_b‖`, with a tolerance of
`floor + k·σ`. σ is the larger of the median session sigma and the spread
across sessions. Each link is `ok`, `outlier`, `too_short` (configured
length below the source's minimum), `weak` (every session below the RSSI
gate), or `noisy` (σ above the source's maximum).
- **Unit mismatch.** A least-squares scale `s` in `measured ≈ s·expected`.
If `s` sits within a band of 0.3048 (feet), 0.0254 (inches), 0.01 (cm) or
0.001 (mm), and rescaling removes outliers, the check reports the unit.
- **Id swap.** For up to 8 ranged nodes, it scores every assignment of the
configured positions to ids. The cost is a truncated quadratic in residual
over tolerance, so one bad link cannot dominate. If another assignment
cuts the cost to at most half and the configured cost is material, the
check suggests that assignment, reported as `(node, position_of)` pairs.
- **Moved node.** If most of a node's links are outliers (at least 2, and a
strict majority), the node was probably moved. Outlier links left over are
reported one at a time as possible bias, a body or wall in the path, or a
bad session, and not as a configuration error.
- **Unknown and unranged nodes.** Observations naming unconfigured ids, and
configured nodes that no scored link reaches.
Distances do not change under rotation, reflection or translation, so check
mode compares distances and never coordinates. Where the layout is
symmetric, it says so instead of guessing:
- A relabelling that is a symmetry of the layout is **invisible**. For
example, ids rotated 180° in a rectangle of corner nodes. Check mode
reports such a layout as consistent.
- When several assignments fit equally well, check mode prefers the one that
changes the fewest ids, since a typo moves few. It reports how many
assignments tied. If the fewest-changes assignments are themselves tied,
as with a swap along one wall of a rectangle (its mirror image is the
opposite wall's swap), it reports `id_swap_ambiguous` and names nothing.
Corner-mounted nodes in a rectangular room are the common case and are
nearly symmetric. Operators should expect ambiguity reports there, and a
fifth node off the axes removes most of them.
**Authority.** Check mode never writes positions, never edits config, and
never changes fusion. Its output is a report for the startup log and a
read-only status field. The report shape is JSON-serialisable; the field
name follows #2109's `node_positions` block once that lands.
**Tolerances come from measured data:**
| Source | `floor_m` | `calibrated_floor_m` | `max_sigma_m` | `min_expected_m` | Basis |
|---|---|---|---|---|---|
| FTM, ESP32-S3, 20 MHz | 2.0 | 0.6 | 1.0 | 1.0 | MEASURED, bench above: bias −0.5 to +1.9 m between starts; spread ≤ 0.31 m within a start (the 1.92 m outlier excepted); clamp below about 0.8 m |
| UWB | 0.3 | 0.3 | 0.3 | 0.0 | ESTIMATE until our own UWB bench. Vendor accuracy is CLAIMED |
With `floor_m = 2.0`, the one 3.71 m start (+4.1 m) would be flagged as a
single-link outlier. That is uncalibrated FTM as it stands. Single-link
outliers stay low-severity on purpose, and the strong findings (unit, swap,
moved node) need several links to agree.
### 3. Per-boot calibration
An offset is recorded per `(initiator, responder, responder_boot)` from
sessions at a known reference distance of at least 1.0 m:
`offset = median(range) − reference`. It applies only to observations with
the same key. After a responder reboot, observations pass through
uncalibrated and get the wider floor.
Zero-separation calibration (ESP-IDF's `cm0` / `ftm -R -o`) is not used,
because short ranges clamp to 0 on this hardware. In practice, the reference
is one tape-measured link per responder per boot, or a UWB link once UWB
exists. That the bias is stable over a whole boot, and not only for two
minutes, is untested (ESTIMATE). Measuring it is the first FTM follow-up.
### 4. Fill mode, gated off
Fill mode (solve positions with classical-MDS initialisation, weighted
robust SMACOF and gauge fixing, then write a file the operator passes to
`--node-positions`) is **not implemented** and stays disabled for a source
until all of these hold:
- the source has **MEASURED** link-error evidence with a reproducer;
- the evidence covers at least 6 ground-truth links;
- the p90 link error is **≤ 0.3 m**.
FTM's measured errors are metres, so it fails this gate. UWB is expected to
pass it, but that is unproven. Even when the gate opens, fill mode writes a
proposal file. The server never rewrites its own positions.
### 5. Pluggable sources
Every source produces `RangeObservation`, and check mode does not care which
one did. Only the tolerance profile changes.
| Source | Status | How it plugs in |
|---|---|---|
| **FTM** (ESP32-S3/C6) | Now, for check mode | APSTA node runs a short scheduled survey window. One node is the SoftAP responder and the others initiate a directed session. `rtt_ns`, RSSI and session spread map to `range_m`, `rssi_dbm`, `sigma_m`. Airtime cost on the CSI channel is unmeasured |
| **UWB** (DW3000 family, e.g. DWM3001CDK over USB) | After its own bench | Two-way ranging between nodes. Shares hardware with ADR-144's anchor-tag model, but node↔node ranges feed this ADR and anchor↔tag ranges keep feeding ADR-144 |
| **BLE CS** | When a CS-capable radio exists | Through the rufield ADR-261 adapter boundary, using ADR-281's cross-validated phase/RTT ranging. A `Divergent` anomaly drops the observation. Single-mechanism evidence gets the wider floor |
| **Manual** | Now | A taped distance entered by the operator. Mainly useful as the per-boot calibration reference |
### 6. Prototype
`v2/crates/ruview-survey` implements sections 1 to 4 on the host, with no
I/O, no clock and no randomness, and bounded inputs: 64 nodes, 10 000
observations, 100 m range, and the swap search capped at 8 nodes.
Integration with the sensing server is a follow-up and depends on #2109 for
where the report surfaces. The crate's tests are SYNTHETIC: generated layouts
with FTM-like bias and noise from the envelope above. They cover consistent
configs, feet and centimetre mistakes, a two-id swap, a three-id rotation,
symmetric invisibility and ambiguity, a moved node, a bench-style single-link
outlier, a noisy link, per-boot calibration, and the fill gate.
`examples/check_ranges.rs` runs check mode on a JSONL file.
## Consequences
- An operator gets a warning when ranges and `--node-positions` disagree
grossly, with no new authority: check mode is read-only.
- FTM on nodes needs APSTA and a SoftAP beacon on the sensing channel. Its
cost to CSI yield must be measured before the firmware side ships.
- The FTM tolerances rest on one evening with one pair. They are parameters,
not constants, and must be revised from more data (40 MHz, more pairs,
non-line-of-sight).
- Ranges between fixed nodes are not person data. Ranges to phones or tags
are, and remain under ADR-141 and ADR-144.
- A symmetric layout can hide a relabelling entirely. Check mode documents
this and does not claim to catch it.
## Evidence rule
No self-survey accuracy claim without a MEASURED reproducer: tape or laser
ground truth, at least 4 nodes, line of sight plus at least one
non-line-of-sight case, every number tagged. The prototype's tests are
SYNTHETIC and show logic, not radio performance.
## Alternatives considered
- **Treat FTM as accurate enough to fill positions.** Rejected: the measured
bias between starts (−0.5 to +1.9 m at 1.14 m) and the +4.1 m at 3.71 m
rule it out.
- **Drop FTM.** Rejected for now. It costs nothing on existing hardware, it
catches metre-scale mistakes, and it may suit coarse gating, calibrated
pairs within a boot, and time sync. It is parked and revisited after UWB.
- **Zero-separation `cm0` calibration.** Rejected: readings clamp to 0 below
about 0.8 m.
- **Server auto-corrects positions.** Rejected under least authority. Fill
mode, when enabled, writes a proposal file only.
- **LoRa SX1280 ranging, router (MT7981) FTM, raw I/Q phase ranging,
CSI-amplitude ranging.** Rejected or deferred: wrong distance regime, no
driver support, unproven, or too inaccurate respectively.
## Open questions
- Does one calibration offset hold for a whole responder boot? For both
roles? For other initiators against the same responder?
- Is the post-start transient (1.8 to 3.0 m in the first three sessions at
0.76 m) real, and how long does it last?
- Does 40 MHz shrink the bias between starts?
- What does an FTM survey window cost in CSI frames on the same channel?
- Can FTM t1..t4 timestamps be tied to the CSI timestamp clock for sync?
- The wire format: new magic, or an extension of the sync packet?
## Appendix A: FTM bench reproducer
1. Flash ESP-IDF v5.5 `examples/wifi/ftm` on two ESP32-S3 boards
(`CONFIG_ESP_WIFI_FTM_ENABLE=y`, the example's default).
2. Responder console: `ap <ssid> <password>` (channel 1, 20 MHz defaults).
Use throwaway credentials and do not commit them.
3. Initiator console, at a taped separation: `ftm -I -s <ssid>`, 32 frames,
10 to 40 sessions per distance. Record `Avg raw RTT`, `Avg RSSI`,
`Estimated Distance` and the valid-frame count for each session.
4. Reset the responder and repeat at the same distance to see the bias move
between starts.
5. Report the median, spread and error per run, tagged MEASURED, with the
raw per-session data alongside.
@@ -0,0 +1,137 @@
# ADR-382: `spatial.evidence.v1` export of RF Gaussians and link observations
- **Status**: proposed
- **Date**: 2026-10-03
- **Deciders**: RuView maintainers
- **Tags**: rf, gaussian, spatial, evidence, wire-format, interop
- **Related**: ADR-273 (unified RF world model), ADR-275 (RF-aware Gaussian
memory), ADR-142 (temporal voxel evidence). Origin of the wire format:
WeftOS ADR-107 §7 ("Spatial evidence engine and workspace"), amended
2026-10-03.
- **Crate**: `v2/crates/ruview-spatial-evidence`
Numbering: on 2026-10-03 `main` had up to ADR-378, open PRs claimed 379
and 380, and 381 was taken by another branch.
## Context
`ruview-unified` keeps a map of RF Gaussians (ADR-275) and a Friis channel
model that turns a TX/RX link into an expected amplitude. A spatial evidence
engine fuses many sensor types (tape measurements, radar, ToF, UWB, RF) into
an occupancy octree and promotes stable geometry (walls, furniture) into room
models. That engine reads a versioned JSONL format, `spatial.evidence.v1`,
specified in WeftOS ADR-107 §7.
RuView should be able to feed it without depending on it, and without a
reader of this repository having to go to another project to learn what the
export writes. This ADR records the part of the contract RuView emits.
## Decision
1. A leaf crate, `ruview-spatial-evidence`, converts `ruview-unified` output
to `spatial.evidence.v1` lines. Its only internal dependency is
`ruview-unified`. The wire shapes are local serde structs; the contract is
the JSON, not a shared crate.
2. It emits two record types, `rf_gaussian` and `rf_link_observation`. It
does not mirror the other v1 types and rejects them as unknown when
parsing.
3. Every record is validated against the v1 rules below before it is
serialised, so a bad value fails in RuView rather than in the consumer.
4. If WeftOS ADR-107 §7 changes the envelope or either RF body, this ADR is
amended in the same change that updates the crate.
### Envelope (every record)
| Field | Rule |
|---|---|
| `schema` | exactly `spatial.evidence.v1` |
| `type` | `rf_gaussian` or `rf_link_observation` here |
| `t_ns` | u64, ns since the Unix epoch |
| `frame` | `room_enu` |
| `region` | region id, e.g. `region/urth/meso/<room>` |
| `source_id` | device or operator id, never a person |
| `uncertainty_m` | 1σ position uncertainty, metres, in (0, 100] |
| `provenance` | `{receipt, producer, proof}`; `proof` is `MEASURED`, `CODE` or `SYNTHETIC` |
Ids (`region`, `source_id`, `receipt`, `producer`) match
`[A-Za-z0-9._:/-@]{1,128}`. A line is at most 16 KiB.
**Frame.** `room_enu` is room-local, origin at the room's SW floor corner,
x east, y north, z up, metres. Coordinates are within ±1000 m.
**Angles.** Every yaw in v1 is degrees counter-clockwise from room +x
(east): 0 faces +x, 90 faces +y. The two RF types have no yaw field. The
`rf_gaussian` orientation is a body-to-room quaternion, and a positive
rotation about +z turns the Gaussian's local x axis counter-clockwise from
east, the same sense.
### `rf_gaussian`
| Field | Rule |
|---|---|
| `position` | [x, y, z] m |
| `scale` | per-axis σ, m, each in [1e-6, 1e4] |
| `orientation` | quaternion `[w, x, y, z]`, finite, norm > 1e-6 |
| `occupancy` | peak extinction, nepers/m, in [0, 1e6] |
| `confidence` | [0, 1] |
| `motion` | `static`, `slow` or `fast` |
| `role`? | `absorber` or `reflector`; set only on priors sent back to RuView |
The exporter maps `RfGaussian` field for field. `uncertainty_m` is the
geometric mean of the three σ, clamped to the envelope range, because
`RfGaussian` has no separate position covariance. The receipt is
`gauss:<device>:<t_ns>:<index>`. A Gaussian whose provenance is `synthetic`
is always tagged `SYNTHETIC`; a caller can lower the proof tag, never raise
it.
### `rf_link_observation`
| Field | Rule |
|---|---|
| `tx`, `rx` | antenna positions, m |
| `freq_hz` | [1e8, 3e11] |
| `excess_loss_db` | `20·log10(free / measured)`, in [−60, 200]; negative is constructive multipath |
The free-space reference comes from `ruview-unified`'s channel model with an
empty map, so it is the same Friis amplitude the ADR-275 inverse update uses.
### Examples
These two lines are the WeftOS ADR-107 §7 examples. The crate's tests parse
them and check that its own output has the same shape.
```jsonl
{"schema":"spatial.evidence.v1","type":"rf_link_observation","t_ns":1759500001300000000,"frame":"room_enu","region":"region/urth/meso/test-room","source_id":"node-2","uncertainty_m":1.0,"provenance":{"receipt":"csi:node1-node2:000881","producer":"ruview-adapter@0.1","proof":"MEASURED"},"tx":[6.20,3.50,1.10],"rx":[0.20,0.20,1.10],"freq_hz":2437000000.0,"excess_loss_db":4.2}
{"schema":"spatial.evidence.v1","type":"rf_gaussian","t_ns":1759500002000000000,"frame":"room_enu","region":"region/urth/meso/test-room","source_id":"ruview-unified","uncertainty_m":0.5,"provenance":{"receipt":"gauss:7f3a","producer":"ruview-unified@0.3","proof":"CODE"},"position":[3.2,0.0,1.2],"scale":[0.05,1.6,1.2],"orientation":[1,0,0,0],"occupancy":0.7,"confidence":0.8,"motion":"static","role":"absorber"}
```
### Versioning
Additive optional fields may appear inside v1 and older readers ignore them.
New record types may also be added inside v1 (the 2026-10-03 amendment added
`tof_depth` and `radar_range`, and an optional `sensor` block on
`radar_track_point`); a reader that does not know a type rejects it as
unknown rather than misreading it. Removing, renaming or changing the
meaning of a field requires `spatial.evidence.v2`, and the parser reports a
v2 line as a version mismatch.
## Consequences
- RuView output can feed a spatial evidence engine with no new runtime
dependency, and the contract is readable from this repository.
- The engine can send promoted walls and objects back as `rf_gaussian`
priors with a `role`. That gives ADR-275's inverse update a geometric
starting point instead of an empty map.
- The struct definitions are duplicated between the two projects. The
golden-line tests catch drift in the RF types; a change on the WeftOS side
still needs someone to amend this ADR and the crate.
## Limitations
- Nothing calls the crate yet. The sensing server does not export records,
and there is no adapter that turns an inbound `rf_gaussian` prior into an
`RfGaussian` in the live map.
- The tests are CODE-level (deterministic conversions and validation). No
MEASURED claim is made about what the exported evidence does to a room
model.
- `uncertainty_m` for a Gaussian is a stand-in derived from its extent.
+16
View File
@@ -2,6 +2,20 @@
Latest proposed decisions:
- [ADR-379: An OTA'd image confirms itself with a bounded health check, or rolls back](ADR-379-ota-first-boot-health-check.md)
- [ADR-380: Loopback UDP tee for second consumers of the CSI stream](ADR-380-loopback-udp-tee.md)
- [ADR-382: `spatial.evidence.v1` export of RF Gaussians and link observations](ADR-382-spatial-evidence-rf-export.md)
- [ADR-378: `ruview-live` showcase views — CSI waterfall, radar fan, animated cells](ADR-378-ruview-live-showcase-views.md)
- [ADR-377: `ruview-live` — a Claude Code mod shipped inside `@ruvnet/ruview`](ADR-377-ruview-live-claude-code-mod.md)
- [ADR-376: `ruview` — one npm install for every RuView component](ADR-376-ruview-umbrella-npm-package.md)
- [ADR-375: MCP Apps console, HTTP transport and terminal UI for `@ruvnet/ruview`](ADR-375-ruview-mcp-apps-console-http-transport-and-terminal-ui.md)
- [ADR-374: Remote host access over SSH (read-only)](ADR-374-remote-host-access-over-ssh.md)
- [ADR-373: Host device access layer — ESP32, mmWave, LiDAR](ADR-373-host-device-access-layer.md)
- [ADR-372: Structured debugging doctor](ADR-372-structured-debugging-doctor.md)
- [ADR-371: Harness training runner and mean-pose evidence gate](ADR-371-harness-training-runner-and-evidence-gate.md)
- [ADR-370: Cross-platform, verified ESP32 firmware flashing from npm](ADR-370-cross-platform-firmware-flashing.md)
- [ADR-369: npm RuView operator surface — CLI, MCP, and SDK](ADR-369-ruview-npm-operator-surface-and-sdk.md)
- [ADR-368: RuView compute kernel as a WASM + napi-rs npm package](ADR-368-ruview-kernel-wasm-napi-npm.md)
- [ADR-367: Bounded research swarm and compute ownership](ADR-367-bounded-research-swarm-and-compute-ownership.md)
- [ADR-366: CSI controls and frozen evaluation](ADR-366-csi-controls-and-frozen-evaluation.md)
- [ADR-365: Public BFI dataset and decoder contract](ADR-365-public-bfi-dataset-and-decoder-contract.md)
@@ -63,6 +77,7 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
| [ADR-040](ADR-040-wasm-programmable-sensing.md) | WASM Programmable Sensing (Tier 3) | Accepted |
| [ADR-041](ADR-041-wasm-module-collection.md) | WASM Module Collection (65 edge modules) | Accepted (hardware-validated) |
| [ADR-044](ADR-044-provisioning-tool-enhancements.md) | Provisioning Tool Enhancements | Proposed |
| [ADR-379](ADR-379-ota-first-boot-health-check.md) | OTA'd image confirms itself with a bounded health check (IP + CSI frame) or rolls back; OTA httpd stack sized from device data | Proposed (host-tested; hardware-verified on one S3 and one C6) |
| [ADR-110](ADR-110-esp32-c6-firmware-extension.md) | ESP32-C6 firmware extension — Wi-Fi 6 / 802.15.4 / TWT / LP-core | Accepted, P1-P10 complete, firmware-side substrate closed at **[v0.7.0-esp32](https://github.com/ruvnet/RuView/releases/tag/v0.7.0-esp32)**. Companion docs: [`WITNESS-LOG-110`](../WITNESS-LOG-110.md) (13 §A0.x entries · 99.56 % cross-board RX · **104.1 µs smoothed sync stdev** · ≤100 µs target met), [`ADR-110-REVIEW-GUIDE`](../ADR-110-REVIEW-GUIDE.md) (one-page reviewer tour), [`ADR-110-BRANCH-STATE`](../ADR-110-BRANCH-STATE.md) (coordination map vs `feat/adr-115-ha-mqtt-matter`). Host decoders + tests: Python `SyncPacketParser` (10) + Rust `wifi_densepose_hardware::SyncPacket` (15), cross-language hex pin gates drift. |
### Signal processing and sensing
@@ -170,6 +185,7 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
| [ADR-294](ADR-294-wifi-veil-integration.md) | WiFi Veil integration — emission-shaping countermeasure as advisory BFLD dependency | Accepted (initial implementation) |
| [ADR-295](ADR-295-source-provenance-state-machine.md) | Source provenance state machine — synthetic can never present as live | Accepted (initial implementation) |
| [ADR-296](ADR-296-sensor-data-plane-bind-hardening.md) | Sensor data-plane hardening — UDP bind control and source allowlist (step one) | Accepted (initial implementation) |
| [ADR-380](ADR-380-loopback-udp-tee.md) | Loopback UDP tee for second consumers of the CSI stream (`--udp-tee`) | Proposed |
| [ADR-297](ADR-297-multi-node-semantic-correctness.md) | Multi-node semantic correctness — per-node inference, node-keyed rate limiting, stale state | Accepted (initial implementation) |
| [ADR-298](ADR-298-model-release-sanity-gates.md) | Model release sanity gates — block degenerate and mislabeled model artifacts | Accepted (initial implementation) |
| [ADR-299](ADR-299-csi-data-incident-repo-controls.md) | Repository CSI data-incident controls — ignore rules and pre-commit/CI policy check | Accepted (controls implemented; tree remediation gated) |
+86 -77
View File
@@ -33,59 +33,82 @@ cargo run --release -p wifi-densepose-sensing-server \
--mqtt-username homeassistant
```
Within ~5 seconds of starting, Home Assistant should auto-create:
Within ~5 seconds of the first CSI frame, Home Assistant should auto-create:
- One **device** per RuView node (named after the MAC or the `friendly_name` from your zones config)
- 17+ **entities** per device (presence, person count, heart rate, breathing rate, motion, fall events, signal strength, zones, and the 10 semantic primitives)
- One **device** per RuView node, named `RuView node <client-id>-node<N>` (see [Device identity](#device-identity))
- **15 entities** per device (13 with `--privacy-mode`): the raw signals and six semantic primitives listed in the [Entity reference](#entity-reference)
If nothing appears in HA's Settings → Devices, see [Troubleshooting](#troubleshooting).
### 3. Stop the publisher cleanly
Ctrl-C — the publisher pushes `offline` to every availability topic before disconnect so HA marks all entities unavailable instantly. A `kill -9` triggers MQTT LWT, which has the same effect within ~30 s.
Ctrl-C (SIGINT/SIGTERM) stops the server. It does **not** publish `offline` first, and no MQTT last-will is registered, so every availability topic keeps its last retained value until the server starts again (MEASURED: no availability message in the last 6 s of a SIGTERM'd run, see [ADR-115 §11](../adr/ADR-115-home-assistant-integration.md#11-status-note-p45-semantic-states-on-the-live-publisher-2026-10-02)). While the server runs, a node that stops sending frames goes `offline` at the next 30 s availability heartbeat.
---
## Entity reference
RuView publishes three classes of entity. Names below are the `unique_id` slugs — Home Assistant assigns friendly names automatically.
Names below are the `unique_id` slugs; Home Assistant assigns friendly names automatically. Every entity has its own availability topic (`…/<slug>/availability`, retained). An entity is `online` only while the server has a real source for it. Otherwise it is `offline`, no state is published, and Home Assistant shows **unavailable** rather than a made-up value.
### Raw signals (11 entities)
### Raw signals (9 entities)
| HA entity | Slug | HA component | Unit | Source field |
| HA entity | Slug | HA component | Unit | Source | Available when |
|---|---|---|---|---|---|
| Presence | `presence` | `binary_sensor` | — | `nodes[].node_inference.classification != "absent"` | always |
| Person count | `person_count` | `sensor` | persons | `persons[]` length, else `estimated_persons` (room-level, same on every node) | always |
| Motion level | `motion_level` | `sensor` | % | node classification: `present_moving` = 100, `present_still`/`absent` = 0 | always |
| Presence score | `presence_score` | `sensor` | % | node classification confidence × 100 while present, else 0 | always |
| Motion energy | `motion_energy` | `sensor` | (dimensionless) | `node_features[].features.motion_band_power`, else the room `features.motion_band_power` | a band-power value was seen in the last 30 s |
| Signal strength | `rssi` | `sensor` | dBm | `nodes[].rssi_dbm` | an RSSI was seen in the last 30 s |
| Heart rate | `heart_rate` | `sensor` | bpm | `vital_signs.heart_rate_bpm` (room-level) | a published estimate in the last 30 s |
| Breathing rate | `breathing_rate` | `sensor` | bpm | `vital_signs.breathing_rate_bpm` (room-level) | a published estimate in the last 30 s |
| Fall detected | `fall` | `event` | — | rising edge of the node's `edge_vitals.fall_detected` | the node sent `edge_vitals` in the last 30 s |
The server publishes heart and breathing rate only after explicit calibration, with exactly one occupant and a passing quality gate. Until then both entities stay unavailable. Heart rate and breathing rate are **biometric**: `--privacy-mode` removes them from discovery and state (see [Privacy](#privacy)).
### Semantic automation primitives (6 entities)
Small finite-state machines run inside the server, one set per node, sampled once per second ([ADR-115 §3.12](../adr/ADR-115-home-assistant-integration.md#312-semantic-automation-primitives-ha-mind), P4.5). All of them are unavailable for the first 60 s after start (warmup). After that each publishes its current state, then on every change.
| HA entity | Slug | HA component | What it fires on | Available when |
|---|---|---|---|---|
| Presence | `presence` | `binary_sensor` | — | `edge_vitals.presence` |
| Person count | `person_count` | `sensor` | persons | `edge_vitals.n_persons` |
| Heart rate | `heart_rate` | `sensor` | bpm | `edge_vitals.heartrate_bpm` |
| Breathing rate | `breathing_rate` | `sensor` | bpm | `edge_vitals.breathing_rate_bpm` |
| Motion level | `motion_level` | `sensor` | % | `edge_vitals.motion` × 100 |
| Motion energy | `motion_energy` | `sensor` | (dimensionless) | `edge_vitals.motion_energy` |
| Fall detected | `fall` | `event` | — | `edge_vitals.fall_detected` |
| Presence score | `presence_score` | `sensor` | % | `edge_vitals.presence_score` × 100 |
| Signal strength | `rssi` | `sensor` | dBm | `edge_vitals.rssi` |
| Zone occupancy | `zone_occupancy` | `binary_sensor` | — | `sensing_update.zones` |
| Pose keypoints | `pose` | `sensor` (attrs) | — | `pose_data.keypoints` (opt-in via `--mqtt-publish-pose`) |
| Room active | `room_active` | `binary_sensor` | presence + motion >10% within a 30 s window; off after 10 min idle | after warmup |
| No movement (safety) | `no_movement` | `binary_sensor` | presence + motion <1% for 30 min | after warmup |
| Elderly inactivity anomaly | `elderly_inactivity_anomaly` | `binary_sensor` | stillness > 2× the longest stillness seen since start (floor 30 min) | after warmup |
| Someone sleeping | `someone_sleeping` | `binary_sensor` | presence + motion <5% + BR ∈ [8,20] bpm for 5 min | after warmup, while breathing rate is available |
| Possible distress | `possible_distress` | `binary_sensor` | HR ≥ 1.5× resting baseline + motion >20% + no fall, for 60 s | after warmup, while heart rate is available |
| Fall risk elevated | `fall_risk_elevated` | `sensor` | 0–100 score: 10 per fall in 24 h + 50 × motion variance over 60 s | after warmup, while the fall detector is available |
Heart rate, breathing rate, and pose are **biometric** entities — they are stripped from MQTT (and never published over Matter) when `--privacy-mode` is set. See [Privacy](#privacy) below.
Per-node motion is a three-level classification, so the motion thresholds above effectively mean "moving" or "not moving". The precision and recall figures in [`semantic-primitives-metrics.md`](./semantic-primitives-metrics.md) are CLAIMED: the replay tooling they cite is not in the repository. No `reason` attribute is published yet; the explanation tags stay server-side.
### Semantic automation primitives (10 entities)
### Not announced
These are the inferred high-level states that customer automations actually use. Each one is a small finite-state machine running server-side with explicit warmup, hysteresis, and refractory windows. Per-primitive precision/recall is published in [`semantic-primitives-metrics.md`](./semantic-primitives-metrics.md).
These entities from ADR-115 §3.1 and §3.12 are not announced, because the sensing broadcast has nothing to drive them. Announcing them only produced entities stuck at "unknown" ([#2085](https://github.com/ruvnet/RuView/issues/2085)).
| HA entity | Slug | HA component | What it fires on |
|---|---|---|---|
| Someone sleeping | `someone_sleeping` | `binary_sensor` | presence + motion<5% + BR ∈ [8,20] bpm sustained for 5 min |
| Possible distress | `possible_distress` | `binary_sensor` | HR > 1.5× baseline + motion >20% + no fall, sustained 60 s |
| Room active | `room_active` | `binary_sensor` | motion >10% in a 30-s rolling window |
| Elderly inactivity anomaly | `elderly_inactivity_anomaly` | `binary_sensor` | idle > 2× observed-max-idle baseline |
| Meeting in progress | `meeting_in_progress` | `binary_sensor` | ≥2 persons + low-amplitude motion for 10 min |
| Bathroom occupied | `bathroom_occupied` | `binary_sensor` | presence + active zone tagged `bathroom` |
| Fall risk elevated | `fall_risk_elevated` | `sensor` | 0–100 score; event fires on ≥70 crossing |
| Bed exit (overnight) | `bed_exit` | `event` | sleeping → presence leaves bed zone between 22:00–06:00 |
| No movement (safety) | `no_movement` | `binary_sensor` | presence + motion <1% for 30 min |
| Multi-room transition | `multi_room_transition` | `event` | zone X exit + zone Y enter within 10 s |
| Slug | Why not |
|---|---|
| `zone_occupancy`, `bathroom_occupied`, `bed_exit`, `multi_room_transition` | the broadcast carries no zones, and the server loads no zone-tag map |
| `meeting_in_progress` | needs a 1–20 % motion level; per-node motion is three-level |
| `pose` | `pose_keypoints` is never populated on the broadcast, and the `persons[].keypoints` skeletons are synthesized. `--mqtt-publish-pose` is accepted but logs a warning and announces nothing |
Every state change carries a `reason` attribute (e.g. `["motion<5%", "br=12bpm", "presence=true"]`) so you can template against it in HA automations to understand why an automation triggered.
### Publish rates and retention
| Entity kind | When it publishes | QoS / retain |
|---|---|---|
| `presence` and semantic `binary_sensor`s | on change, plus a heartbeat every 60 s if unchanged | 1 / retained |
| `person_count`, `motion_level`, `motion_energy`, `presence_score`, `fall_risk_elevated` | at most `--mqtt-rate-motion` / `--mqtt-rate-count` (default 1 Hz) | 0 / not retained |
| `heart_rate`, `breathing_rate` | at most `--mqtt-rate-vitals` (default 0.2 Hz) | 0 / not retained |
| `rssi` | at most `--mqtt-rate-rssi` (default 0.1 Hz) | 0 / not retained |
| `fall` | once per detected fall | 1 / not retained |
| availability | on every transition, and all topics every 30 s | 1 / retained |
Before [#2093](https://github.com/ruvnet/RuView/issues/2093), `presence` was published on every CSI frame (MEASURED 37.9 msg/s with one node, 1 msg per ~45 s after the fix; see [ADR-115 §11](../adr/ADR-115-home-assistant-integration.md#11-status-note-p45-semantic-states-on-the-live-publisher-2026-10-02)).
### Device identity
Each node's HA device id is `wifi_densepose_<client-id>-node<N>`, where `<N>` is the node id from the CSI frames. Without `--mqtt-client-id`, the server creates `wifi-densepose-<8 hex digits>` on first start and keeps it in `<data-dir>/mqtt_client_id`, so device ids survive restarts. Keep `--data-dir` on persistent storage (a Docker volume, for example). If the file cannot be written, the server logs a warning and falls back to a per-process id for that run. `--mqtt-client-id` overrides the persisted id. Use it to pin ids across reinstalls or to run two servers against one broker.
Releases before this change used `wifi-densepose-<pid>`, so every restart created a new set of devices. To remove the leftovers, publish an empty retained message to each stale `homeassistant/+/wifi_densepose_<old-id>…/+/config` topic, or delete the devices in Home Assistant.
### Matter device-type mapping
@@ -115,7 +138,7 @@ Per ADR-115 §3.11.1, the Matter Bridge exposes a subset on standard clusters so
| `--mqtt-port <PORT>` | 1883 (8883 with TLS) | Broker port |
| `--mqtt-username <U>` | — | Username for broker auth |
| `--mqtt-password-env <VAR>` | `MQTT_PASSWORD` | Env var holding the password |
| `--mqtt-client-id <ID>` | `wifi-densepose-<hostname>` | MQTT client ID |
| `--mqtt-client-id <ID>` | persisted `wifi-densepose-<hex>` in `<data-dir>/mqtt_client_id` | MQTT client ID; prefixes every HA device id |
| `--mqtt-prefix <PREFIX>` | `homeassistant` | Discovery topic prefix |
| `--mqtt-tls` | off | Encrypt connection |
| `--mqtt-ca-file <PATH>` | — | Pinned CA for TLS / mTLS |
@@ -126,43 +149,21 @@ Per ADR-115 §3.11.1, the Matter Bridge exposes a subset on standard clusters so
| `--mqtt-rate-motion <HZ>` | 1.0 | Motion publish rate (Hz) |
| `--mqtt-rate-count <HZ>` | 1.0 | Person-count publish rate (Hz) |
| `--mqtt-rate-rssi <HZ>` | 0.1 | RSSI publish rate (Hz) |
| `--mqtt-publish-pose` | off | Enable pose-keypoint publication |
| `--mqtt-rate-pose <HZ>` | 1.0 | Pose publish rate when enabled |
| `--privacy-mode` | off | Strip HR/BR/pose from MQTT and Matter |
| `--mqtt-publish-pose` | off | Accepted; no pose source exists, so it only logs a warning |
| `--mqtt-rate-pose <HZ>` | 1.0 | Unused while no pose entity is announced |
| `--privacy-mode` | off | Strip HR/BR/pose from MQTT, Matter, REST, WebSocket and recordings |
| `--data-dir <DIR>` | `data` | Holds `mqtt_client_id` (and other server state) |
| `--matter` | off | Enable the HA-FABRIC Matter Bridge |
| `--matter-setup-file <PATH>` | — | Where to write the QR + manual code |
| `--matter-reset` | off | Wipe fabric credentials and re-commission |
| `--matter-vendor-id <VID>` | `0xFFF1` (dev) | CSA-assigned vendor ID |
| `--matter-product-id <PID>` | `0x8001` | Product ID |
| `--semantic` | on | Enable inference layer |
| `--semantic-thresholds-file <PATH>` | — | Per-primitive threshold overrides |
| `--semantic-zones-file <PATH>` | — | Zone-tag map (`bathroom`, `bedroom`, …) |
| `--no-semantic <PRIMITIVE>` | — | Disable a specific primitive (repeatable) |
### Zone tag file format
The `--semantic`, `--semantic-thresholds-file`, `--semantic-zones-file` and `--no-semantic` flags from ADR-115 §3.12.5 are **not accepted by `sensing-server` yet**. The primitives always run with the defaults in `PrimitiveConfig`, and no zone map is loaded.
```yaml
# semantic-zones.yaml — passed to --semantic-zones-file
zones:
bathroom: ["zone_3", "zone_7"]
bedroom: ["zone_1"]
kitchen: ["zone_2"]
living: ["zone_5"]
bed_zones: ["zone_1"]
```
### Zone tags and threshold overrides (not implemented)
### Threshold overrides
```yaml
# semantic-thresholds.yaml — passed to --semantic-thresholds-file
sleep_dwell_secs: 300
distress_hr_multiple: 1.5
room_active_motion_threshold: 0.10
elderly_anomaly_multiple: 2.0
meeting_min_persons: 2
no_movement_dwell_secs: 1800
fall_risk_event_threshold: 70.0
```
ADR-115 §3.12.5 specifies a zone-tag file and a threshold-override file. Neither is read by `sensing-server` today, which is why the zone-based primitives are not announced.
---
@@ -170,9 +171,11 @@ fall_risk_event_threshold: 70.0
When deploying in **healthcare**, **AAL (aging-in-place)**, or **commercial** settings, set `--privacy-mode`. This:
- **Strips** heart rate, breathing rate, and pose keypoints from every outbound MQTT publication.
- **Suppresses discovery** for those entities entirely — HA never even sees they exist.
- **Keeps every semantic primitive enabled.** Sleeping / distress / room-active / etc are *inferred* states. The inference happens server-side and only the boolean or score crosses the wire. This is the architectural win that makes the platform deployable in regulated contexts.
- **Strips** heart rate and breathing rate from every outbound MQTT publication and, in the same way, from REST responses, WebSocket frames and recordings (pose is never announced over MQTT; see [Not announced](#not-announced)).
- **Suppresses discovery** for those entities entirely: Home Assistant never sees them, and no availability or state topic is published for them.
- **Keeps the semantic primitives enabled.** `someone_sleeping` and `possible_distress` still use breathing and heart rate *inside the server*; only the ON/OFF state crosses the wire (ADR-115 §3.12.3). If that is too much for your deployment, drop those two entities in Home Assistant.
With `--privacy-mode`, 13 entities are announced per node. Privacy mode reduces what leaves the server. It does not by itself make a deployment compliant with any regulation.
Always pair `--privacy-mode` with `--mqtt-tls` on non-localhost brokers.
@@ -210,7 +213,6 @@ action:
title: "Possible distress detected"
message: >
RuView flagged sustained elevated heart rate + agitated motion.
Reason: {{ state_attr(trigger.entity_id, 'reason') }}.
```
### 2. Dim hallway when someone is sleeping
@@ -256,6 +258,8 @@ action:
### 3. Wake-up routine on bed exit
> `bed_exit` is not published by `sensing-server` today (it needs a bed-zone map; see [Not announced](#not-announced)). This blueprint will not fire until that lands.
```yaml
blueprint:
name: RuView — wake-up routine on bed exit
@@ -335,8 +339,8 @@ cards:
entity: binary_sensor.ruview_kitchen_presence
name: Kitchen
- type: tile
entity: binary_sensor.ruview_bathroom_occupied
name: Bathroom
entity: binary_sensor.ruview_bedroom_room_active
name: Bedroom active
```
---
@@ -373,6 +377,15 @@ All three accept the same HA discovery topics RuView publishes. Performance and
wscat -c ws://localhost:8765/ws/sensing
```
3. Confirm rate limits aren't dropping everything: `--mqtt-rate-vitals 1.0` for diagnosis (default 0.2 Hz = every 5 s).
4. Unchanged binary states (presence, the semantic primitives) publish once per change and then every 60 s. A flat line in HA history is expected while nothing changes.
### Entity shows "unavailable"
The server has no source for it right now: heart and breathing rate need explicit calibration and a single occupant, `fall` needs a node that sends `edge_vitals`, and the semantic primitives wait 60 s after start (`someone_sleeping` and `possible_distress` also need breathing or heart rate). Check the availability topic with `mosquitto_sub -t 'homeassistant/+/+/<slug>/availability' -v`.
### Duplicate devices after a restart
Older releases derived device ids from the process id. See [Device identity](#device-identity) for how to remove the stale devices; the current default id is persisted in `--data-dir`.
### "Plaintext MQTT on non-localhost broker" WARN
@@ -391,7 +404,7 @@ Per [ADR-115 §3.9](../adr/ADR-115-home-assistant-integration.md#39-tls--auth),
## Applications — what people actually do with this
The 21 entities per node — 11 raw signals (presence, person count, breathing, heart rate, motion, RSSI, etc.) and 10 inferred semantic states (someone-sleeping, possible-distress, room-active, elderly-inactivity-anomaly, meeting-in-progress, bathroom-occupied, fall-risk-elevated, bed-exit, no-movement, multi-room-transition) — slot into Home Assistant like any other sensor. The list below groups real-world uses so you can pick the ones that match your space.
The 15 entities per node (9 raw signals and 6 semantic states, see the [Entity reference](#entity-reference)) slot into Home Assistant like any other sensor. Some uses below rely on entities that are not announced yet (zones, bathroom, bed exit, meeting); those wait on a zone source or a finer motion level. The list below groups real-world uses so you can pick the ones that match your space.
### Personal & home
@@ -495,13 +508,9 @@ A few patterns appear over and over; if you understand these you can build most
### What about regulated environments?
Run RuView with `--privacy-mode` and only the 10 inferred semantic states reach Home Assistant — heart rate, breathing rate, and pose values are stripped at the MQTT wire. Per ADR-115 §6, this passes:
With `--privacy-mode`, heart rate and breathing rate never reach the broker; their entities are not even announced. The semantic states still reach Home Assistant, and two of them (`someone_sleeping`, `possible_distress`) are derived from those vitals inside the server, so their ON/OFF values are themselves health-related information.
- **HIPAA-style minimum-necessary** (no biometric numbers leave the device)
- **GDPR purpose-limitation** (the inferred states are the smallest dataset that supports the automation)
- **CCPA "sensitive personal information"** (no health data crosses the wire)
The fall-risk-elevated / possible-distress / someone-sleeping flags still work — they're computed *inside* the sensor pipeline and only the boolean outputs are published. That's the architectural win that makes RuView deployable in care homes, hospitals, schools, and shared-housing scenarios where raw biometrics would be a non-starter.
Privacy mode narrows what crosses the MQTT wire. It is not a compliance certification, and RuView makes no HIPAA, GDPR or CCPA compliance claim. Whether a deployment meets a regulation depends on the whole system: broker access control and TLS, Home Assistant's recorder retention, who can see the dashboards, consent, and local law. Assess that with whoever is responsible for compliance in your setting.
## References
+361
View File
@@ -0,0 +1,361 @@
# MediaTek Router CSI (MT7981, experimental)
RuView can take channel state information (CSI) from a MediaTek Filogic
router, such as an MT7981B with an MT7976C radio running OpenWrt with
MediaTek's vendor CSI patch. The router reports CSI. A host-side bridge
converts it to RuView's MTC1 wire format (ADR-267). The sensing server ingests
it like any other UDP source.
**Status: experimental.** This page documents a working data path, measured
on one board model. It makes no sensing-quality claim. Frames from this path
carry the provenance label `mediatek:physical-unvalidated`. The ADR-266
hardware gates are still open: calibration, sequence, timestamp, and
repeatability.
Developed in whitsentry, a RuView-based project.
Evidence tags used below: **MEASURED** (with a reproducer), **CODE-DERIVED**
(read from source or build scripts, not run here), **CLAIMED** (stated, not
independently verified), **SYNTHETIC** (simulated or fabricated data).
## Contents
- [Supported hardware](#supported-hardware)
- [How the path works](#how-the-path-works)
- [Build the OpenWrt image](#build-the-openwrt-image)
- [Arm CSI on the router](#arm-csi-on-the-router)
- [Run the bridge](#run-the-bridge)
- [Start the sensing server](#start-the-sensing-server)
- [What the data is, and is not](#what-the-data-is-and-is-not)
- [Evidence](#evidence)
- [Troubleshooting](#troubleshooting)
- [Reference](#reference)
## Supported hardware
| Board | SoC + radio | Status |
|---|---|---|
| Wavlink WL-WN586X3 **Rev A** | MT7981BA + MT7976CN (switch MT7531AE) | Run on two units. CSI captured from both is analysed offline below (MEASURED). Live delivery to the sensing server is CLAIMED from local run logs that are not in the tree. |
| OpenWrt One | MT7981B + MT7976C | CLAIMED: same silicon per the OpenWrt hardware table and ADR-266. Not run with this patch set. |
| Xiaomi AX3000T | MT7981B + MT7976C | CLAIMED: same silicon per the OpenWrt hardware table. Not run with this patch set. |
| Other MT7981/MT7986 boards | — | Untested. The CSI patch targets the `mt7915` driver family, so other Filogic radios may work, but nothing here shows that. |
The board patch in `firmware/openwrt-wn586x3/` is specific to the WN586X3
**Rev A**. The CSI driver patch and the two userspace fixes do not depend on
the board.
Each MT7976C radio has **two receive chains**. The WN586X3's four external
antennas are a 2.4 GHz pair and a 5 GHz pair. It is not a 4x4 array, and two
routers are two independent receivers, not one phased array.
## How the path works
```
router (OpenWrt 24.10.8) host
┌────────────────────────────────┐ ┌──────────────────────────────┐
│ mt76 + MediaTek CSI patch │ │ wifi-densepose-mtk-bridge │
│ firmware MCU event 0xc2 │ JSON dump │ groups chains into frames, │
│ mt76-vendor dump csi ─────────┼──────────────►│ applies provenance gate, │
│ CSIdump (UDP, optional) ───────┼──── UDP ─────►│ emits ADR-267 MTC1 │
└────────────────────────────────┘ └──────────────┬───────────────┘
│ UDP :5005
┌──────────────▼───────────────┐
│ sensing-server │
│ --source mediatek │
│ /api/v1/csi/mediatek/* │
└──────────────────────────────┘
```
Two transports leave the router:
- **`mt76-vendor <iface> dump csi <n> <file>`** writes a JSON array of
per-chain records: timestamp, transmitter address, RSSI, SNR, bandwidth,
PPDU mode, tx/rx chain index, chain marker, and I/Q. This is the richer
path, and the one that carried sustained use. You copy the file to the host
(for example, a loop over SSH) and replay it through the bridge.
- **`CSIdump`** (MtkCSIdump) streams one antenna per UDP datagram and drops
RSSI, PPDU mode, and transmitter identity. The bridge emits one 1x1 frame per
datagram. This path was verified end to end once, as a recording. It has not
run as a standing service.
## Build the OpenWrt image
The patch set lives in [`firmware/openwrt-wn586x3/`](../firmware/openwrt-wn586x3/README.md).
It is not a firmware image. Build and hash your own.
| Patch | Applies to |
|---|---|
| `1001-mtk-mt76-mt7915-csi-implement-csi-support-REBASED.patch` | `mt76` package (`package/kernel/mt76/patches/`) |
| `mt76-vendor-csi-dump-bounds.patch` | `mt76-vendor` source (`feed/app/mt76-vendor/src/csi.c` in `mediatek/mtk-openwrt-feeds`) |
| `csidump-nl80211-attr-enum.patch` | MtkCSIdump `src/wifi_drv_api/mt76_api.cpp` (only if you use CSIdump) |
| `0001-wn586x3-reva-single-image-sysupgrade-and-generic-spi-nor.patch` | `target/linux/mediatek` (WN586X3 Rev A only) |
The route that produced the tested image (CODE-DERIVED from its build scripts)
used the stock OpenWrt **24.10.8** `mediatek/filogic` SDK and ImageBuilder in
an x86_64 Linux container:
1. **SDK.** Unpack `openwrt-sdk-24.10.8-mediatek-filogic`, then run
`./scripts/feeds update -a && ./scripts/feeds install -a`.
2. **CSI driver.** Copy the rebased `1001-...` patch into
`feeds/base/package/kernel/mt76/patches/`. Raise `PKG_RELEASE` in that
package's `Makefile` (the tested build used `99`) so the ImageBuilder
prefers your build over the CDN package of the same version.
3. **mt76-vendor.** Add MediaTek's `mt76-vendor` package from
`mtk-openwrt-feeds` as `package/mt76-vendor`, with the bounds patch applied
to `src/csi.c`.
4. **Configure and compile.** Select `CONFIG_TARGET_mediatek_filogic_DEVICE_wavlink_wl-wn586x3`,
`kmod-mt7915e`, `mt76-vendor`, and `libnl-tiny`. Then run
`make package/feeds/base/mt76/compile package/mt76-vendor/compile`.
5. **Image.** Copy the built `.ipk` files into the ImageBuilder's `packages/`
and run:
```bash
make image PROFILE=wavlink_wl-wn586x3 \
PACKAGES="kmod-mt7915e kmod-mt7981-firmware mt7981-wo-firmware mt76-vendor libnl-tiny"
```
6. **Check the manifest.** The image's `.manifest` must list `kmod-mt7915e`
at your raised release (for example `-r99`) and `mt76-vendor`. If it lists
the CDN release, the image has no CSI.
**Rev A board patch.** An ImageBuilder cannot compile a device tree, and its
`make image` reuses the prebuilt DTB and FIT kernel. The tested Rev A build
compiled the patched DTB itself, rebuilt the FIT kernel around it, added the
patched `lib/upgrade/platform.sh` as a rootfs overlay, then checked that the
final sysupgrade image embedded that DTB. A full OpenWrt buildroot applies
`target/` patches directly and avoids those steps. That route was not tested
here.
Flash only a Rev A unit, keep the stock firmware backup, and follow the
OpenWrt wiki and forum thread for this board. Neither this guide nor the patch
set names a LAN, an account, or a password.
**Firmware blobs.** The CSI report is MCU event `0xc2`. The MT7981 firmware in
the tested image (`kmod-mt7981-firmware`, `mt7981-wo-firmware` from OpenWrt
24.10.8) already emits it, so no firmware blob swap was needed on the tested
board. Other firmware versions are untested.
## Arm CSI on the router
CSI follows traffic from **associated clients**. With nothing associated and
transmitting, there is no CSI. Run these on the router, as root, against the AP
interface (`phy0-ap0` was the 2.4 GHz AP on the tested board). The sequence
below is CODE-DERIVED from the capture scripts used on the tested board:
```sh
mt76-vendor phy0-ap0 set csi ctrl=0,0,0,0 # disarm and reset
mt76-vendor phy0-ap0 set csi interval=1000
mt76-vendor phy0-ap0 set csi ctrl=2,3,0,34 # frame-type filter, as used on
mt76-vendor phy0-ap0 set csi ctrl=2,9,1,0 # the tested board (data frames)
mt76-vendor phy0-ap0 set csi ctrl=1,0,0,0 # arm
```
Check that the driver is collecting:
```sh
cat /sys/kernel/debug/ieee80211/phy0/mt76/csi_stats # data_cnt should climb
iw dev phy0-ap0 station dump | grep -E "Station|signal"
```
Pull a batch:
```sh
rm -f /tmp/csi.json # the tool APPENDS; remove first
mt76-vendor phy0-ap0 dump csi 64 /tmp/csi.json
```
`dump csi` needs the filename argument. Without it, the tool returns nothing.
## Run the bridge
```bash
cd v2
cargo build --release -p wifi-densepose-mtk-bridge
B=./target/release/wifi-densepose-mtk-bridge
```
**Dump path (recommended).** Copy `/tmp/csi.json` to the host, then replay it.
A file cannot prove it came off a radio, so `--replay` refuses to start until
you either attest the hardware or declare the file synthetic:
```bash
$B --replay csi.json --captured-on <model>/<firmware> \
--node <node-name> --center-freq-khz <khz> --sink 127.0.0.1:5005
```
- `--node` is hashed into the 64-bit `device_id`. Give each router its own name.
- `--center-freq-khz` is the AP channel's centre frequency, for example
`2437000` for 2.4 GHz channel 6.
- `--captured-on` takes `<model>/<firmware>`, for example
`WN586X3-RevA/openwrt-24.10.8-csi`. It is logged at startup.
For a continuous feed, repeat dump, copy, and replay in a loop, one loop per
router. Keep a copy of each dump only if you need it, and never commit one
(see below).
**Streaming path (CSIdump).** Start `CSIdump phy0-ap0 <rate> 8888` on the
router. Then, on the host:
```bash
$B --listen 0.0.0.0:8888 --csidump <router-ip>:8888 --listen-allow <router-ip> \
--node <node-name> --center-freq-khz <khz> --sink 127.0.0.1:5005 \
--record captures/<node-name>.mtkcap
```
`--listen-allow` is mandatory. Datagrams from any other source are dropped and
counted. CSIdump disarms CSI when it receives SIGTERM. The dump path and
CSIdump cannot share the driver's ring, so run one or the other.
**No router.** The fabricated fixture exercises the whole host path. It must
be replayed with `--synthetic`, and the server labels it `mediatek:simulated`:
```bash
$B --replay crates/wifi-densepose-mtk-bridge/fixtures/FABRICATED-mt76-vendor-dump-mt7981-bw80.synthetic.json \
--synthetic --node demo --sink 127.0.0.1:5005
```
The ADR-266 simulator is another synthetic source:
```bash
cargo run -p wifi-densepose-hardware --bin mediatek-csi-sim -- \
--profile mt7981 --udp 127.0.0.1:5005 --frames 1000 --realtime
```
Full flag reference: [`v2/crates/wifi-densepose-mtk-bridge/README.md`](../v2/crates/wifi-densepose-mtk-bridge/README.md).
## Start the sensing server
```bash
cd v2
cargo run --release -p wifi-densepose-sensing-server -- --source mediatek \
--activity-state <state-dir>/activity-floor.json
```
- `--source mediatek` binds the UDP receiver (loopback by default, ADR-296) and
does **not** start the ESP32-shaped simulator. Before this source existed,
`--source mediatek` bound nothing.
- If the bridge runs on another host, bind a routable address with
`--udp-bind` and name the bridge host in `--udp-allow`.
- `--activity-state` is optional. It persists each device's learned
quiet-room activity floor so that a restart does not relearn it.
- `--csi-ring <n>` sets frames retained per device for inspection (default
256, about 0.5 MiB per 2x2 device at 64 subcarriers).
Endpoints:
| Route | Returns |
|---|---|
| `GET /api/v1/csi/mediatek/latest` | Last MTC1 snapshot, any device |
| `GET /api/v1/csi/mediatek/devices` | One snapshot per `device_id`, with `age_ms`. Devices silent for over 30 s are omitted. |
| `GET /api/v1/csi/mediatek/devices/:device_id/frames?n=&fields=amp,phase` | Newest `n` frames of per-chain, per-subcarrier amplitude and phase |
| `GET /api/v1/csi/mediatek/devices/:device_id/summary` | Per-chain amplitude mean/std per subcarrier, RSSI per Rx chain, frame rate, heuristic window stats |
| `GET /api/v1/csi/mediatek/activity?seconds=300` | About 1 Hz history of the relative activity index |
| `GET /api/v1/sensing/latest` | Room update. On this source, `classifier` is `mediatek-amplitude-heuristic-v0`. |
Check the label on every snapshot:
| `source` | Meaning |
|---|---|
| `mediatek:simulated` | `SYNTHETIC` flag set. Nothing downstream can clear it. |
| `mediatek:physical-unvalidated` | Real silicon, not calibrated. Everything this path produces today. |
| `mediatek` | Real and calibrated. Reserved. Nothing emits it yet. |
## What the data is, and is not
**It is** complex channel estimates per receive chain from a real MT7976C
radio, carried end to end with provenance intact.
**It is not:**
- **A sensor with its own clock.** The report rate is the clients' traffic
rate. An idle phone gives a few frames per second, and a busy one gives
hundreds. With no associated, transmitting client, there is no data. A
stationary device that keeps transmitting to the AP (an "illuminator", for
example an ESP32 sending a steady packet stream) gives a regular rate
(CLAIMED: the 09-20 and 09-24 captures below include an illuminator's
frames; transmitter identity was not re-checked in this analysis).
- **Calibrated.** `scale` is 1.0 in raw firmware units. Nothing has been
checked against a reference. The ADR-266 gates are open.
- **Validated for presence, motion, or vital signs.** The room output on this
source comes from `mediatek-amplitude-heuristic-v0` (presence and motion from
mean amplitude) and `activity-index-v0` (fluctuation against a learned
floor). Both are **CLAIMED**. Neither has been scored against labelled ground
truth. `activity` blocks carry `is_occupancy_estimate: false`. These outputs
do not feed the validated ESP32 vital-sign pipeline.
- **Coherent across routers.** Each radio has its own oscillator. Cross-chain
phase is meaningful within one radio, not between two boxes.
- **Free of personal data.** A raw dump holds client transmitter addresses
(MACs) and channel samples that encode movement. RuView policy (ADR-299)
forbids committing captures. Keep them out of the repository, and run
`scripts/csi-data-policy-check.sh --staged` before committing near capture
directories.
## Evidence
| Claim | Tag | Basis |
|---|---|---|
| A real WL-WN586X3 frame decodes and reaches the server as `mediatek:physical-unvalidated` | MEASURED (path) | First record, 2026-09-19: legacy OFDM, 20 MHz, 64 subcarriers, one chain. Its shape is pinned by `wifi-densepose-mtk-bridge/tests/hardware_capture.rs`, which uses a fabricated record. The capture itself is not in the tree. |
| Cross-chain phase is steady frame to frame on one MT7976C radio, and raw per-chain phase is not | MEASURED (offline) | See below |
| `mediatek-amplitude-heuristic-v0`, `activity-index-v0` | CLAIMED | No labelled evaluation |
| ADR-266 simulator and bridge fixtures | SYNTHETIC | Labelled on the wire |
**Cross-chain phase stability.** Six local dump captures from two WN586X3 units,
2.4 GHz, 20 MHz HT frames carrying chains (tx0, rx0) and (tx0, rx1), adjacent
frames under 100 ms apart, 56 subcarriers (DC and guard bins excluded). The
two 2026-09-19 captures were analysed whole; the four 09-20 and 09-24 captures
by their first 20 MB each.
| Quantity, per adjacent-frame step | Well-sampled transmitters (6, at 825 to 14,104 frames each) |
|---|---|
| Raw per-chain phase `arg(H0[k])`, per bin, p50 | 1.557 to 1.601 rad, with 49.6 to 50.9 % of steps over π/2 |
| Cross-chain phase `arg(H0[k]·conj(H1[k]))`, per bin, p50 | 0.023 to 0.056 rad |
| Cross-chain phase summed coherently over bins, p50 / p90 / p99 | 0.014–0.039 / 0.205–0.358 / 0.437–0.820 rad |
Uniformly random phase gives a step p50 of π/2 = 1.571 rad, and half of all
steps exceed π/2. Raw per-chain phase is indistinguishable from that, as
expected with per-packet CFO and phase offset. In the cross-chain term those
offsets cancel, and the step drops to hundredths of a radian. Steps are
wrapped, so p99 can never exceed π, and is not evidence by itself.
Transmitters with few, irregular frames (229 to 595 frames) were much worse:
coherent-sum p50 0.177 to 0.456 rad, p99 1.857 to 2.849 rad. So the result
holds only with a steady frame rate.
This shows a **stable phase observable exists** within one radio. It does
**not** show that the observable tracks people, breathing, or anything else.
Reproduce it on your own capture (one `dump csi` array per line):
```bash
python3 scripts/mediatek-csi-phase-stability.py <capture.jsonl> [max_MB]
```
The script prints transmitters as `TX-A`, `TX-B`, and so on, never addresses.
## Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| `csi_stats` `data_cnt` stays at 0 | No associated client transmitting, or CSI not armed | Associate a client and generate traffic, then re-run the arm sequence |
| `data_cnt` stopped after a client roamed or the filter changed | Changing the CSI filter stops capture | Re-run the arm sequence ending in `ctrl=1,0,0,0`. Re-arming periodically is a reasonable guard. |
| `mt76-vendor dump csi` prints nothing | No filename argument | Use `dump csi <n> <file>` |
| `mt76-vendor dump csi` segfaults | Unpatched tool: the driver can return n+1 records | Rebuild with `mt76-vendor-csi-dump-bounds.patch` |
| Bridge warns about repeated `(ta, ts, tx_idx, rx_idx)` chains | The dump file was not removed between dumps, and the tool appends | `rm -f` the file before every dump |
| `--replay` refuses to start | Provenance not declared | Add `--captured-on <model>/<firmware>`, or `--synthetic` for fabricated input |
| `--captured-on` is rejected | The input declares itself synthetic (sidecar, `.synthetic.` name, or capture header) | Expected. Synthetic is sticky. Use `--synthetic`. |
| `--listen` refuses to start | No `--listen-allow` | Name the router's address or subnet |
| `rejected_source` counts climb on `--listen` | The router's address is not in `--listen-allow` | Fix the allowlist |
| CSIdump runs but nothing arrives | Unpatched CSIdump uses an off-by-one netlink attribute enum against this driver | Rebuild with `csidump-nl80211-attr-enum.patch` |
| CSI stopped after CSIdump exited | CSIdump disarms CSI on SIGTERM | Re-run the arm sequence |
| `/api/v1/csi/mediatek/devices` is empty | Server not on `--source mediatek` (or `auto` without UDP), wrong `--sink` port, or a non-loopback bridge not in `--udp-allow` | Check `--source`, port 5005, `--udp-bind`, and `--udp-allow` |
| Two routers show as one device | Same `--node` on both bridges | Give each router its own `--node` (or `--device-id`) |
| A frame shows `ppdu_type: legacy` | Normal. Association and management traffic is pre-HT. | None. ADR-267 Amendment 1 carries these frames rather than dropping them. |
| Room status reads `unknown` for a device | The heuristic abstains below its confidence threshold | Expected with sparse frames. It is not an "absent" call. |
| Room reads `absent` with `confidence: 0.0` | Every device abstained, so there is no evidence either way | Treat confidence 0 as "no information", not as an empty room |
| Image flashes, but there is no `mt76-vendor` or CSI | The ImageBuilder used the CDN `kmod-mt7915e` | Check the manifest for your raised `PKG_RELEASE` |
## Reference
- [ADR-266: MediaTek Filogic CSI platform](adr/ADR-266-mediatek-filogic-csi-platform.md) (Amendment 1: vendor CSI on MT7981)
- [ADR-267: MediaTek MIMO CSI wire protocol](adr/ADR-267-mediatek-mimo-csi-wire-protocol.md) (Amendment 1: `Legacy` and `Unknown` PPDU types)
- [`firmware/openwrt-wn586x3/`](../firmware/openwrt-wn586x3/README.md): patches, licences, origins
- [`v2/crates/wifi-densepose-mtk-bridge/`](../v2/crates/wifi-densepose-mtk-bridge/README.md): bridge transports, provenance rules, frame mapping
- [MediaTek OpenWrt feeds](https://git01.mediatek.com/openwrt/feeds/mtk-openwrt-feeds/), [MtkCSIdump](https://github.com/MtkWifiRev/MtkCSIdump), [OpenWrt One](https://openwrt.org/toh/openwrt/one)
+10
View File
@@ -111,7 +111,12 @@ handler (`health_ready`, `main.rs:8128,8133`) and return a `trust` block
```json
{
"status": "ready",
"source": "esp32",
"source_state": "live_unverified",
"waiting_for_frames": false,
"last_frame_age_ms": 42,
"trust": {
"node_positions": { "1": [0.5, 0.0, 1.0], "2": [4.0, 3.0, 1.0] },
"last_witness": "…64 hex chars or null…",
"effective_class": "Anonymous | Restricted | …",
"demoted": false,
@@ -122,6 +127,11 @@ handler (`health_ready`, `main.rs:8128,8133`) and return a `trust` block
}
```
`node_positions` is the `--node-positions` map the governed fuser holds,
keyed by node id. It is `{}` when no positions are configured, and then every
node fuses at the origin. A node the map does not name also fuses at the
origin.
**This is a real, currently-shipped diagnostic surface — but it is honestly
limited.** It tells you *that* errors are occurring and *that* the current
class is demoted, and the total count, but not *why* for your specific run:
+259 -25
View File
@@ -21,12 +21,18 @@ WiFi DensePose turns commodity WiFi signals into real-time human pose estimation
- [Windows WiFi (RSSI Only)](#windows-wifi-rssi-only)
- [ESP32-S3 (Full CSI)](#esp32-s3-full-csi)
- [ESP32 Multistatic Mesh (Advanced)](#esp32-multistatic-mesh-advanced)
- [MediaTek Router CSI (Experimental)](#mediatek-router-csi-experimental)
- [Connect Mesh Data to the Dashboard and Observatory](#connect-mesh-data-to-the-dashboard-and-observatory)
- [Cognitum Spaces activation](#cognitum-spaces-activation)
- [Cognitum Seed Integration (ADR-069)](#cognitum-seed-integration-adr-069)
5. [REST API Reference](#rest-api-reference)
6. [WebSocket Streaming](#websocket-streaming)
7. [Web UI](#web-ui)
7. [RuView npm toolkit (`@ruvnet/ruview`)](#ruview-npm-toolkit-ruvnetruview)
- [Hardware from the command line](#hardware-from-the-command-line)
- [MCP for agents, ChatGPT and MCP Apps](#mcp-for-agents-chatgpt-and-mcp-apps)
- [Claude Code mod: live sensing pane](#claude-code-mod-live-sensing-pane)
- [Homecore and the vitals kernel](#homecore-and-the-vitals-kernel)
8. [Web UI](#web-ui)
8. [Vital Sign Detection](#vital-sign-detection)
9. [CLI Reference](#cli-reference)
10. [Observatory Visualization](#observatory-visualization)
@@ -104,7 +110,7 @@ Multi-architecture image (amd64 + arm64). Works on Intel/AMD and Apple Silicon M
| `simulated` | Generate synthetic CSI frames (no hardware required) |
| `wifi` | Host Wi-Fi RSSI (not available inside containers) |
Example: `docker run -e CSI_SOURCE=esp32 -p 3000:3000 -p 5005:5005/udp ruvnet/wifi-densepose:latest`
Example: `docker run -e RUVIEW_API_TOKEN -e CSI_SOURCE=esp32 -e RUVIEW_UDP_ALLOW=<node-ip-or-cidr> -p 127.0.0.1:3000:3000 -p 5005:5005/udp ruvnet/wifi-densepose:latest`. The container exits with code 64 unless `RUVIEW_API_TOKEN` is set, and receives no ESP32 frames until a UDP source guard is set; see [Receiving ESP32 frames in Docker](#receiving-esp32-frames-in-docker).
### From Source (Rust)
@@ -256,8 +262,9 @@ Non-interactive:
### 30-Second Demo (Docker)
```bash
# Pull and run
docker run -p 3000:3000 -p 3001:3001 ruvnet/wifi-densepose:latest
# Pull and run (the container exits with code 64 without an API token)
export RUVIEW_API_TOKEN=$(openssl rand -hex 32)
docker run -p 127.0.0.1:3000:3000 -p 127.0.0.1:3001:3001 -e RUVIEW_API_TOKEN ruvnet/wifi-densepose:latest
# Open the UI in your browser
# http://localhost:3000
@@ -297,7 +304,11 @@ All endpoints return JSON. In simulated mode, data is generated from a determini
## Data Sources
The `--source` flag controls where CSI data comes from.
The `--source` flag controls where CSI data comes from. Valid values are
`auto`, `esp32`, `wifi`, and `simulated` (alias `simulate`), plus the vendor
feeds `mediatek`, `qualcomm`, `realtek` (RTL8720F radar), and `realtek_csi`
(RTL8721Dx CSI), which bind the UDP receiver like `esp32`. Any other value
stops the server at startup with an error that lists them.
### Simulated Mode (No Hardware)
@@ -330,26 +341,24 @@ docker run --network host ruvnet/wifi-densepose:latest --source wifi --tick-ms 5
### macOS WiFi (RSSI Only)
Uses CoreWLAN via a Swift helper binary. macOS Sonoma 14.4+ redacts real BSSIDs; the adapter generates deterministic synthetic MACs so the multi-BSSID pipeline still works.
Uses CoreWLAN via a Swift helper binary. macOS Sonoma 14.4+ redacts real BSSIDs; the adapter generates deterministic synthetic MACs so the multi-BSSID pipeline still works. On macOS, `--source wifi` selects the CoreWLAN scanner; there is no separate `macos` value.
```bash
# Compile the Swift helper (once)
swiftc -O archive/v1/src/sensing/mac_wifi.swift -o mac_wifi
# Run natively
./target/release/sensing-server --source macos --http-port 3000 --ws-port 3001 --tick-ms 500
./target/release/sensing-server --source wifi --http-port 3000 --ws-port 3001 --tick-ms 500
```
See [ADR-025](adr/ADR-025-macos-corewlan-wifi-sensing.md) for details.
### Linux WiFi (RSSI Only)
Uses `iw dev <iface> scan` to capture RSSI. Requires `CAP_NET_ADMIN` (root) for active scans; use `scan dump` for cached results without root.
```bash
# Run natively (requires root for active scanning)
sudo ./target/release/sensing-server --source linux --http-port 3000 --ws-port 3001 --tick-ms 500
```
The sensing server does not have a Linux RSSI source yet. `--source wifi`
on Linux runs the Windows `netsh` scanner, which is not present there, and
`--source linux` is rejected at startup. Use an ESP32 node (`--source esp32`)
on Linux.
### ESP32-S3 (Full CSI)
@@ -360,11 +369,39 @@ Real Channel State Information at 20 Hz with 56-192 subcarriers. Required for po
./target/release/sensing-server --source esp32 --udp-port 5005 --http-port 3000 --ws-port 3001
# Docker (use CSI_SOURCE environment variable)
docker run -p 3000:3000 -p 3001:3001 -p 5005:5005/udp -e CSI_SOURCE=esp32 ruvnet/wifi-densepose:latest
docker run -p 127.0.0.1:3000:3000 -p 127.0.0.1:3001:3001 -p 5005:5005/udp \
-e RUVIEW_API_TOKEN -e CSI_SOURCE=esp32 -e RUVIEW_UDP_ALLOW=<node-ip-or-cidr> \
ruvnet/wifi-densepose:latest
```
The ESP32 nodes stream binary CSI frames over UDP to port 5005. See [Hardware Setup](#esp32-s3-mesh) for flashing instructions.
#### Receiving ESP32 frames in Docker
The sensing server binds its UDP receiver to `127.0.0.1` by default (ADR-296). Inside a container that address cannot receive anything from a published `5005/udp` port, so no node ever appears. The image's entrypoint binds UDP on `0.0.0.0` only when you set a source guard, and prints a note at startup when you have not:
| Variable | Effect |
|----------|--------|
| `RUVIEW_API_TOKEN` | Required. Without it (or `RUVIEW_ALLOW_UNAUTHENTICATED=1`) the container exits with code 64. |
| `RUVIEW_UDP_ALLOW=<ip-or-cidr>` | Accept frames only from these sources. Preferred. |
| `RUVIEW_UDP_INSECURE_LAN=true` | Accept frames from any source. The UDP data plane is not authenticated, so only use this on a trusted network. |
| `RUVIEW_UDP_BIND` | Explicit UDP bind address. Overrides the entrypoint's choice. |
| `SENSING_ALLOWED_HOSTS=<host-ip>` | Needed to open the UI from another machine; otherwise the server answers 421. That also requires publishing the TCP ports beyond `127.0.0.1`. |
**macOS (Docker Desktop, OrbStack):** datagrams reach the container from the Docker bridge gateway, not from the node's LAN address, so an allowlist that names your LAN subnet drops every frame. Allow the gateway instead:
```bash
export RUVIEW_API_TOKEN=$(openssl rand -hex 32)
GW=$(docker network inspect bridge --format '{{(index .IPAM.Config 0).Gateway}}')
docker run --rm -d --name ruview \
-p 127.0.0.1:3000:3000 -p 127.0.0.1:3001:3001 -p 5005:5005/udp \
-e RUVIEW_API_TOKEN -e CSI_SOURCE=esp32 -e RUVIEW_UDP_ALLOW="$GW/32" \
ruvnet/wifi-densepose:latest
curl -s -H "Authorization: Bearer $RUVIEW_API_TOKEN" localhost:3000/api/v1/nodes
```
Allowing the gateway admits anything that can reach the published port, so keep `5005/udp` off untrusted networks. Node identity still comes from the frame payload. With Docker Compose the service runs on the project network (`<project>_default`), so inspect that network's gateway rather than `bridge`. Docker Desktop on Windows has a related source-address problem; see [TROUBLESHOOTING.md](TROUBLESHOOTING.md).
### ESP32 Multistatic Mesh (Advanced)
For higher accuracy with through-wall tracking, deploy 3-6 ESP32-S3 nodes in a **multistatic mesh** configuration. Each node acts as both transmitter and receiver, creating multiple sensing paths through the environment.
@@ -386,6 +423,16 @@ The mesh uses a **Time-Division Multiplexing (TDM)** protocol so nodes take turn
See [ADR-029](adr/ADR-029-ruvsense-multistatic-sensing-mode.md) and [ADR-032](adr/ADR-032-multistatic-mesh-security-hardening.md) for the full design.
### MediaTek Router CSI (Experimental)
An OpenWrt router with an MT7981B + MT7976C radio (tested on a Wavlink WL-WN586X3 Rev A) can report CSI through MediaTek's vendor CSI patch. A host-side bridge converts it to MTC1 (ADR-267) for the sensing server:
```bash
./target/release/sensing-server --source mediatek --udp-port 5005 --http-port 3000 --ws-port 3001
```
Frames are labelled `mediatek:physical-unvalidated`. The presence and activity outputs on this source are uncalibrated heuristics with no sensing-quality validation. CSI exists only while associated clients transmit. See the [MediaTek Router CSI guide](mediatek-router-csi.md) for the OpenWrt build, arming CSI, the bridge, and limitations.
### Connect Mesh Data to the Dashboard and Observatory
If a standalone `aggregator` command prints live packets, the ESP32 fleet is already reaching that host. To visualize the same data, stop the standalone aggregator and run `sensing-server` on that same host and UDP port. The sensing server is the aggregator used by the REST API, WebSocket stream, dashboard, and Observatory.
@@ -402,13 +449,17 @@ cargo run -p wifi-densepose-sensing-server -- \
# Docker
docker run --rm \
-e RUVIEW_API_TOKEN \
-e CSI_SOURCE=esp32 \
-p 3000:3000 \
-p 3001:3001 \
-e RUVIEW_UDP_ALLOW=<node-ip-or-cidr> \
-p 127.0.0.1:3000:3000 \
-p 127.0.0.1:3001:3001 \
-p 5005:5005/udp \
ruvnet/wifi-densepose:latest
```
On macOS Docker the allowlist must name the bridge gateway; see [Receiving ESP32 frames in Docker](#receiving-esp32-frames-in-docker).
Open the UI from the sensing server, not from a local file:
| View | URL |
@@ -420,10 +471,20 @@ Use these checks before debugging the browser:
```bash
curl http://localhost:3000/health
curl http://localhost:3000/api/v1/status
curl http://localhost:3000/api/v1/nodes
curl http://localhost:3000/api/v1/sensing/latest
```
`/api/v1/status` reports whether frames are actually arriving:
| `source_state` | Meaning |
|---|---|
| `disconnected` | A live source is configured but no frame has arrived yet. `waiting_for_frames` is `true` and `last_frame_age_ms` is `null`. |
| `live_unverified` | Frames are arriving. `last_frame_age_ms` is under 5000. |
| `stale` | Frames arrived, then stopped for more than 5 seconds. For a hardware source, `source` gains an `:offline` suffix (for example `esp32:offline`). |
| `synthetic` | Simulated data. Never shown as live. |
If the ESP32 nodes are provisioned with `--target-ip <AGGREGATOR_HOST>`, that IP must be the machine running `sensing-server`. Only one process can receive UDP `:5005` at a time, so leave the standalone hardware `aggregator` off while the dashboard or Observatory is live.
### Cognitum Spaces activation
@@ -451,8 +512,8 @@ authority. Versioned collections are `sites`, `buildings`, `floors`,
The dependency-free contributor harness exposes the same read path:
```bash
npx @ruvnet/ruview@0.5.0 spaces --resource alerts --limit 25
npx @ruvnet/ruview@0.5.0 mcp start
npx @ruvnet/ruview@0.9.1 spaces --resource alerts --limit 25
npx @ruvnet/ruview@0.9.1 mcp start
```
Its MCP tool is `ruview_spaces_list`. MCP reads are OAuth-only, use the fixed
@@ -582,6 +643,8 @@ Base URL: `http://localhost:3000` (Docker) or `http://localhost:8080` (binary de
| `GET` | `/api/v1/mesh/metrics` | ADR-110 mesh state in Prometheus exposition format ([iter 36](adr/ADR-110-esp32-c6-firmware-extension.md)) | `wifi_densepose_mesh_offset_us{node="9"} 1163565\n…` |
| `GET` | `/api/field` | ADR-262 P3 — latest **signed RuField `FieldEvent`s** from the live sensing cycle, plus the signer pubkey + a `dev_signing_key` flag. Only egress-safe (P1/P2) events are surfaced; identity/biometric (P4/P5) and raw (P0) are held edge-local | `{"spec":"rufield","signer_pubkey_hex":"…","dev_signing_key":true,"events":[…]}` |
Recording and model files live under the server's `--data-dir` (env `RUVIEW_DATA_DIR`, default `data`, resolved against the directory the server was started from): recordings in `<data-dir>/recordings/`, `.rvf` models in `<data-dir>/models/` unless `MODELS_DIR` is set, and the adaptive classifier in `<data-dir>/adaptive_model.json`.
### RuField surface (ADR-262 P3)
RuView's live WiFi-CSI sensing now also speaks the standalone **RuField MFS** wire format. Each governed sensing cycle is converted (via the `wifi-densepose-rufield` anti-corruption bridge) into a **signed** `FieldEvent` (`Modality::WifiCsi`, ed25519 `ProvenanceRef`) and surfaced on two additive endpoints:
@@ -838,7 +901,14 @@ Full design + operator guide: [`docs/integrations/home-assistant.md`](integratio
sensing-server --mqtt --mqtt-host <broker> --mqtt-tls --privacy-mode
```
`--privacy-mode` strips heart rate, breathing rate, and pose keypoints from MQTT **and** Matter — they never reach the wire. Semantic primitives stay published because they're inferred *states* server-side, not biometric *values*. This is the architectural win that makes ADR-115 healthcare- and enterprise-deployable.
`--privacy-mode` (env `RUVIEW_PRIVACY_MODE`) is a server-wide flag. It withholds heart rate, breathing rate, and pose keypoints from every output:
- REST responses: the fields are removed from every JSON body (`/api/v1/sensing/latest`, `/api/v1/vital-signs`, `/api/v1/edge-vitals`, `/api/v1/pose/current`, ...). `/api/v1/pose/current?view=both|refined` returns `403` with `"code": "privacy_mode"`.
- WebSocket streams: `/ws/sensing` and `/api/v1/stream/pose` frames are filtered the same way.
- Recordings started with `POST /api/v1/recording/start` are written already filtered.
- MQTT and Matter: those entities are neither announced nor published.
Presence, motion, person count, zones and the coarse posture label are still served, and semantic primitives stay published because they're inferred *states*, not biometric *values*. Some of those states (sleeping, possible distress) are derived from vitals, so they are still health-related information. Privacy mode reduces what leaves the server. It does not by itself make a deployment compliant with any regulation.
### Matter Bridge (Apple Home / Google Home / Alexa / SmartThings)
@@ -958,6 +1028,164 @@ Source: [`tools/ruview-mcp/`](../tools/ruview-mcp/README.md). Tracking issue: [#
---
## RuView npm toolkit (`@ruvnet/ruview`)
`@ruvnet/ruview` is RuView's operator toolkit on npm. It has no runtime
dependencies and runs on Node 20+. It is tested on Windows and Linux. One tool
registry serves:
- a CLI;
- an MCP server (stdio, or HTTP for ChatGPT);
- a typed SDK;
- a Claude Code mod.
Every result is honest about what it is:
- **MEASURED** when it comes from live packets on your machine;
- **SYNTHETIC** for simulators and self-tests;
- **device-reported** for values a radar's own firmware computes, which are
not validated against a reference.
```bash
npx @ruvnet/ruview@0.9.1 --help
npx @ruvnet/ruview@0.9.1 doctor # what works on this machine, with fixes
```
In a terminal, commands print a formatted view. Pipes and `--json` print JSON,
for scripts.
### Hardware from the command line
Run these on the machine the hardware is plugged into.
```bash
# What is plugged in (USB VID:PID → ESP32, Realtek RTL8721Dx, radar, LiDAR)
npx @ruvnet/ruview@0.9.1 devices
# CSI nodes streaming to this machine (UDP 5005): ESP32 ADR-018 frames and
# Realtek RAC1 frames, per node rate, loss, RSSI and CSI shape
npx @ruvnet/ruview@0.9.1 esp32 --seconds 10
npx @ruvnet/ruview@0.9.1 esp32 --watch # live view, redrawn every few seconds
npx @ruvnet/ruview@0.9.1 esp32 --seconds 45 --analyze # live CSI through the vitals kernel
# A board's serial console, without resetting it
npx @ruvnet/ruview@0.9.1 monitor --port COM9
npx @ruvnet/ruview@0.9.1 monitor --port COM10 --baud 1500000 # Realtek Ameba
# 60 GHz / 24 GHz radar: raw UART, or an ESPHome kit on your network
npx @ruvnet/ruview@0.9.1 mmwave --port COM5 --model auto
npx @ruvnet/ruview@0.9.1 mmwave --source esphome --host 192.168.1.50
# Flash ESP32 firmware: plan first, then write with --confirm (records boot evidence)
npx @ruvnet/ruview@0.9.1 flash-plan --port COM16 --bundle firmware/esp32-csi-node/release_bins/c6-adr110 --variant c6
npx @ruvnet/ruview@0.9.1 flash --port COM16 --bundle firmware/esp32-csi-node/release_bins/c6-adr110 --variant c6 --confirm
```
**What the alerts mean:**
| Alert | Meaning |
|---|---|
| `heartbeat-only sender` | A board is alive but sends no CSI. On a Realtek board, run `monitor --baud 1500000`. If it shows `lack of csi buf`, the board ran out of CSI report buffers (`csi_buffer_starvation`). Reset it. |
| `port_in_use` | The sensing server already holds UDP 5005. Stop it, or capture on another port your nodes target. |
| `no_decodable_packets` | Packets arrived, but none were in a known RuView format. Check the sender. |
| `seqReordered` / `seqStrays` | Out-of-order or stray sequence numbers. These are not counted as loss. |
**ESPHome radar kits** (for example the Seeed MR60BHA2): the kit's firmware
owns the radar, so read it over the network with `--source esphome`.
- If the kit cannot join your WiFi, it starts a fallback hotspot. Join it and
set your network in its captive portal, at `http://192.168.4.1`.
- Only private-network addresses are accepted.
- Encrypted or password-protected ESPHome APIs are reported as unsupported.
### MCP for agents, ChatGPT and MCP Apps
```bash
# Claude Code, Codex or any MCP client (stdio)
claude mcp add ruview -- npx -y @ruvnet/ruview@0.9.1 mcp start
# HTTP, for ChatGPT and remote clients
export RUVIEW_MCP_TOKEN="$(node -e "console.log(require('crypto').randomBytes(24).toString('base64url'))")"
RUVIEW_MCP_GRANTS=device-access npx @ruvnet/ruview@0.9.1 mcp start --http --port 8790
```
**Connecting ChatGPT.** Clients that can send headers use
`Authorization: Bearer <token>`. ChatGPT connectors cannot, so:
1. Expose the port over HTTPS, with a tunnel or reverse proxy.
2. Add `https://<your-host>/mcp/<token>` as the connector URL.
**Least authority:**
- Hardware tools need the `device-access` grant.
- The HTTP transport binds to localhost by default.
- It refuses unknown browser origins.
- It never allows flashing or calibration, whatever grants are set.
Node captures, radar reads, device scans and doctor results render in the
**RuView console**: a widget that ChatGPT and other MCP Apps hosts show inline,
with a Refresh button that re-runs the tool.
### Claude Code mod: live sensing pane
The package ships `ruview-live`, a Claude Code mod. `/ruview` opens a pane
beside the conversation, refreshed on a timer, showing:
- your CSI nodes;
- an optional ESPHome radar;
- alerts.
It also keeps a status line such as `RuView · 2 nodes · radar present`.
Keys `1`, `2` and `3` switch between three views (ADR-378):
- **Overview**: the cards.
- **CSI waterfall**: per-subcarrier amplitude over time, labelled MEASURED or
SYNTHETIC; `n` cycles nodes.
- **Radar**: a range fan with an animated ping, plus heart, breathing and
distance charts. The values are device-reported and not validated, and the
kit reports range, not bearing.
The two live views draw in terminal cells and animate in place.
```bash
npx @ruvnet/ruview@0.9.1 mod # prints the mod's path in your install and how to load it
claude --plugin-dir "<that path>" # try it for one session
# or from the RuView marketplace, inside Claude Code:
# /plugin marketplace add ruvnet/RuView
# /plugin install ruview-live@ruview
```
| In Claude Code | Does |
|---|---|
| `/ruview` | Open or close the pane |
| `/ruview refresh` | Run one capture now; the result goes in the status line |
| `/ruview off` | Close the pane |
| `/ruview waterfall` / `/ruview radar` | Open on that view |
**Settings** (`claude plugin configure ruview-live`): `udpPort`, `radarHost`,
`refreshSeconds`, `captureSeconds` and `liveRefreshSeconds` (live views,
default 4 s).
The mod only runs the package's read-only CLI commands. Mods are early access:
if `/ruview` is missing, start Claude Code with
`CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1`.
### Homecore and the vitals kernel
```bash
npx homecore@0.1.0 guidance --topic plugins --query Wasmtime # Homecore developer metaharness
npx @ruvnet/ruview-kernel@0.1.0 selftest # vitals pipeline as WASM (SYNTHETIC self-test)
```
`esp32 --analyze` uses the kernel when it is installed. Its heart and breathing
estimates carry "no reference measurement": they show the signal path works,
not that the readings are accurate.
Design records:
- [ADR-373](adr/ADR-373-host-device-access-layer.md): device access;
- [ADR-375](adr/ADR-375-ruview-mcp-apps-console-http-transport-and-terminal-ui.md):
MCP Apps console, HTTP transport and terminal UI;
- [ADR-376](adr/ADR-376-ruview-umbrella-npm-package.md): the one-install
`ruview` package, pending the npm name;
- [ADR-377](adr/ADR-377-ruview-live-claude-code-mod.md): the Claude Code mod.
- [ADR-378](adr/ADR-378-ruview-live-showcase-views.md): the mod's CSI waterfall and radar views (keys 1/2/3, `esp32 --spectrum`).
---
## Web UI
The built-in Three.js UI is served at `http://localhost:3000/ui/` (Docker) or the configured HTTP port.
@@ -1114,7 +1342,7 @@ The Rust sensing server binary accepts the following flags:
| Flag | Default | Description |
|------|---------|-------------|
| `--source` | `auto` | Data source: `auto`, `simulate`, `wifi`, `esp32` |
| `--source` | `auto` | Data source: `auto`, `esp32`, `wifi`, `simulated` (alias `simulate`), `mediatek`, `qualcomm`, `realtek`, `realtek_csi`. Other values are rejected at startup |
| `--http-port` | `8080` | HTTP port for REST API and UI |
| `--ws-port` | `8765` | WebSocket port |
| `--udp-port` | `5005` | UDP port for ESP32 CSI frames |
@@ -1331,7 +1559,7 @@ curl -X POST http://localhost:3000/api/v1/recording/start \
curl -X POST http://localhost:3000/api/v1/recording/stop
```
Recordings are saved as JSONL files in `data/recordings/`. Filenames must start with `train_` and contain a class keyword:
Recordings are saved as JSONL files in `recordings/` under the server's `--data-dir` (default `data`, so `data/recordings/` relative to the directory the server was started from). Filenames must start with `train_` and contain a class keyword:
| Filename pattern | Class |
|-----------------|-------|
@@ -1348,7 +1576,7 @@ Train the adaptive classifier from your labeled recordings:
curl -X POST http://localhost:3000/api/v1/adaptive/train
```
The server trains a multiclass logistic regression on 15 features using mini-batch SGD (200 epochs). Training completes in under 1 second for typical recording sets. The trained model is saved to `data/adaptive_model.json` and automatically loaded on server restart.
The server trains a multiclass logistic regression on 15 features using mini-batch SGD (200 epochs). Training completes in under 1 second for typical recording sets. The trained model is saved to `adaptive_model.json` in the `--data-dir` (default `data/adaptive_model.json`) and automatically loaded on server restart.
**Check model status:**
@@ -1368,7 +1596,7 @@ Once trained, the adaptive model runs automatically:
1. Each CSI frame is classified using the learned weights instead of static thresholds
2. Model confidence is blended with smoothed threshold confidence (70/30 split)
3. The model persists across server restarts (loaded from `data/adaptive_model.json`)
3. The model persists across server restarts (loaded from `<data-dir>/adaptive_model.json`)
**Tips for better accuracy:**
@@ -1884,8 +2112,10 @@ Binary size: 990 KB (8MB flash, 52% free) or 773 KB (4MB flash). v0.5.0 adds mmW
# From source
./target/release/sensing-server --source esp32 --udp-port 5005 --http-port 3000 --ws-port 3001
# Docker (use CSI_SOURCE environment variable)
docker run -p 3000:3000 -p 3001:3001 -p 5005:5005/udp -e CSI_SOURCE=esp32 ruvnet/wifi-densepose:latest
# Docker (see "Receiving ESP32 frames in Docker" for the UDP source guard)
docker run -p 127.0.0.1:3000:3000 -p 127.0.0.1:3001:3001 -p 5005:5005/udp \
-e RUVIEW_API_TOKEN -e CSI_SOURCE=esp32 -e RUVIEW_UDP_ALLOW=<node-ip-or-cidr> \
ruvnet/wifi-densepose:latest
```
See [ADR-018](../docs/adr/ADR-018-esp32-dev-implementation.md), [ADR-029](../docs/adr/ADR-029-ruvsense-multistatic-sensing-mode.md), and [Tutorial #34](https://github.com/ruvnet/RuView/issues/34).
@@ -2214,6 +2444,8 @@ For production deployments with both Rust and Python services:
```bash
cd docker
export RUVIEW_API_TOKEN=$(openssl rand -hex 32)
export RUVIEW_UDP_ALLOW=<node-ip-or-cidr> # macOS Docker: <gateway>/32
docker compose up
```
@@ -2221,6 +2453,8 @@ This starts:
- Rust sensing server on ports 3000 (HTTP), 3001 (WS), 5005 (UDP)
- Python legacy server on ports 8080 (HTTP), 8765 (WS)
The sensing server exits with code 64 when `RUVIEW_API_TOKEN` is unset. Without `RUVIEW_UDP_ALLOW` or `RUVIEW_UDP_INSECURE_LAN=true` it starts but receives no ESP32 frames. `docker/docker-compose.yml` passes these variables through from your shell, along with `RUVIEW_UDP_BIND` and `SENSING_ALLOWED_HOSTS`. See [Receiving ESP32 frames in Docker](#receiving-esp32-frames-in-docker).
---
## Testing Firmware Without Hardware (QEMU)
+21 -2
View File
@@ -98,6 +98,13 @@ For an existing provisioned node, back up its current application and inspect
`http://DEVICE_IP:8032/ota/status` before choosing an application-only update.
Writing only offset `0x20000` is safe only when the status endpoint reports
`running_partition` as `ota_0` and the downloaded image matches the board.
After an OTA, the same endpoint's `ota_state` must read `valid` before you
power-cycle the node or push again. `pending_verify` means the image has not
passed its first-boot health check yet and would revert on reset. A passed
check means only that the node reached the network and sent CSI. See
[RUNBOOK §2.1](RUNBOOK.md) and ADR-379.
The full bundles do not include NVS, so the documented four-offset install
preserves WiFi and node configuration while replacing the boot and application
images.
@@ -161,10 +168,16 @@ board default and emits no antenna-path claim.
### 3. Provision WiFi credentials (no reflash needed)
```bash
# Keep the WiFi password in an owner-only file, not on the command line.
printf '%s\n' 'YourPass' > ~/.wifi-pass && chmod 600 ~/.wifi-pass
python firmware/esp32-csi-node/provision.py --port COM7 \
--ssid "YourSSID" --password "YourPass" --target-ip 192.168.1.20
--ssid "YourSSID" --password-file ~/.wifi-pass --target-ip 192.168.1.20
```
Leave out both `--password` and `--password-file` and the script asks for the
password when run from a terminal. `--password` still works, but it shows up in
`ps` and shell history.
### 4. Start the sensing server
```bash
@@ -440,10 +453,15 @@ The easiest way to write NVS settings:
```bash
python firmware/esp32-csi-node/provision.py --port COM7 \
--ssid "MyWiFi" \
--password "MyPassword" \
--password-file ~/.wifi-pass \
--target-ip 192.168.1.20
```
The password file must be owner-only (`chmod 600`); one trailing newline is
dropped. Without a password flag the script prompts on a terminal; in scripts
and CI it doesn't prompt and fails if no password was given now or saved
earlier for that port.
### NVS Key Reference
#### Network Settings
@@ -632,6 +650,7 @@ cargo build -p wifi-densepose-wasm-edge --target wasm32-unknown-unknown --releas
| `main/nvs_config.c` / `.h` | Runtime configuration: loads Kconfig defaults, overrides from NVS |
| `main/edge_processing.c` / `.h` | Tier 0-2 DSP pipeline: SPSC ring buffer, biquad IIR filters, Welford stats, BPM extraction, presence, fall detection |
| `main/ota_update.c` / `.h` | HTTP OTA firmware update server on port 8032 |
| `main/ota_health.c` / `.h` | First-boot health check that confirms an OTA'd image or rolls it back (ADR-379) |
| `main/power_mgmt.c` / `.h` | Battery-aware light sleep duty cycling |
| `main/wasm_runtime.c` / `.h` | WASM3 interpreter: module slots, host API bindings, budget guard, per-frame dispatch |
| `main/wasm_upload.c` / `.h` | HTTP endpoints for WASM module upload, list, start, stop, delete |
+55 -3
View File
@@ -31,9 +31,9 @@ It does **not** pick up `sdkconfig.defaults.16mb`, and that file is where
`CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y` lives.
Build without it and you get an image that is correct in every visible respect
and **cannot roll back a bad OTA** — while `ota_rollback_boot_check()` sits in
the app expecting the capability. Silent, and only discoverable by pushing a
deliberately bad image.
and **cannot roll back a bad OTA**, while `main/ota_health.c` sits in the app
expecting the capability (it compiles to a no-op without it). Silent, and only
discoverable by pushing a deliberately bad image. See §2.1.
That is why the command above concatenates all three explicitly rather than
relying on discovery.
@@ -104,6 +104,48 @@ Corollary: "every node reports health" proves the **app** is current and proves
nothing about the bootloader. Do not answer a bootloader question with app
evidence.
### 2.1 An OTA'd image must confirm itself (ADR-379)
On a rollback-capable bootloader an OTA'd image boots **`PENDING_VERIFY`**.
Until it is confirmed, the **next reset reverts to the previous slot** and
`POST /ota` refuses a second update. `main/ota_health.c` does the confirming:
- It runs only when the running image is `PENDING_VERIFY`. USB-flashed images
(the bootloader writes `VALID`), already-confirmed images, `UNDEFINED` and
`NEW` are left alone.
- It confirms once the STA has an IP **and** the stream sender has accepted at
least one CSI frame, but not before `CONFIG_OTA_HEALTH_MIN_UPTIME_S`
(default 30 s). Log: `ota_health: marked valid after ... in <ms> ms`.
- If both signals have not arrived by `CONFIG_OTA_HEALTH_TIMEOUT_S` (default
120 s from boot), it logs `health check failed: <reason>, rolling back` and
reboots into the previous slot. A crash or reset before confirmation is also
a rollback.
- A pass means only "reached the network and sent CSI". It does not show that
sensing, calibration, or any other function of the new image works.
- `GET /ota/status` reports `ota_state`. `pending_verify` means the image is
not yet durable. `valid` means it survives a power cycle. `new` after an OTA
means this board's bootloader has **no** rollback support.
Consequence for rollouts: wait for `ota_state: "valid"` before pushing again,
and before power-cycling. If the AP is down when the node boots, a good image
rolls back. That is expected; re-push it.
**Hardware check, one node.** It needs a node with the OTA PSK provisioned and
a rollback bootloader.
1. Note `version` and `running_partition` from `/ota/status`.
2. OTA-push the new image. Watch the log for `OTA image pending verify`, then
`marked valid after ... in <ms> ms`. `/ota/status` must show the new
version, the other partition, and `ota_state: "valid"`.
3. Power-cycle it. It must come back on the same version and partition with
`ota_state: "valid"`. Before ADR-379 it came back on the old one.
**Negative check.** Build with `CONFIG_OTA_HEALTH_FORCE_FAIL=y` and push it.
The log must show `health check failed: forced failure ..., rolling back` at
about `MIN_UPTIME_S`, and the node must come back on the step-2 version and
partition. Then push a normal image to restore it. Never leave a FORCE_FAIL
image in `release_bins`.
---
## 3. Deploying
@@ -113,6 +155,16 @@ evidence.
Works end-to-end and is the default for anything that is not a bootloader or
partition change. Roll to **one node**, soak it, then the rest.
**Before ADR-379, `POST /ota` never completed on this firmware.** MEASURED
2026-09-29 on node 4 (S3, v0.8.12): it overflowed the 4 KB httpd stack
(`stack overflow in task httpd`, `RTC_SW_CPU_RST`), and the node came back on
its old slot. The client sees `connection reset`. Any board still on an older
build has to be moved to a fixed one **over USB**.
The server now runs with `CONFIG_OTA_HTTPD_STACK_SIZE` (8192). Each upload
logs `httpd stack after POST /ota: N of 8192 bytes never used`. Record that
number when you verify a node, because it is the evidence the size rests on.
### Over USB — bootloader, partition table, or recovery
```bash
+1 -1
View File
@@ -2,7 +2,7 @@ set(SRCS
"main.c" "csi_collector.c" "node_log.c"
"thermal.c" "stream_sender.c" "nvs_config.c"
"serial_onboarding.c" "serial_onboarding_protocol.c"
"edge_processing.c" "ota_update.c" "power_mgmt.c"
"edge_processing.c" "ota_update.c" "ota_health.c" "power_mgmt.c"
"wasm_runtime.c" "wasm_upload.c" "rvf_parser.c"
"mmwave_sensor.c"
"swarm_bridge.c"
@@ -332,6 +332,25 @@ menu "WASM Programmable Sensing (ADR-040)"
endmenu
menu "OTA / WASM HTTP server (ADR-379)"
config OTA_HTTPD_STACK_SIZE
int "Stack size of the OTA / WASM HTTP server task (bytes)"
range 4096 16384
default 8192
help
HTTPD_DEFAULT_CONFIG() gives 4096. MEASURED 2026-09-29 on an ESP32-S3
(node 4, v0.8.12): every POST /ota overflowed it ("stack overflow in
task httpd", RTC_SW_CPU_RST), so no OTA had ever completed on this
firmware. The same task runs POST /wasm/upload (signature check plus
wasm3 module load) and GET /wasm/list.
Every OTA and WASM upload logs "httpd stack after <handler>: N of M
bytes never used". Lower this only with those numbers in hand, and keep
at least 1 KB unused on the worst handler.
endmenu
menu "ESP32-C6 capabilities (ADR-110)"
depends on IDF_TARGET_ESP32C6
@@ -674,6 +693,50 @@ config UPLINK_WATCHDOG_TIMEOUT_S
endmenu
menu "OTA health check (ADR-379)"
config OTA_HEALTH_TIMEOUT_S
int "Seconds an OTA'd image has to prove itself before rolling back"
depends on BOOTLOADER_APP_ROLLBACK_ENABLE
range 30 900
default 120
help
Only applies when the running image is ESP_OTA_IMG_PENDING_VERIFY, i.e.
it was just delivered by OTA to a node whose bootloader has
CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE. USB-flashed and already-confirmed
images are never checked.
Healthy means: the STA got an IP, and the stream sender accepted at least
one CSI frame. Both must happen within this many seconds of boot, or the
image is marked invalid and the node reboots into the previous slot.
Measured from boot, and the WiFi boot wait alone can take about two
minutes against an absent AP, so an AP outage during a rollout rolls a
good image back. That is the safe direction: re-push it later.
config OTA_HEALTH_MIN_UPTIME_S
int "Minimum uptime before an OTA'd image may be confirmed"
depends on BOOTLOADER_APP_ROLLBACK_ENABLE
range 0 600
default 30
help
Holds confirmation back for this long after boot even once both signals
are in, so an image that crashes shortly after starting its pipelines
still reverts (a crash while PENDING_VERIFY is a rollback). Clamped to
OTA_HEALTH_TIMEOUT_S.
config OTA_HEALTH_FORCE_FAIL
bool "TEST ONLY: fail the OTA health check to exercise rollback"
depends on BOOTLOADER_APP_ROLLBACK_ENABLE
default n
help
Builds an image that rolls itself back at the point it would otherwise
confirm. OTA-push it to a node to prove rollback works end to end: the
node should reboot into the previous image. Has no effect when USB
flashed (such an image is not PENDING_VERIFY). Never ship this.
endmenu
config CSI_GATE_MESH_ALIGNED
bool "Align the CSI rate gate to mesh time (enables cross-node pairing)"
default y
@@ -750,6 +750,11 @@ uint16_t csi_collector_get_send_fail_count(void)
return (f > 0xFFFFu) ? 0xFFFFu : (uint16_t)f;
}
uint32_t csi_collector_get_send_ok_count(void)
{
return s_send_ok;
}
/* The gate here is fixed at compile time (mesh-aligned bucketing, falling
* back to elapsed time when unsynced), so there is no mode or period to
* report. Return the explicit sentinel rather than a number that reads as
@@ -139,6 +139,18 @@ uint16_t csi_collector_get_pkt_yield_per_sec(void);
*/
uint16_t csi_collector_get_send_fail_count(void);
/**
* Get the cumulative count of CSI frames the stream sender accepted since boot.
*
* Nonzero means the whole capture path worked at least once: the driver
* delivered a CSI callback, the frame serialized, and sendto() accepted it.
* Used by the OTA health check (ota_health.c) as its "operational" signal.
*
* @return Number of successful stream_sender_send() calls from the CSI
* callback path.
*/
uint32_t csi_collector_get_send_ok_count(void);
/** Reported by the gate accessors when this build has no selectable gate. */
#define CSI_GATE_NOT_CONFIGURABLE 0xFF
+7
View File
@@ -28,6 +28,7 @@
#include "serial_onboarding.h"
#include "edge_processing.h"
#include "ota_update.h"
#include "ota_health.h"
#include "power_mgmt.h"
#include "wasm_runtime.h"
#include "wasm_upload.h"
@@ -142,6 +143,7 @@ static void event_handler(void *arg, esp_event_base_t event_base,
ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data;
ESP_LOGI(TAG, "Got IP: " IPSTR, IP2STR(&event->ip_info.ip));
s_retry_num = 0;
ota_health_note_got_ip();
xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT);
}
}
@@ -338,6 +340,11 @@ void app_main(void)
ESP_LOGI(TAG, "%s CSI Node (ADR-018 / ADR-110) — v%s — Node ID: %d",
target_name, app_desc->version, g_nvs_config.node_id);
/* ADR-379: an OTA'd image boots PENDING_VERIFY and reverts at the next
* reset unless it confirms itself. Start the clock before anything that
* can block (WiFi waits up to ~2 min for an AP), so the deadline is real. */
ota_health_start();
/* Apply the opt-in XIAO RF path before WiFi starts. Generic C6 boards keep
* ownership of GPIO3 and GPIO14 because the default implementation is a
* no-op unless CONFIG_C6_XIAO_ANTENNA_SELECT is enabled. */
+139
View File
@@ -0,0 +1,139 @@
/* First-boot health check for an OTA-delivered image. See ota_health.h and
* ADR-379 for why it exists; the decision itself is ota_health_step(). */
#include "ota_health.h"
#include "sdkconfig.h"
#ifdef CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_ota_ops.h"
#include "esp_timer.h"
#include "csi_collector.h"
static const char *TAG = "ota_health";
#define OTA_HEALTH_POLL_MS 1000
/* Written from the event loop task, read from the health task. A single
* aligned bool is enough; it only ever goes false -> true. */
static volatile bool s_got_ip;
static const char *state_name(esp_ota_img_states_t st)
{
switch (st) {
case ESP_OTA_IMG_NEW: return "NEW";
case ESP_OTA_IMG_PENDING_VERIFY: return "PENDING_VERIFY";
case ESP_OTA_IMG_VALID: return "VALID";
case ESP_OTA_IMG_INVALID: return "INVALID";
case ESP_OTA_IMG_ABORTED: return "ABORTED";
case ESP_OTA_IMG_UNDEFINED: return "UNDEFINED";
}
return "?";
}
static void ota_health_task(void *arg)
{
(void)arg;
const ota_health_cfg_t cfg = {
.timeout_ms = (uint32_t)CONFIG_OTA_HEALTH_TIMEOUT_S * 1000u,
.min_uptime_ms = (uint32_t)CONFIG_OTA_HEALTH_MIN_UPTIME_S * 1000u,
#ifdef CONFIG_OTA_HEALTH_FORCE_FAIL
.force_fail = true,
#else
.force_fail = false,
#endif
};
ota_health_sm_t sm;
ota_health_sm_init(&sm);
for (;;) {
uint32_t now_ms = (uint32_t)(esp_timer_get_time() / 1000);
ota_health_verdict_t v = ota_health_step(&sm, &cfg, s_got_ip,
csi_collector_get_send_ok_count() > 0,
now_ms);
if (v == OTA_HEALTH_MARK_VALID) {
esp_err_t err = esp_ota_mark_app_valid_cancel_rollback();
if (err == ESP_OK) {
ESP_LOGI(TAG, "marked valid after %s in %lu ms",
sm.reason, (unsigned long)now_ms);
} else {
ESP_LOGE(TAG, "mark valid failed: %s (image stays PENDING_VERIFY "
"and reverts on the next reset)", esp_err_to_name(err));
}
break;
}
if (v == OTA_HEALTH_ROLLBACK) {
ESP_LOGE(TAG, "health check failed: %s, rolling back (%lu ms)",
sm.reason, (unsigned long)now_ms);
/* Returns only on failure: no other bootable slot. Rebooting then
* would just loop, so stay up; the next reset aborts this image. */
esp_err_t err = esp_ota_mark_app_invalid_rollback_and_reboot();
ESP_LOGE(TAG, "rollback not possible: %s -- staying on this image",
esp_err_to_name(err));
break;
}
vTaskDelay(pdMS_TO_TICKS(OTA_HEALTH_POLL_MS));
}
vTaskDelete(NULL);
}
void ota_health_start(void)
{
const esp_partition_t *running = esp_ota_get_running_partition();
esp_ota_img_states_t st;
esp_err_t err = esp_ota_get_state_partition(running, &st);
/* ESP_ERR_NOT_SUPPORTED: running from factory. ESP_ERR_NOT_FOUND: no
* otadata entry for this slot (erased otadata). Neither can be rolled
* back, so there is nothing to confirm. */
if (err != ESP_OK) {
ESP_LOGI(TAG, "running %s has no OTA state (%s): no health check",
running ? running->label : "?", esp_err_to_name(err));
return;
}
/* VALID: USB-flashed with ota_data_initial (the bootloader writes VALID)
* or already confirmed. UNDEFINED: selected by a build without rollback;
* the bootloader boots it unconditionally. NEW: the bootloader has no
* rollback support, so it never promoted the image to PENDING_VERIFY.
* None of these revert on reset. */
if (st != ESP_OTA_IMG_PENDING_VERIFY) {
ESP_LOGI(TAG, "running %s state %s: no health check",
running->label, state_name(st));
return;
}
ESP_LOGW(TAG, "OTA image pending verify on %s: need IP + CSI frame within %d s "
"(min uptime %d s)%s",
running->label, CONFIG_OTA_HEALTH_TIMEOUT_S,
CONFIG_OTA_HEALTH_MIN_UPTIME_S,
#ifdef CONFIG_OTA_HEALTH_FORCE_FAIL
" -- FORCE_FAIL test build, will roll back"
#else
""
#endif
);
if (xTaskCreate(ota_health_task, "ota_health", 4096, NULL, 2, NULL) != pdPASS) {
/* Without the task nothing confirms the image, which is the same as
* failing the check: the next reset reverts it. Say so. */
ESP_LOGE(TAG, "health task create failed; image will revert on next reset");
}
}
void ota_health_note_got_ip(void)
{
s_got_ip = true;
}
#else /* !CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE */
/* Without bootloader rollback no image is ever PENDING_VERIFY. */
void ota_health_start(void) {}
void ota_health_note_got_ip(void) {}
#endif /* CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE */
+110
View File
@@ -0,0 +1,110 @@
/* First-boot health check for an OTA-delivered image (ADR-379).
*
* With CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE the bootloader starts a freshly
* OTA'd image in ESP_OTA_IMG_PENDING_VERIFY. Until the app confirms itself,
* the next reset reverts to the previous slot and esp_ota_begin() refuses a
* second OTA. Nothing confirmed it before this module existed, so every OTA
* on a rollback-enabled node was undone by the next power cycle.
*
* The decision logic is a pure function kept in this header, like
* thermal_next_state(), so the host test exercises the exact code the device
* runs. ota_health.c only feeds it events and a clock and acts on the verdict. */
#pragma once
#include <stdbool.h>
#include <stdint.h>
typedef enum {
OTA_HEALTH_WAIT = 0, /* keep checking */
OTA_HEALTH_MARK_VALID, /* confirm the image: esp_ota_mark_app_valid_cancel_rollback() */
OTA_HEALTH_ROLLBACK, /* esp_ota_mark_app_invalid_rollback_and_reboot() */
} ota_health_verdict_t;
typedef struct {
uint32_t timeout_ms; /* deadline, measured from boot */
uint32_t min_uptime_ms; /* earliest the image may be confirmed; clamped to timeout_ms */
bool force_fail; /* test hook: roll back where it would have confirmed */
} ota_health_cfg_t;
typedef struct {
bool got_ip; /* STA associated and holds an IPv4 address (latched) */
bool csi_delivered; /* stream sender accepted >= 1 CSI frame (latched) */
ota_health_verdict_t verdict;
const char *reason; /* set once verdict leaves WAIT; static string */
} ota_health_sm_t;
static inline void ota_health_sm_init(ota_health_sm_t *sm)
{
sm->got_ip = false;
sm->csi_delivered = false;
sm->verdict = OTA_HEALTH_WAIT;
sm->reason = "waiting";
}
/**
* Advance the health check.
*
* Pure: no globals, no hardware. Both signals latch, because each proves a
* capability the image has, and a later AP outage is not evidence against the
* image. Once the verdict leaves WAIT it is terminal: later calls return it
* unchanged, so a caller that polls after acting cannot act twice.
*
* @param sm State, initialised with ota_health_sm_init().
* @param cfg Timeout, minimum uptime, force-fail hook.
* @param got_ip Event seen since the last step: STA got an IP.
* @param csi_delivered Event seen since the last step: a CSI frame was sent.
* @param now_ms Milliseconds since boot.
* @return The verdict. sm->reason names why, for the log.
*/
static inline ota_health_verdict_t ota_health_step(ota_health_sm_t *sm,
const ota_health_cfg_t *cfg,
bool got_ip,
bool csi_delivered,
uint32_t now_ms)
{
if (sm->verdict != OTA_HEALTH_WAIT) {
return sm->verdict;
}
sm->got_ip = sm->got_ip || got_ip;
sm->csi_delivered = sm->csi_delivered || csi_delivered;
/* A minimum uptime above the timeout could never be satisfied; that would
* turn every OTA into a rollback. Clamp rather than trust the config. */
uint32_t min_up = cfg->min_uptime_ms < cfg->timeout_ms
? cfg->min_uptime_ms : cfg->timeout_ms;
if (sm->got_ip && sm->csi_delivered && now_ms >= min_up) {
if (cfg->force_fail) {
sm->verdict = OTA_HEALTH_ROLLBACK;
sm->reason = "forced failure (CONFIG_OTA_HEALTH_FORCE_FAIL)";
} else {
sm->verdict = OTA_HEALTH_MARK_VALID;
sm->reason = "Wi-Fi got IP and CSI frame delivered";
}
return sm->verdict;
}
if (now_ms >= cfg->timeout_ms) {
sm->verdict = OTA_HEALTH_ROLLBACK;
if (!sm->got_ip && !sm->csi_delivered) {
sm->reason = "timeout: no IP and no CSI frame delivered";
} else if (!sm->got_ip) {
sm->reason = "timeout: no IP";
} else {
sm->reason = "timeout: no CSI frame delivered";
}
return sm->verdict;
}
return OTA_HEALTH_WAIT;
}
/* Device side (ota_health.c). No-ops when the bootloader has no rollback. */
/** Call once, early in app_main. Starts the check only if the running image
* is ESP_OTA_IMG_PENDING_VERIFY; otherwise logs the state and returns. */
void ota_health_start(void);
/** Call from the IP_EVENT_STA_GOT_IP handler. Cheap; safe from any task. */
void ota_health_note_got_ip(void);
+62 -11
View File
@@ -17,6 +17,8 @@
#include "esp_app_desc.h"
#include "nvs_flash.h"
#include "nvs.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
static const char *TAG = "ota_update";
@@ -88,14 +90,26 @@ static esp_err_t ota_status_handler(httpd_req_t *req)
const esp_partition_t *running = esp_ota_get_running_partition();
const esp_partition_t *update = esp_ota_get_next_update_partition(NULL);
/* ADR-379: lets an operator confirm remotely that an OTA'd image left
* pending_verify (and so survives a power cycle) without a serial log. */
esp_ota_img_states_t st;
const char *ota_state = "none";
if (running && esp_ota_get_state_partition(running, &st) == ESP_OK) {
ota_state = st == ESP_OTA_IMG_VALID ? "valid" :
st == ESP_OTA_IMG_PENDING_VERIFY ? "pending_verify" :
st == ESP_OTA_IMG_NEW ? "new" :
st == ESP_OTA_IMG_UNDEFINED ? "undefined" : "other";
}
char response[512];
int len = snprintf(response, sizeof(response),
"{\"version\":\"%s\",\"date\":\"%s\",\"time\":\"%s\","
"\"running_partition\":\"%s\",\"next_partition\":\"%s\","
"\"max_size\":%lu}",
"\"ota_state\":\"%s\",\"max_size\":%lu}",
app->version, app->date, app->time,
running ? running->label : "unknown",
update ? update->label : "none",
ota_state,
(unsigned long)(update ? update->size : 0));
httpd_resp_set_type(req, "application/json");
@@ -103,10 +117,28 @@ static esp_err_t ota_status_handler(httpd_req_t *req)
return ESP_OK;
}
/* Receive chunk for POST /ota. Static, not on the httpd task stack: handlers
* run one at a time on the single httpd task, so one buffer is enough, and
* 1 KB was a quarter of the default 4 KB stack (ADR-379).
* NOT reentrant: this shared buffer is safe only because a single httpd task
* serves requests. Do not use it from another task or a multi-threaded
* httpd configuration. */
static char s_ota_rx_buf[1024];
void ota_update_log_httpd_stack(const char *what)
{
/* ESP-IDF FreeRTOS counts stack in bytes. */
ESP_LOGI(TAG, "httpd stack after %s: %u of %d bytes never used",
what, (unsigned)uxTaskGetStackHighWaterMark(NULL),
CONFIG_OTA_HTTPD_STACK_SIZE);
}
/**
* POST /ota — receive and flash firmware binary.
* POST /ota — receive and flash firmware binary. Returns ESP_OK only after the
* new slot is set as boot partition and the response is sent; the caller
* reboots.
*/
static esp_err_t ota_upload_handler(httpd_req_t *req)
static esp_err_t ota_upload_receive(httpd_req_t *req)
{
/* ADR-050: Authenticate before accepting firmware upload. */
if (!ota_check_auth(req)) {
@@ -144,12 +176,12 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
}
/* Read firmware in chunks. */
char buf[1024];
char *buf = s_ota_rx_buf;
int received = 0;
int total = 0;
while (total < req->content_len) {
received = httpd_req_recv(req, buf, sizeof(buf));
received = httpd_req_recv(req, buf, sizeof(s_ota_rx_buf));
if (received <= 0) {
if (received == HTTPD_SOCK_ERR_TIMEOUT) {
continue; /* Retry on timeout. */
@@ -173,9 +205,11 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
total += received;
if ((total % (64 * 1024)) == 0) {
ESP_LOGI(TAG, "OTA progress: %d / %d bytes (%.0f%%)",
/* Integer percent: %f pulls newlib's float formatter onto this
* task's stack for no benefit. */
ESP_LOGI(TAG, "OTA progress: %d / %d bytes (%d%%)",
total, req->content_len,
(float)total * 100.0f / (float)req->content_len);
(int)((int64_t)total * 100 / req->content_len));
}
}
@@ -201,12 +235,24 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
const char *resp = "{\"status\":\"ok\",\"message\":\"OTA update successful. Rebooting...\"}";
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, resp, strlen(resp));
return ESP_OK;
}
/* Delay briefly to let the response flush, then reboot. */
vTaskDelay(pdMS_TO_TICKS(1000));
esp_restart();
static esp_err_t ota_upload_handler(httpd_req_t *req)
{
esp_err_t ret = ota_upload_receive(req);
return ESP_OK; /* Never reached. */
/* The deepest path this server runs is esp_ota_end() -> esp_image_verify().
* Log the headroom it left so the stack size is set from evidence, not
* guessed a second time. */
ota_update_log_httpd_stack("POST /ota");
if (ret == ESP_OK) {
/* Delay briefly to let the response flush, then reboot. */
vTaskDelay(pdMS_TO_TICKS(1000));
esp_restart();
}
return ret;
}
/** Internal: start the HTTP server and register OTA endpoints. */
@@ -214,6 +260,11 @@ static esp_err_t ota_start_server(httpd_handle_t *out_handle)
{
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
config.server_port = OTA_PORT;
/* HTTPD_DEFAULT_CONFIG gives 4096 B. MEASURED 2026-09-29 on an S3 (node 4,
* v0.8.12): POST /ota overflowed it every time ("stack overflow in task
* httpd", RTC_SW_CPU_RST), so no OTA had ever completed on this firmware.
* See CONFIG_OTA_HTTPD_STACK_SIZE for the sizing. */
config.stack_size = CONFIG_OTA_HTTPD_STACK_SIZE;
config.max_uri_handlers = 12; /* Extra slots for WASM endpoints (ADR-040). */
/* Increase receive timeout for large uploads. */
config.recv_wait_timeout = 30;
@@ -30,4 +30,13 @@ esp_err_t ota_update_init(void);
*/
esp_err_t ota_update_init_ex(void **out_server);
/**
* Log the httpd task's stack high-water mark (bytes never used). Call from an
* HTTP handler on the OTA server after its deepest work, so the configured
* CONFIG_OTA_HTTPD_STACK_SIZE is backed by a measured margin.
*
* @param what Short label for the handler, e.g. "POST /ota".
*/
void ota_update_log_httpd_stack(const char *what);
#endif /* OTA_UPDATE_H */
+25 -7
View File
@@ -24,7 +24,9 @@
#include "wasm_runtime.h"
#include "rvf_parser.h"
#include "nvs_config.h"
#include "ota_update.h"
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include "esp_log.h"
@@ -82,7 +84,7 @@ static uint8_t *receive_body(httpd_req_t *req, int *out_len)
* POST /wasm/upload — Upload RVF or raw .wasm
* ====================================================================== */
static esp_err_t wasm_upload_handler(httpd_req_t *req)
static esp_err_t wasm_upload_process(httpd_req_t *req)
{
int total = 0;
uint8_t *buf = receive_body(req, &total);
@@ -215,6 +217,15 @@ static esp_err_t wasm_upload_handler(httpd_req_t *req)
(void)format;
}
/* Signature check and wasm3 module load run on the shared httpd task; log
* the headroom they left (ADR-379, CONFIG_OTA_HTTPD_STACK_SIZE). */
static esp_err_t wasm_upload_handler(httpd_req_t *req)
{
esp_err_t ret = wasm_upload_process(req);
ota_update_log_httpd_stack("POST /wasm/upload");
return ret;
}
/* ======================================================================
* GET /wasm/list — List module slots
* ====================================================================== */
@@ -237,18 +248,24 @@ static esp_err_t wasm_list_handler(httpd_req_t *req)
uint8_t count = 0;
wasm_runtime_get_info(info, &count);
/* Build JSON array (larger buffer for manifest fields). */
char response[2048];
/* Build JSON array (larger buffer for manifest fields). Heap, not stack:
* 2 KB was half the httpd task's former 4 KB stack. */
const size_t resp_size = 2048;
char *response = malloc(resp_size);
if (response == NULL) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Out of memory");
return ESP_FAIL;
}
int pos = 0;
pos += snprintf(response + pos, sizeof(response) - pos,
pos += snprintf(response + pos, resp_size - pos,
"{\"modules\":[");
for (uint8_t i = 0; i < WASM_MAX_MODULES; i++) {
if (i > 0) pos += snprintf(response + pos, sizeof(response) - pos, ",");
if (i > 0) pos += snprintf(response + pos, resp_size - pos, ",");
uint32_t mean_us = (info[i].frame_count > 0)
? (info[i].total_us / info[i].frame_count) : 0;
const char *name = info[i].module_name[0] ? info[i].module_name : "";
pos += snprintf(response + pos, sizeof(response) - pos,
pos += snprintf(response + pos, resp_size - pos,
"{\"id\":%u,\"state\":\"%s\",\"name\":\"%s\","
"\"binary_size\":%lu,\"caps\":\"0x%04lx\","
"\"frame_count\":%lu,\"event_count\":%lu,\"error_count\":%lu,"
@@ -266,11 +283,12 @@ static esp_err_t wasm_list_handler(httpd_req_t *req)
(unsigned long)info[i].budget_faults);
}
pos += snprintf(response + pos, sizeof(response) - pos,
pos += snprintf(response + pos, resp_size - pos,
"],\"loaded\":%u,\"max\":%d}", count, WASM_MAX_MODULES);
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, response, pos);
free(response);
return ESP_OK;
}
+369 -31
View File
@@ -11,7 +11,7 @@ Usage:
--password "..." --target-ip 192.168.1.20
Requirements:
pip install 'esptool>=5.0' nvs-partition-gen
pip install 'esptool>=5.0' esp-idf-nvs-partition-gen
(or use the nvs_partition_gen.py bundled with ESP-IDF)
ADDITIVE-BY-DEFAULT (issue #391, #574 phase 1):
@@ -28,6 +28,11 @@ ADDITIVE-BY-DEFAULT (issue #391, #574 phase 1):
3. Generate + flash NVS from the merged state.
4. Write the merged state back to the state file.
State is keyed by the board's MAC, read with esptool, so a board plugged
into a port another board used doesn't inherit its node_id (#1755).
Port-keyed files from earlier versions move to the board's record the
first time that board is provisioned.
Net effect: partial reconfigure works the way users expect. Pass `--reset`
to wipe both the state file AND the device NVS for first-time provisioning
of a recycled board.
@@ -41,9 +46,12 @@ ADDITIVE-BY-DEFAULT (issue #391, #574 phase 1):
import argparse
import csv
import getpass
import io
import json
import os
import stat
import re
import struct
import subprocess
import sys
@@ -129,6 +137,129 @@ def _state_path_for(port: str, state_dir: str) -> str:
return os.path.join(state_dir, f"{safe}.json")
# State files hold the WiFi password and seed token in cleartext (#1754), so the
# directory is owner-only and every file in it is 0600.
STATE_DIR_MODE = 0o700
STATE_FILE_MODE = 0o600
# Values `--state` hides unless `--show-secrets` is passed (#1754).
SECRET_ATTRS = ("password", "seed_token")
def _restrict_mode(path: str, mode: int) -> None:
"""chmod `path` to `mode` if it is a real file or dir owned by this user.
Never raises: a read-only or unusual filesystem must not block provisioning,
but a secret left readable by others is reported. Symlinks are not followed,
so a planted link can't redirect the chmod.
"""
try:
st = os.lstat(path)
except FileNotFoundError:
return
except OSError as exc:
print(f"WARNING: could not stat {path}: {exc}", file=sys.stderr)
return
if stat.S_ISLNK(st.st_mode):
print(f"WARNING: not changing permissions through symlink {path}", file=sys.stderr)
return
if hasattr(os, "getuid") and st.st_uid != os.getuid():
print(f"WARNING: {path} is not owned by you; permissions left unchanged",
file=sys.stderr)
return
if stat.S_IMODE(st.st_mode) == mode:
return
try:
os.chmod(path, mode)
except OSError as exc:
print(f"WARNING: could not set {oct(mode)} on {path}: {exc}", file=sys.stderr)
def harden_state_dir(state_dir: str) -> None:
"""Make the state dir 0700 and its state/temp files 0600.
Covers files written by earlier versions of this script, which used the
default umask (0755 dir, 0644 files). POSIX only: chmod can't set
owner-only ACLs on Windows.
"""
if sys.platform == "win32" or not os.path.isdir(state_dir):
return
_restrict_mode(state_dir, STATE_DIR_MODE)
try:
names = os.listdir(state_dir)
except OSError:
return
for name in names:
if name.endswith((".json", ".tmp")):
_restrict_mode(os.path.join(state_dir, name), STATE_FILE_MODE)
def _write_private(path: str, data: bytes) -> None:
"""Write a credential-bearing file (state, NVS CSV or binary) as 0600."""
flags = (os.O_WRONLY | os.O_CREAT | os.O_TRUNC
| getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_BINARY", 0))
try:
fd = os.open(path, flags, STATE_FILE_MODE)
except OSError as exc:
# O_NOFOLLOW makes a pre-existing symlink fail here (ELOOP).
if os.path.islink(path):
raise SystemExit(
f"ERROR: refusing to write credentials through a symlink: {path}. "
f"Remove it and rerun.") from exc
raise
with os.fdopen(fd, "wb") as f:
# O_CREAT ignores the mode when the file already exists.
if hasattr(os, "fchmod"):
os.fchmod(f.fileno(), STATE_FILE_MODE)
f.write(data)
def redact_secrets(state: dict) -> dict:
"""Copy of `state` with secret values replaced by a fixed marker."""
shown = dict(state)
for name in SECRET_ATTRS:
if shown.get(name) is not None:
shown[name] = "(set)" if shown[name] else "(empty)"
return shown
def read_password_file(path: str, allow_insecure: bool = False) -> str:
"""Read the WiFi password from `path`, dropping one trailing newline.
Keeps the password out of argv and shell history (#1754). Refuses a file
that group or others can read unless `allow_insecure` is set. Raises
ValueError with a message meant for the user.
"""
try:
with open(path, "rb") as f:
mode = stat.S_IMODE(os.fstat(f.fileno()).st_mode)
data = f.read()
except FileNotFoundError:
raise ValueError(f"--password-file {path} does not exist") from None
except OSError as exc:
raise ValueError(f"could not read --password-file {path}: {exc}") from None
if sys.platform != "win32" and mode & 0o044:
if not allow_insecure:
raise ValueError(
f"--password-file {path} is readable by group or others "
f"(mode {oct(mode)}). Run 'chmod 600 {path}', or pass "
f"--allow-insecure-password-file."
)
print(f"WARNING: --password-file {path} is readable by group or others "
f"(mode {oct(mode)}).", file=sys.stderr)
try:
text = data.decode("utf-8")
except UnicodeDecodeError:
raise ValueError(f"--password-file {path} is not UTF-8 text") from None
if text.endswith("\r\n"):
text = text[:-2]
elif text.endswith("\n"):
text = text[:-1]
if not text:
raise ValueError(f"--password-file {path} is empty")
return text
def load_state(port: str, state_dir: str) -> dict:
"""Return the merged-state dict for `port`, or `{}` if absent / unreadable."""
path = _state_path_for(port, state_dir)
@@ -145,15 +276,140 @@ def load_state(port: str, state_dir: str) -> dict:
def save_state(port: str, state_dir: str, state: dict) -> str:
"""Write `state` to the per-port file, creating dirs as needed. Returns path."""
os.makedirs(state_dir, exist_ok=True)
"""Write `state` to the per-port file, creating dirs as needed. Returns path.
The file holds secrets, so it is written 0600 inside a 0700 dir (#1754).
"""
os.makedirs(state_dir, mode=STATE_DIR_MODE, exist_ok=True)
harden_state_dir(state_dir)
path = _state_path_for(port, state_dir)
# Sort keys for deterministic on-disk content (easier to diff).
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(state, f, indent=2, sort_keys=True)
f.write("\n")
os.replace(tmp, path)
# mkstemp picks a unique name and opens it O_EXCL with mode 0600, so a
# stale temp file or a planted symlink can't receive the secret.
fd, tmp = tempfile.mkstemp(
dir=state_dir, prefix=os.path.basename(path) + ".", suffix=".tmp"
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
if hasattr(os, "fchmod"):
os.fchmod(f.fileno(), STATE_FILE_MODE)
# Sort keys for deterministic on-disk content (easier to diff).
json.dump(state, f, indent=2, sort_keys=True)
f.write("\n")
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
return path
# ---------------------------------------------------------------------------
# Board identity (#1755)
# ---------------------------------------------------------------------------
#
# Serial port names get reused when boards are swapped, so a port-keyed state
# file hands one board's node_id to the next. State is keyed by the chip's
# base MAC instead. Each record also stores the MAC and the port it was last
# written from, so --state can find it without opening the port.
STATE_MAC_KEY = "_chip_mac"
STATE_PORT_KEY = "_port"
_ESPTOOL_MAC_RE = re.compile(
r"^\s*(BASE MAC|MAC):\s*((?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2})\s*$", re.MULTILINE
)
def normalize_mac(value: str):
"""Return `value` as lowercase aa:bb:cc:dd:ee:ff, or None if it isn't a MAC."""
parts = re.split(r"[:-]", value.strip())
if len(parts) != 6 or not all(re.fullmatch(r"[0-9a-fA-F]{2}", p) for p in parts):
return None
return ":".join(p.lower() for p in parts)
def parse_esptool_mac(output: str):
"""Return the base MAC from `esptool read_mac` output, or None.
Most chips print one 6-byte "MAC:" line. EUI-64 chips (C5, C6, H2) print an
8-byte "MAC:" line, then "BASE MAC:" with the 6-byte address.
"""
found = {label: mac.lower() for label, mac in _ESPTOOL_MAC_RE.findall(output)}
return found.get("BASE MAC") or found.get("MAC")
def read_chip_mac(port: str, baud: int, chip: str):
"""Read the connected board's base MAC with esptool. None if it can't."""
cmd = [
sys.executable, "-m", "esptool",
"--chip", chip,
"--port", port,
"--baud", str(baud),
"read_mac",
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
except (OSError, subprocess.SubprocessError):
return None
if result.returncode != 0:
return None
return parse_esptool_mac(result.stdout)
def mac_state_key(mac: str) -> str:
"""State-file key for a board, e.g. mac-aabbccddeeff."""
return "mac-" + mac.replace(":", "")
def boards_last_on_port(port: str, state_dir: str) -> list:
"""MACs of boards whose state was last written from `port`, newest first."""
if not os.path.isdir(state_dir):
return []
found = []
for name in os.listdir(state_dir):
if not (name.startswith("mac-") and name.endswith(".json")):
continue
data = load_state(name[:-len(".json")], state_dir)
mac = data.get(STATE_MAC_KEY)
if data.get(STATE_PORT_KEY) == port and mac:
found.append((os.path.getmtime(os.path.join(state_dir, name)), mac))
return [mac for _, mac in sorted(found, reverse=True)]
def load_board_state(port: str, mac, state_dir: str):
"""Return (prior state, legacy path) for the board `mac` on `port`.
Without a MAC this is the old port-keyed lookup. With one, the board's own
record wins. If it has none but a port-keyed file from an earlier version
exists, that file is returned for migration along with its path.
"""
if mac is None:
return load_state(port, state_dir), None
prior = load_state(mac_state_key(mac), state_dir)
if prior:
return prior, None
legacy = _state_path_for(port, state_dir)
if os.path.isfile(legacy):
return load_state(port, state_dir), legacy
return {}, None
def save_board_state(port: str, mac, state_dir: str, state: dict, legacy=None) -> str:
"""Persist `state` under the board's MAC (or the port if the MAC is unknown).
A migrated port-keyed file is removed afterwards, so no other board on that
port can pick it up.
"""
if mac is None:
return save_state(port, state_dir, state)
state = dict(state)
state[STATE_MAC_KEY] = mac
state[STATE_PORT_KEY] = port
path = save_state(mac_state_key(mac), state_dir, state)
if legacy and os.path.isfile(legacy):
os.unlink(legacy)
return path
@@ -239,11 +495,12 @@ def build_nvs_csv(args):
def generate_nvs_binary(csv_content, size):
"""Generate an NVS partition binary from CSV using nvs_partition_gen.py."""
with tempfile.NamedTemporaryFile(mode="w", suffix=".csv", delete=False) as f_csv:
f_csv.write(csv_content)
csv_path = f_csv.name
bin_path = csv_path.replace(".csv", ".bin")
# Both files carry the WiFi password. A private 0700 dir keeps the
# generator's output (written with the default umask) away from other users.
work_dir = tempfile.mkdtemp(prefix="provision-nvs-")
csv_path = os.path.join(work_dir, "nvs.csv")
bin_path = os.path.join(work_dir, "nvs.bin")
_write_private(csv_path, csv_content.encode("utf-8"))
try:
# Method 1: subprocess invocation (most reliable across package versions)
@@ -282,6 +539,10 @@ def generate_nvs_binary(csv_content, size):
for p in (csv_path, bin_path):
if os.path.isfile(p):
os.unlink(p)
try:
os.rmdir(work_dir)
except OSError:
pass
def flash_nvs(port, baud, nvs_bin, chip):
@@ -324,7 +585,16 @@ def main():
)
parser.add_argument("--baud", type=int, default=460800, help="Flash baud rate (default: 460800)")
parser.add_argument("--ssid", help="WiFi SSID")
parser.add_argument("--password", help="WiFi password")
password_source = parser.add_mutually_exclusive_group()
password_source.add_argument("--password",
help="WiFi password. Visible in ps and shell history; "
"prefer --password-file.")
password_source.add_argument("--password-file", metavar="PATH",
help="Read the WiFi password from PATH (one trailing newline "
"is dropped). The file must not be readable by group or others.")
parser.add_argument("--allow-insecure-password-file", action="store_true",
help="Accept a --password-file that group or others can read, with a "
"warning (e.g. a read-only 0444 secrets mount).")
parser.add_argument("--target-ip", help="Aggregator host IP (e.g. 192.168.1.20)")
parser.add_argument("--target-port", type=int, help="Aggregator UDP port (default: 5005)")
parser.add_argument("--node-id", type=int, help="Node ID 0-255 (default: 1)")
@@ -360,30 +630,100 @@ def main():
"error when no prior state file exists. The script now merges "
"with prior state by default, so this flag is rarely needed.")
parser.add_argument("--reset", action="store_true",
help="Wipe this machine's per-port state file before merging. "
help="Ignore this machine's per-port state file when merging; it is "
"replaced after a successful flash. "
"Use for first-time provisioning of a recycled board where "
"previously-staged keys should NOT be re-applied.")
parser.add_argument("--state-dir", default=_default_state_dir(),
help="Override the per-user state directory (default: per-OS user config dir).")
parser.add_argument("--mac", type=str,
help="Board MAC (AA:BB:CC:DD:EE:FF) whose state to use. Normally read "
"from the chip with esptool; --state and --dry-run never open the "
"port, so pass it there to pick a board.")
parser.add_argument("--state", action="store_true",
help="Print the merged state that WOULD be flashed for this port and exit. "
"Useful for debugging which keys are about to land on the device.")
"Useful for debugging which keys are about to land on the device. "
"The WiFi password and seed token are shown as (set)/(empty).")
parser.add_argument("--show-secrets", action="store_true",
help="With --state, print the WiFi password and seed token in clear.")
args = parser.parse_args()
# State written by older versions may be 0644. Tighten it before any read
# or early exit (#1754).
harden_state_dir(args.state_dir)
if args.password_file is not None:
try:
args.password = read_password_file(
args.password_file, args.allow_insecure_password_file)
except ValueError as exc:
parser.error(str(exc))
elif args.password is not None:
print("WARNING: --password is visible in ps and shell history; "
"use --password-file instead.", file=sys.stderr)
cli_ssid, cli_password = args.ssid, args.password
# --- Board identity (#1755) ---
# State is keyed by the chip's MAC so a board swapped onto a reused port
# doesn't inherit the previous board's node_id.
if args.mac is not None:
chip_mac = normalize_mac(args.mac)
if chip_mac is None:
parser.error(f"--mac must be in AA:BB:CC:DD:EE:FF format, got '{args.mac}'")
elif args.state:
# Inspection only: don't open the port; show the board last
# provisioned from it.
recent = boards_last_on_port(args.port, args.state_dir)
chip_mac = recent[0] if recent else None
if chip_mac:
print(f"Showing board {chip_mac}, last provisioned on {args.port}. "
f"Pass --mac to pick another.", file=sys.stderr)
elif args.dry_run:
# No board involved; keep the port-keyed state as before.
chip_mac = None
else:
chip_mac = read_chip_mac(args.port, args.baud, args.chip)
if chip_mac:
print(f"Board MAC: {chip_mac}")
else:
print(f"WARNING: could not read the board's MAC with esptool, so state "
f"stays keyed by port {args.port}. Pass --mac to key it by board.",
file=sys.stderr)
legacy = None
# --- Per-port state load + merge (additive-by-default, #391 / #574) ---
if args.reset:
# Don't delete the state file here: validation below can still fail,
# and a failed run must not lose the record. A successful flash
# overwrites it with the reset (CLI-only) state.
path = _state_path_for(args.port, args.state_dir)
if os.path.isfile(path):
os.unlink(path)
print(f"--reset: removed state file {path}", file=sys.stderr)
print(f"--reset: ignoring state file {path}", file=sys.stderr)
prior = {}
else:
prior = load_state(args.port, args.state_dir)
prior, legacy = load_board_state(args.port, chip_mac, args.state_dir)
if legacy:
print(f"Using port-keyed state {legacy} for board {chip_mac}; it moves to "
f"the board's own record when state is next saved. If this isn't the "
f"board last provisioned on {args.port}, rerun with --reset.",
file=sys.stderr)
merged = merge_state_into_args(args, prior)
# A new --ssid with no password given: ask for it on a terminal instead of
# requiring it on the command line. The saved password is reused only when
# it belongs to the same SSID. Without a terminal (scripts, CI) nothing is
# asked and the WiFi-credential check below applies as before.
if (not args.state and cli_ssid is not None and cli_password is None
and (prior.get("password") is None or prior.get("ssid") != cli_ssid)
and sys.stdin.isatty()):
args.password = getpass.getpass(f"WiFi password for {cli_ssid}: ")
merged["password"] = args.password
if args.state:
print(json.dumps(merged, indent=2, sort_keys=True))
shown = merged if args.show_secrets else redact_secrets(merged)
print(json.dumps(shown, indent=2, sort_keys=True))
if shown != merged:
print("Secrets hidden; pass --show-secrets to print them.", file=sys.stderr)
return
if not has_config_value(args):
@@ -403,11 +743,12 @@ def main():
] if val is None or val == ""
]
if wifi_trio_missing and not args.force_partial:
state_key = mac_state_key(chip_mac) if chip_mac else args.port
parser.error(
f"Missing required WiFi credentials after merging prior state: "
f"{', '.join(wifi_trio_missing)}.\n"
f"\n"
f" No per-port state file at {_state_path_for(args.port, args.state_dir)}\n"
f" No saved state at {_state_path_for(state_key, args.state_dir)}\n"
f" and the CLI didn't include them. Either pass --ssid + --password + --target-ip\n"
f" on this run, or add --force-partial to flash without WiFi.\n"
)
@@ -487,8 +828,7 @@ def main():
print(f"\nError generating NVS binary: {e}", file=sys.stderr)
print("\nFallback: save CSV and flash manually with ESP-IDF tools.", file=sys.stderr)
fallback_path = "nvs_config.csv"
with open(fallback_path, "w") as f:
f.write(csv_content)
_write_private(fallback_path, csv_content.encode("utf-8"))
print(f"Saved NVS CSV to {fallback_path}", file=sys.stderr)
print(f"Flash with: python $IDF_PATH/components/nvs_flash/"
f"nvs_partition_generator/nvs_partition_gen.py generate "
@@ -497,22 +837,20 @@ def main():
if args.dry_run:
out = "nvs_provision.bin"
with open(out, "wb") as f:
f.write(nvs_bin)
_write_private(out, nvs_bin)
print(f"NVS binary saved to {out} ({len(nvs_bin)} bytes)")
print(f"Flash manually: python -m esptool --chip {args.chip} --port {args.port} "
f"write_flash 0x9000 {out}")
# Persist merged state even on dry-run so a subsequent real flash from
# this machine sees the same staged config.
path = save_state(args.port, args.state_dir, merged)
print(f"State persisted to {path}")
# Don't persist state on dry-run: nothing reached the device, so the
# next real run must not merge on top of values that were never flashed.
print("Dry run: state file not updated.")
return
flash_nvs(args.port, args.baud, nvs_bin, args.chip)
# Persist merged state after a successful flash so future partial
# invocations from this machine merge on top of what's actually on the
# device. This is the heart of the additive-by-default fix (#391/#574).
path = save_state(args.port, args.state_dir, merged)
path = save_board_state(args.port, chip_mac, args.state_dir, merged, legacy)
print(f"State persisted to {path}")
@@ -18,8 +18,8 @@ CONFIG_PARTITION_TABLE_CUSTOM=y
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions_16mb.csv"
# Firmware rollback: an OTA'd image boots PENDING_VERIFY and must confirm
# itself, or the bootloader reverts on the next boot. See ota_rollback_* in
# ota_update.c for when confirmation happens and why.
# itself, or the bootloader reverts on the next boot. main/ota_health.c does
# the confirming (IP + one CSI frame within OTA_HEALTH_TIMEOUT_S), ADR-379.
CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y
# Coredump to flash. The 64K `coredump` partition has existed in
+16 -5
View File
@@ -56,10 +56,11 @@ FUZZ_JOBS ?= 1
test_vitals run_vitals test_mmwave_detect run_mmwave_detect host_tests \
test_thermal run_thermal test_csi_sanitize run_csi_sanitize \
test_c6_antenna run_c6_antenna test_serial_onboarding run_serial_onboarding \
run_delivery_contract
run_delivery_contract test_ota_health run_ota_health
all: fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect \
test_thermal test_csi_sanitize test_c6_antenna test_serial_onboarding
test_thermal test_csi_sanitize test_c6_antenna test_serial_onboarding \
test_ota_health
# --- XIAO ESP32-C6 RF switch selection ---
# Host-side truth table for the GPIO14 antenna selector. GPIO3 is always driven
@@ -133,12 +134,21 @@ test_serial_onboarding: test_serial_onboarding_protocol.c $(MAIN_DIR)/serial_onb
run_serial_onboarding: test_serial_onboarding
./test_serial_onboarding
# --- OTA first-boot health check (ADR-379) ---
# Host-side. Exercises ota_health_step() from ../main/ota_health.h directly --
# the decision the device runs to confirm or roll back an OTA'd image.
test_ota_health: test_ota_health.c $(MAIN_DIR)/ota_health.h
cc -std=c99 -Wall -Wextra -Werror -I$(MAIN_DIR) -o $@ $<
run_ota_health: test_ota_health
./test_ota_health
run_delivery_contract:
python3 test_delivery_contract.py
host_tests: run_adr110 run_vitals run_mmwave_detect run_thermal run_csi_sanitize \
run_c6_antenna run_serial_onboarding run_delivery_contract
@echo "Host tests passed (ADR-110 + CSI sanitation + vitals + mmwave + thermal + C6 antenna + USB onboarding + delivery contract)"
run_c6_antenna run_serial_onboarding run_delivery_contract run_ota_health
@echo "Host tests passed (ADR-110 + CSI sanitation + vitals + mmwave + thermal + C6 antenna + USB onboarding + delivery contract + OTA health)"
# --- Serialize fuzzer ---
# Tests csi_serialize_frame() with random wifi_csi_info_t inputs.
@@ -175,5 +185,6 @@ run_all: run_serialize run_edge run_nvs
clean:
rm -f fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect \
test_thermal test_csi_sanitize test_c6_antenna test_serial_onboarding
test_thermal test_csi_sanitize test_c6_antenna test_serial_onboarding \
test_ota_health
rm -rf corpus_serialize/ corpus_edge/ corpus_nvs/
@@ -0,0 +1,148 @@
/**
* @file test_ota_health.c
* @brief Pins the OTA first-boot health check decision (ADR-379).
*
* Exercises `ota_health_step()` from ../main/ota_health.h directly -- the same
* function the device build runs, not a copy -- so the test and the firmware
* cannot drift apart.
*
* The regression that motivated this: nothing ever called
* esp_ota_mark_app_valid_cancel_rollback(), so on rollback-enabled nodes every
* OTA'd image reverted at the next reset, and a bad image was never caught.
*
* Config under test: timeout 120 s, minimum uptime 30 s.
*/
#include <stdio.h>
#include <string.h>
#include "ota_health.h"
#define TIMEOUT_MS 120000u
#define MIN_UP_MS 30000u
static int g_failures = 0;
static const char *name(ota_health_verdict_t v)
{
switch (v) {
case OTA_HEALTH_WAIT: return "WAIT";
case OTA_HEALTH_MARK_VALID: return "MARK_VALID";
case OTA_HEALTH_ROLLBACK: return "ROLLBACK";
}
return "?";
}
static void expect(ota_health_verdict_t got, ota_health_verdict_t want,
const char *why)
{
if (got != want) {
printf(" FAIL: got %s, expected %s\n %s\n", name(got), name(want), why);
g_failures++;
}
}
static void expect_reason(const ota_health_sm_t *sm, const char *needle,
const char *why)
{
if (strstr(sm->reason, needle) == NULL) {
printf(" FAIL: reason \"%s\" lacks \"%s\"\n %s\n",
sm->reason, needle, why);
g_failures++;
}
}
static const ota_health_cfg_t CFG = { TIMEOUT_MS, MIN_UP_MS, false };
int main(void)
{
ota_health_sm_t sm;
printf("ota health check (timeout %u ms, min uptime %u ms)\n",
TIMEOUT_MS, MIN_UP_MS);
/* Healthy boot: IP at 4 s, first CSI frame at 6 s, confirmed at 30 s. */
ota_health_sm_init(&sm);
expect(ota_health_step(&sm, &CFG, false, false, 1000), OTA_HEALTH_WAIT,
"nothing yet, well inside the deadline");
expect(ota_health_step(&sm, &CFG, true, false, 4000), OTA_HEALTH_WAIT,
"IP alone is not enough");
expect(ota_health_step(&sm, &CFG, false, true, 6000), OTA_HEALTH_WAIT,
"both signals in, but below the minimum uptime");
expect(ota_health_step(&sm, &CFG, false, false, 29999), OTA_HEALTH_WAIT,
"one ms short of the minimum uptime");
expect(ota_health_step(&sm, &CFG, false, false, 30000), OTA_HEALTH_MARK_VALID,
"latched IP + CSI at the minimum uptime must confirm the image");
expect_reason(&sm, "IP", "the log names the signals that passed");
/* Terminal: a verdict never changes, even past the deadline. */
expect(ota_health_step(&sm, &CFG, false, false, 500000), OTA_HEALTH_MARK_VALID,
"a confirmed image must not later be rolled back by the same check");
/* Signals arriving after the minimum uptime confirm immediately. */
ota_health_sm_init(&sm);
expect(ota_health_step(&sm, &CFG, true, true, 45000), OTA_HEALTH_MARK_VALID,
"both signals after min uptime -> confirm now");
/* No IP by the deadline. */
ota_health_sm_init(&sm);
expect(ota_health_step(&sm, &CFG, false, false, 119999), OTA_HEALTH_WAIT,
"one ms before the deadline");
expect(ota_health_step(&sm, &CFG, false, false, 120000), OTA_HEALTH_ROLLBACK,
"no signals at the deadline -> roll back");
expect_reason(&sm, "no IP and no CSI", "both missing");
/* IP but the CSI pipeline never delivers. */
ota_health_sm_init(&sm);
ota_health_step(&sm, &CFG, true, false, 5000);
expect(ota_health_step(&sm, &CFG, false, false, 120000), OTA_HEALTH_ROLLBACK,
"associated but no CSI frame -> roll back");
expect_reason(&sm, "no CSI frame", "names the missing CSI signal");
/* CSI sent (can happen only with IP in practice) but no IP event seen. */
ota_health_sm_init(&sm);
ota_health_step(&sm, &CFG, false, true, 5000);
expect(ota_health_step(&sm, &CFG, false, false, 130000), OTA_HEALTH_ROLLBACK,
"no IP -> roll back");
expect_reason(&sm, "no IP", "names the missing IP signal");
/* Rollback is terminal too. */
expect(ota_health_step(&sm, &CFG, true, true, 131000), OTA_HEALTH_ROLLBACK,
"late signals must not resurrect a rolled-back verdict");
/* Force-fail hook rolls back exactly where it would have confirmed. */
{
const ota_health_cfg_t ff = { TIMEOUT_MS, MIN_UP_MS, true };
ota_health_sm_init(&sm);
expect(ota_health_step(&sm, &ff, true, true, 10000), OTA_HEALTH_WAIT,
"force-fail still waits for the minimum uptime");
expect(ota_health_step(&sm, &ff, false, false, 30000), OTA_HEALTH_ROLLBACK,
"force-fail must roll back instead of confirming");
expect_reason(&sm, "FORCE_FAIL", "names the test hook");
}
/* Minimum uptime above the timeout is clamped, not a guaranteed rollback. */
{
const ota_health_cfg_t bad = { 60000u, 600000u, false };
ota_health_sm_init(&sm);
expect(ota_health_step(&sm, &bad, true, true, 59999), OTA_HEALTH_WAIT,
"clamped min uptime: still waiting just before it");
expect(ota_health_step(&sm, &bad, false, false, 60000), OTA_HEALTH_MARK_VALID,
"healthy at the deadline must confirm, not roll back");
}
/* Zero minimum uptime confirms on the first step with both signals. */
{
const ota_health_cfg_t zero = { TIMEOUT_MS, 0u, false };
ota_health_sm_init(&sm);
expect(ota_health_step(&sm, &zero, true, true, 0), OTA_HEALTH_MARK_VALID,
"no soak configured -> confirm immediately");
}
if (g_failures) {
printf("FAILED: %d case(s)\n", g_failures);
return 1;
}
printf("OK: all OTA health cases pass\n");
return 0;
}
@@ -0,0 +1,133 @@
"""Side-effect tests for provision.py --dry-run and --reset (#2096).
main() runs in-process with the NVS generator and the flasher stubbed out, so
no serial port is opened and no ESP-IDF tooling is needed. State lives in a
per-test temp dir passed via --state-dir.
"""
import contextlib
import importlib.util
import io
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
PROVISION_PATH = Path(__file__).resolve().parents[1] / "provision.py"
SPEC = importlib.util.spec_from_file_location("provision", PROVISION_PATH)
provision = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(provision)
PORT = "/dev/null-test"
FAKE_NVS = b"\xab" * 64
PRIOR = {
"ssid": "TESTSSID",
"password": "TESTPASS",
"target_ip": "127.0.0.1",
"node_id": 1,
}
WIFI = ["--ssid", "TESTSSID", "--password", "TESTPASS", "--target-ip", "127.0.0.1"]
class ProvisionSideEffectTests(unittest.TestCase):
def setUp(self):
self.root = tempfile.mkdtemp(prefix="provision-side-effects-")
self.state_dir = os.path.join(self.root, "state")
self.cwd = os.path.join(self.root, "cwd")
os.makedirs(self.cwd)
self.state_path = provision._state_path_for(PORT, self.state_dir)
self.flash = mock.Mock()
def tearDown(self):
shutil.rmtree(self.root, ignore_errors=True)
def run_main(self, *argv):
"""Run provision.main() with stubs; return the SystemExit code or None."""
old_cwd = os.getcwd()
os.chdir(self.cwd)
try:
with mock.patch.object(sys, "argv", ["provision.py", "--port", PORT,
"--state-dir", self.state_dir, *argv]), \
mock.patch.object(provision, "generate_nvs_binary",
return_value=FAKE_NVS), \
mock.patch.object(provision, "flash_nvs", self.flash), \
contextlib.redirect_stdout(io.StringIO()), \
contextlib.redirect_stderr(io.StringIO()):
provision.main()
except SystemExit as exc:
return exc.code
finally:
os.chdir(old_cwd)
return None
def seed_state(self):
provision.save_state(PORT, self.state_dir, PRIOR)
with open(self.state_path, "rb") as f:
return f.read()
def read_state_bytes(self):
with open(self.state_path, "rb") as f:
return f.read()
def test_dry_run_does_not_create_state(self):
self.assertIsNone(self.run_main(*WIFI, "--node-id", "1", "--dry-run"))
self.assertFalse(os.path.exists(self.state_path))
self.assertFalse(os.path.exists(self.state_dir))
self.flash.assert_not_called()
def test_dry_run_does_not_modify_existing_state(self):
before = self.seed_state()
self.assertIsNone(self.run_main("--node-id", "7", "--dry-run"))
self.assertEqual(self.read_state_bytes(), before)
self.flash.assert_not_called()
def test_dry_run_still_writes_requested_binary(self):
# The binary is the dry-run's documented output (ADR-061,
# scripts/qemu-mesh-test.sh), so it is kept.
self.run_main(*WIFI, "--dry-run")
with open(os.path.join(self.cwd, "nvs_provision.bin"), "rb") as f:
self.assertEqual(f.read(), FAKE_NVS)
def test_failed_reset_keeps_state(self):
before = self.seed_state()
# The issue's repro: no WiFi trio after reset, so validation fails.
self.assertEqual(self.run_main("--reset", "--node-id", "3"), 2)
self.assertEqual(self.read_state_bytes(), before)
self.flash.assert_not_called()
def test_reset_dry_run_keeps_state(self):
before = self.seed_state()
self.assertIsNone(self.run_main("--reset", *WIFI, "--node-id", "3", "--dry-run"))
self.assertEqual(self.read_state_bytes(), before)
def test_reset_with_failed_flash_keeps_state(self):
before = self.seed_state()
self.flash.side_effect = subprocess.CalledProcessError(2, "esptool")
with self.assertRaises(subprocess.CalledProcessError):
self.run_main("--reset", *WIFI, "--node-id", "3")
self.assertEqual(self.read_state_bytes(), before)
def test_reset_with_successful_flash_replaces_state(self):
provision.save_state(PORT, self.state_dir, {**PRIOR, "zone": "lobby"})
self.assertIsNone(self.run_main("--reset", *WIFI, "--node-id", "3"))
self.flash.assert_called_once()
state = provision.load_state(PORT, self.state_dir)
self.assertEqual(state["node_id"], 3)
self.assertNotIn("zone", state)
def test_real_flash_still_persists_merged_state(self):
self.seed_state()
self.assertIsNone(self.run_main("--zone", "lobby"))
self.flash.assert_called_once()
state = provision.load_state(PORT, self.state_dir)
self.assertEqual(state["zone"], "lobby")
for key, value in PRIOR.items():
self.assertEqual(state[key], value)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,246 @@
"""Tests for keying provision.py state by board MAC instead of serial port (#1755).
main() runs in-process. esptool's MAC read, the NVS generator and the flasher
are all stubbed, so no serial port is opened and nothing is flashed. MACs and
credentials are placeholders.
"""
import contextlib
import csv
import importlib.util
import io
import json
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
PROVISION_PATH = Path(__file__).resolve().parents[1] / "provision.py"
SPEC = importlib.util.spec_from_file_location("provision", PROVISION_PATH)
provision = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(provision)
PORT = "/dev/cu.usbmodem-test"
OTHER_PORT = "/dev/cu.usbmodem-other"
MAC_A = "02:00:00:00:00:0a"
MAC_B = "02:00:00:00:00:0b"
CREDS = ("--ssid", "test-ssid", "--password", "fake-pass-not-real",
"--target-ip", "192.0.2.10")
# Shapes of `esptool read_mac` output (esptool/cmds.py read_mac in 4.x and 5.x).
ESPTOOL_V5_S3 = "Connected to ESP32-S3 on /dev/x:\nMAC: 02:00:00:00:00:0a\n"
ESPTOOL_V4_S3 = "Chip is ESP32-S3\nMAC: 02:00:00:00:00:0a\nHard resetting via RTS pin...\n"
ESPTOOL_V5_C6 = (
"MAC: 02:00:00:ff:fe:00:00:0a\n"
"BASE MAC: 02:00:00:00:00:0a\n"
"MAC_EXT: ff:fe\n"
)
class TestParseEsptoolMac(unittest.TestCase):
def test_six_byte_mac_line(self):
self.assertEqual(provision.parse_esptool_mac(ESPTOOL_V5_S3), MAC_A)
self.assertEqual(provision.parse_esptool_mac(ESPTOOL_V4_S3), MAC_A)
def test_eui64_chips_use_the_base_mac(self):
# C5/C6 print an 8-byte "MAC:" line first; that isn't the base MAC.
self.assertEqual(provision.parse_esptool_mac(ESPTOOL_V5_C6), MAC_A)
def test_no_mac_in_output(self):
self.assertIsNone(provision.parse_esptool_mac("A fatal error occurred\n"))
def test_normalize_mac(self):
self.assertEqual(provision.normalize_mac("02-00-00-00-00-0A"), MAC_A)
self.assertIsNone(provision.normalize_mac("02:00:00:00:00"))
self.assertIsNone(provision.normalize_mac("02:00:00:00:00:zz"))
class TestReadChipMac(unittest.TestCase):
def test_runs_esptool_read_mac_on_the_port(self):
done = subprocess.CompletedProcess([], 0, stdout=ESPTOOL_V5_C6, stderr="")
with mock.patch.object(provision.subprocess, "run", return_value=done) as run:
self.assertEqual(provision.read_chip_mac(PORT, 115200, "auto"), MAC_A)
cmd = run.call_args.args[0]
self.assertIn("read_mac", cmd)
self.assertEqual(cmd[cmd.index("--port") + 1], PORT)
def test_failure_returns_none(self):
failed = subprocess.CompletedProcess([], 2, stdout="", stderr="no port")
with mock.patch.object(provision.subprocess, "run", return_value=failed):
self.assertIsNone(provision.read_chip_mac(PORT, 115200, "auto"))
with mock.patch.object(provision.subprocess, "run",
side_effect=subprocess.TimeoutExpired("esptool", 30)):
self.assertIsNone(provision.read_chip_mac(PORT, 115200, "auto"))
class _MainCase(unittest.TestCase):
def setUp(self):
self.root = tempfile.mkdtemp(prefix="provision-mac-")
self.state_dir = os.path.join(self.root, "state")
self.flashed_csv = []
def tearDown(self):
shutil.rmtree(self.root, ignore_errors=True)
def run_main(self, *argv, mac=None):
"""Run main() with the board `mac` attached; return (code, stdout, stderr)."""
def fake_generate(csv_content, _size):
self.flashed_csv.append(csv_content)
return b"nvs"
out, err = io.StringIO(), io.StringIO()
code = 0
old_cwd = os.getcwd()
os.chdir(self.root)
try:
with mock.patch.object(sys, "argv", ["provision.py", *argv,
"--state-dir", self.state_dir]), \
mock.patch.object(provision, "read_chip_mac", return_value=mac,
create=True) as read, \
mock.patch.object(provision, "generate_nvs_binary",
side_effect=fake_generate), \
mock.patch.object(provision, "flash_nvs"), \
contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
self.read_mac = read
try:
provision.main()
except SystemExit as exc:
code = exc.code
finally:
os.chdir(old_cwd)
return code, out.getvalue(), err.getvalue()
def last_flashed(self):
rows = csv.DictReader(io.StringIO(self.flashed_csv[-1]))
return {row["key"]: row["value"] for row in rows}
def board_file(self, mac):
return provision._state_path_for(provision.mac_state_key(mac), self.state_dir)
class TestBoardKeyedState(_MainCase):
def test_second_board_on_same_port_does_not_inherit_node_id(self):
self.assertEqual(self.run_main("--port", PORT, *CREDS, "--node-id", "2", mac=MAC_A)[0], 0)
code, _, _ = self.run_main("--port", PORT, *CREDS, mac=MAC_B)
self.assertEqual(code, 0)
self.assertNotIn("node_id", self.last_flashed())
def test_second_board_without_credentials_is_not_given_the_first_boards(self):
self.run_main("--port", PORT, *CREDS, "--node-id", "2", mac=MAC_A)
code, _, err = self.run_main("--port", PORT, "--node-id", "3", mac=MAC_B)
self.assertEqual(code, 2)
self.assertIn("Missing required WiFi credentials", err)
def test_board_keeps_its_state_on_a_different_port(self):
self.run_main("--port", PORT, *CREDS, "--node-id", "2", mac=MAC_A)
code, _, _ = self.run_main("--port", OTHER_PORT, "--zone", "lab", mac=MAC_A)
self.assertEqual(code, 0)
flashed = self.last_flashed()
self.assertEqual(flashed["node_id"], "2")
self.assertEqual(flashed["ssid"], "test-ssid")
def test_record_carries_board_identity(self):
self.run_main("--port", PORT, *CREDS, mac=MAC_A)
with open(self.board_file(MAC_A), encoding="utf-8") as f:
saved = json.load(f)
self.assertEqual(saved[provision.STATE_MAC_KEY], MAC_A)
self.assertEqual(saved[provision.STATE_PORT_KEY], PORT)
self.assertNotIn("_chip_mac", self.flashed_csv[-1])
def test_unreadable_mac_falls_back_to_port_keyed_state(self):
code, _, err = self.run_main("--port", PORT, *CREDS, mac=None)
self.assertEqual(code, 0)
self.assertIn("could not read the board's MAC", err)
self.assertTrue(os.path.isfile(provision._state_path_for(PORT, self.state_dir)))
def test_reset_ignores_the_boards_record(self):
self.run_main("--port", PORT, *CREDS, "--node-id", "2", mac=MAC_A)
self.run_main("--port", PORT, "--reset", *CREDS, mac=MAC_A)
self.assertNotIn("node_id", self.last_flashed())
def test_invalid_mac_flag_is_rejected(self):
code, _, err = self.run_main("--port", PORT, "--mac", "not-a-mac", "--state")
self.assertEqual(code, 2)
self.assertIn("--mac", err)
class TestMigration(_MainCase):
def write_legacy(self, port, state):
os.makedirs(self.state_dir, exist_ok=True)
path = provision._state_path_for(port, self.state_dir)
with open(path, "w", encoding="utf-8") as f:
json.dump(state, f)
return path
def test_port_keyed_record_moves_to_the_board_and_is_retired(self):
legacy = self.write_legacy(PORT, {
"ssid": "test-ssid", "password": "fake-pass-not-real",
"target_ip": "192.0.2.10", "node_id": 5,
})
code, _, err = self.run_main("--port", PORT, "--zone", "lab", mac=MAC_A)
self.assertEqual(code, 0)
self.assertIn("port-keyed state", err)
self.assertEqual(self.last_flashed()["node_id"], "5")
self.assertFalse(os.path.exists(legacy))
with open(self.board_file(MAC_A), encoding="utf-8") as f:
self.assertEqual(json.load(f)["node_id"], 5)
# The next board on that port starts clean.
self.run_main("--port", PORT, *CREDS, mac=MAC_B)
self.assertNotIn("node_id", self.last_flashed())
def test_board_record_wins_over_a_port_keyed_file(self):
self.run_main("--port", OTHER_PORT, *CREDS, "--node-id", "2", mac=MAC_A)
self.write_legacy(PORT, {"node_id": 9})
code, _, _ = self.run_main("--port", PORT, "--zone", "lab", mac=MAC_A)
self.assertEqual(code, 0)
self.assertEqual(self.last_flashed()["node_id"], "2")
class TestNoSerialForInspection(_MainCase):
def test_state_shows_last_board_on_port_without_opening_it(self):
self.run_main("--port", PORT, *CREDS, "--node-id", "2", mac=MAC_A)
code, out, err = self.run_main("--port", PORT, "--state", mac=MAC_B)
self.assertEqual(code, 0)
self.read_mac.assert_not_called()
self.assertEqual(json.loads(out)["node_id"], 2)
self.assertIn(MAC_A, err)
def test_state_with_mac_flag_selects_that_board(self):
self.run_main("--port", PORT, *CREDS, "--node-id", "2", mac=MAC_A)
self.run_main("--port", PORT, *CREDS, "--node-id", "3", mac=MAC_B)
code, out, _ = self.run_main("--port", PORT, "--state", "--mac", MAC_A.upper())
self.assertEqual(code, 0)
self.assertEqual(json.loads(out)["node_id"], 2)
def test_dry_run_does_not_open_the_port(self):
code, _, _ = self.run_main("--port", PORT, *CREDS, "--dry-run", mac=MAC_A)
self.assertEqual(code, 0)
self.read_mac.assert_not_called()
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,345 @@
"""Tests for how provision.py protects the WiFi password and seed token (#1754).
The state file, the --dry-run NVS binary and the fallback CSV all hold the
secrets in clear, so they must be owner-only, and --state must not print them
unless asked. main() runs in-process with the NVS generator stubbed, so no
serial port is opened and no ESP-IDF tooling is needed. All credentials here
are placeholders.
"""
import contextlib
import importlib.util
import io
import json
import os
import shutil
import stat
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
PROVISION_PATH = Path(__file__).resolve().parents[1] / "provision.py"
SPEC = importlib.util.spec_from_file_location("provision", PROVISION_PATH)
provision = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(provision)
FAKE_PASSWORD = "fake-pass-not-real"
FAKE_TOKEN = "fake-token-not-real"
POSIX_ONLY = unittest.skipIf(sys.platform == "win32", "POSIX permission model only")
def mode_of(path):
return stat.S_IMODE(os.lstat(path).st_mode)
class _TempDirCase(unittest.TestCase):
def setUp(self):
self.root = tempfile.mkdtemp(prefix="provision-secrets-")
self.state_dir = os.path.join(self.root, "state")
# The default umask is what made files 0644 in the first place.
self.old_umask = os.umask(0o022)
def tearDown(self):
os.umask(self.old_umask)
shutil.rmtree(self.root, ignore_errors=True)
def run_main(self, *argv, cwd=None):
"""Run provision.main() with argv; return (exit code, stdout, stderr)."""
out, err = io.StringIO(), io.StringIO()
code = 0
old_cwd = os.getcwd()
try:
os.chdir(cwd or self.root)
with mock.patch.object(sys, "argv", ["provision.py", *argv]), \
contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
try:
provision.main()
except SystemExit as exc:
code = exc.code
finally:
os.chdir(old_cwd)
return code, out.getvalue(), err.getvalue()
@POSIX_ONLY
class TestStateFilePermissions(_TempDirCase):
def test_new_state_dir_and_file_are_owner_only(self):
path = provision.save_state("COM7", self.state_dir, {"password": FAKE_PASSWORD})
self.assertEqual(mode_of(self.state_dir), 0o700)
self.assertEqual(mode_of(path), 0o600)
def test_save_tightens_files_left_by_an_older_version(self):
os.makedirs(self.state_dir)
os.chmod(self.state_dir, 0o755)
legacy = provision._state_path_for("/dev/ttyUSB0", self.state_dir)
stale_tmp = legacy + ".tmp"
for path in (legacy, stale_tmp):
with open(path, "w", encoding="utf-8") as f:
json.dump({"password": FAKE_PASSWORD}, f)
os.chmod(path, 0o644)
provision.save_state("COM7", self.state_dir, {"ssid": "test-ssid"})
self.assertEqual(mode_of(self.state_dir), 0o700)
self.assertEqual(mode_of(legacy), 0o600)
self.assertEqual(mode_of(stale_tmp), 0o600)
def test_planted_symlink_at_old_temp_path_does_not_receive_secret(self):
os.makedirs(self.state_dir, mode=0o700)
victim = os.path.join(self.root, "victim.txt")
with open(victim, "w", encoding="utf-8") as f:
f.write("original")
os.symlink(victim, provision._state_path_for("COM7", self.state_dir) + ".tmp")
provision.save_state("COM7", self.state_dir, {"password": FAKE_PASSWORD})
with open(victim, encoding="utf-8") as f:
self.assertEqual(f.read(), "original")
def test_hardening_does_not_chmod_through_a_symlink(self):
os.makedirs(self.state_dir, mode=0o700)
victim = os.path.join(self.root, "victim.txt")
with open(victim, "w", encoding="utf-8") as f:
f.write("not a state file")
os.chmod(victim, 0o644)
os.symlink(victim, os.path.join(self.state_dir, "link.json"))
with contextlib.redirect_stderr(io.StringIO()):
provision.harden_state_dir(self.state_dir)
self.assertEqual(mode_of(victim), 0o644)
def test_state_inspection_repairs_legacy_permissions(self):
# --state never writes, so the repair has to happen before the read.
os.makedirs(self.state_dir)
os.chmod(self.state_dir, 0o755)
legacy = provision._state_path_for("COM7", self.state_dir)
with open(legacy, "w", encoding="utf-8") as f:
json.dump({"password": FAKE_PASSWORD}, f)
os.chmod(legacy, 0o644)
code, _, _ = self.run_main("--port", "COM7", "--state-dir", self.state_dir, "--state")
self.assertEqual(code, 0)
self.assertEqual(mode_of(self.state_dir), 0o700)
self.assertEqual(mode_of(legacy), 0o600)
class TestStateOutputMasking(_TempDirCase):
def setUp(self):
super().setUp()
provision.save_state("COM7", self.state_dir, {
"ssid": "test-ssid",
"password": FAKE_PASSWORD,
"seed_token": FAKE_TOKEN,
"target_ip": "192.0.2.10",
})
def test_state_hides_password_and_seed_token(self):
code, out, err = self.run_main(
"--port", "COM7", "--state-dir", self.state_dir, "--state")
self.assertEqual(code, 0)
self.assertNotIn(FAKE_PASSWORD, out)
self.assertNotIn(FAKE_TOKEN, out)
shown = json.loads(out)
self.assertEqual(shown["password"], "(set)")
self.assertEqual(shown["seed_token"], "(set)")
self.assertEqual(shown["ssid"], "test-ssid")
self.assertIn("--show-secrets", err)
def test_show_secrets_prints_them(self):
code, out, _ = self.run_main(
"--port", "COM7", "--state-dir", self.state_dir, "--state", "--show-secrets")
self.assertEqual(code, 0)
shown = json.loads(out)
self.assertEqual(shown["password"], FAKE_PASSWORD)
self.assertEqual(shown["seed_token"], FAKE_TOKEN)
def test_redact_marks_empty_and_leaves_absent_alone(self):
shown = provision.redact_secrets({"password": "", "ssid": "test-ssid"})
self.assertEqual(shown, {"password": "(empty)", "ssid": "test-ssid"})
@POSIX_ONLY
class TestIntermediateArtifacts(_TempDirCase):
ARGS = ("--port", "COM7", "--ssid", "test-ssid", "--password", FAKE_PASSWORD,
"--target-ip", "192.0.2.10", "--dry-run")
def test_dry_run_binary_is_owner_only_even_if_it_already_existed(self):
out_path = os.path.join(self.root, "nvs_provision.bin")
with open(out_path, "wb") as f:
f.write(b"old")
os.chmod(out_path, 0o644)
with mock.patch.object(provision, "generate_nvs_binary",
return_value=b"nvs-with-" + FAKE_PASSWORD.encode()):
code, _, _ = self.run_main(*self.ARGS, "--state-dir", self.state_dir)
self.assertIn(code, (0, None))
self.assertEqual(mode_of(out_path), 0o600)
def test_fallback_csv_is_owner_only(self):
with mock.patch.object(provision, "generate_nvs_binary",
side_effect=RuntimeError("generator missing")):
code, _, _ = self.run_main(*self.ARGS, "--state-dir", self.state_dir)
self.assertEqual(code, 1)
csv_path = os.path.join(self.root, "nvs_config.csv")
self.assertEqual(mode_of(csv_path), 0o600)
def test_generator_files_live_in_a_private_dir(self):
seen = {}
def fake_generator(cmd, **_kwargs):
csv_path, bin_path = cmd[-3], cmd[-2]
seen["dir_mode"] = mode_of(os.path.dirname(bin_path))
seen["csv_mode"] = mode_of(csv_path)
with open(bin_path, "wb") as f:
f.write(b"nvs")
# A shared temp dir like Linux /tmp, rather than macOS's per-user one.
shared_tmp = os.path.join(self.root, "shared-tmp")
os.makedirs(shared_tmp)
os.chmod(shared_tmp, 0o755)
with mock.patch.object(provision.tempfile, "tempdir", shared_tmp), \
mock.patch.object(provision.subprocess, "check_call",
side_effect=fake_generator):
self.assertEqual(provision.generate_nvs_binary("key,type\n", 0x6000), b"nvs")
self.assertEqual(seen["dir_mode"], 0o700)
self.assertEqual(seen["csv_mode"], 0o600)
class TestPasswordInput(_TempDirCase):
"""Ways to give the password without putting it on the command line."""
BASE = ("--port", "COM7", "--target-ip", "192.0.2.10")
def setUp(self):
super().setUp()
self.flashed_csv = []
def password_file(self, content, mode=0o600):
path = os.path.join(self.root, "wifi-pass.txt")
with open(path, "w", encoding="utf-8", newline="") as f:
f.write(content)
os.chmod(path, mode)
return path
def provision(self, *argv, tty=False, typed=None):
"""Run main() with flashing stubbed; return (code, stderr, getpass mock)."""
def fake_generate(csv_content, _size):
self.flashed_csv.append(csv_content)
return b"nvs"
stdin = mock.Mock()
stdin.isatty.return_value = tty
# read_chip_mac only exists once state is keyed by board MAC (#1755);
# stub it so these tests never reach esptool or a serial port.
with mock.patch.object(provision, "generate_nvs_binary", side_effect=fake_generate), \
mock.patch.object(provision, "flash_nvs"), \
mock.patch.object(provision, "read_chip_mac", return_value=None,
create=True), \
mock.patch.object(sys, "stdin", stdin), \
mock.patch("getpass.getpass", return_value=typed) as prompt:
code, _, err = self.run_main(*self.BASE, *argv, "--state-dir", self.state_dir)
return code, err, prompt
def flashed_password(self):
rows = provision.csv.DictReader(io.StringIO(self.flashed_csv[-1]))
return {row["key"]: row["value"] for row in rows}.get("password")
def test_password_file_is_read_and_one_newline_dropped(self):
path = self.password_file(FAKE_PASSWORD + " \n\n")
code, err, _ = self.provision("--ssid", "test-ssid", "--password-file", path)
self.assertEqual(code, 0)
# Only one newline goes; trailing spaces and the second newline stay.
self.assertEqual(self.flashed_password(), FAKE_PASSWORD + " \n")
self.assertNotIn("visible in ps", err)
def test_password_file_crlf_counts_as_one_newline(self):
path = self.password_file(FAKE_PASSWORD + "\r\n")
self.provision("--ssid", "test-ssid", "--password-file", path)
self.assertEqual(self.flashed_password(), FAKE_PASSWORD)
@POSIX_ONLY
def test_group_or_world_readable_password_file_is_refused(self):
for mode in (0o644, 0o640, 0o604):
with self.subTest(mode=oct(mode)):
path = self.password_file(FAKE_PASSWORD + "\n", mode)
code, err, _ = self.provision("--ssid", "test-ssid", "--password-file", path)
self.assertEqual(code, 2)
self.assertIn("chmod 600", err)
@POSIX_ONLY
def test_insecure_password_file_allowed_with_flag_and_warning(self):
path = self.password_file(FAKE_PASSWORD + "\n", 0o644)
code, err, _ = self.provision("--ssid", "test-ssid", "--password-file", path,
"--allow-insecure-password-file")
self.assertEqual(code, 0)
self.assertIn("readable by group or others", err)
self.assertEqual(self.flashed_password(), FAKE_PASSWORD)
def test_missing_or_empty_password_file_is_an_error(self):
missing = os.path.join(self.root, "nope.txt")
code, err, _ = self.provision("--ssid", "test-ssid", "--password-file", missing)
self.assertEqual(code, 2)
self.assertIn("does not exist", err)
empty = self.password_file("\n")
code, err, _ = self.provision("--ssid", "test-ssid", "--password-file", empty)
self.assertEqual(code, 2)
self.assertIn("is empty", err)
def test_password_and_password_file_are_mutually_exclusive(self):
path = self.password_file(FAKE_PASSWORD)
code, err, _ = self.provision("--ssid", "test-ssid", "--password", FAKE_PASSWORD,
"--password-file", path)
self.assertEqual(code, 2)
self.assertIn("not allowed with argument", err)
def test_password_on_command_line_warns(self):
code, err, _ = self.provision("--ssid", "test-ssid", "--password", FAKE_PASSWORD)
self.assertEqual(code, 0)
self.assertIn("--password is visible in ps and shell history", err)
self.assertIn("--password-file", err)
def test_terminal_prompts_for_password_of_a_new_ssid(self):
code, _, prompt = self.provision("--ssid", "test-ssid", tty=True, typed=FAKE_PASSWORD)
self.assertEqual(code, 0)
prompt.assert_called_once()
self.assertEqual(self.flashed_password(), FAKE_PASSWORD)
self.assertEqual(provision.load_state("COM7", self.state_dir)["password"], FAKE_PASSWORD)
def test_no_prompt_when_saved_password_matches_the_ssid(self):
provision.save_state("COM7", self.state_dir,
{"ssid": "test-ssid", "password": FAKE_PASSWORD})
code, _, prompt = self.provision("--ssid", "test-ssid", tty=True, typed="unused")
self.assertEqual(code, 0)
prompt.assert_not_called()
self.assertEqual(self.flashed_password(), FAKE_PASSWORD)
def test_no_prompt_without_a_terminal(self):
code, err, prompt = self.provision("--ssid", "test-ssid", tty=False, typed="unused")
prompt.assert_not_called()
self.assertEqual(code, 2)
self.assertIn("--password", err)
if __name__ == "__main__":
unittest.main()
+78
View File
@@ -0,0 +1,78 @@
# WL-WN586X3 CSI patches
OpenWrt patches that made a Wavlink WL-WN586X3 Rev A emit CSI into
`wifi-densepose-mtk-bridge`. Chipset on the board that was flashed: MT7981BA,
MT7976CN, MT7531AE. Image base: OpenWrt 24.10.8. This directory is the patch
set. It is not a firmware image, a sysupgrade binary, or a capture.
Developed in whitsentry, a RuView-based project. The user guide, covering
the build, arming CSI, the bridge, the server, and limitations, is
[`docs/mediatek-router-csi.md`](../../docs/mediatek-router-csi.md).
Provenance of frames from this path is `mediatek:physical-unvalidated` until
the ADR-266 gates (calibration, sequence, timestamp, repeatability) pass on
this model. Two of these radios are two receivers. They are not one phased array.
## Files
| Patch | What it is | License of the base |
|---|---|---|
| `patches/1001-mtk-mt76-mt7915-csi-implement-csi-support-REBASED.patch` | MediaTek's 2022 mt7915 CSI patch, rebased onto `openwrt/mt76` `eb567bc7` (the revision OpenWrt 24.10.8 pins). Two dead-code warnings were removed so `-Werror` builds. | BSD-3-Clause-Clear, MediaTek |
| `patches/0001-wn586x3-reva-single-image-sysupgrade-and-generic-spi-nor.patch` | Board support ported from [dadogroove/openwrt `wl-wn586x3`](https://github.com/dadogroove/openwrt/tree/wl-wn586x3) @ `b080a655` onto the 24.10.8 tag. Single-image sysupgrade, generic SPI-NOR (the Boya flash path is a deletion of quad-SPI width, not a new chip driver), factory MAC offsets. | OpenWrt target patches, GPL-2.0 |
| `patches/mt76-vendor-csi-dump-bounds.patch` | `mt76-vendor dump csi` wrote past its buffer once a dump held more records than requested. The driver seeds its counter and then decrements, so a request for N can return N+1. Also bounds `snprintf` and checks attribute presence. Base: `mediatek/mtk-openwrt-feeds` @ `a15454c8`, `feed/app/mt76-vendor/src/csi.c`. | GPL-2.0, MediaTek |
| `patches/csidump-nl80211-attr-enum.patch` | [MtkCSIdump](https://github.com/MtkWifiRev/MtkCSIdump) @ `276b4b08` was one attribute off from this driver (`STA_INTERVAL`, `DATA_NUM`). The dump callback returned nothing and the ring sat full. | Apache-2.0 (MtkCSIdump `LICENSE.txt`) |
Do not copy `mt76` itself, the imagebuilder download cache, `out/`, stock
backups, or anything under a `captures/` directory into this repo.
### Licences
These files are patches against third-party code. Each one keeps the licence
of the code it modifies, as listed in the table above. They are not covered
by the repository's MIT/Apache-2.0 licence. In particular, the `0001-` board
patch and `mt76-vendor-csi-dump-bounds.patch` modify GPL-2.0 sources, and the
`1001-` CSI patch is MediaTek's BSD-3-Clause-Clear work with its original
author line kept. Nothing here is a firmware blob, a private header, or SDK
code (ADR-266 decision 5).
## What the radio actually did
CSI follows client traffic. An idle associated station is a few frames per
second. The vendor dump tool opens its output with `fopen(path, "a+")`, so a
loop that does not remove the file replays every earlier batch. Changing the
CSI filter stops capture until `ctrl=1,0,0,0` is sent again. `CSIdump` disarms
CSI on SIGTERM; an explicit disarm is the right stop, and `kill -9` is how the
lab kept the radio armed, which is a bug in the tool rather than a procedure
to keep.
`mt76-vendor` JSON is the path that keeps RSSI, SNR, transmit address, bandwidth,
PPDU mode, and per-chain indices. The CSIdump UDP datagram has none of those
and sends antennas one after another, so the bridge emits one 1x1 frame per
datagram. Prefer the dump path when the chain layout matters. Neither stock
tool prints `pkt_sn`, which the firmware event already carries, so the bridge
cannot see loss on an unpatched dumper.
Each radio is two chains. The four external antennas are a 2.4 GHz pair and a
5 GHz pair. Live CSI in the lab was 2x2 on 2.4 GHz. The 5 GHz radio was idle
for sensing.
## Build outline
Apply the rebased CSI patch to the `mt76` tree OpenWrt 24.10.8 builds, and the
board patch to `target/linux/mediatek`. Point the image at the patched
`mt76-vendor` and, if you use it, the patched CSIdump. Flash only a Rev A unit,
and only an image you built and hashed yourself. This README does not name a
LAN, an account, or a password.
Host side, after the radio is emitting:
```bash
cd v2
cargo build -p wifi-densepose-mtk-bridge
./target/debug/wifi-densepose-mtk-bridge --help
```
Replay stays on loopback unless you choose otherwise. `--listen` refuses to
start without `--listen-allow`. `--replay` of a file refuses to start until
you pass `--synthetic` or `--captured-on <model>/<firmware>`. A filename
containing `.synthetic.` cannot be attested as hardware.
@@ -0,0 +1,187 @@
Subject: [PATCH] mediatek: wl-wn586x3 Rev A - single-image sysupgrade, generic SPI-NOR
Origin: https://github.com/dadogroove/openwrt/tree/wl-wn586x3
branch wl-wn586x3 @ b080a6550ec6928400735dab4837601efaaf6243 (2026-09-19),
which tracks openwrt/main. Ported here onto the v24.10.8 tag.
Forum: https://forum.openwrt.org/t/wavlink-wl-wn586x3-ver-a-installation-advice/243893
Four separable changes, all in target/, none touching package/kernel/mt76, so
this is orthogonal to the CSI driver patch and composes with it.
1. Single-image sysupgrade (filogic.mk, platform.sh). Replaces the stock
wn586x3 recipe with the append-kernel/append-rootfs/append-metadata form
that wl-wn573hx3 already uses in 24.10.8, adds a `fit lzma` KERNEL, drops
IMAGE_SIZE 15424k to 14336k, marks the firmware partition `denx,fit`, and
adds the board to the default_do_upgrade case list. One sysupgrade.bin then
serves both the initial mtd write and later sysupgrades. This is the
soft-brick fix dadogroove landed 2026-04-25.
2. Generic SPI-NOR operation (the Boya fix). There is NO Boya BY25Q128AS
driver patch anywhere - the fix is a DELETION. Dropping
spi-tx-bus-width/spi-rx-bus-width = <4> stops the controller driving the
chip in quad mode, so the generic jedec,spi-nor binding is sufficient and
the chip no longer needs to be recognised by name. spi-max-frequency also
goes 25 -> 50 MHz.
3. MAC addressing. Sources both ethernet MACs from absolute factory offsets
0x3fff4/0x3fffa instead of the mac-base cell at hw+0x44e, and drops the
wifi mac-address cell so wifi takes its MAC from the EEPROM. Entangled with
(2) only by file, but NOT separable within this patch: the eth nodes and the
cell definitions must move together. This CHANGES the MACs the board
presents versus an official 24.10.8 image. Adopted because dadogroove has
the hardware and his build is the one known to boot on Boya units.
4. Cosmetic: gpio-keys node renamed button-reset -> button-0; mdio_pins
pinctrl moved onto &eth.
Adaptations made while porting to 24.10.8:
- platform.sh: the case list is ordered differently; wl-wn586x3 was inserted
next to the wl-wn573hx3 entry that is already there.
- &wifi: 24.10.8 takes the EEPROM via `mediatek,mtd-eeprom = <&factory 0x0>`,
not snapshot's eeprom_factory_0 nvmem cell, so only the mac-address cell
was removed.
- filogic.mk hunk landed at a -1824 line offset; content identical.
Verified: no dangling DTS labels. NOT yet verified: DTB compilation, and
nothing has been flashed.
---
diff --git a/target/linux/mediatek/dts/mt7981b-wavlink-wl-wn586x3.dts b/target/linux/mediatek/dts/mt7981b-wavlink-wl-wn586x3.dts
index 62be0faa4af3..2260df89e9b5 100644
--- a/target/linux/mediatek/dts/mt7981b-wavlink-wl-wn586x3.dts
+++ b/target/linux/mediatek/dts/mt7981b-wavlink-wl-wn586x3.dts
@@ -24,7 +24,7 @@
gpio-keys {
compatible = "gpio-keys";
- button-reset {
+ button-0 {
label = "reset";
linux,code = <KEY_RESTART>;
gpios = <&pio 11 GPIO_ACTIVE_LOW>;
@@ -79,13 +79,17 @@
};
&eth {
+
+ pinctrl-names = "default";
+ pinctrl-0 = <&mdio_pins>;
+
status = "okay";
gmac0: mac@0 {
compatible = "mediatek,eth-mac";
reg = <0>;
phy-mode = "2500base-x";
- nvmem-cells = <&macaddr_hw_44e 0>;
+ nvmem-cells = <&macaddr_factory_3fff4>;
nvmem-cell-names = "mac-address";
fixed-link {
@@ -100,7 +104,7 @@
reg = <1>;
phy-mode = "gmii";
phy-handle = <&int_gbe_phy>;
- nvmem-cells = <&macaddr_hw_44e 1>;
+ nvmem-cells = <&macaddr_factory_3fffa>;
nvmem-cell-names = "mac-address";
};
};
@@ -118,8 +122,6 @@
};
&spi0 {
- pinctrl-names = "default";
- pinctrl-0 = <&spi0_flash_pins>;
status = "disabled";
};
@@ -135,9 +137,7 @@
compatible = "jedec,spi-nor";
reg = <0>;
- spi-max-frequency = <25000000>;
- spi-tx-bus-width = <4>;
- spi-rx-bus-width = <4>;
+ spi-max-frequency = <50000000>;
partitions {
compatible = "fixed-partitions";
@@ -166,10 +166,12 @@
#address-cells = <1>;
#size-cells = <1>;
- macaddr_factory_4: macaddr@4 {
- compatible = "mac-base";
- reg = <0x4 0x6>;
- #nvmem-cell-cells = <1>;
+ macaddr_factory_3fff4: macaddr@3fff4 {
+ reg = <0x3fff4 0x6>;
+ };
+
+ macaddr_factory_3fffa: macaddr@3fffa {
+ reg = <0x3fffa 0x6>;
};
};
};
@@ -181,6 +183,7 @@
};
partition@f0000 {
+ compatible = "denx,fit";
label = "firmware";
reg = <0x180000 0xe00000>;
};
@@ -189,17 +192,6 @@
label = "hw";
reg = <0xf80000 0x80000>;
- nvmem-layout {
- compatible = "fixed-layout";
- #address-cells = <1>;
- #size-cells = <1>;
-
- macaddr_hw_44e: macaddr@44e {
- compatible = "mac-base";
- reg = <0x44e 0x11>;
- #nvmem-cell-cells = <1>;
- };
- };
};
};
};
@@ -265,6 +257,4 @@
&wifi {
status = "okay";
mediatek,mtd-eeprom = <&factory 0x0>;
- nvmem-cells = <&macaddr_factory_4 0>;
- nvmem-cell-names = "mac-address";
};
diff --git a/target/linux/mediatek/filogic/base-files/lib/upgrade/platform.sh b/target/linux/mediatek/filogic/base-files/lib/upgrade/platform.sh
index 8af61b028926..4a71285bea01 100644
--- a/target/linux/mediatek/filogic/base-files/lib/upgrade/platform.sh
+++ b/target/linux/mediatek/filogic/base-files/lib/upgrade/platform.sh
@@ -137,6 +137,7 @@ platform_do_upgrade() {
cudy,wr3000-v1|\
yuncore,ax835|\
wavlink,wl-wn573hx3|\
+ wavlink,wl-wn586x3|\
totolink,x6000r)
default_do_upgrade "$1"
;;
diff --git a/target/linux/mediatek/image/filogic.mk b/target/linux/mediatek/image/filogic.mk
index b7f8ffd47c51..756dc86b8541 100644
--- a/target/linux/mediatek/image/filogic.mk
+++ b/target/linux/mediatek/image/filogic.mk
@@ -1910,8 +1910,12 @@ define Device/wavlink_wl-wn586x3
DEVICE_DTS := mt7981b-wavlink-wl-wn586x3
DEVICE_DTS_DIR := ../dts
DEVICE_DTS_LOADADDR := 0x47000000
- IMAGE_SIZE := 15424k
DEVICE_PACKAGES := kmod-mt7915e kmod-mt7981-firmware mt7981-wo-firmware
+ IMAGE_SIZE := 14336k
+ IMAGES := sysupgrade.bin
+ KERNEL := kernel-bin | lzma | \
+ fit lzma $$(KDIR)/image-$$(firstword $$(DEVICE_DTS)).dtb
+ IMAGE/sysupgrade.bin := append-kernel | pad-to 128k | append-rootfs | pad-rootfs | check-size | append-metadata
endef
TARGET_DEVICES += wavlink_wl-wn586x3
@@ -0,0 +1,128 @@
Fix: CSIdump forwarded nothing against the mt76 CSI driver built here.
Base: github.com/MtkWifiRev/MtkCSIdump @ 276b4b0814a283dcbc6544a76033e0e37154f092
CSIdump was written against a DIFFERENT mt76 vendor ABI than the 2022
MediaTek CSI patch we build. Two enumerators are missing from its copies of
the attribute enums, so every attribute number past them was off by one:
CTRL: our driver has STA_INTERVAL between INTERVAL and DUMP_NUM
DATA: our driver has DATA_NUM between TA and I, and ends CHAIN_INFO not H_IDX
Failure chain. CSIdump put DUMP_NUM at attr 8, which is the driver's
STA_INTERVAL (policy NLA_U32); a 2-byte u16 payload failed the driver's
nla_parse minimum-length check, so the dumpit returned -EINVAL and emitted
NOTHING. The ring therefore never drained (a unit sat pinned at 3000) and the
dump callback never ran. Even had the request landed, the driver replies with
DATA at attr 10 while CSIdump looked at attr 9, so the callback would have
returned NL_SKIP at its first check with no message printed. And the error
print used a stale ret that was always 0, so any failure read as 'Success'.
---
--- /tmp/mt76_api.cpp.orig 2026-09-19 23:14:12
+++ src/wifi_drv_api/mt76_api.cpp 2026-09-19 23:14:12
@@ -47,10 +47,12 @@
MTK_VENDOR_ATTR_CSI_CTRL_CFG_VAL2,
MTK_VENDOR_ATTR_CSI_CTRL_MAC_ADDR,
MTK_VENDOR_ATTR_CSI_CTRL_INTERVAL,
+ MTK_VENDOR_ATTR_CSI_CTRL_STA_INTERVAL,
MTK_VENDOR_ATTR_CSI_CTRL_DUMP_NUM,
MTK_VENDOR_ATTR_CSI_CTRL_DATA,
+ MTK_VENDOR_ATTR_CSI_CTRL_DUMP_MAC_FILTER,
/* keep last */
NUM_MTK_VENDOR_ATTRS_CSI_CTRL,
@@ -70,6 +72,7 @@
MTK_VENDOR_ATTR_CSI_DATA_BW,
MTK_VENDOR_ATTR_CSI_DATA_CH_IDX,
MTK_VENDOR_ATTR_CSI_DATA_TA,
+ MTK_VENDOR_ATTR_CSI_DATA_NUM,
MTK_VENDOR_ATTR_CSI_DATA_I,
MTK_VENDOR_ATTR_CSI_DATA_Q,
MTK_VENDOR_ATTR_CSI_DATA_INFO,
@@ -80,7 +83,7 @@
MTK_VENDOR_ATTR_CSI_DATA_TX_ANT,
MTK_VENDOR_ATTR_CSI_DATA_RX_ANT,
MTK_VENDOR_ATTR_CSI_DATA_MODE,
- MTK_VENDOR_ATTR_CSI_DATA_H_IDX,
+ MTK_VENDOR_ATTR_CSI_DATA_CHAIN_INFO,
/* keep last */
NUM_MTK_VENDOR_ATTRS_CSI_DATA,
@@ -123,7 +126,8 @@
csi_data_policy[MTK_VENDOR_ATTR_CSI_DATA_TX_ANT].type = NLA_U8;
csi_data_policy[MTK_VENDOR_ATTR_CSI_DATA_RX_ANT].type = NLA_U8;
csi_data_policy[MTK_VENDOR_ATTR_CSI_DATA_MODE].type = NLA_U8;
- csi_data_policy[MTK_VENDOR_ATTR_CSI_DATA_H_IDX].type = NLA_U32;
+ csi_data_policy[MTK_VENDOR_ATTR_CSI_DATA_NUM].type = NLA_U32;
+ csi_data_policy[MTK_VENDOR_ATTR_CSI_DATA_CHAIN_INFO].type = NLA_U32;
}
~MT76APIPrivate()
{
@@ -172,7 +176,7 @@
tb_data[MTK_VENDOR_ATTR_CSI_DATA_Q] &&
tb_data[MTK_VENDOR_ATTR_CSI_DATA_INFO] &&
tb_data[MTK_VENDOR_ATTR_CSI_DATA_MODE] &&
- tb_data[MTK_VENDOR_ATTR_CSI_DATA_H_IDX]))
+ tb_data[MTK_VENDOR_ATTR_CSI_DATA_CHAIN_INFO]))
{
fprintf(stderr, "Attributes error for CSI data\n");
return NL_SKIP;
@@ -192,7 +196,7 @@
c->rx_idx = nla_get_u16(tb_data[MTK_VENDOR_ATTR_CSI_DATA_RX_ANT]);
c->ext_info = nla_get_u32(tb_data[MTK_VENDOR_ATTR_CSI_DATA_INFO]);
- c->h_idx = nla_get_u32(tb_data[MTK_VENDOR_ATTR_CSI_DATA_H_IDX]);
+ c->h_idx = nla_get_u32(tb_data[MTK_VENDOR_ATTR_CSI_DATA_CHAIN_INFO]);
c->ts = nla_get_u32(tb_data[MTK_VENDOR_ATTR_CSI_DATA_TS]);
@@ -271,8 +275,9 @@
nla_nest_end(msg, data);
- if (unl_genl_request(&unl, msg, md_csi_dump_cb, NULL))
- fprintf(stderr, "nl80211 call failed: %s\n", strerror(-ret));
+ ret = unl_genl_request(&unl, msg, md_csi_dump_cb, NULL);
+ if (ret)
+ fprintf(stderr, "csi dump: nl80211 call failed: %s\n", strerror(-ret));
unl_free(&unl);
}
--- /tmp/motion_detector.cpp.orig 2026-09-19 23:14:58
+++ src/motion_detector.cpp 2026-09-19 23:14:58
@@ -1,3 +1,4 @@
+#include <cstdio>
#include "motion_detector.h"
#include "wifi_drv_api/mt76_api.h"
#include "parsers/parser_mt76.h"
@@ -35,6 +36,12 @@
std::vector<csi_data *> *list = wifi.motion_detection_dump(ifname.c_str(), pkt_num);
oldTime = currTime;
+ /* Visibility: without this a failing dump is completely silent, which is
+ * exactly how the enum/ABI mismatch went unnoticed. */
+ fprintf(stderr, "csi: dump returned %zu records (%d ms since last)\n",
+ list ? list->size() : (size_t)0, diff);
+
+ unsigned sentThisInterval = 0;
dataMutex.lock();
unsigned antIdx = antMonIdx;
dataMutex.unlock();
@@ -49,6 +56,7 @@
if (!packet.empty()) {
std::vector<std::vector<double>> singlePacket = { packet };
sendCsiDataUdp(singlePacket, i);
+ sentThisInterval++;
}
}
}
@@ -56,6 +64,9 @@
}
}
+ if (sentThisInterval)
+ fprintf(stderr, "csi: sent %u udp datagrams\n", sentThisInterval);
+
std::this_thread::sleep_for(std::chrono::milliseconds(interval));
}
}
@@ -0,0 +1,192 @@
Fix for mt76-vendor 'dump csi' SIGSEGV.
Base: mediatek/mtk-openwrt-feeds @ a15454c888f4f4144c50e33b5feef4f247c5f78b
feed/app/mt76-vendor/src/csi.c
Root cause: the driver seeds its per-dump counter on the FIRST dumpit
callback and only then decrements it, so a request for CSI_DUMP_PER_NUM=3
yields FOUR records. Userspace sized csi[] for pkt_num but looped pkt_num/3
times, storing up to 4*(pkt_num/3) records with no bounds check, so any dump
with >= pkt_num records buffered wrote past the allocation. A dump that found
only one record buffered never overran, which is why the single association
record succeeded and every data-frame dump crashed.
Also fixed: the JSON writer passed the full MAX_BUF_SIZE to every snprintf
instead of the remaining space, and three attributes were dereferenced without
being in the presence check.
---
--- /tmp/csi.c.orig 2026-09-19 19:46:49
+++ src/csi.c 2026-09-19 19:47:16
@@ -6,6 +6,7 @@
struct csi_data *csi;
int csi_idx;
+int csi_max; /* entries allocated in csi[] */
static struct nla_policy csi_ctrl_policy[NUM_MTK_VENDOR_ATTRS_CSI_CTRL] = {
[MTK_VENDOR_ATTR_CSI_CTRL_CFG] = { .type = NLA_NESTED },
@@ -51,8 +52,13 @@
struct nlattr *cur;
size_t idx;
int rem;
- struct csi_data *c = &csi[csi_idx];
+ struct csi_data *c;
+ if (!csi || csi_idx >= csi_max)
+ return NL_SKIP;
+
+ c = &csi[csi_idx];
+
attr = unl_find_attr(&unl, msg, NL80211_ATTR_VENDOR_DATA);
if (!attr) {
fprintf(stderr, "Testdata attribute not found\n");
@@ -90,15 +96,20 @@
c->pri_ch_idx = nla_get_u8(tb_data[MTK_VENDOR_ATTR_CSI_DATA_CH_IDX]);
c->rx_mode = nla_get_u8(tb_data[MTK_VENDOR_ATTR_CSI_DATA_MODE]);
- c->tx_idx = nla_get_u16(tb_data[MTK_VENDOR_ATTR_CSI_DATA_TX_ANT]);
- c->rx_idx = nla_get_u16(tb_data[MTK_VENDOR_ATTR_CSI_DATA_RX_ANT]);
+ c->tx_idx = tb_data[MTK_VENDOR_ATTR_CSI_DATA_TX_ANT] ?
+ nla_get_u16(tb_data[MTK_VENDOR_ATTR_CSI_DATA_TX_ANT]) : 0;
+ c->rx_idx = tb_data[MTK_VENDOR_ATTR_CSI_DATA_RX_ANT] ?
+ nla_get_u16(tb_data[MTK_VENDOR_ATTR_CSI_DATA_RX_ANT]) : 0;
c->ext_info = nla_get_u32(tb_data[MTK_VENDOR_ATTR_CSI_DATA_INFO]);
c->chain_info = nla_get_u32(tb_data[MTK_VENDOR_ATTR_CSI_DATA_CHAIN_INFO]);
c->ts = nla_get_u32(tb_data[MTK_VENDOR_ATTR_CSI_DATA_TS]);
- c->data_num = nla_get_u32(tb_data[MTK_VENDOR_ATTR_CSI_DATA_NUM]);
+ c->data_num = tb_data[MTK_VENDOR_ATTR_CSI_DATA_NUM] ?
+ nla_get_u32(tb_data[MTK_VENDOR_ATTR_CSI_DATA_NUM]) : 0;
+ if (c->data_num > CSI_BW160_DATA_COUNT)
+ c->data_num = CSI_BW160_DATA_COUNT;
idx = 0;
nla_for_each_nested(cur, tb_data[MTK_VENDOR_ATTR_CSI_DATA_TA], rem) {
@@ -125,7 +136,7 @@
static int mt76_csi_to_json(const char *name)
{
-#define MAX_BUF_SIZE 6000
+#define MAX_BUF_SIZE 16384 /* 2 x 512 samples of "-32768," plus header */
FILE *f;
int i, ret = -ENOMEM;
@@ -142,7 +153,7 @@
for (i = 0; i < csi_idx; i++) {
struct csi_data *c = &csi[i];
- char *pos, *buf;
+ char *pos, *buf, *end;
int j;
buf = malloc(MAX_BUF_SIZE);
@@ -150,40 +161,51 @@
goto out;
pos = buf;
- pos += snprintf(pos, MAX_BUF_SIZE, "%c", '[');
+ end = buf + MAX_BUF_SIZE;
+#define EMIT(...) do { \
+ int _n = snprintf(pos, (size_t)(end - pos), __VA_ARGS__); \
+ if (_n < 0 || _n >= (int)(end - pos)) { \
+ fprintf(stderr, "csi json: record %d truncated\n", i); \
+ free(buf); \
+ goto out; \
+ } \
+ pos += _n; \
+ } while (0)
- pos += snprintf(pos, MAX_BUF_SIZE, "%d,", c->ts);
- pos += snprintf(pos, MAX_BUF_SIZE, "\"%02x%02x%02x%02x%02x%02x\",", c->ta[0], c->ta[1], c->ta[2], c->ta[3], c->ta[4], c->ta[5]);
+ EMIT("%c", '[');
- pos += snprintf(pos, MAX_BUF_SIZE, "%d,", c->rssi);
- pos += snprintf(pos, MAX_BUF_SIZE, "%u,", c->snr);
- pos += snprintf(pos, MAX_BUF_SIZE, "%u,", c->data_bw);
- pos += snprintf(pos, MAX_BUF_SIZE, "%u,", c->pri_ch_idx);
- pos += snprintf(pos, MAX_BUF_SIZE, "%u,", c->rx_mode);
- pos += snprintf(pos, MAX_BUF_SIZE, "%d,", c->tx_idx);
- pos += snprintf(pos, MAX_BUF_SIZE, "%d,", c->rx_idx);
- pos += snprintf(pos, MAX_BUF_SIZE, "%d,", c->chain_info);
- pos += snprintf(pos, MAX_BUF_SIZE, "%d,", c->ext_info);
+ EMIT("%d,", c->ts);
+ EMIT("\"%02x%02x%02x%02x%02x%02x\",", c->ta[0], c->ta[1], c->ta[2], c->ta[3], c->ta[4], c->ta[5]);
- pos += snprintf(pos, MAX_BUF_SIZE, "%c", '[');
- for (j = 0; j < c->data_num; j++) {
- pos += snprintf(pos, MAX_BUF_SIZE, "%d", c->data_i[j]);
- if (j != (c->data_num - 1))
- pos += snprintf(pos, MAX_BUF_SIZE, ",");
- }
- pos += snprintf(pos, MAX_BUF_SIZE, "%c,", ']');
+ EMIT("%d,", c->rssi);
+ EMIT("%u,", c->snr);
+ EMIT("%u,", c->data_bw);
+ EMIT("%u,", c->pri_ch_idx);
+ EMIT("%u,", c->rx_mode);
+ EMIT("%d,", c->tx_idx);
+ EMIT("%d,", c->rx_idx);
+ EMIT("%d,", c->chain_info);
+ EMIT("%d,", c->ext_info);
- pos += snprintf(pos, MAX_BUF_SIZE, "%c", '[');
+ EMIT("%c", '[');
for (j = 0; j < c->data_num; j++) {
- pos += snprintf(pos, MAX_BUF_SIZE, "%d", c->data_q[j]);
+ EMIT("%d", c->data_i[j]);
if (j != (c->data_num - 1))
- pos += snprintf(pos, MAX_BUF_SIZE, ",");
+ EMIT(",");
}
- pos += snprintf(pos, MAX_BUF_SIZE, "%c", ']');
+ EMIT("%c,", ']');
- pos += snprintf(pos, MAX_BUF_SIZE, "%c", ']');
+ EMIT("%c", '[');
+ for (j = 0; j < c->data_num; j++) {
+ EMIT("%d", c->data_q[j]);
+ if (j != (c->data_num - 1))
+ EMIT(",");
+ }
+ EMIT("%c", ']');
+
+ EMIT("%c", ']');
if (i != csi_idx - 1)
- pos += snprintf(pos, MAX_BUF_SIZE, ",");
+ EMIT(",");
if (fwrite(buf, 1, pos - buf, f) != (pos - buf)) {
perror("fwrite");
@@ -192,6 +214,7 @@
}
free(buf);
+#undef EMIT
}
if (fwrite("]", 1, 1, f) != 1) {
@@ -221,10 +244,21 @@
return -EINVAL;
#define CSI_DUMP_PER_NUM 3
+/* The driver seeds its per-dump counter on the first callback and only then
+ * decrements it, so a request for N yields N+1 records. Size for that. */
+#define CSI_DUMP_PER_REQ (CSI_DUMP_PER_NUM + 1)
csi_idx = 0;
- csi = (struct csi_data *)calloc(pkt_num, sizeof(*csi));
+ csi_max = pkt_num;
+ csi = (struct csi_data *)calloc(csi_max, sizeof(*csi));
+ if (!csi) {
+ fprintf(stderr, "csi: cannot allocate %d records\n", csi_max);
+ return -ENOMEM;
+ }
- for (i = 0; i < pkt_num / CSI_DUMP_PER_NUM; i++) {
+ for (i = 0; i < DIV_ROUND_UP(pkt_num, CSI_DUMP_PER_REQ); i++) {
+ if (csi_idx >= csi_max)
+ break;
+
if (unl_genl_init(&unl, "nl80211") < 0) {
fprintf(stderr, "Failed to connect to nl80211\n");
return 2;
+2 -2
View File
@@ -32,7 +32,7 @@
"README.md": "7b4eda2e05ab35345e50ec8b8058c0bcbe0fc52e11e8b7447f3d8d231b4ca58d",
"bin/cli.js": "c463451f4cecebf308cbfce74c553ca44edb58462fe4e7bfacc49a3bf3aa0c49",
"brain/corpus/core.jsonl": "a01a42723490e8c4bcf4ceb4233e8115cb7e27fb9a17a802f279d565dc3493fb",
"package.json": "45d9e00dc059e84e2445e9b7a8131a04c4c0241b88908c5e849b964735833601",
"package.json": "b3614446b634a5b522484adb2d1292421c57ffdc57177b054cda759356d3f87c",
"scripts/update-manifest.mjs": "5dbebd536a65c88dd26f6830c5a4b62b3f9058d971304c3c6c410c3e0e78faaa",
"scripts/verify-manifest.mjs": "787351d57f452ee58e67675e09158f17e43adba643981f5148ac144cae17fd80",
"skills/explore.md": "4fccb5aab3705fd0d266ae7cb98523d71cce4eed4623e9c10e46fb2136b690e3",
@@ -54,7 +54,7 @@
"src/repo-trust.js": "c11b2255e43f7cba0c74c33e4a972f60193490821445dce8bab0f97dd47db977",
"src/tools.js": "1ad280eebca0688a52ae08fe5647c8e42a6a8e8e5d70534cc4361dc319c22fbf"
},
"filesDigest": "ad2340db9fc044ffe69d6a9b09a560515cee1d454a405db1b9cc3547afc868c1",
"filesDigest": "01b6331f69562fc216bec49a23ef318daf2e7fed76f3714c1db26c0ebe1c7f5f",
"brainDigest": "a01a42723490e8c4bcf4ceb4233e8115cb7e27fb9a17a802f279d565dc3493fb",
"policyDigest": "73893df248c9a8d79da5451940b40d6b930b9b92999b52ef8bd538c527cd8a47",
"dependencies": {
+1 -1
View File
@@ -1 +1 @@
1027780eb92030366cf78f50402234e7a039cce328163e6f0f0f70934284164e manifest.json
61e790efeca553f1c99d31b4492fe5f535afc99e4007b8209b723b7dda429fd2 manifest.json
+1
View File
@@ -8,6 +8,7 @@
},
"exports": {
".": "./src/tools.js",
"./cli": "./bin/cli.js",
"./brain": "./src/brain.js",
"./guidance": "./src/guidance.js",
"./hosts": "./src/hosts/index.js",
+9
View File
@@ -0,0 +1,9 @@
# Built by `npm run build` (ADR-368); never committed. CI builds, checksums,
# and packs them from source.
wasm/*.wasm
wasm/SHA256SUMS
native/*.node
native/SHA256SUMS
node_modules/
*.tgz
dist-pack/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ruvnet
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+94
View File
@@ -0,0 +1,94 @@
# @ruvnet/ruview-kernel
RuView's Rust signal pipeline for JavaScript. The ADR-021 vitals pipeline
(`wifi-densepose-vitals`) is compiled to **zero-import WebAssembly** (default)
and, optionally, a **napi-rs** native addon. Both expose the same one-function
JSON ABI, so results match across backends (ADR-368).
> Outputs are signal-processing estimates, not clinical or camera-grade
> measurements. `synthesize()` data and every self-test number are
> **SYNTHETIC**.
## Build from a RuView checkout
```bash
rustup target add wasm32-unknown-unknown
cd harness/ruview-kernel
npm run build # wasm + napi for this platform, with SHA256SUMS
npm test # unit, security, and end-to-end tests
node bin/cli.js parity # wasm vs napi on identical SYNTHETIC input
```
`npm run build:wasm` alone is enough for the portable backend.
## CLI
```bash
ruview-kernel doctor # both backends, ABI, integrity
ruview-kernel selftest [--backend wasm|napi|auto]
ruview-kernel synth --seconds 60 --out frames.json
ruview-kernel analyze --file frames.json --readings
ruview-kernel bench --backend napi # MEASURED on this host only
```
`analyze` accepts a JSON array of frames, `{ "config": {...}, "frames": [...] }`,
or JSONL (one `{"amplitudes":[...],"phases":[...]}` per line).
## API
```js
import { loadKernel, selfTest } from '@ruvnet/ruview-kernel';
const kernel = loadKernel(); // wasm; or { backend: 'napi' | 'auto' }
console.log(kernel.backend, kernel.fallbackReason, kernel.integrity);
const { frames } = kernel.synthesize({ seconds: 60, breathing_bpm: 15 });
const report = kernel.analyze(frames, { n_subcarriers: 56, sample_rate_hz: 20 });
console.log(report.summary.last.respiratory); // { bpm, confidence, status }
const session = kernel.openSession({ n_subcarriers: 56 });
const readings = session.push(frames.slice(0, 200));
session.close();
```
### Binary fast path
`analyze()` and `session.push()` automatically send uniform frames as one
`Float64Array` (all amplitudes, then all phases) instead of JSON numbers.
Irregular input still goes through JSON, so errors name the bad frame. To
skip packing entirely:
```js
const data = new Float64Array(frames * 56 * 2); // amplitudes…, then phases…
kernel.analyzeFlat(data, { n_subcarriers: 56 }, { phases: true });
```
MEASURED on one Linux x64 host (`ruview-kernel bench --seconds 120`): 2.8×
(wasm) and 2.5× (napi) faster than the JSON transport for 2 400 frames. The
binary path is also bit-exact, while JSON number parsing can move inputs by
ULPs.
In a browser, fetch the module yourself and use the filesystem-free entry:
```js
import { loadWasmKernelFromBytes } from '@ruvnet/ruview-kernel/wasm';
const kernel = loadWasmKernelFromBytes(new Uint8Array(await (await fetch(url)).arrayBuffer()));
```
## Backends and trust
| Backend | Authority | Selection |
|---|---|---|
| `wasm` | none: module imports nothing, loader rejects any import | default |
| `napi` | full process (native code) | `backend: 'napi'`; never falls back silently |
| `auto` | napi if present, otherwise wasm with `fallbackReason` | opt-in |
Artifacts are checked against `SHA256SUMS`; a mismatch is always fatal.
Requests are bounded (16 MiB, 512 subcarriers, 100 000 frames per call, 64
sessions) and validated before processing.
## Metaharness
`npx @ruvnet/ruview kernel` (MCP tool `ruview_kernel_selftest`) runs this
package's self-test from the RuView harness when it is installed, and reports
`kernel_not_installed` otherwise.
@@ -0,0 +1,42 @@
#!/usr/bin/env node
// SPDX-License-Identifier: MIT
// Reproducer for the ADR-368 amendment's rejected WASM build options.
// Builds the kernel three ways into separate target dirs and reports size,
// median analyzeFlat time over 15 runs (2 400 SYNTHETIC frames), and whether
// outputs are byte-identical to the baseline build.
// node bench/wasm-variants.mjs (from harness/ruview-kernel; needs cargo + wasm32 target)
import { spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { flattenFrames } from '../src/abi.js';
import { loadWasmKernelFromBytes } from '../src/wasm.js';
const V2 = resolve(import.meta.dirname, '..', '..', '..', 'v2');
const variants = {
baseline: {},
simd128: { CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS: '-C target-feature=+simd128' },
'opt-level-s': { CARGO_PROFILE_RELEASE_OPT_LEVEL: 's' },
};
const built = {};
for (const [name, env] of Object.entries(variants)) {
const targetDir = join(tmpdir(), `ruview-kernel-variant-${name}`);
const r = spawnSync('cargo', ['build', '-q', '-p', 'ruview-kernel', '--release', '--target', 'wasm32-unknown-unknown', '--target-dir', targetDir], { cwd: V2, env: { ...process.env, ...env }, stdio: 'inherit' });
if (r.status !== 0) process.exit(1);
built[name] = readFileSync(join(targetDir, 'wasm32-unknown-unknown', 'release', 'ruview_kernel.wasm'));
}
const base = loadWasmKernelFromBytes(built.baseline);
const { frames } = base.synthesize({ seconds: 120 });
const { data } = flattenFrames(frames, 56);
const reference = JSON.stringify(base.analyzeFlat(data, {}, { phases: true, includeReadings: true }));
const rows = [];
for (const [name, bytes] of Object.entries(built)) {
const k = loadWasmKernelFromBytes(bytes);
const identical = JSON.stringify(k.analyzeFlat(data, {}, { phases: true, includeReadings: true })) === reference;
for (let i = 0; i < 3; i++) k.analyzeFlat(data, {}, { phases: true });
const runs = [];
for (let i = 0; i < 15; i++) { const t = performance.now(); k.analyzeFlat(data, {}, { phases: true }); runs.push(performance.now() - t); }
runs.sort((a, b) => a - b);
rows.push({ variant: name, bytes: bytes.length, medianMs: Number(runs[7].toFixed(1)), identicalToBaseline: identical });
}
console.log(JSON.stringify({ evidence: 'MEASURED (this host only)', frames: frames.length, rows }, null, 2));
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env node
// SPDX-License-Identifier: MIT
// `npx @ruvnet/ruview-kernel` — run the RuView compute kernel from the shell
// (ADR-368). Zero runtime dependencies; WASM is the default backend.
import { closeSync, openSync, readFileSync, readSync, realpathSync, statSync, writeFileSync } from 'node:fs';
import { argv } from 'node:process';
import { fileURLToPath } from 'node:url';
import { MAX_INPUT_BYTES } from '../src/abi.js';
import { BACKENDS, kernelStatus, loadKernel, parity, selfTest } from '../src/index.js';
const pjson = (value) => console.log(JSON.stringify(value, null, 2));
function parseFlags(rest) {
const flags = {};
for (let i = 0; i < rest.length; i++) {
const a = rest[i];
if (!a.startsWith('--')) continue;
const eq = a.indexOf('=');
if (eq !== -1) flags[a.slice(2, eq)] = a.slice(eq + 1);
else if (i + 1 < rest.length && !rest[i + 1].startsWith('--')) flags[a.slice(2)] = rest[++i];
else flags[a.slice(2)] = true;
}
return flags;
}
function number(flags, key, fallback) {
if (flags[key] === undefined) return fallback;
const n = Number(flags[key]);
if (!Number.isFinite(n)) throw new Error(`--${key} must be a number`);
return n;
}
/** Bounded read of a caller-named regular file (JSON array/object or JSONL frames). */
function readFrames(path) {
const stat = statSync(path);
if (!stat.isFile()) throw new Error(`${path} is not a regular file`);
if (stat.size > MAX_INPUT_BYTES) throw new Error(`${path} exceeds ${MAX_INPUT_BYTES} bytes`);
const fd = openSync(path, 'r');
let text;
try {
const buf = Buffer.alloc(stat.size);
readSync(fd, buf, 0, stat.size, 0);
text = buf.toString('utf8');
} finally {
closeSync(fd);
}
const trimmed = text.trim();
let parsed;
try {
parsed = JSON.parse(trimmed);
} catch {
// Not a single JSON document: treat as JSONL, one frame per line.
const frames = trimmed.split('\n').filter((l) => l.trim()).map((l, i) => {
try { return JSON.parse(l); } catch { throw new Error(`line ${i + 1} is not valid JSON`); }
});
return { frames };
}
if (Array.isArray(parsed)) return { frames: parsed };
if (Array.isArray(parsed?.frames)) return { frames: parsed.frames, config: parsed.config };
if (parsed && Array.isArray(parsed.amplitudes)) return { frames: [parsed] };
throw new Error('input must be a JSON array of frames, {frames, config}, or JSONL');
}
function help() {
console.log(`Usage: ruview-kernel <command> [--backend wasm|napi|auto]
doctor [--strict-napi] report both backends, ABI, and artifact integrity
info kernel build info and limits
selftest [--seconds 60] SYNTHETIC end-to-end pipeline check
parity diff wasm vs napi outputs on the same SYNTHETIC input
synth [--seconds 60] [--breathing 15] [--heart 72] [--subcarriers 56] [--seed 7] [--out file.json]
analyze --file frames.json|frames.jsonl [--config '{"n_subcarriers":56}'] [--readings]
bench [--seconds 120] time analyze on SYNTHETIC input (MEASURED on this host)
Default backend: RUVIEW_KERNEL_BACKEND or wasm (zero-import, least authority).
Outputs are signal-processing estimates, not clinical or camera-grade measurements.`);
return 0;
}
export async function run(args) {
const cmd = args[0] ?? '--help';
const flags = parseFlags(args.slice(1));
if (flags.backend !== undefined && !BACKENDS.includes(flags.backend)) {
console.error(`--backend must be one of ${BACKENDS.join(', ')}`);
return 2;
}
const load = () => loadKernel({ backend: flags.backend });
switch (cmd) {
case 'doctor': {
const status = kernelStatus();
const ok = status.wasm.available && (!flags['strict-napi'] || status.napi.available);
pjson({ ok, ...status, note: status.napi.available ? 'both backends available' : 'napi optional; wasm backend active by default' });
return ok ? 0 : 1;
}
case 'info': {
const k = load();
pjson({ backend: k.backend, requestedBackend: k.requestedBackend, fallbackReason: k.fallbackReason, integrity: k.integrity, ...k.info() });
return 0;
}
case 'selftest': {
const k = load();
const result = selfTest(k, { seconds: number(flags, 'seconds', 60) });
pjson({ ...result, requestedBackend: k.requestedBackend, fallbackReason: k.fallbackReason });
return result.ok ? 0 : 1;
}
case 'parity': {
const result = parity(loadKernel({ backend: 'wasm' }), loadKernel({ backend: 'napi' }));
pjson(result);
return result.ok ? 0 : 1;
}
case 'synth': {
const out = load().synthesize({
seconds: number(flags, 'seconds', 60),
breathing_bpm: number(flags, 'breathing', 15),
heart_bpm: number(flags, 'heart', 72),
n_subcarriers: number(flags, 'subcarriers', 56),
sample_rate_hz: number(flags, 'rate', 20),
seed: number(flags, 'seed', 7),
});
const body = JSON.stringify({ evidence: out.evidence, config: { n_subcarriers: out.request.n_subcarriers, sample_rate_hz: out.request.sample_rate_hz }, frames: out.frames });
if (typeof flags.out === 'string') {
writeFileSync(flags.out, `${body}\n`, { flag: 'wx' }); // never overwrite an existing file
pjson({ ok: true, evidence: out.evidence, frames: out.frames.length, out: flags.out });
} else console.log(body);
return 0;
}
case 'analyze': {
if (typeof flags.file !== 'string') { console.error('analyze: --file <frames.json|frames.jsonl> is required'); return 2; }
const input = readFrames(flags.file);
const config = typeof flags.config === 'string' ? JSON.parse(flags.config) : (input.config || {});
const k = load();
pjson({ ok: true, backend: k.backend, ...k.analyze(input.frames, config, { includeReadings: flags.readings === true }) });
return 0;
}
case 'bench': {
const k = load();
const seconds = number(flags, 'seconds', 120);
const { frames } = k.synthesize({ seconds });
k.analyze(frames.slice(0, 200)); // warm-up
const iterations = 5;
const time = (fn) => { const t0 = performance.now(); for (let i = 0; i < iterations; i++) fn(); return (performance.now() - t0) / iterations; };
const ms = time(() => k.analyze(frames));
const jsonMs = time(() => k.call('analyze', { config: {}, frames, include_readings: false }));
pjson({
evidence: 'MEASURED',
reproducer: `npx @ruvnet/ruview-kernel bench --backend ${k.backend} --seconds ${seconds}`,
host: { node: process.version, platform: process.platform, arch: process.arch },
backend: k.backend, frames: frames.length, transport: k.binary ? 'binary' : 'json',
msPerAnalyze: Number(ms.toFixed(2)), framesPerSecond: Math.round(frames.length / (ms / 1000)),
jsonTransportMs: Number(jsonMs.toFixed(2)), speedupVsJson: Number((jsonMs / ms).toFixed(2)),
note: 'Includes transfer across the ABI; single-host timing, not a cross-host claim.',
});
return 0;
}
case '--version': case '-v': {
console.log(JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version);
return 0;
}
case '--help': case '-h': case 'help': return help();
default:
console.error(`Unknown command: ${cmd}. Try \`ruview-kernel --help\`.`);
return 2;
}
}
const invokedDirectly = (() => {
if (!argv[1]) return false;
try {
const a = realpathSync(argv[1]);
const b = realpathSync(fileURLToPath(import.meta.url));
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
} catch { return false; }
})();
if (invokedDirectly) {
run(argv.slice(2)).then((code) => process.exit(code)).catch((error) => {
pjson({ ok: false, code: error.code || 'error', error: error.message });
process.exit(1);
});
}
Whitespace-only changes.
+67
View File
@@ -0,0 +1,67 @@
{
"name": "@ruvnet/ruview-kernel",
"version": "0.1.0",
"description": "RuView portable compute kernel for Node and browsers: the ADR-021 vitals pipeline in Rust, shipped as zero-import WebAssembly with an optional napi-rs native backend and a byte-identical JSON ABI.",
"type": "module",
"bin": {
"ruview-kernel": "bin/cli.js"
},
"exports": {
".": "./src/index.js",
"./cli": "./bin/cli.js",
"./wasm": "./src/wasm.js",
"./abi": "./src/abi.js",
"./package.json": "./package.json"
},
"files": [
"bin/",
"src/",
"wasm/ruview_kernel.wasm",
"wasm/SHA256SUMS",
"native/*.node",
"native/SHA256SUMS",
"scripts/",
"README.md",
"LICENSE"
],
"scripts": {
"build": "node ./scripts/build.mjs --wasm --napi",
"build:wasm": "node ./scripts/build.mjs --wasm",
"build:napi": "node ./scripts/build.mjs --napi",
"test": "node --test",
"test:security": "node --test test/abi.test.mjs test/security.test.mjs",
"test:e2e": "node --test test/e2e.test.mjs",
"doctor": "node ./bin/cli.js doctor",
"selftest": "node ./bin/cli.js selftest",
"parity": "node ./bin/cli.js parity",
"prepack": "node ./scripts/verify-artifacts.mjs --require-wasm"
},
"keywords": [
"ruview",
"wifi-sensing",
"csi",
"channel-state-information",
"vital-signs",
"webassembly",
"wasm",
"napi-rs",
"metaharness"
],
"engines": {
"node": ">=20.0.0"
},
"license": "MIT",
"author": "ruvnet",
"homepage": "https://github.com/ruvnet/RuView#readme",
"repository": {
"type": "git",
"url": "git+https://github.com/ruvnet/RuView.git",
"directory": "harness/ruview-kernel"
},
"bugs": {
"url": "https://github.com/ruvnet/RuView/issues"
},
"publishConfig": {
"access": "public"
}
}
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env node
// SPDX-License-Identifier: MIT
// Build the ruview-kernel artifacts from the Rust source in ../../v2 (ADR-368).
// --wasm cargo build -p ruview-kernel --target wasm32-unknown-unknown --release
// --napi cargo build --manifest-path v2/crates/ruview-kernel-napi/Cargo.toml --release
// Artifacts are copied into wasm/ and native/ with SHA256SUMS. No shell is used.
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { platformTriple } from '../src/platform.js';
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
const V2 = resolve(ROOT, '..', '..', 'v2');
const args = new Set(process.argv.slice(2));
const wantWasm = args.has('--wasm');
const wantNapi = args.has('--napi');
if (!wantWasm && !wantNapi) {
console.error('Usage: node scripts/build.mjs [--wasm] [--napi]');
process.exit(2);
}
if (!existsSync(join(V2, 'Cargo.toml'))) {
console.error(`RuView Rust workspace not found at ${V2}; build from a RuView checkout.`);
process.exit(2);
}
function cargo(cargoArgs, cwd) {
console.error(`$ cargo ${cargoArgs.join(' ')}`);
const r = spawnSync('cargo', cargoArgs, { cwd, stdio: 'inherit' });
if (r.status !== 0) {
console.error(`cargo failed (${r.error?.message || `exit ${r.status}`})`);
const missing = uninitializedSubmodules();
if (missing.length) {
// The v2 workspace resolves path dependencies inside submodules; a fresh
// clone or `git worktree add` leaves them empty and cargo fails on a
// missing Cargo.toml.
console.error(`Uninitialized git submodules (likely cause): ${missing.join(', ')}`);
console.error('Fix: git submodule update --init --recursive (run at the repository root)');
}
process.exit(1);
}
}
function uninitializedSubmodules() {
const r = spawnSync('git', ['submodule', 'status'], { cwd: join(V2, '..'), encoding: 'utf8' });
if (r.status !== 0) return [];
return r.stdout.split('\n').filter((l) => l.startsWith('-')).map((l) => l.slice(1).trim().split(/\s+/)[1]).filter(Boolean);
}
const sha256 = (path) => createHash('sha256').update(readFileSync(path)).digest('hex');
function install(src, dir, name) {
mkdirSync(join(ROOT, dir), { recursive: true });
const dest = join(ROOT, dir, name);
copyFileSync(src, dest);
const digest = sha256(dest);
// Merge: one line per artifact, so several platform addons can share native/.
const sums = join(ROOT, dir, 'SHA256SUMS');
const kept = existsSync(sums)
? readFileSync(sums, 'utf8').split('\n').filter((l) => l.trim() && !l.trim().endsWith(` ${name}`))
: [];
writeFileSync(sums, [...kept, `${digest} ${name}`].sort((a, b) => a.slice(66).localeCompare(b.slice(66))).join('\n') + '\n');
return { artifact: `${dir}/${name}`, sha256: digest };
}
/** Fail fast when the toolchain v2/ pins lacks the wasm32 target. */
function requireWasmTarget() {
const r = spawnSync('rustup', ['target', 'list', '--installed'], { cwd: V2, encoding: 'utf8' });
if (r.status === 0 && !r.stdout.split(/\r?\n/).includes('wasm32-unknown-unknown')) {
console.error('wasm32-unknown-unknown is not installed for the toolchain pinned by v2/rust-toolchain.');
console.error('Fix: (cd v2 && rustup target add wasm32-unknown-unknown)');
process.exit(1);
}
}
const built = [];
if (wantWasm) {
requireWasmTarget();
cargo(['build', '-p', 'ruview-kernel', '--release', '--target', 'wasm32-unknown-unknown', '--locked'], V2);
const src = join(V2, 'target', 'wasm32-unknown-unknown', 'release', 'ruview_kernel.wasm');
built.push(install(src, 'wasm', 'ruview_kernel.wasm'));
}
if (wantNapi) {
const crate = join(V2, 'crates', 'ruview-kernel-napi');
cargo(['build', '--release', '--locked', '--manifest-path', join(crate, 'Cargo.toml')], V2);
const lib = { win32: 'ruview_kernel_napi.dll', darwin: 'libruview_kernel_napi.dylib' }[process.platform] || 'libruview_kernel_napi.so';
const src = join(crate, 'target', 'release', lib);
built.push(install(src, 'native', `ruview_kernel.${platformTriple()}.node`));
}
console.log(JSON.stringify({ ok: true, built }, null, 2));
@@ -0,0 +1,44 @@
#!/usr/bin/env node
// SPDX-License-Identifier: MIT
// Verify packaged kernel artifacts before pack/publish (ADR-368):
// checksums match, the WASM module imports nothing, and ABI versions agree.
import { existsSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { ABI_VERSION } from '../src/abi.js';
import { DEFAULT_WASM_PATH, PACKAGE_ROOT, loadKernel, readVerifiedArtifact } from '../src/index.js';
import { compileKernelModule } from '../src/wasm.js';
const requireWasm = process.argv.includes('--require-wasm');
const quiet = process.argv.includes('--quiet');
const report = { ok: true, wasm: null, native: [] };
const fail = (message) => { report.ok = false; (report.errors ||= []).push(message); };
if (existsSync(DEFAULT_WASM_PATH)) {
try {
const artifact = readVerifiedArtifact(DEFAULT_WASM_PATH);
if (artifact.integrity !== 'verified') fail('wasm/ruview_kernel.wasm has no SHA256SUMS entry');
compileKernelModule(artifact.bytes);
const kernel = loadKernel({ backend: 'wasm' });
if (kernel.info().abi !== ABI_VERSION) fail('WASM ABI mismatch');
report.wasm = { sha256: artifact.sha256, bytes: artifact.bytes.length, imports: 0 };
} catch (error) {
fail(`wasm: ${error.message}`);
}
} else if (requireWasm) {
fail('wasm/ruview_kernel.wasm is missing; run `npm run build:wasm`');
}
const nativeDir = join(PACKAGE_ROOT, 'native');
for (const name of existsSync(nativeDir) ? readdirSync(nativeDir).filter((n) => n.endsWith('.node')).sort() : []) {
try {
const artifact = readVerifiedArtifact(join(nativeDir, name));
if (artifact.integrity !== 'verified') fail(`native/${name} has no SHA256SUMS entry`);
report.native.push({ name, sha256: artifact.sha256 });
} catch (error) {
fail(`native/${name}: ${error.message}`);
}
}
if (!quiet || !report.ok) console.log(JSON.stringify(report, null, 2));
process.exit(report.ok ? 0 : 1);
+131
View File
@@ -0,0 +1,131 @@
// SPDX-License-Identifier: MIT
// Shared JSON-ABI envelope handling for every ruview-kernel transport (ADR-368).
export const ABI_VERSION = 1;
export const MAX_INPUT_BYTES = 16 * 1024 * 1024;
export const OPERATIONS = Object.freeze([
'info', 'validate_config', 'analyze', 'synthesize',
'session_open', 'session_push', 'session_summary', 'session_close',
'analyze_flat', 'session_push_flat',
]);
const DEFAULT_SUBCARRIERS = 56;
const FRAME_KEYS = new Set(['amplitudes', 'phases']);
/** Error raised for a `{ok:false}` kernel envelope or a transport failure. */
export class KernelError extends Error {
constructor(code, message, details = {}) {
super(message);
this.name = 'KernelError';
this.code = code;
Object.assign(this, details);
}
}
/** Serialize a request, enforcing the ABI's operation allow-list and size bound. */
export function encodeRequest(op, request) {
if (!OPERATIONS.includes(op)) throw new KernelError('unknown_operation', `unknown operation: ${String(op).slice(0, 64)}`);
const json = typeof request === 'string' ? request : JSON.stringify(request ?? {});
// UTF-8 needs at most 3 bytes per UTF-16 unit; only encode when it could matter.
const bytes = json.length * 3 <= MAX_INPUT_BYTES ? json.length : new TextEncoder().encode(json).length;
if (bytes > MAX_INPUT_BYTES) {
throw new KernelError('limit_exceeded', `request exceeds ${MAX_INPUT_BYTES} bytes`);
}
return json;
}
/** Parse a kernel envelope; returns `result` or throws KernelError. */
export function decodeResponse(text) {
let envelope;
try {
envelope = JSON.parse(text);
} catch {
throw new KernelError('internal', 'kernel returned malformed JSON');
}
if (envelope?.ok === true) return envelope.result;
const error = envelope?.error || {};
throw new KernelError(String(error.code || 'internal'), String(error.message || 'kernel error'));
}
/**
* Pack uniform frames into one Float64Array: all amplitudes, then all phases.
* Returns null whenever the JSON path must handle the input instead, so that
* malformed frames still get the kernel's precise per-frame error messages.
*/
export function flattenFrames(frames, n) {
if (!Array.isArray(frames) || !Number.isInteger(n) || n < 1) return null;
const count = frames.length;
const withPhases = count > 0 && Array.isArray(frames[0]?.phases) && frames[0].phases.length > 0;
const data = new Float64Array(count * n * (withPhases ? 2 : 1));
const phaseBase = count * n;
for (let k = 0; k < count; k++) {
const f = frames[k];
if (!f || typeof f !== 'object' || !Array.isArray(f.amplitudes) || f.amplitudes.length !== n) return null;
for (const key of Object.keys(f)) if (!FRAME_KEYS.has(key)) return null;
const ph = f.phases ?? [];
if (!Array.isArray(ph) || ph.length !== (withPhases ? n : 0)) return null;
for (let i = 0; i < n; i++) {
const a = f.amplitudes[i];
if (typeof a !== 'number') return null;
data[k * n + i] = a;
if (withPhases) {
const p = ph[i];
if (typeof p !== 'number') return null;
data[phaseBase + k * n + i] = p;
}
}
}
return { data, withPhases };
}
/**
* Wrap a raw transport `(op, json) => json` into the high-level kernel API.
* Both backends go through here, so their behaviour differs only in transport.
* `transportF64(op, json, Float64Array)` is the optional binary fast path.
*/
export function createKernel(transport, meta, transportF64 = null) {
const call = (op, request) => decodeResponse(transport(op, encodeRequest(op, request)));
const callF64 = (op, request, data) => {
if (!(data instanceof Float64Array)) throw new KernelError('invalid_request', 'binary frame data must be a Float64Array');
if (data.byteLength > MAX_INPUT_BYTES) throw new KernelError('limit_exceeded', `binary data exceeds ${MAX_INPUT_BYTES} bytes`);
return decodeResponse(transportF64(op, encodeRequest(op, request), data));
};
const flat = (frames, config) => (transportF64 ? flattenFrames(frames, config?.n_subcarriers ?? DEFAULT_SUBCARRIERS) : null);
const kernel = {
...meta,
binary: Boolean(transportF64),
call,
info: () => call('info', {}),
validateConfig: (config = {}) => call('validate_config', config),
analyze(frames, config = {}, { includeReadings = false } = {}) {
const packed = flat(frames, config);
return packed
? callF64('analyze_flat', { config, phases: packed.withPhases, include_readings: includeReadings }, packed.data)
: call('analyze', { config, frames, include_readings: includeReadings });
},
/** Zero-copy entry: amplitudes for every frame, then phases if `phases`. */
analyzeFlat(data, config = {}, { phases = false, includeReadings = false } = {}) {
if (!transportF64) throw new KernelError('backend_unavailable', 'this kernel build has no binary transport');
return callF64('analyze_flat', { config, phases, include_readings: includeReadings }, data);
},
synthesize: (options = {}) => call('synthesize', options),
openSession(config = {}) {
const n = config?.n_subcarriers ?? DEFAULT_SUBCARRIERS;
const { session } = call('session_open', { config });
let open = true;
const ensureOpen = () => { if (!open) throw new KernelError('unknown_session', 'session is closed'); };
return {
id: session,
push(frames) {
ensureOpen();
const packed = transportF64 ? flattenFrames(frames, n) : null;
return packed
? callF64('session_push_flat', { session, phases: packed.withPhases }, packed.data).readings
: call('session_push', { session, frames }).readings;
},
summary() { ensureOpen(); return call('session_summary', { session }).summary; },
close() { if (!open) return false; open = false; return call('session_close', { session }).closed; },
};
},
};
return Object.freeze(kernel);
}
+218
View File
@@ -0,0 +1,218 @@
// SPDX-License-Identifier: MIT
// @ruvnet/ruview-kernel — Node loader with explicit backend selection (ADR-368).
//
// Backends:
// wasm (default) — zero-import WebAssembly: no host authority at all.
// napi — optional napi-rs native addon; full process authority,
// so it is only used when requested (`napi`) or allowed
// (`auto`, which prefers napi and falls back to wasm).
// The loader always reports which backend actually runs and why any
// fallback happened; it never silently substitutes a backend for `napi`.
import { createHash } from 'node:crypto';
import { existsSync, readFileSync, statSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
import { ABI_VERSION, KernelError, createKernel } from './abi.js';
import { loadWasmKernelFromBytes } from './wasm.js';
import { platformTriple } from './platform.js';
export { ABI_VERSION, KernelError, OPERATIONS } from './abi.js';
export { loadWasmKernelFromBytes } from './wasm.js';
export const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
export const DEFAULT_WASM_PATH = join(PACKAGE_ROOT, 'wasm', 'ruview_kernel.wasm');
export const BACKENDS = Object.freeze(['wasm', 'napi', 'auto']);
const MAX_ARTIFACT_BYTES = 64 * 1024 * 1024;
const require = createRequire(import.meta.url);
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
/** Parse a `sha256 name` checksum file next to an artifact. */
function expectedDigest(artifactPath) {
const sums = join(dirname(artifactPath), 'SHA256SUMS');
if (!existsSync(sums)) return null;
for (const line of readFileSync(sums, 'utf8').split('\n')) {
const match = /^([0-9a-f]{64})\s+\*?(.+)$/.exec(line.trim());
if (match && match[2] === basename(artifactPath)) return match[1];
}
return null;
}
/**
* Read an artifact and check it against its SHA256SUMS entry. A missing
* checksum is reported (integrity `unverified`); a mismatch always fails.
*/
export function readVerifiedArtifact(path) {
const stat = statSync(path);
if (!stat.isFile()) throw new KernelError('artifact_invalid', `${path} is not a regular file`);
if (stat.size > MAX_ARTIFACT_BYTES) throw new KernelError('artifact_invalid', `${path} exceeds ${MAX_ARTIFACT_BYTES} bytes`);
const bytes = readFileSync(path);
const digest = sha256(bytes);
const expected = expectedDigest(path);
if (expected && expected !== digest) {
throw new KernelError('integrity_mismatch', `${basename(path)} sha256 ${digest} does not match SHA256SUMS ${expected}`);
}
return { bytes, sha256: digest, integrity: expected ? 'verified' : 'unverified' };
}
/** Candidate native addon locations, most specific first. */
export function nativeCandidates(triple = platformTriple()) {
const candidates = [join(PACKAGE_ROOT, 'native', `ruview_kernel.${triple}.node`)];
try {
candidates.push(require.resolve(`@ruvnet/ruview-kernel-${triple}`));
} catch { /* optional platform package not installed */ }
return candidates;
}
function loadWasm(options) {
const path = options.wasmPath || DEFAULT_WASM_PATH;
if (!existsSync(path)) {
throw new KernelError('backend_unavailable', `WASM kernel not found at ${path}; run \`npm run build:wasm\` in harness/ruview-kernel`);
}
const artifact = readVerifiedArtifact(path);
return loadWasmKernelFromBytes(artifact.bytes, { artifact: path, sha256: artifact.sha256, integrity: artifact.integrity });
}
function loadNapi(options) {
const triple = platformTriple();
const candidates = options.nativePath ? [options.nativePath] : nativeCandidates(triple);
const path = candidates.find((p) => existsSync(p));
if (!path) {
throw new KernelError('backend_unavailable', `no napi-rs addon for ${triple}; run \`npm run build:napi\` or use the wasm backend`);
}
const artifact = readVerifiedArtifact(path);
const addon = require(path);
if (typeof addon?.call !== 'function' || typeof addon?.abiVersion !== 'function') {
throw new KernelError('untrusted_module', `${basename(path)} does not expose the ruview-kernel ABI`);
}
const abi = addon.abiVersion();
if (abi !== ABI_VERSION) throw new KernelError('abi_mismatch', `napi ABI ${abi} != loader ABI ${ABI_VERSION}`);
return createKernel((op, json) => addon.call(op, json), {
backend: 'napi', abi, triple, artifact: path, sha256: artifact.sha256, integrity: artifact.integrity,
}, typeof addon.callF64 === 'function' ? (op, json, data) => addon.callF64(op, json, data) : null);
}
/**
* Load the kernel. `backend` defaults to RUVIEW_KERNEL_BACKEND, then `wasm`.
* Options: { backend, wasmPath, nativePath, requireIntegrity }.
*/
export function loadKernel(options = {}) {
const requested = options.backend || process.env.RUVIEW_KERNEL_BACKEND || 'wasm';
if (!BACKENDS.includes(requested)) {
throw new KernelError('invalid_backend', `backend must be one of ${BACKENDS.join(', ')}`);
}
let kernel;
let fallbackReason = null;
if (requested === 'wasm') kernel = loadWasm(options);
else if (requested === 'napi') kernel = loadNapi(options);
else {
try {
kernel = loadNapi(options);
} catch (error) {
if (error.code === 'integrity_mismatch') throw error; // tampering is never a fallback case
fallbackReason = error.message;
kernel = loadWasm(options);
}
}
if (options.requireIntegrity && kernel.integrity !== 'verified') {
throw new KernelError('integrity_unverified', `${kernel.backend} artifact has no SHA256SUMS entry`);
}
return Object.freeze({ ...kernel, requestedBackend: requested, fallbackReason });
}
/** Non-throwing availability report for both backends (used by `doctor`). */
export function kernelStatus(options = {}) {
const probe = (backend) => {
try {
const k = loadKernel({ ...options, backend });
const info = k.info();
return { available: true, version: info.version, abi: k.abi, target: info.target, artifact: k.artifact, sha256: k.sha256, integrity: k.integrity };
} catch (error) {
return { available: false, code: error.code || 'error', reason: error.message };
}
};
return { node: process.version, triple: platformTriple(), abi: ABI_VERSION, wasm: probe('wasm'), napi: probe('napi') };
}
const within = (value, target, tolerance) => typeof value === 'number' && Math.abs(value - target) <= tolerance;
/**
* Deterministic end-to-end self-test on SYNTHETIC CSI with known tones.
* Exercises synthesize → analyze → streaming session → validation errors.
*/
export function selfTest(kernel, { seconds = 60, breathingBpm = 15, heartBpm = 72 } = {}) {
const started = performance.now();
const checks = [];
const check = (name, pass, detail = undefined) => checks.push({ name, pass: Boolean(pass), ...(detail === undefined ? {} : { detail }) });
const info = kernel.info();
check('abi matches loader', info.abi === ABI_VERSION, { abi: info.abi });
const synth = kernel.synthesize({ seconds, breathing_bpm: breathingBpm, heart_bpm: heartBpm, seed: 7 });
check('synthetic data is labelled SYNTHETIC', synth.evidence === 'SYNTHETIC');
const report = kernel.analyze(synth.frames, {}, { includeReadings: true });
const last = report.summary.last;
check('one reading per second', report.readings.length === Math.round(seconds), { readings: report.readings.length });
check('respiratory rate recovered within 3 bpm', within(last?.respiratory.bpm, breathingBpm, 3), { bpm: last?.respiratory.bpm });
check('heart rate recovered within 8 bpm', within(last?.heart.bpm, heartBpm, 8), { bpm: last?.heart.bpm });
const session = kernel.openSession({});
for (let i = 0; i < synth.frames.length; i += 97) session.push(synth.frames.slice(i, i + 97));
const streamed = session.summary();
session.close();
check('streaming session equals batch analysis', JSON.stringify(streamed) === JSON.stringify(report.summary));
let rejected = null;
try { kernel.analyze([{ amplitudes: [1] }], { n_subcarriers: 2 }); } catch (error) { rejected = error.code; }
check('malformed frame rejected fail-closed', rejected === 'invalid_request', { code: rejected });
return {
ok: checks.every((c) => c.pass),
evidence: 'SYNTHETIC',
backend: kernel.backend,
kernel: { version: info.version, abi: info.abi, target: info.target },
summary: report.summary,
checks,
elapsedMs: Math.round(performance.now() - started),
};
}
/** Numeric deep comparison with a relative tolerance (libm may differ by ULPs across targets). */
export function deepClose(a, b, tolerance = 1e-9, path = '$') {
if (typeof a === 'number' && typeof b === 'number') {
const scale = Math.max(1, Math.abs(a), Math.abs(b));
return Math.abs(a - b) <= tolerance * scale ? [] : [`${path}: ${a} != ${b}`];
}
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return [`${path}: length ${a.length} != ${b.length}`];
return a.flatMap((v, i) => deepClose(v, b[i], tolerance, `${path}[${i}]`));
}
if (a && b && typeof a === 'object' && typeof b === 'object') {
const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
return keys.flatMap((k) => deepClose(a[k], b[k], tolerance, `${path}.${k}`));
}
return Object.is(a, b) ? [] : [`${path}: ${JSON.stringify(a)} != ${JSON.stringify(b)}`];
}
/** Run the same SYNTHETIC workload on two kernels and diff every output. */
export function parity(left, right, { seconds = 45 } = {}) {
const request = { seconds, seed: 11, n_subcarriers: 32 };
const a = left.synthesize(request);
const b = right.synthesize(request);
const synthDiff = deepClose(a.frames, b.frames);
const ra = left.analyze(a.frames, { n_subcarriers: 32 }, { includeReadings: true });
const rb = right.analyze(a.frames, { n_subcarriers: 32 }, { includeReadings: true });
const analyzeDiff = deepClose(ra, rb);
const differences = [...synthDiff, ...analyzeDiff];
return {
ok: differences.length === 0,
evidence: 'SYNTHETIC',
backends: [left.backend, right.backend],
frames: a.frames.length,
readings: ra.readings.length,
exactJsonMatch: JSON.stringify(ra) === JSON.stringify(rb),
differences: differences.slice(0, 20),
};
}
+25
View File
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: MIT
// napi-rs platform triple resolution (matches @napi-rs/cli naming).
import { readFileSync } from 'node:fs';
function isMusl() {
if (process.platform !== 'linux') return false;
try {
const report = process.report?.getReport?.();
const header = typeof report === 'string' ? JSON.parse(report).header : report?.header;
if (header && 'glibcVersionRuntime' in header) return !header.glibcVersionRuntime;
} catch { /* fall through */ }
try {
return readFileSync('/usr/bin/ldd', 'utf8').includes('musl');
} catch {
return false;
}
}
/** e.g. `linux-x64-gnu`, `darwin-arm64`, `win32-x64-msvc`. */
export function platformTriple(platform = process.platform, arch = process.arch) {
if (platform === 'linux') return `linux-${arch}-${isMusl() ? 'musl' : 'gnu'}`;
if (platform === 'win32') return `win32-${arch}-msvc`;
return `${platform}-${arch}`;
}
+101
View File
@@ -0,0 +1,101 @@
// SPDX-License-Identifier: MIT
// Browser- and Node-safe WASM transport (ADR-368). No filesystem access here:
// callers supply the module bytes, so this file can be bundled for the web.
//
// The module imports nothing from the host (no fs, network, clock, or
// randomness); instantiation fails closed if that ever changes.
import { ABI_VERSION, KernelError, createKernel } from './abi.js';
const REQUIRED_EXPORTS = ['memory', 'rvk_abi_version', 'rvk_alloc', 'rvk_free', 'rvk_call'];
const encoder = new TextEncoder();
const decoder = new TextDecoder('utf-8', { fatal: true });
/** Compile and validate a module; rejects any host import. */
export function compileKernelModule(bytes) {
const module = new WebAssembly.Module(bytes);
const imports = WebAssembly.Module.imports(module);
if (imports.length !== 0) {
throw new KernelError('untrusted_module', `kernel module must not import host functions (found ${imports.length})`);
}
const names = new Set(WebAssembly.Module.exports(module).map((e) => e.name));
const missing = REQUIRED_EXPORTS.filter((n) => !names.has(n));
if (missing.length) throw new KernelError('untrusted_module', `kernel module is missing exports: ${missing.join(', ')}`);
return module;
}
function instantiate(module) {
const { exports } = new WebAssembly.Instance(module, {});
const abi = exports.rvk_abi_version();
if (abi !== ABI_VERSION) throw new KernelError('abi_mismatch', `WASM ABI ${abi} != loader ABI ${ABI_VERSION}`);
return exports;
}
function write(exports, text) {
const bytes = encoder.encode(text);
const ptr = exports.rvk_alloc(bytes.length);
if (ptr === 0) throw new KernelError('out_of_memory', 'kernel allocation failed');
new Uint8Array(exports.memory.buffer, ptr, bytes.length).set(bytes);
return [ptr, bytes.length];
}
/**
* Create a raw `(op, json) => json` transport. A trap (Rust panic, OOM)
* poisons the instance; the next call gets a fresh one, and open sessions
* from the trapped instance are lost (reported as `unknown_session`).
*/
export function createWasmTransport(module) {
let exports = instantiate(module);
let generation = 0;
const transport = (op, json) => {
if (!exports) { exports = instantiate(module); generation += 1; }
try {
const [opPtr, opLen] = write(exports, op);
const [inPtr, inLen] = write(exports, json);
const packed = exports.rvk_call(opPtr, opLen, inPtr, inLen);
if (packed === 0n) throw new KernelError('out_of_memory', 'kernel response allocation failed');
const ptr = Number(packed >> 32n);
const len = Number(packed & 0xffffffffn);
const text = decoder.decode(new Uint8Array(exports.memory.buffer, ptr, len).slice());
exports.rvk_free(ptr, len);
return text;
} catch (error) {
if (error instanceof KernelError) throw error;
exports = null;
throw new KernelError('trap', `WASM kernel trapped: ${error?.message || error}`);
}
};
transport.generation = () => generation;
transport.f64 = (op, json, data) => {
if (!exports) { exports = instantiate(module); generation += 1; }
if (typeof exports.rvk_call_f64 !== 'function') throw new KernelError('backend_unavailable', 'WASM module predates the binary transport');
try {
const [opPtr, opLen] = write(exports, op);
const [inPtr, inLen] = write(exports, json);
const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
const dataPtr = exports.rvk_alloc(bytes.length);
if (dataPtr === 0) throw new KernelError('out_of_memory', 'kernel allocation failed');
new Uint8Array(exports.memory.buffer, dataPtr, bytes.length).set(bytes);
const packed = exports.rvk_call_f64(opPtr, opLen, inPtr, inLen, dataPtr, bytes.length);
if (packed === 0n) throw new KernelError('out_of_memory', 'kernel response allocation failed');
const ptr = Number(packed >> 32n);
const len = Number(packed & 0xffffffffn);
const text = decoder.decode(new Uint8Array(exports.memory.buffer, ptr, len).slice());
exports.rvk_free(ptr, len);
return text;
} catch (error) {
if (error instanceof KernelError) throw error;
exports = null;
throw new KernelError('trap', `WASM kernel trapped: ${error?.message || error}`);
}
};
return transport;
}
/** Build the high-level kernel API from WASM bytes (browser or Node). */
export function loadWasmKernelFromBytes(bytes, meta = {}) {
const module = compileKernelModule(bytes);
const transport = createWasmTransport(module);
const hasF64 = WebAssembly.Module.exports(module).some((e) => e.name === 'rvk_call_f64');
return createKernel(transport, { backend: 'wasm', abi: ABI_VERSION, ...meta }, hasF64 ? transport.f64 : null);
}
+47
View File
@@ -0,0 +1,47 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ABI_VERSION, KernelError, MAX_INPUT_BYTES, createKernel, decodeResponse, encodeRequest } from '../src/abi.js';
import { deepClose } from '../src/index.js';
test('encodeRequest enforces the operation allow-list and size bound', () => {
assert.equal(encodeRequest('info', {}), '{}');
assert.throws(() => encodeRequest('rm_rf', {}), (e) => e instanceof KernelError && e.code === 'unknown_operation');
assert.throws(() => encodeRequest('analyze', 'x'.repeat(MAX_INPUT_BYTES + 1)), (e) => e.code === 'limit_exceeded');
// Multi-byte characters count as UTF-8 bytes, not UTF-16 units.
assert.throws(() => encodeRequest('analyze', '€'.repeat(MAX_INPUT_BYTES / 3 + 1)), (e) => e.code === 'limit_exceeded');
});
test('decodeResponse unwraps ok envelopes and raises structured errors', () => {
assert.deepEqual(decodeResponse('{"ok":true,"result":{"a":1}}'), { a: 1 });
assert.throws(() => decodeResponse('{"ok":false,"error":{"code":"invalid_request","message":"bad"}}'),
(e) => e.code === 'invalid_request' && e.message === 'bad');
assert.throws(() => decodeResponse('not json'), (e) => e.code === 'internal');
assert.throws(() => decodeResponse('{"result":1}'), (e) => e.code === 'internal');
});
test('createKernel routes every helper through the single transport', () => {
const calls = [];
const transport = (op, json) => {
calls.push([op, JSON.parse(json)]);
if (op === 'session_open') return '{"ok":true,"result":{"session":9}}';
if (op === 'session_close') return '{"ok":true,"result":{"closed":true}}';
return `{"ok":true,"result":{"op":"${op}","abi":${ABI_VERSION}}}`;
};
const k = createKernel(transport, { backend: 'fake' });
assert.equal(k.info().op, 'info');
k.analyze([{ amplitudes: [1] }], { n_subcarriers: 1 }, { includeReadings: true });
assert.deepEqual(calls.at(-1), ['analyze', { config: { n_subcarriers: 1 }, frames: [{ amplitudes: [1] }], include_readings: true }]);
const s = k.openSession({});
assert.equal(s.close(), true);
assert.equal(s.close(), false, 'double close is a no-op');
assert.throws(() => s.push([]), (e) => e.code === 'unknown_session');
assert.ok(Object.isFrozen(k));
});
test('deepClose tolerates ULP noise but reports real differences', () => {
assert.deepEqual(deepClose({ a: [1, 2.0000000000001] }, { a: [1, 2] }), []);
assert.equal(deepClose({ a: 1 }, { a: 1.001 }).length, 1);
assert.equal(deepClose({ a: 'x' }, { a: 'y' }).length, 1);
assert.equal(deepClose([1], [1, 2]).length, 1);
assert.equal(deepClose({ a: 1 }, {}).length, 1);
});
@@ -0,0 +1,54 @@
// Binary (Float64Array) fast path (ADR-368 amendment). Evidence: SYNTHETIC.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { flattenFrames, MAX_INPUT_BYTES } from '../src/abi.js';
import { deepClose, loadKernel } from '../src/index.js';
import { napiSkip, requireWasm } from './helpers.mjs';
test('flattenFrames packs uniform frames and defers irregular ones to JSON', () => {
const f = (a, p) => (p ? { amplitudes: a, phases: p } : { amplitudes: a });
const packed = flattenFrames([f([1, 2], [5, 6]), f([3, 4], [7, 8])], 2);
assert.deepEqual([...packed.data], [1, 2, 3, 4, 5, 6, 7, 8]);
assert.equal(packed.withPhases, true);
assert.deepEqual([...flattenFrames([f([1, 2]), f([3, 4])], 2).data], [1, 2, 3, 4]);
assert.equal(flattenFrames([f([1, 2])], 3), null, 'width mismatch');
assert.equal(flattenFrames([f([1, 2], [1, 2]), f([3, 4])], 2), null, 'mixed phases');
assert.equal(flattenFrames([{ amplitudes: [1, 2], extra: 1 }], 2), null, 'unknown key');
assert.equal(flattenFrames([f([1, '2'])], 2), null, 'non-number');
assert.equal(flattenFrames([null], 2), null);
assert.equal(flattenFrames([], 2).data.length, 0);
});
function transportsAgree(k) {
assert.equal(k.binary, true, `${k.backend} exposes the binary transport`);
const { frames } = k.synthesize({ seconds: 30, n_subcarriers: 16 });
const viaBinary = k.analyze(frames, { n_subcarriers: 16 }, { includeReadings: true });
const viaJson = k.call('analyze', { config: { n_subcarriers: 16 }, frames, include_readings: true });
// JSON number parsing perturbs inputs by ULPs; outputs agree to 1e-9.
assert.deepEqual(deepClose(viaBinary, viaJson), []);
const s = k.openSession({ n_subcarriers: 16 });
for (let i = 0; i < frames.length; i += 50) s.push(frames.slice(i, i + 50));
assert.deepEqual(s.summary(), viaBinary.summary, 'streamed binary pushes equal batch binary analysis');
s.close();
}
test('wasm binary and JSON transports agree', () => {
requireWasm();
transportsAgree(loadKernel({ backend: 'wasm' }));
});
test('napi binary and JSON transports agree', { skip: napiSkip }, () => {
transportsAgree(loadKernel({ backend: 'napi' }));
});
test('binary entry points validate input and keep precise JSON errors for bad frames', () => {
requireWasm();
const k = loadKernel({ backend: 'wasm' });
assert.throws(() => k.analyzeFlat([1, 2, 3]), (e) => e.code === 'invalid_request');
assert.throws(() => k.analyzeFlat(new Float64Array(MAX_INPUT_BYTES / 8 + 1)), (e) => e.code === 'limit_exceeded');
assert.throws(() => k.analyzeFlat(new Float64Array(5), { n_subcarriers: 2 }), (e) => e.code === 'invalid_request');
assert.throws(() => k.analyzeFlat(Float64Array.of(1, NaN), { n_subcarriers: 2 }), (e) => e.code === 'invalid_request');
// A malformed frame falls back to JSON, whose error names the frame.
assert.throws(() => k.analyze([{ amplitudes: [1, 2] }, { amplitudes: [1] }], { n_subcarriers: 2 }), (e) => /frames\[1\]/.test(e.message));
assert.equal(k.info().abi, 1, 'instance healthy after rejected input');
});
+102
View File
@@ -0,0 +1,102 @@
// End-to-end: Rust core → WASM/napi transports → JS API → CLI (ADR-368).
// Evidence for every numeric assertion here: SYNTHETIC.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { kernelStatus, loadKernel, parity, selfTest } from '../src/index.js';
import { napiSkip, requireWasm } from './helpers.mjs';
const CLI = join(dirname(dirname(fileURLToPath(import.meta.url))), 'bin', 'cli.js');
const cli = (...args) => {
const r = spawnSync(process.execPath, [CLI, ...args], { encoding: 'utf8', env: { ...process.env, RUVIEW_KERNEL_BACKEND: '' } });
return { status: r.status, stdout: r.stdout, stderr: r.stderr, json: () => JSON.parse(r.stdout) };
};
test('wasm backend passes the SYNTHETIC self-test', () => {
requireWasm();
const k = loadKernel({ backend: 'wasm' });
assert.equal(k.backend, 'wasm');
assert.equal(k.integrity, 'verified');
const result = selfTest(k);
assert.ok(result.ok, JSON.stringify(result.checks.filter((c) => !c.pass)));
assert.equal(result.evidence, 'SYNTHETIC');
assert.equal(result.kernel.target, 'wasm32');
});
test('napi backend passes the SYNTHETIC self-test', { skip: napiSkip }, () => {
const k = loadKernel({ backend: 'napi' });
assert.equal(k.backend, 'napi');
const result = selfTest(k);
assert.ok(result.ok, JSON.stringify(result.checks.filter((c) => !c.pass)));
assert.equal(result.kernel.target, 'native');
});
test('wasm and napi produce the same outputs for the same input', { skip: napiSkip }, () => {
const result = parity(loadKernel({ backend: 'wasm' }), loadKernel({ backend: 'napi' }));
assert.ok(result.ok, result.differences.join('\n'));
assert.equal(result.readings, 45);
});
test('auto prefers napi and reports an honest fallback to wasm', () => {
requireWasm();
const k = loadKernel({ backend: 'auto', nativePath: '/nonexistent/ruview_kernel.node' });
assert.equal(k.backend, 'wasm');
assert.equal(k.requestedBackend, 'auto');
assert.match(k.fallbackReason, /no napi-rs addon|not found|nonexistent/);
});
test('kernelStatus reports both backends without throwing', () => {
const status = kernelStatus();
assert.equal(status.abi, 1);
assert.equal(typeof status.wasm.available, 'boolean');
assert.equal(typeof status.napi.available, 'boolean');
});
test('CLI: doctor, selftest, synth → analyze round trip', () => {
requireWasm();
const doctor = cli('doctor');
assert.equal(doctor.status, 0, doctor.stderr);
assert.equal(doctor.json().wasm.available, true);
const st = cli('selftest', '--seconds', '45');
assert.equal(st.status, 0, st.stdout);
assert.equal(st.json().backend, 'wasm');
const dir = mkdtempSync(join(tmpdir(), 'ruview-kernel-e2e-'));
try {
const file = join(dir, 'frames.json');
const synth = cli('synth', '--seconds', '40', '--breathing', '12', '--subcarriers', '24', '--out', file);
assert.equal(synth.status, 0, synth.stderr);
assert.equal(cli('synth', '--seconds', '1', '--out', file).status, 1, 'synth must refuse to overwrite');
const analyzed = cli('analyze', '--file', file, '--readings');
assert.equal(analyzed.status, 0, analyzed.stderr);
const report = analyzed.json();
assert.equal(report.summary.frames, 800);
assert.equal(report.readings.length, 40);
assert.ok(Math.abs(report.summary.last.respiratory.bpm - 12) <= 3, `SYNTHETIC RR ${report.summary.last.respiratory.bpm}`);
// JSONL input takes the same path.
const frames = JSON.parse(cli('synth', '--seconds', '2', '--subcarriers', '4').stdout).frames;
const jsonl = join(dir, 'frames.jsonl');
writeFileSync(jsonl, frames.map((f) => JSON.stringify(f)).join('\n'));
const small = cli('analyze', '--file', jsonl, '--config', '{"n_subcarriers":4}');
assert.equal(small.status, 0, small.stdout);
assert.equal(small.json().summary.frames, 40);
const bad = cli('analyze', '--file', jsonl);
assert.equal(bad.status, 1, 'width mismatch against default 56 subcarriers must fail');
assert.equal(bad.json().code, 'invalid_request');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('CLI rejects unknown backends and commands', () => {
assert.equal(cli('info', '--backend', 'gpu').status, 2);
assert.equal(cli('frobnicate').status, 2);
});
+20
View File
@@ -0,0 +1,20 @@
import { existsSync } from 'node:fs';
import { DEFAULT_WASM_PATH, nativeCandidates } from '../src/index.js';
export const HAS_WASM = existsSync(DEFAULT_WASM_PATH);
export const HAS_NAPI = nativeCandidates().some((p) => existsSync(p));
// CI sets RUVIEW_KERNEL_REQUIRE_NAPI=1 after `npm run build:napi` so a missing
// addon is a failure there, while contributors without Rust can still run tests.
export const REQUIRE_NAPI = process.env.RUVIEW_KERNEL_REQUIRE_NAPI === '1';
export const napiSkip = !HAS_NAPI && !REQUIRE_NAPI ? 'napi addon not built (npm run build:napi)' : false;
export function requireWasm() {
if (!HAS_WASM) throw new Error(`WASM kernel missing at ${DEFAULT_WASM_PATH}; run \`npm run build:wasm\` first`);
}
/** Smallest valid module that imports one host function ("env"."f"). */
export const MODULE_WITH_IMPORT = new Uint8Array([
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
0x01, 0x04, 0x01, 0x60, 0x00, 0x00,
0x02, 0x09, 0x01, 0x03, 0x65, 0x6e, 0x76, 0x01, 0x66, 0x00, 0x00,
]);
@@ -0,0 +1,84 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { copyFileSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DEFAULT_WASM_PATH, loadKernel } from '../src/index.js';
import { compileKernelModule, loadWasmKernelFromBytes } from '../src/wasm.js';
import { HAS_WASM, MODULE_WITH_IMPORT, requireWasm } from './helpers.mjs';
test('a module that imports host functions is refused', () => {
assert.throws(() => compileKernelModule(MODULE_WITH_IMPORT), (e) => e.code === 'untrusted_module');
assert.throws(() => loadWasmKernelFromBytes(MODULE_WITH_IMPORT), (e) => e.code === 'untrusted_module');
});
test('a module without the kernel exports is refused', () => {
const empty = new Uint8Array([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]);
assert.throws(() => compileKernelModule(empty), (e) => e.code === 'untrusted_module' && /missing exports/.test(e.message));
});
test('invalid backend names are rejected', () => {
assert.throws(() => loadKernel({ backend: 'python' }), (e) => e.code === 'invalid_backend');
});
test('explicit napi never silently falls back to wasm', () => {
assert.throws(() => loadKernel({ backend: 'napi', nativePath: '/nonexistent/ruview.node' }), (e) => e.code === 'backend_unavailable');
});
test('checksum mismatch fails closed and is never an auto fallback', { skip: !HAS_WASM && 'wasm not built' }, () => {
const dir = mkdtempSync(join(tmpdir(), 'ruview-kernel-'));
try {
const wasmPath = join(dir, 'ruview_kernel.wasm');
copyFileSync(DEFAULT_WASM_PATH, wasmPath);
writeFileSync(join(dir, 'SHA256SUMS'), `${'0'.repeat(64)} ruview_kernel.wasm\n`);
assert.throws(() => loadKernel({ backend: 'wasm', wasmPath }), (e) => e.code === 'integrity_mismatch');
const fakeNative = join(dir, 'fake.node');
writeFileSync(fakeNative, 'not an addon');
writeFileSync(join(dir, 'SHA256SUMS'), `${'1'.repeat(64)} fake.node\n`);
assert.throws(() => loadKernel({ backend: 'auto', nativePath: fakeNative }), (e) => e.code === 'integrity_mismatch');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('requireIntegrity refuses an unchecksummed artifact', { skip: !HAS_WASM && 'wasm not built' }, () => {
const dir = mkdtempSync(join(tmpdir(), 'ruview-kernel-'));
try {
const wasmPath = join(dir, 'ruview_kernel.wasm');
copyFileSync(DEFAULT_WASM_PATH, wasmPath);
assert.equal(loadKernel({ backend: 'wasm', wasmPath }).integrity, 'unverified');
assert.throws(() => loadKernel({ backend: 'wasm', wasmPath, requireIntegrity: true }), (e) => e.code === 'integrity_unverified');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('kernel rejects hostile frames without crashing the instance', () => {
requireWasm();
const k = loadKernel({ backend: 'wasm' });
const cases = [
[() => k.analyze([{ amplitudes: [1, 2] }], { n_subcarriers: 3 }), 'invalid_request'],
[() => k.analyze([{ amplitudes: [1e300] }], { n_subcarriers: 1 }), 'invalid_request'],
[() => k.analyze([{ amplitudes: [1], evil: true }], { n_subcarriers: 1 }), 'invalid_request'],
[() => k.analyze([], { n_subcarriers: 100000 }), 'invalid_request'],
[() => k.analyze([], { sample_rate_hz: -1 }), 'invalid_request'],
[() => k.synthesize({ seconds: 3600, sample_rate_hz: 1000 }), 'limit_exceeded'],
[() => k.call('session_push', { session: 424242, frames: [] }), 'unknown_session'],
];
for (const [fn, code] of cases) assert.throws(fn, (e) => e.code === code, code);
assert.equal(k.info().abi, 1, 'instance still healthy after rejected input');
});
test('session registry is bounded per instance', () => {
requireWasm();
const k = loadKernel({ backend: 'wasm' });
const sessions = [];
let limited = false;
for (let i = 0; i < 70; i++) {
try { sessions.push(k.openSession({})); } catch (e) { assert.equal(e.code, 'limit_exceeded'); limited = true; break; }
}
assert.ok(limited, 'more than 64 concurrent sessions must be refused');
for (const s of sessions) s.close();
assert.ok(k.openSession({}).close());
});
View File
Whitespace-only changes.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ruvnet
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+102
View File
@@ -0,0 +1,102 @@
# ruview
**RuView is an ambient intelligence platform, not a sensor.** It turns the radio
signals already in a room (WiFi channel state from ESP32 and Realtek nodes,
60/24 GHz mmWave radar, LiDAR) into presence, motion, breathing and pose. All
of it is camera-free and reachable from the tools people and agents already use.
The sensing stack is ahead of packaged commercial options. The bottleneck has
been packaging. This package is RuView in one install: one CLI, one MCP
server, and a live console that renders inside ChatGPT and other MCP Apps hosts.
```bash
npx ruview --help
npx ruview capabilities # every component, version and tool in this install
```
## What you get
| Component | Package | What it does |
|---|---|---|
| Operator harness | [`@ruvnet/ruview`](https://www.npmjs.com/package/@ruvnet/ruview) | Node capture and diagnostics for ESP32 and Realtek RTL8721Dx CSI, 60/24 GHz radar (raw UART or ESPHome kits such as the Seeed MR60BHA2), RPLIDAR and iPhone LiDAR, firmware flashing with boot evidence, training with a baseline/leakage gate, doctor, remote hosts over SSH |
| Homecore metaharness | [`homecore`](https://www.npmjs.com/package/homecore) | Source-cited guidance for the Homecore runtime, WASM kernel status, reviewed memory |
| Vitals kernel | [`@ruvnet/ruview-kernel`](https://www.npmjs.com/package/@ruvnet/ruview-kernel) | The Rust breathing/heart-rate pipeline as zero-import WebAssembly; `ruview esp32 --analyze` runs live CSI through it |
## Quick start
```bash
# What is plugged in? (USB VID:PID → ESP32, Realtek, radar, LiDAR)
npx ruview devices
# Live CSI from your nodes (UDP :5005), redrawn every few seconds
npx ruview esp32 --watch
# A 60 GHz radar kit running ESPHome, on your LAN
npx ruview mmwave --source esphome --host 192.168.1.50
# Diagnostics with fixes
npx ruview doctor
# Homecore and the kernel through the same CLI
npx ruview homecore guidance --topic plugins --query Wasmtime
npx ruview kernel selftest
```
A terminal gets a formatted view; pipes and `--json` get JSON.
In **Claude Code**, the bundled `ruview-live` mod puts a live sensing pane beside the conversation (`/ruview`). Run `npx ruview mod` to see how to load it (mods are early access).
## Agents: MCP, ChatGPT and MCP Apps
One server exposes the harness tools (`ruview_*`) and the Homecore tools
(`homecore_*`):
```bash
npx ruview mcp start # stdio (Claude Code, Codex, any MCP client)
```
Node captures, radar reads, device scans and doctor results render in the
**RuView console** (`ui://ruview/console-v1.html`) in ChatGPT and other MCP Apps
hosts. A Refresh button re-runs the tool in place.
For ChatGPT and other remote clients, serve MCP over HTTP:
```bash
export RUVIEW_MCP_TOKEN="$(node -e "console.log(require('crypto').randomBytes(24).toString('base64url'))")"
RUVIEW_MCP_GRANTS=device-access npx ruview mcp start --http --port 8790
# then expose it over HTTPS (tunnel / reverse proxy) and add the connector URL
# https://<your-host>/mcp/<token>
```
**Least authority by default.**
- Hardware reads need the `device-access` grant.
- The HTTP transport requires a token.
- It binds to loopback.
- It refuses unknown browser origins.
- It **never** honours write grants: flashing and calibration stay on the
local CLI.
## Honesty rules
- **Device-reported vitals** (from radar firmware) are labelled as such and
are not validated against a reference.
- **Kernel estimates** carry "no reference measurement".
- **Synthetic data** (simulators, self-tests) is flagged `SYNTHETIC`.
- **Accuracy claims** must pass `ruview claim-check`, tagged MEASURED (with a
reproducer), CLAIMED or SYNTHETIC.
WiFi sensing is not camera-grade, and RuView never presents it that way.
## Privacy
RuView senses without cameras, and nothing leaves your network unless you send
it. The MCP HTTP transport binds to localhost unless you choose otherwise, and
the console widget makes no network requests.
## Links
- Source and architecture decisions: <https://github.com/ruvnet/RuView>
(ADR-373 device access, ADR-375 MCP Apps console/HTTP/terminal UI, ADR-376 this package)
- Issues: <https://github.com/ruvnet/RuView/issues>
MIT licensed.
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env node
// SPDX-License-Identifier: MIT
// `npx ruview` — one CLI for every RuView component (ADR-376).
// ruview homecore <cmd> → Homecore developer metaharness
// ruview kernel <subcmd> → WASM vitals kernel CLI (doctor|info|selftest|parity|synth|analyze|bench)
// ruview mcp start [--http] → one MCP server: harness + Homecore tools, ui:// console
// ruview capabilities → what this install can do
// ruview <anything else> → @ruvnet/ruview operator harness
import { realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { argv } from 'node:process';
import { KERNEL_COMMANDS, VERSION, capabilities, componentVersions, wireKernel } from '../src/index.js';
const HELP_HEADER = `ruview ${VERSION} — RuView in one install
Components:
ruview <command> operator harness (@ruvnet/ruview) — devices, radar, firmware, training, doctor
ruview homecore <command> Homecore developer metaharness (ruview homecore --help)
ruview kernel <subcommand> vitals kernel: ${KERNEL_COMMANDS.join('|')} (ruview kernel --help)
ruview mcp start [--http] one MCP server: harness + Homecore tools, ChatGPT/MCP Apps console
ruview capabilities list every component, version and tool
`;
async function mcp(rest) {
const { unifiedHandler } = await import('../src/mcp.js');
const flags = new Set(rest);
if (rest[0] !== undefined && rest[0] !== 'start') { console.error('Usage: ruview mcp start [--http [--host 127.0.0.1] [--port 8790] [--allow-origin URL]]'); return 2; }
if (!flags.has('--http')) {
const { startMcpServer } = await import('@ruvnet/ruview/mcp');
startMcpServer({ handler: unifiedHandler, label: 'harness + homecore tools' });
return new Promise(() => {});
}
// Reuse the harness CLI's HTTP startup (flags, banner, token handling), with the unified handler.
const { run } = await import('@ruvnet/ruview/cli');
return run(['mcp', ...rest], { mcpHandler: unifiedHandler });
}
export async function run(args) {
const [cmd, ...rest] = args;
await wireKernel();
if (cmd === 'homecore') {
const { run: homecore } = await import('homecore/cli');
return homecore(rest);
}
if (cmd === 'kernel' && (KERNEL_COMMANDS.includes(rest[0]) || rest[0] === '--help' || rest[0] === '-h')) {
const { run: kernel } = await import('@ruvnet/ruview-kernel/cli');
return kernel(rest);
}
if (cmd === 'mcp') return mcp(rest);
if (cmd === 'capabilities') {
const res = await capabilities();
console.log(JSON.stringify(res, null, 2));
return 0;
}
if (cmd === '--version' || cmd === '-v') {
const versions = await componentVersions();
console.log(`ruview ${VERSION}`);
for (const [name, v] of Object.entries(versions)) console.log(` ${name} ${v ?? 'not installed'}`);
return 0;
}
const { run: harness } = await import('@ruvnet/ruview/cli');
if (cmd === '--help' || cmd === '-h') { console.log(HELP_HEADER); return harness(['--help']); }
return harness(args);
}
const invokedDirectly = (() => {
if (!argv[1]) return false;
try {
const a = realpathSync(argv[1]);
const b = realpathSync(fileURLToPath(import.meta.url));
return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
} catch { return false; }
})();
if (invokedDirectly) {
run(argv.slice(2)).then((code) => process.exit(code)).catch((err) => { console.error(err); process.exit(1); });
}
+56
View File
@@ -0,0 +1,56 @@
{
"name": "ruview",
"version": "0.9.1",
"description": "RuView in one install: the WiFi-sensing operator harness (ESP32/Realtek CSI, 60/24 GHz radar incl. ESPHome kits, LiDAR, flashing, training, doctor, MCP + ChatGPT/MCP Apps console), the Homecore developer metaharness, and the WASM vitals kernel. One CLI and one MCP server.",
"type": "module",
"bin": {
"ruview": "bin/ruview.js"
},
"exports": {
".": "./src/index.js",
"./mcp": "./src/mcp.js",
"./package.json": "./package.json"
},
"files": [
"bin/",
"src/",
"README.md",
"LICENSE"
],
"scripts": {
"test": "node --test test/*.test.mjs"
},
"dependencies": {
"@ruvnet/ruview": "0.9.1",
"@ruvnet/ruview-kernel": "0.1.0",
"homecore": "0.1.0"
},
"engines": {
"node": ">=20.0.0"
},
"keywords": [
"ruview",
"wifi-sensing",
"csi",
"esp32",
"realtek",
"mmwave",
"esphome",
"mcp",
"mcp-apps",
"chatgpt",
"homecore",
"wasm"
],
"license": "MIT",
"author": "ruvnet",
"homepage": "https://github.com/ruvnet/RuView#readme",
"repository": {
"type": "git",
"url": "git+https://github.com/ruvnet/RuView.git",
"directory": "harness/ruview-umbrella"
},
"bugs": {
"url": "https://github.com/ruvnet/RuView/issues"
}
}
+71
View File
@@ -0,0 +1,71 @@
// SPDX-License-Identifier: MIT
// `ruview` umbrella (ADR-376): one install for the RuView operator harness
// (@ruvnet/ruview), the Homecore developer metaharness (homecore) and the WASM
// vitals kernel (@ruvnet/ruview-kernel). Components load lazily so `--help`
// and the harness commands never pay for Homecore or the kernel.
import { readFileSync } from 'node:fs';
const PKG = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
export const VERSION = PKG.version;
/**
* Resolve the kernel from this package's dependency tree for every harness
* code path (esp32 --analyze, kernel self-test, doctor), including linked and
* pnpm-style installs where the harness cannot see its sibling.
*/
export async function wireKernel() {
const { setKernelImporter } = await import('@ruvnet/ruview/kernel');
setKernelImporter((specifier) => import(specifier));
}
/** Kernel CLI subcommands; `ruview kernel` with none of these stays the harness self-test. */
export const KERNEL_COMMANDS = Object.freeze(['doctor', 'info', 'selftest', 'parity', 'synth', 'analyze', 'bench']);
export const COMPONENTS = Object.freeze([
{ name: '@ruvnet/ruview', role: 'operator harness: devices, firmware, training, doctor, MCP, ChatGPT/MCP Apps console' },
{ name: 'homecore', role: 'Homecore developer metaharness: source-cited guidance, WASM kernel status, verification' },
{ name: '@ruvnet/ruview-kernel', role: 'portable vitals kernel (WASM, optional napi) used by `esp32 --analyze`' },
]);
/** Installed component versions, read from each package's own package.json. */
export async function componentVersions(importer = (s) => import(s, { with: { type: 'json' } })) {
const out = {};
for (const c of COMPONENTS) {
try {
const url = import.meta.resolve(c.name === '@ruvnet/ruview-kernel' ? `${c.name}/package.json` : c.name);
const pkgUrl = c.name === '@ruvnet/ruview-kernel' ? url : new URL('../package.json', url).href;
out[c.name] = (await importer(pkgUrl)).default.version;
} catch {
out[c.name] = null;
}
}
return out;
}
/** Everything this install can do, grouped by component (used by `ruview capabilities`). */
export async function capabilities() {
const [{ listTools: ruviewTools }, homecore, versions] = await Promise.all([
import('@ruvnet/ruview'),
import('homecore').catch(() => null),
componentVersions(),
]);
return {
ok: true,
package: 'ruview',
version: VERSION,
components: COMPONENTS.map((c) => ({ ...c, version: versions[c.name] })),
tools: {
harness: ruviewTools().map((t) => t.name),
homecore: homecore ? homecore.listTools().map((t) => t.name) : [],
kernelCli: [...KERNEL_COMMANDS],
},
entryPoints: {
cli: 'ruview <command> (ruview --help)',
homecore: 'ruview homecore <command>',
kernel: `ruview kernel ${KERNEL_COMMANDS.join('|')}`,
mcpStdio: 'ruview mcp start',
mcpHttp: 'ruview mcp start --http (ChatGPT / remote MCP; token: RUVIEW_MCP_TOKEN)',
},
};
}
+37
View File
@@ -0,0 +1,37 @@
// SPDX-License-Identifier: MIT
// Unified MCP handler (ADR-376): the @ruvnet/ruview tool registry plus the
// Homecore tools in one server. Homecore tool names are already prefixed
// (`homecore_*`), so there is no collision. Everything that is not a Homecore
// tool call — handshake, resources (the ui:// console), ruview tools, policy —
// is answered by the harness handler unchanged.
import { handleRpc, toCallToolResult } from '@ruvnet/ruview/mcp';
import { VERSION, wireKernel } from './index.js';
await wireKernel();
let homecorePromise = null;
const loadHomecore = () => (homecorePromise ??= import('homecore').catch(() => null));
/** Merge a Homecore tool into the MCP tool shape (no UI metadata). */
function homecoreTool(t) {
return { name: t.name, ...(t.title ? { title: t.title } : {}), description: t.description, inputSchema: t.inputSchema, annotations: t.annotations };
}
export async function unifiedHandler(msg, context = {}) {
const homecore = await loadHomecore();
if (msg && msg.jsonrpc === '2.0' && msg.method === 'tools/call' && homecore && typeof msg.params?.name === 'string' && msg.params.name.startsWith('homecore_')) {
const out = await homecore.runTool(msg.params.name, msg.params.arguments || {}, { ...context, source: 'mcp' });
return { jsonrpc: '2.0', id: msg.id, result: toCallToolResult(msg.params.name, out) };
}
const reply = await handleRpc(msg, context);
if (!reply || !reply.result) return reply;
if (msg.method === 'initialize') {
reply.result.serverInfo = { ...reply.result.serverInfo, name: 'ruview', version: VERSION };
reply.result.instructions += ' Homecore developer tools (homecore_*) are included: source-cited guidance, WASM kernel status and reviewed memory.';
}
if (msg.method === 'tools/list' && homecore) {
reply.result.tools = [...reply.result.tools, ...homecore.listTools({ source: 'mcp' }).map(homecoreTool)];
}
return reply;
}
@@ -0,0 +1,97 @@
// ADR-376: the `ruview` umbrella — CLI dispatch to every component and one
// MCP server (harness + Homecore tools), over stdio and HTTP.
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawn, spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { unifiedHandler } from '../src/mcp.js';
import { capabilities, componentVersions, KERNEL_COMMANDS, VERSION } from '../src/index.js';
const BIN = fileURLToPath(new URL('../bin/ruview.js', import.meta.url));
const PKG = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
const cli = (args, env = {}) => spawnSync(process.execPath, [BIN, ...args], { encoding: 'utf8', env: { ...process.env, ...env }, timeout: 120000 });
const rpc = (method, params, id = 1) => unifiedHandler({ jsonrpc: '2.0', id, method, params });
test('version and capabilities cover every component', async () => {
assert.equal(VERSION, PKG.version);
const versions = await componentVersions();
for (const name of Object.keys(PKG.dependencies)) assert.ok(versions[name], `${name} resolves`);
const caps = await capabilities();
assert.ok(caps.tools.harness.includes('ruview_esp32_capture'));
assert.ok(caps.tools.harness.includes('ruview_mmwave_read'));
assert.ok(caps.tools.homecore.includes('homecore_guidance'));
assert.deepEqual(caps.tools.kernelCli, [...KERNEL_COMMANDS]);
const v = cli(['--version']);
assert.equal(v.status, 0);
assert.match(v.stdout, new RegExp(`^ruview ${VERSION.replace(/\./g, '\\.')}`));
assert.match(v.stdout, /homecore \d+\.\d+\.\d+/);
});
test('CLI dispatches to the harness, Homecore and the kernel', () => {
const help = cli(['--help']);
assert.equal(help.status, 0);
assert.match(help.stdout, /RuView in one install/);
assert.match(help.stdout, /esp32 \[--udp-port 5005\]/, 'harness help follows');
const claim = cli(['claim-check', '--text', 'presence accuracy 0.9', '--json']);
assert.equal(JSON.parse(claim.stdout).name ?? 'ruview_claim_check', 'ruview_claim_check');
const hc = cli(['homecore', 'guidance', '--topic', 'plugins', '--query', 'Wasmtime', '--limit', '1']);
assert.equal(hc.status, 0, hc.stderr);
assert.match(hc.stdout, /wasm-plugins/);
const kernel = cli(['kernel', 'info']);
assert.equal(kernel.status, 0, kernel.stderr);
assert.match(kernel.stdout, /"abi"|"version"/);
const selftest = cli(['kernel', '--backend', 'wasm', '--json']);
assert.ok([0, 1].includes(selftest.status), 'plain `kernel` stays the harness self-test');
assert.match(selftest.stdout, /SYNTHETIC/);
});
test('one MCP server lists harness + Homecore tools and routes calls', async () => {
const init = (await rpc('initialize', { protocolVersion: '2025-06-18' })).result;
assert.deepEqual([init.serverInfo.name, init.serverInfo.version], ['ruview', VERSION]);
assert.match(init.instructions, /homecore_/);
const tools = (await rpc('tools/list')).result.tools;
const names = tools.map((t) => t.name);
assert.ok(names.includes('ruview_esp32_capture') && names.includes('homecore_guidance'));
assert.equal(new Set(names).size, names.length, 'no duplicate tool names');
for (const t of tools) assert.match(t.name, /^[a-zA-Z0-9_-]{1,64}$/);
assert.ok(tools.find((t) => t.name === 'ruview_esp32_capture')._meta.ui.resourceUri.startsWith('ui://'));
const hc = (await rpc('tools/call', { name: 'homecore_guidance', arguments: { topic: 'plugins', query: 'Wasmtime', limit: 1 } }, 2)).result;
assert.equal(hc._meta['ruview/tool'], 'homecore_guidance');
assert.equal(typeof hc.structuredContent, 'object');
const rv = (await rpc('tools/call', { name: 'ruview_claim_check', arguments: { text: 'x MEASURED' } }, 3)).result;
assert.equal(rv._meta['ruview/tool'], 'ruview_claim_check');
const denied = (await unifiedHandler({ jsonrpc: '2.0', id: 4, method: 'tools/call', params: { name: 'ruview_devices_scan', arguments: {} } }, { source: 'mcp', grants: [] })).result;
assert.equal(denied.structuredContent.reason, 'authority_denied', 'harness authority model is preserved');
const res = (await rpc('resources/read', { uri: 'ui://ruview/console-v1.html' }, 5)).result;
assert.match(res.contents[0].text, /RuView console/);
});
test('stdio and HTTP transports serve the unified tool set', async (t) => {
const child = spawn(process.execPath, [BIN, 'mcp', 'start'], { stdio: ['pipe', 'pipe', 'pipe'] });
t.after(() => child.kill());
const lines = [];
child.stdout.on('data', (d) => lines.push(...String(d).trim().split('\n')));
child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' })}\n`);
for (let i = 0; i < 100 && !lines.length; i++) await new Promise((r) => setTimeout(r, 50));
const stdioTools = JSON.parse(lines[0]).result.tools.map((x) => x.name);
assert.ok(stdioTools.includes('homecore_guidance') && stdioTools.includes('ruview_mmwave_read'));
const token = 'umbrella-test-token-0123456789';
const port = 20000 + Math.floor(Math.random() * 20000);
const http = spawn(process.execPath, [BIN, 'mcp', 'start', '--http', '--port', String(port)], { env: { ...process.env, RUVIEW_MCP_TOKEN: token }, stdio: ['ignore', 'pipe', 'pipe'] });
t.after(() => http.kill());
let ok = false;
for (let i = 0; i < 100 && !ok; i++) {
try { ok = (await fetch(`http://127.0.0.1:${port}/healthz`)).ok; } catch { await new Promise((r) => setTimeout(r, 100)); }
}
assert.ok(ok, 'HTTP transport started');
const r = await fetch(`http://127.0.0.1:${port}/mcp/${token}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }) });
const httpTools = (await r.json()).result.tools.map((x) => x.name);
assert.deepEqual(httpTools.sort(), [...stdioTools].sort());
assert.equal((await fetch(`http://127.0.0.1:${port}/mcp`, { method: 'POST', body: '{}' })).status, 401);
});
@@ -0,0 +1,53 @@
---
name: device-access
description: Reach RuView sensing hardware from the machine it is attached to — ESP32 CSI nodes (serial + UDP stream), 60 GHz MR60BHA2 / 24 GHz LD2410 mmWave radars, RPLIDAR and iPhone LiDAR — and from other machines over SSH, read-only.
---
# device-access
Hardware tools must run **on the host the device is plugged into**. A cloud
agent cannot see your USB ports. Run `npx @ruvnet/ruview` locally, start a
local Claude Code session there (`claude remote-control` in the repo if you
want to drive it from the Claude app), or add the machine as an SSH host.
## 1. Find devices
```
npx @ruvnet/ruview doctor --group python,serial,devices,remote
npx @ruvnet/ruview devices # USB VID:PID → likely ESP32 / mmWave / RPLIDAR + confirm command
```
## 2. Read each modality
| Device | Command | Confirms |
|---|---|---|
| ESP32 node (USB) | `ruview doctor --port <p> --probe`, `ruview monitor --port <p>` | chip id; CSI callbacks in the serial log (the monitor never resets the node; C6/S3 USB-Serial/JTAG builds log on the native USB port) |
| ESP32 / Realtek node (network) | `ruview esp32 --seconds 10 [--udp-port 5005]` | per-node CSI rate, sequence loss, RSSI, CSI shapes, device vitals; Realtek RAC1 CSI and heartbeat-only senders |
| Realtek RTL8721Dx (USB, PL2303) | `ruview monitor --port <p> --baud 1500000` | `RUVIEW_CSI: frame #` lines in the Ameba log |
| Live CSI → kernel | `ruview esp32 --seconds 45 --analyze [--node-id N] [--backend napi]` | kernel vitals summary at the measured frame rate (no reference measurement) |
| 60 GHz MR60BHA2 | `ruview mmwave --port <p> --model mr60bha2` | valid frames, checksum errors, presence/distance/device HR+BR |
| 24 GHz LD2410 | `ruview mmwave --port <p> --model ld2410` | target state and distances |
| RPLIDAR | `ruview lidar --source rplidar --port <p> [--baud 115200]` | points, revolutions, range, angular coverage |
| iPhone LiDAR | `export RUVIEW_LIDAR_TOKEN=…; ruview lidar --source iphone --url ws://HOST:8787/ws/lidar` | depth frame rate, confident fraction, median depth |
The UDP capture cannot share port 5005 with a running sensing server; stop
the server or have the nodes target another port.
## 3. Other machines (SSH, read-only)
```
npx @ruvnet/ruview hosts add --name lab-pi --ssh ruv@lab-pi.local
ssh ruv@lab-pi.local true # pin the host key once
npx @ruvnet/ruview hosts run --host lab-pi --tool ruview_devices_scan
npx @ruvnet/ruview hosts run --host lab-pi --tool ruview_mmwave_read --args-json '{"port":"/dev/ttyUSB0"}'
```
Remote hosts run a pinned `@ruvnet/ruview` and accept only read-only tools.
Flash, train, and calibrate on the host itself.
## Evidence
Device readouts are `MEASURED` *readings*, not accuracy results. mmWave
heart/breathing values are the radar's own estimates. Hardware validation
still means a captured log or stream from real silicon, recorded with the
command that produced it.
@@ -1,6 +1,6 @@
---
name: provision-node
description: Build, flash, and provision an ESP32-S3/C6 CSI node for RuView — firmware variant choice, ESP-IDF Windows-subprocess flow, NVS/WiFi/channel/MAC-filter overrides.
description: Build, flash, and provision an ESP32-S3/C6 CSI node for RuView — firmware variant choice, cross-platform checksum-verified esptool flashing, NVS/WiFi/channel/MAC-filter overrides, boot-log evidence.
---
# provision-node
@@ -19,17 +19,23 @@ Prebuilt binaries: GitHub release `v0.8.1-esp32` (hardware-validated on S3 QFN56
## 2. Flash
ESP-IDF v5.4 on Windows is **subprocess-only** (Git Bash/MSYS is unsupported — strip
`MSYSTEM*` env vars). Offsets for the S3 image:
Use a release flash bundle (or `release_bins/<variant>`, or an ESP-IDF `build/`).
The harness flashes on Windows, macOS, and Linux through `python -m esptool`:
```
esptool --chip esp32s3 -p <PORT> -b 460800 write_flash \
0x0 bootloader.bin 0x8000 partition-table.bin \
0xf000 ota_data_initial.bin 0x20000 esp32-csi-node-s3-8mb.bin
npx @ruvnet/ruview doctor --group python,serial,firmware # esptool, pyserial, ports, bundle checksums
npx @ruvnet/ruview flash-plan --port <PORT> --bundle <dir> --variant s3-8mb
npx @ruvnet/ruview flash --port <PORT> --bundle <dir> --variant s3-8mb --confirm
```
(`ruview_node_flash` returns the exact pinned command rather than running an
unattended flash.)
`flash` verifies SHA256SUMS, refuses a chip that does not match the variant
(`esptool chip_id`), writes 0x0 / 0x8000 / 0xf000 / 0x20000 (NVS preserved),
then captures a boot log. `hardwareValidated: true` requires CSI callbacks in
that log. Over MCP, `ruview_node_flash` needs the `hardware-write` grant and
`confirm: true`.
Building from source still uses ESP-IDF v5.4 (Docker or the Windows subprocess
flow; Git Bash/MSYS is unsupported, so strip `MSYSTEM*` env vars).
## 3. Provision
@@ -24,6 +24,17 @@ or temporal leakage. Example honest result (ADR-181):
- **in-browser** (ADR-181) — WebGPU/WASM trainer; the active backend is shown as a badge
(honest about what's executing).
## Run it through the harness (ADR-371)
```
npx @ruvnet/ruview train-plan --mode pose-smoke # command, cwd, outputs; runs nothing
npx @ruvnet/ruview train --mode pose-smoke --confirm # SYNTHETIC pipeline smoke (libtorch 2.11 for tch 0.24)
npx @ruvnet/ruview train --mode pose --data-dir <in-repo MM-Fi dir> --confirm
npx @ruvnet/ruview train-gate --file eval-report.json # mean-pose baseline + leakage gate
```
The gate returns the only acceptable claim sentence. Quote nothing it fails.
## Before you publish a number
1. Run the mean-pose baseline on the same split.
+43 -6
View File
@@ -8,27 +8,64 @@
"ruview_verify",
"ruview_node_monitor",
"ruview_guidance",
"ruview_memory_search"
"ruview_memory_search",
"ruview_kernel_selftest",
"ruview_doctor",
"ruview_firmware_plan",
"ruview_firmware_ports",
"ruview_train_plan",
"ruview_train_gate",
"ruview_host_list"
],
"grants": {
"credential-use": {
"tools": ["ruview_spaces_list"],
"tools": [
"ruview_spaces_list"
],
"requiresConfirmation": false,
"notes": "Allows a tenant-scoped external read; OAuth refresh may rotate the local credential file."
},
"workspace-write": {
"tools": ["ruview_calibrate"],
"tools": [
"ruview_calibrate",
"ruview_train"
],
"requiresConfirmation": true
},
"hardware-write": {
"tools": ["ruview_node_flash"],
"tools": [
"ruview_node_flash"
],
"requiresConfirmation": true
},
"device-access": {
"tools": [
"ruview_devices_scan",
"ruview_esp32_capture",
"ruview_mmwave_read",
"ruview_lidar_read"
],
"requiresConfirmation": false,
"notes": "Read-only access to serial devices and the local ESP32 UDP stream; RPLIDAR scans spin the motor but write no configuration."
},
"remote-host": {
"tools": [
"ruview_host_run"
],
"requiresConfirmation": false,
"notes": "Runs one read-only tool on an operator-configured SSH host with pinned host keys; the remote tool's own grant is also required."
}
},
"agentHosts": {
"defaultMode": "read-only",
"writeRequires": ["allow-write", "confirm"],
"forbiddenFlags": ["dangerously-skip-permissions", "dangerously-bypass-approvals-and-sandbox"]
"writeRequires": [
"allow-write",
"confirm"
],
"forbiddenFlags": [
"dangerously-skip-permissions",
"dangerously-bypass-approvals-and-sandbox"
]
}
}
}
+36 -16
View File
@@ -3,7 +3,7 @@
"generator": "RuView metaharness provenance v2",
"template": "vertical:ruview",
"name": "@ruvnet/ruview",
"version": "0.5.1",
"version": "0.9.1",
"hosts": [
"claude-code",
"codex"
@@ -13,17 +13,18 @@
".claude/settings.json": "57d03e8995363bd120fb6d515702967afd0bd557797051301ff8f8156c845824",
".claude/skills/calibrate-room/SKILL.md": "4b29c7c331f47acad3c0f51b3d3d8f5b5573e316e081bae71dbe21a47fa95240",
".claude/skills/cognitum-spaces/SKILL.md": "96ae42cc72ad31dbb2f34d59e874c4d15f2e55fc969cd1f610dc1b9a4138840e",
".claude/skills/device-access/SKILL.md": "cb6aef6bfe7283df0c2dfd08a24e856ad023cae76b1415118784b31505c63aef",
".claude/skills/onboard/SKILL.md": "97ee71f0aa985cfc03bb8e764789bb55c4f9fd5dae10a116c1071eab85b5893f",
".claude/skills/provision-node/SKILL.md": "5f73823794ed5f0b25c102aa8b1bf2dd534a1ec468173d8330c2af0ca24f239c",
".claude/skills/train-pose/SKILL.md": "92aebd4423470eb10eabaee642ec3493284d98b7ae9785e0f34378c709746e65",
".claude/skills/provision-node/SKILL.md": "13dd6c35eac67b79116e370412b77df3509458da2c6f5d7a5938c2d008c5ed73",
".claude/skills/train-pose/SKILL.md": "8cb132e468392497377828f7974c744e4c877134ed9a87f960f77fa1221540ba",
".claude/skills/verify/SKILL.md": "2d38d240e9810a7827e2ebd3717dc0f85c646cc92e46c3812fe77c5b9eb40b76",
".harness/claims.json": "9544cee8012328eb26856a9fff38d80a73f09e48a2da7537f6c3695521b0fd54",
".harness/mcp-policy.json": "749e9f24bde85921a45b91bf6fa4ab5605675af769c04c53fe69129019662d3e",
".harness/claims.json": "b0fef8d5bb0f747f9c1c3ed952f9bf1bce0ae2a861ba93efb79fbd74500e2d21",
".harness/mcp-policy.json": "7ea639f763f31ead88d113368dd11aabe9c48a69de3314e0a337e472ac1f28e0",
".mcp/servers.json": "fec6075400f8350d8075beac8306690355c4b015425bfd0e5f52966234e9d66f",
"CLAUDE.md": "46d5514f4cbf4d94f683f76aa6d50a3dce2ec5a95fd87b9154ed4752f5ea0e16",
"CLAUDE.md": "25f9306b6caae22078cb9375108b6f6bb813d83e617739f6d9372f116b0ede5d",
"LICENSE": "631f94984f626818d42ecf717aa6e8e0afd4f9f355ca706bd2effafbd1416d06",
"README.md": "ce716f07b4b93d5b86285a46cc7be1c6ff48d95ee12ac73518dbf2fb7b61d82e",
"bin/cli.js": "0c96bf65a189732a35760c88a3d441a5bd6ce53abbd3bfaa73141665825e1be1",
"README.md": "a0ee4d09ecef1b125c9d96a6ec8cdfce869bb727c986e650bb7941b21d23ddce",
"bin/cli.js": "8ebf6ca4b6d9529a5e338374c19329c1a2fc2f9e52f13b4341928b7168ee461b",
"brain/corpus/core.jsonl": "c0fb7b079ded157059b91601361429944697dae3cc42abc00dfe1a680986b0f4",
"flywheel/evaluations.json": "ac4ff1f897a2444870cd2b8ae8aee8b1578e61467aeca4db57893f41be98a572",
"flywheel/fixture.mjs": "de71be88753d0da4695d91011b54380c994a018986fafba36cb13739307a9bce",
@@ -31,31 +32,50 @@
"flywheel/genome.json": "75db44a3cab70d9459fc8c07863f640ac1214bfaa243483939e1506d63f51214",
"flywheel/replay.mjs": "0670ca0b03701f4afe0b4bca8a3d58d481676b61a94a5b98c6a425aefb1159ab",
"flywheel/run.mjs": "6d4f97db16900c45367b6538848cbe1915af999e663720dfc51f2bb1698f1cd0",
"package.json": "5d29ef238f310c9ee5c57501ab651acc0f856f831b696ada187de71e4b5935a6",
"package.json": "01627b1b2c804c6dddfea1b366058c23c0e99111829dcd3e32967b76d0bce997",
"scripts/sync-skills.mjs": "43715dab61e204dc91bbd61755810e8fdb2f66e2b0c0bd791b4bf48a2e293565",
"scripts/update-manifest.mjs": "8f56764b8f70aed55da0c7e2417ae875b0d58d781d839b6db7f115f08af61e6b",
"scripts/verify-manifest.mjs": "6491a221762efcfeb3e749ecab243b204f17fd5bc871f3d4025597f31b8f0f10",
"skills/calibrate-room.md": "4b29c7c331f47acad3c0f51b3d3d8f5b5573e316e081bae71dbe21a47fa95240",
"skills/cognitum-spaces.md": "96ae42cc72ad31dbb2f34d59e874c4d15f2e55fc969cd1f610dc1b9a4138840e",
"skills/device-access.md": "cb6aef6bfe7283df0c2dfd08a24e856ad023cae76b1415118784b31505c63aef",
"skills/onboard.md": "97ee71f0aa985cfc03bb8e764789bb55c4f9fd5dae10a116c1071eab85b5893f",
"skills/provision-node.md": "5f73823794ed5f0b25c102aa8b1bf2dd534a1ec468173d8330c2af0ca24f239c",
"skills/train-pose.md": "92aebd4423470eb10eabaee642ec3493284d98b7ae9785e0f34378c709746e65",
"skills/provision-node.md": "13dd6c35eac67b79116e370412b77df3509458da2c6f5d7a5938c2d008c5ed73",
"skills/train-pose.md": "8cb132e468392497377828f7974c744e4c877134ed9a87f960f77fa1221540ba",
"skills/verify.md": "2d38d240e9810a7827e2ebd3717dc0f85c646cc92e46c3812fe77c5b9eb40b76",
"src/brain.js": "0f16a75aea943acdacc430ff11d5df7ecdec9cca2ab497795ff6f33eaebdfab6",
"src/cli-ui.js": "507fbd4444c6639c69354790ed3aed2addcb826929a6b3ad7ddb752f058c1f2f",
"src/cli.js": "5e8467f0d0fb90d81347715143b3777e239165d707dd9d8c5d647294509a7fc3",
"src/devices/esp32.js": "4d7ff3d63d751222756e227a98de38c70a707e8be2a431d1b3ac604622f94225",
"src/devices/esphome.js": "83b0fbd8c9075e233a401fa9f9dad04fc911e04819e7a0c48eb32d7e35d226af",
"src/devices/lidar.js": "d753d88375358ced5174376753c276b5ca621f38ae48eac58dbb307b9004b629",
"src/devices/mmwave.js": "09d828e3b3e140926194ab83bf27e7c63cbda1cf438385c82c0e89b9d8e4b526",
"src/devices/registry.js": "26725e43adaa908b41462f492c5f6bf63e9193f3ee076e3d64ebaa012b66f6be",
"src/devices/serial-pump.js": "bb1351cd129fd00aa71a5d06104efcff5c0a56f3507e39e1df05395fe7fe7844",
"src/doctor.js": "f139c2446b469afe6f5b507685836c65ee318c8f16b1ef29819ba828bbf7828f",
"src/exec.js": "8889d13b8a8277f322856e743c79ac8f29b707251eaf9757c46d3f745743ae22",
"src/firmware.js": "1af024e8e1cd75d43f9e8ef6ff91d0413cf5f4cd84c368d1407e690b051cac4d",
"src/guardrails.js": "aacc8fa6088f7f1ccea3a0b02171a5c516b95d3416ee3ba87add3879a1d6aaad",
"src/guidance.js": "583904c854eb17e98cb7d959330989c01990a71cff091515777aba8f345de1bf",
"src/guidance.js": "1e0ccee808a736319b3f8257a3fc4fc0f8d9f8956970887d597870bb8d615949",
"src/hosts/claude-code.js": "2212bc39b49822018800dfe33a471e56bbb4c5233d716bfa7aa4fff77aa23edb",
"src/hosts/codex.js": "d41ecd132ce2db7b47aad9cebbc020d70e6810d48c3554858d099ff2e8f6608b",
"src/hosts/index.js": "ab276c41ab722bcdf72c2d1649cecbb760ae05c41c1372aae4c2447aa7c11539",
"src/mcp-server.js": "8b2ee4b939b25c1b1f507b295a43a2ebad852b8bac9d31af9bf7fb39b181c12e",
"src/policy.js": "169cc33793b91ee01a78e6403aeefff1ab5e92f33b73eb85912fe03666464975",
"src/kernel.js": "48dfd218a747dd158f82ae108e7e8ed89a82cd68649214d23266112dc9033f5d",
"src/mcp-http.js": "6eb36fa546f333e32ec6283e711d2c93914d4de48a6a3b47fce963c5ec711f5d",
"src/mcp-server.js": "5eb8c3431768932c3a85476df032d0608fc7a732ede3d6bdcc8467ca47552666",
"src/policy.js": "baad5eb8c3eb2e416e0a71b342ad850d7c8413d7d976571ebf596937f69da8e2",
"src/process-runner.js": "49533b038044dfb8bc76ed01c030d06a9856ead0836157fb693e2a7d40f786d6",
"src/redact.js": "ebf1afff46341078706b0401838c53db043603586e280d51ece5cf1feba35189",
"src/remote.js": "ca510804176205eca799b396c0f0bca72df4c08139547c66483885d38fed6815",
"src/repo-trust.js": "06e2a94d7113ed936f208a12b7fcc785801c215a3e2c5e7418f6238d991a289c",
"src/sdk.d.ts": "82e73721de433551a2b4339821ebf7b2c9d44133c766498ae91d15483ed8a073",
"src/sdk.js": "a07b5e5777ee897a50c12f6002065c3ca781e087d625541d54e5f5308ede2116",
"src/spaces.js": "45ef786537cb2a446db5e926e5a1c10b73639d2767dec84611f914f78d4325eb",
"src/tools.js": "55960c9a677661763e0317fd54ccc787c2edb39c87371c7fbc40cd55f0761c04"
"src/tools.js": "7c41347b4be063457d7759ddb8f1f5dd608cbe28baa1c80e4a27d6567af6bc98",
"src/training.js": "b8451c48db69c7d077d75f07500996213beaa52705116f64afafe084e68e3956",
"src/ui/console-widget.js": "0d2f707d57b5ec54968dee1dc80f31c7db9152dcadfc48700c18379dabb55462"
},
"filesDigest": "28a3bbd9bbcf966df9fae8ec6ea5be3441f6ab535c1636bb1ce33f67b827d423",
"filesDigest": "60935843295b67f3354b4b02cc31df62bac8b5093568b11f896dfc7fc3a91545",
"brainDigest": "c0fb7b079ded157059b91601361429944697dae3cc42abc00dfe1a680986b0f4",
"gateFingerprint": "6e53c784eee38310188948fc75fb49e6b4ebc04e247d01b903fa8c8a92d67bdd",
"developmentPins": {
+1 -1
View File
@@ -1 +1 @@
478ccaff9aa249bc7ea6e20551ccc9ac88697a3cc91a7b55400337c5e939a19e manifest.json
0f73608c5acd0547ef850c0944ef76633a40e957c226ce555309832f87491a1c manifest.json
+35 -1
View File
@@ -12,13 +12,43 @@
"ruview_verify",
"ruview_node_monitor",
"ruview_guidance",
"ruview_memory_search"
"ruview_memory_search",
"ruview_kernel_selftest",
"ruview_doctor",
"ruview_firmware_plan",
"ruview_firmware_ports",
"ruview_train_plan",
"ruview_train_gate",
"ruview_host_list"
],
"guardedReadTools": {
"ruview_spaces_list": {
"grant": "credential-use",
"network": true,
"mayRefreshStoredCredential": true
},
"ruview_devices_scan": {
"grant": "device-access",
"hardware": true
},
"ruview_esp32_capture": {
"grant": "device-access",
"hardware": true
},
"ruview_mmwave_read": {
"grant": "device-access",
"hardware": true,
"network": true
},
"ruview_lidar_read": {
"grant": "device-access",
"hardware": true,
"network": true
},
"ruview_host_run": {
"grant": "remote-host",
"network": true,
"readOnlyRemoteTools": true
}
},
"dangerousTools": {
@@ -29,6 +59,10 @@
"ruview_node_flash": {
"grant": "hardware-write",
"confirm": true
},
"ruview_train": {
"grant": "workspace-write",
"confirm": true
}
}
}
Loaded 100 of 283 files, more files were not shown because too many files have changed in this diff. Show more