feat(sensing-server): wire ADR-099 introspection tap + /ws/introspection + /api/v1/introspection/snapshot
I3 (per ADR-099). Three changes in main.rs:
1) AppStateInner: + intro: IntrospectionState + intro_tx: broadcast::Sender<String>
(256-slot ring, same shape as the existing tx).
2) ESP32 frame path: after the global frame_history push, before the
per-node mutable borrow of s.node_states, compute the per-frame derived
feature (mean amplitude across subcarriers), call s.intro.update(ts_ns,
feature), and broadcast the snapshot JSON to s.intro_tx. Placement is
deliberate — between the global state's mutable touch and the per-node
&mut so borrow-checking stays linear; ns is borrowed *after* the tap
completes its s.intro / s.intro_tx access.
3) Routes:
ws_introspection_handler → /ws/introspection
api_introspection_snapshot → /api/v1/introspection/snapshot
Same Axum + tokio::sync::broadcast pattern as ws_sensing_handler,
subscribed against s.intro_tx. Wrapped by the bearer-auth middleware
already on /api/v1/* — orchestrator probes and unauthenticated /ws/sensing
reachers continue to land on the existing topic.
Verified:
cargo build -p wifi-densepose-sensing-server --no-default-features ✓
cargo test -p wifi-densepose-sensing-server --no-default-features
lib: 207 passed, 0 failed (199 pre-tap + 8 introspection)
integration suites: 70, 8, 16, 18 passed, 0 failed
cargo clippy: clean on the introspection surface (pre-existing warnings
on -core / -ruvector / -signal unchanged).
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
94ef125240
commit
4a1f3a1e10
|
|
@ -553,6 +553,11 @@ struct AppStateInner {
|
||||||
/// Instant of the last ESP32 UDP frame received (for offline detection).
|
/// Instant of the last ESP32 UDP frame received (for offline detection).
|
||||||
last_esp32_frame: Option<std::time::Instant>,
|
last_esp32_frame: Option<std::time::Instant>,
|
||||||
tx: broadcast::Sender<String>,
|
tx: broadcast::Sender<String>,
|
||||||
|
// ADR-099 D2/D3/D4: real-time CSI introspection tap. Per-frame state +
|
||||||
|
// a parallel broadcast topic (`/ws/introspection`) running alongside
|
||||||
|
// (not replacing) the window-aggregated `tx` / `/ws/sensing` pipeline.
|
||||||
|
intro: wifi_densepose_sensing_server::introspection::IntrospectionState,
|
||||||
|
intro_tx: broadcast::Sender<String>,
|
||||||
total_detections: u64,
|
total_detections: u64,
|
||||||
start_time: std::time::Instant,
|
start_time: std::time::Instant,
|
||||||
/// Vital sign detector (processes CSI frames to estimate HR/RR).
|
/// Vital sign detector (processes CSI frames to estimate HR/RR).
|
||||||
|
|
@ -2027,6 +2032,59 @@ async fn handle_ws_client(mut socket: WebSocket, state: SharedState) {
|
||||||
info!("WebSocket client disconnected (sensing)");
|
info!("WebSocket client disconnected (sensing)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── ADR-099: real-time CSI introspection — WS topic + REST snapshot ──────────
|
||||||
|
//
|
||||||
|
// Parallel to the window-aggregated `/ws/sensing` topic. Subscribers see a
|
||||||
|
// fresh `IntrospectionSnapshot` JSON frame on every accepted CSI frame
|
||||||
|
// (regime / Lyapunov exponent / top-k DTW similarity), no window-close delay.
|
||||||
|
|
||||||
|
async fn ws_introspection_handler(
|
||||||
|
ws: WebSocketUpgrade,
|
||||||
|
State(state): State<SharedState>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
ws.on_upgrade(|socket| handle_ws_introspection_client(socket, state))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_ws_introspection_client(mut socket: WebSocket, state: SharedState) {
|
||||||
|
let mut rx = {
|
||||||
|
let s = state.read().await;
|
||||||
|
s.intro_tx.subscribe()
|
||||||
|
};
|
||||||
|
|
||||||
|
info!("WebSocket client connected (introspection)");
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
msg = rx.recv() => {
|
||||||
|
match msg {
|
||||||
|
Ok(json) => {
|
||||||
|
if socket.send(Message::Text(json.into())).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
msg = socket.recv() => {
|
||||||
|
match msg {
|
||||||
|
Some(Ok(Message::Close(_))) | None => break,
|
||||||
|
_ => {} // ignore client messages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("WebSocket client disconnected (introspection)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/v1/introspection/snapshot` — one-shot poll for the latest
|
||||||
|
/// per-frame snapshot (regime, Lyapunov, top-k similarity). Mirrors the shape
|
||||||
|
/// of `/api/v1/sensing/latest` for the dashboard one-shot path.
|
||||||
|
async fn api_introspection_snapshot(State(state): State<SharedState>) -> impl IntoResponse {
|
||||||
|
let s = state.read().await;
|
||||||
|
Json(s.intro.snapshot().clone())
|
||||||
|
}
|
||||||
|
|
||||||
// ── Pose WebSocket handler (sends pose_data messages for Live Demo) ──────────
|
// ── Pose WebSocket handler (sends pose_data messages for Live Demo) ──────────
|
||||||
|
|
||||||
async fn ws_pose_handler(
|
async fn ws_pose_handler(
|
||||||
|
|
@ -3871,6 +3929,30 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||||
s.frame_history.pop_front();
|
s.frame_history.pop_front();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── ADR-099: real-time introspection tap ────────────────
|
||||||
|
// Per-frame update of the attractor / DTW pipeline running
|
||||||
|
// parallel to the window-aggregated event path. Placed
|
||||||
|
// BEFORE the per-node `&mut` borrow of `s.node_states` so
|
||||||
|
// `s.intro` / `s.intro_tx` stay reachable. Never window-
|
||||||
|
// blocked; `/ws/introspection` sees a fresh snapshot on
|
||||||
|
// every accepted frame.
|
||||||
|
{
|
||||||
|
let intro_feature = if frame.amplitudes.is_empty() {
|
||||||
|
0.0
|
||||||
|
} else {
|
||||||
|
frame.amplitudes.iter().copied().sum::<f64>()
|
||||||
|
/ frame.amplitudes.len() as f64
|
||||||
|
};
|
||||||
|
let intro_ts_ns = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos() as u64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let _ = s.intro.update(intro_ts_ns, intro_feature);
|
||||||
|
if let Ok(intro_json) = serde_json::to_string(s.intro.snapshot()) {
|
||||||
|
let _ = s.intro_tx.send(intro_json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Per-node processing (issue #249) ──────────────────
|
// ── Per-node processing (issue #249) ──────────────────
|
||||||
// Process entirely within per-node state so different
|
// Process entirely within per-node state so different
|
||||||
// ESP32 nodes never mix their smoothing/vitals buffers.
|
// ESP32 nodes never mix their smoothing/vitals buffers.
|
||||||
|
|
@ -4767,6 +4849,10 @@ async fn main() {
|
||||||
info!("Discovered {} model files, {} recording files", initial_models.len(), initial_recordings.len());
|
info!("Discovered {} model files, {} recording files", initial_models.len(), initial_recordings.len());
|
||||||
|
|
||||||
let (tx, _) = broadcast::channel::<String>(256);
|
let (tx, _) = broadcast::channel::<String>(256);
|
||||||
|
// ADR-099: parallel broadcast for the per-frame introspection snapshot stream
|
||||||
|
// consumed by `/ws/introspection`. Same ring size as `tx` (256) — slow
|
||||||
|
// clients drop oldest, identical backpressure shape.
|
||||||
|
let (intro_tx, _) = broadcast::channel::<String>(256);
|
||||||
let state: SharedState = Arc::new(RwLock::new(AppStateInner {
|
let state: SharedState = Arc::new(RwLock::new(AppStateInner {
|
||||||
latest_update: None,
|
latest_update: None,
|
||||||
rssi_history: VecDeque::new(),
|
rssi_history: VecDeque::new(),
|
||||||
|
|
@ -4775,6 +4861,8 @@ async fn main() {
|
||||||
source: source.into(),
|
source: source.into(),
|
||||||
last_esp32_frame: None,
|
last_esp32_frame: None,
|
||||||
tx,
|
tx,
|
||||||
|
intro: wifi_densepose_sensing_server::introspection::IntrospectionState::new(),
|
||||||
|
intro_tx,
|
||||||
total_detections: 0,
|
total_detections: 0,
|
||||||
start_time: std::time::Instant::now(),
|
start_time: std::time::Instant::now(),
|
||||||
vital_detector: VitalSignDetector::new(vital_sample_rate),
|
vital_detector: VitalSignDetector::new(vital_sample_rate),
|
||||||
|
|
@ -4936,6 +5024,9 @@ async fn main() {
|
||||||
.route("/api/v1/stream/pose", get(ws_pose_handler))
|
.route("/api/v1/stream/pose", get(ws_pose_handler))
|
||||||
// Sensing WebSocket on the HTTP port so the UI can reach it without a second port
|
// Sensing WebSocket on the HTTP port so the UI can reach it without a second port
|
||||||
.route("/ws/sensing", get(ws_sensing_handler))
|
.route("/ws/sensing", get(ws_sensing_handler))
|
||||||
|
// ADR-099: real-time introspection — per-frame attractor + DTW snapshot.
|
||||||
|
.route("/ws/introspection", get(ws_introspection_handler))
|
||||||
|
.route("/api/v1/introspection/snapshot", get(api_introspection_snapshot))
|
||||||
// Model management endpoints (UI compatibility)
|
// Model management endpoints (UI compatibility)
|
||||||
.route("/api/v1/models", get(list_models))
|
.route("/api/v1/models", get(list_models))
|
||||||
.route("/api/v1/models/active", get(get_active_model))
|
.route("/api/v1/models/active", get(get_active_model))
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue