diff --git a/.github/workflows/npm-packages.yml b/.github/workflows/npm-packages.yml index 7fadcced5..ac7864f5a 100644 --- a/.github/workflows/npm-packages.yml +++ b/.github/workflows/npm-packages.yml @@ -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 diff --git a/.github/workflows/ruview-npm-release.yml b/.github/workflows/ruview-npm-release.yml index d9deae7d8..85400f41b 100644 --- a/.github/workflows/ruview-npm-release.yml +++ b/.github/workflows/ruview-npm-release.yml @@ -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). diff --git a/docs/adr/ADR-378-ruview-live-showcase-views.md b/docs/adr/ADR-378-ruview-live-showcase-views.md new file mode 100644 index 000000000..c2f3888ac --- /dev/null +++ b/docs/adr/ADR-378-ruview-live-showcase-views.md @@ -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. diff --git a/docs/adr/README.md b/docs/adr/README.md index 391f96661..c5051feaa 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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) diff --git a/docs/user-guide.md b/docs/user-guide.md index 9ef05425f..e6ecbd2ce 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -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 "" # try it for one session @@ -1079,9 +1089,11 @@ claude --plugin-dir "" # 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`). --- diff --git a/harness/ruview/.harness/manifest.json b/harness/ruview/.harness/manifest.json index 9fa7dbfd8..1101fb01b 100644 --- a/harness/ruview/.harness/manifest.json +++ b/harness/ruview/.harness/manifest.json @@ -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": { diff --git a/harness/ruview/.harness/manifest.sha256 b/harness/ruview/.harness/manifest.sha256 index cdf854fb9..6cb00d731 100644 --- a/harness/ruview/.harness/manifest.sha256 +++ b/harness/ruview/.harness/manifest.sha256 @@ -1 +1 @@ -b7f72c94e8f41c3f2687549972e5ec69cfd0302f16d90ff3aaa511449f5158bd manifest.json +09b27be67905c5459a5e1a01c21e66a3c9d9f9c523befc1361b0c41a0a06c9ac manifest.json diff --git a/harness/ruview/README.md b/harness/ruview/README.md index ddc2676f3..0004cb992 100644 --- a/harness/ruview/README.md +++ b/harness/ruview/README.md @@ -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 "" # 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:** diff --git a/harness/ruview/mod/.claude-plugin/plugin.json b/harness/ruview/mod/.claude-plugin/plugin.json index 752240aaf..153f6c52b 100644 --- a/harness/ruview/mod/.claude-plugin/plugin.json +++ b/harness/ruview/mod/.claude-plugin/plugin.json @@ -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 } } } diff --git a/harness/ruview/mod/hooks/anim.mjs b/harness/ruview/mod/hooks/anim.mjs new file mode 100644 index 000000000..7e36fd406 --- /dev/null +++ b/harness/ruview/mod/hooks/anim.mjs @@ -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; diff --git a/harness/ruview/mod/hooks/model.mjs b/harness/ruview/mod/hooks/model.mjs new file mode 100644 index 000000000..5828430fc --- /dev/null +++ b/harness/ruview/mod/hooks/model.mjs @@ -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 /mod, so the CLI is + * /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(' · '); +} diff --git a/harness/ruview/mod/hooks/raster.mjs b/harness/ruview/mod/hooks/raster.mjs new file mode 100644 index 000000000..bbab0cce3 --- /dev/null +++ b/harness/ruview/mod/hooks/raster.mjs @@ -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 }; +} diff --git a/harness/ruview/mod/hooks/register.mjs b/harness/ruview/mod/hooks/register.mjs index 4c42ca945..27b63be0c 100644 --- a/harness/ruview/mod/hooks/register.mjs +++ b/harness/ruview/mod/hooks/register.mjs @@ -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 /mod, so the CLI is - * /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(); }, }); }); diff --git a/harness/ruview/mod/hooks/views.mjs b/harness/ruview/mod/hooks/views.mjs new file mode 100644 index 000000000..425567b27 --- /dev/null +++ b/harness/ruview/mod/hooks/views.mjs @@ -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] }); +} diff --git a/harness/ruview/mod/tests/register.test.ts b/harness/ruview/mod/tests/register.test.ts index d9d5b6b84..3953eb7cd 100644 --- a/harness/ruview/mod/tests/register.test.ts +++ b/harness/ruview/mod/tests/register.test.ts @@ -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) diff --git a/harness/ruview/src/cli.js b/harness/ruview/src/cli.js index d67ca5f3d..6a0944fb1 100644 --- a/harness/ruview/src/cli.js +++ b/harness/ruview/src/cli.js @@ -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']); diff --git a/harness/ruview/src/devices/esp32.js b/harness/ruview/src/devices/esp32.js index ed49a5b56..cf1d5ee74 100644 --- a/harness/ruview/src/devices/esp32.js +++ b/harness/ruview/src/devices/esp32.js @@ -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); }); diff --git a/harness/ruview/src/tools.js b/harness/ruview/src/tools.js index db97f9164..b3081b13a 100644 --- a/harness/ruview/src/tools.js +++ b/harness/ruview/src/tools.js @@ -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 = {}) { diff --git a/harness/ruview/test/csi-nodes.test.mjs b/harness/ruview/test/csi-nodes.test.mjs index 619d04878..efd408247 100644 --- a/harness/ruview/test/csi-nodes.test.mjs +++ b/harness/ruview/test/csi-nodes.test.mjs @@ -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 }), []); +}); diff --git a/harness/ruview/test/mod-showcase.test.mjs b/harness/ruview/test/mod-showcase.test.mjs new file mode 100644 index 000000000..26819468f --- /dev/null +++ b/harness/ruview/test/mod-showcase.test.mjs @@ -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'); +}); diff --git a/harness/ruview/test/mod.test.mjs b/harness/ruview/test/mod.test.mjs index 98c458fbe..cb6933abb 100644 --- a/harness/ruview/test/mod.test.mjs +++ b/harness/ruview/test/mod.test.mjs @@ -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'); });