mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-21 22:22:33 -05:00
feat(ruview-live): showcase views — CSI waterfall, radar fan, animated cells (ADR-378)
The /ruview pane gains three views (keys 1/2/3) drawn in terminal Rasters
and animated in place with $.ui.blit (~12 fps, no redraws):
- CSI waterfall: per-subcarrier amplitude over time from the new
`esp32 --spectrum` capture, two frames per row via the half block,
5-95th percentile colour limits, replayed at the frames' arrival rate,
labelled MEASURED or SYNTHETIC from the packets' own flag; n cycles nodes.
- Radar: a 120-degree range fan with rings, a fading distance trail and a
sonar ping to the measured range; braille heart/breathing/distance charts
scaled to the data; a heart and breathing gauge that pulse at the
device-reported rates ("a metronome, not a waveform").
- A shimmer rule under the title; surfaces without Raster get a note.
Harness: captureEsp32 / ruview_esp32_capture take spectrum,
spectrum_bins (8-128) and spectrum_frames (8-256): a bounded ring of
binned amplitude frames per node, dominant shape, rate and synthetic flag.
The mod is split into model/raster/anim/views/register (own-file imports
only). Tests: 11 showcase + 11 mod Node tests, a --spectrum loopback test,
4 engine tests (waterfall tab sends --spectrum, draws the Raster, blits);
npm test 189 pass, test:security 90 pass, manifest verified, validate passes.
Evidence: MEASURED on an ESP32-C6 (COM11, release c6-adr110 v0.7.0, node 6)
streaming 7-18 Hz CSI to this host; a 1x256 capture renders the occupied
band against guard/null tones. Unpacked budget 384 -> 448 KiB (+~37 KB).
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
1 parent
c4d7ef8086
commit
751b7ae17b
21 files changed
+1331
-280
No files matched your search
@@ -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
|
||||
|
||||
@@ -106,8 +106,8 @@ jobs:
|
||||
case "${{ inputs.package }}" in
|
||||
# ADR-283/325/369-374: brain + hosts + replay + Spaces OAuth + SDK,
|
||||
# flashing, training, doctor, device access, SSH hosts; ADR-375 console
|
||||
# widget, HTTP transport, terminal UI; no runtime deps.
|
||||
harness/ruview) export UNPACKED_BUDGET=393216 ;;
|
||||
# widget, HTTP transport, terminal UI; ADR-378 mod showcase; no runtime deps.
|
||||
harness/ruview) export UNPACKED_BUDGET=458752 ;;
|
||||
# ADR-285: CLI + MCP + reviewed brain + WASM-kernel adapter.
|
||||
harness/homecore) export UNPACKED_BUDGET=180000 ;;
|
||||
# ADR-264 O2: map-free tarball (was 188 kB with maps).
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# ADR-378: `ruview-live` showcase views — CSI waterfall, radar fan, animated cells
|
||||
|
||||
- **Status**: proposed
|
||||
- **Date**: 2026-10-01
|
||||
- **Deciders**: rUv ("make this a showcase … the art of the possible for mods"); RuView maintainers
|
||||
- **Tags**: claude-code, mods, ui, raster, animation, csi, radar, honesty
|
||||
- **Related**: ADR-377 (the `ruview-live` mod), ADR-373 (device access),
|
||||
ADR-375 (terminal UI), ADR-018 / ADR-323 (CSI formats)
|
||||
|
||||
## Context
|
||||
|
||||
ADR-377 shipped `/ruview`: a pane of cards built from text and sparklines. The
|
||||
mod engine can do much more on the terminal surface:
|
||||
|
||||
- `Raster` is a grid of cells, each with a glyph and a 24-bit foreground and
|
||||
background.
|
||||
- `$.ui.blit` repaints a mounted Raster in place, up to 60 frames a second,
|
||||
without redrawing the tree.
|
||||
- Buttons take hotkeys.
|
||||
|
||||
The pane only showed summaries. It never showed the signal itself: per-subcarrier
|
||||
CSI amplitude over time, or the radar's range as geometry.
|
||||
|
||||
## Decision
|
||||
|
||||
### Three views, keys `1` `2` `3`
|
||||
|
||||
| View | What it draws | Data |
|
||||
|---|---|---|
|
||||
| Overview | the ADR-377 cards | unchanged |
|
||||
| CSI waterfall | subcarrier bins across, time down; two frames per text row through `▀` (foreground = upper frame, background = lower); 5th–95th percentile colour limits with a colour bar | `ruview esp32 --spectrum` (new) |
|
||||
| Radar | top-down 120° fan: range rings, a fading trail of past distances, the current distance as an arc; braille line charts of heart, breathing and distance | ESPHome radar kit (ADR-373) |
|
||||
|
||||
`n` cycles nodes in the waterfall when more than one streams. The live views
|
||||
refresh every `liveRefreshSeconds` (default 4, range 2–60); the overview keeps
|
||||
`refreshSeconds`.
|
||||
|
||||
### Harness: `--spectrum`
|
||||
|
||||
`captureEsp32` and the `ruview_esp32_capture` tool take three new arguments:
|
||||
|
||||
- `spectrum`: boolean;
|
||||
- `spectrum_bins`: 8–128, default 48;
|
||||
- `spectrum_frames`: 8–256, default 64.
|
||||
|
||||
A `SpectrumCollector` keeps a ring of the newest frames for each node and CSI
|
||||
shape. It averages each frame's amplitudes into equal bins, and reports the
|
||||
dominant shape per node with its arrival rate and `synthetic` flag. The
|
||||
collector is read-only and bounded (8 nodes), and it adds nothing to a capture
|
||||
that does not ask for it.
|
||||
|
||||
### Animation with `$.ui.blit`
|
||||
|
||||
One 80 ms timer (about 12 fps) repaints only these keyed Rasters:
|
||||
|
||||
- **shimmer**: a highlight sweeping along the rule under the title. Pure
|
||||
decoration.
|
||||
- **pulse**: a ♥ that beats at the device-reported heart rate and a gauge that
|
||||
fills and empties at the device-reported breathing rate. It is labelled *a
|
||||
metronome, not a waveform*.
|
||||
- **fan**: a sonar ring that leaves the sensor and reaches the measured range.
|
||||
- **waterfall replay**: frames arrive a capture at a time, so the view drains
|
||||
them at their measured arrival rate instead of jumping every refresh. It is
|
||||
labelled *replayed at the arrival rate*.
|
||||
|
||||
The first paint and every frame come from the same function (`picturesOf`). A
|
||||
test asserts that each picture keeps its size across frames, because a blit of
|
||||
another size is refused.
|
||||
|
||||
Blits are fire-and-forget: a blit resolves only once a frame is painted, and
|
||||
the surface folds blits between frames anyway.
|
||||
|
||||
### Honesty rules (CLAUDE.md)
|
||||
|
||||
- The waterfall says **MEASURED — live CSI amplitude received on this host** or
|
||||
**SYNTHETIC — simulator frames**, from the packets' own flag.
|
||||
- The radar view states *range only: this kit reports distance, not bearing*.
|
||||
The arc covers every bearing at that range; nothing implies a position.
|
||||
- Vitals stay *device-reported, not validated*. Values outside 40–180 bpm
|
||||
(heart) or 4–40 bpm (breathing) are flagged.
|
||||
- Chart scales fit the data so a rhythm is visible. The plausible band is
|
||||
shaded only where it overlaps the scale.
|
||||
- Surfaces without `Raster` (desktop, VS Code, mobile) get a note in the
|
||||
showcase views instead of a tree the surface would refuse. The overview works
|
||||
everywhere.
|
||||
|
||||
### Structure
|
||||
|
||||
Mods may import only their own files, so the hooks module is split into:
|
||||
|
||||
- `model.mjs`: settings, argv, results, model and history (pure);
|
||||
- `raster.mjs`: Grid, base64, colour map, waterfall, braille, radar fan, line
|
||||
chart (pure);
|
||||
- `anim.mjs`: frame functions of time (pure);
|
||||
- `views.mjs`: drawing;
|
||||
- `register.mjs`: wiring, timers and blits. It re-exports the rest so tests
|
||||
import one module.
|
||||
|
||||
## Evidence
|
||||
|
||||
- **MEASURED** (real silicon, 2026-10-01):
|
||||
- Hardware: an ESP32-C6 on COM11, flashed with the hardware-verified
|
||||
`release_bins/c6-adr110` image (v0.7.0; SHA-256 checked) and provisioned to
|
||||
ruv.net, node 6.
|
||||
- Boot log: `Got IP: 192.168.1.103` and `UDP sender initialized:
|
||||
192.168.1.45:5005`.
|
||||
- Captures: `ruview esp32 --spectrum` received 29–50 frames per 2 s at 7–18
|
||||
Hz and −37 to −78 dBm, in 1×64 and 1×256 shapes.
|
||||
- Waterfall: a 1×256 capture rendered as 114 bins showing the occupied band
|
||||
against guard and null tones.
|
||||
- Reproducer: `npx @ruvnet/ruview esp32 --seconds 4 --spectrum --json` with
|
||||
a node targeting the host.
|
||||
- **MEASURED** (device-reported): the Seeed MR60BHA2 kit at 192.168.1.102 drives
|
||||
the radar view.
|
||||
- **SYNTHETIC**: the `realtek-csi-sim` simulator exercises the waterfall in
|
||||
development; its frames carry the synthetic flag and the view says so.
|
||||
- **Tests**:
|
||||
- 11 mod tests and 11 showcase tests: Grid/base64 against Node's encoder,
|
||||
colour map monotone, waterfall ordering, braille bits, fan scaling and
|
||||
ping, chart scaling, frame functions, constant picture sizes, MEASURED and
|
||||
SYNTHETIC labels, surfaces without Raster;
|
||||
- a loopback test of `--spectrum`;
|
||||
- 4 engine tests (`claude plugin test mod`), including the waterfall tab
|
||||
sending `--spectrum`, drawing the Raster and animating it with blits.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The package grows by about 37 KB. The reviewed unpacked ceiling rises from
|
||||
384 KiB to 448 KiB in `npm-packages.yml` and `ruview-npm-release.yml`. There
|
||||
are still no runtime dependencies.
|
||||
- The live views poll more often: a 2 s capture every 4 s by default. A
|
||||
capture binds the UDP port briefly, so a sensing server on the same port
|
||||
still reports `port_in_use`, as in ADR-377.
|
||||
- The animation timer runs only while the pane is open, and stops on close and
|
||||
session end.
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
Latest proposed decisions:
|
||||
|
||||
- [ADR-378: `ruview-live` showcase views — CSI waterfall, radar fan, animated cells](ADR-378-ruview-live-showcase-views.md)
|
||||
- [ADR-377: `ruview-live` — a Claude Code mod shipped inside `@ruvnet/ruview`](ADR-377-ruview-live-claude-code-mod.md)
|
||||
- [ADR-376: `ruview` — one npm install for every RuView component](ADR-376-ruview-umbrella-npm-package.md)
|
||||
- [ADR-375: MCP Apps console, HTTP transport and terminal UI for `@ruvnet/ruview`](ADR-375-ruview-mcp-apps-console-http-transport-and-terminal-ui.md)
|
||||
|
||||
+14
-1
@@ -1066,6 +1066,16 @@ beside the conversation, refreshed on a timer, showing:
|
||||
|
||||
It also keeps a status line such as `RuView · 2 nodes · radar present`.
|
||||
|
||||
Keys `1`, `2` and `3` switch between three views (ADR-378):
|
||||
- **Overview**: the cards.
|
||||
- **CSI waterfall**: per-subcarrier amplitude over time, labelled MEASURED or
|
||||
SYNTHETIC; `n` cycles nodes.
|
||||
- **Radar**: a range fan with an animated ping, plus heart, breathing and
|
||||
distance charts. The values are device-reported and not validated, and the
|
||||
kit reports range, not bearing.
|
||||
|
||||
The two live views draw in terminal cells and animate in place.
|
||||
|
||||
```bash
|
||||
npx @ruvnet/ruview@0.9.0 mod # prints the mod's path in your install and how to load it
|
||||
claude --plugin-dir "<that path>" # try it for one session
|
||||
@@ -1079,9 +1089,11 @@ claude --plugin-dir "<that path>" # try it for one session
|
||||
| `/ruview` | Open or close the pane |
|
||||
| `/ruview refresh` | Run one capture now; the result goes in the status line |
|
||||
| `/ruview off` | Close the pane |
|
||||
| `/ruview waterfall` / `/ruview radar` | Open on that view |
|
||||
|
||||
**Settings** (`claude plugin configure ruview-live`): `udpPort`, `radarHost`,
|
||||
`refreshSeconds` and `captureSeconds`.
|
||||
`refreshSeconds`, `captureSeconds` and `liveRefreshSeconds` (live views,
|
||||
default 4 s).
|
||||
|
||||
The mod only runs the package's read-only CLI commands. Mods are early access:
|
||||
if `/ruview` is missing, start Claude Code with
|
||||
@@ -1105,6 +1117,7 @@ Design records:
|
||||
- [ADR-376](adr/ADR-376-ruview-umbrella-npm-package.md): the one-install
|
||||
`ruview` package, pending the npm name;
|
||||
- [ADR-377](adr/ADR-377-ruview-live-claude-code-mod.md): the Claude Code mod.
|
||||
- [ADR-378](adr/ADR-378-ruview-live-showcase-views.md): the mod's CSI waterfall and radar views (keys 1/2/3, `esp32 --spectrum`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
".mcp/servers.json": "fec6075400f8350d8075beac8306690355c4b015425bfd0e5f52966234e9d66f",
|
||||
"CLAUDE.md": "25f9306b6caae22078cb9375108b6f6bb813d83e617739f6d9372f116b0ede5d",
|
||||
"LICENSE": "631f94984f626818d42ecf717aa6e8e0afd4f9f355ca706bd2effafbd1416d06",
|
||||
"README.md": "20df33af2fcd83481321d963b76169b863c6a8acb1d7281e9f40169786169a87",
|
||||
"README.md": "a0ee4d09ecef1b125c9d96a6ec8cdfce869bb727c986e650bb7941b21d23ddce",
|
||||
"bin/cli.js": "8ebf6ca4b6d9529a5e338374c19329c1a2fc2f9e52f13b4341928b7168ee461b",
|
||||
"brain/corpus/core.jsonl": "c0fb7b079ded157059b91601361429944697dae3cc42abc00dfe1a680986b0f4",
|
||||
"flywheel/evaluations.json": "ac4ff1f897a2444870cd2b8ae8aee8b1578e61467aeca4db57893f41be98a572",
|
||||
@@ -45,8 +45,8 @@
|
||||
"skills/verify.md": "2d38d240e9810a7827e2ebd3717dc0f85c646cc92e46c3812fe77c5b9eb40b76",
|
||||
"src/brain.js": "0f16a75aea943acdacc430ff11d5df7ecdec9cca2ab497795ff6f33eaebdfab6",
|
||||
"src/cli-ui.js": "507fbd4444c6639c69354790ed3aed2addcb826929a6b3ad7ddb752f058c1f2f",
|
||||
"src/cli.js": "2401106a97509da8dcbf7e002195328868aa2b18f69cbc02ddaaf1cad95b4388",
|
||||
"src/devices/esp32.js": "ae389193430fc85601b46aef9281ad91631a1cb9bf4d2f247dfb02e2a4491c4d",
|
||||
"src/cli.js": "5e8467f0d0fb90d81347715143b3777e239165d707dd9d8c5d647294509a7fc3",
|
||||
"src/devices/esp32.js": "726f1ce60b27e5392bedcd367cb190c2a81521ae7935c9e8051fdce768667e56",
|
||||
"src/devices/esphome.js": "83b0fbd8c9075e233a401fa9f9dad04fc911e04819e7a0c48eb32d7e35d226af",
|
||||
"src/devices/lidar.js": "d753d88375358ced5174376753c276b5ca621f38ae48eac58dbb307b9004b629",
|
||||
"src/devices/mmwave.js": "09d828e3b3e140926194ab83bf27e7c63cbda1cf438385c82c0e89b9d8e4b526",
|
||||
@@ -71,11 +71,11 @@
|
||||
"src/sdk.d.ts": "82e73721de433551a2b4339821ebf7b2c9d44133c766498ae91d15483ed8a073",
|
||||
"src/sdk.js": "a07b5e5777ee897a50c12f6002065c3ca781e087d625541d54e5f5308ede2116",
|
||||
"src/spaces.js": "45ef786537cb2a446db5e926e5a1c10b73639d2767dec84611f914f78d4325eb",
|
||||
"src/tools.js": "0fb9114944c79bf351ec736f4034b486608d1e96c7e42f6d3c617053fd8a90a7",
|
||||
"src/tools.js": "7c41347b4be063457d7759ddb8f1f5dd608cbe28baa1c80e4a27d6567af6bc98",
|
||||
"src/training.js": "b8451c48db69c7d077d75f07500996213beaa52705116f64afafe084e68e3956",
|
||||
"src/ui/console-widget.js": "0d2f707d57b5ec54968dee1dc80f31c7db9152dcadfc48700c18379dabb55462"
|
||||
},
|
||||
"filesDigest": "d7989b453bdabbff37ef3b2382192c36b9513544ab593ffa9da97ba18dc7f003",
|
||||
"filesDigest": "80177f2f205fb00ec00fddef0f46422c8421ab02d4e883282c247b8e7b69e797",
|
||||
"brainDigest": "c0fb7b079ded157059b91601361429944697dae3cc42abc00dfe1a680986b0f4",
|
||||
"gateFingerprint": "6e53c784eee38310188948fc75fb49e6b4ebc04e247d01b903fa8c8a92d67bdd",
|
||||
"developmentPins": {
|
||||
|
||||
@@ -1 +1 @@
|
||||
b7f72c94e8f41c3f2687549972e5ec69cfd0302f16d90ff3aaa511449f5158bd manifest.json
|
||||
09b27be67905c5459a5e1a01c21e66a3c9d9f9c523befc1361b0c41a0a06c9ac manifest.json
|
||||
@@ -251,6 +251,16 @@ This package ships **`ruview-live`**, a Claude Code mod: a plugin whose behaviou
|
||||
|
||||
It also keeps a one-line status: `RuView · 2 nodes · radar present · 1 alert`.
|
||||
|
||||
**Showcase views (ADR-378).** The pane has three views, switched with keys `1`, `2` and `3`. The two live views are drawn in terminal cells (24-bit colour) and animated in place:
|
||||
|
||||
| Key | View | What you see |
|
||||
|---|---|---|
|
||||
| `1` | Overview | the cards above |
|
||||
| `2` | CSI waterfall | per-subcarrier amplitude over time (`esp32 --spectrum`), replayed at the frames' arrival rate and labelled **MEASURED** or **SYNTHETIC** from the packets' own flag; `n` cycles nodes |
|
||||
| `3` | Radar | a 120° range fan with a sonar ping out to the measured distance; heart and breathing charts, and a ♥ and breathing gauge that pulse at the device-reported rates (a metronome, not a waveform) |
|
||||
|
||||
The radar kit reports range, not bearing, so the arc covers every bearing at that range. Desktop and IDE surfaces show the overview, and a note in the live views.
|
||||
|
||||
```bash
|
||||
npx @ruvnet/ruview mod # where the mod is in this install, and how to load it
|
||||
claude --plugin-dir "<path printed above>" # try it for one session
|
||||
@@ -266,11 +276,13 @@ In Claude Code:
|
||||
| `/ruview` | Open or close the pane |
|
||||
| `/ruview refresh` | Run one capture now; the result goes in the status line |
|
||||
| `/ruview off` | Close the pane |
|
||||
| `/ruview waterfall`, `/ruview radar` | Open the pane on that view |
|
||||
|
||||
**Settings** (`claude plugin configure ruview-live`):
|
||||
- `udpPort` (default 5005);
|
||||
- `radarHost`, an ESPHome kit on your private network;
|
||||
- `refreshSeconds` (default 15, minimum 5);
|
||||
- `liveRefreshSeconds` for the waterfall and radar views (default 4, range 2–60);
|
||||
- `captureSeconds` (default 3).
|
||||
|
||||
**How it reads:**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ruview-live",
|
||||
"version": "0.1.0",
|
||||
"description": "RuView live sensing in Claude Code: /ruview opens a pane beside the transcript with your CSI nodes (ESP32, Realtek) and 60 GHz radar (raw or ESPHome kits): rates, loss, RSSI, presence, device-reported vitals and alerts such as heartbeat-only boards, refreshed on a timer, plus a one-line status. Reads only; runs the @ruvnet/ruview CLI it ships beside.",
|
||||
"description": "RuView live sensing in Claude Code: /ruview opens a pane beside the transcript with three views: an overview of your CSI nodes (ESP32, Realtek) and 60 GHz radar (rates, loss, RSSI, presence, device-reported vitals, alerts), a live CSI amplitude waterfall, and a radar range fan with vitals charts, all drawn in terminal cells. Reads only; runs the @ruvnet/ruview CLI it ships beside.",
|
||||
"author": {
|
||||
"name": "ruvnet",
|
||||
"url": "https://github.com/ruvnet/RuView"
|
||||
@@ -32,6 +32,12 @@
|
||||
"title": "Capture window (s)",
|
||||
"description": "Seconds each refresh listens for node packets and radar states (1-10).",
|
||||
"default": 3
|
||||
},
|
||||
"liveRefreshSeconds": {
|
||||
"type": "number",
|
||||
"title": "Live view refresh (s)",
|
||||
"description": "Seconds between refreshes in the waterfall and radar views (2-60).",
|
||||
"default": 4
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,168 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// ruview-live data: settings, CLI argv, result parsing, the pane model, trend
|
||||
// history and the status line. Pure functions, tested under plain Node.
|
||||
|
||||
const HOST_RE = /^[A-Za-z0-9][A-Za-z0-9.-]{0,252}$/;
|
||||
export const HISTORY = 48;
|
||||
export const MODES = Object.freeze(['overview', 'waterfall', 'radar']);
|
||||
|
||||
/**
|
||||
* The harness CLI beside this mod: the plugin root is <pkg>/mod, so the CLI is
|
||||
* <pkg>/bin/cli.js. Mods import nothing but their own files, so the engine's
|
||||
* `$.plugin.root` locates it.
|
||||
*/
|
||||
export function cliPathOf(pluginRoot) {
|
||||
return `${String(pluginRoot).replace(/[\\/]+$/, '')}/../bin/cli.js`;
|
||||
}
|
||||
|
||||
/** Normalise plugin options (userConfig) into bounded settings. */
|
||||
export function settingsOf(options = {}) {
|
||||
const num = (v, d, lo, hi) => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? Math.min(Math.max(Math.round(n), lo), hi) : d;
|
||||
};
|
||||
const host = typeof options.radarHost === 'string' ? options.radarHost.trim() : '';
|
||||
return {
|
||||
udpPort: num(options.udpPort, 5005, 1024, 65535),
|
||||
radarHost: HOST_RE.test(host) ? host : '',
|
||||
refreshMs: num(options.refreshSeconds, 15, 5, 3600) * 1000,
|
||||
captureSeconds: num(options.captureSeconds, 3, 1, 10),
|
||||
liveRefreshMs: num(options.liveRefreshSeconds, 4, 2, 60) * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* argv for one capture and (optionally) one radar read. In a live view the
|
||||
* capture is shorter, and the waterfall asks for binned amplitude frames.
|
||||
*/
|
||||
export function commandsOf(settings, { live = false, spectrum = null } = {}) {
|
||||
const s = String(live ? Math.min(settings.captureSeconds, 2) : settings.captureSeconds);
|
||||
const capture = ['esp32', '--seconds', s, '--udp-port', String(settings.udpPort), '--json'];
|
||||
if (spectrum) capture.push('--spectrum', '--spectrum-bins', String(spectrum.bins), '--spectrum-frames', String(spectrum.frames));
|
||||
return {
|
||||
capture,
|
||||
radar: settings.radarHost ? ['mmwave', '--source', 'esphome', '--host', settings.radarHost, '--seconds', s, '--json'] : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse a CLI run into its JSON result, or an honest failure. */
|
||||
export function resultOf(run) {
|
||||
if (!run) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(run.stdout);
|
||||
if (parsed && typeof parsed === 'object') return parsed;
|
||||
} catch { /* fall through */ }
|
||||
return { ok: false, reason: 'cli_error', detail: String(run.stderr || run.stdout || `exit ${run.exitCode}`).trim().slice(0, 300) };
|
||||
}
|
||||
|
||||
const fixed = (v, d = 1) => (typeof v === 'number' && Number.isFinite(v) ? v.toFixed(d) : '—');
|
||||
const pct = (v) => (typeof v === 'number' ? `${(v * 100).toFixed(1)}%` : '—');
|
||||
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
||||
|
||||
/** Resting ranges outside which a device-reported vital is flagged, not trusted. */
|
||||
export const RANGES = Object.freeze({ heart: [40, 180], breathing: [4, 40] });
|
||||
|
||||
/** Why a device-reported vital looks implausible, or null. */
|
||||
export function plausibilityOf(kind, value) {
|
||||
if (value == null || !RANGES[kind]) return null;
|
||||
const [lo, hi] = RANGES[kind];
|
||||
return value < lo || value > hi ? `outside ${lo}–${hi} bpm` : null;
|
||||
}
|
||||
|
||||
const SPARK = '▁▂▃▄▅▆▇█';
|
||||
/** Unicode sparkline of the last `width` finite values ('' below two points). */
|
||||
export function sparkline(values, width = 16) {
|
||||
const v = (values || []).filter((x) => typeof x === 'number' && Number.isFinite(x)).slice(-width);
|
||||
if (v.length < 2) return '';
|
||||
const lo = Math.min(...v);
|
||||
const hi = Math.max(...v);
|
||||
return v.map((x) => SPARK[hi === lo ? 3 : Math.round(((x - lo) / (hi - lo)) * 7)]).join('');
|
||||
}
|
||||
|
||||
/** "12s ago" / "3m ago" from a millisecond age, or '' when unknown. */
|
||||
export function ageOf(ms) {
|
||||
if (!Number.isFinite(ms) || ms < 0) return '';
|
||||
const s = Math.round(ms / 1000);
|
||||
return s < 60 ? `${s}s ago` : `${Math.round(s / 60)}m ago`;
|
||||
}
|
||||
|
||||
/** The pane's model: plain data, drawn by the views and summarised by statusOf. */
|
||||
export function modelOf(capture, radar, at) {
|
||||
const alerts = [];
|
||||
// "No packets" is shown inside the nodes card; only real faults become alerts.
|
||||
if (capture && capture.ok === false && capture.reason !== 'no_packets') {
|
||||
alerts.push({ level: 'bad', text: `nodes: ${String(capture.reason).replace(/_/g, ' ')}${capture.remedy ? ` — ${capture.remedy}` : ''}` });
|
||||
}
|
||||
for (const hb of capture?.heartbeatOnlySenders || []) alerts.push({ level: 'warn', text: `${hb.address} sends heartbeats but no CSI (run \`ruview monitor --baud 1500000\` on it: "lack of csi buf" = buffer starvation)` });
|
||||
if (radar && radar.ok === false) alerts.push({ level: 'bad', text: `radar: ${String(radar.reason).replace(/_/g, ' ')}${radar.detail ? ` — ${radar.detail}` : ''}` });
|
||||
const nodes = (capture?.nodes || []).map((n) => ({
|
||||
key: `${n.source}:${n.nodeId}`,
|
||||
label: `${n.source === 'realtek' ? 'realtek' : 'esp32'} ${n.nodeId}`,
|
||||
rateHz: num(n.csiRateHz),
|
||||
rate: `${fixed(n.csiRateHz)} Hz`,
|
||||
loss: pct(n.csiLossFraction),
|
||||
rssi: `${fixed(n.rssiMean)} dBm`,
|
||||
shape: n.csi?.shape || '—',
|
||||
lossy: typeof n.csiLossFraction === 'number' && n.csiLossFraction > 0.05,
|
||||
synthetic: Boolean(n.csi?.synthetic),
|
||||
}));
|
||||
const spectrum = (capture?.spectrum || []).map((s) => ({
|
||||
key: `${s.source}:${s.nodeId}`,
|
||||
label: `${s.source === 'realtek' ? 'realtek' : 'esp32'} ${s.nodeId}`,
|
||||
subcarriers: s.subcarriers, bins: s.bins, frames: Array.isArray(s.frames) ? s.frames : [],
|
||||
rateHz: num(s.rateHz), synthetic: Boolean(s.synthetic),
|
||||
}));
|
||||
const radarRow = radar && radar.ok !== false ? {
|
||||
name: radar.device?.name || radar.host || 'radar',
|
||||
present: radar.presentNow ?? (radar.presentFraction == null ? null : radar.presentFraction >= 0.5),
|
||||
targets: num(radar.targetsMax),
|
||||
distanceCm: num(radar.distanceCmMean),
|
||||
heartBpm: num(radar.heartBpmMean),
|
||||
breathingBpm: num(radar.breathingBpmMean),
|
||||
distance: radar.distanceCmMean == null ? '—' : `${fixed(radar.distanceCmMean)} cm`,
|
||||
heart: radar.heartBpmMean == null ? '—' : `${fixed(radar.heartBpmMean)} bpm`,
|
||||
breathing: radar.breathingBpmMean == null ? '—' : `${fixed(radar.breathingBpmMean)} bpm`,
|
||||
} : null;
|
||||
return {
|
||||
at, packets: capture?.packets ?? 0, decoded: capture?.decodedPackets ?? 0,
|
||||
noPackets: capture?.reason === 'no_packets', nodes, spectrum, radar: radarRow, alerts,
|
||||
};
|
||||
}
|
||||
|
||||
/** Append one model to a bounded history of trend values. */
|
||||
export function historyWith(history, model, max = HISTORY) {
|
||||
const h = history || { heart: [], breathing: [], distance: [], rates: {} };
|
||||
const cap = (arr, v) => [...arr, v].slice(-max);
|
||||
const rates = { ...h.rates };
|
||||
for (const n of model?.nodes || []) rates[n.key] = cap(rates[n.key] || [], n.rateHz);
|
||||
return {
|
||||
heart: cap(h.heart, model?.radar?.heartBpm ?? null),
|
||||
breathing: cap(h.breathing, model?.radar?.breathingBpm ?? null),
|
||||
distance: cap(h.distance, model?.radar?.distanceCm ?? null),
|
||||
rates,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Carry each node's waterfall frames across captures, so the picture scrolls
|
||||
* instead of restarting every refresh. A node whose bin count changes starts
|
||||
* over; a node absent from this capture keeps its frames.
|
||||
*/
|
||||
export function spectrumWith(previous, spectrum, max = 128) {
|
||||
const byKey = new Map((previous || []).map((s) => [s.key, s]));
|
||||
for (const s of spectrum || []) {
|
||||
const old = byKey.get(s.key);
|
||||
const frames = old && old.bins === s.bins ? [...old.frames, ...s.frames] : [...s.frames];
|
||||
byKey.set(s.key, { ...s, frames: frames.slice(-max) });
|
||||
}
|
||||
return [...byKey.values()];
|
||||
}
|
||||
|
||||
/** One line for the status bar. */
|
||||
export function statusOf(model) {
|
||||
if (!model) return 'RuView · starting';
|
||||
const parts = [`RuView · ${model.nodes.length} node${model.nodes.length === 1 ? '' : 's'}`];
|
||||
if (model.radar) parts.push(`radar ${model.radar.present == null ? '?' : model.radar.present ? 'present' : 'clear'}`);
|
||||
if (model.alerts.length) parts.push(`${model.alerts.length} alert${model.alerts.length === 1 ? '' : 's'}`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -1,269 +1,64 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// ruview-live — a Claude Code mod (function hooks) shipped inside @ruvnet/ruview.
|
||||
//
|
||||
// `/ruview` opens a pane beside the transcript with the CSI nodes streaming to
|
||||
// this machine and, optionally, an ESPHome radar kit; it refreshes on a timer
|
||||
// and keeps a one-line status. Every reading comes from the @ruvnet/ruview CLI
|
||||
// this mod ships beside (`--json`), so the pane shows exactly what the tested
|
||||
// tools return. Read-only: it never flashes, provisions or writes to a device.
|
||||
// `/ruview` opens a pane beside the transcript with three views: an overview of
|
||||
// the CSI nodes streaming to this machine and an optional ESPHome radar kit; a
|
||||
// live CSI amplitude waterfall; and a radar fan with vitals charts (ADR-377,
|
||||
// ADR-378). Every reading comes from the @ruvnet/ruview CLI this mod ships
|
||||
// beside (`--json`), so the pane shows exactly what the tested tools return.
|
||||
// Read-only: it never flashes, provisions or writes to a device.
|
||||
|
||||
import { commandsOf, cliPathOf, historyWith, MODES, modelOf, resultOf, settingsOf, spectrumWith, statusOf } from './model.mjs';
|
||||
import { drain } from './anim.mjs';
|
||||
import { ANIMATED, picturesOf, sizesOf, viewOf } from './views.mjs';
|
||||
|
||||
export * from './anim.mjs';
|
||||
export * from './model.mjs';
|
||||
export * from './raster.mjs';
|
||||
export { ANIMATED, picturesOf, sizesOf, viewOf } from './views.mjs';
|
||||
|
||||
export const PANE_ID = 'ruview-live';
|
||||
export const COMMAND = 'ruview';
|
||||
const HOST_RE = /^[A-Za-z0-9][A-Za-z0-9.-]{0,252}$/;
|
||||
const HISTORY = 24;
|
||||
const TICK_MS = 5000;
|
||||
/** Animation frame period: ~12 fps, well inside blit's 60 shown a second. */
|
||||
export const FRAME_MS = 80;
|
||||
|
||||
/**
|
||||
* The harness CLI beside this mod: the plugin root is <pkg>/mod, so the CLI is
|
||||
* <pkg>/bin/cli.js. Mods import nothing but their own files, so the engine's
|
||||
* `$.plugin.root` locates it.
|
||||
*/
|
||||
export function cliPathOf(pluginRoot) {
|
||||
return `${String(pluginRoot).replace(/[\\/]+$/, '')}/../bin/cli.js`;
|
||||
}
|
||||
|
||||
/** Normalise plugin options (userConfig) into bounded settings. */
|
||||
export function settingsOf(options = {}) {
|
||||
const num = (v, d, lo, hi) => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? Math.min(Math.max(Math.round(n), lo), hi) : d;
|
||||
};
|
||||
const host = typeof options.radarHost === 'string' ? options.radarHost.trim() : '';
|
||||
return {
|
||||
udpPort: num(options.udpPort, 5005, 1024, 65535),
|
||||
radarHost: HOST_RE.test(host) ? host : '',
|
||||
refreshMs: num(options.refreshSeconds, 15, 5, 3600) * 1000,
|
||||
captureSeconds: num(options.captureSeconds, 3, 1, 10),
|
||||
};
|
||||
}
|
||||
|
||||
/** argv for one capture and (optionally) one radar read. */
|
||||
export function commandsOf(settings) {
|
||||
const s = String(settings.captureSeconds);
|
||||
return {
|
||||
capture: ['esp32', '--seconds', s, '--udp-port', String(settings.udpPort), '--json'],
|
||||
radar: settings.radarHost ? ['mmwave', '--source', 'esphome', '--host', settings.radarHost, '--seconds', s, '--json'] : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse a CLI run into its JSON result, or an honest failure. */
|
||||
export function resultOf(run) {
|
||||
if (!run) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(run.stdout);
|
||||
if (parsed && typeof parsed === 'object') return parsed;
|
||||
} catch { /* fall through */ }
|
||||
return { ok: false, reason: 'cli_error', detail: String(run.stderr || run.stdout || `exit ${run.exitCode}`).trim().slice(0, 300) };
|
||||
}
|
||||
|
||||
const fixed = (v, d = 1) => (typeof v === 'number' && Number.isFinite(v) ? v.toFixed(d) : '—');
|
||||
const pct = (v) => (typeof v === 'number' ? `${(v * 100).toFixed(1)}%` : '—');
|
||||
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
||||
|
||||
/** Resting ranges outside which a device-reported vital is flagged, not trusted. */
|
||||
export const RANGES = Object.freeze({ heart: [40, 180], breathing: [4, 40] });
|
||||
|
||||
/** Why a device-reported vital looks implausible, or null. */
|
||||
export function plausibilityOf(kind, value) {
|
||||
if (value == null || !RANGES[kind]) return null;
|
||||
const [lo, hi] = RANGES[kind];
|
||||
return value < lo || value > hi ? `outside ${lo}–${hi} bpm` : null;
|
||||
}
|
||||
|
||||
const SPARK = '▁▂▃▄▅▆▇█';
|
||||
/** Unicode sparkline of the last `width` finite values ('' below two points). */
|
||||
export function sparkline(values, width = 16) {
|
||||
const v = (values || []).filter((x) => typeof x === 'number' && Number.isFinite(x)).slice(-width);
|
||||
if (v.length < 2) return '';
|
||||
const lo = Math.min(...v);
|
||||
const hi = Math.max(...v);
|
||||
return v.map((x) => SPARK[hi === lo ? 3 : Math.round(((x - lo) / (hi - lo)) * 7)]).join('');
|
||||
}
|
||||
|
||||
/** "12s ago" / "3m ago" from a millisecond age, or '' when unknown. */
|
||||
export function ageOf(ms) {
|
||||
if (!Number.isFinite(ms) || ms < 0) return '';
|
||||
const s = Math.round(ms / 1000);
|
||||
return s < 60 ? `${s}s ago` : `${Math.round(s / 60)}m ago`;
|
||||
}
|
||||
|
||||
/** The pane's model: plain data, drawn by viewOf and summarised by statusOf. */
|
||||
export function modelOf(capture, radar, at) {
|
||||
const alerts = [];
|
||||
// "No packets" is shown inside the nodes card; only real faults become alerts.
|
||||
if (capture && capture.ok === false && capture.reason !== 'no_packets') {
|
||||
alerts.push({ level: 'bad', text: `nodes: ${String(capture.reason).replace(/_/g, ' ')}${capture.remedy ? ` — ${capture.remedy}` : ''}` });
|
||||
}
|
||||
for (const hb of capture?.heartbeatOnlySenders || []) alerts.push({ level: 'warn', text: `${hb.address} sends heartbeats but no CSI (run \`ruview monitor --baud 1500000\` on it: "lack of csi buf" = buffer starvation)` });
|
||||
if (radar && radar.ok === false) alerts.push({ level: 'bad', text: `radar: ${String(radar.reason).replace(/_/g, ' ')}${radar.detail ? ` — ${radar.detail}` : ''}` });
|
||||
const nodes = (capture?.nodes || []).map((n) => ({
|
||||
key: `${n.source}:${n.nodeId}`,
|
||||
label: `${n.source === 'realtek' ? 'realtek' : 'esp32'} ${n.nodeId}`,
|
||||
rateHz: num(n.csiRateHz),
|
||||
rate: `${fixed(n.csiRateHz)} Hz`,
|
||||
loss: pct(n.csiLossFraction),
|
||||
rssi: `${fixed(n.rssiMean)} dBm`,
|
||||
shape: n.csi?.shape || '—',
|
||||
lossy: typeof n.csiLossFraction === 'number' && n.csiLossFraction > 0.05,
|
||||
synthetic: Boolean(n.csi?.synthetic),
|
||||
}));
|
||||
const radarRow = radar && radar.ok !== false ? {
|
||||
name: radar.device?.name || radar.host || 'radar',
|
||||
present: radar.presentNow ?? (radar.presentFraction == null ? null : radar.presentFraction >= 0.5),
|
||||
targets: num(radar.targetsMax),
|
||||
distanceCm: num(radar.distanceCmMean),
|
||||
heartBpm: num(radar.heartBpmMean),
|
||||
breathingBpm: num(radar.breathingBpmMean),
|
||||
distance: radar.distanceCmMean == null ? '—' : `${fixed(radar.distanceCmMean)} cm`,
|
||||
heart: radar.heartBpmMean == null ? '—' : `${fixed(radar.heartBpmMean)} bpm`,
|
||||
breathing: radar.breathingBpmMean == null ? '—' : `${fixed(radar.breathingBpmMean)} bpm`,
|
||||
} : null;
|
||||
return {
|
||||
at, packets: capture?.packets ?? 0, decoded: capture?.decodedPackets ?? 0,
|
||||
noPackets: capture?.reason === 'no_packets', nodes, radar: radarRow, alerts,
|
||||
};
|
||||
}
|
||||
|
||||
/** Append one model to a bounded history of trend values. */
|
||||
export function historyWith(history, model, max = HISTORY) {
|
||||
const h = history || { heart: [], breathing: [], distance: [], rates: {} };
|
||||
const cap = (arr, v) => [...arr, v].slice(-max);
|
||||
const rates = { ...h.rates };
|
||||
for (const n of model?.nodes || []) rates[n.key] = cap(rates[n.key] || [], n.rateHz);
|
||||
return {
|
||||
heart: cap(h.heart, model?.radar?.heartBpm ?? null),
|
||||
breathing: cap(h.breathing, model?.radar?.breathingBpm ?? null),
|
||||
distance: cap(h.distance, model?.radar?.distanceCm ?? null),
|
||||
rates,
|
||||
};
|
||||
}
|
||||
|
||||
/** One line for the status bar. */
|
||||
export function statusOf(model) {
|
||||
if (!model) return 'RuView · starting';
|
||||
const parts = [`RuView · ${model.nodes.length} node${model.nodes.length === 1 ? '' : 's'}`];
|
||||
if (model.radar) parts.push(`radar ${model.radar.present == null ? '?' : model.radar.present ? 'present' : 'clear'}`);
|
||||
if (model.alerts.length) parts.push(`${model.alerts.length} alert${model.alerts.length === 1 ? '' : 's'}`);
|
||||
return parts.join(' · ');
|
||||
/** Spectrum request for the waterfall: one bin per picture column (at most 128). */
|
||||
export function spectrumOf(columns, rows) {
|
||||
const { width, height } = sizesOf(columns, rows);
|
||||
return { bins: Math.max(8, Math.min(128, width)), frames: Math.max(8, Math.min(256, height * 2)) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the pane with the surface's own elements ({ Box, Text, Button }).
|
||||
* opts: { refreshMs, busy, onRefresh, onClose, columns, now, history, udpPort, radarConfigured }.
|
||||
*/
|
||||
export function viewOf(ui, model, opts) {
|
||||
const { Box, Text, Button } = ui;
|
||||
const { refreshMs, busy, onRefresh, onClose } = opts;
|
||||
const columns = opts.columns ?? 80;
|
||||
const history = opts.history || historyWith(null, null, 0);
|
||||
const t = (children, props = {}) => Text({ wrap: 'truncate-end', ...props, children });
|
||||
const row = (children, props = {}) => Box({ flexDirection: 'row', gap: 1, ...props, children: children.filter(Boolean) });
|
||||
const every = `every ${Math.round(refreshMs / 1000)}s`;
|
||||
|
||||
// Header: freshness badge, update time and age.
|
||||
const finiteAt = model && Number.isFinite(model.at);
|
||||
const age = finiteAt && Number.isFinite(opts.now) ? opts.now - model.at : NaN;
|
||||
const live = Number.isFinite(age) && age <= refreshMs * 2 + 5000;
|
||||
const when = finiteAt ? new Date(model.at).toLocaleTimeString() : '—';
|
||||
const badge = !model ? t('○ STARTING', { dimColor: true, bold: true })
|
||||
: live ? t('● LIVE', { color: 'green', bold: true }) : t('○ STALE', { color: 'yellow', bold: true });
|
||||
const header = row([
|
||||
t('RuView', { color: 'cyan', bold: true }),
|
||||
badge,
|
||||
t(model ? `updated ${when}${ageOf(age) ? ` (${ageOf(age)})` : ''} · ${every}` : `waiting for the first capture… · ${every}`, { dimColor: true }),
|
||||
busy ? t('⟳ refreshing', { color: 'cyan' }) : null,
|
||||
], { justifyContent: 'flex-start' });
|
||||
|
||||
const alerts = (model?.alerts || []).map((a) => Text({ color: a.level === 'bad' ? 'red' : 'yellow', wrap: 'wrap', children: `! ${a.text}` }));
|
||||
|
||||
// Radar card.
|
||||
const vital = (label, value, raw, kind, series, unitWidth) => {
|
||||
const warn = kind ? plausibilityOf(kind, raw) : null;
|
||||
return row([
|
||||
t(label.padEnd(10), { dimColor: true }),
|
||||
t(value.padStart(unitWidth), { bold: raw != null && !warn, color: warn ? 'yellow' : undefined }),
|
||||
t(sparkline(series), { color: 'cyan' }),
|
||||
warn ? t(`⚠ ${warn}`, { color: 'yellow' }) : null,
|
||||
]);
|
||||
};
|
||||
let radarCard = null;
|
||||
if (model?.radar) {
|
||||
const r = model.radar;
|
||||
const presence = r.present == null ? t('? presence unknown', { dimColor: true })
|
||||
: r.present ? t(`● PRESENCE DETECTED${r.targets ? ` · ${r.targets} target${r.targets === 1 ? '' : 's'}` : ''}`, { color: 'green', bold: true })
|
||||
: t('○ no presence', { dimColor: true });
|
||||
radarCard = card('60 GHz RADAR', r.name, r.present ? 'green' : 'gray', [
|
||||
presence,
|
||||
vital('distance', r.distance, r.distanceCm, null, history.distance, 9),
|
||||
vital('heart', r.heart, r.heartBpm, 'heart', history.heart, 9),
|
||||
vital('breathing', r.breathing, r.breathingBpm, 'breathing', history.breathing, 9),
|
||||
t('device-reported values, not validated against a reference', { dimColor: true, italic: true }),
|
||||
]);
|
||||
} else if (model && !opts.radarConfigured) {
|
||||
radarCard = card('60 GHz RADAR', 'not configured', 'gray', [
|
||||
t('Set radarHost to an ESPHome radar kit:', { dimColor: true }),
|
||||
t('claude plugin configure ruview-live', { color: 'cyan' }),
|
||||
]);
|
||||
}
|
||||
|
||||
// Nodes card.
|
||||
let nodesCard = null;
|
||||
if (model) {
|
||||
const lines = model.nodes.length
|
||||
? model.nodes.map((n) => row([
|
||||
t(n.label.padEnd(10), { bold: true }),
|
||||
t(n.rate.padStart(9)),
|
||||
t(sparkline(history.rates[n.key]), { color: 'cyan' }),
|
||||
t(`loss ${n.loss}`, n.lossy ? { color: 'yellow' } : { dimColor: true }),
|
||||
t(n.rssi, { dimColor: true }),
|
||||
t(n.shape, { dimColor: true }),
|
||||
n.synthetic ? t('SYNTHETIC', { color: 'yellow', bold: true }) : null,
|
||||
]))
|
||||
: [
|
||||
t('none streaming to this machine', { dimColor: true }),
|
||||
model.noPackets ? t(`check node target IP/port · firewall UDP ${opts.udpPort ?? 5005}`, { dimColor: true }) : null,
|
||||
].filter(Boolean);
|
||||
nodesCard = card('CSI NODES', `UDP ${opts.udpPort ?? 5005} · ${model.decoded}/${model.packets} decoded`, model.nodes.length ? 'cyan' : 'gray', lines);
|
||||
}
|
||||
|
||||
function card(title, subtitle, borderColor, children) {
|
||||
return Box({
|
||||
flexDirection: 'column', borderStyle: 'round', borderColor, paddingX: 1, flexGrow: 1,
|
||||
children: [row([t(title, { bold: true }), t(subtitle, { dimColor: true })]), ...children],
|
||||
});
|
||||
}
|
||||
|
||||
const wide = columns >= 100;
|
||||
const cards = [radarCard, nodesCard].filter(Boolean);
|
||||
const body = cards.length ? Box({ flexDirection: wide ? 'row' : 'column', gap: 1, children: cards }) : null;
|
||||
const footer = row([
|
||||
Button({ key: 'refresh', label: busy ? 'Refreshing…' : 'Refresh (r)', hotkey: 'r', onPress: onRefresh }),
|
||||
Button({ key: 'close', label: 'Close (c)', hotkey: 'c', onPress: onClose }),
|
||||
], { gap: 2 });
|
||||
|
||||
return Box({ flexDirection: 'column', gap: 1, paddingX: 1, children: [header, ...alerts, body, footer].filter(Boolean) });
|
||||
}
|
||||
|
||||
/**
|
||||
* The mod entry: `/ruview [off|refresh]`, the pane, the timer, the status.
|
||||
* The mod entry: `/ruview [off|refresh|overview|waterfall|radar]`, the pane,
|
||||
* the timers, the status.
|
||||
* @param on the engine's registrar
|
||||
* @param options this plugin's userConfig values
|
||||
*/
|
||||
export function register(on, options = {}) {
|
||||
const settings = settingsOf(options);
|
||||
const commands = commandsOf(settings);
|
||||
let host = null;
|
||||
let isOpen = false;
|
||||
let busy = false;
|
||||
let model = null;
|
||||
let history = null;
|
||||
let spectrum = [];
|
||||
let mode = 'overview';
|
||||
let nodeIndex = 0;
|
||||
let size = { columns: 100, rows: 30 };
|
||||
let stopTimer = null;
|
||||
let stopTick = null;
|
||||
let stopAnim = null;
|
||||
let animating = false;
|
||||
let lastFrame = null;
|
||||
let lag = {};
|
||||
let cliPath = null;
|
||||
|
||||
const intervalOf = () => (mode === 'overview' ? settings.refreshMs : settings.liveRefreshMs);
|
||||
const stop = () => {
|
||||
if (stopTimer) { stopTimer(); stopTimer = null; }
|
||||
if (stopTick) { stopTick(); stopTick = null; }
|
||||
if (stopAnim) { stopAnim(); stopAnim = null; }
|
||||
};
|
||||
const cancelOf = (t) => (typeof t === 'function' ? t : () => t?.cancel?.());
|
||||
|
||||
@@ -281,10 +76,20 @@ export function register(on, options = {}) {
|
||||
busy = true;
|
||||
host.invalidate();
|
||||
try {
|
||||
const commands = commandsOf(settings, {
|
||||
live: mode !== 'overview',
|
||||
spectrum: mode === 'waterfall' ? spectrumOf(size.columns, size.rows) : null,
|
||||
});
|
||||
const [capture, radar] = await Promise.all([runCli(commands.capture), runCli(commands.radar)]);
|
||||
// $.clock.now() resolves a promise of epoch milliseconds.
|
||||
model = modelOf(resultOf(capture), resultOf(radar), await host.now());
|
||||
history = historyWith(history, model);
|
||||
spectrum = spectrumWith(spectrum, model.spectrum);
|
||||
// New frames join the replay queue: shown at their arrival rate, not at once.
|
||||
for (const s of model.spectrum) {
|
||||
const held = spectrum.find((x) => x.key === s.key)?.frames.length ?? 0;
|
||||
lag[s.key] = Math.min(held, (lag[s.key] ?? 0) + s.frames.length);
|
||||
}
|
||||
host.status(statusOf(model));
|
||||
} finally {
|
||||
busy = false;
|
||||
@@ -292,16 +97,52 @@ export function register(on, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Start the refresh timer (and the age tick) and fetch now, unless already polling. */
|
||||
/** Options shared by the full drawing and each animation frame. */
|
||||
const drawOpts = (t) => ({ mode, nodeIndex, lag, history, t, columns: size.columns, rows: size.rows });
|
||||
const shownModel = () => (model ? { ...model, spectrum } : null);
|
||||
|
||||
/** One animation frame: repaint each mounted animated Raster in place. */
|
||||
async function animate() {
|
||||
if (!host || animating) return;
|
||||
animating = true;
|
||||
try {
|
||||
// Frame time advances by the frame period: no clock round trip per frame.
|
||||
const t = (lastFrame ?? 0) + FRAME_MS;
|
||||
const dt = FRAME_MS;
|
||||
lastFrame = t;
|
||||
for (const s of spectrum) lag[s.key] = drain(lag[s.key], s.rateHz, dt);
|
||||
const pics = picturesOf(shownModel(), drawOpts(t));
|
||||
// Fire and forget: a blit resolves only once a frame is painted, and the
|
||||
// surface folds blits between frames anyway, so nothing waits on one.
|
||||
for (const key of ANIMATED) {
|
||||
if (!pics[key]) continue;
|
||||
const { columns, rows, cells } = pics[key].grid.toRaster(key);
|
||||
Promise.resolve(host.blit({ requestId: PANE_ID, key, cells, columns, rows })).catch(() => undefined);
|
||||
}
|
||||
} finally {
|
||||
animating = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Start the refresh timer, the age tick and the animation, and fetch now, unless already polling. */
|
||||
function startPolling() {
|
||||
isOpen = true;
|
||||
if (stopTimer) return;
|
||||
stopTimer = cancelOf(host.every(settings.refreshMs, () => { void refresh(); }));
|
||||
stopTimer = cancelOf(host.every(intervalOf(), () => { void refresh(); }));
|
||||
stopTick = cancelOf(host.every(TICK_MS, () => host.invalidate()));
|
||||
stopAnim = cancelOf(host.every(FRAME_MS, () => { void animate(); }));
|
||||
void refresh();
|
||||
}
|
||||
|
||||
async function open() {
|
||||
/** Switch view; the live views poll faster, so the timer restarts. */
|
||||
function setMode(next) {
|
||||
if (!MODES.includes(next) || next === mode) return;
|
||||
mode = next;
|
||||
if (isOpen && host) { stop(); startPolling(); } else host?.invalidate();
|
||||
}
|
||||
|
||||
async function open(initialMode) {
|
||||
if (MODES.includes(initialMode)) mode = initialMode;
|
||||
await host.open({ id: PANE_ID, title: 'RuView', closeOnEscape: true });
|
||||
stop();
|
||||
startPolling();
|
||||
@@ -323,19 +164,24 @@ export function register(on, options = {}) {
|
||||
close: (pane) => $.ui.close(pane),
|
||||
status: (text) => $.ui.status(text),
|
||||
invalidate: () => $.ui.invalidate('ui.render'),
|
||||
blit: (args) => $.ui.blit(args),
|
||||
};
|
||||
await $.command.register({ name: COMMAND, description: 'RuView live sensing pane: CSI nodes and radar, refreshed', argumentHint: '[off|refresh]' }).catch(() => undefined);
|
||||
await $.command.register({ name: COMMAND, description: 'RuView live sensing pane: CSI nodes, CSI waterfall and radar', argumentHint: '[waterfall|radar|refresh|off]' }).catch(() => undefined);
|
||||
return next(e);
|
||||
});
|
||||
|
||||
on('command.run', { command: COMMAND }, async ($, e, next) => {
|
||||
if (!host) return next(e);
|
||||
const arg = String(e.args || '').trim();
|
||||
const arg = String(e.args || '').trim().toLowerCase();
|
||||
if (arg === 'off') { await close(); host.status(undefined); return { text: 'RuView pane closed.' }; }
|
||||
if (arg === 'refresh') { await refresh(); return { text: statusOf(model) }; }
|
||||
if (MODES.includes(arg)) {
|
||||
if (isOpen) setMode(arg); else await open(arg);
|
||||
return { text: `RuView pane: ${arg} view.` };
|
||||
}
|
||||
if (isOpen) { await close(); return { text: 'RuView pane closed.' }; }
|
||||
await open();
|
||||
return { text: `RuView pane open: nodes on UDP ${settings.udpPort}${settings.radarHost ? `, radar ${settings.radarHost}` : ''}; refreshing every ${Math.round(settings.refreshMs / 1000)}s.` };
|
||||
return { text: `RuView pane open: nodes on UDP ${settings.udpPort}${settings.radarHost ? `, radar ${settings.radarHost}` : ''}; keys 1 overview · 2 CSI waterfall · 3 radar.` };
|
||||
});
|
||||
|
||||
on('ui.render', { component: 'Pane' }, async ($, e, next) => {
|
||||
@@ -344,13 +190,19 @@ export function register(on, options = {}) {
|
||||
// variables while the engine keeps the pane open: drawing it means it is
|
||||
// open, so resume polling instead of waiting for the first capture forever.
|
||||
if (host && !stopTimer) startPolling();
|
||||
size = {
|
||||
columns: e.props?.bodyColumns ?? e.viewport?.columns ?? 100,
|
||||
rows: e.props?.scroll?.bodyRows ?? e.viewport?.rows ?? 30,
|
||||
};
|
||||
const ui = await $.ui.resolve(e);
|
||||
const now = await $.clock.now();
|
||||
return viewOf(ui, model, {
|
||||
refreshMs: settings.refreshMs, busy, history, now,
|
||||
columns: e.props?.bodyColumns ?? e.viewport?.columns ?? 80,
|
||||
return viewOf(ui, shownModel(), {
|
||||
...drawOpts(now), refreshMs: settings.refreshMs, liveRefreshMs: settings.liveRefreshMs, busy, now,
|
||||
udpPort: settings.udpPort, radarConfigured: Boolean(settings.radarHost),
|
||||
onRefresh: () => { void refresh(); }, onClose: () => { void close(); },
|
||||
onRefresh: () => { void refresh(); },
|
||||
onClose: () => { void close(); },
|
||||
onMode: (m) => setMode(m),
|
||||
onNextNode: () => { nodeIndex += 1; host?.invalidate(); },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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] });
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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']);
|
||||
|
||||
|
||||
@@ -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); });
|
||||
|
||||
@@ -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 = {}) {
|
||||
|
||||
@@ -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 }), []);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
Reference in new issue
Block a user