115 lines
6.5 KiB
HTML
115 lines
6.5 KiB
HTML
<!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>
|