RuView live sensing in Claude Code: /ruview opens a pane beside the transcript with three views: an overview of your CSI nodes (ESP32, Realtek) and 60 GHz…

<a href="https://cognitum.one/seed"> <img src="assets/ruview-hero-h3-v3.gif" alt="RuView - WiFi DensePose — animated visualization of real-time pose estimation, breathing, and heart-rate sensing through WiFi" width="100%"> </a>
<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>
Turn ordinary WiFi into a spatial intelligence / sensing system. Detect people, measure breathing and heart rate, track movement, and monitor rooms — through walls, in the dark, with no cameras or wearables. Just physics.
Works natively with the four major smart-home ecosystems: Home Assistant via the HA-DISCO MQTT publisher, Apple Home & HomePod as a discoverable HAP-1.1 bridge, Google Home + Amazon Alexa via the same HA bridge or a Matter endpoint. Siri, Google Assistant, and Alexa can voice presence and vitals by room with zero custom skills.
Drop into any Home Assistant install with one
--mqttflag. Or pair into Apple Home / Google Home / Alexa / SmartThings as a Matter Bridge. Ships 21 entities per node (11 raw signals + 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) plus 3 starter HA Blueprints. Seedocs/integrations/home-assistant.md· ADR-115.
Every WiFi router already fills your space with radio waves. When people move, breathe, or even sit still, they disturb those waves in measurable ways. RuView captures these disturbances using Channel State Information (CSI) from low-cost ESP32 sensors and turns them into actionable data: who's there, what they're doing, and whether they're okay.
What it senses:
Also included:
The RuView-specific metaharness we created is published as @ruvnet/ruview. It provides:
Companion packages: homecore (Homecore developer metaharness) and @ruvnet/ruview-kernel (the vitals pipeline as WASM).
# Check the local setup and get source-cited guidance
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.9.1 agent run --host codex --repo . \
--prompt "Find the nearest tests and cite the source files"
# 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"
Safety:
--allow-write and --confirm.device-access grant.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.
Built on RuVector and Cognitum Seed, RuView runs entirely on edge hardware — an ESP32 mesh (as low as $9 per node) paired with a Cognitum Seed for persistent memory, cryptographic attestation, and AI integration. No cloud, no cameras, no internet required.
The system learns each environment locally using spiking neural networks that adapt in under 30 seconds, with multi-frequency mesh scanning across 6 WiFi channels that uses your neighbors' routers as free radar illuminators. Every measurement is cryptographically attested via an Ed25519 witness chain.
RuView turns ordinary WiFi into a contactless sensor. A $9 ESP32 board reads the radio reflections off the people in a room, and a small pretrained model — published on Hugging Face at ruvnet/wifi-densepose-pretrained — tells you who's there, how they're breathing, and how their heart rate is trending. The model fits in 8 KB (4-bit quantized) and runs in microseconds on a Raspberry Pi. (The v2 encoder reports an honest, label-free held-out temporal-triplet accuracy of 82.3% — up from 66.4% raw; the older "100% presence" figure was measured on a single-class recording and has been retracted in favor of this.) No cameras, no wearables, no app on the user's phone.
Edge modules are small programs that run directly on the ESP32 sensor — no internet needed, no cloud fees, instant response.
What How Speed / scale 🫁 Breathing rate Bandpass 0.1–0.5 Hz on wrapped phase, circular variance, zero-crossing BPM (#593) 6–30 BPM, real-time 💓 Heart rate Bandpass 0.8–2.0 Hz, zero-crossing BPM 40–120 BPM, real-time 👤 Presence detection Trained head on Hugging Face ( ruvnet/wifi-densepose-pretrained; v2 encoder = 82.3% held-out temporal-triplet acc, honestly re-benchmarked) + a phase-variance fallback that needs no model< 1 ms, ~30 s ambient calibration 🧬 CSI embeddings 128-dim contrastive encoder shipped on Hugging Face, 4-bit quantised variant fits in 8 KB 164,183 emb/s on M4 Pro 🦴 17-keypoint pose estimation cog-pose-estimationCog v0.0.1 — signed aarch64 + x86_64 binaries on GCS, loadspose_v1.safetensorsvia Candle (the committedpose_v1is a first-cut on-device model: PCK@20 = 3.0%, below the ADR-079 ≥35% target, and its runtime path is still aconfidence=0stub — see Model weights: what's real, what's not; the 82.69% figure below is the separate published MM-Fi benchmark, not this live cog). Train your own from paired data in 2.1 s on an RTX 5080 (ADR-101, benchmarks). SOTA on MM-Fi:ruvnet/wifi-densepose-mmfi-posehits 82.69% torso-PCK@20 (ensemble 83.59%), beating MultiFormer (72.25%) and CSI2Pose (68.41%) on the matched MM-Firandom_splitprotocol — self-corrected and auditable on AetherArena8.4 ms cold-start on a Pi 5 🚶 Motion / activity Motion-band power + phase acceleration Real-time 🤸 Fall detection Phase-acceleration threshold + 3-frame debounce + 5 s cooldown (#263) < 200 ms 🧮 Multi-person count Adaptive P95 normalisation + runtime-tunable dedup factor ( /api/v1/config/dedup-factor, #491). Six specialised learned counters available as Cogs:occupancy-zones,elevator-count,queue-length,customer-flow,clean-room,person-matchingReal-time, self-calibrating 🌍 World model prediction OccWorld TransVQVAE — 15-frame future occupancy prediction, 209 ms inference, 3.4 GB VRAM on RTX 5080; fine-tune on your space with occworld_retrain.py(ADR-147)15 frames × 200×200×16 vox 🧱 Through-wall sensing Fresnel-zone geometry + multipath modeling Up to ~5 m, signal-dependent 🧠 Edge intelligence 105-cog catalog (ADR-102) live from app-registry.json— health, security, building, retail, industrial, research, AI, swarm, signal, network, and developer modules. Optional Cognitum Seed adds persistent vector store + kNN + witness chain$140 total BOM 🎯 Camera-free pre-training Self-supervised contrastive encoder, 12.2M training steps on 60K frames, shipped on Hugging Face 84 s/epoch retrain on M4 Pro 📷 Camera-supervised fine-tune MediaPipe + ESP32 CSI paired training, end-to-end Candle pipeline on RTX 5080 (ADR-079) 2.1 s for 400 epochs (~5 ms/epoch) 📡 Multi-frequency mesh Channel hopping across 6 bands, TDM slot scheduling (ADR-029) 3× sensing bandwidth 🌐 3D point cloud fusion Camera depth (MiDaS) + WiFi CSI + mmWave radar → unified spatial model 22 ms pipeline · 19K+ points/frame Browse the full 105-module catalog (with practical descriptions, sizes, and difficulty) below in 🧩 Edge Module Catalog, or visit seed.cognitum.one/store.
🤗 Pretrained weights: download from
ruvnet/wifi-densepose-pretrained— see Loading the pretrained model below for one-command setup.
# Option 1: Docker (simulated data, no hardware needed)
docker pull 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:
python -m esptool --chip esp32s3 --port COM9 --baud 460800 \
write_flash 0x0 bootloader.bin 0x8000 partition-table.bin \
0xf000 ota_data_initial.bin 0x20000 esp32-csi-node.bin
python firmware/esp32-csi-node/provision.py --port COM9 \
--ssid "YourWiFi" --password "secret" --target-ip 192.168.1.20
# Option 2b: WiFi 6 + 802.15.4 research sensing with ESP32-C6 ($6-10, ADR-110)
# Same csi-node firmware compiled for the C6 target — picks up the C6
# overlay (sdkconfig.defaults.esp32c6) automatically.
cd firmware/esp32-csi-node
idf.py set-target esp32c6 && idf.py build
idf.py -p COM6 flash
# C6 boot extras (vs S3): HE-LTF subcarrier tagging in ADR-018 bytes 18-19,
# 802.15.4 mesh time-sync on channel 15, TWT setup when the AP supports it,
# opt-in LP-core wake-on-motion for ~5 µA battery seed nodes.
# v0.6.7 adds: real LP-core RISC-V motion-gate program (debounce + motion
# counter) and a Wi-Fi 6 soft-AP with TWT Responder so two C6 boards can
# benchmark real iTWT without buying an 11ax router. Both default off,
# flip CONFIG_C6_{LP_CORE,SOFTAP_HE}_ENABLE to turn them on.
# Option 3: Full system with Cognitum Seed ($140)
# ESP32 streams CSI → bridge forwards to Seed for persistent storage + kNN + witness chain
node scripts/rf-scan.js --port 5006 # Live RF room scan
node scripts/snn-csi-processor.js --port 5006 # SNN real-time learning
node scripts/mincut-person-counter.js --port 5006 # Correct person counting
# Option 4: Python — live on PyPI (ADR-117)
pip install ruview # or: pip install wifi-densepose
# Both ship the same compiled PyO3 wheel (~250 KB, abi3-py310, Linux/macOS/Windows).
# Add [client] for the asyncio WebSocket + paho-mqtt clients:
pip install "ruview[client]" # or: pip install "wifi-densepose[client]"
# from ruview import BreathingExtractor, HeartRateExtractor # equivalent to:
# from wifi_densepose import BreathingExtractor, HeartRateExtractor
# from ruview.client import SensingClient, RuViewMqttClient
[!NOTE] CSI-capable hardware recommended. Presence, vital signs, through-wall sensing, and all advanced capabilities require Channel State Information (CSI) from an ESP32-S3 ($9) or research NIC. The Docker image runs with simulated data for evaluation. Consumer WiFi laptops provide RSSI-only presence detection.
Hardware options for live CSI capture:
Option Hardware Cost Full CSI Capabilities ESP32 + Cognitum Seed (recommended) ESP32-S3 + Cognitum Seed ~$140 Yes Presence, motion, breathing, heart rate, fall detection, multi-person counting, 17-keypoint pose (signed Cog binary — first-cut on-device model, see Model weights: what's real, what's not), 105-cog catalog, persistent vector store, kNN search, witness chain, MCP proxy ESP32 Mesh 3-6× ESP32-S3 + WiFi router ~$54 Yes Same capabilities as above without the persistent-memory features ESP32-C6 research node (ADR-110, witness, reviewer guide, firmware v0.7.0) ESP32-C6-DevKit ($6–10) ~$10 Yes (Wi-Fi 6 capable) Dual-target CSI with 99.56% measured ESP-NOW sync match and measured HE-LTF capture on IDF 5.5.2. TWT and ~5 µA operation still need hardware validation. Research NIC Intel 5300 / Atheros AR9580 ~$50-100 Yes Full CSI with 3x3 MIMO Qualcomm CSI beta (ADR-268) QCA9300 now; QCN9074/QCN9274 experimental ~$30-200 Simulator now; hardware adapter gated Rust QCS1codec, deterministic replay, UDP/API integration; modern ath11k/ath12k profiles do not claim public CSI exportVendor provider beta (ADR-270) Origin, Plume, Mist, NETGEAR, Electric Imp, RF Solutions, Luma, Nest, Linksys, Wifigarden Varies Capability-dependent Bounded Rust adapters and deterministic fixtures; telemetry/network-only/unsupported states cannot masquerade as CSI Any WiFi Windows, macOS, or Linux laptop $0 No RSSI-only: coarse presence and motion (see tutorial #36) No hardware? Verify the signal processing pipeline with the deterministic reference signal:
python archive/v1/data/proof/verify.py
<a href="https://ruvnet.github.io/RuView/"> <img src="assets/v2-screen.png" alt="WiFi DensePose — Live pose detection with setup guide" width="800"> </a> <em>Real-time pose skeleton from WiFi CSI signals — no cameras, no wearables (demo visualization; the live CSI-only single-ESP32 17-keypoint model is still first-cut — see <a href="#model-weights-whats-real-whats-not">Model weights: what's real, what's not</a>)</em> <a href="https://ruvnet.github.io/RuView/"><strong>▶ Live Observatory Demo</strong></a> | <a href="https://ruvnet.github.io/RuView/pose-fusion.html"><strong>▶ Dual-Modal Pose Fusion Demo</strong></a> | <a href="https://ruvnet.github.io/RuView/pointcloud/"><strong>▶ Live 3D Point Cloud</strong></a> | <a href="https://ruvnet.github.io/RuView/three.js/"><strong>▶ three.js Demos (5)</strong></a>
The server is optional for visualization and aggregation — the ESP32 runs independently for presence detection, vital signs, and fall alerts.
Live ESP32 pipeline: Connect an ESP32-S3 node → run the sensing server → open the pose fusion demo for real-time dual-modal pose estimation (webcam + WiFi CSI). See ADR-059. (The webcam supplies ground-truth pose in this dual-modal demo; the CSI-only on-device 17-keypoint model is still first-cut — see Model weights: what's real, what's not.)
three.js scene gallery at
/three.js/— five progressively richer ADR-097 demos: helpers, cinematic, GLTF skinned, FBX skinned, and a live MediaPipe→Mixamo retargeting feed driven by ESP32 CSI. Demos 04 and 05 require a local MixamoX Bot.fbx(license boundary — not redistributed).
Pretrained CSI weights live at ruvnet/wifi-densepose-pretrained — 12.2M training steps on 60K frames / 610K contrastive triplets, 82.3% held-out temporal-triplet accuracy (up from 66.4% raw; the older "100% presence" figure was measured on a single-class recording and has been retracted), 4-bit quantized variant fits in 8 KB. The release includes a contrastive CSI encoder producing 128-dim embeddings (164,183 emb/s on M4 Pro) and a presence-detection head. Per-node LoRA adapters are included for environment-specific fine-tuning.
# Download the model bundle
pip install huggingface_hub
huggingface-cli download ruvnet/wifi-densepose-pretrained --local-dir models/wifi-densepose-pretrained
What works today vs. what's pending wiring:
| Consumer | Format used | Status |
|---|---|---|
| Python training / evaluation / embedding extraction | model.safetensors | ⚠️ The published file's header is NUL-padded, which the reference safetensors.torch.load_file rejects (issue #1522) — pending a corrected re-upload. csi-embed-v2.safetensors in the same repo is unaffected and loads normally. |
| Inspect / re-export the bundle | model.rvf.jsonl (line-by-line JSON) | ✅ Works — plain JSONL |
Sensing-server --model <PATH> flag | native RVF, model.safetensors, or model.rvf.jsonl | ✅ Native RVF loads directly; safetensors and JSONL auto-convert in memory |
Loader scope: --model now accepts native RVF and auto-converts the published safetensors or JSONL files. The quantized model-q*.bin files still need a compatible reader, and loading weights does not supply the matching pose-decoder architecture or establish end-to-end pose accuracy.
Quantization choices (all in the HF repo): model-q2.bin (4 KB) · model-q4.bin ⭐ recommended (8 KB) · model-q8.bin (16 KB) · model.safetensors full (48 KB)
The separate 17-keypoint pose-estimation model is now published at ruvnet/wifi-densepose-mmfi-pose — 82.69% torso-PCK@20 on MM-Fi (single model) / 83.59% (3-model ensemble + TTA), beating the prior published SOTA MultiFormer (72.25%) and CSI2Pose (68.41%) on the matched random_split protocol. See Results & proof below.
See the measured benchmarks, witness records, and one-command reproducibility check.
| What | Where | Numbers |
|---|
| MM-Fi pose model (SOTA) | [ruvnet/wifi-densepose-mmfi-pose](https://huggingface.co/ruvnet/wifi-d
hooks/register.mjs 243 lines1// SPDX-License-Identifier: MIT
2// ruview-live — a Claude Code mod (function hooks) shipped inside @ruvnet/ruview.
3//
4// `/ruview` opens a pane beside the transcript with three views: an overview of
5// the CSI nodes streaming to this machine and an optional ESPHome radar kit; a
6// live CSI amplitude waterfall; and a radar fan with vitals charts (ADR-377,
7// ADR-378). Every reading comes from the @ruvnet/ruview CLI this mod ships
8// beside (`--json`), so the pane shows exactly what the tested tools return.
9// Read-only: it never flashes, provisions or writes to a device.
10
11import { commandsOf, cliPathOf, historyWith, MODES, modelOf, resultOf, settingsOf, spectrumWith, statusOf } from './model.mjs';
12import { drain } from './anim.mjs';
13import { ANIMATED, picturesOf, sizesOf, viewOf } from './views.mjs';
14
15export * from './anim.mjs';
16export * from './model.mjs';
17export * from './raster.mjs';
18export { ANIMATED, picturesOf, sizesOf, viewOf } from './views.mjs';
19
20export const PANE_ID = 'ruview-live';
21export const COMMAND = 'ruview';
22const TICK_MS = 5000;
23/** Body rows each view asks for when the pane sits inline above the prompt. */
24export const VIEW_ROWS = Object.freeze({ overview: 16, waterfall: 30, radar: 26 });
25
26/**
27 * The open request: a dialog (focus + closeOnEscape + holdToasts) takes the
28 * keyboard, so the view keys work at once; `rows` sizes the inline pane to
29 * the view instead of a third of the screen (the dock ignores it).
30 */
31export const openArgsOf = (mode) => ({
32 id: PANE_ID, title: 'RuView', focus: true, closeOnEscape: true, holdToasts: true, rows: VIEW_ROWS[mode] ?? VIEW_ROWS.overview,
33});
34/** Animation frame period: ~12 fps, well inside blit's 60 shown a second. */
35export const FRAME_MS = 80;
36
37/** Spectrum request for the waterfall: one bin per picture column (at most 128). */
38export function spectrumOf(columns, rows) {
39 const { width, height } = sizesOf(columns, rows);
40 return { bins: Math.max(8, Math.min(128, width)), frames: Math.max(8, Math.min(256, height * 2)) };
41}
42
43/**
44 * The mod entry: `/ruview [off|refresh|overview|waterfall|radar]`, the pane,
45 * the timers, the status.
46 * @param on the engine's registrar
47 * @param options this plugin's userConfig values
48 */
49export function register(on, options = {}) {
50 const settings = settingsOf(options);
51 let host = null;
52 let isOpen = false;
53 let busy = false;
54 let model = null;
55 let history = null;
56 let spectrum = [];
57 let mode = 'overview';
58 let nodeIndex = 0;
59 let size = { columns: 100, rows: 30 };
60 let stopTimer = null;
61 let stopTick = null;
62 let stopAnim = null;
63 let animating = false;
64 let lastFrame = null;
65 let lag = {};
66 let cliPath = null;
67
68 const intervalOf = () => (mode === 'overview' ? settings.refreshMs : settings.liveRefreshMs);
69 const stop = () => {
70 if (stopTimer) { stopTimer(); stopTimer = null; }
71 if (stopTick) { stopTick(); stopTick = null; }
72 if (stopAnim) { stopAnim(); stopAnim = null; }
73 };
74 const cancelOf = (t) => (typeof t === 'function' ? t : () => t?.cancel?.());
75
76 async function runCli(args) {
77 if (!args) return null;
78 try {
79 return await host.run(['node', cliPath, ...args], { timeoutMs: (settings.captureSeconds + 30) * 1000 });
80 } catch (error) {
81 return { exitCode: -1, stdout: '', stderr: String(error?.message || error) };
82 }
83 }
84
85 async function refresh() {
86 if (!host || busy) return;
87 busy = true;
88 host.invalidate();
89 try {
90 const commands = commandsOf(settings, {
91 live: mode !== 'overview',
92 spectrum: mode === 'waterfall' ? spectrumOf(size.columns, size.rows) : null,
93 });
94 const [capture, radar] = await Promise.all([runCli(commands.capture), runCli(commands.radar)]);
95 // $.clock.now() resolves a promise of epoch milliseconds.
96 model = modelOf(resultOf(capture), resultOf(radar), await host.now());
97 history = historyWith(history, model);
98 spectrum = spectrumWith(spectrum, model.spectrum);
99 lag = Object.fromEntries(Object.entries(lag).filter(([key]) => spectrum.some((s) => s.key === key)));
100 // New frames join the replay queue: shown at their arrival rate, not at once.
101 for (const s of model.spectrum) {
102 const held = spectrum.find((x) => x.key === s.key)?.frames.length ?? 0;
103 lag[s.key] = Math.min(held, (lag[s.key] ?? 0) + s.frames.length);
104 }
105 host.status(statusOf(model));
106 } finally {
107 busy = false;
108 host.invalidate();
109 }
110 }
111
112 /** Options shared by the full drawing and each animation frame. */
113 const drawOpts = (t) => ({ mode, nodeIndex, lag, history, t, columns: size.columns, rows: size.rows });
114 const shownModel = () => (model ? { ...model, spectrum } : null);
115
116 /** One animation frame: repaint each mounted animated Raster in place. */
117 async function animate() {
118 if (!host || animating) return;
119 animating = true;
120 try {
121 // Real time, the clock the full render reads too: pulses keep the
122 // reported rates and the replay keeps up even when a frame fires late.
123 const t = await host.now();
124 if (!Number.isFinite(t)) return;
125 const dt = Number.isFinite(lastFrame) ? Math.max(0, Math.min(1000, t - lastFrame)) : 0;
126 lastFrame = t;
127 for (const s of spectrum) lag[s.key] = drain(lag[s.key], s.rateHz, dt);
128 const pics = picturesOf(shownModel(), drawOpts(t));
129 // Fire and forget: a blit resolves only once a frame is painted, and the
130 // surface folds blits between frames anyway, so nothing waits on one.
131 for (const key of ANIMATED) {
132 if (!pics[key]) continue;
133 const { columns, rows, cells } = pics[key].grid.toRaster(key);
134 Promise.resolve(host.blit({ requestId: PANE_ID, key, cells, columns, rows })).catch(() => undefined);
135 }
136 } finally {
137 animating = false;
138 }
139 }
140
141 /** Start the refresh timer, the age tick and the animation, and fetch now, unless already polling. */
142 function startPolling() {
143 isOpen = true;
144 if (stopTimer) return;
145 stopTimer = cancelOf(host.every(intervalOf(), () => { void refresh(); }));
146 stopTick = cancelOf(host.every(TICK_MS, () => host.invalidate()));
147 stopAnim = cancelOf(host.every(FRAME_MS, () => { void animate(); }));
148 void refresh();
149 }
150
151 /** Switch view; the live views poll faster, so the timer restarts. */
152 function setMode(next) {
153 if (!MODES.includes(next) || next === mode) return;
154 mode = next;
155 if (isOpen && host) {
156 // Each open sets the size anew: re-request rows for this view, keeping the keys.
157 void host.open(openArgsOf(mode)).catch(() => undefined);
158 stop();
159 startPolling();
160 } else host?.invalidate();
161 }
162
163 async function open(initialMode) {
164 if (MODES.includes(initialMode)) mode = initialMode;
165 await host.open(openArgsOf(mode));
166 stop();
167 startPolling();
168 }
169
170 async function close() {
171 stop();
172 isOpen = false;
173 await host.close({ id: PANE_ID }).catch(() => undefined);
174 }
175
176 on('session.start', async ($, e, next) => {
177 cliPath = cliPathOf($.plugin.root);
178 host = {
179 run: (argv, init) => $.process.run(argv, init),
180 every: (ms, fn) => $.clock.every(ms, fn),
181 now: () => $.clock.now(),
182 open: (pane) => $.ui.open(pane),
183 close: (pane) => $.ui.close(pane),
184 status: (text) => $.ui.status(text),
185 invalidate: () => $.ui.invalidate('ui.render'),
186 blit: (args) => $.ui.blit(args),
187 };
188 await $.command.register({ name: COMMAND, description: 'RuView live sensing pane: CSI nodes, CSI waterfall and radar', argumentHint: '[waterfall|radar|refresh|off]' }).catch(() => undefined);
189 return next(e);
190 });
191
192 on('command.run', { command: COMMAND }, async ($, e, next) => {
193 if (!host) return next(e);
194 const arg = String(e.args || '').trim().toLowerCase();
195 if (arg === 'off') { await close(); host.status(undefined); return { text: 'RuView pane closed.' }; }
196 if (arg === 'refresh') { await refresh(); return { text: statusOf(model) }; }
197 if (MODES.includes(arg)) {
198 if (isOpen) {
199 if (arg === mode) await host.open(openArgsOf(mode)).catch(() => undefined);
200 else setMode(arg);
201 } else await open(arg);
202 return { text: `RuView pane: ${arg} view.` };
203 }
204 if (isOpen) { await close(); return { text: 'RuView pane closed.' }; }
205 await open();
206 return { text: `RuView pane open: nodes on UDP ${settings.udpPort}${settings.radarHost ? `, radar ${settings.radarHost}` : ''}; keys 1 overview · 2 CSI waterfall · 3 radar.` };
207 });
208
209 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
210 if (e.requestId !== PANE_ID) return next(e);
211 // A reload (hot reload, or a resumed session) re-runs register with fresh
212 // variables while the engine keeps the pane open: drawing it means it is
213 // open, so resume polling instead of waiting for the first capture forever.
214 if (host && !stopTimer) startPolling();
215 size = {
216 columns: e.props?.bodyColumns ?? e.viewport?.columns ?? 100,
217 rows: e.props?.scroll?.bodyRows ?? e.viewport?.rows ?? 30,
218 };
219 const ui = await $.ui.resolve(e);
220 const now = await $.clock.now();
221 return viewOf(ui, shownModel(), {
222 ...drawOpts(now), refreshMs: settings.refreshMs, liveRefreshMs: settings.liveRefreshMs, busy, now,
223 focused: e.props?.isFocused !== false,
224 udpPort: settings.udpPort, radarConfigured: Boolean(settings.radarHost),
225 onRefresh: () => { void refresh(); },
226 onClose: () => { void close(); },
227 onMode: (m) => setMode(m),
228 onNextNode: () => { nodeIndex += 1; host?.invalidate(); },
229 });
230 });
231
232 on('ui.close', { id: PANE_ID }, async ($, e, next) => {
233 stop();
234 isOpen = false;
235 return next(e);
236 });
237
238 on('session.end', async ($, e, next) => {
239 stop();
240 return next(e);
241 });
242}
243hooks/model.mjs 170 lines1// SPDX-License-Identifier: MIT
2// ruview-live data: settings, CLI argv, result parsing, the pane model, trend
3// history and the status line. Pure functions, tested under plain Node.
4
5const HOST_RE = /^[A-Za-z0-9][A-Za-z0-9.-]{0,252}$/;
6export const HISTORY = 48;
7export const MODES = Object.freeze(['overview', 'waterfall', 'radar']);
8
9/**
10 * The harness CLI beside this mod: the plugin root is <pkg>/mod, so the CLI is
11 * <pkg>/bin/cli.js. Mods import nothing but their own files, so the engine's
12 * `$.plugin.root` locates it.
13 */
14export function cliPathOf(pluginRoot) {
15 return `${String(pluginRoot).replace(/[\\/]+$/, '')}/../bin/cli.js`;
16}
17
18/** Normalise plugin options (userConfig) into bounded settings. */
19export function settingsOf(options = {}) {
20 const num = (v, d, lo, hi) => {
21 const n = Number(v);
22 return Number.isFinite(n) ? Math.min(Math.max(Math.round(n), lo), hi) : d;
23 };
24 const host = typeof options.radarHost === 'string' ? options.radarHost.trim() : '';
25 return {
26 udpPort: num(options.udpPort, 5005, 1024, 65535),
27 radarHost: HOST_RE.test(host) ? host : '',
28 refreshMs: num(options.refreshSeconds, 15, 5, 3600) * 1000,
29 captureSeconds: num(options.captureSeconds, 3, 1, 10),
30 liveRefreshMs: num(options.liveRefreshSeconds, 4, 2, 60) * 1000,
31 };
32}
33
34/**
35 * argv for one capture and (optionally) one radar read. In a live view the
36 * capture is shorter, and the waterfall asks for binned amplitude frames.
37 */
38export function commandsOf(settings, { live = false, spectrum = null } = {}) {
39 const s = String(live ? Math.min(settings.captureSeconds, 2) : settings.captureSeconds);
40 const capture = ['esp32', '--seconds', s, '--udp-port', String(settings.udpPort), '--json'];
41 if (spectrum) capture.push('--spectrum', '--spectrum-bins', String(spectrum.bins), '--spectrum-frames', String(spectrum.frames));
42 return {
43 capture,
44 radar: settings.radarHost ? ['mmwave', '--source', 'esphome', '--host', settings.radarHost, '--seconds', s, '--json'] : null,
45 };
46}
47
48/** Parse a CLI run into its JSON result, or an honest failure. */
49export function resultOf(run) {
50 if (!run) return null;
51 try {
52 const parsed = JSON.parse(run.stdout);
53 if (parsed && typeof parsed === 'object') return parsed;
54 } catch { /* fall through */ }
55 return { ok: false, reason: 'cli_error', detail: String(run.stderr || run.stdout || `exit ${run.exitCode}`).trim().slice(0, 300) };
56}
57
58const fixed = (v, d = 1) => (typeof v === 'number' && Number.isFinite(v) ? v.toFixed(d) : '—');
59const pct = (v) => (typeof v === 'number' ? `${(v * 100).toFixed(1)}%` : '—');
60const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
61
62/** Resting ranges outside which a device-reported vital is flagged, not trusted. */
63export const RANGES = Object.freeze({ heart: [40, 180], breathing: [4, 40] });
64
65/** Why a device-reported vital looks implausible, or null. */
66export function plausibilityOf(kind, value) {
67 if (value == null || !RANGES[kind]) return null;
68 const [lo, hi] = RANGES[kind];
69 return value < lo || value > hi ? `outside ${lo}–${hi} bpm` : null;
70}
71
72const SPARK = '▁▂▃▄▅▆▇█';
73/** Unicode sparkline of the last `width` finite values ('' below two points). */
74export function sparkline(values, width = 16) {
75 const v = (values || []).filter((x) => typeof x === 'number' && Number.isFinite(x)).slice(-width);
76 if (v.length < 2) return '';
77 const lo = Math.min(...v);
78 const hi = Math.max(...v);
79 return v.map((x) => SPARK[hi === lo ? 3 : Math.round(((x - lo) / (hi - lo)) * 7)]).join('');
80}
81
82/** "12s ago" / "3m ago" from a millisecond age, or '' when unknown. */
83export function ageOf(ms) {
84 if (!Number.isFinite(ms) || ms < 0) return '';
85 const s = Math.round(ms / 1000);
86 return s < 60 ? `${s}s ago` : `${Math.round(s / 60)}m ago`;
87}
88
89/** The pane's model: plain data, drawn by the views and summarised by statusOf. */
90export function modelOf(capture, radar, at) {
91 const alerts = [];
92 // "No packets" is shown inside the nodes card; only real faults become alerts.
93 if (capture && capture.ok === false && capture.reason !== 'no_packets') {
94 alerts.push({ level: 'bad', text: `nodes: ${String(capture.reason).replace(/_/g, ' ')}${capture.remedy ? ` — ${capture.remedy}` : ''}` });
95 }
96 for (const hb of capture?.heartbeatOnlySenders || []) alerts.push({ level: 'warn', text: `${hb.address} sends heartbeats but no CSI (run \`ruview monitor --baud 1500000\` on it: "lack of csi buf" = buffer starvation)` });
97 if (radar && radar.ok === false) alerts.push({ level: 'bad', text: `radar: ${String(radar.reason).replace(/_/g, ' ')}${radar.detail ? ` — ${radar.detail}` : ''}` });
98 const nodes = (capture?.nodes || []).map((n) => ({
99 key: `${n.source}:${n.nodeId}`,
100 label: `${n.source === 'realtek' ? 'realtek' : 'esp32'} ${n.nodeId}`,
101 rateHz: num(n.csiRateHz),
102 rate: `${fixed(n.csiRateHz)} Hz`,
103 loss: pct(n.csiLossFraction),
104 rssi: `${fixed(n.rssiMean)} dBm`,
105 shape: n.csi?.shape || '—',
106 lossy: typeof n.csiLossFraction === 'number' && n.csiLossFraction > 0.05,
107 synthetic: Boolean(n.csi?.synthetic),
108 }));
109 const spectrum = (capture?.spectrum || []).map((s) => ({
110 key: `${s.source}:${s.nodeId}`,
111 label: `${s.source === 'realtek' ? 'realtek' : 'esp32'} ${s.nodeId}`,
112 shape: s.shape ?? null, subcarriers: s.subcarriers, bins: s.bins, frames: Array.isArray(s.frames) ? s.frames : [],
113 rateHz: num(s.rateHz), synthetic: Boolean(s.synthetic),
114 }));
115 const radarRow = radar && radar.ok !== false ? {
116 name: radar.device?.name || radar.host || 'radar',
117 present: radar.presentNow ?? (radar.presentFraction == null ? null : radar.presentFraction >= 0.5),
118 targets: num(radar.targetsMax),
119 distanceCm: num(radar.distanceCmMean),
120 heartBpm: num(radar.heartBpmMean),
121 breathingBpm: num(radar.breathingBpmMean),
122 distance: radar.distanceCmMean == null ? '—' : `${fixed(radar.distanceCmMean)} cm`,
123 heart: radar.heartBpmMean == null ? '—' : `${fixed(radar.heartBpmMean)} bpm`,
124 breathing: radar.breathingBpmMean == null ? '—' : `${fixed(radar.breathingBpmMean)} bpm`,
125 } : null;
126 return {
127 at, packets: capture?.packets ?? 0, decoded: capture?.decodedPackets ?? 0,
128 noPackets: capture?.reason === 'no_packets', nodes, spectrum, radar: radarRow, alerts,
129 };
130}
131
132/** Append one model to a bounded history of trend values. */
133export function historyWith(history, model, max = HISTORY) {
134 const h = history || { heart: [], breathing: [], distance: [], rates: {} };
135 const cap = (arr, v) => [...arr, v].slice(-max);
136 const rates = { ...h.rates };
137 for (const n of model?.nodes || []) rates[n.key] = cap(rates[n.key] || [], n.rateHz);
138 return {
139 heart: cap(h.heart, model?.radar?.heartBpm ?? null),
140 breathing: cap(h.breathing, model?.radar?.breathingBpm ?? null),
141 distance: cap(h.distance, model?.radar?.distanceCm ?? null),
142 rates,
143 };
144}
145
146/**
147 * Carry each node's waterfall frames across captures, so the picture scrolls
148 * instead of restarting every refresh. Only nodes in this capture remain: a
149 * node that stopped streaming (or a failed capture) leaves the waterfall, so
150 * old frames are never shown as live. A node whose CSI shape or bin count
151 * changed starts over rather than joining two subcarrier layouts.
152 */
153export function spectrumWith(previous, spectrum, max = 128) {
154 const byKey = new Map((previous || []).map((s) => [s.key, s]));
155 return (spectrum || []).map((s) => {
156 const old = byKey.get(s.key);
157 const same = old && old.bins === s.bins && old.shape === s.shape;
158 return { ...s, frames: (same ? [...old.frames, ...s.frames] : [...s.frames]).slice(-max) };
159 });
160}
161
162/** One line for the status bar. */
163export function statusOf(model) {
164 if (!model) return 'RuView · starting';
165 const parts = [`RuView · ${model.nodes.length} node${model.nodes.length === 1 ? '' : 's'}`];
166 if (model.radar) parts.push(`radar ${model.radar.present == null ? '?' : model.radar.present ? 'present' : 'clear'}`);
167 if (model.alerts.length) parts.push(`${model.alerts.length} alert${model.alerts.length === 1 ? '' : 's'}`);
168 return parts.join(' · ');
169}
170hooks/anim.mjs 74 lines1// SPDX-License-Identifier: MIT
2// ruview-live animation frames (ADR-378): pure functions of time that the
3// hooks module repaints in place with `$.ui.blit` (no redraw). Decoration is
4// labelled as decoration; anything driven by a reading says which reading.
5
6import { Grid, rgb } from './raster.mjs';
7
8const mix = (a, b, f) => rgb(
9 ((a >> 16) & 255) + ((((b >> 16) & 255) - ((a >> 16) & 255)) * f),
10 ((a >> 8) & 255) + ((((b >> 8) & 255) - ((a >> 8) & 255)) * f),
11 (a & 255) + (((b & 255) - (a & 255)) * f),
12);
13const CYAN = rgb(0, 190, 220);
14const EMERALD = rgb(40, 210, 140);
15const DARK = rgb(16, 22, 30);
16
17/** A one-row cyan→emerald rule with a highlight sweeping across it (pure decoration). */
18export function shimmer(columns, tMs) {
19 const g = new Grid(columns, 1);
20 const head = ((tMs / 2400) % 1) * (g.columns + 24) - 12;
21 for (let x = 0; x < g.columns; x++) {
22 const base = mix(CYAN, EMERALD, x / Math.max(1, g.columns - 1));
23 const glow = Math.max(0, 1 - Math.abs(x - head) / 10);
24 g.set(x, 0, 0x2501, mix(mix(DARK, base, 0.55), rgb(235, 255, 250), glow * glow));
25 }
26 return g;
27}
28
29/** Phase in [0, 1) of a rhythm at `perMinute` (0 when unknown). */
30export function phaseOf(tMs, perMinute) {
31 if (!Number.isFinite(perMinute) || perMinute <= 0) return 0;
32 return ((tMs / 60000) * perMinute) % 1;
33}
34
35/**
36 * A heart icon that beats, and a breathing gauge that fills and empties, at
37 * the device-reported rates: a metronome of the reading, not a waveform.
38 * Unknown rates draw a still, dim strip.
39 */
40export function pulse(columns, tMs, { heartBpm = null, breathingBpm = null } = {}) {
41 const g = new Grid(columns, 1);
42 const beat = Number.isFinite(heartBpm) ? Math.exp(-phaseOf(tMs, heartBpm) * 7) : 0;
43 g.set(0, 0, 0x2665, Number.isFinite(heartBpm) ? mix(rgb(90, 20, 35), rgb(255, 90, 120), beat) : rgb(70, 76, 84));
44 const width = g.columns - 3;
45 const breath = Number.isFinite(breathingBpm) ? 0.5 - 0.5 * Math.cos(2 * Math.PI * phaseOf(tMs, breathingBpm)) : 0;
46 const fill = breath * width;
47 for (let x = 0; x < width; x++) {
48 const f = Math.max(0, Math.min(1, fill - x));
49 const on = mix(rgb(40, 120, 200), rgb(140, 220, 255), x / Math.max(1, width));
50 g.set(x + 3, 0, f > 0 ? 0x2588 : 0x2500, f > 0 ? mix(DARK, on, f) : rgb(40, 46, 54));
51 }
52 return g;
53}
54
55/**
56 * Waterfall replay: frames arrive a capture at a time, so the view shows them
57 * at their arrival rate instead of jumping. `lag` is how many received frames
58 * are not shown yet; it drains at `rateHz` and never exceeds what is held.
59 */
60export function drain(lag, rateHz, dtMs) {
61 if (!Number.isFinite(lag) || lag <= 0) return 0;
62 const rate = Number.isFinite(rateHz) && rateHz > 0 ? rateHz : 10;
63 return Math.max(0, lag - (rate * dtMs) / 1000);
64}
65
66/** Frames to show now: all but the `lag` newest. */
67export function shownFrames(frames, lag) {
68 const hold = Math.min(frames.length, Math.max(0, Math.floor(lag || 0)));
69 return hold ? frames.slice(0, frames.length - hold) : frames;
70}
71
72/** Sonar ping phase for the radar fan: a ring leaving the sensor every 1.6 s. */
73export const pingOf = (tMs) => (tMs % 1600) / 1600;
74hooks/views.mjs 232 lines1// SPDX-License-Identifier: MIT
2// ruview-live drawing (ADR-377, ADR-378): the overview cards and the two
3// showcase views (CSI waterfall, radar fan + vitals charts), built from the
4// surface's own elements. Pictures are `Raster`s (terminal); other surfaces
5// get an honest note instead. `picturesOf` builds every animated picture, so
6// the first paint and each `$.ui.blit` frame share one size and one source.
7
8import { pingOf, pulse, shimmer, shownFrames } from './anim.mjs';
9import { ageOf, plausibilityOf, RANGES, sparkline } from './model.mjs';
10import { colourbar, lineChart, radarFan, rgb, waterfall } from './raster.mjs';
11
12const TABS = [['overview', '1', 'Overview'], ['waterfall', '2', 'CSI waterfall'], ['radar', '3', 'Radar']];
13/** Keys of the pictures the hooks module repaints with `$.ui.blit`. */
14export const ANIMATED = Object.freeze(['shimmer', 'waterfall', 'fan', 'pulse']);
15
16/** Picture sizes that fit the pane body. */
17export function sizesOf(columns = 80, rows = 30) {
18 const width = Math.max(24, Math.min(128, columns - 6));
19 const height = Math.max(6, Math.min(26, rows - 15)); // the header, tabs, card chrome and footer take ~15 rows
20 return { width, height };
21}
22
23const wrapIndex = (i, n) => ((i % n) + n) % n;
24const radarLayout = (columns, rows) => {
25 const { width, height } = sizesOf(columns, rows);
26 const wide = (columns ?? 80) >= 110;
27 const fanW = wide ? Math.floor(width * 0.55) : width;
28 // A 120° wedge is about width / (4·sin 60°) rows tall.
29 const fanH = Math.max(6, Math.min(height, Math.round(fanW / 3.4) + 1));
30 return { width, wide, fanW, fanH, chartW: wide ? Math.max(20, width - fanW - 6) : width };
31};
32
33/**
34 * Every animated picture for this mode at time `t` (ms): `{ shimmer, waterfall,
35 * fan, pulse }`, each `{ grid, ... }` or null. opts: { mode, columns, rows, t,
36 * nodeIndex, lag (per node key), history }.
37 */
38export function picturesOf(model, opts) {
39 const mode = opts.mode || 'overview';
40 const t = Number.isFinite(opts.t) ? opts.t : 0;
41 const columns = opts.columns ?? 80;
42 const out = { shimmer: { grid: shimmer(Math.max(8, Math.min(160, columns - 4)), t) }, waterfall: null, fan: null, pulse: null };
43 if (!model) return out;
44 if (mode === 'waterfall' && model.spectrum?.length) {
45 const nodes = model.spectrum;
46 const node = nodes[wrapIndex(opts.nodeIndex ?? 0, nodes.length)];
47 const { width, height } = sizesOf(columns, opts.rows);
48 out.waterfall = { ...waterfall(shownFrames(node.frames, opts.lag?.[node.key]), width, height), node, nodes };
49 }
50 if (mode === 'radar' && model.radar) {
51 const history = opts.history || { distance: [] };
52 const { fanW, fanH, chartW } = radarLayout(columns, opts.rows);
53 out.fan = radarFan(fanW, fanH, { distances: history.distance, present: model.radar.present, ping: pingOf(t) });
54 out.pulse = { grid: pulse(Math.min(chartW, 40), t, { heartBpm: model.radar.heartBpm, breathingBpm: model.radar.breathingBpm }) };
55 }
56 return out;
57}
58
59/**
60 * Draw the pane. opts: { mode, nodeIndex, refreshMs, liveRefreshMs, busy, now, t, lag, columns, rows,
61 * history, udpPort, radarConfigured, onRefresh, onClose, onMode(mode), onNextNode }.
62 */
63export function viewOf(ui, model, opts) {
64 const { Box, Text, Button } = ui;
65 const mode = opts.mode || 'overview';
66 const live = mode !== 'overview';
67 const interval = live ? (opts.liveRefreshMs ?? opts.refreshMs) : opts.refreshMs;
68 const t = (children, props = {}) => Text({ wrap: 'truncate-end', ...props, children });
69 const row = (children, props = {}) => Box({ flexDirection: 'row', gap: 1, ...props, children: children.filter(Boolean) });
70 const pics = ui.Raster ? picturesOf(model, opts) : {};
71
72 // Header: freshness badge and age, then the signal rule.
73 const finiteAt = model && Number.isFinite(model.at);
74 const age = finiteAt && Number.isFinite(opts.now) ? opts.now - model.at : NaN;
75 const fresh = Number.isFinite(age) && age <= interval * 2 + 5000;
76 const when = finiteAt ? new Date(model.at).toLocaleTimeString() : '—';
77 const badge = !model ? t('○ STARTING', { dimColor: true, bold: true })
78 : fresh ? t('● LIVE', { color: 'green', bold: true }) : t('○ STALE', { color: 'yellow', bold: true });
79 const header = row([
80 t('RuView', { color: 'cyan', bold: true }),
81 badge,
82 t(model ? `updated ${when}${ageOf(age) ? ` (${ageOf(age)})` : ''} · every ${Math.round(interval / 1000)}s` : `waiting for the first capture… · every ${Math.round(interval / 1000)}s`, { dimColor: true }),
83 opts.busy ? t('⟳ refreshing', { color: 'cyan' }) : null,
84 ]);
85 const rule = pics.shimmer ? ui.Raster(pics.shimmer.grid.toRaster('shimmer')) : null;
86
87 // Hotkeys reach a Pane only while it holds the keyboard; say how to give it.
88 const tabs = opts.onMode ? row([
89 ...TABS.map(([m, key, label]) => Button({
90 key: `tab-${m}`, hotkey: key, label: `${m === mode ? '▸ ' : ''}${label} (${key})`, onPress: () => opts.onMode(m),
91 })),
92 opts.focused === false ? t('click the pane or press ctrl+x tab to use the keys', { dimColor: true, italic: true }) : null,
93 ], { gap: 2 }) : null;
94
95 const alerts = (model?.alerts || []).map((a) => Text({ color: a.level === 'bad' ? 'red' : 'yellow', wrap: 'wrap', children: `! ${a.text}` }));
96
97 let body;
98 if (mode === 'waterfall') body = waterfallView(ui, model, opts, pics, t, row);
99 else if (mode === 'radar') body = radarView(ui, model, opts, pics, t, row);
100 else body = overview(ui, model, opts, t, row);
101
102 const footer = row([
103 Button({ key: 'refresh', label: opts.busy ? 'Refreshing…' : 'Refresh (r)', hotkey: 'r', onPress: opts.onRefresh }),
104 mode === 'waterfall' && opts.onNextNode && (model?.spectrum?.length ?? 0) > 1 ? Button({ key: 'next-node', label: 'Next node (n)', hotkey: 'n', onPress: opts.onNextNode }) : null,
105 Button({ key: 'close', label: 'Close (c)', hotkey: 'c', onPress: opts.onClose }),
106 ], { gap: 2 });
107
108 return Box({ flexDirection: 'column', gap: 1, paddingX: 1, children: [Box({ flexDirection: 'column', children: [header, rule].filter(Boolean) }), tabs, ...alerts, body, footer].filter(Boolean) });
109}
110
111function card(ui, title, subtitle, borderColor, children, t, row) {
112 return ui.Box({
113 flexDirection: 'column', borderStyle: 'round', borderColor, paddingX: 1, flexGrow: 1,
114 children: [row([t(title, { bold: true }), subtitle ? t(subtitle, { dimColor: true }) : null]), ...children.filter(Boolean)],
115 });
116}
117
118function needsTerminal(ui, t) {
119 return ui.Raster ? null : t('This view draws with terminal cells; open it in the terminal. The overview works everywhere.', { dimColor: true });
120}
121
122function overview(ui, model, opts, t, row) {
123 if (!model) return null;
124 const history = opts.history || { heart: [], breathing: [], distance: [], rates: {} };
125 const vital = (label, value, raw, kind, series) => {
126 const warn = kind ? plausibilityOf(kind, raw) : null;
127 return row([
128 t(label.padEnd(10), { dimColor: true }),
129 t(value.padStart(9), { bold: raw != null && !warn, color: warn ? 'yellow' : undefined }),
130 t(sparkline(series), { color: 'cyan' }),
131 warn ? t(`⚠ ${warn}`, { color: 'yellow' }) : null,
132 ]);
133 };
134 let radarCard = null;
135 if (model.radar) {
136 const r = model.radar;
137 const presence = r.present == null ? t('? presence unknown', { dimColor: true })
138 : r.present ? t(`● PRESENCE DETECTED${r.targets ? ` · ${r.targets} target${r.targets === 1 ? '' : 's'}` : ''}`, { color: 'green', bold: true })
139 : t('○ no presence', { dimColor: true });
140 radarCard = card(ui, '60 GHz RADAR', r.name, r.present ? 'green' : 'gray', [
141 presence,
142 vital('distance', r.distance, r.distanceCm, null, history.distance),
143 vital('heart', r.heart, r.heartBpm, 'heart', history.heart),
144 vital('breathing', r.breathing, r.breathingBpm, 'breathing', history.breathing),
145 t('device-reported values, not validated against a reference', { dimColor: true, italic: true }),
146 ], t, row);
147 } else if (!opts.radarConfigured) {
148 radarCard = card(ui, '60 GHz RADAR', 'not configured', 'gray', [
149 t('Set radarHost to an ESPHome radar kit:', { dimColor: true }),
150 t('claude plugin configure ruview-live', { color: 'cyan' }),
151 ], t, row);
152 }
153 const lines = model.nodes.length
154 ? model.nodes.map((n) => row([
155 t(n.label.padEnd(10), { bold: true }),
156 t(n.rate.padStart(9)),
157 t(sparkline(history.rates[n.key]), { color: 'cyan' }),
158 t(`loss ${n.loss}`, n.lossy ? { color: 'yellow' } : { dimColor: true }),
159 t(n.rssi, { dimColor: true }),
160 t(n.shape, { dimColor: true }),
161 n.synthetic ? t('SYNTHETIC', { color: 'yellow', bold: true }) : null,
162 ]))
163 : [
164 t('none streaming to this machine', { dimColor: true }),
165 model.noPackets ? t(`check node target IP/port · firewall UDP ${opts.udpPort ?? 5005}`, { dimColor: true }) : null,
166 ];
167 const nodesCard = card(ui, 'CSI NODES', `UDP ${opts.udpPort ?? 5005} · ${model.decoded}/${model.packets} decoded`, model.nodes.length ? 'cyan' : 'gray', [
168 ...lines,
169 model.nodes.length ? t('press 2 for the live CSI waterfall', { dimColor: true, italic: true }) : null,
170 ], t, row);
171 const cards = [radarCard, nodesCard].filter(Boolean);
172 return ui.Box({ flexDirection: (opts.columns ?? 80) >= 100 ? 'row' : 'column', gap: 1, children: cards });
173}
174
175function waterfallView(ui, model, opts, pics, t, row) {
176 const note = needsTerminal(ui, t);
177 if (note) return note;
178 if (!model) return t('waiting for the first capture…', { dimColor: true });
179 if (!pics.waterfall) {
180 return card(ui, 'CSI WATERFALL', 'no CSI frames', 'gray', [
181 t('No CSI node is streaming to this machine, so there is nothing to draw.', { dimColor: true }),
182 t(`Point a node at this host's UDP ${opts.udpPort ?? 5005} (provision.py --target-ip), then this view fills by itself.`, { dimColor: true }),
183 ], t, row);
184 }
185 const { grid, lo, hi, node: n, nodes } = pics.waterfall;
186 const legend = colourbar(Math.min(32, grid.columns - 20));
187 return card(ui, 'CSI WATERFALL', `${n.label} · ${n.subcarriers} subcarriers → ${n.bins} bins · ${n.rateHz ?? '—'} Hz${nodes.length > 1 ? ` · node ${nodes.indexOf(n) + 1}/${nodes.length}` : ''}`, n.synthetic ? 'yellow' : 'cyan', [
188 n.synthetic ? t('SYNTHETIC — simulator frames, not a live measurement', { color: 'yellow', bold: true }) : t('MEASURED — live CSI amplitude received on this host', { color: 'green' }),
189 ui.Raster(grid.toRaster('waterfall')),
190 row([t('subcarrier →', { dimColor: true }), t('newest at the bottom · replayed at the arrival rate', { dimColor: true })], { justifyContent: 'space-between' }),
191 row([t(lo.toFixed(0), { dimColor: true }), ui.Raster(legend.toRaster('legend')), t(`${hi.toFixed(0)} amplitude (5th–95th pct)`, { dimColor: true })]),
192 ], t, row);
193}
194
195function radarView(ui, model, opts, pics, t, row) {
196 const note = needsTerminal(ui, t);
197 if (note) return note;
198 if (!model) return t('waiting for the first capture…', { dimColor: true });
199 if (!opts.radarConfigured) {
200 return card(ui, 'RADAR', 'not configured', 'gray', [t('Set radarHost: claude plugin configure ruview-live', { color: 'cyan' })], t, row);
201 }
202 const r = model.radar;
203 if (!r || !pics.fan) return card(ui, '60 GHz RADAR', 'unreachable', 'red', [t('No reading this refresh; see the alert above.', { dimColor: true })], t, row);
204 const history = opts.history || { heart: [], breathing: [], distance: [] };
205 const { wide, chartW } = radarLayout(opts.columns, opts.rows);
206 const presence = r.present ? t(`● PRESENCE DETECTED · ${r.distance}${r.targets ? ` · ${r.targets} target${r.targets === 1 ? '' : 's'}` : ''}`, { color: 'green', bold: true })
207 : t('○ no presence', { dimColor: true });
208 const fan = card(ui, '60 GHz RADAR', `${r.name} · range ${pics.fan.maxCm / 100} m`, r.present ? 'green' : 'gray', [
209 presence,
210 ui.Raster(pics.fan.grid.toRaster('fan')),
211 t('range only: this kit reports distance, not bearing (arc = every bearing at that range)', { dimColor: true, italic: true }),
212 ], t, row);
213 const chart = (label, series, kind, unit, colour, minSpan) => {
214 const { grid: g, lo, hi } = lineChart(series, chartW, 3, { band: kind ? RANGES[kind] : null, colour, minSpan });
215 const last = [...series].reverse().find((v) => Number.isFinite(v));
216 const warn = kind ? plausibilityOf(kind, last ?? null) : null;
217 return [
218 row([t(label, { bold: true }), t(last == null ? '—' : `${last.toFixed(1)} ${unit}`, { color: warn ? 'yellow' : undefined }), warn ? t(`⚠ ${warn}`, { color: 'yellow' }) : null, t(`${lo.toFixed(0)}–${hi.toFixed(0)}`, { dimColor: true })]),
219 ui.Raster(g.toRaster(`chart-${label}`)),
220 ];
221 };
222 const charts = card(ui, 'VITALS', 'device-reported, not validated', 'gray', [
223 pics.pulse ? ui.Raster(pics.pulse.grid.toRaster('pulse')) : null,
224 pics.pulse ? t('♥ beats and the gauge breathes at the reported rates (a metronome, not a waveform)', { dimColor: true, italic: true }) : null,
225 ...chart('heart', history.heart, 'heart', 'bpm', rgb(255, 110, 130), 10),
226 ...chart('breathing', history.breathing, 'breathing', 'bpm', rgb(110, 200, 255), 4),
227 ...chart('distance', history.distance, null, 'cm', rgb(120, 255, 170), 20),
228 t('shaded = inside the plausible resting range', { dimColor: true, italic: true }),
229 ], t, row);
230 return ui.Box({ flexDirection: wide ? 'row' : 'column', gap: 1, children: [fan, charts] });
231}
232hooks/raster.mjs 258 lines1// SPDX-License-Identifier: MIT
2// Cell graphics for the ruview-live showcase (ADR-378): every picture is a
3// terminal `Raster`, a grid of width-1 characters with 24-bit colours, so it
4// draws in any terminal (Windows Terminal included), not only pixel-capable
5// ones. Pure functions: no engine calls, so Node tests can check them.
6
7/** The terminal's own colour (bit 24 alone), per the Raster contract. */
8export const DEFAULT = 0x01000000;
9
10/** 0xRRGGBB from components. */
11export const rgb = (r, g, b) => (((r & 255) << 16) | ((g & 255) << 8) | (b & 255)) >>> 0;
12
13const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
14
15/** Standard padded base64 of a byte array (the mod environment has no Node Buffer). */
16export function toBase64(bytes) {
17 let out = '';
18 let i = 0;
19 for (; i + 2 < bytes.length; i += 3) {
20 const n = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2];
21 out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63] + B64[(n >> 6) & 63] + B64[n & 63];
22 }
23 const rest = bytes.length - i;
24 if (rest === 1) {
25 const n = bytes[i] << 16;
26 out += `${B64[(n >> 18) & 63]}${B64[(n >> 12) & 63]}==`;
27 } else if (rest === 2) {
28 const n = (bytes[i] << 16) | (bytes[i + 1] << 8);
29 out += `${B64[(n >> 18) & 63]}${B64[(n >> 12) & 63]}${B64[(n >> 6) & 63]}=`;
30 }
31 return out;
32}
33
34/** A grid of cells: code point, foreground, background. */
35export class Grid {
36 constructor(columns, rows) {
37 this.columns = Math.max(1, Math.min(512, Math.floor(columns)));
38 this.rows = Math.max(1, Math.min(256, Math.floor(rows)));
39 this.cells = new Uint32Array(this.columns * this.rows * 3);
40 for (let i = 0; i < this.columns * this.rows; i++) {
41 this.cells[i * 3] = 0x20;
42 this.cells[i * 3 + 1] = DEFAULT;
43 this.cells[i * 3 + 2] = DEFAULT;
44 }
45 }
46 set(x, y, ch, fg = DEFAULT, bg = DEFAULT) {
47 if (x < 0 || y < 0 || x >= this.columns || y >= this.rows) return;
48 const i = (Math.floor(y) * this.columns + Math.floor(x)) * 3;
49 this.cells[i] = typeof ch === 'number' ? ch : ch.codePointAt(0);
50 this.cells[i + 1] = fg >>> 0;
51 this.cells[i + 2] = bg >>> 0;
52 }
53 get(x, y) {
54 const i = (y * this.columns + x) * 3;
55 return [this.cells[i], this.cells[i + 1], this.cells[i + 2]];
56 }
57 /** Write a string left to right (labels over a picture). */
58 text(x, y, str, fg = DEFAULT, bg = DEFAULT) {
59 [...str].forEach((ch, k) => this.set(x + k, y, ch, fg, bg));
60 }
61 /** Props for the surface's `Raster` element. */
62 toRaster(key) {
63 const bytes = new Uint8Array(this.cells.buffer, this.cells.byteOffset, this.cells.byteLength);
64 return { key, columns: this.columns, rows: this.rows, cells: toBase64(bytes) };
65 }
66}
67
68// Perceptual-ish ramp on near-black: indigo → cyan → emerald → amber → white.
69const STOPS = [
70 [0.0, [10, 12, 22]], [0.22, [36, 38, 120]], [0.45, [0, 150, 200]],
71 [0.65, [30, 205, 140]], [0.85, [235, 205, 70]], [1.0, [255, 250, 235]],
72];
73
74/** Colour for t in [0, 1]. */
75export function colormap(t) {
76 const x = Math.max(0, Math.min(1, Number.isFinite(t) ? t : 0));
77 for (let k = 1; k < STOPS.length; k++) {
78 const [t1, c1] = STOPS[k];
79 if (x <= t1) {
80 const [t0, c0] = STOPS[k - 1];
81 const f = (x - t0) / (t1 - t0 || 1);
82 return rgb(c0[0] + (c1[0] - c0[0]) * f, c0[1] + (c1[1] - c0[1]) * f, c0[2] + (c1[2] - c0[2]) * f);
83 }
84 }
85 return rgb(255, 250, 235);
86}
87
88/** The values at fractions p of a sample (robust colour limits). */
89export function percentiles(values, ps) {
90 const v = values.filter((x) => Number.isFinite(x)).sort((a, b) => a - b);
91 if (!v.length) return ps.map(() => 0);
92 return ps.map((p) => v[Math.min(v.length - 1, Math.max(0, Math.round(p * (v.length - 1))))]);
93}
94
95/**
96 * Waterfall of amplitude frames (newest last): subcarrier bins across, time
97 * down, two frames per text row through the upper-half block (fg = upper
98 * frame, bg = lower frame). Colour limits are the 5th–95th percentile of the
99 * shown frames. Returns { grid, lo, hi, shown }.
100 */
101export function waterfall(frames, columns, rows) {
102 const g = new Grid(columns, rows);
103 const shown = (frames || []).slice(-(g.rows * 2));
104 const [lo, hi] = percentiles(shown.flat(), [0.05, 0.95]);
105 const span = hi - lo || 1;
106 const offset = g.rows * 2 - shown.length; // empty frame slots at the top
107 const colourAt = (slot, x) => {
108 const f = shown[slot - offset];
109 if (!f || !f.length) return rgb(10, 12, 22);
110 const bin = Math.min(f.length - 1, Math.floor((x * f.length) / g.columns));
111 return colormap((f[bin] - lo) / span);
112 };
113 for (let y = 0; y < g.rows; y++) {
114 for (let x = 0; x < g.columns; x++) g.set(x, y, 0x2580, colourAt(y * 2, x), colourAt(y * 2 + 1, x));
115 }
116 return { grid: g, lo, hi, shown: shown.length };
117}
118
119/** A one-row colour bar for a legend. */
120export function colourbar(columns) {
121 const g = new Grid(columns, 1);
122 for (let x = 0; x < g.columns; x++) g.set(x, 0, 0x2588, colormap(x / Math.max(1, g.columns - 1)));
123 return g;
124}
125
126const BRAILLE_BITS = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]];
127
128/** A braille canvas: 2×4 dots per cell, one colour per cell (highest priority wins). */
129export class Braille {
130 constructor(columns, rows) {
131 this.columns = Math.max(1, Math.min(512, Math.floor(columns)));
132 this.rows = Math.max(1, Math.min(256, Math.floor(rows)));
133 this.width = this.columns * 2;
134 this.height = this.rows * 4;
135 this.bits = new Uint8Array(this.columns * this.rows);
136 this.colour = new Uint32Array(this.columns * this.rows).fill(DEFAULT);
137 this.prio = new Int8Array(this.columns * this.rows).fill(-1);
138 this.bg = new Uint32Array(this.columns * this.rows).fill(DEFAULT);
139 }
140 plot(px, py, colour = DEFAULT, prio = 0) {
141 const x = Math.round(px);
142 const y = Math.round(py);
143 if (x < 0 || y < 0 || x >= this.width || y >= this.height) return;
144 const c = (y >> 2) * this.columns + (x >> 1);
145 this.bits[c] |= BRAILLE_BITS[y & 3][x & 1];
146 if (prio >= this.prio[c]) { this.prio[c] = prio; this.colour[c] = colour; }
147 }
148 /** A straight segment between two dot positions. */
149 line(x0, y0, x1, y1, colour, prio = 0) {
150 const steps = Math.max(1, Math.ceil(Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0))));
151 for (let s = 0; s <= steps; s++) this.plot(x0 + ((x1 - x0) * s) / steps, y0 + ((y1 - y0) * s) / steps, colour, prio);
152 }
153 /** Shade a cell's background (bands, glows). */
154 shade(cx, cy, colour) {
155 if (cx < 0 || cy < 0 || cx >= this.columns || cy >= this.rows) return;
156 this.bg[cy * this.columns + cx] = colour;
157 }
158 toGrid() {
159 const g = new Grid(this.columns, this.rows);
160 for (let y = 0; y < this.rows; y++) {
161 for (let x = 0; x < this.columns; x++) {
162 const c = y * this.columns + x;
163 g.set(x, y, this.bits[c] ? 0x2800 + this.bits[c] : 0x20, this.colour[c], this.bg[c]);
164 }
165 }
166 return g;
167 }
168}
169
170const GREY = rgb(70, 76, 84);
171const DIM = rgb(110, 118, 128);
172
173/**
174 * Top-down radar fan: the sensor at the bottom centre, range rings, the field
175 * of view as a wedge, and the measured distance as an arc across it (this kit
176 * reports range, not bearing), with older distances as fading arcs.
177 * history: distances in cm, oldest first; present: boolean|null.
178 */
179export function radarFan(columns, rows, { distances = [], present = null, fovDeg = 120, ping = null } = {}) {
180 const c = new Braille(columns, rows);
181 const ox = c.width / 2;
182 const oy = c.height - 1;
183 const known = distances.filter((d) => Number.isFinite(d) && d > 0);
184 const latest = known.length ? known[known.length - 1] : null;
185 const maxCm = Math.max(150, Math.min(600, Math.ceil(((latest ?? 100) * 1.4) / 50) * 50));
186 const half = (fovDeg * Math.PI) / 360;
187 // Dots per cm (dots are about square): the wedge's half-width is r·sin(half).
188 const scale = Math.min(oy, ox / Math.sin(Math.min(half, Math.PI / 2))) / maxCm;
189 const arc = (rCm, colour, prio, density = 1) => {
190 const r = rCm * scale;
191 const steps = Math.max(8, Math.ceil(r * 2 * half * density));
192 for (let s = 0; s <= steps; s++) {
193 const a = -half + (2 * half * s) / steps;
194 c.plot(ox + r * Math.sin(a), oy - r * Math.cos(a), colour, prio);
195 }
196 };
197 // Wedge edges and rings every 50 cm.
198 for (const a of [-half, half]) c.line(ox, oy, ox + maxCm * scale * Math.sin(a), oy - maxCm * scale * Math.cos(a), GREY, 0);
199 for (let r = 50; r <= maxCm; r += 50) arc(r, GREY, 0, 0.35);
200 // Trail: older distances fade from teal to near-background.
201 const trail = known.slice(-8, -1);
202 trail.forEach((d, k) => {
203 const f = (k + 1) / (trail.length + 1);
204 arc(d, rgb(20 + 20 * f, 70 + 90 * f, 80 + 80 * f), 1);
205 });
206 // Ping (animation): a ring leaving the sensor and reaching the measured range.
207 if (ping != null && latest != null) {
208 const f = Math.max(0, Math.min(1, ping));
209 const fade = 0.35 + 0.65 * f;
210 arc(latest * f, rgb(40 * fade, 160 * fade, 200 * fade), 2, 1);
211 }
212 const hit = ping != null && ping > 0.92;
213 if (latest != null) arc(latest, hit ? rgb(220, 255, 235) : present ? rgb(80, 255, 150) : rgb(230, 235, 240), 3, 1.6);
214 // Sensor marker.
215 c.plot(ox, oy, rgb(255, 220, 90), 4);
216 c.plot(ox - 1, oy, rgb(255, 220, 90), 4);
217 const g = c.toGrid();
218 // Ring labels along the right edge of the wedge.
219 for (let r = 100; r <= maxCm; r += 100) {
220 const px = ox + r * scale * Math.sin(half);
221 const py = oy - r * scale * Math.cos(half);
222 g.text(Math.min(g.columns - 3, Math.round(px / 2) + 1), Math.max(0, Math.round(py / 4)), `${r / 100}m`, DIM);
223 }
224 return { grid: g, maxCm };
225}
226
227/**
228 * Braille line chart of a series (oldest first, nulls skipped). The scale fits
229 * the data (at least `minSpan` wide), so a rhythm stays visible; a plausible
230 * band is shaded where it overlaps the scale. Returns { grid, lo, hi }.
231 */
232export function lineChart(series, columns, rows, { band = null, colour = rgb(0, 200, 220), minSpan = 2 } = {}) {
233 const c = new Braille(columns, rows);
234 const pts = (series || []).map((v, i) => [i, v]).filter(([, v]) => Number.isFinite(v)).slice(-c.width);
235 const vals = pts.map(([, v]) => v);
236 let lo = vals.length ? Math.min(...vals) : (band ? band[0] : 0);
237 let hi = vals.length ? Math.max(...vals) : (band ? band[1] : 1);
238 const pad = Math.max(0, minSpan - (hi - lo)) / 2 + (hi - lo) * 0.1;
239 lo -= pad;
240 hi += pad;
241 if (hi - lo < 1e-9) { lo -= 1; hi += 1; }
242 const yOf = (v) => (c.height - 1) - ((v - lo) / (hi - lo)) * (c.height - 1);
243 if (band && band[1] >= lo && band[0] <= hi) {
244 const top = Math.max(0, Math.floor(yOf(Math.min(band[1], hi)) / 4));
245 const bottom = Math.min(c.rows - 1, Math.floor(yOf(Math.max(band[0], lo)) / 4));
246 for (let y = top; y <= bottom; y++) for (let x = 0; x < c.columns; x++) c.shade(x, y, rgb(14, 34, 30));
247 }
248 const n = pts.length;
249 const xOf = (k) => (n <= 1 ? c.width - 1 : (k * (c.width - 1)) / (n - 1));
250 for (let k = 0; k < n; k++) {
251 const y = yOf(pts[k][1]);
252 if (k > 0) c.line(xOf(k - 1), yOf(pts[k - 1][1]), xOf(k), y, colour, 1);
253 else c.plot(xOf(k), y, colour, 1);
254 }
255 if (n) c.plot(xOf(n - 1), yOf(pts[n - 1][1]), rgb(255, 255, 255), 2);
256 return { grid: c.toGrid(), lo, hi };
257}
258