deploy(esp32s3): PHY gain-lock for baseline-stable CSI + raw signals UI
Ports Francesco Pace's ESPectre gain-lock (GPLv3) to RuView FW: medians AGC and FFT scale over the first 300 packets after boot, then freezes them via phy_force_rx_gain / phy_fft_scale_force. With both sensors locked and proper AP→body→sensor geometry, a 30-s × 3-state capture (empty / still / walk) now separates by ×3.4–×5.9 instead of ±0.02 within ±0.10 noise as in ADR-099. Adds static/raw.html — per-node 56-subcarrier amplitude bars + RSSI/ broadband traces, no DSP, for live calibration. ADR-100 documents the technique, boot calibration values for the operator's deployment (AGC=42/44, both APPLIED), and the verified three-state separation table.
This commit is contained in:
parent
b292c7d869
commit
8aef82069b
|
|
@ -0,0 +1,162 @@
|
|||
# ADR-100 — PHY Gain Lock for Baseline-Stable CSI
|
||||
|
||||
**Status**: Accepted
|
||||
**Date**: 2026-05-17
|
||||
**Scope**: `firmware/esp32-csi-node/main/csi_collector.c`,
|
||||
`v2/crates/wifi-densepose-sensing-server/static/raw.html`.
|
||||
|
||||
## Context
|
||||
|
||||
After ADR-099 deployed the TP-Link WISP AP and the operator captured three
|
||||
controlled one-minute windows (empty / sit / walk), the RSSI MAD-Δ
|
||||
classifier failed to separate the three states — measured `d` values
|
||||
overlapped within ±0.03 of 0.49 while in-state spread was ±0.10. We
|
||||
inspected the live amplitude spectrum on the new `raw.html` console and
|
||||
saw a slow ±20-30 % broadband drift in the sensor amplitude even with
|
||||
the room provably empty. The drift was indistinguishable from body
|
||||
modulation at multi-meter range and dominated every downstream feature.
|
||||
|
||||
Francesco Pace's [ESPectre](https://github.com/francescopace/espectre)
|
||||
project (GPLv3) traced the same artefact to the ESP32 PHY's automatic
|
||||
gain control: AGC continuously rebalances the receiver gain per packet
|
||||
so received frames stay in the optimal decoding range. For CSI sensing
|
||||
this is a disaster — the same channel state arrives with a different
|
||||
amplitude every packet because the gain stage shifts under it. Pace
|
||||
documented two undocumented PHY routines in the IDF blob that freeze
|
||||
AGC and FFT scaling, plus a calibration recipe (median of the first
|
||||
300 packets) that is robust to brief startup activity.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1 — Port the ESPectre gain-lock to RuView FW
|
||||
|
||||
Added a self-contained block to `csi_collector.c`:
|
||||
|
||||
* **Overlay struct** `rv_phy_rx_ctrl_t` aliased over `wifi_csi_info_t.rx_ctrl`
|
||||
to read the hidden `agc_gain` (u8) and `fft_gain` (signed i8) fields.
|
||||
* **Extern declarations** for the two PHY routines:
|
||||
```c
|
||||
extern void phy_fft_scale_force(bool force_en, int8_t force_value);
|
||||
extern void phy_force_rx_gain(int force_en, int force_value);
|
||||
```
|
||||
* **Two-phase calibration** (`rv_gain_lock_process`):
|
||||
- Phase 1 (≤ 300 packets, ~6 s at the rate-gated 50 Hz callback):
|
||||
accumulate AGC and FFT samples into static arrays.
|
||||
- At the 300th packet: `qsort` both arrays, take the median, and
|
||||
call the two PHY routines to freeze gain.
|
||||
* **Safety branch**: if median AGC < 30, skip the lock and log a
|
||||
warning. Forcing a low gain on a strong-signal deployment causes the
|
||||
RX path to freeze (empirically documented in ESPectre's
|
||||
`gain_controller.h`).
|
||||
* **Supported targets**: ESP32-S3, ESP32-C3, ESP32-C6 only — older
|
||||
parts compile to a no-op stub. RuView ships on S3 so this is the only
|
||||
path we care about.
|
||||
|
||||
The hook is wired immediately after the existing rate-gate and MAC
|
||||
filter in the CSI callback so calibration completes within the first
|
||||
~6 s after the WiFi association, regardless of host traffic. After
|
||||
that it short-circuits.
|
||||
|
||||
Tagged as ADR-100 in the source comment for traceability.
|
||||
|
||||
### D2 — Use the existing `raw.html` console (ADR-099, D2 reuse) as the verification UI
|
||||
|
||||
The console added in ADR-099 already streams `nodes[].amplitude` from
|
||||
the existing WebSocket. No server-side change was needed. The HTML
|
||||
displays a per-node bar histogram of all 56 active subcarriers plus
|
||||
broadband mean amplitude and RSSI traces over the last 30 s. This is
|
||||
the surface where the operator can watch — without any DSP, without any
|
||||
classification — whether the gain-lock has actually flattened the
|
||||
baseline.
|
||||
|
||||
### D3 — Geometry matters as much as gain-lock
|
||||
|
||||
A controlled three-state capture made on 2026-05-17 with both sensors
|
||||
positioned so that the line `TP-Link AP → sensor` passes through the
|
||||
operator (lying on the bed) confirmed both decisions. The summary
|
||||
table appears under *Verified Acceptance* below. Earlier captures
|
||||
(ADR-099) failed to separate states partly because the sensors were
|
||||
placed off-axis from the AP-to-body line; with that geometry the body
|
||||
never physically obstructs the CSI channel.
|
||||
|
||||
## Calibration values observed (real captures, this deployment)
|
||||
|
||||
| Node | Boot rate (low traffic) | Boot rate (ping flood) | AGC median | FFT scale median | Lock decision |
|
||||
|---|---|---|---|---|---|
|
||||
| room01 (192.168.0.101) | 0.3 fps | 30+ fps | **42–44** | −31 / −33 | **APPLIED** |
|
||||
| room02 (192.168.0.100) | 0.3 fps | 30+ fps | **44** | −40 / −42 | **APPLIED** |
|
||||
|
||||
Both AGC medians are comfortably above the 30 safety threshold. The
|
||||
calibration completes in ~6 s when there is any host traffic (a single
|
||||
ping to the sensor at 10 pps is enough); on a totally idle channel
|
||||
beacons drive the rate down to 0.3 fps and calibration would take ~17
|
||||
minutes — practically we always have some traffic.
|
||||
|
||||
## Verified Acceptance — three-state separation
|
||||
|
||||
Geometry: TP-Link AP on the wall, both sensors at table-level on the
|
||||
opposite side of the room, operator lying on the bed between AP and
|
||||
sensors. 30 seconds per state, gain-lock active on both nodes,
|
||||
`raw.html` open during capture, `target_ip` provisioned to the Mac's
|
||||
TP-Link-side IP (192.168.0.103) so no upstream NAT is in the path.
|
||||
|
||||
| State | node 1 mean A | node 1 CV | node 1 sub-CV <5 % | node 2 mean A | node 2 CV | node 2 sub-CV <7 % |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **EMPTY** (operator out) | **37.28** | **2.71 %** | **44/44** | 9.52 | 5.22 % | 26/44 |
|
||||
| **STILL** (operator lying still on bed) | 22.43 | 3.70 % | 30/44 | 9.67 | 5.02 % | 24/44 |
|
||||
| **WALK** (operator pacing the room) | 31.77 | **12.50 %** | 0/44 | 7.15 | **29.72 %** | 0/44 |
|
||||
|
||||
Observations:
|
||||
|
||||
* **Node 1 separates all three states** by mean amplitude alone: 37 →
|
||||
22 → 32. The body lying still blocks the direct path
|
||||
(40 % amplitude drop), then motion adds reflections back. The CV
|
||||
ladder 2.71 → 3.70 → 12.50 % is a second independent feature.
|
||||
* **Node 2 separates STILL+EMPTY from WALK** by CV (5 → 30 %). Its
|
||||
geometry doesn't pick up a still body, only motion.
|
||||
* **Compare to ADR-099** where empty/sit/walk differed by ±0.02 inside
|
||||
±0.10 noise — we now have inter-state separation ratios of **×3.4 on
|
||||
node 1 and ×5.9 on node 2**. The signal is no longer dominated by
|
||||
baseline drift.
|
||||
|
||||
## Files Touched
|
||||
|
||||
```
|
||||
firmware/esp32-csi-node/main/csi_collector.c # gain-lock module + hook
|
||||
v2/crates/wifi-densepose-sensing-server/static/raw.html # already from ADR-099
|
||||
docs/adr/ADR-100-gain-lock-baseline-stabilization.md # this ADR
|
||||
```
|
||||
|
||||
## Open Items
|
||||
|
||||
* **NBVI subcarrier selection** is the next ESPectre technique to
|
||||
port. With gain-lock alone we see 0–44 subcarriers below CV 5 % per
|
||||
state — NBVI would automatically select the top-K stable ones at
|
||||
boot and let the DSP compute motion variance only on those.
|
||||
Expected to lift the SNR another factor of 2–3×.
|
||||
* **Server-side RSSI parsing** is currently broken for the new frame
|
||||
shape: `mean_rssi` returns 0 in the WS payload even though the
|
||||
raw CSI frame carries a valid int8. Cosmetic; doesn't affect amplitude.
|
||||
* **NVS target_ip is hardcoded** to one of Mac's two possible IPs
|
||||
(192.168.0.103 on TP-Link side). When the operator switches Mac WiFi
|
||||
the CSI stream stops. Long-term fix: provision sensors to send to
|
||||
the Mac's Tailscale IP, which is stable across networks. Optional
|
||||
short-term: a static DHCP lease on TP-Link admin so 192.168.0.103
|
||||
is reserved for the Mac.
|
||||
* **Calibration latency on an idle channel.** If no host traffic
|
||||
exists when the sensor boots, gain-lock collects samples at the
|
||||
beacon-only rate (~0.3 fps) and takes ~17 min to converge. In
|
||||
practice the host always sends something. If not — `ping -i 0.1
|
||||
192.168.0.10x` for 30 s right after boot is enough.
|
||||
|
||||
## References
|
||||
|
||||
* ADR-039 — Edge intelligence pipeline (host DSP path).
|
||||
* ADR-098 — Earlier ESP32-S3 deployment fixes.
|
||||
* ADR-099 — TP-Link WISP deployment + first RSSI-Δ attempt (this ADR
|
||||
supersedes the threshold table in ADR-099, D3 — the RSSI MAD-Δ
|
||||
detector is left in place but no longer the primary signal).
|
||||
* Francesco Pace, *How I Turned My Wi-Fi Into a Motion Sensor — Part 2*,
|
||||
Dec 2025 — source of the gain-lock recipe.
|
||||
* `francescopace/espectre`, `components/espectre/gain_controller.{h,cpp}`
|
||||
on GitHub — reference implementation (GPLv3).
|
||||
|
|
@ -17,6 +17,7 @@
|
|||
#include "edge_processing.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include "esp_log.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "esp_timer.h"
|
||||
|
|
@ -52,6 +53,100 @@ static bool s_filter_mac_set = false;
|
|||
|
||||
static const char *TAG = "csi_collector";
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────
|
||||
* ADR-100: Gain Lock (AGC + FFT scale).
|
||||
*
|
||||
* ESP32 WiFi PHY applies automatic gain control per packet, which
|
||||
* manifests as a 20-30 % slow drift in CSI amplitude even with a
|
||||
* completely static room — masking the real modulation caused by
|
||||
* body motion. Ported from Francesco Pace's ESPectre (GPLv3,
|
||||
* https://github.com/francescopace/espectre).
|
||||
*
|
||||
* The first ~300 packets after boot are sampled. We take the median
|
||||
* AGC + FFT gain values and freeze them with two undocumented PHY
|
||||
* routines from the IDF blob. If the median AGC is below the safe
|
||||
* threshold (sensor sits very close to the AP), we *don't* lock —
|
||||
* forcing a low gain causes the RX path to freeze.
|
||||
* Supported targets: ESP32-S3 / C3 / C6. Older parts skip silently.
|
||||
* ──────────────────────────────────────────────────────────────── */
|
||||
#if CONFIG_IDF_TARGET_ESP32S3 || CONFIG_IDF_TARGET_ESP32C3 || CONFIG_IDF_TARGET_ESP32C6
|
||||
#define RV_GAIN_LOCK_SUPPORTED 1
|
||||
/* Overlay struct on wifi_csi_info_t.rx_ctrl exposing the hidden agc/fft fields. */
|
||||
typedef struct {
|
||||
unsigned : 32; unsigned : 32; unsigned : 32;
|
||||
unsigned : 32; unsigned : 32; unsigned : 16;
|
||||
signed fft_gain : 8;
|
||||
unsigned agc_gain : 8;
|
||||
unsigned : 32; unsigned : 32;
|
||||
unsigned : 32; unsigned : 32; unsigned : 32;
|
||||
unsigned : 32;
|
||||
} rv_phy_rx_ctrl_t;
|
||||
extern void phy_fft_scale_force(bool force_en, int8_t force_value);
|
||||
extern void phy_force_rx_gain(int force_en, int force_value);
|
||||
#define RV_GAIN_CAL_PACKETS 300u
|
||||
#define RV_GAIN_MIN_SAFE_AGC 30u /* < 30 → forcing freezes RX. */
|
||||
static uint8_t s_agc_samples[RV_GAIN_CAL_PACKETS];
|
||||
static int8_t s_fft_samples[RV_GAIN_CAL_PACKETS];
|
||||
static uint16_t s_gain_pkt_count = 0;
|
||||
static bool s_gain_locked = false;
|
||||
static bool s_gain_skipped_strong = false;
|
||||
static uint8_t s_gain_agc_value = 0;
|
||||
static int8_t s_gain_fft_value = 0;
|
||||
|
||||
static int rv_cmp_u8(const void *a, const void *b) {
|
||||
return (int)*(const uint8_t *)a - (int)*(const uint8_t *)b;
|
||||
}
|
||||
static int rv_cmp_i8(const void *a, const void *b) {
|
||||
return (int)*(const int8_t *)a - (int)*(const int8_t *)b;
|
||||
}
|
||||
|
||||
static void rv_gain_lock_process(const wifi_csi_info_t *info)
|
||||
{
|
||||
if (s_gain_locked || info == NULL) return;
|
||||
const rv_phy_rx_ctrl_t *phy = (const rv_phy_rx_ctrl_t *)info;
|
||||
|
||||
if (s_gain_pkt_count < RV_GAIN_CAL_PACKETS) {
|
||||
s_agc_samples[s_gain_pkt_count] = phy->agc_gain;
|
||||
s_fft_samples[s_gain_pkt_count] = phy->fft_gain;
|
||||
s_gain_pkt_count++;
|
||||
if (s_gain_pkt_count == RV_GAIN_CAL_PACKETS / 4 ||
|
||||
s_gain_pkt_count == RV_GAIN_CAL_PACKETS / 2 ||
|
||||
s_gain_pkt_count == (3u * RV_GAIN_CAL_PACKETS) / 4u) {
|
||||
ESP_LOGI(TAG, "gain-lock cal %u%% (%u/%u, AGC=%u FFT=%d)",
|
||||
(unsigned)((s_gain_pkt_count * 100u) / RV_GAIN_CAL_PACKETS),
|
||||
(unsigned)s_gain_pkt_count, (unsigned)RV_GAIN_CAL_PACKETS,
|
||||
(unsigned)phy->agc_gain, (int)phy->fft_gain);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
/* Reached the calibration target — compute medians, lock or skip. */
|
||||
qsort(s_agc_samples, RV_GAIN_CAL_PACKETS, sizeof(uint8_t), rv_cmp_u8);
|
||||
qsort(s_fft_samples, RV_GAIN_CAL_PACKETS, sizeof(int8_t), rv_cmp_i8);
|
||||
s_gain_agc_value = s_agc_samples[RV_GAIN_CAL_PACKETS / 2];
|
||||
s_gain_fft_value = s_fft_samples[RV_GAIN_CAL_PACKETS / 2];
|
||||
|
||||
if (s_gain_agc_value < RV_GAIN_MIN_SAFE_AGC) {
|
||||
s_gain_skipped_strong = true;
|
||||
ESP_LOGW(TAG,
|
||||
"gain-lock SKIPPED: AGC median=%u < %u (signal too strong, "
|
||||
"forcing would freeze RX). Move sensor 2-3 m from AP.",
|
||||
(unsigned)s_gain_agc_value, (unsigned)RV_GAIN_MIN_SAFE_AGC);
|
||||
} else {
|
||||
phy_fft_scale_force(true, s_gain_fft_value);
|
||||
phy_force_rx_gain(1, (int)s_gain_agc_value);
|
||||
ESP_LOGI(TAG,
|
||||
"gain-lock APPLIED: AGC=%u FFT=%d (median of %u packets) — "
|
||||
"baseline drift should now collapse.",
|
||||
(unsigned)s_gain_agc_value, (int)s_gain_fft_value,
|
||||
(unsigned)RV_GAIN_CAL_PACKETS);
|
||||
}
|
||||
s_gain_locked = true;
|
||||
}
|
||||
#else
|
||||
static inline void rv_gain_lock_process(const wifi_csi_info_t *info) { (void)info; }
|
||||
#endif
|
||||
|
||||
static uint32_t s_sequence = 0;
|
||||
static uint32_t s_cb_count = 0;
|
||||
static uint32_t s_send_ok = 0;
|
||||
|
|
@ -211,6 +306,11 @@ static void wifi_csi_callback(void *ctx, wifi_csi_info_t *info)
|
|||
}
|
||||
}
|
||||
|
||||
/* ADR-100: feed the gain-lock calibrator. No-op once locked / on
|
||||
* unsupported targets. Runs before the heavy work so calibration
|
||||
* happens during the first ~6 s after boot regardless of host traffic. */
|
||||
rv_gain_lock_process(info);
|
||||
|
||||
s_cb_count++;
|
||||
|
||||
if (s_cb_count <= 3 || (s_cb_count % 100) == 0) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,292 @@
|
|||
<!doctype html>
|
||||
<html lang="en"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>RuView — Raw Signals</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
body { margin:0; padding:14px; font-family:-apple-system,Inter,system-ui,sans-serif;
|
||||
background:#0a0e13; color:#e6edf3; font-size:12px; }
|
||||
h1 { font-size:15px; font-weight:600; margin:0 0 2px; }
|
||||
.sub { font-size:11px; color:#888; margin:0 0 12px; }
|
||||
.topbar { display:flex; gap:14px; align-items:center; margin-bottom:10px; flex-wrap:wrap; }
|
||||
.pill { padding:4px 10px; border-radius:4px; font-family:JetBrains Mono,monospace; font-size:11px;
|
||||
background:#1c2128; }
|
||||
.pill.dis { background:#3a1418; color:#ff6a6a; }
|
||||
.pill.ok { background:#0e2a1a; color:#7ce38b; }
|
||||
button { background:#21262d; color:#e6edf3; border:1px solid #30363d; border-radius:4px;
|
||||
padding:4px 10px; font-size:11px; cursor:pointer; }
|
||||
.node { background:#161b22; border:1px solid #30363d; border-radius:6px;
|
||||
padding:10px 12px; margin-bottom:10px; }
|
||||
.node h2 { margin:0 0 6px; font-size:12px; font-weight:600; color:#7cb6ff;
|
||||
font-family:JetBrains Mono,monospace; display:flex; gap:14px; align-items:baseline; }
|
||||
.node h2 .stat { color:#888; font-weight:normal; font-size:11px; }
|
||||
.node h2 .stat b { color:#e6edf3; font-weight:600; }
|
||||
.row { display:grid; grid-template-columns: 1fr 360px; gap:10px; }
|
||||
@media (max-width: 900px) { .row { grid-template-columns: 1fr; } }
|
||||
canvas { display:block; width:100%; background:#0a0e13; border-radius:3px; }
|
||||
canvas.bars { height: 130px; }
|
||||
canvas.trace { height: 130px; }
|
||||
.lbl { color:#666; font-size:10px; font-family:JetBrains Mono,monospace; margin:2px 0 0; }
|
||||
.controls { display:flex; gap:8px; margin-left:auto; }
|
||||
.controls label { font-size:11px; color:#aaa; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>RuView — Raw CSI signals</h1>
|
||||
<p class="sub">Per-node subcarrier amplitudes + RSSI/broadband traces. No DSP, no classification. Stream straight from the sensor.</p>
|
||||
|
||||
<div class="topbar">
|
||||
<span id="status" class="pill dis">disconnected</span>
|
||||
<span class="pill" id="rate">0 fps</span>
|
||||
<span class="pill" id="lastTs">last: --</span>
|
||||
<div class="controls">
|
||||
<label>peak-hold <input type="checkbox" id="peakHold" checked></label>
|
||||
<label>log-y <input type="checkbox" id="logY"></label>
|
||||
<button onclick="resetState()">reset</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="nodes"></div>
|
||||
|
||||
<script>
|
||||
// ── State ──────────────────────────────────────────────────────────
|
||||
const TRACE_SEC = 30; // seconds of history per node
|
||||
const TRACE_MAX_PTS = 1200; // safety cap
|
||||
const state = new Map(); // node_id -> { amp, peak, rssiHist[], meanAmpHist[], lastTs, frames }
|
||||
let frameCount = 0;
|
||||
let lastRateTs = performance.now();
|
||||
let rateFps = 0;
|
||||
let logY = false;
|
||||
let peakHold = true;
|
||||
|
||||
function resetState() {
|
||||
state.clear();
|
||||
document.getElementById('nodes').innerHTML = '';
|
||||
frameCount = 0;
|
||||
}
|
||||
|
||||
document.getElementById('peakHold').addEventListener('change', e => { peakHold = e.target.checked; });
|
||||
document.getElementById('logY').addEventListener('change', e => { logY = e.target.checked; });
|
||||
|
||||
// ── Per-node block factory ─────────────────────────────────────────
|
||||
function ensureNodeBlock(nodeId) {
|
||||
if (state.has(nodeId)) return state.get(nodeId);
|
||||
const ent = {
|
||||
amp: [],
|
||||
peak: [],
|
||||
rssiHist: [], // { t, v }
|
||||
meanAmpHist: [],
|
||||
lastTs: 0,
|
||||
frames: 0,
|
||||
lastFrameWall: performance.now(),
|
||||
fps: 0,
|
||||
};
|
||||
state.set(nodeId, ent);
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'node';
|
||||
wrap.id = 'node-' + nodeId;
|
||||
wrap.innerHTML = `
|
||||
<h2>
|
||||
Node ${nodeId}
|
||||
<span class="stat">subc <b id="n${nodeId}-sub">0</b></span>
|
||||
<span class="stat">rssi <b id="n${nodeId}-rssi">--</b> dBm</span>
|
||||
<span class="stat">mean A <b id="n${nodeId}-meanA">0</b></span>
|
||||
<span class="stat">peak A <b id="n${nodeId}-peakA">0</b></span>
|
||||
<span class="stat">node fps <b id="n${nodeId}-fps">0</b></span>
|
||||
</h2>
|
||||
<div class="row">
|
||||
<div>
|
||||
<canvas class="bars" id="n${nodeId}-bars"></canvas>
|
||||
<p class="lbl">subcarrier amplitude bars (left → low freq, right → high freq)</p>
|
||||
</div>
|
||||
<div>
|
||||
<canvas class="trace" id="n${nodeId}-trace"></canvas>
|
||||
<p class="lbl"><span style="color:#8b949e">RSSI</span> <span style="color:#3fb950">broadband mean amplitude</span> (last ${TRACE_SEC}s)</p>
|
||||
</div>
|
||||
</div>`;
|
||||
document.getElementById('nodes').appendChild(wrap);
|
||||
return ent;
|
||||
}
|
||||
|
||||
// ── Drawing ────────────────────────────────────────────────────────
|
||||
function drawBars(canvas, amps, peaks) {
|
||||
const w = canvas.clientWidth, h = canvas.clientHeight;
|
||||
if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#0a0e13'; ctx.fillRect(0, 0, w, h);
|
||||
if (!amps.length) return;
|
||||
|
||||
// Determine scale
|
||||
let maxV = peakHold && peaks.length
|
||||
? Math.max(...peaks)
|
||||
: Math.max(...amps);
|
||||
if (!isFinite(maxV) || maxV <= 0) maxV = 1;
|
||||
|
||||
const n = amps.length;
|
||||
const bw = w / n;
|
||||
const margin = 4;
|
||||
|
||||
// Bars
|
||||
for (let i = 0; i < n; i++) {
|
||||
let v = amps[i];
|
||||
let pv = peaks[i] || 0;
|
||||
if (logY) {
|
||||
v = v > 0 ? Math.log10(v + 1) : 0;
|
||||
pv = pv > 0 ? Math.log10(pv + 1) : 0;
|
||||
}
|
||||
const scaleMax = logY ? Math.log10(maxV + 1) : maxV;
|
||||
const bh = Math.max(1, (v / scaleMax) * (h - margin));
|
||||
const ph = Math.max(1, (pv / scaleMax) * (h - margin));
|
||||
const x = i * bw;
|
||||
// peak (faint)
|
||||
if (peakHold && pv > 0) {
|
||||
ctx.fillStyle = '#1f3a5a';
|
||||
ctx.fillRect(x, h - ph, Math.max(1, bw - 1), 1.5);
|
||||
}
|
||||
// bar (active)
|
||||
const hue = 200 + (i / n) * 100;
|
||||
ctx.fillStyle = `hsl(${hue}, 70%, 55%)`;
|
||||
ctx.fillRect(x, h - bh, Math.max(1, bw - 1), bh);
|
||||
}
|
||||
|
||||
// Y-axis label
|
||||
ctx.fillStyle = '#555'; ctx.font = '9px monospace';
|
||||
ctx.fillText('max=' + maxV.toFixed(0), 4, 10);
|
||||
ctx.fillText('n=' + n, w - 40, 10);
|
||||
}
|
||||
|
||||
function drawTrace(canvas, rssiHist, meanAmpHist) {
|
||||
const w = canvas.clientWidth, h = canvas.clientHeight;
|
||||
if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#0a0e13'; ctx.fillRect(0, 0, w, h);
|
||||
|
||||
const now = performance.now() / 1000;
|
||||
const t0 = now - TRACE_SEC;
|
||||
|
||||
const drawSeries = (arr, color, getRange) => {
|
||||
if (arr.length < 2) return;
|
||||
const visible = arr.filter(p => p.t >= t0);
|
||||
if (visible.length < 2) return;
|
||||
const { min, max } = getRange(visible);
|
||||
const span = (max - min) || 1;
|
||||
ctx.strokeStyle = color; ctx.lineWidth = 1.5; ctx.beginPath();
|
||||
for (let i = 0; i < visible.length; i++) {
|
||||
const p = visible[i];
|
||||
const x = ((p.t - t0) / TRACE_SEC) * w;
|
||||
const y = h - ((p.v - min) / span) * (h - 8) - 4;
|
||||
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
// y-range text
|
||||
ctx.fillStyle = color; ctx.font = '9px monospace';
|
||||
return { min, max };
|
||||
};
|
||||
|
||||
const rssiR = drawSeries(rssiHist, '#8b949e', arr => {
|
||||
const vals = arr.map(p => p.v);
|
||||
return { min: Math.min(...vals), max: Math.max(...vals) };
|
||||
});
|
||||
const ampR = drawSeries(meanAmpHist, '#3fb950', arr => {
|
||||
const vals = arr.map(p => p.v);
|
||||
return { min: 0, max: Math.max(...vals) };
|
||||
});
|
||||
|
||||
// labels
|
||||
ctx.font = '9px monospace';
|
||||
if (rssiR) { ctx.fillStyle = '#8b949e'; ctx.fillText(`rssi ${rssiR.min.toFixed(0)}…${rssiR.max.toFixed(0)} dBm`, 4, 10); }
|
||||
if (ampR) { ctx.fillStyle = '#3fb950'; ctx.fillText(`A ${ampR.min.toFixed(0)}…${ampR.max.toFixed(0)}`, 4, 22); }
|
||||
|
||||
// grid line at now
|
||||
ctx.strokeStyle = '#1c2128'; ctx.beginPath();
|
||||
ctx.moveTo(w - 1, 0); ctx.lineTo(w - 1, h); ctx.stroke();
|
||||
}
|
||||
|
||||
// ── Frame ingestion ────────────────────────────────────────────────
|
||||
function handleSensingUpdate(d) {
|
||||
const nodes = d.nodes || [];
|
||||
const ts = d.timestamp || (Date.now() / 1000);
|
||||
const now = performance.now() / 1000;
|
||||
for (const n of nodes) {
|
||||
const id = n.node_id;
|
||||
const amps = n.amplitude || [];
|
||||
if (!amps.length) continue;
|
||||
const ent = ensureNodeBlock(id);
|
||||
ent.amp = amps;
|
||||
// peak-hold update
|
||||
if (ent.peak.length !== amps.length) ent.peak = amps.slice();
|
||||
else for (let i = 0; i < amps.length; i++) if (amps[i] > ent.peak[i]) ent.peak[i] = amps[i];
|
||||
|
||||
const meanA = amps.reduce((s, x) => s + x, 0) / amps.length;
|
||||
ent.rssiHist.push({ t: now, v: n.rssi_dbm });
|
||||
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);
|
||||
|
||||
// per-node fps (EMA)
|
||||
const dt = now - (ent.lastFrameWall / 1000);
|
||||
if (dt > 0 && dt < 5) {
|
||||
const inst = 1 / dt;
|
||||
ent.fps = ent.fps ? ent.fps * 0.9 + inst * 0.1 : inst;
|
||||
}
|
||||
ent.lastFrameWall = performance.now();
|
||||
ent.frames++;
|
||||
ent.lastTs = ts;
|
||||
|
||||
document.getElementById(`n${id}-sub`).textContent = amps.length;
|
||||
document.getElementById(`n${id}-rssi`).textContent = n.rssi_dbm.toFixed(1);
|
||||
document.getElementById(`n${id}-meanA`).textContent = meanA.toFixed(1);
|
||||
document.getElementById(`n${id}-peakA`).textContent = Math.max(...ent.peak).toFixed(1);
|
||||
document.getElementById(`n${id}-fps`).textContent = ent.fps.toFixed(1);
|
||||
}
|
||||
|
||||
document.getElementById('lastTs').textContent = 'last: ' + new Date(ts * 1000).toLocaleTimeString();
|
||||
frameCount++;
|
||||
}
|
||||
|
||||
function renderTick() {
|
||||
for (const [id, ent] of state) {
|
||||
const bars = document.getElementById('n' + id + '-bars');
|
||||
const trace = document.getElementById('n' + id + '-trace');
|
||||
if (bars) drawBars(bars, ent.amp, ent.peak);
|
||||
if (trace) drawTrace(trace, ent.rssiHist, ent.meanAmpHist);
|
||||
}
|
||||
// fps pill
|
||||
const now = performance.now();
|
||||
if (now - lastRateTs > 500) {
|
||||
rateFps = (frameCount * 1000) / (now - lastRateTs);
|
||||
document.getElementById('rate').textContent = rateFps.toFixed(1) + ' fps total';
|
||||
frameCount = 0;
|
||||
lastRateTs = now;
|
||||
}
|
||||
requestAnimationFrame(renderTick);
|
||||
}
|
||||
requestAnimationFrame(renderTick);
|
||||
|
||||
// ── WS ─────────────────────────────────────────────────────────────
|
||||
function connect() {
|
||||
const ws = new WebSocket('ws://' + location.hostname + ':8765/ws/sensing');
|
||||
ws.onopen = () => {
|
||||
const p = document.getElementById('status');
|
||||
p.textContent = 'connected'; p.className = 'pill ok';
|
||||
};
|
||||
ws.onclose = () => {
|
||||
const p = document.getElementById('status');
|
||||
p.textContent = 'disconnected — reconnecting'; p.className = 'pill dis';
|
||||
setTimeout(connect, 1500);
|
||||
};
|
||||
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