Skip to content

Releases: ruvnet/RuView

Release v1596

04 Jun 06:35
872d759

Choose a tag to compare

Automated release from CI pipeline

Changes:
fix: IDF v6.0 ESP-NOW callback compat (#944) + occupancy noise-floor anchor (#942) (#945)

  • fix(firmware): on_send ESP-NOW callback compat for IDF v6.0 (closes #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)

The C6 sync ESP-NOW path's on_recv was already version-guarded with
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 0, 0) (lines 102-112)
but the on_send sibling missed the equivalent guard. CI runs against
IDF v5.4 so the regression slipped through; the reporter on IDF v6.0.1
with xtensa-esp-elf esp-15.2.0_20251204 hit:

c6_sync_espnow.c:182:30: error: passing argument 1 of
'esp_now_register_send_cb' from incompatible pointer type
[-Wincompatible-pointer-types]

Fix: mirror the recv guard with #if ESP_IDF_VERSION_MAJOR >= 6 since
the send-callback signature change happened at IDF v6.0 (not v5.x like
the recv-callback). Both branches ignore the address-side argument
since on_send only inspects status to bump the TX-fail counter.

Adds #include "esp_idf_version.h" so the macro is in scope.

Closes #944

Co-Authored-By: claude-flow ruv@ruv.net

  • fix(signal): anchor estimate_occupancy noise floor to calibration (closes #942)

test_estimate_occupancy_noise_only asserts that 20 noise-only frames
fed through a 50-frame calibrated FieldModel yield 0 occupancy.
Failure reported on the upstream Linux + BLAS build.

Root cause

Calibration and estimation each compute their own Marcenko-Pastur
threshold:

threshold = noise_var · (1 + sqrt(p / N))²

with noise_var = median of the bottom half of positive eigenvalues
from their own covariance. The MP ratio differs across the two phases:

calibration (50 frames, p=8): ratio = 0.16, factor ≈ 1.96
estimation (20 frames, p=8): ratio = 0.40, factor ≈ 2.66

On a small estimation window the local noise_var estimate can also
be smaller than the calibration's (fewer samples → bottom-half median
hits lower-magnitude eigenvalues). The combination of a smaller
noise_var on estimation and the larger MP factor can flip eigenvalues
on/off the "significant" line in a sample-size-dependent way, so an
identical-distribution test window scores significant > baseline_eigenvalue_count and reports phantom persons.

Fix

Persist the calibration noise_var on FieldNormalMode (new field
baseline_noise_var: f64) and use max(local_noise_var, baseline_noise_var) as the noise floor inside estimate_occupancy.
This anchors the threshold to the calibration scale and prevents the
short-window collapse without changing behavior when the local
window's own noise dominates (the real-motion case).

baseline_noise_var defaults to 0.0 in the diagonal-fallback paths;
the estimation code treats 0.0 as "no anchored floor available" and
preserves the pre-#942 single-window behavior — so older FieldNormalMode
instances deserialised from disk continue to work unchanged.

Test results

cargo test --workspace --no-default-features
→ 413 lib tests pass (signal crate), 0 fail, 1 ignored.

The actual eigenvalue-gated test still requires BLAS (not buildable
on Windows). Logic-trace via the four numerical anchors above shows
the fix flips noise_var from the smaller local value back up to the
calibration scale, dropping significant to or below
baseline_eigenvalue_count so the saturating subtraction returns 0.

Closes #942

Co-Authored-By: claude-flow ruv@ruv.net

Docker Image:
ghcr.io/ruvnet/RuView:872d7593bbeeed63524386aa60e6805bb4e1b26c

Release v1591

03 Jun 10:06
2c136ac

Choose a tag to compare

Automated release from CI pipeline

Changes:
fix(protocol): resolve 0xC511_0004 magic collision (closes #928) (#931)

  • fix(ci): SAST actually scans the code + drop deprecated flaky semgrep action

Two real problems in the Static Application Security Testing job:

  1. It scanned a path that no longer exists. bandit -r src/ and
    semgrep … src/ pointed at the repo-root src/, but the Python code
    moved to archive/v1/src/ (64 .py files) when the runtime was rewritten
    in Rust. So the SAST scan matched nothing — a silent no-op (this is also
    why bandit-results.sarif was "Path does not exist" on recent runs).
    Fixed both to archive/v1/src/.

  2. Deprecated + redundant + flaky semgrep step. The
    returntocorp/semgrep-action@v1 step pulled returntocorp/semgrep-agent:v1
    from Docker Hub every run (intermittently timing out → red check, e.g. on
    #929) and is EOL. It was redundant: the pip semgrep --sarif step is what
    feeds GitHub Security; the action only pushed to the Semgrep cloud app via
    SEMGREP_APP_TOKEN. Removed it and folded its p/docker + p/kubernetes
    rulesets into the pip semgrep command, so coverage is preserved with no
    Docker pull.

The job stays continue-on-error: true (non-gating). YAML validated.

Co-Authored-By: claude-flow ruv@ruv.net

  • fix(protocol): resolve 0xC511_0004 magic collision (closes #928)

Background

0xC511_0004 was assigned to two different packet formats in firmware
EDGE_FUSED_MAGIC (ADR-063, 48-byte edge_fused_vitals_pkt_t) and
WASM_OUTPUT_MAGIC (ADR-040, variable-length wasm_output_pkt_t).
Both were transmitted. The sensing-server only had a WASM parser for
that magic and no fused-vitals parser, so on the ESP32-C6 + MR60BHA2
mmWave configuration the fused-vitals packet was silently misparsed
as a malformed WASM output — breathing_rate was read as
event_count, mmWave-fused vitals were lost, and spurious WASM events
were emitted to subscribers.

Fix

  1. Reassign WASM_OUTPUT_MAGIC to 0xC511_0007 (next free slot per
    the registry in rv_feature_state.h). Smaller blast radius than
    moving fused-vitals — the registry already treats 0xC511_0004 as
    fused-vitals canonical and several years of deployed feature
    tracking depends on that assignment.

  2. Add parse_edge_fused_vitals + EdgeFusedVitalsPacket in
    wifi-densepose-sensing-server::main. Byte layout taken directly
    from edge_processing.h:129, mirroring the firmware's
    _Static_assert(sizeof(edge_fused_vitals_pkt_t) == 48) so future
    firmware changes that grow the packet will break this parser
    loudly instead of silently.

  3. Add a dispatch arm in the UDP receive loop. Fused-vitals is tried
    BEFORE WASM so a stale firmware (still emitting 0xC511_0004 with
    the WASM payload) fails to parse as fused-vitals (size mismatch),
    then fails to parse as WASM (magic mismatch on the new 0x...0007),
    and gets dropped — a deliberate "fail loud" outcome rather than the
    pre-fix silent garbage.

  4. Update the registry comment in rv_feature_state.h to add the new
    0x...0007 row.

  5. Add five tests in a new issue_928_magic_collision_tests mod:

    • parse_edge_fused_vitals_extracts_fields_correctly
    • parse_edge_fused_vitals_rejects_short_buffer
    • parse_edge_fused_vitals_rejects_wrong_magic
    • parse_wasm_output_rejects_legacy_0004_magic
    • parse_wasm_output_accepts_new_0007_magic

WebSocket payload

Fused-vitals now broadcasts as {"type": "edge_fused_vitals", ...}
with the mmWave-specific block nested under mmwave. Schema is
additive — existing subscribers that only inspect type are
unaffected; subscribers that switch on type gain a new branch.

Deployment note

This is a wire-protocol change. Firmware older than this commit that
emits WASM output on 0xC511_0004 will lose its WASM event stream
against an updated host (host expects 0xC511_0007). Per the issue
discussion, "fail loud" is preferred to silent misparsing. Operators
running C6+mmWave should reflash firmware concurrent with the host
upgrade.

Test results
cargo test -p wifi-densepose-sensing-server --no-default-features
--bin sensing-server
→ 122 passed / 0 failed (5 new + 117 existing, unchanged)

Co-Authored-By: claude-flow ruv@ruv.net

Docker Image:
ghcr.io/ruvnet/RuView:2c136aca7456ee5555a21fdcf7176fae38b8cf38

Release v1590

03 Jun 09:57
2c136ac

Choose a tag to compare

Automated release from CI pipeline

Changes:
docs(changelog): record this cycle's behavior-changing fixes (#932)

Per the CLAUDE.md pre-merge checklist (item 5, "Add entry under
[Unreleased]"), several recently-merged PRs landed without CHANGELOG
entries. Backfilling the user/operator-facing ones — most importantly the
MAT triage safety fix:

  • #926 (Security/safety): survivor with a heartbeat never triaged Deceased
  • #918: per-node HA devices report each node's own presence/motion
  • #919: actionable --model load diagnostic (refs #894)
  • #920: --export-rvf no longer silently produces a placeholder model
  • #929 (Security): bearer scheme matched case-insensitively (RFC 6750)

CI-internal fixes (#925 rust-cache, #930 SAST) are intentionally omitted —
they don't change product behavior. Docs-only.

Docker Image:
ghcr.io/ruvnet/RuView:69e61e3437932f5f0d005f7d7b97b7c5eb7b2053

Release v1588

03 Jun 09:28
d9e87e1

Choose a tag to compare

Automated release from CI pipeline

Changes:
fix(ci): SAST actually scans the code + drop deprecated flaky semgrep action (#930)

Two real problems in the Static Application Security Testing job:

  1. It scanned a path that no longer exists. bandit -r src/ and
    semgrep … src/ pointed at the repo-root src/, but the Python code
    moved to archive/v1/src/ (64 .py files) when the runtime was rewritten
    in Rust. So the SAST scan matched nothing — a silent no-op (this is also
    why bandit-results.sarif was "Path does not exist" on recent runs).
    Fixed both to archive/v1/src/.

  2. Deprecated + redundant + flaky semgrep step. The
    returntocorp/semgrep-action@v1 step pulled returntocorp/semgrep-agent:v1
    from Docker Hub every run (intermittently timing out → red check, e.g. on
    #929) and is EOL. It was redundant: the pip semgrep --sarif step is what
    feeds GitHub Security; the action only pushed to the Semgrep cloud app via
    SEMGREP_APP_TOKEN. Removed it and folded its p/docker + p/kubernetes
    rulesets into the pip semgrep command, so coverage is preserved with no
    Docker pull.

The job stays continue-on-error: true (non-gating). YAML validated.

Docker Image:
ghcr.io/ruvnet/RuView:d9e87e13b4d39d8ed6a5555c0e7e4fb7230129c4

Release v1585

03 Jun 09:16
be48143

Choose a tag to compare

Automated release from CI pipeline

Changes:
fix(auth): match the Bearer scheme case-insensitively (RFC 6750) (#929)

require_bearer parsed the Authorization header with
strip_prefix("Bearer "), which is case-sensitive. Per RFC 6750 §2.1 /
RFC 7235 §2.1 the auth-scheme is case-insensitive, so a correct token sent
as Authorization: bearer <token> (or BEARER, or with extra whitespace)
was rejected with a confusing "invalid bearer token" 401 — needless friction
when setting up RUVIEW_API_TOKEN (the active #864/#924 theme).

Now the scheme is matched with eq_ignore_ascii_case and leading token
whitespace trimmed. The token comparison itself is unchanged — still exact
and constant-time (ct_eq) — so this does not weaken auth: a wrong token or
a non-Bearer scheme (Basic …) still returns 401.

New test accepts_case_insensitive_bearer_scheme covers bearer/BEARER/
extra-space (accept) and wrong-token/Basic (still reject). bearer_auth
suite: 9 passed.

Docker Image:
ghcr.io/ruvnet/RuView:be48143f774770ad1b89f2491473306f55004847

Release v1583

03 Jun 07:46
c453268

Choose a tag to compare

Automated release from CI pipeline

Changes:
fix(mat): never triage a survivor with a heartbeat as Deceased (safety) (#926)

Both triage paths in the Mass Casualty Assessment tool classified a
survivor as Deceased (Black) on "no breathing + no movement" while
completely ignoring the heartbeat signal:

  • domain TriageCalculator::calculatecombine_assessments(Absent, None)
    returned Deceased. That branch is in fact only reachable because a
    heartbeat makes has_vitals() true (breathing+movement absent alone →
    Unknown) — so every "Deceased" was a live person with a pulse.
  • detection EnsembleClassifier::determine_triage (the path used by
    classify()) returned Deceased on !has_breathing && !has_movement,
    also ignoring reading.heartbeat.

A survivor with a detectable pulse but no sensed breathing/movement is in
respiratory arrest — the most time-critical savable state. Reporting them
Deceased would deprioritize a rescuable person. WiFi-CSI also cannot confirm
death (no airway-repositioning step), so a pulse must override.

Fix: in both paths, if the result would be Deceased but a heartbeat is
present, return Immediate. Total absence of breathing, movement AND heartbeat
is unchanged (domain → Unknown, ensemble → Deceased).

2 safety regression tests added. Full MAT suite: 168 + 6 + 3 passed, 0 failed
(existing test_no_vitals_is_deceased still green — no heartbeat → Deceased).

Docker Image:
ghcr.io/ruvnet/RuView:c453268002fe242769ce9ae059569a3cfcac9912

Release v1581

03 Jun 07:27
6ee21a0

Choose a tag to compare

Automated release from CI pipeline

Changes:
ci: use Swatinem/rust-cache for the Rust workspace job (reliability) (#925)

The Rust Workspace Tests job manually cached the whole v2/target via
actions/cache@v4. For a 38-crate workspace that dir is multi-GB, and several
CI runs this cycle intermittently died at the cache/setup step (after
toolchain install, before "Run Rust tests"), each needing a rerun.

Swatinem/rust-cache@v2 is the de-facto standard Rust CI cache: it caches the
cargo registry/git + a pruned target, evicts stale dependencies, and restores
large workspaces far more reliably and faster than a naive whole-target cache.
workspaces: v2 points it at the v2/ cargo workspace.

Reliability/speed change — verified by observing subsequent main runs.

Docker Image:
ghcr.io/ruvnet/RuView:6ee21a094105f004fdb1e2af5a12b891bd00f145

Release v1579

03 Jun 07:07
0cfd255

Choose a tag to compare

Automated release from CI pipeline

Changes:
fix: --export-rvf no longer silently produces a placeholder model (#920)

The --export-rvf handler ran before the --train/--pretrain handlers and
unconditionally wrote placeholder sine-wave weights, then returned. So the
documented --train --dataset … --export-rvf <path> workflow
(user-guide.md) short-circuited to a PLACEHOLDER model and never trained —
printing "exported successfully" for a non-functional model. Given the
project's anti-"is it fake" stance, silently emitting a fake model is the
wrong default.

Fix:

  • Only emit the placeholder container-format demo when --export-rvf is used
    standalone (new export_emits_placeholder_demo guard). With
    --train/--pretrain, fall through so the real training pipeline runs and
    exports calibrated weights.
  • The standalone path now prints a clear WARNING that it writes a
    container-format demo with placeholder weights — not a trained model —
    pointing to --train / a pretrained encoder (#894).
  • Docs: flag --export-rvf as a placeholder demo in the flag table, and fix
    the Docker training example to use --save-rvf (consistent with the
    from-source example) instead of the placeholder --export-rvf.

3 unit tests for the guard. Full crate unit suite: 429 + 117 passed, 0 failed.

Docker Image:
ghcr.io/ruvnet/RuView:0cfd255730a83ec97dcd0124b69962e5a8c25af7

Release v1576

02 Jun 18:15
f5d0e1e

Choose a tag to compare

Automated release from CI pipeline

Changes:
fix(#894): actionable diagnostic when --model gets a non-RVF file (#919)

Users who downloaded ruvnet/wifi-densepose-pretrained and passed
model.safetensors / model-q4.bin / model.rvf.jsonl to --model hit a bare
"Progressive loader init failed: invalid magic at offset 0: expected
0x52564653, got 0x77455735" and were stuck — the server then silently fell
back to signal heuristics (which over-count, feeding "is it fake" reports).

The HF files are a different format and encoder architecture than the RVF
binary container the progressive loader expects, so they can't load directly.
Now the load-failure path detects the common cases (safetensors header,
JSONL manifest, quantized .bin blob) and emits a plain explanation naming the
format, what --model actually expects (RVF RVFS container from
wifi-densepose-train), and that it's continuing with heuristics — with a
pointer to #894.

Pure, testable diagnose_model_load_error() + 4 unit tests (run under the
default --no-default-features CI). Full crate unit suite: 429 + 114 passed,
0 failed.

Docker Image:
ghcr.io/ruvnet/RuView:f5d0e1e69ef07cefc3008d6c0594562e35c0d430

Release v1574

02 Jun 17:49
b12662a

Choose a tag to compare

Automated release from CI pipeline

Changes:
fix(mqtt): per-node HA devices use each node's own presence/motion (#872) (#918)

The MQTT bridge fanned out one Home-Assistant device per node (#898) but
applied the room-level aggregate classification to every node — so in a
multi-node setup a node in an empty corner inherited another node's
"present", and motion_level: "absent" was mis-mapped to full motion
(the aggregate match fell through Some(_) => 1.0).

Each node in the sensing broadcast's nodes array already carries its own
classification (motion_level/presence/confidence, see
PerNodeFeatureInfo) and RSSI. Now each per-node snapshot reads that node's
own classification, deferring to the room aggregate only for fields a node
omits. Vitals (breathing/heart rate) and person count stay room-level.

Extracted the JSON→VitalsSnapshot mapping into a pure, testable function
(vitals_snapshots_from_sensing_json) and added 4 unit tests covering
per-node divergence, partial-field fallback, the no-nodes aggregate path,
and the absent→zero-motion fix.

Supersedes #899, which targeted the right bug but read non-existent fields
(node["motion_level"] / node["status"] instead of the nested
node["classification"] + stale).

Verified: builds with --features mqtt; new tests pass; full crate unit
suite 432 + 114 passed, 0 failed.

Docker Image:
ghcr.io/ruvnet/RuView:b12662a54d64be1a22b67820412f63b713d73856