Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot] 21e7d14abb chore: update vendor submodules to latest upstream 2026-06-04 01:12:00 +00:00
11 changed files with 18 additions and 165 deletions
+1 -47
View File
@@ -15,52 +15,6 @@
# MODELS_DIR — directory to scan for .rvf model files (default: data/models)
set -e
# ── Issue #864: fail-closed on default posture ───────────────────────────────
# The pre-fix default was: empty RUVIEW_API_TOKEN (auth off) + --bind-addr
# 0.0.0.0 + docker-compose publishing :3000/:3001/:5005 → an unauthenticated
# attacker on any reachable network segment could read /api/v1/sensing/latest
# and the /ws/sensing live stream. That posture is unsafe on guest WiFi,
# untrusted LANs, accidentally-port-forwarded hosts, or any reverse-proxied
# deployment. Refuse to start with this combination.
#
# Escape hatches (operator must opt in explicitly):
# * Set RUVIEW_API_TOKEN to a strong secret → auth enabled on /api/v1/*.
# * Set RUVIEW_ALLOW_UNAUTHENTICATED=1 → preserves the pre-fix behaviour;
# only safe on an isolated trust boundary.
# * Set RUVIEW_BIND_ADDR to a loopback / private interface → unauth is fine
# when the socket isn't reachable. The auto-bind nudges toward 127.0.0.1.
#
# This check runs only for the default sensing-server path (no args + flag-only
# args). The `cog-ha-matter` / `homecore` routes below are excluded because
# they own their own auth lifecycle.
case "${1:-}" in
cog-ha-matter|ha-matter|homecore|homecore-server) ;;
*)
if [ -z "${RUVIEW_API_TOKEN:-}" ] && [ "${RUVIEW_ALLOW_UNAUTHENTICATED:-}" != "1" ]; then
# If the operator hasn't overridden the bind, refuse outright on
# the default 0.0.0.0. If they've nailed it to loopback (or a
# specific private address they trust), let it run.
__bind_default="${RUVIEW_BIND_ADDR:-0.0.0.0}"
case "$__bind_default" in
127.*|localhost|::1)
: ;; # loopback bind is safe even without a token
*)
echo "[entrypoint] ERROR: refusing to start sensing-server with default" >&2
echo "[entrypoint] posture: RUVIEW_API_TOKEN is unset AND bind is" >&2
echo "[entrypoint] ${__bind_default}. /ws/sensing streams live sensing" >&2
echo "[entrypoint] frames; that data would be readable by anyone who" >&2
echo "[entrypoint] can reach this host. Pick one:" >&2
echo "[entrypoint] docker run -e RUVIEW_API_TOKEN=\$(openssl rand -hex 32) ..." >&2
echo "[entrypoint] docker run -e RUVIEW_BIND_ADDR=127.0.0.1 ..." >&2
echo "[entrypoint] docker run -e RUVIEW_ALLOW_UNAUTHENTICATED=1 ... # only on trusted network" >&2
echo "[entrypoint] See https://github.com/ruvnet/RuView/issues/864" >&2
exit 64
;;
esac
fi
;;
esac
# Route to cog-ha-matter (ADR-116) when invoked as:
# docker run <image> cog-ha-matter [--flags]
# or via the short alias `ha-matter`. Strips the keyword and execs the
@@ -94,7 +48,7 @@ if [ "${1#-}" != "$1" ] || [ -z "$1" ]; then
--ui-path /app/ui \
--http-port 3000 \
--ws-port 3001 \
--bind-addr "${RUVIEW_BIND_ADDR:-0.0.0.0}" \
--bind-addr 0.0.0.0 \
"$@"
fi
@@ -65,15 +65,6 @@ target_compile_definitions(${COMPONENT_LIB} PUBLIC
d_m3LogOutput=0 # Disable WASM3 stdout logging (use ESP_LOG)
d_m3FixedHeap=0 # Use dynamic allocation (PSRAM-friendly)
WASM3_AVAILABLE=1 # Flag for conditional compilation
# Issue #946: GCC 15.2.0 for Xtensa (ESP-IDF v6.0.1) rejects wasm3's
# `M3_MUSTTAIL` aggressive tail-call attribute with
# "cannot tail-call: machine description does not have a sibcall_epilogue
# instruction pattern". wasm3 falls back to a regular call sequence when
# M3_NO_MUSTTAIL is defined — slightly slower per opcode but functionally
# identical. Forcing it off unconditionally on Xtensa is fine because the
# tail-call optimisation was never reliable on this target anyway. Older
# IDF/GCC builds also accept the define (it just becomes a no-op).
M3_NO_MUSTTAIL=1
)
# Suppress warnings from third-party code.
@@ -220,20 +220,11 @@ static void fast_loop_cb(TimerHandle_t t)
adaptive_controller_decide(&s_cfg, s_state, &obs, &dec);
apply_decision(&dec);
/* ADR-081 Layer 4/5: emit compact feature state at 1 Hz (the spec's
* 110 Hz floor). Was previously emitted on every fast tick (~5 Hz at
* the default 200 ms fast period), which combined with CSI promiscuous
* RX saturated the WiFi TX airtime — measured live on COM8 (S3) and
* COM9 (C6): every adaptive cycle showed `sendto ENOMEM — backing off
* for 100 ms`, and bumping LWIP/WiFi buffer pools to 4× had no effect
* on the rate because the bottleneck was radio TX time, not pool size.
* Dropping to 1 Hz (5× less feature_state traffic) frees the TX queue
* for CSI sends and lands well within the spec. */
static uint8_t s_emit_divider = 0;
if (++s_emit_divider >= 5) {
s_emit_divider = 0;
emit_feature_state();
}
/* ADR-081 Layer 4/5: emit compact feature state on every fast tick
* (default 200 ms → 5 Hz, within the 110 Hz spec). Replaces raw
* ADR-018 CSI as the default upstream; raw remains available as a
* debug stream gated by the channel plan. */
emit_feature_state();
}
static void medium_loop_cb(TimerHandle_t t)
@@ -21,7 +21,6 @@
#include "esp_wifi.h"
#include "esp_mac.h"
#include "esp_timer.h"
#include "esp_idf_version.h"
#include "freertos/FreeRTOS.h"
#include "freertos/timers.h"
#include <string.h>
@@ -145,27 +144,11 @@ static void on_recv(const uint8_t *src_mac, const uint8_t *data, int len)
}
}
/* Issue #944: ESP-IDF v6.0 changed `esp_now_send_cb_t` from
* void (*)(const uint8_t *mac, esp_now_send_status_t status)
* to
* void (*)(const esp_now_send_info_t *tx_info, esp_now_send_status_t status)
* Both signatures ignore the address-side argument here — we only inspect
* `status` to bump the TX-fail counter — so the body is identical; only the
* function-pointer type differs. ESP_IDF_VERSION_MAJOR is the canonical guard.
*/
#if ESP_IDF_VERSION_MAJOR >= 6
static void on_send(const esp_now_send_info_t *tx_info, esp_now_send_status_t status)
{
(void)tx_info;
if (status != ESP_NOW_SEND_SUCCESS) s_tx_fail++;
}
#else
static void on_send(const uint8_t *mac, esp_now_send_status_t status)
{
(void)mac;
if (status != ESP_NOW_SEND_SUCCESS) s_tx_fail++;
}
#endif
static void beacon_timer_cb(TimerHandle_t t)
{
+1 -10
View File
@@ -23,16 +23,7 @@
static const char *TAG = "swarm";
/* ---- Task parameters ---- */
/* Issue #949: 3 KB was sized for plain HTTP (~2.5 KB). The bug reporter
* configured `--seed-url https://…` which exercises TLS — mbedTLS handshake
* alone needs 4-6 KB on the stack (cipher suite + cert chain + ECDH), and on
* top of that esp_http_client adds another 1.5-2 KB. The task panicked with
* `0xa5a5a5a5` (FreeRTOS stack-fill sentinel) immediately after "bridge init
* OK". 8 KB comfortably fits TLS with margin for the cert chain + headers;
* confirmed against mbedTLS's stack analyser. Plain-HTTP deployments waste
* ~5 KB of headroom but that's <0.1 % of PSRAM, an acceptable cost for the
* bug class this prevents. */
#define SWARM_TASK_STACK 8192 /**< 8 KB stack — fits mbedTLS handshake. */
#define SWARM_TASK_STACK 3072 /**< 3 KB stack — HTTP client uses ~2.5 KB. */
#define SWARM_TASK_PRIO 3
#define SWARM_TASK_CORE 0
#define SWARM_HTTP_TIMEOUT 3000 /**< HTTP timeout in ms (Seed responds <100ms on LAN). */
@@ -29,30 +29,6 @@ CONFIG_LOG_DEFAULT_LEVEL_INFO=y
# LWIP: enable extended socket options for UDP multicast
CONFIG_LWIP_SO_RCVBUF=y
# Issue (sibling of #946/#949/#864 cluster): UDP `sendto` returned ENOMEM
# in a tight loop on both ESP32-S3 (COM8) and ESP32-C6 (COM9) at the v0.7.0
# CSI packet rate (CSI cb + status + sync + feature_state all sharing the
# LWIP/WiFi pools). stream_sender.c has a cooldown path so the device
# doesn't crash, but ~90 % of CSI frames were dropped before reaching the
# host — boot trace showed `sendto ENOMEM — backing off 100 ms` repeating
# every capture cycle. Stock IDF v5.4 defaults: UDP recv mbox=6, TCPIP
# mbox=32, WiFi dynamic TX buffers=32 — too small once CSI promiscuous
# mode is active. These bumps roughly quadruple the relevant pools at
# ~3 KB extra heap cost, measured live on both targets Jun 8 2026.
CONFIG_LWIP_UDP_RECVMBOX_SIZE=32
CONFIG_LWIP_TCPIP_RECVMBOX_SIZE=64
CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM=64
# NOTE: Empirical 25 s measurements on the S3 at COM8 showed these bumps
# eliminate the csi_collector.sendto failure path (`fail #1..5` →
# `fail #0`) — real improvement — but do NOT eliminate the broader
# `feature_state emit` ENOMEM at ~10/s. That residual is the WiFi
# radio's TX airtime saturating under CSI promiscuous RX, and bigger
# buffers cap out at the 100 ms backoff window regardless of size
# (verified at WIFI_DYNAMIC_TX=128 + PBUF_POOL=32 — identical count).
# The proper fix is rate-limiting adaptive_controller.c's emit cadence
# from ~50 ms to the intended 1 Hz, which is a code refactor tracked
# in a separate follow-up issue.
# FreeRTOS: increase task stack for CSI processing
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
@@ -276,13 +276,6 @@ pub struct FieldNormalMode {
pub geometry_hash: u64,
/// Baseline eigenvalue count above Marcenko-Pastur threshold (empty-room).
pub baseline_eigenvalue_count: usize,
/// Baseline noise variance estimate (median of bottom-half positive
/// eigenvalues from the calibration covariance). Persisted so that
/// `estimate_occupancy` can anchor its Marcenko-Pastur threshold to the
/// calibration noise floor instead of letting it drift with the
/// per-window sample size. Defaults to 0.0 in the diagonal-fallback path.
/// Issue #942.
pub baseline_noise_var: f64,
}
/// Body perturbation extracted from a CSI observation.
@@ -511,11 +504,7 @@ impl FieldModel {
let baseline: Vec<Vec<f64>> = self.link_stats.iter().map(|ls| ls.mean_vector()).collect();
// --- True eigenvalue decomposition (with diagonal fallback) ---
// Returns: (energies, modes, baseline_count, baseline_noise_var).
// The noise_var slot is 0.0 in the diagonal-fallback paths; the
// estimation hot path treats 0.0 as "no anchored noise floor" and
// falls back to per-window noise_var, preserving pre-#942 behavior.
let (mode_energies, environmental_modes, baseline_eig_count, baseline_noise_var) =
let (mode_energies, environmental_modes, baseline_eig_count) =
if let Some(ref cov_sum) = self.covariance_sum {
if self.covariance_count > 1 {
// Compute sample covariance from raw outer products:
@@ -599,28 +588,23 @@ impl FieldModel {
let baseline_count =
eigenvalues.iter().filter(|&&ev| ev > mp_threshold).count();
(energies, modes, baseline_count, noise_var)
(energies, modes, baseline_count)
}
Err(_) => {
// Fallback to diagonal approximation on SVD failure
let (e, m, b) =
diagonal_fallback(&self.link_stats, n_sc, n_modes);
(e, m, b, 0.0_f64)
diagonal_fallback(&self.link_stats, n_sc, n_modes)
}
}
// When eigenvalue feature is disabled, use diagonal fallback
#[cfg(not(feature = "eigenvalue"))]
{
let (e, m, b) = diagonal_fallback(&self.link_stats, n_sc, n_modes);
(e, m, b, 0.0_f64)
diagonal_fallback(&self.link_stats, n_sc, n_modes)
}
} else {
let (e, m, b) = diagonal_fallback(&self.link_stats, n_sc, n_modes);
(e, m, b, 0.0_f64)
diagonal_fallback(&self.link_stats, n_sc, n_modes)
}
} else {
let (e, m, b) = diagonal_fallback(&self.link_stats, n_sc, n_modes);
(e, m, b, 0.0_f64)
diagonal_fallback(&self.link_stats, n_sc, n_modes)
};
// Compute variance explained using the same centered covariance as modes.
@@ -664,7 +648,6 @@ impl FieldModel {
calibrated_at_us: timestamp_us,
geometry_hash,
baseline_eigenvalue_count: baseline_eig_count,
baseline_noise_var,
};
self.modes = Some(field_mode);
@@ -811,7 +794,7 @@ impl FieldModel {
// Marcenko-Pastur noise estimate: median of POSITIVE eigenvalues
// in the bottom half. Excludes zeros from rank-deficient matrices
// (common when n_subcarriers > n_frames, e.g. 56 subcarriers / 50 frames).
let local_noise_var = {
let noise_var = {
let mut positive: Vec<f64> =
eigenvalues.iter().copied().filter(|&e| e > 1e-10).collect();
positive.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
@@ -824,22 +807,6 @@ impl FieldModel {
return Ok(0); // All zero eigenvalues — can't estimate
}
};
// Issue #942: anchor the noise floor to the calibration's noise_var
// when it's available. Per-window noise_var drifts with sample size —
// a short estimation window can produce a small local_noise_var that
// inflates `significant` and breaks the test_estimate_occupancy_noise_only
// invariant. The max of (calibration noise, local noise) keeps the
// threshold from collapsing on small windows while still letting the
// per-window noise dominate when it's the larger estimate. Falls back
// to local_noise_var when baseline_noise_var == 0 (diagonal-fallback
// calibration path, or pre-#942 stored modes).
let noise_var = if modes.baseline_noise_var > 0.0 {
local_noise_var.max(modes.baseline_noise_var)
} else {
local_noise_var
};
let ratio = n as f64 / count as f64;
let mp_threshold = noise_var * (1.0 + ratio.sqrt()).powi(2);
+1 -1