chore(sensing-server/static): keep only raw.html, drop duplicates
Operator request: only one UI page open. raw.html (ADR-099 console, extended in ADR-101 with per-node classification badges) covers all live-debug use cases. mobile.html / spectrum.html / calibrate.html were either superseded or never adopted in the field — removing them reduces the surface that has to track ADR-101/102 contract changes. raw.html stays at /static/raw.html on the existing :8080 listener.
This commit is contained in:
parent
7535dff3e4
commit
7185ead826
|
|
@ -1,114 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>RuView — Sensor Placement Calibration</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
body { margin:0; padding:24px; font-family:-apple-system,Inter,system-ui,sans-serif;
|
||||
background:#0d1117; color:#e6edf3; }
|
||||
h1 { font-size:18px; font-weight:600; margin:0 0 4px; }
|
||||
.sub { font-size:12px; color:#888; margin:0 0 24px; }
|
||||
.node { background:#161b22; border:1px solid #30363d; border-radius:8px;
|
||||
padding:16px 20px; margin-bottom:12px; }
|
||||
.node .head { display:flex; justify-content:space-between; align-items:baseline; margin-bottom:8px; }
|
||||
.node .name { font-weight:600; }
|
||||
.node .ip { color:#888; font-size:12px; font-family:JetBrains Mono,monospace; }
|
||||
.metric { display:flex; align-items:center; gap:12px; margin:8px 0; }
|
||||
.metric .label { width:90px; font-size:12px; color:#aaa; }
|
||||
.metric .bar { flex:1; height:16px; background:#21262d; border-radius:4px; overflow:hidden; position:relative; }
|
||||
.metric .fill { height:100%; transition:width 80ms linear; }
|
||||
.metric .val { width:75px; text-align:right; font-family:JetBrains Mono,monospace; font-size:13px; }
|
||||
.metric .max { width:70px; color:#999; font-size:11px; text-align:right; font-family:JetBrains Mono,monospace; }
|
||||
.fill.motion { background:linear-gradient(90deg,#1f6feb,#388bfd); }
|
||||
.fill.presence { background:linear-gradient(90deg,#238636,#3fb950); }
|
||||
.fill.rssi { background:linear-gradient(90deg,#d29922,#f0883e); }
|
||||
.legend { color:#666; font-size:11px; margin-top:14px; }
|
||||
.status { padding:8px 12px; background:#1c2128; border-radius:4px; font-size:12px;
|
||||
font-family:JetBrains Mono,monospace; color:#7ce38b; margin-bottom:16px; }
|
||||
.status.dis { color:#f85149; }
|
||||
.tip { background:#1a1f24; border-left:3px solid #1f6feb; padding:10px 14px; font-size:13px;
|
||||
color:#aaa; margin-top:16px; border-radius:4px; }
|
||||
button { background:#21262d; color:#e6edf3; border:1px solid #30363d; border-radius:6px;
|
||||
padding:6px 12px; font-size:12px; cursor:pointer; margin-left:8px; }
|
||||
button:hover { border-color:#58a6ff; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>RuView Sensor Placement Calibration</h1>
|
||||
<p class="sub">Live per-node motion / presence / rssi. Move sensors around and watch the bars.</p>
|
||||
<div id="status" class="status dis">disconnected</div>
|
||||
<div id="nodes"></div>
|
||||
<div class="tip">
|
||||
<b>Цель:</b> когда ты ходишь в нужной зоне, motion-бар должен подниматься <b>на обеих нодах одновременно</b>.
|
||||
Идеальная позиция — обе ноды по разные стороны от тебя, прямая линия между ними пересекает зону движения.
|
||||
Кликни <b>Reset peaks</b> чтобы сбросить пиковые значения и переоценить новую позицию.
|
||||
</div>
|
||||
<script>
|
||||
const peaks = {};
|
||||
const smoothed = {}; // EMA-smoothed values, ~1 s time constant
|
||||
const SMOOTH_ALPHA = 0.10;
|
||||
let lastFrameTs = Date.now();
|
||||
|
||||
function ensureNode(id, ip) {
|
||||
let el = document.getElementById('node-'+id);
|
||||
if (el) return el;
|
||||
el = document.createElement('div');
|
||||
el.id = 'node-'+id; el.className = 'node';
|
||||
el.innerHTML = `
|
||||
<div class="head"><span class="name">Node ${id}</span>
|
||||
<span><span class="ip">${ip}</span>
|
||||
<button onclick="resetPeak(${id})">Reset peak</button></span></div>
|
||||
<div class="metric"><span class="label">motion</span><div class="bar"><div class="fill motion" id="m-${id}" style="width:0"></div></div>
|
||||
<span class="val" id="mv-${id}">0.000</span><span class="max" id="mx-${id}">↑0.000</span></div>
|
||||
<div class="metric"><span class="label">presence</span><div class="bar"><div class="fill presence" id="p-${id}" style="width:0"></div></div>
|
||||
<span class="val" id="pv-${id}">0.000</span><span class="max" id="px-${id}">↑0.000</span></div>
|
||||
<div class="metric"><span class="label">RSSI</span><div class="bar"><div class="fill rssi" id="r-${id}" style="width:0"></div></div>
|
||||
<span class="val" id="rv-${id}">--</span></div>`;
|
||||
document.getElementById('nodes').appendChild(el);
|
||||
peaks[id] = { motion: 0, presence: 0 };
|
||||
return el;
|
||||
}
|
||||
function resetPeak(id) { peaks[id] = { motion: 0, presence: 0 };
|
||||
document.getElementById('mx-'+id).textContent = '↑0.000';
|
||||
document.getElementById('px-'+id).textContent = '↑0.000'; }
|
||||
function update(id, m, p, rssi, ip) {
|
||||
ensureNode(id, ip || '');
|
||||
m = m || 0; p = p || 0;
|
||||
// EMA smooth so RF flicker doesn't make the bars jump
|
||||
if (!smoothed[id]) smoothed[id] = { motion: m, presence: p, rssi: rssi || -60 };
|
||||
smoothed[id].motion = (1 - SMOOTH_ALPHA) * smoothed[id].motion + SMOOTH_ALPHA * m;
|
||||
smoothed[id].presence = (1 - SMOOTH_ALPHA) * smoothed[id].presence + SMOOTH_ALPHA * p;
|
||||
if (rssi) smoothed[id].rssi = (1 - SMOOTH_ALPHA) * smoothed[id].rssi + SMOOTH_ALPHA * rssi;
|
||||
const sm = smoothed[id].motion, sp = smoothed[id].presence;
|
||||
peaks[id].motion = Math.max(peaks[id].motion, sm);
|
||||
peaks[id].presence = Math.max(peaks[id].presence, sp);
|
||||
document.getElementById('m-'+id).style.width = (Math.min(sm,1)*100).toFixed(0)+'%';
|
||||
document.getElementById('mv-'+id).textContent = sm.toFixed(3);
|
||||
document.getElementById('mx-'+id).textContent = '↑'+peaks[id].motion.toFixed(3);
|
||||
document.getElementById('p-'+id).style.width = (Math.min(sp,1)*100).toFixed(0)+'%';
|
||||
document.getElementById('pv-'+id).textContent = sp.toFixed(3);
|
||||
document.getElementById('px-'+id).textContent = '↑'+peaks[id].presence.toFixed(3);
|
||||
// RSSI in -90..-30 dBm range -> 0..100%
|
||||
const sr = smoothed[id].rssi;
|
||||
const rssiNorm = Math.max(0, Math.min(1, (sr + 90) / 60));
|
||||
document.getElementById('r-'+id).style.width = (rssiNorm*100).toFixed(0)+'%';
|
||||
document.getElementById('rv-'+id).textContent = sr.toFixed(0) + ' dBm';
|
||||
}
|
||||
function connect() {
|
||||
const ws = new WebSocket('ws://' + location.hostname + ':8765/ws/sensing');
|
||||
ws.onopen = () => { document.getElementById('status').textContent='connected ws://'+location.hostname+':8765'; document.getElementById('status').className='status'; };
|
||||
ws.onclose = () => { document.getElementById('status').textContent='disconnected — reconnecting in 2 s'; document.getElementById('status').className='status dis'; setTimeout(connect, 2000); };
|
||||
ws.onerror = () => ws.close();
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
const d = JSON.parse(e.data);
|
||||
if (d.type === 'edge_vitals') {
|
||||
update(d.node_id, d.motion_energy, d.presence_score, d.rssi);
|
||||
}
|
||||
} catch (_) {}
|
||||
};
|
||||
}
|
||||
connect();
|
||||
</script>
|
||||
</body></html>
|
||||
|
|
@ -1,311 +0,0 @@
|
|||
<!doctype html>
|
||||
<!--
|
||||
static/mobile.html — phone-first RuView UI.
|
||||
|
||||
Optimised for a 360-420 px wide screen held one-handed:
|
||||
- sticky global status header (badge stays visible while scrolling)
|
||||
- big readable per-node cards (28-34 px badges, 16 px stats)
|
||||
- one trace per card (broadband mean amplitude over last 30 s) — bars
|
||||
omitted because they're unreadable below 600 px wide
|
||||
- touch targets >= 44 px
|
||||
- no controls (reset is one-tap in the header)
|
||||
- high-contrast palette tuned for outdoor light
|
||||
|
||||
Talks to the same /ws/sensing WebSocket as raw.html, so any improvement
|
||||
to that contract lights up here automatically.
|
||||
-->
|
||||
<html lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no"/>
|
||||
<meta name="theme-color" content="#0a0e13"/>
|
||||
<meta name="apple-mobile-web-app-capable" content="yes"/>
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"/>
|
||||
<title>RuView — Mobile</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; --bg:#0a0e13; --card:#161b22; --line:#30363d;
|
||||
--fg:#e6edf3; --mute:#7d8590; --accent:#7cb6ff;
|
||||
--absent:#7d8590; --still:#58a6ff; --moving:#56d364; --active:#f85149; }
|
||||
|
||||
* { box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
|
||||
html, body { margin:0; padding:0; background:var(--bg); color:var(--fg);
|
||||
font-family:-apple-system,BlinkMacSystemFont,Inter,system-ui,sans-serif;
|
||||
-webkit-font-smoothing:antialiased; overscroll-behavior:none; }
|
||||
body { padding: env(safe-area-inset-top) env(safe-area-inset-right)
|
||||
env(safe-area-inset-bottom) env(safe-area-inset-left); }
|
||||
|
||||
header {
|
||||
position: sticky; top: 0; z-index: 10; background: var(--bg);
|
||||
padding: 10px 14px 8px; border-bottom: 1px solid var(--line);
|
||||
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
.titleRow { display:flex; align-items:center; justify-content:space-between; gap:8px; }
|
||||
.title { font-size:13px; font-weight:600; color:var(--mute); letter-spacing:.04em; text-transform:uppercase; }
|
||||
.conn { font-size:11px; font-family:ui-monospace,Menlo,Consolas,monospace;
|
||||
padding:3px 8px; border-radius:999px; background:#1c2128; color:var(--mute); }
|
||||
.conn.ok { background:#0e2a1a; color:#7ce38b; }
|
||||
.conn.dis { background:#3a1418; color:#ff6a6a; }
|
||||
|
||||
.globalRow { display:flex; align-items:center; gap:10px; margin-top:8px; }
|
||||
#globalBadge {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
padding: 14px 12px;
|
||||
border-radius: 12px;
|
||||
letter-spacing: .02em;
|
||||
text-transform: lowercase;
|
||||
background: #1c2128; color: var(--absent);
|
||||
transition: background .15s ease;
|
||||
}
|
||||
#globalBadge.absent { background:#1c2128; color:var(--absent); }
|
||||
#globalBadge.present_still { background:#0c2748; color:#9cccff; }
|
||||
#globalBadge.present_moving{ background:#1d3a0d; color:#a0e783; }
|
||||
#globalBadge.active { background:#430b0b; color:#ffa1a1; }
|
||||
|
||||
button.reset {
|
||||
flex: 0 0 auto;
|
||||
min-width: 60px;
|
||||
min-height: 44px;
|
||||
background: #21262d; color: var(--fg);
|
||||
border: 1px solid var(--line); border-radius: 10px;
|
||||
font-size: 13px; font-weight: 500;
|
||||
}
|
||||
button.reset:active { background:#30363d; }
|
||||
|
||||
main { padding: 12px 14px 24px; display:flex; flex-direction:column; gap:12px; }
|
||||
|
||||
.card {
|
||||
background: var(--card); border: 1px solid var(--line); border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.cardHead {
|
||||
display:flex; align-items:baseline; justify-content:space-between; gap:8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.cardHead .nid { font-size:13px; font-weight:600; color:var(--accent);
|
||||
font-family:ui-monospace,Menlo,Consolas,monospace; }
|
||||
.cardHead .stale { font-size:10px; color:#ff6a6a; }
|
||||
|
||||
.nodeBadge {
|
||||
display:block; width:100%; text-align:center;
|
||||
font-size: 22px; font-weight: 700;
|
||||
padding: 10px 8px; border-radius: 10px; margin-bottom: 10px;
|
||||
background: #1c2128; color: var(--absent);
|
||||
}
|
||||
.nodeBadge.absent { background:#1c2128; color:var(--absent); }
|
||||
.nodeBadge.present_still { background:#0c2748; color:#9cccff; }
|
||||
.nodeBadge.present_moving{ background:#1d3a0d; color:#a0e783; }
|
||||
.nodeBadge.active { background:#430b0b; color:#ffa1a1; }
|
||||
|
||||
.stats { display:grid; grid-template-columns: repeat(3, 1fr); gap:8px; margin-bottom: 10px; }
|
||||
.stat { background:#0d1117; border-radius:8px; padding:8px 10px; text-align:center; }
|
||||
.stat .v { font-size:18px; font-weight:600; color:var(--fg);
|
||||
font-family:ui-monospace,Menlo,Consolas,monospace; }
|
||||
.stat .l { font-size:10px; color:var(--mute); margin-top:2px;
|
||||
font-family:ui-monospace,Menlo,Consolas,monospace;
|
||||
text-transform: uppercase; letter-spacing:.05em; }
|
||||
|
||||
canvas.trace { display:block; width:100%; height:90px; background:#0d1117; border-radius:8px; }
|
||||
.traceCaption { font-size:10px; color:var(--mute); margin-top:4px;
|
||||
font-family:ui-monospace,Menlo,Consolas,monospace; }
|
||||
|
||||
.empty { color:var(--mute); text-align:center; padding:40px 16px; font-size:13px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="titleRow">
|
||||
<span class="title">RuView · mobile</span>
|
||||
<span id="conn" class="conn dis">disconnected</span>
|
||||
</div>
|
||||
<div class="globalRow">
|
||||
<div id="globalBadge" class="absent">absent</div>
|
||||
<button class="reset" onclick="resetState()" aria-label="Reset state">reset</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="nodes">
|
||||
<div class="empty" id="emptyMsg">waiting for first frame…</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// ── Constants ──────────────────────────────────────────────────────
|
||||
const TRACE_SEC = 30;
|
||||
const TRACE_MAX_PTS = 600;
|
||||
const STALE_MS = 3500; // ⇒ "stale" tag on the card
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────
|
||||
const state = new Map(); // node_id → { meanAmpHist, lastFrameWall, fps }
|
||||
|
||||
function resetState() {
|
||||
state.clear();
|
||||
document.getElementById('nodes').innerHTML =
|
||||
'<div class="empty" id="emptyMsg">waiting for first frame…</div>';
|
||||
}
|
||||
|
||||
// ── Per-node card factory ─────────────────────────────────────────
|
||||
function ensureCard(nodeId) {
|
||||
if (state.has(nodeId)) return state.get(nodeId);
|
||||
const empty = document.getElementById('emptyMsg');
|
||||
if (empty) empty.remove();
|
||||
const ent = {
|
||||
meanAmpHist: [], // { t, v }
|
||||
rssiHist: [],
|
||||
lastFrameWall: performance.now(),
|
||||
fps: 0,
|
||||
lastTs: 0,
|
||||
};
|
||||
state.set(nodeId, ent);
|
||||
const card = document.createElement('section');
|
||||
card.className = 'card'; card.id = 'card-' + nodeId;
|
||||
card.innerHTML = `
|
||||
<div class="cardHead">
|
||||
<span class="nid">node ${nodeId}</span>
|
||||
<span class="stale" id="n${nodeId}-stale"></span>
|
||||
</div>
|
||||
<div class="nodeBadge absent" id="n${nodeId}-badge">absent</div>
|
||||
<div class="stats">
|
||||
<div class="stat"><div class="v" id="n${nodeId}-rssi">--</div><div class="l">rssi dBm</div></div>
|
||||
<div class="stat"><div class="v" id="n${nodeId}-cv">0%</div><div class="l">conf</div></div>
|
||||
<div class="stat"><div class="v" id="n${nodeId}-fps">0</div><div class="l">fps</div></div>
|
||||
</div>
|
||||
<canvas class="trace" id="n${nodeId}-trace"></canvas>
|
||||
<p class="traceCaption">
|
||||
<span style="color:#3fb950">●</span> broadband amplitude
|
||||
<span style="color:#7d8590">●</span> rssi <span style="float:right">last ${TRACE_SEC}s</span>
|
||||
</p>`;
|
||||
document.getElementById('nodes').appendChild(card);
|
||||
return ent;
|
||||
}
|
||||
|
||||
// ── Drawing ────────────────────────────────────────────────────────
|
||||
function drawTrace(canvas, rssi, amp) {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = canvas.clientWidth, h = canvas.clientHeight;
|
||||
if (canvas.width !== w * dpr || canvas.height !== h * dpr) {
|
||||
canvas.width = w * dpr; canvas.height = h * dpr;
|
||||
}
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.fillStyle = '#0d1117'; ctx.fillRect(0, 0, w, h);
|
||||
|
||||
const now = performance.now() / 1000;
|
||||
const t0 = now - TRACE_SEC;
|
||||
|
||||
function plot(arr, color, fixedMin, fixedMax) {
|
||||
const v = arr.filter(p => p.t >= t0);
|
||||
if (v.length < 2) return null;
|
||||
let min, max;
|
||||
if (fixedMin != null) { min = fixedMin; max = fixedMax; }
|
||||
else { const vs = v.map(p => p.v); min = Math.min(...vs); max = Math.max(...vs); }
|
||||
const span = (max - min) || 1;
|
||||
ctx.strokeStyle = color; ctx.lineWidth = 1.8; ctx.beginPath();
|
||||
for (let i = 0; i < v.length; i++) {
|
||||
const p = v[i];
|
||||
const x = ((p.t - t0) / TRACE_SEC) * w;
|
||||
const y = h - ((p.v - min) / span) * (h - 12) - 6;
|
||||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
return { min, max };
|
||||
}
|
||||
|
||||
const ampR = plot(amp, '#3fb950', 0, null);
|
||||
const rssiR = plot(rssi, '#7d8590', null, null);
|
||||
|
||||
// Tiny corner labels
|
||||
ctx.font = '10px ui-monospace,Menlo,Consolas,monospace';
|
||||
if (ampR) { ctx.fillStyle = '#3fb950';
|
||||
ctx.fillText('A ' + ampR.max.toFixed(0), 4, 11); }
|
||||
if (rssiR) { ctx.fillStyle = '#7d8590';
|
||||
ctx.fillText('rssi ' + rssiR.min.toFixed(0) + '…' + rssiR.max.toFixed(0), 4, 23); }
|
||||
}
|
||||
|
||||
// ── Frame ingestion ───────────────────────────────────────────────
|
||||
function handleSensingUpdate(d) {
|
||||
const nodes = d.nodes || [];
|
||||
const now = performance.now() / 1000;
|
||||
for (const n of nodes) {
|
||||
const id = n.node_id;
|
||||
const ent = ensureCard(id);
|
||||
const amps = n.amplitude || [];
|
||||
const meanA = amps.length ? amps.reduce((s, x) => s + x, 0) / amps.length : 0;
|
||||
|
||||
ent.rssiHist.push({ t: now, v: n.rssi_dbm });
|
||||
if (meanA > 0) ent.meanAmpHist.push({ t: now, v: meanA });
|
||||
const cutoff = now - TRACE_SEC;
|
||||
while (ent.rssiHist.length && ent.rssiHist[0].t < cutoff) ent.rssiHist.shift();
|
||||
while (ent.meanAmpHist.length && ent.meanAmpHist[0].t < cutoff) ent.meanAmpHist.shift();
|
||||
if (ent.rssiHist.length > TRACE_MAX_PTS) ent.rssiHist.splice(0, ent.rssiHist.length - TRACE_MAX_PTS);
|
||||
if (ent.meanAmpHist.length > TRACE_MAX_PTS) ent.meanAmpHist.splice(0, ent.meanAmpHist.length - TRACE_MAX_PTS);
|
||||
|
||||
const dt = now - (ent.lastFrameWall / 1000);
|
||||
if (dt > 0 && dt < 5) {
|
||||
ent.fps = ent.fps ? ent.fps * 0.85 + (1/dt) * 0.15 : 1/dt;
|
||||
}
|
||||
ent.lastFrameWall = performance.now();
|
||||
ent.lastTs = (d.timestamp || Date.now() / 1000);
|
||||
|
||||
document.getElementById(`n${id}-rssi`).textContent = n.rssi_dbm.toFixed(0);
|
||||
document.getElementById(`n${id}-fps`).textContent = ent.fps.toFixed(0);
|
||||
}
|
||||
|
||||
// Per-node classification + global classification — same shape as raw.html.
|
||||
const nf = d.node_features || [];
|
||||
for (const f of nf) {
|
||||
const id = f.node_id;
|
||||
const cls = f.classification || {};
|
||||
const lvl = cls.motion_level || 'absent';
|
||||
const badge = document.getElementById(`n${id}-badge`);
|
||||
if (badge) { badge.textContent = lvl; badge.className = 'nodeBadge ' + lvl; }
|
||||
const cv = document.getElementById(`n${id}-cv`);
|
||||
if (cv) cv.textContent = Math.round((cls.confidence || 0) * 100) + '%';
|
||||
}
|
||||
|
||||
const gcl = d.classification || {};
|
||||
const glvl = gcl.motion_level || 'absent';
|
||||
const gb = document.getElementById('globalBadge');
|
||||
if (gb) { gb.textContent = glvl; gb.className = glvl; }
|
||||
}
|
||||
|
||||
function renderTick() {
|
||||
const now = performance.now();
|
||||
for (const [id, ent] of state) {
|
||||
const trace = document.getElementById('n' + id + '-trace');
|
||||
if (trace) drawTrace(trace, ent.rssiHist, ent.meanAmpHist);
|
||||
// Stale marker
|
||||
const ageMs = now - ent.lastFrameWall;
|
||||
const tag = document.getElementById('n' + id + '-stale');
|
||||
if (tag) tag.textContent = ageMs > STALE_MS
|
||||
? `stale ${(ageMs/1000).toFixed(1)}s`
|
||||
: '';
|
||||
}
|
||||
requestAnimationFrame(renderTick);
|
||||
}
|
||||
requestAnimationFrame(renderTick);
|
||||
|
||||
// ── WS ────────────────────────────────────────────────────────────
|
||||
function connect() {
|
||||
const ws = new WebSocket('ws://' + location.hostname + ':8765/ws/sensing');
|
||||
ws.onopen = () => {
|
||||
const c = document.getElementById('conn');
|
||||
c.textContent = 'connected'; c.className = 'conn ok';
|
||||
};
|
||||
ws.onclose = () => {
|
||||
const c = document.getElementById('conn');
|
||||
c.textContent = 'reconnecting'; c.className = 'conn dis';
|
||||
setTimeout(connect, 1500);
|
||||
};
|
||||
ws.onerror = () => ws.close();
|
||||
ws.onmessage = e => {
|
||||
try {
|
||||
const d = JSON.parse(e.data);
|
||||
if (d.type === 'sensing_update') handleSensingUpdate(d);
|
||||
} catch (_) {}
|
||||
};
|
||||
}
|
||||
connect();
|
||||
</script>
|
||||
</body></html>
|
||||
|
|
@ -1,200 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>RuView — Live Signal</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
body { margin:0; padding:18px; font-family:-apple-system,Inter,system-ui,sans-serif;
|
||||
background:#0d1117; color:#e6edf3; }
|
||||
h1 { font-size:16px; font-weight:600; margin:0 0 4px; }
|
||||
.sub { font-size:11px; color:#888; margin:0 0 16px; }
|
||||
.panel { background:#161b22; border:1px solid #30363d; border-radius:8px;
|
||||
padding:14px 16px; margin-bottom:12px; }
|
||||
.panel h2 { margin:0 0 10px; font-size:13px; font-weight:600; color:#7ce38b; }
|
||||
.row { display:flex; justify-content:space-between; align-items:center;
|
||||
gap:12px; margin:6px 0; font-size:12px; }
|
||||
.row .name { color:#aaa; flex-shrink:0; width:160px; }
|
||||
.row .bar { flex:1; height:14px; background:#21262d; border-radius:3px; overflow:hidden; }
|
||||
.row .fill { height:100%; transition:width 100ms linear; }
|
||||
.row .val { width:90px; text-align:right; font-family:JetBrains Mono,monospace; font-size:12px; }
|
||||
.row .peak { width:70px; color:#888; text-align:right; font-family:JetBrains Mono,monospace; font-size:11px; }
|
||||
.fill.var { background:linear-gradient(90deg,#1f6feb,#388bfd); }
|
||||
.fill.mot { background:linear-gradient(90deg,#238636,#3fb950); }
|
||||
.fill.spc { background:linear-gradient(90deg,#a371f7,#bc8cff); }
|
||||
.fill.bre { background:linear-gradient(90deg,#d29922,#f0883e); }
|
||||
.fill.rssi { background:linear-gradient(90deg,#6e7681,#8b949e); }
|
||||
.status { padding:6px 10px; background:#1c2128; border-radius:4px; font-size:12px;
|
||||
font-family:JetBrains Mono,monospace; color:#7ce38b; margin-bottom:14px; display:inline-block; }
|
||||
.status.dis { color:#f85149; }
|
||||
button { background:#21262d; color:#e6edf3; border:1px solid #30363d; border-radius:6px;
|
||||
padding:4px 10px; font-size:11px; cursor:pointer; margin-left:8px; }
|
||||
.class { font-family:JetBrains Mono,monospace; font-size:14px;
|
||||
padding:4px 10px; border-radius:4px; display:inline-block; }
|
||||
.class.absent { background:#21262d; color:#888; }
|
||||
.class.present_still { background:#1c3a55; color:#7cb6ff; }
|
||||
.class.present_moving { background:#3a5520; color:#90d36b; }
|
||||
.class.active { background:#552020; color:#ff7a7a; }
|
||||
canvas { display:block; width:100%; height:70px; background:#0a0e13; border-radius:4px; margin-top:8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>RuView Live Signal — Calibration Console</h1>
|
||||
<p class="sub">All features the host DSP computes from raw CSI in real time. Move sensors and yourself, watch which ones react.</p>
|
||||
<div>
|
||||
<span id="status" class="status dis">disconnected</span>
|
||||
<button onclick="resetPeaks()">Reset peaks</button>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Combined classification</h2>
|
||||
<div class="row"><span class="name">motion_level</span><span id="motion-level" class="class absent">absent</span><span class="val" id="cls-conf">conf 0.00</span></div>
|
||||
<div class="row"><span class="name">presence</span><span class="val" id="presence">false</span><span class="val" id="persons">0 persons</span></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Host-computed features (from raw CSI)</h2>
|
||||
<div class="row"><span class="name">variance</span><div class="bar"><div class="fill var" id="b-var" style="width:0"></div></div><span class="val" id="v-var">0.00</span><span class="peak" id="p-var">↑0</span></div>
|
||||
<div class="row"><span class="name">motion_band_power</span><div class="bar"><div class="fill mot" id="b-mbp" style="width:0"></div></div><span class="val" id="v-mbp">0.00</span><span class="peak" id="p-mbp">↑0</span></div>
|
||||
<div class="row"><span class="name">spectral_power</span><div class="bar"><div class="fill spc" id="b-spc" style="width:0"></div></div><span class="val" id="v-spc">0.00</span><span class="peak" id="p-spc">↑0</span></div>
|
||||
<div class="row"><span class="name">breathing_band_power</span><div class="bar"><div class="fill bre" id="b-bre" style="width:0"></div></div><span class="val" id="v-bre">0.00</span><span class="peak" id="p-bre">↑0</span></div>
|
||||
<div class="row"><span class="name">mean_rssi (dBm)</span><div class="bar"><div class="fill rssi" id="b-rssi" style="width:0"></div></div><span class="val" id="v-rssi">--</span></div>
|
||||
<div class="row"><span class="name">dominant_freq (Hz)</span><span class="val" id="v-freq">--</span><span class="val" id="v-bpm">-- BPM</span></div>
|
||||
<div class="row"><span class="name">change_points</span><span class="val" id="v-cp">0</span></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Per-node FW signals (feature_state @ 10 Hz)</h2>
|
||||
<div id="nodes"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>Variance trace (last 60 sec)</h2>
|
||||
<canvas id="trace"></canvas>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const peaks = { var:0, mbp:0, spc:0, bre:0 };
|
||||
const nodePeaks = {}; // per-node motion peak
|
||||
const trace = []; // [{t, var, mbp}]
|
||||
const TRACE_MAX = 600; // ~30 sec at 20 Hz
|
||||
let lastTs = Date.now();
|
||||
|
||||
function resetPeaks() {
|
||||
peaks.var = peaks.mbp = peaks.spc = peaks.bre = 0;
|
||||
for (const k in nodePeaks) nodePeaks[k] = 0;
|
||||
trace.length = 0;
|
||||
document.getElementById('p-var').textContent = '↑0';
|
||||
document.getElementById('p-mbp').textContent = '↑0';
|
||||
document.getElementById('p-spc').textContent = '↑0';
|
||||
document.getElementById('p-bre').textContent = '↑0';
|
||||
}
|
||||
|
||||
function fmt(x) { return (x === undefined || x === null) ? '--' : (typeof x === 'number' ? x.toFixed(2) : String(x)); }
|
||||
|
||||
function ensureNode(id) {
|
||||
let el = document.getElementById('n-'+id);
|
||||
if (el) return;
|
||||
el = document.createElement('div');
|
||||
el.id = 'n-'+id;
|
||||
el.innerHTML = `<div class="row"><span class="name">Node ${id} motion</span><div class="bar"><div class="fill mot" id="nm-${id}" style="width:0"></div></div><span class="val" id="nmv-${id}">0.00</span><span class="peak" id="nmp-${id}">↑0</span></div>
|
||||
<div class="row"><span class="name">Node ${id} rssi</span><div class="bar"><div class="fill rssi" id="nr-${id}" style="width:0"></div></div><span class="val" id="nrv-${id}">--</span></div>`;
|
||||
document.getElementById('nodes').appendChild(el);
|
||||
nodePeaks[id] = 0;
|
||||
}
|
||||
|
||||
function updateCombined(d) {
|
||||
const cl = d.classification || {};
|
||||
const f = d.features || {};
|
||||
const vs = d.vital_signs || {};
|
||||
const ml = cl.motion_level || 'absent';
|
||||
const elClass = document.getElementById('motion-level');
|
||||
elClass.textContent = ml;
|
||||
elClass.className = 'class ' + ml;
|
||||
document.getElementById('cls-conf').textContent = 'conf ' + (cl.confidence || 0).toFixed(2);
|
||||
document.getElementById('presence').textContent = cl.presence ? 'TRUE' : 'false';
|
||||
document.getElementById('persons').textContent = (d.estimated_persons || 0) + ' persons';
|
||||
|
||||
const updMetric = (key, id, scale) => {
|
||||
const v = f[key] || 0;
|
||||
const pct = Math.min(1, v / scale) * 100;
|
||||
document.getElementById('b-'+id).style.width = pct.toFixed(0)+'%';
|
||||
document.getElementById('v-'+id).textContent = v.toFixed(3);
|
||||
peaks[id] = Math.max(peaks[id], v);
|
||||
document.getElementById('p-'+id).textContent = '↑'+peaks[id].toFixed(3);
|
||||
};
|
||||
updMetric('variance', 'var', 50);
|
||||
updMetric('motion_band_power', 'mbp', 30);
|
||||
updMetric('spectral_power', 'spc', 500);
|
||||
updMetric('breathing_band_power', 'bre', 100);
|
||||
const rssi = f.mean_rssi || 0;
|
||||
document.getElementById('b-rssi').style.width = Math.max(0, Math.min(1, (rssi + 90) / 60)) * 100 + '%';
|
||||
document.getElementById('v-rssi').textContent = rssi.toFixed(0) + ' dBm';
|
||||
document.getElementById('v-freq').textContent = (f.dominant_freq_hz || 0).toFixed(2) + ' Hz';
|
||||
document.getElementById('v-bpm').textContent = ((f.dominant_freq_hz || 0) * 60).toFixed(0) + ' BPM';
|
||||
document.getElementById('v-cp').textContent = String(f.change_points || 0);
|
||||
|
||||
// trace
|
||||
trace.push({ t: Date.now(), v: f.variance || 0, m: f.motion_band_power || 0 });
|
||||
while (trace.length > TRACE_MAX) trace.shift();
|
||||
}
|
||||
|
||||
function updateEdgeVitals(d) {
|
||||
ensureNode(d.node_id);
|
||||
const m = d.motion_energy || 0;
|
||||
document.getElementById('nm-'+d.node_id).style.width = Math.min(1, m) * 100 + '%';
|
||||
document.getElementById('nmv-'+d.node_id).textContent = m.toFixed(3);
|
||||
if (m > nodePeaks[d.node_id]) nodePeaks[d.node_id] = m;
|
||||
document.getElementById('nmp-'+d.node_id).textContent = '↑'+nodePeaks[d.node_id].toFixed(3);
|
||||
const r = d.rssi || -60;
|
||||
document.getElementById('nr-'+d.node_id).style.width = Math.max(0, Math.min(1, (r + 90) / 60)) * 100 + '%';
|
||||
document.getElementById('nrv-'+d.node_id).textContent = r.toFixed(0) + ' dBm';
|
||||
}
|
||||
|
||||
function drawTrace() {
|
||||
const cv = document.getElementById('trace');
|
||||
const w = cv.clientWidth, h = cv.clientHeight;
|
||||
if (cv.width !== w || cv.height !== h) { cv.width = w; cv.height = h; }
|
||||
const ctx = cv.getContext('2d');
|
||||
ctx.fillStyle = '#0a0e13'; ctx.fillRect(0, 0, w, h);
|
||||
if (trace.length < 2) return;
|
||||
const maxV = Math.max(1, Math.max(...trace.map(p => p.v)));
|
||||
const maxM = Math.max(1, Math.max(...trace.map(p => p.m)));
|
||||
ctx.strokeStyle = '#388bfd'; ctx.lineWidth = 1.5; ctx.beginPath();
|
||||
for (let i = 0; i < trace.length; i++) {
|
||||
const x = (i / (trace.length - 1)) * w;
|
||||
const y = h - (trace[i].v / maxV) * (h - 4);
|
||||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.strokeStyle = '#3fb950'; ctx.beginPath();
|
||||
for (let i = 0; i < trace.length; i++) {
|
||||
const x = (i / (trace.length - 1)) * w;
|
||||
const y = h - (trace[i].m / maxM) * (h - 4);
|
||||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = '#666'; ctx.font = '10px monospace';
|
||||
ctx.fillText('variance', 4, 12);
|
||||
ctx.fillStyle = '#3fb950';
|
||||
ctx.fillText('motion_band_power', 70, 12);
|
||||
}
|
||||
|
||||
function tick() { drawTrace(); requestAnimationFrame(tick); }
|
||||
tick();
|
||||
|
||||
function connect() {
|
||||
const ws = new WebSocket('ws://' + location.hostname + ':8765/ws/sensing');
|
||||
ws.onopen = () => { document.getElementById('status').textContent='connected'; document.getElementById('status').className='status'; };
|
||||
ws.onclose = () => { document.getElementById('status').textContent='disconnected — reconnecting'; document.getElementById('status').className='status dis'; setTimeout(connect, 2000); };
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
const d = JSON.parse(e.data);
|
||||
if (d.type === 'sensing_update') updateCombined(d);
|
||||
else if (d.type === 'edge_vitals') updateEdgeVitals(d);
|
||||
} catch (_) {}
|
||||
};
|
||||
}
|
||||
connect();
|
||||
</script>
|
||||
</body></html>
|
||||
Loading…
Reference in New Issue