diff --git a/harness/ruview/mod/hooks/register.mjs b/harness/ruview/mod/hooks/register.mjs index 1e3070463..4c42ca945 100644 --- a/harness/ruview/mod/hooks/register.mjs +++ b/harness/ruview/mod/hooks/register.mjs @@ -10,6 +10,8 @@ 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; /** * The harness CLI beside this mod: the plugin root is /mod, so the CLI is @@ -56,15 +58,48 @@ export function resultOf(run) { 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 = []; - if (capture && capture.ok === false) alerts.push({ level: capture.reason === 'no_packets' ? 'warn' : 'bad', text: `nodes: ${String(capture.reason).replace(/_/g, ' ')}${capture.remedy ? ` — ${capture.remedy}` : ''}` }); + // "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`, @@ -75,11 +110,32 @@ export function modelOf(capture, radar, at) { 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, nodes, radar: radarRow, alerts }; + 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. */ @@ -91,36 +147,101 @@ export function statusOf(model) { return parts.join(' · '); } -/** Draw the pane with the surface's own elements ({ Box, Text, Button }). */ -export function viewOf(ui, model, { refreshMs, busy, onRefresh, onClose }) { +/** + * 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 line = (children, props = {}) => Text({ ...props, wrap: 'truncate-end', children }); - const rows = []; - const when = model && Number.isFinite(model.at) ? new Date(model.at).toLocaleTimeString() : '—'; - rows.push(line(model ? `updated ${when} · every ${Math.round(refreshMs / 1000)}s · ${model.decoded}/${model.packets} packets decoded` : 'waiting for the first capture…', { dimColor: true })); - for (const a of model?.alerts || []) rows.push(Text({ color: a.level === 'bad' ? 'red' : 'yellow', wrap: 'wrap', children: `! ${a.text}` })); - if (model) { - rows.push(line('NODES', { bold: true })); - if (!model.nodes.length) rows.push(line(' none streaming to this machine', { dimColor: true })); - for (const n of model.nodes) { - rows.push(Box({ flexDirection: 'row', gap: 2, children: [ - line(n.label.padEnd(10)), line(n.rate.padStart(9)), - line(`loss ${n.loss}`, n.lossy ? { color: 'yellow' } : {}), line(n.rssi), line(n.shape, { dimColor: true }), - n.synthetic ? line('SYNTHETIC', { color: 'yellow' }) : null, - ].filter(Boolean) })); - } - if (model.radar) { - const r = model.radar; - rows.push(line(`RADAR ${r.name}`, { bold: true })); - rows.push(line(` presence ${r.present == null ? '—' : r.present ? '● detected' : '○ none'} · distance ${r.distance} · heart ${r.heart} · breathing ${r.breathing}`, r.present ? { color: 'green' } : {})); - rows.push(line(' device-reported values, not validated against a reference', { dimColor: true })); - } + 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' }), + ]); } - rows.push(Box({ flexDirection: 'row', gap: 2, children: [ - Button({ key: 'refresh', label: busy ? 'Refreshing…' : 'Refresh', hotkey: 'r', onPress: onRefresh }), - Button({ key: 'close', label: 'Close', hotkey: 'c', onPress: onClose }), - ] })); - return Box({ flexDirection: 'column', gap: 0, padding: 1, children: rows }); + + // 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) }); } /** @@ -135,10 +256,15 @@ export function register(on, options = {}) { let isOpen = false; let busy = false; let model = null; + let history = null; let stopTimer = null; + let stopTick = null; let cliPath = null; - const stop = () => { if (stopTimer) { stopTimer(); stopTimer = null; } }; + const stop = () => { + if (stopTimer) { stopTimer(); stopTimer = null; } + if (stopTick) { stopTick(); stopTick = null; } + }; const cancelOf = (t) => (typeof t === 'function' ? t : () => t?.cancel?.()); async function runCli(args) { @@ -158,6 +284,7 @@ export function register(on, options = {}) { 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); host.status(statusOf(model)); } finally { busy = false; @@ -165,11 +292,12 @@ export function register(on, options = {}) { } } - /** Start the refresh timer and fetch now, unless already polling. */ + /** Start the refresh timer (and the age tick) and fetch now, unless already polling. */ function startPolling() { isOpen = true; if (stopTimer) return; stopTimer = cancelOf(host.every(settings.refreshMs, () => { void refresh(); })); + stopTick = cancelOf(host.every(TICK_MS, () => host.invalidate())); void refresh(); } @@ -217,7 +345,13 @@ export function register(on, options = {}) { // open, so resume polling instead of waiting for the first capture forever. if (host && !stopTimer) startPolling(); const ui = await $.ui.resolve(e); - return viewOf(ui, model, { refreshMs: settings.refreshMs, busy, onRefresh: () => { void refresh(); }, onClose: () => { void close(); } }); + const now = await $.clock.now(); + return viewOf(ui, model, { + refreshMs: settings.refreshMs, busy, history, now, + columns: e.props?.bodyColumns ?? e.viewport?.columns ?? 80, + udpPort: settings.udpPort, radarConfigured: Boolean(settings.radarHost), + onRefresh: () => { void refresh(); }, onClose: () => { void close(); }, + }); }); on('ui.close', { id: PANE_ID }, async ($, e, next) => { diff --git a/harness/ruview/mod/tests/register.test.ts b/harness/ruview/mod/tests/register.test.ts index 78fe59368..d9d5b6b84 100644 --- a/harness/ruview/mod/tests/register.test.ts +++ b/harness/ruview/mod/tests/register.test.ts @@ -130,7 +130,7 @@ describe('register', () => { await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' }) const { text } = await $.command.run({ command: 'ruview', args: 'refresh', origin: { kind: 'composer' } }) - expect(text).toBe('RuView · 0 nodes · 1 alert') - expect(statuses.at(-1)).toBe('RuView · 0 nodes · 1 alert') + expect(text).toBe('RuView · 0 nodes') + expect(statuses.at(-1)).toBe('RuView · 0 nodes') }) }) diff --git a/harness/ruview/test/mod.test.mjs b/harness/ruview/test/mod.test.mjs index 748436a13..98c458fbe 100644 --- a/harness/ruview/test/mod.test.mjs +++ b/harness/ruview/test/mod.test.mjs @@ -3,7 +3,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; -import { cliPathOf, commandsOf, modelOf, resultOf, settingsOf, statusOf, viewOf } from '../mod/hooks/register.mjs'; +import { ageOf, cliPathOf, commandsOf, historyWith, modelOf, plausibilityOf, resultOf, settingsOf, sparkline, statusOf, viewOf } from '../mod/hooks/register.mjs'; const capture = { ok: true, packets: 120, decodedPackets: 118, @@ -62,7 +62,7 @@ test('the pane draws with the surface elements and wires both buttons', () => { walk(tree); const texts = flat.filter((n) => n.type === 'Text').map((n) => String(n.props.children)); assert.ok(texts.some((t) => t.includes('heartbeats but no CSI'))); - assert.ok(texts.some((t) => t.startsWith('RADAR'))); + assert.ok(texts.some((t) => t === '60 GHz RADAR')); assert.ok(texts.some((t) => t.includes('not validated'))); assert.ok(texts.some((t) => t === 'SYNTHETIC')); const buttons = flat.filter((n) => n.type === 'Button'); @@ -89,6 +89,59 @@ test('refresh awaits the clock before building the model', () => { assert.match(src, /model = modelOf\(.*await host\.now\(\)\);/); }); +test('sparklines, plausibility, ages and history are bounded and honest', () => { + assert.equal(sparkline([1, 2, 3, 4, 5, 6, 7, 8]), '▁▂▃▄▅▆▇█'); + assert.equal(sparkline([5]), '', 'one point is not a trend'); + assert.equal(sparkline([null, 3, NaN, 3]), '▄▄'); + assert.equal(plausibilityOf('breathing', 1.0), 'outside 4–40 bpm', 'the live 1.0 bpm reading is flagged'); + assert.equal(plausibilityOf('heart', 80.7), null); + assert.equal(plausibilityOf('heart', 250), 'outside 40–180 bpm'); + assert.equal(plausibilityOf('breathing', null), null); + assert.equal(ageOf(12_400), '12s ago'); + 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); +}); + +test('the redesigned pane: badge, cards side by side when wide, flags implausible vitals', () => { + const el = (type) => (props) => ({ type, props }); + const ui = { Box: el('Box'), Text: el('Text'), Button: el('Button') }; + const live = { ...radar, breathingBpmMean: 1.0, heartBpmMean: 80.7, distanceCmMean: 40.7, targetsMax: 1 }; + const model = modelOf(capture, live, 1_000_000); + const base = { refreshMs: 15000, busy: false, onRefresh() {}, onClose() {}, udpPort: 5005, radarConfigured: true }; + const flat = (node, out = []) => { if (node && typeof node === 'object') { out.push(node); [].concat(node.props?.children ?? []).forEach((c) => flat(c, out)); } return out; }; + const wide = flat(viewOf(ui, model, { ...base, columns: 140, now: 1_012_000 })); + const texts = wide.filter((n) => n.type === 'Text').map((n) => String(n.props.children)); + assert.ok(texts.includes('● LIVE')); + assert.ok(texts.some((t) => t.includes('(12s ago)'))); + assert.ok(texts.some((t) => t.startsWith('● PRESENCE DETECTED · 1 target'))); + assert.ok(texts.includes('⚠ outside 4–40 bpm'), 'breathing 1.0 bpm is flagged'); + assert.ok(!texts.includes('⚠ outside 40–180 bpm'), 'heart 80.7 bpm is not flagged'); + const cardsRow = wide.find((n) => n.type === 'Box' && n.props.children?.some?.((c) => c?.props?.borderStyle === 'round')); + assert.equal(cardsRow.props.flexDirection, 'row', 'cards sit side by side when wide'); + const narrow = flat(viewOf(ui, model, { ...base, columns: 80, now: 1_012_000 })); + assert.equal(narrow.find((n) => n.type === 'Box' && n.props.children?.some?.((c) => c?.props?.borderStyle === 'round')).props.flexDirection, 'column'); + const stale = flat(viewOf(ui, model, { ...base, columns: 80, now: 1_000_000 + 120_000 })); + assert.ok(stale.some((n) => n.type === 'Text' && n.props.children === '○ STALE')); + for (const n of wide) assert.ok(!('flexBasis' in (n.props || {})), 'only props the terminal Box takes'); +}); + +test('no packets is a hint inside the nodes card, not a top alert', () => { + const el = (type) => (props) => ({ type, props }); + const ui = { Box: el('Box'), Text: el('Text'), Button: el('Button') }; + const m = modelOf({ ok: false, reason: 'no_packets', packets: 0, remedy: 'long remedy text' }, null, 0); + assert.equal(m.alerts.length, 0); + assert.equal(statusOf(m), 'RuView · 0 nodes'); + const s = JSON.stringify(viewOf(ui, m, { refreshMs: 15000, busy: false, onRefresh() {}, onClose() {}, udpPort: 5005, radarConfigured: false, columns: 80, now: 0 })); + assert.match(s, /check node target IP\/port · firewall UDP 5005/); + assert.match(s, /claude plugin configure ruview-live/, 'an unconfigured radar says how to set it'); + assert.doesNotMatch(s, /long remedy text/); +}); + test('the mod manifest and hooks module are a valid plugin shape', () => { const plugin = JSON.parse(readFileSync(new URL('../mod/.claude-plugin/plugin.json', import.meta.url), 'utf8')); const hooks = JSON.parse(readFileSync(new URL('../mod/hooks/hooks.json', import.meta.url), 'utf8'));