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>
This commit is contained in:
ruv committed 2026-10-01 18:22:40 -04:00
1 parent c4d7ef8086
commit 751b7ae17b
21 files changed
+1331 -280

No files matched your search

+3 -2
View File
@@ -44,8 +44,9 @@ jobs:
# 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 reviewed ceiling.
unpacked_budget: 393216
# 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
+2 -2
View File
@@ -106,8 +106,8 @@ jobs:
case "${{ inputs.package }}" in
# 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; no runtime deps.
harness/ruview) export UNPACKED_BUDGET=393216 ;;
# 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).
@@ -0,0 +1,135 @@
# 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.
### 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.
+1
View File
@@ -2,6 +2,7 @@
Latest proposed decisions:
- [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)
+14 -1
View File
@@ -1066,6 +1066,16 @@ beside the conversation, refreshed on a timer, showing:
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.0 mod # prints the mod's path in your install and how to load it
claude --plugin-dir "<that path>" # try it for one session
@@ -1079,9 +1089,11 @@ claude --plugin-dir "<that path>" # try it for one session
| `/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` and `captureSeconds`.
`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
@@ -1105,6 +1117,7 @@ Design records:
- [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`).
---
+5 -5
View File
@@ -23,7 +23,7 @@
".mcp/servers.json": "fec6075400f8350d8075beac8306690355c4b015425bfd0e5f52966234e9d66f",
"CLAUDE.md": "25f9306b6caae22078cb9375108b6f6bb813d83e617739f6d9372f116b0ede5d",
"LICENSE": "631f94984f626818d42ecf717aa6e8e0afd4f9f355ca706bd2effafbd1416d06",
"README.md": "20df33af2fcd83481321d963b76169b863c6a8acb1d7281e9f40169786169a87",
"README.md": "a0ee4d09ecef1b125c9d96a6ec8cdfce869bb727c986e650bb7941b21d23ddce",
"bin/cli.js": "8ebf6ca4b6d9529a5e338374c19329c1a2fc2f9e52f13b4341928b7168ee461b",
"brain/corpus/core.jsonl": "c0fb7b079ded157059b91601361429944697dae3cc42abc00dfe1a680986b0f4",
"flywheel/evaluations.json": "ac4ff1f897a2444870cd2b8ae8aee8b1578e61467aeca4db57893f41be98a572",
@@ -45,8 +45,8 @@
"skills/verify.md": "2d38d240e9810a7827e2ebd3717dc0f85c646cc92e46c3812fe77c5b9eb40b76",
"src/brain.js": "0f16a75aea943acdacc430ff11d5df7ecdec9cca2ab497795ff6f33eaebdfab6",
"src/cli-ui.js": "507fbd4444c6639c69354790ed3aed2addcb826929a6b3ad7ddb752f058c1f2f",
"src/cli.js": "2401106a97509da8dcbf7e002195328868aa2b18f69cbc02ddaaf1cad95b4388",
"src/devices/esp32.js": "ae389193430fc85601b46aef9281ad91631a1cb9bf4d2f247dfb02e2a4491c4d",
"src/cli.js": "5e8467f0d0fb90d81347715143b3777e239165d707dd9d8c5d647294509a7fc3",
"src/devices/esp32.js": "726f1ce60b27e5392bedcd367cb190c2a81521ae7935c9e8051fdce768667e56",
"src/devices/esphome.js": "83b0fbd8c9075e233a401fa9f9dad04fc911e04819e7a0c48eb32d7e35d226af",
"src/devices/lidar.js": "d753d88375358ced5174376753c276b5ca621f38ae48eac58dbb307b9004b629",
"src/devices/mmwave.js": "09d828e3b3e140926194ab83bf27e7c63cbda1cf438385c82c0e89b9d8e4b526",
@@ -71,11 +71,11 @@
"src/sdk.d.ts": "82e73721de433551a2b4339821ebf7b2c9d44133c766498ae91d15483ed8a073",
"src/sdk.js": "a07b5e5777ee897a50c12f6002065c3ca781e087d625541d54e5f5308ede2116",
"src/spaces.js": "45ef786537cb2a446db5e926e5a1c10b73639d2767dec84611f914f78d4325eb",
"src/tools.js": "0fb9114944c79bf351ec736f4034b486608d1e96c7e42f6d3c617053fd8a90a7",
"src/tools.js": "7c41347b4be063457d7759ddb8f1f5dd608cbe28baa1c80e4a27d6567af6bc98",
"src/training.js": "b8451c48db69c7d077d75f07500996213beaa52705116f64afafe084e68e3956",
"src/ui/console-widget.js": "0d2f707d57b5ec54968dee1dc80f31c7db9152dcadfc48700c18379dabb55462"
},
"filesDigest": "d7989b453bdabbff37ef3b2382192c36b9513544ab593ffa9da97ba18dc7f003",
"filesDigest": "80177f2f205fb00ec00fddef0f46422c8421ab02d4e883282c247b8e7b69e797",
"brainDigest": "c0fb7b079ded157059b91601361429944697dae3cc42abc00dfe1a680986b0f4",
"gateFingerprint": "6e53c784eee38310188948fc75fb49e6b4ebc04e247d01b903fa8c8a92d67bdd",
"developmentPins": {
+1 -1
View File
@@ -1 +1 @@
b7f72c94e8f41c3f2687549972e5ec69cfd0302f16d90ff3aaa511449f5158bd manifest.json
09b27be67905c5459a5e1a01c21e66a3c9d9f9c523befc1361b0c41a0a06c9ac manifest.json
+12
View File
@@ -251,6 +251,16 @@ This package ships **`ruview-live`**, a Claude Code mod: a plugin whose behaviou
It also keeps a one-line status: `RuView · 2 nodes · radar present · 1 alert`.
**Showcase views (ADR-378).** The pane has three views, switched with keys `1`, `2` and `3`. The two live views are drawn in terminal cells (24-bit colour) and animated in place:
| Key | View | What you see |
|---|---|---|
| `1` | Overview | the cards above |
| `2` | CSI waterfall | per-subcarrier amplitude over time (`esp32 --spectrum`), replayed at the frames' arrival rate and labelled **MEASURED** or **SYNTHETIC** from the packets' own flag; `n` cycles nodes |
| `3` | Radar | a 120° range fan with a sonar ping out to the measured distance; heart and breathing charts, and a ♥ and breathing gauge that pulse at the device-reported rates (a metronome, not a waveform) |
The radar kit reports range, not bearing, so the arc covers every bearing at that range. Desktop and IDE surfaces show the overview, and a note in the live views.
```bash
npx @ruvnet/ruview mod # where the mod is in this install, and how to load it
claude --plugin-dir "<path printed above>" # try it for one session
@@ -266,11 +276,13 @@ In Claude Code:
| `/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 the pane on that view |
**Settings** (`claude plugin configure ruview-live`):
- `udpPort` (default 5005);
- `radarHost`, an ESPHome kit on your private network;
- `refreshSeconds` (default 15, minimum 5);
- `liveRefreshSeconds` for the waterfall and radar views (default 4, range 2–60);
- `captureSeconds` (default 3).
**How it reads:**
@@ -1,7 +1,7 @@
{
"name": "ruview-live",
"version": "0.1.0",
"description": "RuView live sensing in Claude Code: /ruview opens a pane beside the transcript with your CSI nodes (ESP32, Realtek) and 60 GHz radar (raw or ESPHome kits): rates, loss, RSSI, presence, device-reported vitals and alerts such as heartbeat-only boards, refreshed on a timer, plus a one-line status. Reads only; runs the @ruvnet/ruview CLI it ships beside.",
"description": "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 radar (rates, loss, RSSI, presence, device-reported vitals, alerts), a live CSI amplitude waterfall, and a radar range fan with vitals charts, all drawn in terminal cells. Reads only; runs the @ruvnet/ruview CLI it ships beside.",
"author": {
"name": "ruvnet",
"url": "https://github.com/ruvnet/RuView"
@@ -32,6 +32,12 @@
"title": "Capture window (s)",
"description": "Seconds each refresh listens for node packets and radar states (1-10).",
"default": 3
},
"liveRefreshSeconds": {
"type": "number",
"title": "Live view refresh (s)",
"description": "Seconds between refreshes in the waterfall and radar views (2-60).",
"default": 4
}
}
}
+73
View File
@@ -0,0 +1,73 @@
// SPDX-License-Identifier: MIT
// ruview-live animation frames (ADR-378): pure functions of time that the
// hooks module repaints in place with `$.ui.blit` (no redraw). Decoration is
// labelled as decoration; anything driven by a reading says which reading.
import { Grid, rgb } from './raster.mjs';
const mix = (a, b, f) => rgb(
((a >> 16) & 255) + ((((b >> 16) & 255) - ((a >> 16) & 255)) * f),
((a >> 8) & 255) + ((((b >> 8) & 255) - ((a >> 8) & 255)) * f),
(a & 255) + (((b & 255) - (a & 255)) * f),
);
const CYAN = rgb(0, 190, 220);
const EMERALD = rgb(40, 210, 140);
const DARK = rgb(16, 22, 30);
/** A one-row cyan→emerald rule with a highlight sweeping across it (pure decoration). */
export function shimmer(columns, tMs) {
const g = new Grid(columns, 1);
const head = ((tMs / 2400) % 1) * (g.columns + 24) - 12;
for (let x = 0; x < g.columns; x++) {
const base = mix(CYAN, EMERALD, x / Math.max(1, g.columns - 1));
const glow = Math.max(0, 1 - Math.abs(x - head) / 10);
g.set(x, 0, 0x2501, mix(mix(DARK, base, 0.55), rgb(235, 255, 250), glow * glow));
}
return g;
}
/** Phase in [0, 1) of a rhythm at `perMinute` (0 when unknown). */
export function phaseOf(tMs, perMinute) {
if (!Number.isFinite(perMinute) || perMinute <= 0) return 0;
return ((tMs / 60000) * perMinute) % 1;
}
/**
* A heart icon that beats, and a breathing gauge that fills and empties, at
* the device-reported rates: a metronome of the reading, not a waveform.
* Unknown rates draw a still, dim strip.
*/
export function pulse(columns, tMs, { heartBpm = null, breathingBpm = null } = {}) {
const g = new Grid(columns, 1);
const beat = Number.isFinite(heartBpm) ? Math.exp(-phaseOf(tMs, heartBpm) * 7) : 0;
g.set(0, 0, 0x2665, Number.isFinite(heartBpm) ? mix(rgb(90, 20, 35), rgb(255, 90, 120), beat) : rgb(70, 76, 84));
const width = g.columns - 3;
const breath = Number.isFinite(breathingBpm) ? 0.5 - 0.5 * Math.cos(2 * Math.PI * phaseOf(tMs, breathingBpm)) : 0;
const fill = breath * width;
for (let x = 0; x < width; x++) {
const f = Math.max(0, Math.min(1, fill - x));
const on = mix(rgb(40, 120, 200), rgb(140, 220, 255), x / Math.max(1, width));
g.set(x + 3, 0, f > 0 ? 0x2588 : 0x2500, f > 0 ? mix(DARK, on, f) : rgb(40, 46, 54));
}
return g;
}
/**
* Waterfall replay: frames arrive a capture at a time, so the view shows them
* at their arrival rate instead of jumping. `lag` is how many received frames
* are not shown yet; it drains at `rateHz` and never exceeds what is held.
*/
export function drain(lag, rateHz, dtMs) {
if (!Number.isFinite(lag) || lag <= 0) return 0;
const rate = Number.isFinite(rateHz) && rateHz > 0 ? rateHz : 10;
return Math.max(0, lag - (rate * dtMs) / 1000);
}
/** Frames to show now: all but the `lag` newest. */
export function shownFrames(frames, lag) {
const hold = Math.min(frames.length, Math.max(0, Math.floor(lag || 0)));
return hold ? frames.slice(0, frames.length - hold) : frames;
}
/** Sonar ping phase for the radar fan: a ring leaving the sensor every 1.6 s. */
export const pingOf = (tMs) => (tMs % 1600) / 1600;
+168
View File
@@ -0,0 +1,168 @@
// SPDX-License-Identifier: MIT
// ruview-live data: settings, CLI argv, result parsing, the pane model, trend
// history and the status line. Pure functions, tested under plain Node.
const HOST_RE = /^[A-Za-z0-9][A-Za-z0-9.-]{0,252}$/;
export const HISTORY = 48;
export const MODES = Object.freeze(['overview', 'waterfall', 'radar']);
/**
* The harness CLI beside this mod: the plugin root is <pkg>/mod, so the CLI is
* <pkg>/bin/cli.js. Mods import nothing but their own files, so the engine's
* `$.plugin.root` locates it.
*/
export function cliPathOf(pluginRoot) {
return `${String(pluginRoot).replace(/[\\/]+$/, '')}/../bin/cli.js`;
}
/** Normalise plugin options (userConfig) into bounded settings. */
export function settingsOf(options = {}) {
const num = (v, d, lo, hi) => {
const n = Number(v);
return Number.isFinite(n) ? Math.min(Math.max(Math.round(n), lo), hi) : d;
};
const host = typeof options.radarHost === 'string' ? options.radarHost.trim() : '';
return {
udpPort: num(options.udpPort, 5005, 1024, 65535),
radarHost: HOST_RE.test(host) ? host : '',
refreshMs: num(options.refreshSeconds, 15, 5, 3600) * 1000,
captureSeconds: num(options.captureSeconds, 3, 1, 10),
liveRefreshMs: num(options.liveRefreshSeconds, 4, 2, 60) * 1000,
};
}
/**
* argv for one capture and (optionally) one radar read. In a live view the
* capture is shorter, and the waterfall asks for binned amplitude frames.
*/
export function commandsOf(settings, { live = false, spectrum = null } = {}) {
const s = String(live ? Math.min(settings.captureSeconds, 2) : settings.captureSeconds);
const capture = ['esp32', '--seconds', s, '--udp-port', String(settings.udpPort), '--json'];
if (spectrum) capture.push('--spectrum', '--spectrum-bins', String(spectrum.bins), '--spectrum-frames', String(spectrum.frames));
return {
capture,
radar: settings.radarHost ? ['mmwave', '--source', 'esphome', '--host', settings.radarHost, '--seconds', s, '--json'] : null,
};
}
/** Parse a CLI run into its JSON result, or an honest failure. */
export function resultOf(run) {
if (!run) return null;
try {
const parsed = JSON.parse(run.stdout);
if (parsed && typeof parsed === 'object') return parsed;
} catch { /* fall through */ }
return { ok: false, reason: 'cli_error', detail: String(run.stderr || run.stdout || `exit ${run.exitCode}`).trim().slice(0, 300) };
}
const fixed = (v, d = 1) => (typeof v === 'number' && Number.isFinite(v) ? v.toFixed(d) : '—');
const pct = (v) => (typeof v === 'number' ? `${(v * 100).toFixed(1)}%` : '—');
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
/** Resting ranges outside which a device-reported vital is flagged, not trusted. */
export const RANGES = Object.freeze({ heart: [40, 180], breathing: [4, 40] });
/** Why a device-reported vital looks implausible, or null. */
export function plausibilityOf(kind, value) {
if (value == null || !RANGES[kind]) return null;
const [lo, hi] = RANGES[kind];
return value < lo || value > hi ? `outside ${lo}–${hi} bpm` : null;
}
const SPARK = '▁▂▃▄▅▆▇█';
/** Unicode sparkline of the last `width` finite values ('' below two points). */
export function sparkline(values, width = 16) {
const v = (values || []).filter((x) => typeof x === 'number' && Number.isFinite(x)).slice(-width);
if (v.length < 2) return '';
const lo = Math.min(...v);
const hi = Math.max(...v);
return v.map((x) => SPARK[hi === lo ? 3 : Math.round(((x - lo) / (hi - lo)) * 7)]).join('');
}
/** "12s ago" / "3m ago" from a millisecond age, or '' when unknown. */
export function ageOf(ms) {
if (!Number.isFinite(ms) || ms < 0) return '';
const s = Math.round(ms / 1000);
return s < 60 ? `${s}s ago` : `${Math.round(s / 60)}m ago`;
}
/** The pane's model: plain data, drawn by the views and summarised by statusOf. */
export function modelOf(capture, radar, at) {
const alerts = [];
// "No packets" is shown inside the nodes card; only real faults become alerts.
if (capture && capture.ok === false && capture.reason !== 'no_packets') {
alerts.push({ level: 'bad', text: `nodes: ${String(capture.reason).replace(/_/g, ' ')}${capture.remedy ? ` — ${capture.remedy}` : ''}` });
}
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)` });
if (radar && radar.ok === false) alerts.push({ level: 'bad', text: `radar: ${String(radar.reason).replace(/_/g, ' ')}${radar.detail ? ` — ${radar.detail}` : ''}` });
const nodes = (capture?.nodes || []).map((n) => ({
key: `${n.source}:${n.nodeId}`,
label: `${n.source === 'realtek' ? 'realtek' : 'esp32'} ${n.nodeId}`,
rateHz: num(n.csiRateHz),
rate: `${fixed(n.csiRateHz)} Hz`,
loss: pct(n.csiLossFraction),
rssi: `${fixed(n.rssiMean)} dBm`,
shape: n.csi?.shape || '—',
lossy: typeof n.csiLossFraction === 'number' && n.csiLossFraction > 0.05,
synthetic: Boolean(n.csi?.synthetic),
}));
const spectrum = (capture?.spectrum || []).map((s) => ({
key: `${s.source}:${s.nodeId}`,
label: `${s.source === 'realtek' ? 'realtek' : 'esp32'} ${s.nodeId}`,
subcarriers: s.subcarriers, bins: s.bins, frames: Array.isArray(s.frames) ? s.frames : [],
rateHz: num(s.rateHz), synthetic: Boolean(s.synthetic),
}));
const radarRow = radar && radar.ok !== false ? {
name: radar.device?.name || radar.host || 'radar',
present: radar.presentNow ?? (radar.presentFraction == null ? null : radar.presentFraction >= 0.5),
targets: num(radar.targetsMax),
distanceCm: num(radar.distanceCmMean),
heartBpm: num(radar.heartBpmMean),
breathingBpm: num(radar.breathingBpmMean),
distance: radar.distanceCmMean == null ? '—' : `${fixed(radar.distanceCmMean)} cm`,
heart: radar.heartBpmMean == null ? '—' : `${fixed(radar.heartBpmMean)} bpm`,
breathing: radar.breathingBpmMean == null ? '—' : `${fixed(radar.breathingBpmMean)} bpm`,
} : null;
return {
at, packets: capture?.packets ?? 0, decoded: capture?.decodedPackets ?? 0,
noPackets: capture?.reason === 'no_packets', nodes, spectrum, radar: radarRow, alerts,
};
}
/** Append one model to a bounded history of trend values. */
export function historyWith(history, model, max = HISTORY) {
const h = history || { heart: [], breathing: [], distance: [], rates: {} };
const cap = (arr, v) => [...arr, v].slice(-max);
const rates = { ...h.rates };
for (const n of model?.nodes || []) rates[n.key] = cap(rates[n.key] || [], n.rateHz);
return {
heart: cap(h.heart, model?.radar?.heartBpm ?? null),
breathing: cap(h.breathing, model?.radar?.breathingBpm ?? null),
distance: cap(h.distance, model?.radar?.distanceCm ?? null),
rates,
};
}
/**
* Carry each node's waterfall frames across captures, so the picture scrolls
* instead of restarting every refresh. A node whose bin count changes starts
* over; a node absent from this capture keeps its frames.
*/
export function spectrumWith(previous, spectrum, max = 128) {
const byKey = new Map((previous || []).map((s) => [s.key, s]));
for (const s of spectrum || []) {
const old = byKey.get(s.key);
const frames = old && old.bins === s.bins ? [...old.frames, ...s.frames] : [...s.frames];
byKey.set(s.key, { ...s, frames: frames.slice(-max) });
}
return [...byKey.values()];
}
/** One line for the status bar. */
export function statusOf(model) {
if (!model) return 'RuView · starting';
const parts = [`RuView · ${model.nodes.length} node${model.nodes.length === 1 ? '' : 's'}`];
if (model.radar) parts.push(`radar ${model.radar.present == null ? '?' : model.radar.present ? 'present' : 'clear'}`);
if (model.alerts.length) parts.push(`${model.alerts.length} alert${model.alerts.length === 1 ? '' : 's'}`);
return parts.join(' · ');
}
+257
View File
@@ -0,0 +1,257 @@
// SPDX-License-Identifier: MIT
// Cell graphics for the ruview-live showcase (ADR-378): every picture is a
// terminal `Raster`, a grid of width-1 characters with 24-bit colours, so it
// draws in any terminal (Windows Terminal included), not only pixel-capable
// ones. Pure functions: no engine calls, so Node tests can check them.
/** The terminal's own colour (bit 24 alone), per the Raster contract. */
export const DEFAULT = 0x01000000;
/** 0xRRGGBB from components. */
export const rgb = (r, g, b) => (((r & 255) << 16) | ((g & 255) << 8) | (b & 255)) >>> 0;
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
/** Standard padded base64 of a byte array (the mod environment has no Node Buffer). */
export function toBase64(bytes) {
let out = '';
let i = 0;
for (; i + 2 < bytes.length; i += 3) {
const n = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2];
out += B64[(n >> 18) & 63] + B64[(n >> 12) & 63] + B64[(n >> 6) & 63] + B64[n & 63];
}
const rest = bytes.length - i;
if (rest === 1) {
const n = bytes[i] << 16;
out += `${B64[(n >> 18) & 63]}${B64[(n >> 12) & 63]}==`;
} else if (rest === 2) {
const n = (bytes[i] << 16) | (bytes[i + 1] << 8);
out += `${B64[(n >> 18) & 63]}${B64[(n >> 12) & 63]}${B64[(n >> 6) & 63]}=`;
}
return out;
}
/** A grid of cells: code point, foreground, background. */
export class Grid {
constructor(columns, rows) {
this.columns = Math.max(1, Math.min(512, Math.floor(columns)));
this.rows = Math.max(1, Math.min(256, Math.floor(rows)));
this.cells = new Uint32Array(this.columns * this.rows * 3);
for (let i = 0; i < this.columns * this.rows; i++) {
this.cells[i * 3] = 0x20;
this.cells[i * 3 + 1] = DEFAULT;
this.cells[i * 3 + 2] = DEFAULT;
}
}
set(x, y, ch, fg = DEFAULT, bg = DEFAULT) {
if (x < 0 || y < 0 || x >= this.columns || y >= this.rows) return;
const i = (Math.floor(y) * this.columns + Math.floor(x)) * 3;
this.cells[i] = typeof ch === 'number' ? ch : ch.codePointAt(0);
this.cells[i + 1] = fg >>> 0;
this.cells[i + 2] = bg >>> 0;
}
get(x, y) {
const i = (y * this.columns + x) * 3;
return [this.cells[i], this.cells[i + 1], this.cells[i + 2]];
}
/** Write a string left to right (labels over a picture). */
text(x, y, str, fg = DEFAULT, bg = DEFAULT) {
[...str].forEach((ch, k) => this.set(x + k, y, ch, fg, bg));
}
/** Props for the surface's `Raster` element. */
toRaster(key) {
const bytes = new Uint8Array(this.cells.buffer, this.cells.byteOffset, this.cells.byteLength);
return { key, columns: this.columns, rows: this.rows, cells: toBase64(bytes) };
}
}
// Perceptual-ish ramp on near-black: indigo → cyan → emerald → amber → white.
const STOPS = [
[0.0, [10, 12, 22]], [0.22, [36, 38, 120]], [0.45, [0, 150, 200]],
[0.65, [30, 205, 140]], [0.85, [235, 205, 70]], [1.0, [255, 250, 235]],
];
/** Colour for t in [0, 1]. */
export function colormap(t) {
const x = Math.max(0, Math.min(1, Number.isFinite(t) ? t : 0));
for (let k = 1; k < STOPS.length; k++) {
const [t1, c1] = STOPS[k];
if (x <= t1) {
const [t0, c0] = STOPS[k - 1];
const f = (x - t0) / (t1 - t0 || 1);
return rgb(c0[0] + (c1[0] - c0[0]) * f, c0[1] + (c1[1] - c0[1]) * f, c0[2] + (c1[2] - c0[2]) * f);
}
}
return rgb(255, 250, 235);
}
/** The values at fractions p of a sample (robust colour limits). */
export function percentiles(values, ps) {
const v = values.filter((x) => Number.isFinite(x)).sort((a, b) => a - b);
if (!v.length) return ps.map(() => 0);
return ps.map((p) => v[Math.min(v.length - 1, Math.max(0, Math.round(p * (v.length - 1))))]);
}
/**
* Waterfall of amplitude frames (newest last): subcarrier bins across, time
* down, two frames per text row through the upper-half block (fg = upper
* frame, bg = lower frame). Colour limits are the 5th–95th percentile of the
* shown frames. Returns { grid, lo, hi, shown }.
*/
export function waterfall(frames, columns, rows) {
const g = new Grid(columns, rows);
const shown = (frames || []).slice(-(g.rows * 2));
const [lo, hi] = percentiles(shown.flat(), [0.05, 0.95]);
const span = hi - lo || 1;
const offset = g.rows * 2 - shown.length; // empty frame slots at the top
const colourAt = (slot, x) => {
const f = shown[slot - offset];
if (!f || !f.length) return rgb(10, 12, 22);
const bin = Math.min(f.length - 1, Math.floor((x * f.length) / g.columns));
return colormap((f[bin] - lo) / span);
};
for (let y = 0; y < g.rows; y++) {
for (let x = 0; x < g.columns; x++) g.set(x, y, 0x2580, colourAt(y * 2, x), colourAt(y * 2 + 1, x));
}
return { grid: g, lo, hi, shown: shown.length };
}
/** A one-row colour bar for a legend. */
export function colourbar(columns) {
const g = new Grid(columns, 1);
for (let x = 0; x < g.columns; x++) g.set(x, 0, 0x2588, colormap(x / Math.max(1, g.columns - 1)));
return g;
}
const BRAILLE_BITS = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]];
/** A braille canvas: 2×4 dots per cell, one colour per cell (highest priority wins). */
export class Braille {
constructor(columns, rows) {
this.columns = Math.max(1, Math.min(512, Math.floor(columns)));
this.rows = Math.max(1, Math.min(256, Math.floor(rows)));
this.width = this.columns * 2;
this.height = this.rows * 4;
this.bits = new Uint8Array(this.columns * this.rows);
this.colour = new Uint32Array(this.columns * this.rows).fill(DEFAULT);
this.prio = new Int8Array(this.columns * this.rows).fill(-1);
this.bg = new Uint32Array(this.columns * this.rows).fill(DEFAULT);
}
plot(px, py, colour = DEFAULT, prio = 0) {
const x = Math.round(px);
const y = Math.round(py);
if (x < 0 || y < 0 || x >= this.width || y >= this.height) return;
const c = (y >> 2) * this.columns + (x >> 1);
this.bits[c] |= BRAILLE_BITS[y & 3][x & 1];
if (prio >= this.prio[c]) { this.prio[c] = prio; this.colour[c] = colour; }
}
/** A straight segment between two dot positions. */
line(x0, y0, x1, y1, colour, prio = 0) {
const steps = Math.max(1, Math.ceil(Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0))));
for (let s = 0; s <= steps; s++) this.plot(x0 + ((x1 - x0) * s) / steps, y0 + ((y1 - y0) * s) / steps, colour, prio);
}
/** Shade a cell's background (bands, glows). */
shade(cx, cy, colour) {
if (cx < 0 || cy < 0 || cx >= this.columns || cy >= this.rows) return;
this.bg[cy * this.columns + cx] = colour;
}
toGrid() {
const g = new Grid(this.columns, this.rows);
for (let y = 0; y < this.rows; y++) {
for (let x = 0; x < this.columns; x++) {
const c = y * this.columns + x;
g.set(x, y, this.bits[c] ? 0x2800 + this.bits[c] : 0x20, this.colour[c], this.bg[c]);
}
}
return g;
}
}
const GREY = rgb(70, 76, 84);
const DIM = rgb(110, 118, 128);
/**
* Top-down radar fan: the sensor at the bottom centre, range rings, the field
* of view as a wedge, and the measured distance as an arc across it (this kit
* reports range, not bearing), with older distances as fading arcs.
* history: distances in cm, oldest first; present: boolean|null.
*/
export function radarFan(columns, rows, { distances = [], present = null, fovDeg = 120, ping = null } = {}) {
const c = new Braille(columns, rows);
const ox = c.width / 2;
const oy = c.height - 1;
const known = distances.filter((d) => Number.isFinite(d) && d > 0);
const latest = known.length ? known[known.length - 1] : null;
const maxCm = Math.max(150, Math.min(600, Math.ceil(((latest ?? 100) * 1.4) / 50) * 50));
const half = (fovDeg * Math.PI) / 360;
// Dots per cm (dots are about square): the wedge's half-width is r·sin(half).
const scale = Math.min(oy, ox / Math.sin(Math.min(half, Math.PI / 2))) / maxCm;
const arc = (rCm, colour, prio, density = 1) => {
const r = rCm * scale;
const steps = Math.max(8, Math.ceil(r * 2 * half * density));
for (let s = 0; s <= steps; s++) {
const a = -half + (2 * half * s) / steps;
c.plot(ox + r * Math.sin(a), oy - r * Math.cos(a), colour, prio);
}
};
// Wedge edges and rings every 50 cm.
for (const a of [-half, half]) c.line(ox, oy, ox + maxCm * scale * Math.sin(a), oy - maxCm * scale * Math.cos(a), GREY, 0);
for (let r = 50; r <= maxCm; r += 50) arc(r, GREY, 0, 0.35);
// Trail: older distances fade from teal to near-background.
const trail = known.slice(-8, -1);
trail.forEach((d, k) => {
const f = (k + 1) / (trail.length + 1);
arc(d, rgb(20 + 20 * f, 70 + 90 * f, 80 + 80 * f), 1);
});
// Ping (animation): a ring leaving the sensor and reaching the measured range.
if (ping != null && latest != null) {
const f = Math.max(0, Math.min(1, ping));
const fade = 0.35 + 0.65 * f;
arc(latest * f, rgb(40 * fade, 160 * fade, 200 * fade), 2, 1);
}
const hit = ping != null && ping > 0.92;
if (latest != null) arc(latest, hit ? rgb(220, 255, 235) : present ? rgb(80, 255, 150) : rgb(230, 235, 240), 3, 1.6);
// Sensor marker.
c.plot(ox, oy, rgb(255, 220, 90), 4);
c.plot(ox - 1, oy, rgb(255, 220, 90), 4);
const g = c.toGrid();
// Ring labels along the right edge of the wedge.
for (let r = 100; r <= maxCm; r += 100) {
const px = ox + r * scale * Math.sin(half);
const py = oy - r * scale * Math.cos(half);
g.text(Math.min(g.columns - 3, Math.round(px / 2) + 1), Math.max(0, Math.round(py / 4)), `${r / 100}m`, DIM);
}
return { grid: g, maxCm };
}
/**
* Braille line chart of a series (oldest first, nulls skipped). The scale fits
* the data (at least `minSpan` wide), so a rhythm stays visible; a plausible
* band is shaded where it overlaps the scale. Returns { grid, lo, hi }.
*/
export function lineChart(series, columns, rows, { band = null, colour = rgb(0, 200, 220), minSpan = 2 } = {}) {
const c = new Braille(columns, rows);
const pts = (series || []).map((v, i) => [i, v]).filter(([, v]) => Number.isFinite(v)).slice(-c.width);
const vals = pts.map(([, v]) => v);
let lo = vals.length ? Math.min(...vals) : (band ? band[0] : 0);
let hi = vals.length ? Math.max(...vals) : (band ? band[1] : 1);
const pad = Math.max(0, minSpan - (hi - lo)) / 2 + (hi - lo) * 0.1;
lo -= pad;
hi += pad;
if (hi - lo < 1e-9) { lo -= 1; hi += 1; }
const yOf = (v) => (c.height - 1) - ((v - lo) / (hi - lo)) * (c.height - 1);
if (band && band[1] >= lo && band[0] <= hi) {
const top = Math.max(0, Math.floor(yOf(Math.min(band[1], hi)) / 4));
const bottom = Math.min(c.rows - 1, Math.floor(yOf(Math.max(band[0], lo)) / 4));
for (let y = top; y <= bottom; y++) for (let x = 0; x < c.columns; x++) c.shade(x, y, rgb(14, 34, 30));
}
const n = pts.length;
const xOf = (k) => (n <= 1 ? c.width - 1 : (k * (c.width - 1)) / (n - 1));
for (let k = 0; k < n; k++) {
const y = yOf(pts[k][1]);
if (k > 0) c.line(xOf(k - 1), yOf(pts[k - 1][1]), xOf(k), y, colour, 1);
else c.plot(xOf(k), y, colour, 1);
}
if (n) c.plot(xOf(n - 1), yOf(pts[n - 1][1]), rgb(255, 255, 255), 2);
return { grid: c.toGrid(), lo, hi };
}
+100 -248
View File
@@ -1,269 +1,64 @@
// SPDX-License-Identifier: MIT
// ruview-live — a Claude Code mod (function hooks) shipped inside @ruvnet/ruview.
//
// `/ruview` opens a pane beside the transcript with the CSI nodes streaming to
// this machine and, optionally, an ESPHome radar kit; it refreshes on a timer
// and keeps a one-line status. Every reading comes from the @ruvnet/ruview CLI
// this mod ships beside (`--json`), so the pane shows exactly what the tested
// tools return. Read-only: it never flashes, provisions or writes to a device.
// `/ruview` opens a pane beside the transcript with three views: an overview of
// the CSI nodes streaming to this machine and an optional ESPHome radar kit; a
// live CSI amplitude waterfall; and a radar fan with vitals charts (ADR-377,
// ADR-378). Every reading comes from the @ruvnet/ruview CLI this mod ships
// beside (`--json`), so the pane shows exactly what the tested tools return.
// Read-only: it never flashes, provisions or writes to a device.
import { commandsOf, cliPathOf, historyWith, MODES, modelOf, resultOf, settingsOf, spectrumWith, statusOf } from './model.mjs';
import { drain } from './anim.mjs';
import { ANIMATED, picturesOf, sizesOf, viewOf } from './views.mjs';
export * from './anim.mjs';
export * from './model.mjs';
export * from './raster.mjs';
export { ANIMATED, picturesOf, sizesOf, viewOf } from './views.mjs';
export const PANE_ID = 'ruview-live';
export const COMMAND = 'ruview';
const HOST_RE = /^[A-Za-z0-9][A-Za-z0-9.-]{0,252}$/;
const HISTORY = 24;
const TICK_MS = 5000;
/** Animation frame period: ~12 fps, well inside blit's 60 shown a second. */
export const FRAME_MS = 80;
/**
* The harness CLI beside this mod: the plugin root is <pkg>/mod, so the CLI is
* <pkg>/bin/cli.js. Mods import nothing but their own files, so the engine's
* `$.plugin.root` locates it.
*/
export function cliPathOf(pluginRoot) {
return `${String(pluginRoot).replace(/[\\/]+$/, '')}/../bin/cli.js`;
}
/** Normalise plugin options (userConfig) into bounded settings. */
export function settingsOf(options = {}) {
const num = (v, d, lo, hi) => {
const n = Number(v);
return Number.isFinite(n) ? Math.min(Math.max(Math.round(n), lo), hi) : d;
};
const host = typeof options.radarHost === 'string' ? options.radarHost.trim() : '';
return {
udpPort: num(options.udpPort, 5005, 1024, 65535),
radarHost: HOST_RE.test(host) ? host : '',
refreshMs: num(options.refreshSeconds, 15, 5, 3600) * 1000,
captureSeconds: num(options.captureSeconds, 3, 1, 10),
};
}
/** argv for one capture and (optionally) one radar read. */
export function commandsOf(settings) {
const s = String(settings.captureSeconds);
return {
capture: ['esp32', '--seconds', s, '--udp-port', String(settings.udpPort), '--json'],
radar: settings.radarHost ? ['mmwave', '--source', 'esphome', '--host', settings.radarHost, '--seconds', s, '--json'] : null,
};
}
/** Parse a CLI run into its JSON result, or an honest failure. */
export function resultOf(run) {
if (!run) return null;
try {
const parsed = JSON.parse(run.stdout);
if (parsed && typeof parsed === 'object') return parsed;
} catch { /* fall through */ }
return { ok: false, reason: 'cli_error', detail: String(run.stderr || run.stdout || `exit ${run.exitCode}`).trim().slice(0, 300) };
}
const fixed = (v, d = 1) => (typeof v === 'number' && Number.isFinite(v) ? v.toFixed(d) : '—');
const pct = (v) => (typeof v === 'number' ? `${(v * 100).toFixed(1)}%` : '—');
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
/** Resting ranges outside which a device-reported vital is flagged, not trusted. */
export const RANGES = Object.freeze({ heart: [40, 180], breathing: [4, 40] });
/** Why a device-reported vital looks implausible, or null. */
export function plausibilityOf(kind, value) {
if (value == null || !RANGES[kind]) return null;
const [lo, hi] = RANGES[kind];
return value < lo || value > hi ? `outside ${lo}–${hi} bpm` : null;
}
const SPARK = '▁▂▃▄▅▆▇█';
/** Unicode sparkline of the last `width` finite values ('' below two points). */
export function sparkline(values, width = 16) {
const v = (values || []).filter((x) => typeof x === 'number' && Number.isFinite(x)).slice(-width);
if (v.length < 2) return '';
const lo = Math.min(...v);
const hi = Math.max(...v);
return v.map((x) => SPARK[hi === lo ? 3 : Math.round(((x - lo) / (hi - lo)) * 7)]).join('');
}
/** "12s ago" / "3m ago" from a millisecond age, or '' when unknown. */
export function ageOf(ms) {
if (!Number.isFinite(ms) || ms < 0) return '';
const s = Math.round(ms / 1000);
return s < 60 ? `${s}s ago` : `${Math.round(s / 60)}m ago`;
}
/** The pane's model: plain data, drawn by viewOf and summarised by statusOf. */
export function modelOf(capture, radar, at) {
const alerts = [];
// "No packets" is shown inside the nodes card; only real faults become alerts.
if (capture && capture.ok === false && capture.reason !== 'no_packets') {
alerts.push({ level: 'bad', text: `nodes: ${String(capture.reason).replace(/_/g, ' ')}${capture.remedy ? ` — ${capture.remedy}` : ''}` });
}
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)` });
if (radar && radar.ok === false) alerts.push({ level: 'bad', text: `radar: ${String(radar.reason).replace(/_/g, ' ')}${radar.detail ? ` — ${radar.detail}` : ''}` });
const nodes = (capture?.nodes || []).map((n) => ({
key: `${n.source}:${n.nodeId}`,
label: `${n.source === 'realtek' ? 'realtek' : 'esp32'} ${n.nodeId}`,
rateHz: num(n.csiRateHz),
rate: `${fixed(n.csiRateHz)} Hz`,
loss: pct(n.csiLossFraction),
rssi: `${fixed(n.rssiMean)} dBm`,
shape: n.csi?.shape || '—',
lossy: typeof n.csiLossFraction === 'number' && n.csiLossFraction > 0.05,
synthetic: Boolean(n.csi?.synthetic),
}));
const radarRow = radar && radar.ok !== false ? {
name: radar.device?.name || radar.host || 'radar',
present: radar.presentNow ?? (radar.presentFraction == null ? null : radar.presentFraction >= 0.5),
targets: num(radar.targetsMax),
distanceCm: num(radar.distanceCmMean),
heartBpm: num(radar.heartBpmMean),
breathingBpm: num(radar.breathingBpmMean),
distance: radar.distanceCmMean == null ? '—' : `${fixed(radar.distanceCmMean)} cm`,
heart: radar.heartBpmMean == null ? '—' : `${fixed(radar.heartBpmMean)} bpm`,
breathing: radar.breathingBpmMean == null ? '—' : `${fixed(radar.breathingBpmMean)} bpm`,
} : null;
return {
at, packets: capture?.packets ?? 0, decoded: capture?.decodedPackets ?? 0,
noPackets: capture?.reason === 'no_packets', nodes, radar: radarRow, alerts,
};
}
/** Append one model to a bounded history of trend values. */
export function historyWith(history, model, max = HISTORY) {
const h = history || { heart: [], breathing: [], distance: [], rates: {} };
const cap = (arr, v) => [...arr, v].slice(-max);
const rates = { ...h.rates };
for (const n of model?.nodes || []) rates[n.key] = cap(rates[n.key] || [], n.rateHz);
return {
heart: cap(h.heart, model?.radar?.heartBpm ?? null),
breathing: cap(h.breathing, model?.radar?.breathingBpm ?? null),
distance: cap(h.distance, model?.radar?.distanceCm ?? null),
rates,
};
}
/** One line for the status bar. */
export function statusOf(model) {
if (!model) return 'RuView · starting';
const parts = [`RuView · ${model.nodes.length} node${model.nodes.length === 1 ? '' : 's'}`];
if (model.radar) parts.push(`radar ${model.radar.present == null ? '?' : model.radar.present ? 'present' : 'clear'}`);
if (model.alerts.length) parts.push(`${model.alerts.length} alert${model.alerts.length === 1 ? '' : 's'}`);
return parts.join(' · ');
/** Spectrum request for the waterfall: one bin per picture column (at most 128). */
export function spectrumOf(columns, rows) {
const { width, height } = sizesOf(columns, rows);
return { bins: Math.max(8, Math.min(128, width)), frames: Math.max(8, Math.min(256, height * 2)) };
}
/**
* Draw the pane with the surface's own elements ({ Box, Text, Button }).
* opts: { refreshMs, busy, onRefresh, onClose, columns, now, history, udpPort, radarConfigured }.
*/
export function viewOf(ui, model, opts) {
const { Box, Text, Button } = ui;
const { refreshMs, busy, onRefresh, onClose } = opts;
const columns = opts.columns ?? 80;
const history = opts.history || historyWith(null, null, 0);
const t = (children, props = {}) => Text({ wrap: 'truncate-end', ...props, children });
const row = (children, props = {}) => Box({ flexDirection: 'row', gap: 1, ...props, children: children.filter(Boolean) });
const every = `every ${Math.round(refreshMs / 1000)}s`;
// Header: freshness badge, update time and age.
const finiteAt = model && Number.isFinite(model.at);
const age = finiteAt && Number.isFinite(opts.now) ? opts.now - model.at : NaN;
const live = Number.isFinite(age) && age <= refreshMs * 2 + 5000;
const when = finiteAt ? new Date(model.at).toLocaleTimeString() : '—';
const badge = !model ? t('○ STARTING', { dimColor: true, bold: true })
: live ? t('● LIVE', { color: 'green', bold: true }) : t('○ STALE', { color: 'yellow', bold: true });
const header = row([
t('RuView', { color: 'cyan', bold: true }),
badge,
t(model ? `updated ${when}${ageOf(age) ? ` (${ageOf(age)})` : ''} · ${every}` : `waiting for the first capture… · ${every}`, { dimColor: true }),
busy ? t('⟳ refreshing', { color: 'cyan' }) : null,
], { justifyContent: 'flex-start' });
const alerts = (model?.alerts || []).map((a) => Text({ color: a.level === 'bad' ? 'red' : 'yellow', wrap: 'wrap', children: `! ${a.text}` }));
// Radar card.
const vital = (label, value, raw, kind, series, unitWidth) => {
const warn = kind ? plausibilityOf(kind, raw) : null;
return row([
t(label.padEnd(10), { dimColor: true }),
t(value.padStart(unitWidth), { bold: raw != null && !warn, color: warn ? 'yellow' : undefined }),
t(sparkline(series), { color: 'cyan' }),
warn ? t(`⚠ ${warn}`, { color: 'yellow' }) : null,
]);
};
let radarCard = null;
if (model?.radar) {
const r = model.radar;
const presence = r.present == null ? t('? presence unknown', { dimColor: true })
: r.present ? t(`● PRESENCE DETECTED${r.targets ? ` · ${r.targets} target${r.targets === 1 ? '' : 's'}` : ''}`, { color: 'green', bold: true })
: t('○ no presence', { dimColor: true });
radarCard = card('60 GHz RADAR', r.name, r.present ? 'green' : 'gray', [
presence,
vital('distance', r.distance, r.distanceCm, null, history.distance, 9),
vital('heart', r.heart, r.heartBpm, 'heart', history.heart, 9),
vital('breathing', r.breathing, r.breathingBpm, 'breathing', history.breathing, 9),
t('device-reported values, not validated against a reference', { dimColor: true, italic: true }),
]);
} else if (model && !opts.radarConfigured) {
radarCard = card('60 GHz RADAR', 'not configured', 'gray', [
t('Set radarHost to an ESPHome radar kit:', { dimColor: true }),
t('claude plugin configure ruview-live', { color: 'cyan' }),
]);
}
// Nodes card.
let nodesCard = null;
if (model) {
const lines = model.nodes.length
? model.nodes.map((n) => row([
t(n.label.padEnd(10), { bold: true }),
t(n.rate.padStart(9)),
t(sparkline(history.rates[n.key]), { color: 'cyan' }),
t(`loss ${n.loss}`, n.lossy ? { color: 'yellow' } : { dimColor: true }),
t(n.rssi, { dimColor: true }),
t(n.shape, { dimColor: true }),
n.synthetic ? t('SYNTHETIC', { color: 'yellow', bold: true }) : null,
]))
: [
t('none streaming to this machine', { dimColor: true }),
model.noPackets ? t(`check node target IP/port · firewall UDP ${opts.udpPort ?? 5005}`, { dimColor: true }) : null,
].filter(Boolean);
nodesCard = card('CSI NODES', `UDP ${opts.udpPort ?? 5005} · ${model.decoded}/${model.packets} decoded`, model.nodes.length ? 'cyan' : 'gray', lines);
}
function card(title, subtitle, borderColor, children) {
return Box({
flexDirection: 'column', borderStyle: 'round', borderColor, paddingX: 1, flexGrow: 1,
children: [row([t(title, { bold: true }), t(subtitle, { dimColor: true })]), ...children],
});
}
const wide = columns >= 100;
const cards = [radarCard, nodesCard].filter(Boolean);
const body = cards.length ? Box({ flexDirection: wide ? 'row' : 'column', gap: 1, children: cards }) : null;
const footer = row([
Button({ key: 'refresh', label: busy ? 'Refreshing…' : 'Refresh (r)', hotkey: 'r', onPress: onRefresh }),
Button({ key: 'close', label: 'Close (c)', hotkey: 'c', onPress: onClose }),
], { gap: 2 });
return Box({ flexDirection: 'column', gap: 1, paddingX: 1, children: [header, ...alerts, body, footer].filter(Boolean) });
}
/**
* The mod entry: `/ruview [off|refresh]`, the pane, the timer, the status.
* The mod entry: `/ruview [off|refresh|overview|waterfall|radar]`, the pane,
* the timers, the status.
* @param on the engine's registrar
* @param options this plugin's userConfig values
*/
export function register(on, options = {}) {
const settings = settingsOf(options);
const commands = commandsOf(settings);
let host = null;
let isOpen = false;
let busy = false;
let model = null;
let history = null;
let spectrum = [];
let mode = 'overview';
let nodeIndex = 0;
let size = { columns: 100, rows: 30 };
let stopTimer = null;
let stopTick = null;
let stopAnim = null;
let animating = false;
let lastFrame = null;
let lag = {};
let cliPath = null;
const intervalOf = () => (mode === 'overview' ? settings.refreshMs : settings.liveRefreshMs);
const stop = () => {
if (stopTimer) { stopTimer(); stopTimer = null; }
if (stopTick) { stopTick(); stopTick = null; }
if (stopAnim) { stopAnim(); stopAnim = null; }
};
const cancelOf = (t) => (typeof t === 'function' ? t : () => t?.cancel?.());
@@ -281,10 +76,20 @@ export function register(on, options = {}) {
busy = true;
host.invalidate();
try {
const commands = commandsOf(settings, {
live: mode !== 'overview',
spectrum: mode === 'waterfall' ? spectrumOf(size.columns, size.rows) : null,
});
const [capture, radar] = await Promise.all([runCli(commands.capture), runCli(commands.radar)]);
// $.clock.now() resolves a promise of epoch milliseconds.
model = modelOf(resultOf(capture), resultOf(radar), await host.now());
history = historyWith(history, model);
spectrum = spectrumWith(spectrum, model.spectrum);
// New frames join the replay queue: shown at their arrival rate, not at once.
for (const s of model.spectrum) {
const held = spectrum.find((x) => x.key === s.key)?.frames.length ?? 0;
lag[s.key] = Math.min(held, (lag[s.key] ?? 0) + s.frames.length);
}
host.status(statusOf(model));
} finally {
busy = false;
@@ -292,16 +97,52 @@ export function register(on, options = {}) {
}
}
/** Start the refresh timer (and the age tick) and fetch now, unless already polling. */
/** Options shared by the full drawing and each animation frame. */
const drawOpts = (t) => ({ mode, nodeIndex, lag, history, t, columns: size.columns, rows: size.rows });
const shownModel = () => (model ? { ...model, spectrum } : null);
/** One animation frame: repaint each mounted animated Raster in place. */
async function animate() {
if (!host || animating) return;
animating = true;
try {
// Frame time advances by the frame period: no clock round trip per frame.
const t = (lastFrame ?? 0) + FRAME_MS;
const dt = FRAME_MS;
lastFrame = t;
for (const s of spectrum) lag[s.key] = drain(lag[s.key], s.rateHz, dt);
const pics = picturesOf(shownModel(), drawOpts(t));
// Fire and forget: a blit resolves only once a frame is painted, and the
// surface folds blits between frames anyway, so nothing waits on one.
for (const key of ANIMATED) {
if (!pics[key]) continue;
const { columns, rows, cells } = pics[key].grid.toRaster(key);
Promise.resolve(host.blit({ requestId: PANE_ID, key, cells, columns, rows })).catch(() => undefined);
}
} finally {
animating = false;
}
}
/** Start the refresh timer, the age tick and the animation, and fetch now, unless already polling. */
function startPolling() {
isOpen = true;
if (stopTimer) return;
stopTimer = cancelOf(host.every(settings.refreshMs, () => { void refresh(); }));
stopTimer = cancelOf(host.every(intervalOf(), () => { void refresh(); }));
stopTick = cancelOf(host.every(TICK_MS, () => host.invalidate()));
stopAnim = cancelOf(host.every(FRAME_MS, () => { void animate(); }));
void refresh();
}
async function open() {
/** Switch view; the live views poll faster, so the timer restarts. */
function setMode(next) {
if (!MODES.includes(next) || next === mode) return;
mode = next;
if (isOpen && host) { stop(); startPolling(); } else host?.invalidate();
}
async function open(initialMode) {
if (MODES.includes(initialMode)) mode = initialMode;
await host.open({ id: PANE_ID, title: 'RuView', closeOnEscape: true });
stop();
startPolling();
@@ -323,19 +164,24 @@ export function register(on, options = {}) {
close: (pane) => $.ui.close(pane),
status: (text) => $.ui.status(text),
invalidate: () => $.ui.invalidate('ui.render'),
blit: (args) => $.ui.blit(args),
};
await $.command.register({ name: COMMAND, description: 'RuView live sensing pane: CSI nodes and radar, refreshed', argumentHint: '[off|refresh]' }).catch(() => undefined);
await $.command.register({ name: COMMAND, description: 'RuView live sensing pane: CSI nodes, CSI waterfall and radar', argumentHint: '[waterfall|radar|refresh|off]' }).catch(() => undefined);
return next(e);
});
on('command.run', { command: COMMAND }, async ($, e, next) => {
if (!host) return next(e);
const arg = String(e.args || '').trim();
const arg = String(e.args || '').trim().toLowerCase();
if (arg === 'off') { await close(); host.status(undefined); return { text: 'RuView pane closed.' }; }
if (arg === 'refresh') { await refresh(); return { text: statusOf(model) }; }
if (MODES.includes(arg)) {
if (isOpen) setMode(arg); else await open(arg);
return { text: `RuView pane: ${arg} view.` };
}
if (isOpen) { await close(); return { text: 'RuView pane closed.' }; }
await open();
return { text: `RuView pane open: nodes on UDP ${settings.udpPort}${settings.radarHost ? `, radar ${settings.radarHost}` : ''}; refreshing every ${Math.round(settings.refreshMs / 1000)}s.` };
return { text: `RuView pane open: nodes on UDP ${settings.udpPort}${settings.radarHost ? `, radar ${settings.radarHost}` : ''}; keys 1 overview · 2 CSI waterfall · 3 radar.` };
});
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
@@ -344,13 +190,19 @@ export function register(on, options = {}) {
// variables while the engine keeps the pane open: drawing it means it is
// open, so resume polling instead of waiting for the first capture forever.
if (host && !stopTimer) startPolling();
size = {
columns: e.props?.bodyColumns ?? e.viewport?.columns ?? 100,
rows: e.props?.scroll?.bodyRows ?? e.viewport?.rows ?? 30,
};
const ui = await $.ui.resolve(e);
const now = await $.clock.now();
return viewOf(ui, model, {
refreshMs: settings.refreshMs, busy, history, now,
columns: e.props?.bodyColumns ?? e.viewport?.columns ?? 80,
return viewOf(ui, shownModel(), {
...drawOpts(now), refreshMs: settings.refreshMs, liveRefreshMs: settings.liveRefreshMs, busy, now,
udpPort: settings.udpPort, radarConfigured: Boolean(settings.radarHost),
onRefresh: () => { void refresh(); }, onClose: () => { void close(); },
onRefresh: () => { void refresh(); },
onClose: () => { void close(); },
onMode: (m) => setMode(m),
onNextNode: () => { nodeIndex += 1; host?.invalidate(); },
});
});
+227
View File
@@ -0,0 +1,227 @@
// SPDX-License-Identifier: MIT
// ruview-live drawing (ADR-377, ADR-378): the overview cards and the two
// showcase views (CSI waterfall, radar fan + vitals charts), built from the
// surface's own elements. Pictures are `Raster`s (terminal); other surfaces
// get an honest note instead. `picturesOf` builds every animated picture, so
// the first paint and each `$.ui.blit` frame share one size and one source.
import { pingOf, pulse, shimmer, shownFrames } from './anim.mjs';
import { ageOf, plausibilityOf, RANGES, sparkline } from './model.mjs';
import { colourbar, lineChart, radarFan, rgb, waterfall } from './raster.mjs';
const TABS = [['overview', '1', 'Overview'], ['waterfall', '2', 'CSI waterfall'], ['radar', '3', 'Radar']];
/** Keys of the pictures the hooks module repaints with `$.ui.blit`. */
export const ANIMATED = Object.freeze(['shimmer', 'waterfall', 'fan', 'pulse']);
/** Picture sizes that fit the pane body. */
export function sizesOf(columns = 80, rows = 30) {
const width = Math.max(24, Math.min(128, columns - 6));
const height = Math.max(6, Math.min(26, rows - 12));
return { width, height };
}
const wrapIndex = (i, n) => ((i % n) + n) % n;
const radarLayout = (columns, rows) => {
const { width, height } = sizesOf(columns, rows);
const wide = (columns ?? 80) >= 110;
const fanW = wide ? Math.floor(width * 0.55) : width;
// A 120° wedge is about width / (4·sin 60°) rows tall.
const fanH = Math.max(6, Math.min(height, Math.round(fanW / 3.4) + 1));
return { width, wide, fanW, fanH, chartW: wide ? Math.max(20, width - fanW - 6) : width };
};
/**
* Every animated picture for this mode at time `t` (ms): `{ shimmer, waterfall,
* fan, pulse }`, each `{ grid, ... }` or null. opts: { mode, columns, rows, t,
* nodeIndex, lag (per node key), history }.
*/
export function picturesOf(model, opts) {
const mode = opts.mode || 'overview';
const t = Number.isFinite(opts.t) ? opts.t : 0;
const columns = opts.columns ?? 80;
const out = { shimmer: { grid: shimmer(Math.max(8, Math.min(160, columns - 4)), t) }, waterfall: null, fan: null, pulse: null };
if (!model) return out;
if (mode === 'waterfall' && model.spectrum?.length) {
const nodes = model.spectrum;
const node = nodes[wrapIndex(opts.nodeIndex ?? 0, nodes.length)];
const { width, height } = sizesOf(columns, opts.rows);
out.waterfall = { ...waterfall(shownFrames(node.frames, opts.lag?.[node.key]), width, height), node, nodes };
}
if (mode === 'radar' && model.radar) {
const history = opts.history || { distance: [] };
const { fanW, fanH, chartW } = radarLayout(columns, opts.rows);
out.fan = radarFan(fanW, fanH, { distances: history.distance, present: model.radar.present, ping: pingOf(t) });
out.pulse = { grid: pulse(Math.min(chartW, 40), t, { heartBpm: model.radar.heartBpm, breathingBpm: model.radar.breathingBpm }) };
}
return out;
}
/**
* Draw the pane. opts: { mode, nodeIndex, refreshMs, liveRefreshMs, busy, now, t, lag, columns, rows,
* history, udpPort, radarConfigured, onRefresh, onClose, onMode(mode), onNextNode }.
*/
export function viewOf(ui, model, opts) {
const { Box, Text, Button } = ui;
const mode = opts.mode || 'overview';
const live = mode !== 'overview';
const interval = live ? (opts.liveRefreshMs ?? opts.refreshMs) : opts.refreshMs;
const t = (children, props = {}) => Text({ wrap: 'truncate-end', ...props, children });
const row = (children, props = {}) => Box({ flexDirection: 'row', gap: 1, ...props, children: children.filter(Boolean) });
const pics = ui.Raster ? picturesOf(model, opts) : {};
// Header: freshness badge and age, then the signal rule.
const finiteAt = model && Number.isFinite(model.at);
const age = finiteAt && Number.isFinite(opts.now) ? opts.now - model.at : NaN;
const fresh = Number.isFinite(age) && age <= interval * 2 + 5000;
const when = finiteAt ? new Date(model.at).toLocaleTimeString() : '—';
const badge = !model ? t('○ STARTING', { dimColor: true, bold: true })
: fresh ? t('● LIVE', { color: 'green', bold: true }) : t('○ STALE', { color: 'yellow', bold: true });
const header = row([
t('RuView', { color: 'cyan', bold: true }),
badge,
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 }),
opts.busy ? t('⟳ refreshing', { color: 'cyan' }) : null,
]);
const rule = pics.shimmer ? ui.Raster(pics.shimmer.grid.toRaster('shimmer')) : null;
const tabs = opts.onMode ? row(TABS.map(([m, key, label]) => Button({
key: `tab-${m}`, hotkey: key, label: `${m === mode ? '▸ ' : ''}${label} (${key})`, onPress: () => opts.onMode(m),
})), { gap: 2 }) : null;
const alerts = (model?.alerts || []).map((a) => Text({ color: a.level === 'bad' ? 'red' : 'yellow', wrap: 'wrap', children: `! ${a.text}` }));
let body;
if (mode === 'waterfall') body = waterfallView(ui, model, opts, pics, t, row);
else if (mode === 'radar') body = radarView(ui, model, opts, pics, t, row);
else body = overview(ui, model, opts, t, row);
const footer = row([
Button({ key: 'refresh', label: opts.busy ? 'Refreshing…' : 'Refresh (r)', hotkey: 'r', onPress: opts.onRefresh }),
mode === 'waterfall' && opts.onNextNode && (model?.spectrum?.length ?? 0) > 1 ? Button({ key: 'next-node', label: 'Next node (n)', hotkey: 'n', onPress: opts.onNextNode }) : null,
Button({ key: 'close', label: 'Close (c)', hotkey: 'c', onPress: opts.onClose }),
], { gap: 2 });
return Box({ flexDirection: 'column', gap: 1, paddingX: 1, children: [Box({ flexDirection: 'column', children: [header, rule].filter(Boolean) }), tabs, ...alerts, body, footer].filter(Boolean) });
}
function card(ui, title, subtitle, borderColor, children, t, row) {
return ui.Box({
flexDirection: 'column', borderStyle: 'round', borderColor, paddingX: 1, flexGrow: 1,
children: [row([t(title, { bold: true }), subtitle ? t(subtitle, { dimColor: true }) : null]), ...children.filter(Boolean)],
});
}
function needsTerminal(ui, t) {
return ui.Raster ? null : t('This view draws with terminal cells; open it in the terminal. The overview works everywhere.', { dimColor: true });
}
function overview(ui, model, opts, t, row) {
if (!model) return null;
const history = opts.history || { heart: [], breathing: [], distance: [], rates: {} };
const vital = (label, value, raw, kind, series) => {
const warn = kind ? plausibilityOf(kind, raw) : null;
return row([
t(label.padEnd(10), { dimColor: true }),
t(value.padStart(9), { bold: raw != null && !warn, color: warn ? 'yellow' : undefined }),
t(sparkline(series), { color: 'cyan' }),
warn ? t(`⚠ ${warn}`, { color: 'yellow' }) : null,
]);
};
let radarCard = null;
if (model.radar) {
const r = model.radar;
const presence = r.present == null ? t('? presence unknown', { dimColor: true })
: r.present ? t(`● PRESENCE DETECTED${r.targets ? ` · ${r.targets} target${r.targets === 1 ? '' : 's'}` : ''}`, { color: 'green', bold: true })
: t('○ no presence', { dimColor: true });
radarCard = card(ui, '60 GHz RADAR', r.name, r.present ? 'green' : 'gray', [
presence,
vital('distance', r.distance, r.distanceCm, null, history.distance),
vital('heart', r.heart, r.heartBpm, 'heart', history.heart),
vital('breathing', r.breathing, r.breathingBpm, 'breathing', history.breathing),
t('device-reported values, not validated against a reference', { dimColor: true, italic: true }),
], t, row);
} else if (!opts.radarConfigured) {
radarCard = card(ui, '60 GHz RADAR', 'not configured', 'gray', [
t('Set radarHost to an ESPHome radar kit:', { dimColor: true }),
t('claude plugin configure ruview-live', { color: 'cyan' }),
], t, row);
}
const lines = model.nodes.length
? model.nodes.map((n) => row([
t(n.label.padEnd(10), { bold: true }),
t(n.rate.padStart(9)),
t(sparkline(history.rates[n.key]), { color: 'cyan' }),
t(`loss ${n.loss}`, n.lossy ? { color: 'yellow' } : { dimColor: true }),
t(n.rssi, { dimColor: true }),
t(n.shape, { dimColor: true }),
n.synthetic ? t('SYNTHETIC', { color: 'yellow', bold: true }) : null,
]))
: [
t('none streaming to this machine', { dimColor: true }),
model.noPackets ? t(`check node target IP/port · firewall UDP ${opts.udpPort ?? 5005}`, { dimColor: true }) : null,
];
const nodesCard = card(ui, 'CSI NODES', `UDP ${opts.udpPort ?? 5005} · ${model.decoded}/${model.packets} decoded`, model.nodes.length ? 'cyan' : 'gray', [
...lines,
model.nodes.length ? t('press 2 for the live CSI waterfall', { dimColor: true, italic: true }) : null,
], t, row);
const cards = [radarCard, nodesCard].filter(Boolean);
return ui.Box({ flexDirection: (opts.columns ?? 80) >= 100 ? 'row' : 'column', gap: 1, children: cards });
}
function waterfallView(ui, model, opts, pics, t, row) {
const note = needsTerminal(ui, t);
if (note) return note;
if (!model) return t('waiting for the first capture…', { dimColor: true });
if (!pics.waterfall) {
return card(ui, 'CSI WATERFALL', 'no CSI frames', 'gray', [
t('No CSI node is streaming to this machine, so there is nothing to draw.', { dimColor: true }),
t(`Point a node at this host's UDP ${opts.udpPort ?? 5005} (provision.py --target-ip), then this view fills by itself.`, { dimColor: true }),
], t, row);
}
const { grid, lo, hi, node: n, nodes } = pics.waterfall;
const legend = colourbar(Math.min(32, grid.columns - 20));
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', [
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' }),
ui.Raster(grid.toRaster('waterfall')),
row([t('subcarrier →', { dimColor: true }), t('newest at the bottom · replayed at the arrival rate', { dimColor: true })], { justifyContent: 'space-between' }),
row([t(lo.toFixed(0), { dimColor: true }), ui.Raster(legend.toRaster('legend')), t(`${hi.toFixed(0)} amplitude (5th–95th pct)`, { dimColor: true })]),
], t, row);
}
function radarView(ui, model, opts, pics, t, row) {
const note = needsTerminal(ui, t);
if (note) return note;
if (!model) return t('waiting for the first capture…', { dimColor: true });
if (!opts.radarConfigured) {
return card(ui, 'RADAR', 'not configured', 'gray', [t('Set radarHost: claude plugin configure ruview-live', { color: 'cyan' })], t, row);
}
const r = model.radar;
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);
const history = opts.history || { heart: [], breathing: [], distance: [] };
const { wide, chartW } = radarLayout(opts.columns, opts.rows);
const presence = r.present ? t(`● PRESENCE DETECTED · ${r.distance}${r.targets ? ` · ${r.targets} target${r.targets === 1 ? '' : 's'}` : ''}`, { color: 'green', bold: true })
: t('○ no presence', { dimColor: true });
const fan = card(ui, '60 GHz RADAR', `${r.name} · range ${pics.fan.maxCm / 100} m`, r.present ? 'green' : 'gray', [
presence,
ui.Raster(pics.fan.grid.toRaster('fan')),
t('range only: this kit reports distance, not bearing (arc = every bearing at that range)', { dimColor: true, italic: true }),
], t, row);
const chart = (label, series, kind, unit, colour, minSpan) => {
const { grid: g, lo, hi } = lineChart(series, chartW, 3, { band: kind ? RANGES[kind] : null, colour, minSpan });
const last = [...series].reverse().find((v) => Number.isFinite(v));
const warn = kind ? plausibilityOf(kind, last ?? null) : null;
return [
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 })]),
ui.Raster(g.toRaster(`chart-${label}`)),
];
};
const charts = card(ui, 'VITALS', 'device-reported, not validated', 'gray', [
pics.pulse ? ui.Raster(pics.pulse.grid.toRaster('pulse')) : null,
pics.pulse ? t('♥ beats and the gauge breathes at the reported rates (a metronome, not a waveform)', { dimColor: true, italic: true }) : null,
...chart('heart', history.heart, 'heart', 'bpm', rgb(255, 110, 130), 10),
...chart('breathing', history.breathing, 'breathing', 'bpm', rgb(110, 200, 255), 4),
...chart('distance', history.distance, null, 'cm', rgb(120, 255, 170), 20),
t('shaded = inside the plausible resting range', { dimColor: true, italic: true }),
], t, row);
return ui.Box({ flexDirection: wide ? 'row' : 'column', gap: 1, children: [fan, charts] });
}
+53 -4
View File
@@ -59,7 +59,7 @@ describe('register', () => {
const beforeTicks = runs.length
for (let i = 0; i < 3; i++) {
clock.advance(15_000)
await clock.advance(15_000)
await settle()
}
expect(runs.length).toBeGreaterThan(beforeTicks)
@@ -69,7 +69,7 @@ describe('register', () => {
expect(closed).toEqual(['ruview-live'])
const afterClose = runs.length
for (let i = 0; i < 4; i++) {
clock.advance(15_000)
await clock.advance(15_000)
await settle()
}
expect(runs.length).toBe(afterClose)
@@ -89,6 +89,7 @@ describe('register', () => {
})
on('ui.status', () => ({ value: undefined }))
on('ui.invalidate', () => ({ value: undefined }))
on('ui.blit', () => ({ value: {} }))
const PANE = {
title: 'RuView',
isFocused: false,
@@ -109,12 +110,60 @@ describe('register', () => {
expect(await again.find({ type: 'Text', text: /NODES/ })).toBeDefined()
expect(await again.find({ type: 'Text', text: /waiting for the first capture/ })).toBeUndefined()
const polled = runs.length
clock.advance(15_000)
for (let i = 0; i < 5; i++) await clock.settle()
// Step time as a live session does (the animation timer runs every 80 ms).
for (let s = 0; s < 16; s++) {
await clock.advance(1_000)
for (let i = 0; i < 5; i++) await clock.settle()
}
expect(runs.length).toBeGreaterThan(polled)
await again.unmount()
})
test('the waterfall tab asks for spectrum frames, draws a Raster, and animates it with blits', async ($, on) => {
const runs: (readonly string[])[] = []
const blits: string[] = []
const clock = mock.clock(on)
const frames = Array.from({ length: 40 }, (_, k) => [k, 2 * k, 3 * k, 4 * k])
on('session.start', ($, e) => ({ cwd: e.cwd }))
on('command.register', ($, e) => ({ value: { command: e.name } }))
on('process.run', ($, e) => {
runs.push(e.argv)
const spectrum = e.argv.includes('--spectrum')
? [{ source: 'esp32', nodeId: 42, subcarriers: 64, bins: 4, rateHz: 20, synthetic: false, frames }]
: undefined
return { value: { exitCode: 0, stdout: JSON.stringify({ ...CAPTURE, spectrum }), stderr: '' } }
})
on('ui.status', () => ({ value: undefined }))
// No ui.invalidate stub here: the mounted drawing follows the mod's invalidations.
on('ui.blit', ($, e) => {
blits.push(e.key)
return { value: {} }
})
const PANE = { title: 'RuView', isFocused: true, bodyColumns: 120, placement: 'dock' as const, scroll: { offset: 0, bodyRows: 34 }, view: {} }
const settle = async () => {
for (let i = 0; i < 5; i++) await clock.settle()
}
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' })
const pane = await $.ui.mount({ plugin: 'ruview-live', surface: 'terminal', component: 'Pane', requestId: 'ruview-live', props: PANE })
await settle()
expect(await pane.find({ key: 'tab-waterfall' })).toBeDefined()
await pane.press({ key: 'tab-waterfall' })
await settle()
expect(runs.at(-1)).toContain('--spectrum')
expect(runs.at(-1)).toContain('--spectrum-bins')
expect(await pane.find({ type: 'Text', text: /MEASURED/ })).toBeDefined()
expect(await pane.find({ type: 'Raster', key: 'waterfall' })).toBeDefined()
for (let i = 0; i < 4; i++) {
await clock.advance(100)
await settle()
}
expect(blits).toContain('waterfall')
expect(blits).toContain('shimmer')
await pane.unmount()
})
test('/ruview refresh with no nodes reports the honest failure in the status', async ($, on) => {
const statuses: (string | undefined)[] = []
mock.clock(on)
+2 -2
View File
@@ -40,8 +40,8 @@ const VERB_TO_TOOL = {
// Verbs whose kebab-case flags map 1:1 onto snake_case schema fields (ADR-369).
const SNAKE_VERBS = new Set(['flash', 'flash-plan', 'train', 'train-plan', 'train-gate', 'devices', 'esp32', 'mmwave', 'lidar']);
const NUMERIC_FLAGS = new Set(['baud', 'boot_log_seconds', 'samples', 'model_score', 'baseline_score', 'n_test', 'seconds', 'udp_port', 'max_packets', 'max_frames', 'node_id', 'analyze_max_frames', 'api_port']);
const BOOLEAN_FLAGS = new Set(['confirm', 'cuda', 'allow_unverified', 'analyze']);
const NUMERIC_FLAGS = new Set(['baud', 'boot_log_seconds', 'samples', 'model_score', 'baseline_score', 'n_test', 'seconds', 'udp_port', 'max_packets', 'max_frames', 'node_id', 'analyze_max_frames', 'api_port', 'spectrum_bins', 'spectrum_frames']);
const BOOLEAN_FLAGS = new Set(['confirm', 'cuda', 'allow_unverified', 'analyze', 'spectrum']);
// Presentation-only flags never reach a tool schema.
const UI_FLAGS = new Set(['json', 'watch', 'interval']);
+68 -3
View File
@@ -305,12 +305,68 @@ class FrameCollector {
}
}
/** Average a frame's per-subcarrier amplitudes into `bins` equal groups. */
export function binAmplitudes(amplitudes, bins) {
const n = amplitudes.length;
const b = Math.max(1, Math.min(bins, n));
const out = new Array(b);
for (let i = 0; i < b; i++) {
const start = Math.floor((i * n) / b);
const end = Math.max(start + 1, Math.floor(((i + 1) * n) / b));
let sum = 0;
for (let k = start; k < end; k++) sum += amplitudes[k];
out[i] = Math.round((sum / (end - start)) * 10) / 10;
}
return out;
}
/**
* The most recent amplitude frames per node, binned, for a waterfall view
* (ADR-378). A ring buffer: unlike the analyzer's collector it keeps the
* newest frames. Each node reports its dominant CSI shape only.
*/
export class SpectrumCollector {
constructor(frames, bins, maxNodes = 8) { this.frames = frames; this.bins = bins; this.maxNodes = maxNodes; this.byKey = new Map(); }
add(pkt, t) {
if (pkt.kind !== 'csi' || !pkt.amplitudes) return;
const key = `${pkt.source}:${pkt.nodeId}|${shapeKey(pkt)}`;
let c = this.byKey.get(key);
if (!c) {
if (this.byKey.size >= this.maxNodes * 4) return;
c = { source: pkt.source, nodeId: pkt.nodeId, subcarriers: pkt.subcarriers, rows: [], count: 0, first: t, last: t, synthetic: false };
this.byKey.set(key, c);
}
c.rows.push(binAmplitudes(pkt.amplitudes, this.bins));
if (c.rows.length > this.frames) c.rows.shift();
c.count += 1;
c.last = t;
c.synthetic ||= Boolean(pkt.synthetic);
}
result() {
const best = new Map();
for (const c of this.byKey.values()) {
const node = `${c.source}:${c.nodeId}`;
if (!best.has(node) || best.get(node).count < c.count) best.set(node, c);
}
return [...best.values()]
.sort((a, b) => (a.source === b.source ? a.nodeId - b.nodeId : a.source.localeCompare(b.source)))
.slice(0, this.maxNodes)
.map((c) => ({
source: c.source, nodeId: c.nodeId, subcarriers: c.subcarriers, bins: c.rows[0]?.length ?? 0,
frames: c.rows, framesSeen: c.count,
rateHz: c.last > c.first ? Number((((c.count - 1) * 1000) / (c.last - c.first)).toFixed(2)) : null,
synthetic: c.synthetic,
}));
}
}
const BIND_HOSTS = new Set(['0.0.0.0', '127.0.0.1', '::', '::1']);
const NODE_PACKETS = (summary) => summary.nodes.reduce((a, n) => a + Object.values(n.packets).reduce((x, y) => x + y, 0), 0);
/**
* Listen for node packets. args: { udp_port=5005, bind='0.0.0.0', seconds=10, max_packets=200000,
* analyze=false, node_id, analyze_max_frames=6000 }. deps.createSocket is injectable for tests;
* analyze=false, node_id, analyze_max_frames=6000, spectrum=false, spectrum_bins=48, spectrum_frames=64 }.
* deps.createSocket is injectable for tests;
* deps.analyze(frames, config) runs the compute kernel when args.analyze is set.
*/
export function captureEsp32(args = {}, deps = {}) {
@@ -320,6 +376,9 @@ export function captureEsp32(args = {}, deps = {}) {
const maxPackets = Math.min(args.max_packets ?? 200_000, 1_000_000);
const analyze = Boolean(args.analyze);
const maxFrames = Math.min(Math.max(args.analyze_max_frames ?? 6000, 64), 20_000);
const spectrum = Boolean(args.spectrum);
const spectrumBins = Math.min(Math.max(Math.round(args.spectrum_bins ?? 48), 8), 128);
const spectrumFrames = Math.min(Math.max(Math.round(args.spectrum_frames ?? 64), 8), 256);
if (!Number.isInteger(port) || port < 1024 || port > 65535) return Promise.resolve({ ok: false, reason: 'invalid_port', detail: 'udp_port must be an integer in 1024..65535' });
if (!BIND_HOSTS.has(bind)) return Promise.resolve({ ok: false, reason: 'invalid_bind', detail: `bind must be one of ${[...BIND_HOSTS].join(', ')}` });
if (args.node_id !== undefined && (!Number.isInteger(args.node_id) || args.node_id < 0 || args.node_id > 255)) return Promise.resolve({ ok: false, reason: 'invalid_node_id', detail: 'node_id must be an integer in 0..255' });
@@ -327,7 +386,8 @@ export function captureEsp32(args = {}, deps = {}) {
const createSocket = deps.createSocket || ((type) => dgram.createSocket({ type, reuseAddr: false }));
const stats = new NodeStats();
const collector = analyze ? new FrameCollector(maxFrames) : null;
const parseOpts = { iq: analyze };
const spectra = spectrum ? new SpectrumCollector(spectrumFrames, spectrumBins) : null;
const parseOpts = { iq: analyze || spectrum };
let packets = 0;
return new Promise((resolve) => {
const socket = createSocket(bind.includes(':') ? 'udp6' : 'udp4');
@@ -356,6 +416,7 @@ export function captureEsp32(args = {}, deps = {}) {
evidence: failure ? null : 'MEASURED: live UDP packets received and decoded on this host',
...extra,
};
if (spectra && !failure) result.spectrum = spectra.result();
if (collector && !failure) result.analysis = await runAnalysis(collector, args.node_id, deps.analyze);
resolve(result);
};
@@ -372,7 +433,11 @@ export function captureEsp32(args = {}, deps = {}) {
packets += 1;
const pkt = parsePacket(msg, parseOpts);
stats.add(pkt, rinfo?.address);
if (collector) collector.add(pkt, performance.now());
if (collector || spectra) {
const t = performance.now();
if (collector) collector.add(pkt, t);
if (spectra) spectra.add(pkt, t);
}
if (packets >= maxPackets) finish({ truncated: true });
});
socket.bind(port, bind, () => { timer = setTimeout(() => finish(), seconds * 1000); });
+3
View File
@@ -514,6 +514,9 @@ export const TOOLS = {
node_id: { type: 'number', minimum: 0, maximum: 255, description: 'Node to analyze. Default: the node with the most CSI frames.' },
backend: { type: 'string', enum: [...KERNEL_BACKENDS], description: 'Kernel backend for analyze. Default wasm.' },
analyze_max_frames: { type: 'number', minimum: 64, maximum: 20000, description: 'Frame cap for analyze. Default 6000.' },
spectrum: { type: 'boolean', description: 'Also return the newest per-node amplitude frames, binned, for a waterfall view (ADR-378).' },
spectrum_bins: { type: 'number', minimum: 8, maximum: 128, description: 'Subcarrier bins per frame. Default 48.' },
spectrum_frames: { type: 'number', minimum: 8, maximum: 256, description: 'Newest frames kept per node. Default 64.' },
},
},
handler(args = {}) {
+22 -1
View File
@@ -5,7 +5,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import dgram from 'node:dgram';
import { captureEsp32, crc32, parsePacket, RAC1_MAGIC, RHB1_MAGIC } from '../src/devices/esp32.js';
import { binAmplitudes, captureEsp32, crc32, parsePacket, RAC1_MAGIC, RHB1_MAGIC, SpectrumCollector } from '../src/devices/esp32.js';
import { classifyPort } from '../src/devices/registry.js';
import { kernelAnalyzer } from '../src/kernel.js';
import { runTool, TOOLS } from '../src/tools.js';
@@ -208,3 +208,24 @@ test('tool schemas accept the new arguments and reject bad ones', async () => {
const denied = await runTool('ruview_esp32_capture', { analyze: true }, { source: 'mcp', grants: [] });
assert.equal(denied.reason, 'authority_denied');
});
test('spectrum: binned amplitude frames per node for the waterfall (ADR-378)', async () => {
assert.deepEqual(binAmplitudes([1, 2, 3, 4, 5, 6], 3), [1.5, 3.5, 5.5]);
assert.deepEqual(binAmplitudes([1, 2], 8), [1, 2], 'never more bins than subcarriers');
const ring = new SpectrumCollector(3, 2);
for (let k = 0; k < 5; k++) ring.add({ kind: 'csi', source: 'esp32', nodeId: 1, subcarriers: 4, amplitudes: [k, k, k, k] }, k * 100);
ring.add({ kind: 'heartbeat' }, 0);
const [r] = ring.result();
assert.deepEqual(r.frames.map((f) => f[0]), [2, 3, 4], 'keeps the newest frames');
assert.deepEqual([r.framesSeen, r.rateHz, r.bins, r.synthetic], [5, 10, 2, false]);
const res = await capture({ spectrum: true, spectrum_bins: 16, spectrum_frames: 8 }, Array.from({ length: 12 }, (_, seq) => esp32Csi({ seq })));
assert.equal(res.ok, true);
const [s] = res.spectrum;
assert.deepEqual([s.source, s.nodeId, s.subcarriers, s.bins, s.frames.length], ['esp32', 42, 64, 16, 8]);
assert.ok(s.frames.every((f) => f.every((v) => v === 5)), 'amplitude |(0,5)| = 5');
const plain = await capture({}, [esp32Csi()]);
assert.equal(plain.spectrum, undefined, 'only when asked');
const tool = TOOLS.ruview_esp32_capture.inputSchema;
assert.deepEqual(validateArguments(tool, { spectrum: true, spectrum_bins: 48, spectrum_frames: 64 }), []);
assert.notDeepEqual(validateArguments(tool, { spectrum_bins: 4 }), []);
});
+162
View File
@@ -0,0 +1,162 @@
// The ruview-live showcase (ADR-378): cell graphics, animation frames and the
// waterfall/radar views, under plain Node. Engine behaviour (blit, keys) is
// covered by `claude plugin test mod`.
import test from 'node:test';
import assert from 'node:assert/strict';
import {
ANIMATED, Braille, colormap, commandsOf, DEFAULT, drain, Grid, lineChart, modelOf, phaseOf, picturesOf, pingOf, pulse,
radarFan, rgb, settingsOf, shimmer, shownFrames, spectrumOf, spectrumWith, toBase64, viewOf, waterfall,
} from '../mod/hooks/register.mjs';
const decode = ({ columns, rows, cells }) => {
const b = Buffer.from(cells, 'base64');
assert.equal(b.length, columns * rows * 12, 'three u32 per cell');
return new Uint32Array(b.buffer.slice(b.byteOffset, b.byteOffset + b.length));
};
const el = (type) => (props) => ({ type, props });
const UI = { Box: el('Box'), Text: el('Text'), Button: el('Button'), Raster: el('Raster') };
const flat = (n, out = []) => { if (n && typeof n === 'object') { out.push(n); [].concat(n.props?.children ?? []).forEach((c) => flat(c, out)); } return out; };
const spectrumCapture = (synthetic) => ({
ok: true, packets: 40, decodedPackets: 40, nodes: [{ source: 'esp32', nodeId: 6, csiRateHz: 13.7, csiLossFraction: 0, rssiMean: -50, csi: { shape: '1x256', synthetic } }],
spectrum: [{ source: 'esp32', nodeId: 6, subcarriers: 256, bins: 4, rateHz: 13.7, synthetic, frames: Array.from({ length: 40 }, (_, k) => [k, 2 * k, 3 * k, 4 * k]) }],
});
const radar = { ok: true, device: { name: 'kit' }, presentNow: true, targetsMax: 1, distanceCmMean: 120, heartBpmMean: 72, breathingBpmMean: 14 };
test('base64 and Grid pack the Raster contract exactly', () => {
for (const n of [0, 1, 2, 3, 4, 5, 100]) {
const bytes = Uint8Array.from({ length: n }, (_, i) => (i * 37) & 255);
assert.equal(toBase64(bytes), Buffer.from(bytes).toString('base64'));
}
const g = new Grid(3, 2);
g.set(1, 1, 'A', rgb(1, 2, 3), rgb(255, 0, 0));
g.set(9, 9, 'Z');
const u = decode(g.toRaster('k'));
assert.deepEqual([...u.slice(0, 3)], [0x20, DEFAULT, DEFAULT], 'blank cells use the terminal colours');
assert.deepEqual([...u.slice(12, 15)], [65, 0x010203, 0xff0000]);
assert.deepEqual([new Grid(9999, 9999).columns, new Grid(9999, 9999).rows], [512, 256], 'clamped to the Raster bounds');
});
test('colour map is monotone in brightness and clamps', () => {
const lum = (c) => ((c >> 16) & 255) * 0.3 + ((c >> 8) & 255) * 0.59 + (c & 255) * 0.11;
let last = -1;
for (let k = 0; k <= 20; k++) { const l = lum(colormap(k / 20)); assert.ok(l >= last - 1); last = l; }
assert.equal(colormap(-5), colormap(0));
assert.equal(colormap(NaN), colormap(0));
});
test('waterfall: newest frame at the bottom, two frames per row, robust limits', () => {
const frames = Array.from({ length: 10 }, (_, k) => [k, k, k, k]);
const { grid, lo, hi, shown } = waterfall(frames, 4, 3);
assert.equal(shown, 6, 'rows × 2 frames');
assert.ok(lo >= 4 && hi <= 9);
const u = decode(grid.toRaster('w'));
const bottom = (2 * 4) * 3;
assert.equal(u[bottom], 0x2580, 'upper half block');
assert.equal(u[bottom + 2], colormap(1), 'lower half of the last row is the newest frame');
assert.doesNotThrow(() => waterfall([], 8, 4));
});
test('braille canvas sets the right dots and keeps the higher-priority colour', () => {
const b = new Braille(2, 1);
b.plot(0, 0, 1, 0); b.plot(1, 3, 2, 5); b.plot(0, 1, 3, 1);
const u = decode(b.toGrid().toRaster('b'));
assert.equal(u[0], 0x2800 + 0x01 + 0x80 + 0x02);
assert.equal(u[1], 2, 'priority 5 wins the cell colour');
assert.equal(u[3], 0x20, 'an empty cell is a space');
});
test('radar fan scales to the reading, labels rings, and pings honestly', () => {
const near = radarFan(60, 18, { distances: [80, 90, 100], present: true });
const far = radarFan(60, 18, { distances: [400], present: false });
assert.ok(near.maxCm < far.maxCm);
assert.ok(near.maxCm >= 150 && far.maxCm <= 600);
const text = (g) => String.fromCodePoint(...[...decode(g.toRaster('f'))].filter((_, i) => i % 3 === 0));
assert.match(text(near.grid), /1m/);
const a = text(radarFan(60, 18, { distances: [100], ping: 0.2 }).grid);
const b = text(radarFan(60, 18, { distances: [100], ping: 0.7 }).grid);
assert.notEqual(a, b, 'the ping moves');
assert.doesNotThrow(() => radarFan(10, 6, { distances: [], ping: 0.5 }), 'no reading, no ping');
});
test('line chart fits the data so a rhythm stays visible, shades the band where it overlaps', () => {
const { lo, hi } = lineChart([70, 72, 74, 72, 70], 20, 3, { band: [40, 180], minSpan: 10 });
assert.ok(hi - lo < 20, `scale ${lo}–${hi} fits the data, not the whole 40–180 band`);
const shaded = decode(lineChart([70, 72], 10, 3, { band: [40, 180] }).grid.toRaster('c'));
assert.ok([...shaded].some((v, i) => i % 3 === 2 && v !== DEFAULT), 'band shaded');
const outside = decode(lineChart([300, 310], 10, 3, { band: [40, 180] }).grid.toRaster('c'));
assert.ok([...outside].every((v, i) => i % 3 !== 2 || v === DEFAULT), 'no band when it is off the scale');
});
test('animation frames: shimmer moves, pulse follows the reported rates, replay drains', () => {
assert.notDeepEqual(shimmer(40, 0).toRaster('s').cells, shimmer(40, 600).toRaster('s').cells);
assert.equal(shimmer(40, 0).toRaster('s').cells, shimmer(40, 2400).toRaster('s').cells, 'a 2.4 s loop');
assert.equal(phaseOf(1000, 60), 0, 'one beat a second');
assert.equal(phaseOf(500, 60), 0.5);
assert.equal(phaseOf(500, null), 0);
const beat = (t) => decode(pulse(20, t, { heartBpm: 60, breathingBpm: 15 }).toRaster('p'))[1];
assert.notEqual(beat(0), beat(500), 'the heart dims between beats');
const still = decode(pulse(20, 123, {}).toRaster('p'));
assert.equal(still[3 * 3], 0x2500, 'unknown breathing draws an empty gauge');
assert.equal(drain(10, 20, 250), 5);
assert.equal(drain(1, 20, 1000), 0);
assert.equal(drain(NaN, 20, 100), 0);
assert.deepEqual(shownFrames([1, 2, 3, 4], 2.7), [1, 2]);
assert.deepEqual(shownFrames([1, 2], 9), []);
assert.equal(pingOf(800), 0.5);
});
test('every animated picture keeps its size across frames (a blit must match the mount)', () => {
const model = { ...modelOf(spectrumCapture(false), radar, 0) };
const history = { heart: [70, 72], breathing: [14, 15], distance: [110, 120], rates: {} };
for (const mode of ['overview', 'waterfall', 'radar']) {
for (const columns of [70, 120, 200]) {
const opts = { mode, columns, rows: 34, history, lag: { 'esp32:6': 30 } };
const a = picturesOf(model, { ...opts, t: 0 });
const b = picturesOf(model, { ...opts, t: 1234, lag: { 'esp32:6': 3 } });
for (const key of ANIMATED) {
assert.equal(Boolean(a[key]), Boolean(b[key]), `${mode}/${key}`);
if (a[key]) assert.deepEqual([a[key].grid.columns, a[key].grid.rows], [b[key].grid.columns, b[key].grid.rows], `${mode}/${key}@${columns}`);
}
}
}
});
test('waterfall view labels MEASURED vs SYNTHETIC and needs a terminal', () => {
const opts = { mode: 'waterfall', refreshMs: 15000, liveRefreshMs: 4000, columns: 120, rows: 34, onMode() {}, onRefresh() {}, onClose() {} };
for (const synthetic of [false, true]) {
const texts = flat(viewOf(UI, modelOf(spectrumCapture(synthetic), null, 0), opts)).filter((n) => n.type === 'Text').map((n) => String(n.props.children));
assert.ok(texts.some((t) => (synthetic ? /^SYNTHETIC/ : /^MEASURED/).test(t)));
}
const tree = flat(viewOf(UI, modelOf(spectrumCapture(false), null, 0), opts));
assert.ok(tree.some((n) => n.type === 'Raster' && n.props.key === 'waterfall'));
assert.deepEqual(tree.filter((n) => n.type === 'Button').map((b) => b.props.hotkey), ['1', '2', '3', 'r', 'c'], 'no next-node key for one node');
const desk = JSON.stringify(viewOf({ Box: UI.Box, Text: UI.Text, Button: UI.Button }, modelOf(spectrumCapture(false), null, 0), opts));
assert.match(desk, /open it in the terminal/, 'surfaces without Raster get a note, not a refused tree');
const empty = JSON.stringify(viewOf(UI, modelOf({ ok: false, reason: 'no_packets' }, null, 0), opts));
assert.match(empty, /nothing to draw/);
});
test('radar view: fan, pulse and charts, with the honesty labels', () => {
const opts = { mode: 'radar', refreshMs: 15000, liveRefreshMs: 4000, columns: 130, rows: 34, radarConfigured: true, history: { heart: [70, 72], breathing: [14, 15], distance: [110, 120], rates: {} }, onMode() {}, onRefresh() {}, onClose() {} };
const tree = flat(viewOf(UI, modelOf(null, radar, 0), opts));
const keys = tree.filter((n) => n.type === 'Raster').map((n) => n.props.key);
assert.deepEqual(keys, ['shimmer', 'fan', 'pulse', 'chart-heart', 'chart-breathing', 'chart-distance']);
const s = JSON.stringify(tree.map((n) => (n.type === 'Text' ? n.props.children : '')));
assert.match(s, /range only: this kit reports distance, not bearing/);
assert.match(s, /a metronome, not a waveform/);
assert.match(s, /device-reported, not validated/);
const down = JSON.stringify(viewOf(UI, modelOf(null, { ok: false, reason: 'timeout' }, 0), opts));
assert.match(down, /unreachable/);
});
test('spectrum requests, merging across captures, and live commands', () => {
assert.deepEqual(spectrumOf(120, 34), { bins: 114, frames: 44 });
assert.deepEqual(spectrumOf(10, 5), { bins: 24, frames: 12 });
const c = commandsOf(settingsOf({ radarHost: 'kit.local' }), { live: true, spectrum: { bins: 64, frames: 48 } });
assert.deepEqual(c.capture, ['esp32', '--seconds', '2', '--udp-port', '5005', '--json', '--spectrum', '--spectrum-bins', '64', '--spectrum-frames', '48']);
assert.equal(c.radar[c.radar.indexOf('--seconds') + 1], '2');
const a = { key: 'esp32:6', bins: 4, frames: [[1], [2]] };
const merged = spectrumWith(spectrumWith([], [a]), [{ ...a, frames: [[3]] }], 2);
assert.deepEqual(merged[0].frames, [[2], [3]], 'frames carry across captures, bounded');
assert.deepEqual(spectrumWith(merged, [{ ...a, bins: 8, frames: [[9]] }])[0].frames, [[9]], 'a new bin count starts over');
});
+16 -10
View File
@@ -3,7 +3,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { ageOf, cliPathOf, commandsOf, historyWith, modelOf, plausibilityOf, resultOf, settingsOf, sparkline, statusOf, viewOf } from '../mod/hooks/register.mjs';
import { ageOf, cliPathOf, commandsOf, HISTORY, historyWith, modelOf, plausibilityOf, resultOf, settingsOf, sparkline, statusOf, viewOf } from '../mod/hooks/register.mjs';
const capture = {
ok: true, packets: 120, decodedPackets: 118,
@@ -16,9 +16,9 @@ const capture = {
const radar = { ok: true, host: '192.168.1.102', device: { name: 'mr60-kit' }, presentNow: true, distanceCmMean: 40.2, heartBpmMean: 75.3, breathingBpmMean: 10.7 };
test('settings are bounded and radar hosts are validated', () => {
assert.deepEqual(settingsOf({}), { udpPort: 5005, radarHost: '', refreshMs: 15000, captureSeconds: 3 });
const s = settingsOf({ udpPort: 80, radarHost: ' 192.168.1.102 ', refreshSeconds: 1, captureSeconds: 99 });
assert.deepEqual(s, { udpPort: 1024, radarHost: '192.168.1.102', refreshMs: 5000, captureSeconds: 10 });
assert.deepEqual(settingsOf({}), { udpPort: 5005, radarHost: '', refreshMs: 15000, captureSeconds: 3, liveRefreshMs: 4000 });
const s = settingsOf({ udpPort: 80, radarHost: ' 192.168.1.102 ', refreshSeconds: 1, captureSeconds: 99, liveRefreshSeconds: 0 });
assert.deepEqual(s, { udpPort: 1024, radarHost: '192.168.1.102', refreshMs: 5000, captureSeconds: 10, liveRefreshMs: 2000 });
assert.equal(settingsOf({ radarHost: 'evil host; rm -rf /' }).radarHost, '');
});
@@ -101,10 +101,10 @@ test('sparklines, plausibility, ages and history are bounded and honest', () =>
assert.equal(ageOf(185_000), '3m ago');
assert.equal(ageOf(NaN), '');
let h = null;
for (let i = 0; i < 30; i++) h = historyWith(h, modelOf(capture, { ...radar, heartBpmMean: 70 + i }, i));
assert.equal(h.heart.length, 24, 'history is bounded');
assert.equal(h.heart.at(-1), 99);
assert.equal(h.rates['realtek:3'].length, 24);
for (let i = 0; i < HISTORY + 6; i++) h = historyWith(h, modelOf(capture, { ...radar, heartBpmMean: 70 + i }, i));
assert.equal(h.heart.length, HISTORY, 'history is bounded');
assert.equal(h.heart.at(-1), 70 + HISTORY + 5);
assert.equal(h.rates['realtek:3'].length, HISTORY);
});
test('the redesigned pane: badge, cards side by side when wide, flags implausible vitals', () => {
@@ -147,6 +147,12 @@ test('the mod manifest and hooks module are a valid plugin shape', () => {
const hooks = JSON.parse(readFileSync(new URL('../mod/hooks/hooks.json', import.meta.url), 'utf8'));
assert.equal(plugin.name, 'ruview-live');
assert.deepEqual(hooks.modules, ['./register.mjs']);
const src = readFileSync(new URL('../mod/hooks/register.mjs', import.meta.url), 'utf8');
assert.doesNotMatch(src, /^\s*import\s/m, 'a mod imports nothing but its own files');
for (const file of ['register.mjs', 'model.mjs', 'views.mjs', 'raster.mjs', 'anim.mjs']) {
const src = readFileSync(new URL(`../mod/hooks/${file}`, import.meta.url), 'utf8');
for (const [, from] of src.matchAll(/^\s*(?:import|export)\s[^;]*?from\s+'([^']+)'/gm)) {
assert.match(from, /^\.\/[a-z]+\.mjs$/, `${file}: a mod imports nothing but its own files (${from})`);
}
assert.doesNotMatch(src, /\bimport\(/, `${file}: no dynamic import`);
}
assert.ok(plugin.userConfig.liveRefreshSeconds, 'the live views have their own refresh option');
});