feat(ops): one-command OTA deploy + mobile-first operator UI
scripts/ota-deploy.sh
Python 3 helper (the earlier bash version tripped over macOS bash 3.2's
missing associative arrays). One invocation with no arguments:
1. discovers nodes in the local /24 via ARP + /ota/status:8032 probe;
2. POSTs the firmware blob to every node in parallel;
3. waits for reboot, polls /ota/status until running_partition flips,
and fails-loud if any node stays on the old partition (typical
symptom of a panic on first boot from the new slot).
Supports `--build` (idf.py build first), `--no-verify`, explicit IP
list, and OTA_PSK=<token> for the ADR-050 Bearer auth path.
Measured cycle: ~25 s end-to-end for both room01 + room02.
static/mobile.html
Mobile-first sibling of static/raw.html. The desktop page is unreadable
on a 360-420 px screen — bars chart fights the narrow viewport, 11-12 px
font, controls overlap the badge. The mobile page:
- sticky global badge (30 px) + connection pill + reset (44 px tap);
- per-node card with 22 px node badge, 18 px stat tiles, 90 px trace;
- drops the bars chart (useless under 600 px wide);
- viewport-fit=cover, theme-color, apple-mobile-web-app meta tags;
- high-contrast palette tuned for outdoor light;
- reuses the /ws/sensing contract verbatim — anything that lights up
raw.html lights this up too.
main.rs ServeDir route
Adds `.nest_service("/static", ServeDir::new(.../static))` so
raw.html / mobile.html / calibrate.html / spectrum.html are served on
the main 8080 port. Previously they needed a separate
`python -m http.server :8091`, which the operator had to remember to
start by hand on every deploy. Now there's exactly one URL per device.
Reachable from a phone on the LAN:
http://<mac>:8080/static/mobile.html
http://<mac>:8080/static/raw.html
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
03b123bfc3
commit
72047a4185
|
|
@ -0,0 +1,275 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
scripts/ota-deploy.sh — push esp32-csi-node.bin to one or more sensor nodes
|
||||
over WiFi. Talks to the on-device /ota endpoint (ADR-045, port 8032,
|
||||
handler in firmware/esp32-csi-node/main/ota_update.c).
|
||||
|
||||
Usage:
|
||||
scripts/ota-deploy.sh # auto-discover via ARP, deploy to all
|
||||
scripts/ota-deploy.sh 192.168.0.100 # one node
|
||||
scripts/ota-deploy.sh 192.168.0.100 192.168.0.101
|
||||
scripts/ota-deploy.sh --build # idf.py build first, then deploy
|
||||
scripts/ota-deploy.sh --no-verify ... # skip post-reboot /ota/status check
|
||||
|
||||
Auth: set env OTA_PSK=<token> to send "Authorization: Bearer <token>"
|
||||
(matches the on-device check in ota_update.c::ota_check_auth).
|
||||
|
||||
Exit codes:
|
||||
0 — all targeted nodes confirmed running_partition flipped
|
||||
1 — one or more nodes failed verification or were unreachable
|
||||
2 — build or argument error
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import concurrent.futures as cf
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
FW_DIR = REPO_ROOT / "firmware" / "esp32-csi-node"
|
||||
BIN_PATH = FW_DIR / "build" / "esp32-csi-node.bin"
|
||||
PORT = 8032
|
||||
|
||||
UPLOAD_TIMEOUT_S = 120
|
||||
REBOOT_WAIT_S = 10
|
||||
VERIFY_RETRIES = 6
|
||||
VERIFY_DELAY_S = 3
|
||||
|
||||
|
||||
# ---- ANSI logging helpers ----------------------------------------------------
|
||||
def _c(code: str, msg: str) -> str:
|
||||
if not sys.stdout.isatty():
|
||||
return msg
|
||||
return f"\033[{code}m{msg}\033[0m"
|
||||
|
||||
def log(msg: str) -> None: print(_c("36", "[ota-deploy] ") + msg, flush=True)
|
||||
def warn(msg: str) -> None: print(_c("33", "[ota-deploy] ") + msg, file=sys.stderr, flush=True)
|
||||
def err(msg: str) -> None: print(_c("31", "[ota-deploy] ") + msg, file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
# ---- helpers -----------------------------------------------------------------
|
||||
def http_get(url: str, timeout: float = 4.0) -> str | None:
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=timeout) as r:
|
||||
return r.read().decode("utf-8", errors="replace")
|
||||
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def get_ota_status(ip: str) -> dict | None:
|
||||
body = http_get(f"http://{ip}:{PORT}/ota/status")
|
||||
if not body:
|
||||
return None
|
||||
try:
|
||||
return json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def local_subnet_prefix() -> str | None:
|
||||
"""Return e.g. '192.168.0' from en0 (macOS) or first non-loopback IP."""
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
["ipconfig", "getifaddr", "en0"], stderr=subprocess.DEVNULL, text=True
|
||||
).strip()
|
||||
if out:
|
||||
return out.rsplit(".", 1)[0]
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
pass
|
||||
# Linux fallback
|
||||
try:
|
||||
out = subprocess.check_output(["hostname", "-I"], text=True).strip()
|
||||
if out:
|
||||
return out.split()[0].rsplit(".", 1)[0]
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def discover_nodes() -> list[str]:
|
||||
"""ARP-prefilter + parallel /ota/status probe to find live sensor nodes."""
|
||||
prefix = local_subnet_prefix()
|
||||
if not prefix:
|
||||
err("could not determine local /24 — pass node IPs explicitly")
|
||||
return []
|
||||
log(f"scanning {prefix}.0/24 for /ota/status responders ...")
|
||||
|
||||
candidates: list[str] = []
|
||||
try:
|
||||
arp_out = subprocess.check_output(
|
||||
["arp", "-a", "-n"], text=True, stderr=subprocess.DEVNULL
|
||||
)
|
||||
for line in arp_out.splitlines():
|
||||
m = re.search(rf"\(({re.escape(prefix)}\.\d+)\)", line)
|
||||
if m and "incomplete" not in line:
|
||||
ip = m.group(1)
|
||||
if not ip.endswith(".1"): # skip gateway
|
||||
candidates.append(ip)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
pass
|
||||
if not candidates:
|
||||
warn(f"no ARP hits — falling back to {prefix}.100-110 ping sweep")
|
||||
candidates = [f"{prefix}.{i}" for i in range(100, 111)]
|
||||
candidates = sorted(set(candidates))
|
||||
|
||||
found: list[str] = []
|
||||
with cf.ThreadPoolExecutor(max_workers=32) as pool:
|
||||
futs = {pool.submit(get_ota_status, ip): ip for ip in candidates}
|
||||
for fut in cf.as_completed(futs):
|
||||
ip = futs[fut]
|
||||
try:
|
||||
if fut.result():
|
||||
found.append(ip)
|
||||
except Exception:
|
||||
pass
|
||||
return sorted(found, key=lambda x: tuple(int(o) for o in x.split(".")))
|
||||
|
||||
|
||||
def upload_one(ip: str, payload: bytes, psk: str | None) -> tuple[bool, float, str]:
|
||||
"""POST the firmware to one node. Returns (success, elapsed_s, body_snippet)."""
|
||||
req = urllib.request.Request(
|
||||
f"http://{ip}:{PORT}/ota",
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/octet-stream"},
|
||||
method="POST",
|
||||
)
|
||||
if psk:
|
||||
req.add_header("Authorization", f"Bearer {psk}")
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=UPLOAD_TIMEOUT_S) as r:
|
||||
body = r.read().decode("utf-8", errors="replace")[:200]
|
||||
return True, time.monotonic() - t0, body
|
||||
except (urllib.error.HTTPError, urllib.error.URLError,
|
||||
TimeoutError, ConnectionResetError, OSError) as e:
|
||||
# ConnectionReset is *expected* when the chip restarts before flushing
|
||||
# the response. We treat it as a soft pass and verify via /ota/status.
|
||||
return (isinstance(e, ConnectionResetError),
|
||||
time.monotonic() - t0,
|
||||
f"{type(e).__name__}: {e}")
|
||||
|
||||
|
||||
def build_firmware() -> int:
|
||||
log("building firmware via idf.py ...")
|
||||
if "IDF_PATH" not in os.environ:
|
||||
export = Path.home() / "esp" / "esp-idf-v5.2" / "export.sh"
|
||||
if not export.is_file():
|
||||
err("IDF_PATH not set and ~/esp/esp-idf-v5.2/export.sh not found")
|
||||
return 2
|
||||
# source the env in a child shell
|
||||
rc = subprocess.call(
|
||||
["bash", "-lc", f". '{export}' >/dev/null 2>&1 && cd '{FW_DIR}' && idf.py build"]
|
||||
)
|
||||
else:
|
||||
rc = subprocess.call(["idf.py", "build"], cwd=str(FW_DIR))
|
||||
if rc != 0:
|
||||
err("build failed")
|
||||
return 2
|
||||
return 0
|
||||
|
||||
|
||||
# ---- main --------------------------------------------------------------------
|
||||
def main(argv: list[str]) -> int:
|
||||
ap = argparse.ArgumentParser(
|
||||
prog="ota-deploy.sh",
|
||||
description="Push esp32-csi-node.bin to one or more sensor nodes over WiFi.",
|
||||
)
|
||||
ap.add_argument("targets", nargs="*",
|
||||
help="node IPs; auto-discover if omitted")
|
||||
ap.add_argument("--build", action="store_true",
|
||||
help="idf.py build before deploying")
|
||||
ap.add_argument("--no-verify", action="store_true",
|
||||
help="skip post-reboot /ota/status confirmation")
|
||||
args = ap.parse_args(argv)
|
||||
|
||||
if args.build:
|
||||
rc = build_firmware()
|
||||
if rc != 0:
|
||||
return rc
|
||||
|
||||
if not BIN_PATH.is_file():
|
||||
err(f"firmware binary not found: {BIN_PATH} — pass --build first")
|
||||
return 2
|
||||
payload = BIN_PATH.read_bytes()
|
||||
log(f"firmware: {BIN_PATH} ({len(payload)} bytes)")
|
||||
|
||||
targets = args.targets or discover_nodes()
|
||||
if not targets:
|
||||
err("no nodes given and none discovered")
|
||||
return 1
|
||||
log(f"targets: {' '.join(targets)}")
|
||||
|
||||
# snapshot before
|
||||
before: dict[str, str] = {}
|
||||
for ip in targets:
|
||||
st = get_ota_status(ip)
|
||||
if not st:
|
||||
warn(f"{ip}: not reachable before upload")
|
||||
before[ip] = "UNREACHABLE"
|
||||
continue
|
||||
before[ip] = st.get("running_partition", "UNKNOWN")
|
||||
log(f"{ip} before: running_partition={before[ip]} time={st.get('time')}")
|
||||
|
||||
psk = os.environ.get("OTA_PSK") or None
|
||||
if psk:
|
||||
log("OTA_PSK set — sending Bearer token")
|
||||
|
||||
# upload in parallel
|
||||
log("uploading in parallel ...")
|
||||
results: dict[str, tuple[bool, float, str]] = {}
|
||||
with cf.ThreadPoolExecutor(max_workers=max(2, len(targets))) as pool:
|
||||
futs = {pool.submit(upload_one, ip, payload, psk): ip for ip in targets}
|
||||
for fut in cf.as_completed(futs):
|
||||
ip = futs[fut]
|
||||
ok, dt, body = fut.result()
|
||||
results[ip] = (ok, dt, body)
|
||||
tag = _c("32", "ok") if ok else _c("31", "ERR")
|
||||
log(f"{ip} upload {tag} in {dt:.1f}s body={body[:120]}")
|
||||
|
||||
if args.no_verify:
|
||||
log("--no-verify — done")
|
||||
return 0 if all(v[0] for v in results.values()) else 1
|
||||
|
||||
# verify
|
||||
log(f"waiting {REBOOT_WAIT_S}s for reboot ...")
|
||||
time.sleep(REBOOT_WAIT_S)
|
||||
fail = False
|
||||
for ip in targets:
|
||||
new_st: dict | None = None
|
||||
for _ in range(VERIFY_RETRIES):
|
||||
new_st = get_ota_status(ip)
|
||||
if new_st:
|
||||
break
|
||||
time.sleep(VERIFY_DELAY_S)
|
||||
if not new_st:
|
||||
err(f"{ip}: not reachable after reboot — DEAD or panic loop")
|
||||
fail = True
|
||||
continue
|
||||
new_part = new_st.get("running_partition", "?")
|
||||
new_time = new_st.get("time", "?")
|
||||
if new_part == before.get(ip):
|
||||
err(f"{ip}: running_partition still {new_part} — OTA did NOT take "
|
||||
"(likely panic on first boot from new slot)")
|
||||
fail = True
|
||||
else:
|
||||
log(f"{ip}: {before[ip]} → {_c('32', new_part)} (time={new_time}) ✓")
|
||||
return 1 if fail else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
except KeyboardInterrupt:
|
||||
err("interrupted")
|
||||
sys.exit(130)
|
||||
|
|
@ -5570,6 +5570,18 @@ async fn main() {
|
|||
.route("/api/v1/calibration/status", get(calibration_status))
|
||||
// Static UI files
|
||||
.nest_service("/ui", ServeDir::new(&ui_path))
|
||||
// ADR-100/ADR-101 operator pages (raw.html, mobile.html, calibrate.html,
|
||||
// spectrum.html). Lives in `crates/wifi-densepose-sensing-server/static/`
|
||||
// — same crate as the server so it ships with cargo install. Previously
|
||||
// these were exposed via a separate `python -m http.server :8091`; now
|
||||
// they're served on the main HTTP port so the operator only has to
|
||||
// remember one URL per device (http://<mac>:8080/static/mobile.html).
|
||||
.nest_service(
|
||||
"/static",
|
||||
ServeDir::new(
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("static"),
|
||||
),
|
||||
)
|
||||
.layer(SetResponseHeaderLayer::overriding(
|
||||
axum::http::header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("no-cache, no-store, must-revalidate"),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,311 @@
|
|||
<!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>
|
||||
Loading…
Reference in New Issue