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:
arsen 2026-05-17 02:21:06 +07:00
parent 03b123bfc3
commit 72047a4185
3 changed files with 598 additions and 0 deletions

275
scripts/ota-deploy.sh Executable file
View File

@ -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)

View File

@ -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"),

View File

@ -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 &nbsp;
<span style="color:#7d8590"></span> rssi &nbsp; <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>