mirror of
https://github.com/ruvnet/RuView
synced 2026-06-21 12:13:19 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f927aaedb | |||
| 635c152e61 |
@@ -15,7 +15,7 @@ jobs:
|
||||
name: Build ESP32-S3 Firmware
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: espressif/idf:v5.4
|
||||
image: espressif/idf:v5.2
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -54,10 +54,9 @@ jobs:
|
||||
fi
|
||||
|
||||
# Check partition table magic (0xAA50 at offset 0).
|
||||
# Use od instead of xxd (xxd not available in espressif/idf container).
|
||||
PT=build/partition_table/partition-table.bin
|
||||
if [ -f "$PT" ]; then
|
||||
MAGIC=$(od -A n -t x1 -N 2 "$PT" | tr -d ' ')
|
||||
MAGIC=$(xxd -l2 -p "$PT")
|
||||
if [ "$MAGIC" != "aa50" ]; then
|
||||
echo "::warning::Partition table magic mismatch: $MAGIC (expected aa50)"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
@@ -72,7 +71,7 @@ jobs:
|
||||
fi
|
||||
|
||||
# Verify non-zero data in binary (not all 0xFF padding).
|
||||
NONZERO=$(od -A n -t x1 -N 1024 "$BIN" | tr -d ' f\n' | wc -c)
|
||||
NONZERO=$(xxd -l 1024 -p "$BIN" | tr -d 'f' | wc -c)
|
||||
if [ "$NONZERO" -lt 100 ]; then
|
||||
echo "::error::Binary appears to be mostly padding (non-zero chars: $NONZERO)"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
@@ -98,5 +97,4 @@ jobs:
|
||||
firmware/esp32-csi-node/build/esp32-csi-node.bin
|
||||
firmware/esp32-csi-node/build/bootloader/bootloader.bin
|
||||
firmware/esp32-csi-node/build/partition_table/partition-table.bin
|
||||
firmware/esp32-csi-node/build/ota_data_initial.bin
|
||||
retention-days: 90
|
||||
retention-days: 30
|
||||
|
||||
@@ -43,12 +43,6 @@ static const char *TAG = "edge_proc";
|
||||
static edge_ring_buf_t s_ring;
|
||||
static uint32_t s_ring_drops; /* Frames dropped due to full ring buffer. */
|
||||
|
||||
/* Scratch buffers for BPM estimation — moved from stack to static to avoid
|
||||
* stack overflow. process_frame + update_multi_person_vitals combined used
|
||||
* ~6.5-7.5 KB of the 8 KB task stack. These save ~4 KB of stack. */
|
||||
static float s_scratch_br[EDGE_PHASE_HISTORY_LEN];
|
||||
static float s_scratch_hr[EDGE_PHASE_HISTORY_LEN];
|
||||
|
||||
static inline bool ring_push(const uint8_t *iq, uint16_t len,
|
||||
int8_t rssi, uint8_t channel)
|
||||
{
|
||||
@@ -519,18 +513,20 @@ static void update_multi_person_vitals(const uint8_t *iq_data, uint16_t n_sc,
|
||||
|
||||
/* Estimate BPM when we have enough history. */
|
||||
if (pv->history_len >= 64) {
|
||||
/* Build contiguous buffer (reuse static scratch to save ~2 KB stack). */
|
||||
/* Build contiguous buffer for zero-crossing. */
|
||||
float br_buf[EDGE_PHASE_HISTORY_LEN];
|
||||
float hr_buf[EDGE_PHASE_HISTORY_LEN];
|
||||
uint16_t buf_len = pv->history_len;
|
||||
|
||||
for (uint16_t i = 0; i < buf_len; i++) {
|
||||
uint16_t ri = (pv->history_idx + EDGE_PHASE_HISTORY_LEN
|
||||
- buf_len + i) % EDGE_PHASE_HISTORY_LEN;
|
||||
s_scratch_br[i] = s_person_br_filt[p][ri];
|
||||
s_scratch_hr[i] = s_person_hr_filt[p][ri];
|
||||
br_buf[i] = s_person_br_filt[p][ri];
|
||||
hr_buf[i] = s_person_hr_filt[p][ri];
|
||||
}
|
||||
|
||||
float br = estimate_bpm_zero_crossing(s_scratch_br, buf_len, sample_rate);
|
||||
float hr = estimate_bpm_zero_crossing(s_scratch_hr, buf_len, sample_rate);
|
||||
float br = estimate_bpm_zero_crossing(br_buf, buf_len, sample_rate);
|
||||
float hr = estimate_bpm_zero_crossing(hr_buf, buf_len, sample_rate);
|
||||
|
||||
/* Sanity clamp. */
|
||||
if (br >= 6.0f && br <= 40.0f) pv->breathing_bpm = br;
|
||||
@@ -694,18 +690,20 @@ static void process_frame(const edge_ring_slot_t *slot)
|
||||
|
||||
/* --- Step 7: BPM estimation (zero-crossing) --- */
|
||||
if (s_history_len >= 64) {
|
||||
/* Build contiguous buffers from ring (using static scratch to save stack). */
|
||||
/* Build contiguous buffers from ring. */
|
||||
float br_buf[EDGE_PHASE_HISTORY_LEN];
|
||||
float hr_buf[EDGE_PHASE_HISTORY_LEN];
|
||||
uint16_t buf_len = s_history_len;
|
||||
|
||||
for (uint16_t i = 0; i < buf_len; i++) {
|
||||
uint16_t ri = (s_history_idx + EDGE_PHASE_HISTORY_LEN
|
||||
- buf_len + i) % EDGE_PHASE_HISTORY_LEN;
|
||||
s_scratch_br[i] = s_breathing_filtered[ri];
|
||||
s_scratch_hr[i] = s_heartrate_filtered[ri];
|
||||
br_buf[i] = s_breathing_filtered[ri];
|
||||
hr_buf[i] = s_heartrate_filtered[ri];
|
||||
}
|
||||
|
||||
float br_bpm = estimate_bpm_zero_crossing(s_scratch_br, buf_len, sample_rate);
|
||||
float hr_bpm = estimate_bpm_zero_crossing(s_scratch_hr, buf_len, sample_rate);
|
||||
float br_bpm = estimate_bpm_zero_crossing(br_buf, buf_len, sample_rate);
|
||||
float hr_bpm = estimate_bpm_zero_crossing(hr_buf, buf_len, sample_rate);
|
||||
|
||||
/* Sanity clamp: breathing 6-40 BPM, heart rate 40-180 BPM. */
|
||||
if (br_bpm >= 6.0f && br_bpm <= 40.0f) s_breathing_bpm = br_bpm;
|
||||
@@ -841,11 +839,12 @@ static void edge_task(void *arg)
|
||||
* Without a batch limit the task processes frames back-to-back with
|
||||
* only 1-tick yields, which on high frame rates can still starve
|
||||
* IDLE1 enough to trip the 5-second task watchdog. See #266, #321. */
|
||||
const uint8_t BATCH_LIMIT = 4;
|
||||
|
||||
while (1) {
|
||||
uint8_t processed = 0;
|
||||
|
||||
while (processed < EDGE_BATCH_LIMIT && ring_pop(&slot)) {
|
||||
while (processed < BATCH_LIMIT && ring_pop(&slot)) {
|
||||
process_frame(&slot);
|
||||
processed++;
|
||||
/* 1-tick yield between frames within a batch. */
|
||||
@@ -853,10 +852,10 @@ static void edge_task(void *arg)
|
||||
}
|
||||
|
||||
if (processed > 0) {
|
||||
/* Post-batch yield: ~20 ms so IDLE1 can run and feed the
|
||||
* Core 1 watchdog even under sustained load. Uses pdMS_TO_TICKS
|
||||
* for tick-rate independence (minimum 1 tick). */
|
||||
{ TickType_t d = pdMS_TO_TICKS(20); vTaskDelay(d > 0 ? d : 1); }
|
||||
/* Post-batch yield: 2 ticks (~20 ms at 100 Hz) so IDLE1 can
|
||||
* run and feed the Core 1 watchdog even under sustained load.
|
||||
* This is intentionally longer than the 1-tick inter-frame yield. */
|
||||
vTaskDelay(2);
|
||||
} else {
|
||||
/* No frames available — sleep one full tick.
|
||||
* NOTE: pdMS_TO_TICKS(5) == 0 at 100 Hz, which would busy-spin. */
|
||||
|
||||
@@ -46,9 +46,6 @@
|
||||
#define EDGE_FALL_COOLDOWN_MS 5000 /**< Minimum ms between fall alerts (debounce). */
|
||||
#define EDGE_FALL_CONSEC_MIN 3 /**< Consecutive frames above threshold to trigger. */
|
||||
|
||||
/* ---- DSP task tuning ---- */
|
||||
#define EDGE_BATCH_LIMIT 4 /**< Max frames per batch before longer yield. */
|
||||
|
||||
/* ---- SPSC ring buffer slot ---- */
|
||||
typedef struct {
|
||||
uint8_t iq_data[EDGE_MAX_IQ_BYTES]; /**< Raw I/Q bytes from CSI callback. */
|
||||
|
||||
@@ -21,4 +21,3 @@ pub use bvp::attention_weighted_bvp;
|
||||
pub use fresnel::solve_fresnel_geometry;
|
||||
pub use spectrogram::gate_spectrogram;
|
||||
pub use subcarrier::mincut_subcarrier_partition;
|
||||
pub use subcarrier::subcarrier_importance_weights;
|
||||
|
||||
@@ -142,29 +142,6 @@ pub fn mincut_subcarrier_partition(sensitivity: &[f32]) -> (Vec<usize>, Vec<usiz
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a mincut partition into per-subcarrier importance weights.
|
||||
///
|
||||
/// Sensitive subcarriers (high body-motion correlation) get weight > 1.0,
|
||||
/// insensitive ones get weight 0.5. This allows downstream feature extraction
|
||||
/// to emphasise the most informative subcarriers.
|
||||
pub fn subcarrier_importance_weights(sensitivity: &[f32]) -> Vec<f32> {
|
||||
if sensitivity.is_empty() {
|
||||
return vec![];
|
||||
}
|
||||
let (sensitive, _insensitive) = mincut_subcarrier_partition(sensitivity);
|
||||
let max_sens = sensitivity
|
||||
.iter()
|
||||
.cloned()
|
||||
.fold(f32::NEG_INFINITY, f32::max)
|
||||
.max(1e-9);
|
||||
|
||||
let mut weights = vec![0.5f32; sensitivity.len()];
|
||||
for &idx in &sensitive {
|
||||
weights[idx] = 1.0 + (sensitivity[idx] / max_sens).min(1.0);
|
||||
}
|
||||
weights
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -198,38 +175,4 @@ mod tests {
|
||||
assert_eq!(s, vec![0]);
|
||||
assert!(i.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_importance_weights_empty() {
|
||||
let w = subcarrier_importance_weights(&[]);
|
||||
assert!(w.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_importance_weights_all_equal() {
|
||||
let sensitivity = vec![1.0f32; 8];
|
||||
let w = subcarrier_importance_weights(&sensitivity);
|
||||
assert_eq!(w.len(), 8);
|
||||
// All subcarriers have identical sensitivity so all should be classified
|
||||
// the same way (either all sensitive or all insensitive after mincut).
|
||||
// At minimum, no weight should exceed 2.0 or be negative.
|
||||
for &wt in &w {
|
||||
assert!(wt >= 0.5 && wt <= 2.0, "weight {wt} out of range");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_importance_weights_sensitive_higher() {
|
||||
// First 5 subcarriers have high sensitivity, last 5 low.
|
||||
let sensitivity: Vec<f32> = (0..10).map(|i| if i < 5 { 0.9 } else { 0.1 }).collect();
|
||||
let w = subcarrier_importance_weights(&sensitivity);
|
||||
assert_eq!(w.len(), 10);
|
||||
|
||||
let mean_high: f32 = w[..5].iter().sum::<f32>() / 5.0;
|
||||
let mean_low: f32 = w[5..].iter().sum::<f32>() / 5.0;
|
||||
assert!(
|
||||
mean_high > mean_low,
|
||||
"sensitive subcarriers should have higher mean weight ({mean_high}) than insensitive ({mean_low})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -565,25 +565,13 @@ fn parse_esp32_frame(buf: &[u8]) -> Option<Esp32Frame> {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Frame layout (must match firmware csi_collector.c):
|
||||
// [0..3] magic (u32 LE)
|
||||
// [4] node_id (u8)
|
||||
// [5] n_antennas (u8)
|
||||
// [6..7] n_subcarriers (u16 LE)
|
||||
// [8..11] freq_mhz (u32 LE)
|
||||
// [12..15] sequence (u32 LE)
|
||||
// [16] rssi (i8)
|
||||
// [17] noise_floor (i8)
|
||||
// [18..19] reserved
|
||||
// [20..] I/Q data
|
||||
let node_id = buf[4];
|
||||
let n_antennas = buf[5];
|
||||
let n_subcarriers_u16 = u16::from_le_bytes([buf[6], buf[7]]);
|
||||
let n_subcarriers = n_subcarriers_u16 as u8; // truncate to u8 for Esp32Frame compat
|
||||
let freq_mhz = u16::from_le_bytes([buf[8], buf[9]]); // low 16 bits of u32
|
||||
let sequence = u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]);
|
||||
let rssi = buf[16] as i8; // #332: was buf[14], 2 bytes off
|
||||
let noise_floor = buf[17] as i8; // #332: was buf[15], 2 bytes off
|
||||
let n_subcarriers = buf[6];
|
||||
let freq_mhz = u16::from_le_bytes([buf[8], buf[9]]);
|
||||
let sequence = u32::from_le_bytes([buf[10], buf[11], buf[12], buf[13]]);
|
||||
let rssi = buf[14] as i8;
|
||||
let noise_floor = buf[15] as i8;
|
||||
|
||||
let iq_start = 20;
|
||||
let n_pairs = n_antennas as usize * n_subcarriers as usize;
|
||||
@@ -804,40 +792,6 @@ fn estimate_breathing_rate_hz(frame_history: &VecDeque<Vec<f64>>, sample_rate_hz
|
||||
/// For each subcarrier index `k`, returns `Var[A_k]` over all stored frames.
|
||||
/// This captures spatial signal variation; subcarriers whose amplitude fluctuates
|
||||
/// heavily across time correspond to directions with motion.
|
||||
/// Compute per-subcarrier importance weights using a simple sensitivity split.
|
||||
///
|
||||
/// Subcarriers whose sensitivity (amplitude magnitude) is above the median are
|
||||
/// considered "sensitive" and receive weight `1.0 + (sens / max_sens)` (range 1.0–2.0).
|
||||
/// The rest receive a baseline weight of 0.5. This mirrors the RuVector mincut
|
||||
/// partition logic without requiring the graph dependency.
|
||||
fn compute_subcarrier_importance_weights(sensitivity: &[f64]) -> Vec<f64> {
|
||||
let n = sensitivity.len();
|
||||
if n == 0 {
|
||||
return vec![];
|
||||
}
|
||||
let max_sens = sensitivity.iter().cloned().fold(f64::NEG_INFINITY, f64::max).max(1e-9);
|
||||
|
||||
// Compute median via a sorted copy.
|
||||
let mut sorted = sensitivity.to_vec();
|
||||
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let median = if n % 2 == 0 {
|
||||
(sorted[n / 2 - 1] + sorted[n / 2]) / 2.0
|
||||
} else {
|
||||
sorted[n / 2]
|
||||
};
|
||||
|
||||
sensitivity
|
||||
.iter()
|
||||
.map(|&s| {
|
||||
if s >= median {
|
||||
1.0 + (s / max_sens).min(1.0)
|
||||
} else {
|
||||
0.5
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn compute_subcarrier_variances(frame_history: &VecDeque<Vec<f64>>, n_sub: usize) -> Vec<f64> {
|
||||
if frame_history.is_empty() || n_sub == 0 {
|
||||
return vec![0.0; n_sub];
|
||||
@@ -886,34 +840,13 @@ fn extract_features_from_frame(
|
||||
) -> (FeatureInfo, ClassificationInfo, f64, Vec<f64>, f64) {
|
||||
let n_sub = frame.amplitudes.len().max(1);
|
||||
let n = n_sub as f64;
|
||||
let mean_amp: f64 = frame.amplitudes.iter().sum::<f64>() / n;
|
||||
let mean_rssi = frame.rssi as f64;
|
||||
|
||||
// ── RuVector Phase 1: subcarrier importance weighting ──
|
||||
// Compute per-subcarrier sensitivity from amplitude magnitude, then weight
|
||||
// sensitive subcarriers higher (>1.0) and insensitive ones lower (0.5).
|
||||
// This emphasises body-motion-correlated subcarriers in all downstream metrics.
|
||||
let sub_sensitivity: Vec<f64> = frame.amplitudes.iter().map(|a| a.abs()).collect();
|
||||
let importance_weights = compute_subcarrier_importance_weights(&sub_sensitivity);
|
||||
|
||||
let weight_sum: f64 = importance_weights.iter().sum::<f64>();
|
||||
let mean_amp: f64 = if weight_sum > 0.0 {
|
||||
frame.amplitudes.iter().zip(importance_weights.iter())
|
||||
.map(|(a, w)| a * w)
|
||||
.sum::<f64>() / weight_sum
|
||||
} else {
|
||||
frame.amplitudes.iter().sum::<f64>() / n
|
||||
};
|
||||
|
||||
// ── Intra-frame subcarrier variance (weighted by importance) ──
|
||||
let intra_variance: f64 = if weight_sum > 0.0 {
|
||||
frame.amplitudes.iter().zip(importance_weights.iter())
|
||||
.map(|(a, w)| w * (a - mean_amp).powi(2))
|
||||
.sum::<f64>() / weight_sum
|
||||
} else {
|
||||
frame.amplitudes.iter()
|
||||
.map(|a| (a - mean_amp).powi(2))
|
||||
.sum::<f64>() / n
|
||||
};
|
||||
// ── Intra-frame subcarrier variance (spatial spread across subcarriers) ──
|
||||
let intra_variance: f64 = frame.amplitudes.iter()
|
||||
.map(|a| (a - mean_amp).powi(2))
|
||||
.sum::<f64>() / n;
|
||||
|
||||
// ── Temporal (sliding-window) per-subcarrier variance ──
|
||||
let sub_variances = compute_subcarrier_variances(frame_history, n_sub);
|
||||
@@ -3184,10 +3117,7 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
// We scope the mutable borrow of node_states so we can
|
||||
// access other AppStateInner fields afterward.
|
||||
let node_id = frame.node_id;
|
||||
// Clone adaptive model before mutable borrow of node_states
|
||||
// to avoid unsafe raw pointer (review finding #2).
|
||||
let adaptive_model_clone = s.adaptive_model.clone();
|
||||
|
||||
let adaptive_model_ref = s.adaptive_model.as_ref().map(|m| m as *const _);
|
||||
let ns = s.node_states.entry(node_id).or_insert_with(NodeState::new);
|
||||
ns.last_frame_time = Some(std::time::Instant::now());
|
||||
|
||||
@@ -3201,8 +3131,12 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
extract_features_from_frame(&frame, &ns.frame_history, sample_rate_hz);
|
||||
smooth_and_classify_node(ns, &mut classification, raw_motion);
|
||||
|
||||
// Adaptive override using cloned model (safe, no raw pointers).
|
||||
if let Some(ref model) = adaptive_model_clone {
|
||||
// SAFETY: adaptive_model_ref points into s which we hold
|
||||
// via write lock; the model is not mutated here. We use a
|
||||
// raw pointer to break the borrow-checker deadlock between
|
||||
// node_states and adaptive_model (both inside s).
|
||||
if let Some(model_ptr) = adaptive_model_ref {
|
||||
let model: &adaptive_classifier::AdaptiveModel = unsafe { &*model_ptr };
|
||||
let amps = ns.frame_history.back()
|
||||
.map(|v| v.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
@@ -3317,19 +3251,6 @@ async fn udp_receiver_task(state: SharedState, udp_port: u16) {
|
||||
let _ = s.tx.send(json);
|
||||
}
|
||||
s.latest_update = Some(update);
|
||||
|
||||
// Evict stale nodes every 100 ticks to prevent memory leak.
|
||||
if tick % 100 == 0 {
|
||||
let stale = Duration::from_secs(60);
|
||||
let before = s.node_states.len();
|
||||
s.node_states.retain(|_id, ns| {
|
||||
ns.last_frame_time.map_or(false, |t| now.duration_since(t) < stale)
|
||||
});
|
||||
let evicted = before - s.node_states.len();
|
||||
if evicted > 0 {
|
||||
info!("Evicted {} stale node(s), {} active", evicted, s.node_states.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
-233
@@ -1,233 +0,0 @@
|
||||
//! Integration test: multi-node per-node state isolation (ADR-068, #249).
|
||||
//!
|
||||
//! Sends simulated ESP32 CSI frames from multiple node IDs to the server's
|
||||
//! UDP port and verifies that:
|
||||
//! 1. Each node gets independent state (no cross-contamination)
|
||||
//! 2. Person count aggregates across active nodes
|
||||
//! 3. Stale nodes are excluded from aggregation
|
||||
//!
|
||||
//! This does NOT require QEMU — it sends raw UDP packets directly.
|
||||
|
||||
use std::net::UdpSocket;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Build a minimal valid ESP32 CSI frame (magic 0xC511_0001).
|
||||
///
|
||||
/// Format (ADR-018):
|
||||
/// [0..3] magic: 0xC511_0001 (LE)
|
||||
/// [4] node_id
|
||||
/// [5] n_antennas (1)
|
||||
/// [6] n_subcarriers (e.g., 32)
|
||||
/// [7] reserved
|
||||
/// [8..9] freq_mhz (2437 = channel 6)
|
||||
/// [10..13] sequence (LE u32)
|
||||
/// [14] rssi (signed)
|
||||
/// [15] noise_floor
|
||||
/// [16..19] reserved
|
||||
/// [20..] I/Q pairs (n_antennas * n_subcarriers * 2 bytes)
|
||||
fn build_csi_frame(node_id: u8, seq: u32, rssi: i8, n_sub: u8) -> Vec<u8> {
|
||||
let n_pairs = n_sub as usize;
|
||||
let mut buf = vec![0u8; 20 + n_pairs * 2];
|
||||
|
||||
// Magic
|
||||
let magic: u32 = 0xC511_0001;
|
||||
buf[0..4].copy_from_slice(&magic.to_le_bytes());
|
||||
|
||||
buf[4] = node_id;
|
||||
buf[5] = 1; // n_antennas
|
||||
buf[6] = n_sub;
|
||||
buf[7] = 0;
|
||||
|
||||
// freq = 2437 MHz (channel 6)
|
||||
let freq: u16 = 2437;
|
||||
buf[8..10].copy_from_slice(&freq.to_le_bytes());
|
||||
|
||||
// sequence
|
||||
buf[10..14].copy_from_slice(&seq.to_le_bytes());
|
||||
|
||||
buf[14] = rssi as u8;
|
||||
buf[15] = (-90i8) as u8; // noise floor
|
||||
|
||||
// Generate I/Q pairs with node-specific patterns.
|
||||
// Different nodes produce different amplitude patterns so the server
|
||||
// computes different features for each.
|
||||
for i in 0..n_pairs {
|
||||
let phase = (i as f64 + node_id as f64 * 0.5) * 0.3;
|
||||
let amplitude = 20.0 + (node_id as f64) * 5.0 + (phase.sin() * 10.0);
|
||||
let i_val = (amplitude * phase.cos()) as i8;
|
||||
let q_val = (amplitude * phase.sin()) as i8;
|
||||
buf[20 + i * 2] = i_val as u8;
|
||||
buf[20 + i * 2 + 1] = q_val as u8;
|
||||
}
|
||||
|
||||
buf
|
||||
}
|
||||
|
||||
/// Build an edge vitals packet (magic 0xC511_0002).
|
||||
fn build_vitals_packet(node_id: u8, presence: bool, n_persons: u8, rssi: i8) -> Vec<u8> {
|
||||
let mut buf = vec![0u8; 32];
|
||||
|
||||
let magic: u32 = 0xC511_0002;
|
||||
buf[0..4].copy_from_slice(&magic.to_le_bytes());
|
||||
|
||||
buf[4] = node_id;
|
||||
buf[5] = if presence { 0x01 } else { 0x00 }; // flags
|
||||
// breathing_rate (u16 LE) = 15.0 * 100 = 1500
|
||||
buf[6..8].copy_from_slice(&1500u16.to_le_bytes());
|
||||
// heartrate (u32 LE) = 72.0 * 10000 = 720000
|
||||
buf[8..12].copy_from_slice(&720000u32.to_le_bytes());
|
||||
buf[12] = rssi as u8;
|
||||
buf[13] = n_persons;
|
||||
// bytes 14-15: reserved
|
||||
// motion_energy (f32 LE)
|
||||
let me: f32 = if presence { 0.5 } else { 0.0 };
|
||||
buf[16..20].copy_from_slice(&me.to_le_bytes());
|
||||
// presence_score (f32 LE)
|
||||
let ps: f32 = if presence { 0.8 } else { 0.0 };
|
||||
buf[20..24].copy_from_slice(&ps.to_le_bytes());
|
||||
// timestamp_ms (u32 LE)
|
||||
buf[24..28].copy_from_slice(&1000u32.to_le_bytes());
|
||||
|
||||
buf
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_csi_frame_builder_valid() {
|
||||
let frame = build_csi_frame(1, 0, -50, 32);
|
||||
assert_eq!(frame.len(), 20 + 32 * 2);
|
||||
assert_eq!(u32::from_le_bytes([frame[0], frame[1], frame[2], frame[3]]), 0xC511_0001);
|
||||
assert_eq!(frame[4], 1); // node_id
|
||||
assert_eq!(frame[5], 1); // n_antennas
|
||||
assert_eq!(frame[6], 32); // n_subcarriers
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vitals_packet_builder_valid() {
|
||||
let pkt = build_vitals_packet(2, true, 1, -45);
|
||||
assert_eq!(pkt.len(), 32);
|
||||
assert_eq!(u32::from_le_bytes([pkt[0], pkt[1], pkt[2], pkt[3]]), 0xC511_0002);
|
||||
assert_eq!(pkt[4], 2); // node_id
|
||||
assert_eq!(pkt[5], 0x01); // flags: presence
|
||||
assert_eq!(pkt[13], 1); // n_persons
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_nodes_produce_different_frames() {
|
||||
let frame1 = build_csi_frame(1, 0, -50, 32);
|
||||
let frame2 = build_csi_frame(2, 0, -50, 32);
|
||||
// I/Q data should differ due to node_id-based amplitude offset
|
||||
assert_ne!(&frame1[20..], &frame2[20..]);
|
||||
}
|
||||
|
||||
/// Send multiple frames from different nodes to a UDP port.
|
||||
/// This test verifies the packet format is accepted by a real server
|
||||
/// if one is running, but doesn't fail if no server is available.
|
||||
#[test]
|
||||
fn test_multi_node_udp_send() {
|
||||
// Try to bind to a random port and send to localhost:5005
|
||||
// This is a smoke test — it verifies frames can be sent without panic.
|
||||
let sock = UdpSocket::bind("0.0.0.0:0").expect("bind");
|
||||
sock.set_write_timeout(Some(Duration::from_millis(100))).ok();
|
||||
|
||||
let n_sub = 32u8;
|
||||
let node_ids = [1u8, 2, 3, 5, 7];
|
||||
|
||||
for &nid in &node_ids {
|
||||
for seq in 0..10u32 {
|
||||
let frame = build_csi_frame(nid, seq, -50 + nid as i8, n_sub);
|
||||
// Send to localhost:5005 (won't fail even if nothing is listening)
|
||||
let _ = sock.send_to(&frame, "127.0.0.1:5005");
|
||||
}
|
||||
}
|
||||
|
||||
// Also send vitals packets
|
||||
for &nid in &node_ids {
|
||||
let pkt = build_vitals_packet(nid, true, 1, -45);
|
||||
let _ = sock.send_to(&pkt, "127.0.0.1:5005");
|
||||
}
|
||||
|
||||
// If we get here without panic, the frame builders work correctly
|
||||
assert!(true, "Multi-node UDP send completed without errors");
|
||||
}
|
||||
|
||||
/// Verify that the frame builder produces frames of the correct minimum
|
||||
/// size for various subcarrier counts (boundary testing).
|
||||
#[test]
|
||||
fn test_frame_sizes() {
|
||||
for n_sub in [1u8, 16, 32, 52, 56, 64, 128] {
|
||||
let frame = build_csi_frame(1, 0, -50, n_sub);
|
||||
let expected = 20 + (n_sub as usize) * 2;
|
||||
assert_eq!(frame.len(), expected, "wrong size for n_sub={n_sub}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Simulate a mesh of N nodes sending frames at different rates.
|
||||
/// Nodes 1-3 send every "tick", node 4 sends every other tick,
|
||||
/// node 5 stops after 5 ticks (simulating going offline).
|
||||
#[test]
|
||||
fn test_mesh_simulation_pattern() {
|
||||
let sock = UdpSocket::bind("0.0.0.0:0").expect("bind");
|
||||
sock.set_write_timeout(Some(Duration::from_millis(50))).ok();
|
||||
|
||||
let mut total_sent = 0u32;
|
||||
|
||||
for tick in 0..20u32 {
|
||||
// Nodes 1-3: every tick
|
||||
for nid in 1..=3u8 {
|
||||
let frame = build_csi_frame(nid, tick, -50, 32);
|
||||
let _ = sock.send_to(&frame, "127.0.0.1:5005");
|
||||
total_sent += 1;
|
||||
}
|
||||
|
||||
// Node 4: every other tick
|
||||
if tick % 2 == 0 {
|
||||
let frame = build_csi_frame(4, tick / 2, -55, 32);
|
||||
let _ = sock.send_to(&frame, "127.0.0.1:5005");
|
||||
total_sent += 1;
|
||||
}
|
||||
|
||||
// Node 5: stops after tick 5
|
||||
if tick < 5 {
|
||||
let frame = build_csi_frame(5, tick, -60, 32);
|
||||
let _ = sock.send_to(&frame, "127.0.0.1:5005");
|
||||
total_sent += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Expected: 3*20 + 10 + 5 = 75 frames
|
||||
assert_eq!(total_sent, 75, "unexpected frame count");
|
||||
}
|
||||
|
||||
/// Large mesh: simulate 100 nodes each sending 10 frames.
|
||||
/// Verifies the frame builder scales without issues.
|
||||
#[test]
|
||||
fn test_large_mesh_100_nodes() {
|
||||
let sock = UdpSocket::bind("0.0.0.0:0").expect("bind");
|
||||
sock.set_write_timeout(Some(Duration::from_millis(50))).ok();
|
||||
|
||||
let mut total = 0u32;
|
||||
for nid in 1..=100u8 {
|
||||
for seq in 0..10u32 {
|
||||
let frame = build_csi_frame(nid, seq, -50 + (nid % 30) as i8, 32);
|
||||
let _ = sock.send_to(&frame, "127.0.0.1:5005");
|
||||
total += 1;
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(total, 1000);
|
||||
}
|
||||
|
||||
/// Max mesh: simulate 255 nodes (max u8 node_id) with 1 frame each.
|
||||
#[test]
|
||||
fn test_max_nodes_255() {
|
||||
let sock = UdpSocket::bind("0.0.0.0:0").expect("bind");
|
||||
sock.set_write_timeout(Some(Duration::from_millis(100))).ok();
|
||||
|
||||
for nid in 1..=255u8 {
|
||||
let frame = build_csi_frame(nid, 0, -50, 16);
|
||||
let _ = sock.send_to(&frame, "127.0.0.1:5005");
|
||||
}
|
||||
|
||||
// 255 unique node_ids — the HashMap should handle this fine
|
||||
assert!(true);
|
||||
}
|
||||
Reference in New Issue
Block a user