Compare commits

...

8 Commits

Author SHA1 Message Date
rUv
e04f269f1a Merge pull request #1762 from ruvnet/codex/zone-aware-calibration
firmware: fail closed occupancy and stabilize C6 temporal sensing
2026-08-31 14:41:43 -04:00
ruv
12a61c16e8 docs(firmware): prepare 0.8.8 release 2026-08-31 14:14:08 -04:00
ruv
a70803fe31 fix(security): update wasmtime to 36.0.14 2026-08-31 14:14:08 -04:00
ruv
4b295b1b4d docs(firmware): qualify second ESP32 C6 node 2026-08-31 13:57:21 -04:00
ruv
615e2d419b docs(firmware): qualify ESP32 S3 transport 2026-08-31 13:31:19 -04:00
ruv
f85896cccb feat(firmware): stabilize rate-aware ESP32 sensing 2026-08-31 12:56:32 -04:00
ruv
0a0b3411f8 fix: fail closed contradictory ESP32 occupancy evidence 2026-08-31 12:56:32 -04:00
rUv
08210b02c9 Update README.md
removed promo imgs.
2026-08-31 12:42:10 -04:00
24 changed files with 1198 additions and 150 deletions

View File

@@ -162,10 +162,14 @@ jobs:
mkdir -p release-staging
cp build/esp32-csi-node.bin release-staging/${{ matrix.artifact_app }}
cp build/partition_table/partition-table.bin release-staging/${{ matrix.artifact_pt }}
if [ "${{ matrix.variant }}" = "8mb" ]; then
cp build/bootloader/bootloader.bin release-staging/bootloader.bin
cp build/ota_data_initial.bin release-staging/ota_data_initial.bin
fi
cp build/bootloader/bootloader.bin release-staging/bootloader.bin
cp build/ota_data_initial.bin release-staging/ota_data_initial.bin
cp version.txt release-staging/version.txt
(cd release-staging && sha256sum \
"${{ matrix.artifact_app }}" \
"${{ matrix.artifact_pt }}" \
bootloader.bin ota_data_initial.bin version.txt \
> SHA256SUMS.txt)
ls -la release-staging/
- name: Check QEMU ESP32-S3 support status

View File

@@ -5,16 +5,7 @@
<img src="assets/ruview-seed.png" alt="RuView - WiFi DensePose" width="100%">
</a>
</p>
<p align="center">
<a href="https://cognitum.one/marketplace">
<img src="assets/musica-promo.png" alt="Cognitum Musica" width="100%">
</a>
</p>
<p align="center">
<a href="https://github.com/ruvnet/RuCelium">
<img src="assets/rucelium-hero.png" alt="RuCelium — environmental intelligence" width="100%">
</a>
</p>
## **See through walls with WiFi** ##

View File

@@ -0,0 +1,52 @@
# ADR 346: Fail closed ESP32 occupancy evidence
## Status
Accepted and implemented. Physical qualification is required after each firmware build.
## Date
2026 08 31
## Context
The ESP32 Tier 2 pipeline produces two different signals. Presence is a debounced room level decision. Person count is a bounded subcarrier diversity heuristic. A live four node installation emitted packets with `presence=false` and `n_persons=3` or `4`. The server eventually gated the aggregate room count, but raw WebSocket consumers and diagnostics could still treat the contradictory count as occupancy evidence.
That contradiction is more dangerous than a missed optional count. It can contaminate empty room calibration, train a room model on false labels, and encourage a product claim that the firmware cannot support. The count is not identity, pose, or a validated multi person estimator.
## Decision
1. Firmware person slots are subordinate to the debounced presence gate.
2. When presence is false, the firmware clears slot activity, slot history, candidate count, persistence streak, and stable count.
3. The serialized person count is always zero when presence is false and is clamped to `EDGE_MAX_PERSONS` when presence is true.
4. The sensing server repeats the invariant for older firmware. A contradictory or out of range count becomes zero and carries `person_count_valid=false`.
5. Fused CSI plus mmWave packets use either CSI presence or mmWave presence as the supporting presence condition.
6. The node inventory and WebSocket diagnostics expose person count validity. Consumers must not infer a person from an invalid count.
7. No count accuracy claim is created by this change. The firmware output remains a heuristic until a leakage free, held out physical dataset demonstrates otherwise.
8. OTA admission uses the selected update partition size rather than a stale fixed 900 KB ceiling. The status endpoint reports that same hardware bound, while image validation and authenticated OTA remain mandatory.
## Security and privacy
The change retains no raw CSI or personal data. It reduces authority by preventing a secondary heuristic from asserting occupancy after the primary gate has closed. The host validates packet length, magic, range, and logical consistency before using count evidence.
## Consequences
Older firmware remains wire compatible. Invalid count evidence becomes visibly unavailable instead of silently affecting calibration. A true multi person event can still be undercounted when the presence gate is false, which is the intended fail closed behavior. Current C6 images larger than 900 KB can use the installed 1,900,544 byte OTA slots after one serial upgrade, without weakening the OTA authentication gate.
The largest uncertainty is whether the current presence gate itself generalizes across the installed rooms. The fix path is a room bound empty baseline plus the fixed room selective held out protocol, not a global threshold reduction.
## Evidence and acceptance
MEASURED before implementation on 2026 08 31: four live nodes streamed for 86 seconds with zero transport errors, while edge packets repeatedly contradicted `presence=false` with counts of three or four.
Software acceptance requires:
1. Host firmware tests prove absent plus four active slots serializes zero.
2. Rust parser tests prove contradictory and out of range counts fail closed.
3. The node API exposes count validity without breaking older firmware.
Physical acceptance requires the updated firmware on a confirmed board, a captured boot log, five minutes of live packets, zero logical count contradictions, and no increase in transport errors. Accuracy remains unmeasured until labelled held out sequences are recorded.
Physical occupancy qualification completed for ESP32 C6 node 4 on 2026 08 31. The five minute run observed 242 edge packets, including 61 absent packets, with zero logical count contradictions and zero parse errors. See `docs/validation/2026-08-31-esp32-c6-occupancy-integrity.md`.
ESP32 C6 node 7 was subsequently identified, upgraded to firmware 0.8.8, and transport qualified for five minutes with zero fused presence count contradictions and zero steady state transport errors. Its controlled empty room sequence remains required before occupancy qualification. See `docs/validation/2026-08-31-esp32-c6-node7-rate-aware-sensing.md`. Other nodes remain unqualified until separately identified and upgraded.

View File

@@ -0,0 +1,85 @@
# ADR 347: Rate aware ESP32 temporal sensing
## Status
Accepted. Implemented in firmware 0.8.8. The timing and transport path is
physically qualified on ESP32 C6. The independent raw transport path is also
physically qualified on an ESP32 S3 running Tier 0. Held out inference accuracy
remains required.
## Context
The ESP32 firmware creates CSI opportunities by sending one byte ICMP probes to
the connected access point. The traffic source is configured for 50 Hz, but the
delivered CSI cadence varies with channel contention and callback safety gates.
A physical ESP32 C6 produced 28 to 37 callbacks per second during the baseline
capture. Firmware 0.8.5 then exposed that the old per-interval estimator saw
only 12 to 16 Hz because WiFi replies arrived in short bursts separated by
longer gaps. The filters still consumed those burst frames, so excluding them
from the clock estimate was incorrect.
The edge DSP estimates its sample rate from timestamps so that breathing,
heartbeat, motion, and future Doppler features stay in physical Hertz. That
estimator was capped at 30 Hz. Once the actual cadence exceeded the cap, every
temporal feature was scaled against the wrong clock.
Physical firmware 0.8.5 validation corrected that initial diagnosis. Although
the callback path received 26 to 40 frames per second, Tier 2 on the unicore C6
processed an irregular subset that converged toward the 8 Hz estimator floor.
The right design is not to force the edge DSP to match raw capture. The paths
need independent, explicit cadence contracts.
The device free gesture preprint at
`https://www.preprints.org/manuscript/202602.0018` reinforces the importance of
timestamp correct Doppler features, but its 100 Hz controlled link is not a
safe firmware default for RuView. Existing S3 and C6 evidence records WiFi ISR
and packet buffer failures under sustained callback pressure above 50 Hz.
## Decision
1. Make the connected STA probe rate a build time setting from 10 through 50
Hz, with a default and hard ceiling of 50 Hz.
2. Track the delivered DSP cadence by counting every processed frame interval
over one second timestamp windows, then smooth successive windows in an 8
through 60 Hz estimator range. The 60 Hz estimator ceiling accommodates
timestamp jitter; it does not authorize more than 50 Hz callback processing.
3. Reject incomplete windows below one second and stalled windows above three
seconds. Do not discard valid burst frames from the estimated clock.
4. Surface the DSP rate in the one second controller diagnostic so hardware
validation can compare callback yield with the clock used by temporal
filters.
5. Keep raw CSI on the wire at the independent network cadence. Rate-limit the
C6 on-device Tier 1 and Tier 2 DSP input to a uniform 8 Hz. Physical 0.8.7
evidence showed that a requested 10 Hz input still converged to 8.0 through
8.4 Hz under Tier 2 load, while raw delivery remained 30 through 40 pps.
Eight hertz retains a 4 Hz Nyquist limit for the 0.1 through 2.0 Hz vital
bands without creating a backlog. The S3 default remains 20 Hz.
6. STFT, spectrogram gating, and learned temporal
classification remain host or iPhone responsibilities where memory,
rollback, and held out evaluation are stronger.
## Consequences
Heartbeat, respiration, and motion features receive a stable timestamped clock
instead of an accidental subset determined by C6 backlog. Operators can lower
the probe or DSP load for constrained networks without editing source. The host
still receives the higher-rate raw stream for richer Doppler processing.
This does not prove vital sign accuracy or gesture recognition. Higher temporal
fidelity only improves the representation available to a separately validated
model. The 50 Hz ceiling also means the paper's 100 Hz results are not directly
transferable.
## Acceptance test
On a physical C6, run at least five minutes after flashing. Pass when the boot
log reports the configured probe and DSP rates, the controller converges within
one hertz of the configured DSP cadence, raw callback yield remains at least 20
pps, no steady-state ENOMEM, watchdog, panic, or reboot occurs, and the fail
closed occupancy invariant remains zero contradictions for at least 30 absent
packets.

View File

@@ -106,6 +106,8 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
| [ADR-036](ADR-036-rvf-training-pipeline-ui.md) | Training Pipeline UI Integration | Proposed |
| [ADR-043](ADR-043-sensing-server-ui-api-completion.md) | Sensing Server UI API Completion (14 endpoints) | Accepted |
| [ADR-344](ADR-344-adaptive-local-installation-discovery.md) | Adaptive Local Installation Discovery | Accepted (local software path) |
| [ADR-346](ADR-346-fail-closed-edge-occupancy-evidence.md) | Fail closed ESP32 occupancy evidence | Accepted (C6 occupancy integrity qualified) |
| [ADR-347](ADR-347-rate-aware-esp32-temporal-sensing.md) | Rate aware ESP32 temporal sensing | Accepted (C6 timing and transport qualified) |
| [ADR-115](ADR-115-home-assistant-integration.md) | Home Assistant integration via MQTT auto-discovery + Matter bridge (HA-DISCO + HA-FABRIC + HA-MIND) | Accepted (MQTT track) / Proposed (Matter SDK P8b) |
| [ADR-169](ADR-169-adam-mode-light-theme.md) | adam-mode — light theme toggle for the three.js realtime demo | Proposed |
| [ADR-170](ADR-170-yoga-mode-pose-system.md) | yoga-mode — yoga pose detection, classification, and scoring for the three.js realtime demo | Proposed |

View File

@@ -0,0 +1,111 @@
# RuView ESP32 firmware 0.8.8
Firmware 0.8.8 is a reliability and correctness release for ESP32-S3 and
ESP32-C6 RuView nodes. It makes the timing used by signal processing explicit,
prevents contradictory occupancy output, and improves update diagnostics.
## What changed
### Empty means zero people
Older firmware could report `presence=false` and a nonzero person count in the
same edge packet. That was internally contradictory and could contaminate an
empty-room calibration. Firmware 0.8.8 clears the count whenever the presence
gate is closed. The sensing server repeats the same check when it receives data
from older nodes.
This is a consistency fix, not proof that the heuristic can count multiple
people accurately. See
[ADR 346](../adr/ADR-346-fail-closed-edge-occupancy-evidence.md).
### Stable time scale on ESP32-C6
Raw CSI and on-device signal processing now have separate clocks. The C6 keeps
raw CSI moving over the network while its Tier 2 filters process a stable 8 Hz
sample stream. The S3 retains its 20 Hz DSP default. A phase-preserving sampler
keeps callback jitter from shifting those clocks.
The result is a correct time base for motion and vital-band features. It does
not by itself prove that heartbeat, respiration, gesture, or pose estimates are
more accurate. See
[ADR 347](../adr/ADR-347-rate-aware-esp32-temporal-sensing.md).
### Better diagnostics and safer updates
The one-second controller log now shows both raw callback yield and DSP rate.
The OTA status endpoint reports the actual selected application partition size
instead of a fixed 900 KB assumption. Firmware upload remains fail closed when
the node has no provisioned OTA signing secret.
## Measured hardware validation
All results below are physical measurements from 2026-08-31. They are not
simulator claims.
| Board | Duration | Raw CSI mean | DSP clock | Live coverage | Steady-state transport errors |
|-------|---------:|-------------:|----------:|--------------:|------------------------------:|
| ESP32-C6 node 4 | 300.64 s | 34.92 pps | 8.00 Hz | 97.62% | 0 |
| ESP32-C6 node 7 | 300.70 s | 36.32 pps | 8.00 Hz | 97.40% | 0 |
| ESP32-S3 node 1 | 300 s | 28.03 pps | Tier 0 | 100.00% | 0 |
The first C6 empty-room qualification observed 61 absent packets with zero
nonzero counts. The second C6 was transport-qualified in an occupied room and
still needs its own controlled empty-room sequence. Full evidence is recorded
in:
1. [C6 timing and transport](../validation/2026-08-31-esp32-c6-rate-aware-sensing.md)
2. [C6 occupancy integrity](../validation/2026-08-31-esp32-c6-occupancy-integrity.md)
3. [Second C6 timing and transport](../validation/2026-08-31-esp32-c6-node7-rate-aware-sensing.md)
4. [S3 transport](../validation/2026-08-31-esp32-s3-rate-aware-transport.md)
## Choose the correct download
| Release file | Target |
|--------------|--------|
| `esp32-csi-node-v0.8.8-s3-8mb-flash-bundle.zip` | ESP32-S3 with 8 MB flash |
| `esp32-csi-node-v0.8.8-s3-4mb-flash-bundle.zip` | ESP32-S3 with 4 MB flash |
| `esp32-csi-node-v0.8.8-c6-4mb-flash-bundle.zip` | ESP32-C6 using the supported 4 MB partition layout |
| `esp32-csi-node-v0.8.8-s3-8mb.bin` | S3 8 MB application only |
| `esp32-csi-node-v0.8.8-s3-4mb.bin` | S3 4 MB application only |
| `esp32-csi-node-v0.8.8-c6-4mb.bin` | C6 application only |
Never mix S3 and C6 images. Confirm the chip and physical flash before writing.
## Install or update
For a fresh installation, extract the matching bundle and follow its included
`FLASHING.md`. The standard offsets are:
| Image | Offset |
|-------|-------:|
| Bootloader | `0x0000` |
| Partition table | `0x8000` |
| OTA metadata | `0xf000` |
| Application | `0x20000` |
For an existing provisioned node:
1. Back up the current application partition.
2. Confirm the exact chip, flash layout, logical node, and serial port.
3. Read `http://DEVICE_IP:8032/ota/status`.
4. Use an application-only serial update at `0x20000` only when the running
partition is `ota_0` and the image matches the board.
5. Reboot and confirm version 0.8.8, the preserved node identity, channel, and
sensing-server target.
6. Run a five-minute burn-in before returning the node to calibration duty.
The full bundle does not contain an NVS image. A four-offset install therefore
preserves the existing WiFi and node settings, but operators should still keep
a backup before changing firmware.
## What this release does not prove
Firmware 0.8.8 does not prove medical-grade vital signs, accurate person
counting, identity, dense pose, through-wall video, or room separation. Those
claims require synchronized references and leakage-free held-out sequences.
The practical next acceptance test is a controlled empty-room capture with at
least 30 absent edge packets per updated node, zero absent packets carrying a
nonzero count, and zero transport or parser errors. Accuracy evaluation then
needs held-out occupied, movement, heartbeat-reference, and adjacent-room
sequences.

View File

@@ -0,0 +1,120 @@
# ESP32 C6 node 7 rate aware sensing qualification
## Scope
This record qualifies the firmware 0.8.8 timing and transport path on a second
physically attached ESP32 C6. It measures raw callback cadence, edge DSP
cadence, process stability, and end to end sensing delivery. It does not
qualify heartbeat, respiration, pose, identity, room separation, or person
count accuracy against labelled ground truth.
## Hardware and firmware
| Field | Measured value |
|---|---|
| Board | ESP32 C6 QFN40 revision 0.2 |
| Logical node | 7 |
| Firmware before | 0.8.4 |
| Firmware after | 0.8.8 development build |
| App image | 1,051,552 bytes |
| App SHA 256 | `eab7561d65e302dc33e9331ac591763a46f92fb3fa9f824fef0e9b541daddb3f` |
| OTA slot size | 1,900,544 bytes |
| OTA headroom | 848,992 bytes, 45 percent |
Only the application partition at offset `0x20000` was flashed. WiFi
credentials, logical node identity, sensing server target, channel, edge tier,
bootloader, partition table, OTA metadata, and NVS were preserved. The pre
update application was copied to a private recovery file outside the
repository. Its SHA 256 is
`f5ebc5e0142425adae16e9180bf298ef444ea8862ba0d8809c310a39fe45721d`.
The device partition table was also read before the update and had SHA 256
`0a8d2f192a8fff209d6c75ab639fcf8aa2f43c64abb732c6e74596fbd6971dca`.
The post update boot log reported firmware 0.8.8, node 7, channel 10, Tier 2,
an 8 Hz edge DSP cadence, and the preserved sensing server target. The OTA
status endpoint reported firmware 0.8.8 running from `ota_0`, with `ota_1` as
the next partition and the correct 1,900,544 byte limit. The sensing server
health endpoint remained ready with ESP32 input.
## Before and after
The pre update baseline was a 20 second observation on the same attached board.
The post update observation was a five minute steady state run after boot.
| Observation | Before 0.8.4 | After 0.8.8 |
|---|---:|---:|
| Raw callback mean | 39.05 pps | 36.32 pps |
| Raw callback range | 35 through 42 pps | 24 through 42 pps |
| Server CSI FPS mean | 46.23 Hz | 48.88 Hz |
| WebSocket parser errors | 0 | 0 |
| WebSocket reconnects | 0 | 0 |
Raw callback mean changed by negative 7.0 percent while the server CSI FPS
estimate changed by positive 5.7 percent. Both remain above the 20 pps
transport floor. The result is transport neutral rather than an accuracy lift;
the room and WiFi traffic were not controlled between the two windows.
Firmware 0.8.4 did not expose the edge DSP cadence used by the temporal
filters. Firmware 0.8.8 held that separately governed clock at exactly 8.0 Hz
for every controller sample while preserving the higher rate raw network path.
## Five minute physical result
MEASURED on 2026 08 31 after flashing firmware 0.8.8:
| Device observation | Result |
|---|---:|
| Duration | 300.70 seconds |
| Controller samples | 300 |
| Raw callback mean | 36.32 pps |
| Raw callback range | 24 through 42 pps |
| Edge DSP mean | 8.00 Hz |
| Edge DSP range | 8.00 through 8.00 Hz |
| ENOMEM events | 0 |
| UDP send failures | 0 |
| ESP NOW nonzero failure lines | 0 |
| Other steady state errors | 0 |
| Watchdogs, panics, or reboots | 0 |
| End to end WebSocket observation | Result |
|---|---:|
| Duration | 300.06 seconds |
| Sensing frames | 36,254 |
| JSON parse errors | 0 |
| WebSocket errors or reconnects | 0 |
| Frames containing node 7 | 35,313 |
| Node 7 frame coverage | 97.40 percent |
| Node 7 stale frames | 0 |
| Maximum node 7 staleness | 972 ms |
| Maximum node 7 inference age | 483 ms |
| Maximum WebSocket frame gap | 108 ms |
| Nodes per frame | 0 through 5 |
| Fused presence count contradictions | 0 |
One ENOMEM backoff occurred during startup and recovered in 210 ms. No memory
backoff or send failure recurred in the separate five minute steady state
window. The boot log also reported the documented fail closed OTA behavior:
the status service was available, but image upload remained rejected because
this node has no provisioned OTA signing secret.
## Result and limitation
The node 7 timing and transport update passes. Its configuration survived, the
edge DSP clock remained phase stable at the measured sustainable C6 rate, and
the live service received fresh node 7 data throughout the run. This does not
complete the ADR 346 occupancy qualification for node 7 because the room was
not held empty and the live aggregate did not expose 30 absent edge packets.
The largest uncertainty remains inference accuracy. Timing stability cannot
prove better vital, motion, room separation, or multi person estimates without
synchronized held out labels and a controlled empty room sequence.
## Acceptance test
Repeat this five minute procedure after timing, WiFi, filter, or scheduling
changes. Pass transport only when raw callback yield remains at least 20 pps,
DSP cadence stays within one hertz of the configured target, the device has
zero steady state memory backoff, send failure, watchdog, panic, and reboot
events, the server has zero parse failures and reconnects, and the updated node
stays fresh. Complete occupancy qualification separately with at least 30
absent edge packets and zero absent packets carrying a nonzero person count.

View File

@@ -0,0 +1,60 @@
# ESP32 C6 occupancy evidence qualification
## Scope
This record qualifies the fail closed person count invariant in ADR 346 on one physically attached ESP32 C6. It does not qualify person counting accuracy, identity, pose, room separation, or vital sign accuracy.
## Hardware and firmware
| Field | Measured value |
|---|---|
| Board | ESP32 C6 QFN40 revision 0.2 |
| Logical node | 4 |
| Firmware before | 0.7.0 |
| Firmware after | 0.8.4 development build |
| App image | 1,051,168 bytes |
| App SHA 256 | `f9470a31b82612f1740f0cf0943ddb78917cd58784ba16d2e9d57cc8fb39364c` |
| OTA slot size | 1,900,544 bytes |
| CSI stream target | Preserved from NVS |
The device partition table was read before the update. NVS, OTA metadata, bootloader, and partition table were not overwritten. A private recovery copy was created outside the repository and excluded from version control.
## Software gates
| Gate | Result |
|---|---|
| Firmware host tests | PASS, 54 assertions across encoding, vital evidence, and mmWave detection |
| Rust sensing server package | PASS, 532 library tests plus all package integration and documentation tests |
| Mobile Jest suite | PASS, 164 suites and 1,223 tests |
| Mobile TypeScript | PASS |
| Mobile ESLint | PASS |
| Mobile security verifier | PASS |
| Repository wide Rust formatting | PREEXISTING DRIFT outside this change; changed code builds and package tests pass |
## Physical result
MEASURED on 2026 08 31 from the live local sensing WebSocket for 300 seconds:
| Node | Firmware state | Edge packets | Absent packets | Absent with nonzero count | Result |
|---|---|---:|---:|---:|---|
| 4 | Updated | 242 | 61 | 0 | PASS |
| 3 | Unupdated control | 216 | 216 | 216 | Expected control failure |
| 7 | Unupdated control | 280 | 278 | 278 | Expected control failure |
Node 4 reduced the targeted logical contradiction from observed to zero, a 100 percent reduction for this invariant during this run. This is not a person count accuracy result.
The WebSocket run had zero JSON parse errors and one expected client close at completion. The sensing server remained ready with `engine_error_count=0`. A separate 45 second serial observation recorded 120 log lines, 17 CSI callback markers, zero ENOMEM backoffs, and zero other error lines.
The updated OTA status endpoint reports the selected 1,900,544 byte partition rather than the stale 921,600 byte constant. The 1,051,168 byte image therefore fits with 849,376 bytes of partition headroom.
## Remaining qualification
Nodes 3 and 7 still demonstrate the old contradictory behavior and must be upgraded only after their network identity, OTA credential, and rollback path are verified. The current run had no labelled ground truth, so multi person fidelity and adjacent room rejection remain unmeasured.
## Subsequent node 7 status
Later on 2026 08 31, node 7 was separately identified, backed up, upgraded to firmware 0.8.8, and transport qualified for five minutes. That later occupied room run had zero fused presence count contradictions but did not produce the 30 absent edge packets required to supersede the historical control result above. See `docs/validation/2026-08-31-esp32-c6-node7-rate-aware-sensing.md`.
## Acceptance test
Repeat a five minute capture after every firmware change. Pass only when every updated node has at least 30 absent packets, zero packets where `presence=false` and `n_persons>0`, zero parser errors, and a ready sensing server with zero engine errors.

View File

@@ -0,0 +1,124 @@
# ESP32 C6 rate aware sensing qualification
## Scope
This record qualifies ADR 347 on one physically attached ESP32 C6 and verifies
that the same source compiles for ESP32 S3. It measures transport cadence, edge
DSP cadence, process stability, and end to end sensing delivery. It does not
qualify heartbeat, respiration, gesture, pose, identity, or person count
accuracy against labelled ground truth.
## Hardware and firmware
| Field | Measured value |
|---|---|
| Board | ESP32 C6 QFN40 revision 0.2 |
| Logical node | 4 |
| Firmware before | 0.8.4 |
| Firmware after | 0.8.8 development build |
| C6 app image | 1,051,552 bytes |
| C6 app SHA 256 | `f2ea422c9b99ec13c7a168afc2b019229642769ffabfd8f29a85978770236e87` |
| OTA slot size | 1,900,544 bytes |
| OTA headroom | 848,992 bytes, 45 percent |
| S3 compile image | 1,127,104 bytes |
| S3 compile SHA 256 | `63e4f0c484d79e7dd37eb28275951c8beb924e6908942f7fec0b90d92109129c` |
Only the application partition at offset `0x20000` was flashed. WiFi
credentials, node identity, sensing server target, bootloader, partition table,
OTA metadata, and NVS were preserved. The pre update OTA application was read
to a private recovery file outside the repository. Its SHA 256 is
`a2e503f1622b2f3f9c1cfce0a07ba34b9fc5d6a413b6346311622af1fe18a6d8`.
The OTA status endpoint reported firmware 0.8.8 running from `ota_0` after the
update. The sensing server health endpoint remained ready with ESP32 input.
## Software gates
| Gate | Result |
|---|---|
| Rate estimator and occupancy host tests | PASS, 30 assertions |
| ADR 110 encoding host tests | PASS, 21 assertions |
| mmWave frame predicate host tests | PASS, 8 assertions |
| ESP32 C6 IDF 5.4 ARM64 build | PASS |
| ESP32 S3 IDF 5.4 ARM64 build | PASS, compile only |
| Image checksum and validation hash | PASS |
| Repository diff whitespace check | PASS |
| Local libFuzzer aggregate | NOT RUN, local Xcode toolchain lacks `libclang_rt.fuzzer_osx.a` |
For this C6 record, the S3 result was source and toolchain validation only and
no S3 runtime claim is made here. The later physical S3 Tier 0 transport run is
recorded separately in
`docs/validation/2026-08-31-esp32-s3-rate-aware-transport.md`.
## Measured rate correction
The pre update 20 second C6 baseline delivered a mean 34.05 raw callbacks per
second, median 34.5, and range 28 through 37. An intermediate 0.8.7 physical
run requested 10 Hz edge DSP but converged to 8.0 through 8.4 Hz while raw CSI
remained 30 through 40 packets per second. This proved that C6 Tier 2 compute,
not the raw transport, was the limiting path.
Firmware 0.8.8 therefore keeps the 50 Hz probe and independent raw network
path, but sets the C6 Tier 2 DSP clock to its measured sustainable 8 Hz. The
phase preserving sampler prevents callback jitter from shifting the configured
clock, and the filter estimator follows processed timestamps rather than raw
probe intent.
## Five minute physical result
MEASURED on 2026 08 31 after flashing firmware 0.8.8:
| Device observation | Result |
|---|---:|
| Duration | 300.64 seconds |
| Controller ticks | 300 |
| Raw callback mean | 34.92 pps |
| Raw callback range | 22 through 41 pps |
| Edge DSP mean | 8.00 Hz |
| Edge DSP range | 8.00 through 8.00 Hz |
| ENOMEM events | 0 |
| UDP send failures | 0 |
| Other steady state errors | 0 |
| Watchdogs, panics, or reboots | 0 |
| End to end WebSocket observation | Result |
|---|---:|
| Duration | 300.01 seconds |
| Sensing frames | 26,786 |
| JSON parse errors | 0 |
| Reconnects | 0 |
| Frames containing node 4 | 26,148 |
| Node 4 frame coverage | 97.62 percent |
| Node 4 stale frames | 0 |
| Maximum node 4 inference age | 176 ms |
| Maximum WebSocket frame gap | 110 ms |
| Nodes per frame | 0 through 4 |
| Fused `presence=false` with nonzero count contradictions | 0 |
The boot log emitted one expected iTWT negotiation error because the access
point rejected the requested target wake time parameters. Firmware immediately
selected its documented opportunistic CSI fallback. No iTWT or other error
recurred during the five minute steady state window.
## Result and limitation
ADR 347 timing and transport acceptance passes on the attached C6. Raw
throughput did not regress relative to the short baseline, the edge clock now
matches the rate the temporal filters actually receive, and node 4 was never
stale when present in the live sensing service. The separate occupancy
qualification recorded 61 absent node 4 packets with zero contradictions for
the unchanged fail closed invariant. This run did not repeat an empty room
sequence because the room was occupied during qualification.
The largest remaining uncertainty is inference accuracy. Stable timing removes
one source of feature distortion but cannot prove better heartbeat, respiration,
gesture, or multi person classification without synchronized held out labels.
## Acceptance test
Repeat this five minute procedure after any timing, WiFi, filter, or task
scheduling change. Pass only when raw callback yield remains at least 20 pps,
DSP cadence remains within one hertz of the configured target, the device has
zero steady state ENOMEM, send failure, watchdog, panic, and reboot events, the
server has zero parse failures and reconnects, node 4 stays fresh, and fused
presence count contradictions remain zero.

View File

@@ -0,0 +1,126 @@
# ESP32 S3 rate aware transport qualification
## Scope
This record qualifies the firmware 0.8.8 raw transport path on one physically
attached ESP32 S3. The node retained its existing Tier 0 configuration, so this
run does not qualify the S3 edge DSP rate, temporal filters, heartbeat,
respiration, gesture, pose, identity, person count, or localization accuracy.
## Hardware and firmware
| Field | Measured value |
|---|---|
| Board | ESP32 S3 QFN56 revision 0.2 with 2 MB embedded PSRAM |
| Logical node | 1 |
| Firmware before | 0.8.4 |
| Firmware after | 0.8.8 development build |
| Edge tier | 0, raw passthrough |
| App image | 1,127,104 bytes |
| App SHA 256 | `b531c76900c07d0d6f6e864a5f28afff3e71f124777af97358bb405b34e339a2` |
| OTA slot size | 2,097,152 bytes |
| OTA headroom | 970,048 bytes, 46 percent |
The production partition table was read from the device before the update.
Only the application partition at offset `0x20000` was flashed. WiFi
credentials, logical node identity, channel, sensing server target, bootloader,
partition table, OTA metadata, and NVS were preserved. A private recovery copy
of the prior 2 MB application partition was saved outside the repository. Its
SHA 256 is
`14e72c060c4f1a465f739949b873f6aade5b8899a8909f6c5bb591ee49837c1b`.
The post update boot log reported firmware 0.8.8, logical node 1, channel 4,
the preserved UDP target, Tier 0 raw passthrough, and successful CSI streaming.
The OTA status endpoint reported firmware 0.8.8 running from `ota_0` with the
correct 2,097,152 byte update limit. The sensing server health endpoint remained
ready with ESP32 input.
## Software and image gates
| Gate | Result |
|---|---|
| Firmware encoding, vitals, occupancy, and mmWave host tests | PASS, 59 assertions |
| Firmware provisioning Python tests | PASS, 14 tests |
| ESP32 S3 IDF 5.4 ARM64 clean build | PASS |
| Image target detection | PASS, ESP32 S3 |
| Image checksum | PASS |
| Image validation hash | PASS |
| Application partition fit | PASS, 46 percent free |
| Physical flash write verification | PASS |
| Preserved runtime configuration | PASS |
The build excluded the optional WASM3 source because it was not present in the
firmware checkout. The boot log therefore reported WASM Tier 3 disabled. That
is not a regression introduced by this update and is outside this transport
qualification.
## Before and after comparison
The pre update baseline was a 20 second serial and WebSocket capture on firmware
0.8.4. The post update stability observation was 300 seconds on firmware 0.8.8.
| Observation | Before 0.8.4 | After 0.8.8 | Change |
|---|---:|---:|---:|
| Raw CSI yield mean | 27.80 pps | 28.03 pps | plus 0.83 percent |
| Server CSI FPS mean | 39.58 | 39.13 | minus 1.15 percent |
| Node frame coverage | 100 percent | 100 percent | unchanged |
| Maximum node staleness | 1,571 ms | 1,565 ms | minus 0.38 percent |
| Maximum WebSocket frame gap | 111 ms | 112 ms | plus 0.90 percent |
| Device or parser errors | 0 | 0 | unchanged |
These small movements are operationally neutral and within uncontrolled room
and WiFi variation. Firmware 0.8.8 did not regress the raw transport. Because
Tier 0 bypasses the DSP task, this run provides no evidence that temporal
features or inference accuracy improved.
## Five minute physical result
MEASURED on 2026 08 31 after flashing firmware 0.8.8:
| Device observation | Result |
|---|---:|
| Duration | 300 seconds |
| Controller yield samples | 300 |
| Raw CSI yield mean | 28.03 pps |
| Raw CSI yield range | 22 through 34 pps |
| ENOMEM or stack errors | 0 |
| UDP send failures | 0 |
| ESP NOW send failures | 0 |
| Unexpected resets | 0 |
| Watchdogs or panics | 0 |
| End to end WebSocket observation | Result |
|---|---:|
| Duration | 300.03 seconds |
| Sensing frames | 10,559 |
| Frames containing node 1 | 10,559 |
| Node 1 frame coverage | 100 percent |
| Source offline frames | 0 |
| JSON parse errors | 0 |
| WebSocket errors | 0 |
| Early closes | 0 |
| Maximum node staleness | 1,565 ms |
| Maximum WebSocket frame gap | 112 ms |
## Result and limitation
The ESP32 S3 raw transport acceptance passes. Firmware 0.8.8 booted from the
existing slot, retained the installation configuration, sustained the prior raw
CSI delivery rate, and completed the burn with zero transport or runtime
errors. The result extends ADR 347 physical coverage to the S3 transport path.
The largest remaining uncertainty is S3 Tier 2 behavior and inference accuracy.
The log line reporting the configured 20 Hz DSP cadence is not proof that DSP
ran because the preserved Tier 0 setting explicitly disables the DSP task. A
separate, rollback protected Tier 2 qualification with synchronized held out
labels is required before making heartbeat, respiration, motion, or accuracy
claims.
## Acceptance test
Repeat this five minute procedure after any S3 timing, WiFi, transport, or task
scheduling change. Pass only when raw callback yield remains at least 20 pps,
node frame coverage remains at least 99 percent, and the device and server have
zero send failures, offline frames, parser errors, WebSocket errors, watchdogs,
panics, and unexpected resets. Qualify Tier 2 separately and require its
measured DSP cadence to stay within one hertz of the configured target.

View File

@@ -7,40 +7,81 @@ This firmware captures WiFi Channel State Information (CSI) from an ESP32-S3 (pr
[![ESP-IDF v5.4](https://img.shields.io/badge/ESP--IDF-v5.4-blue.svg)](https://docs.espressif.com/projects/esp-idf/en/v5.4/)
[![Target: ESP32-S3 / ESP32-C6](https://img.shields.io/badge/target-ESP32--S3%20%7C%20ESP32--C6-purple.svg)](https://www.espressif.com/en/products/socs/esp32-s3)
[![License: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-green.svg)](../../LICENSE)
[![Binary: ~943 KB](https://img.shields.io/badge/binary-~943%20KB-orange.svg)](#memory-budget)
[![Binary: up to 1.1 MB](https://img.shields.io/badge/binary-up%20to%201.1%20MB-orange.svg)](#memory-budget)
[![CI: Docker Build](https://img.shields.io/badge/CI-Docker%20Build-brightgreen.svg)](../../.github/workflows/firmware-ci.yml)
> | Capability | Method | Performance |
> |------------|--------|-------------|
> | **CSI streaming** | Per-subcarrier I/Q capture over UDP | ~20 Hz, ADR-018 binary format |
> | **Breathing detection** | Bandpass 0.1-0.5 Hz, zero-crossing BPM | 6-30 BPM |
> | **Heart rate** | Bandpass 0.8-2.0 Hz, zero-crossing BPM | 40-120 BPM |
> | **Presence indicator** (heuristic) | Phase variance + adaptive threshold (60 s ambient learning) | < 1 ms latency, false-positives under strong RF interference — see [Tier 2 caveats](#what-this-firmware-does-not-do-tier-2-caveats) |
> | Capability | Method | Current contract |
> |------------|--------|------------------|
> | **CSI streaming** | Per-subcarrier I/Q capture over UDP | Radio-dependent cadence with a 20 packets-per-second hardware acceptance floor, ADR-018 binary format |
> | **Breathing estimate** | Bandpass 0.1-0.5 Hz, zero-crossing BPM | Experimental 6-30 BPM output; calibrate against a reference before use |
> | **Heart-rate estimate** | Bandpass 0.8-2.0 Hz, zero-crossing BPM | Experimental 40-120 BPM output; not a medical measurement |
> | **Presence indicator** (heuristic) | Phase variance + adaptive threshold (60 s ambient learning) | Fast local indicator; strong RF interference can cause false positives — see [Tier 2 caveats](#what-this-firmware-does-not-do-tier-2-caveats) |
> | **Fall detection** | Phase acceleration threshold | Configurable sensitivity |
> | **Programmable sensing** | WASM modules loaded over HTTP | Hot-swap, no reflash |
## Firmware 0.8.8 in plain language
Release 0.8.8 makes the sensing stream more internally consistent and easier
to diagnose:
1. An empty-room decision can no longer carry a nonzero person count. Older
firmware could expose those two contradictory values at the same time.
2. ESP32-C6 signal processing now uses a stable 8 Hz clock while raw CSI keeps
streaming at the faster radio-dependent rate. This prevents temporal
filters from silently using the wrong time scale.
3. The one-second diagnostic reports both raw callback yield and the DSP rate,
making slow or overloaded nodes visible.
4. OTA reports the application slot selected by the board instead of assuming
a fixed 900 KB limit.
Two ESP32-C6 boards and one ESP32-S3 completed five-minute physical transport
runs. The updated nodes had zero steady-state send failures, parser failures,
watchdogs, panics, or reboots. These results prove timing and transport
stability, not better heartbeat, pose, identity, or person-count accuracy.
See the [0.8.8 release notes](../../docs/releases/v0.8.8-esp32.md) and
[ADR 347](../../docs/adr/ADR-347-rate-aware-esp32-temporal-sensing.md) for the
measured evidence and limitations.
---
## Quick Start
For users who want to get running fast. Detailed explanations follow in later sections.
### 0. Pre-built binaries (v0.6.5 — skip the build step)
### 0. Download the 0.8.8 release
Pre-built binaries are in `firmware/esp32-csi-node/release_bins/` (version: see `release_bins/version.txt`).
Flash them directly:
Use the versioned source tag and binaries on the
[v0.8.8 ESP32 release page](https://github.com/ruvnet/RuView/releases/tag/v0.8.8-esp32).
Choose the package that names both your chip and flash size:
| Package | Use it for |
|---------|------------|
| `esp32-csi-node-v0.8.8-s3-8mb-flash-bundle.zip` | Fresh ESP32-S3 installation with 8 MB flash |
| `esp32-csi-node-v0.8.8-s3-4mb-flash-bundle.zip` | Fresh ESP32-S3 installation with 4 MB flash |
| `esp32-csi-node-v0.8.8-c6-4mb-flash-bundle.zip` | Fresh ESP32-C6 installation using the supported 4 MB layout |
Each bundle contains the matching bootloader, partition table, OTA metadata,
application, checksums, and a short flashing guide. Never flash an S3 bundle
onto a C6, or a C6 bundle onto an S3.
Example for an 8 MB ESP32-S3 after extracting its bundle:
```bash
python -m esptool --chip esp32s3 --port COM7 --baud 460800 \
write_flash --flash_mode dio --flash_size 8MB \
0x0 firmware/esp32-csi-node/release_bins/bootloader.bin \
0x8000 firmware/esp32-csi-node/release_bins/partition-table.bin \
0xf000 firmware/esp32-csi-node/release_bins/ota_data_initial.bin \
0x20000 firmware/esp32-csi-node/release_bins/esp32-csi-node.bin
0x0 bootloader.bin \
0x8000 partition-table.bin \
0xf000 ota_data_initial.bin \
0x20000 esp32-csi-node.bin
```
For 4 MB boards use `release_bins/esp32-csi-node-4mb.bin` and `release_bins/partition-table-4mb.bin`
with `--flash_size 4MB`.
For an existing provisioned node, back up its current application and inspect
`http://DEVICE_IP:8032/ota/status` before choosing an application-only update.
Writing only offset `0x20000` is safe only when the status endpoint reports
`running_partition` as `ota_0` and the downloaded image matches the board.
The full bundles do not include NVS, so the documented four-offset install
preserves WiFi and node configuration while replacing the boot and application
images.
### 1. Build (Docker -- the only reliable method)
@@ -111,7 +152,7 @@ curl http://<ESP32_IP>:8032/wasm/list
| **Recommended boards** | ESP32-S3-DevKitC-1, XIAO ESP32-S3 | Any ESP32-S3 with 8 MB flash works |
| **Deployment** | 3-6 nodes per room | Multistatic mesh for 360-degree coverage |
> **Tip:** A single node provides presence and vital signs along its line of sight. Multiple nodes (3-6) create a multistatic mesh that resolves 3D pose with <30 mm jitter and zero identity swaps.
> **Tip:** A single node is mainly useful for presence and motion along one RF link. Three or more spatially separated links improve geometry and track separation. Location, pose, and multi-person accuracy still require room-specific calibration and held-out ground-truth evaluation.
> **⚠️ Thermal warning — compact boards (ESP32-S3-Zero, SuperMini, other coin-sized clones):** This firmware runs the WiFi radio with modem sleep disabled (`WIFI_PS_NONE`, required for continuous CSI capture) plus a full edge-processing DSP pipeline on Core 1 (`edge_tier=2`) plus, on ADR-183 builds, a continuous 40 Hz onboard LED driver. That's sustained high current draw with no duty-cycling. Full-size dev boards (DevKitC-1, XIAO) have more copper pour and thermal mass around the regulator and tolerate this fine. Coin-sized clones with minimal PCB area and budget regulators may run hot to the touch during normal operation, and in at least one field report, boards that ran hot during a session failed to power on afterward (regulator damage suspected — see issue tracker). Give these boards airflow, don't stack or enclose them, and check them by touch during the first several minutes of a new deployment. If a board is uncomfortably hot (not just warm), power it down and let it cool before continuing.

View File

@@ -39,6 +39,17 @@ menu "CSI Node Configuration"
help
WiFi channel to listen on for CSI data.
config CSI_SELF_PING_HZ
int "Connected-STA CSI probe rate (Hz)"
default 50
range 10 50
help
Rate of the one-byte ICMP probes used to create a stable OFDM
CSI source on quiet networks. Fifty hertz is the measured safety
ceiling for the current ESP-IDF WiFi callback path. Higher rates
are intentionally rejected because sustained callback load above
50 Hz has caused WiFi ISR and packet-buffer failures on S3 and C6.
endmenu
menu "Edge Intelligence (ADR-039)"
@@ -66,6 +77,18 @@ menu "Edge Intelligence (ADR-039)"
help
Number of highest-variance subcarriers to use for DSP.
config EDGE_DSP_SAMPLE_HZ
int "On-device edge DSP sample rate (Hz)"
default 8 if IDF_TARGET_ESP32C6
default 20
range 8 50
help
Uniform rate at which CSI callbacks enter the Tier 1 and Tier 2
edge DSP. Raw CSI transmission keeps its independent full-rate
path. Eight hertz is the hardware-measured sustainable C6 Tier 2
setting and preserves a 4 Hz Nyquist limit for the 0.1-2.0 Hz
vital bands.
config EDGE_FALL_THRESH
int "Fall detection threshold (x1000)"
default 15000

View File

@@ -248,9 +248,10 @@ static void medium_loop_cb(TimerHandle_t t)
portEXIT_CRITICAL(&s_obs_lock);
if (s_obs_valid) {
ESP_LOGI(TAG, "medium tick: state=%u yield=%upps motion=%.2f presence=%.2f rssi=%d",
ESP_LOGI(TAG, "medium tick: state=%u yield=%upps dsp=%.1fHz motion=%.2f presence=%.2f rssi=%d",
(unsigned)s_state,
(unsigned)obs.pkt_yield_per_sec,
(double)edge_get_sample_rate_hz(),
(double)obs.motion_score,
(double)obs.presence_score,
(int)obs.rssi_median_dbm);

View File

@@ -63,6 +63,32 @@ static uint32_t s_send_ok = 0;
static uint32_t s_send_fail = 0;
static uint32_t s_rate_skip = 0;
#ifndef CONFIG_CSI_SELF_PING_HZ
#define CONFIG_CSI_SELF_PING_HZ 50
#endif
#if CONFIG_CSI_SELF_PING_HZ < 10 || CONFIG_CSI_SELF_PING_HZ > 50
#error "CONFIG_CSI_SELF_PING_HZ must stay within the hardware-qualified 10-50 Hz range"
#endif
#define CSI_SELF_PING_INTERVAL_MS (1000U / CONFIG_CSI_SELF_PING_HZ)
#ifndef CONFIG_EDGE_DSP_SAMPLE_HZ
#if CONFIG_IDF_TARGET_ESP32C6
#define CONFIG_EDGE_DSP_SAMPLE_HZ 8
#else
#define CONFIG_EDGE_DSP_SAMPLE_HZ 20
#endif
#endif
#if CONFIG_EDGE_DSP_SAMPLE_HZ < 8 || CONFIG_EDGE_DSP_SAMPLE_HZ > 50
#error "CONFIG_EDGE_DSP_SAMPLE_HZ must stay within the supported 8-50 Hz range"
#endif
#define EDGE_DSP_MIN_INTERVAL_US (1000000U / CONFIG_EDGE_DSP_SAMPLE_HZ)
static int64_t s_next_edge_enqueue_us = 0;
static uint32_t s_edge_rate_skip = 0;
/**
* Minimum interval between UDP sends in microseconds.
* CSI callbacks can fire hundreds of times per second in promiscuous mode.
@@ -300,10 +326,31 @@ static void wifi_csi_callback(void *ctx, wifi_csi_info_t *info)
}
}
/* ADR-039: Enqueue raw I/Q into edge processing ring buffer. */
/* ADR-039 / ADR-347: Raw CSI stays at the independent network cadence,
* while the on-device Tier 1/2 pipeline receives a uniform, sustainable
* stream. Enqueuing every burst frame overloaded the unicore C6 DSP and
* turned 30-40 callback pps into an irregular approximately 8 Hz subset. */
if (info->buf && info->len > 0) {
edge_enqueue_csi((const uint8_t *)info->buf, (uint16_t)info->len,
(int8_t)info->rx_ctrl.rssi, info->rx_ctrl.channel);
if (s_next_edge_enqueue_us == 0) {
s_next_edge_enqueue_us = now_us;
}
if (now_us >= s_next_edge_enqueue_us) {
(void)edge_enqueue_csi((const uint8_t *)info->buf, (uint16_t)info->len,
(int8_t)info->rx_ctrl.rssi, info->rx_ctrl.channel);
/* Preserve the configured sample clock instead of resetting it to
* each irregular callback. With roughly 35 raw callbacks per
* second, a last-seen 100 ms gate selected every fourth callback
* and drifted to roughly 8 Hz. Advancing the deadline by complete
* periods alternates the available callbacks around the configured
* phase and prevents both drift and catch-up bursts. */
int64_t periods = ((now_us - s_next_edge_enqueue_us) /
EDGE_DSP_MIN_INTERVAL_US) + 1;
s_next_edge_enqueue_us += periods * EDGE_DSP_MIN_INTERVAL_US;
} else {
s_edge_rate_skip++;
}
}
/* ADR-110 §A0.11/§A0.12 — Emit a sync-packet every N CSI frames so the
@@ -411,7 +458,7 @@ static void csi_start_self_ping(void)
esp_ping_config_t cfg = ESP_PING_DEFAULT_CONFIG();
cfg.target_addr = target;
cfg.count = ESP_PING_COUNT_INFINITE;
cfg.interval_ms = 20; /* 50 Hz -> ~50 received OFDM replies/sec */
cfg.interval_ms = CSI_SELF_PING_INTERVAL_MS;
cfg.data_size = 1;
cfg.task_stack_size = 4096;
@@ -424,7 +471,8 @@ static void csi_start_self_ping(void)
if (esp_ping_new_session(&cfg, &cbs, &s_self_ping) == ESP_OK && s_self_ping != NULL) {
esp_ping_start(s_self_ping);
ESP_LOGI(TAG, "self-ping started -> %s @50Hz (CSI OFDM source, fix #521/#954)", gw_str);
ESP_LOGI(TAG, "self-ping started -> %s @%dHz (CSI OFDM source, fix #521/#954)",
gw_str, CONFIG_CSI_SELF_PING_HZ);
} else {
ESP_LOGW(TAG, "self-ping: esp_ping_new_session failed");
s_self_ping = NULL;
@@ -592,6 +640,8 @@ void csi_collector_init(void)
ESP_LOGI(TAG, "CSI collection initialized (node_id=%u, channel=%u)",
(unsigned)s_node_id, (unsigned)csi_channel);
ESP_LOGI(TAG, "edge DSP cadence=%dHz; raw CSI network cadence remains independent",
CONFIG_EDGE_DSP_SAMPLE_HZ);
/* RuView#521/#954: start the connected-STA traffic source so the CSI engine
* receives a guaranteed OFDM unicast floor even when promiscuous capture is

View File

@@ -38,6 +38,16 @@ extern nvs_config_t g_nvs_config;
static const char *TAG = "edge_proc";
#ifndef CONFIG_EDGE_DSP_SAMPLE_HZ
#if CONFIG_IDF_TARGET_ESP32C6
#define CONFIG_EDGE_DSP_SAMPLE_HZ 8
#else
#define CONFIG_EDGE_DSP_SAMPLE_HZ 20
#endif
#endif
#define EDGE_CONFIGURED_SAMPLE_RATE_HZ ((float)CONFIG_EDGE_DSP_SAMPLE_HZ)
/* ======================================================================
* SPSC Ring Buffer (lock-free, single-producer single-consumer)
* ====================================================================== */
@@ -355,11 +365,12 @@ static float s_heartrate_filtered[EDGE_PHASE_HISTORY_LEN];
/** Measured CSI sample rate (Hz), smoothed from frame timestamps.
* #985's self-ping raised the callback rate above the old ~10 Hz beacon
* assumption and made it variable (~13-19 Hz); a fixed rate scaled BPM wrong
* and made HR swing with CSI yield. See update in process_csi_frame(). */
static float s_sample_rate_hz = 15.0f;
static float s_filter_design_fs = 20.0f; /* fs the biquads were last designed at */
static uint32_t s_last_frame_ts_us = 0;
* assumption and made it variable. A fixed rate scales BPM and Doppler bins
* incorrectly. Start from the filter design rate, then follow measured time. */
static float s_sample_rate_hz = EDGE_CONFIGURED_SAMPLE_RATE_HZ;
static float s_filter_design_fs = EDGE_CONFIGURED_SAMPLE_RATE_HZ; /* fs the biquads were last designed at */
static uint32_t s_rate_window_start_us = 0;
static uint32_t s_rate_window_intervals = 0;
/** Latest vitals state. */
static float s_breathing_bpm;
@@ -409,6 +420,21 @@ static edge_biquad_t s_person_bq_hr[EDGE_MAX_PERSONS];
static float s_person_br_filt[EDGE_MAX_PERSONS][EDGE_PHASE_HISTORY_LEN];
static float s_person_hr_filt[EDGE_MAX_PERSONS][EDGE_PHASE_HISTORY_LEN];
/** Clear person slots whenever the room-level presence gate is closed. */
static void reset_person_count_state(void)
{
s_person_count_candidate = 0;
s_person_count_streak = 0;
s_person_count_stable = 0;
for (uint8_t p = 0; p < EDGE_MAX_PERSONS; p++) {
s_persons[p].active = false;
s_persons[p].history_len = 0;
s_persons[p].history_idx = 0;
s_persons[p].breathing_bpm = 0.0f;
s_persons[p].heartrate_bpm = 0.0f;
}
}
/** Latest vitals packet (thread-safe via volatile copy). */
static volatile edge_vitals_pkt_t s_latest_pkt;
static volatile bool s_pkt_valid;
@@ -898,7 +924,10 @@ static void send_vitals_packet(void)
for (uint8_t p = 0; p < EDGE_MAX_PERSONS; p++) {
if (s_persons[p].active) n_active++;
}
pkt.n_persons = n_active;
/* Fail closed: the slot heuristic cannot assert occupants while the
* debounced presence gate is false. The host repeats this invariant for
* backward compatibility with older firmware. */
pkt.n_persons = edge_evidence_person_count(s_presence_detected, n_active);
pkt.motion_energy = s_motion_energy;
pkt.presence_score = s_presence_score;
@@ -1038,20 +1067,27 @@ static void process_frame(const edge_ring_slot_t *slot)
s_frame_count++;
s_latest_rssi = slot->rssi;
/* Measure the REAL CSI sample rate from inter-frame timestamps. #985's
* self-ping made the callback rate variable (~13-19 Hz); the old fixed
* 10 Hz both scaled BPM wrong (true ~87 BPM read as ~45) and made HR swing
* as CSI yield fluctuated. EMA-smooth and clamp to a plausible band. */
if (s_last_frame_ts_us != 0 && slot->timestamp_us > s_last_frame_ts_us) {
float dt = (float)(slot->timestamp_us - s_last_frame_ts_us) * 1e-6f;
if (dt > 0.02f && dt < 0.5f) { /* 2-50 Hz plausible; reject gaps/hops */
float inst = 1.0f / dt;
s_sample_rate_hz += 0.05f * (inst - s_sample_rate_hz);
if (s_sample_rate_hz < 8.0f) s_sample_rate_hz = 8.0f;
if (s_sample_rate_hz > 30.0f) s_sample_rate_hz = 30.0f;
/* Measure the real CSI sample rate over one-second timestamp windows. WiFi
* replies arrive in bursts, so filtering individual short intervals made
* a 35 pps stream look like 12-16 Hz. Counting all processed intervals in
* the window preserves the clock actually seen by the temporal filters. */
if (s_rate_window_start_us == 0) {
s_rate_window_start_us = slot->timestamp_us;
s_rate_window_intervals = 0;
} else if (slot->timestamp_us > s_rate_window_start_us) {
s_rate_window_intervals++;
uint32_t elapsed_us = slot->timestamp_us - s_rate_window_start_us;
if (elapsed_us >= EDGE_SAMPLE_RATE_WINDOW_MIN_US) {
s_sample_rate_hz = edge_sample_rate_window_update(
s_sample_rate_hz, s_rate_window_intervals, elapsed_us);
s_rate_window_start_us = slot->timestamp_us;
s_rate_window_intervals = 0;
}
} else {
/* Timer wrap or reset. Start a fresh evidence window. */
s_rate_window_start_us = slot->timestamp_us;
s_rate_window_intervals = 0;
}
s_last_frame_ts_us = slot->timestamp_us;
/* Re-tune the biquads if the measured rate has drifted from their design fs,
* so the breathing (0.1-0.5 Hz) and HR (0.8-2.0 Hz) passbands stay in real
@@ -1202,8 +1238,15 @@ static void process_frame(const edge_ring_slot_t *slot)
}
}
/* --- Step 11: Multi-person vitals --- */
update_multi_person_vitals(slot->iq_data, n_subcarriers, sample_rate);
/* --- Step 11: Multi-person vitals ---
* Person slots are subordinate to the room presence gate. Processing or
* retaining slots while absent produced contradictory packets such as
* presence=false with n_persons=4. */
if (s_presence_detected) {
update_multi_person_vitals(slot->iq_data, n_subcarriers, sample_rate);
} else {
reset_person_count_state();
}
/* Yield after multi-person DSP so IDLE1 can feed Core 1 watchdog (#683). */
if (s_cfg.tier >= 2) vTaskDelay(1);
@@ -1314,6 +1357,11 @@ bool edge_get_vitals(edge_vitals_pkt_t *pkt)
return true;
}
float edge_get_sample_rate_hz(void)
{
return s_sample_rate_hz;
}
void edge_get_multi_person(edge_person_vitals_t *persons, uint8_t *n_active)
{
uint8_t active = 0;
@@ -1373,6 +1421,10 @@ esp_err_t edge_processing_init(const edge_config_t *cfg)
s_fall_detected = false;
s_latest_rssi = 0;
s_frame_count = 0;
s_sample_rate_hz = EDGE_CONFIGURED_SAMPLE_RATE_HZ;
s_filter_design_fs = EDGE_CONFIGURED_SAMPLE_RATE_HZ;
s_rate_window_start_us = 0;
s_rate_window_intervals = 0;
s_prev_phase_velocity = 0.0f;
s_fall_consec_count = 0;
s_fall_last_alert_us = 0;
@@ -1397,9 +1449,9 @@ esp_err_t edge_processing_init(const edge_config_t *cfg)
s_person_count_streak = 0;
s_person_count_stable = 0;
/* Design biquad bandpass filters.
* Sampling rate ~20 Hz (typical ESP32 CSI callback rate). */
const float fs = 20.0f;
/* Design biquad bandpass filters against the configured DSP clock. The
* measured timestamp estimator then follows sustained hardware drift. */
const float fs = EDGE_CONFIGURED_SAMPLE_RATE_HZ;
biquad_bandpass_design(&s_bq_breathing, fs, 0.1f, 0.5f);
biquad_bandpass_design(&s_bq_heartrate, fs, 0.8f, 2.0f);

View File

@@ -35,9 +35,56 @@
#define EDGE_TOP_K 8 /**< Top-K subcarriers to track. */
#define EDGE_MAX_SUBCARRIERS 128 /**< Max subcarriers per frame. */
/* ---- Measured sample-rate tracking ----
*
* The connected-STA probe produces up to 50 CSI opportunities per second,
* while contention and callback gating make the delivered cadence variable.
* Temporal filters must follow measured time rather than a fixed frame-rate
* assumption. The 60 Hz estimator ceiling leaves jitter headroom above the
* qualified 50 Hz callback limit. A one-second frame-count window represents
* bursty but valid WiFi arrivals more accurately than averaging only selected
* inter-frame intervals. */
#define EDGE_SAMPLE_RATE_MIN_HZ 8.0f
#define EDGE_SAMPLE_RATE_MAX_HZ 60.0f
#define EDGE_SAMPLE_RATE_EMA_ALPHA 0.25f
#define EDGE_SAMPLE_RATE_WINDOW_MIN_US 1000000U
#define EDGE_SAMPLE_RATE_WINDOW_MAX_US 3000000U
static inline float edge_sample_rate_window_update(float current_hz,
uint32_t frame_intervals,
uint32_t elapsed_us)
{
if (frame_intervals == 0 || elapsed_us < EDGE_SAMPLE_RATE_WINDOW_MIN_US ||
elapsed_us > EDGE_SAMPLE_RATE_WINDOW_MAX_US) {
return current_hz;
}
float instant_hz = (float)frame_intervals * 1000000.0f / (float)elapsed_us;
if (instant_hz < EDGE_SAMPLE_RATE_MIN_HZ) instant_hz = EDGE_SAMPLE_RATE_MIN_HZ;
if (instant_hz > EDGE_SAMPLE_RATE_MAX_HZ) instant_hz = EDGE_SAMPLE_RATE_MAX_HZ;
float next_hz = current_hz + EDGE_SAMPLE_RATE_EMA_ALPHA * (instant_hz - current_hz);
if (next_hz < EDGE_SAMPLE_RATE_MIN_HZ) return EDGE_SAMPLE_RATE_MIN_HZ;
if (next_hz > EDGE_SAMPLE_RATE_MAX_HZ) return EDGE_SAMPLE_RATE_MAX_HZ;
return next_hz;
}
/* ---- Multi-person ---- */
#define EDGE_MAX_PERSONS 4 /**< Max simultaneous persons. */
/**
* Enforce the wire-level occupancy invariant.
*
* A subcarrier slot estimate is supporting evidence only. It cannot assert an
* occupant when the independently debounced presence gate is false. Keeping
* this helper in the public firmware header lets host tests exercise the exact
* function used by the device build.
*/
static inline uint8_t edge_evidence_person_count(bool presence, uint8_t active_count)
{
if (!presence) return 0;
return active_count > EDGE_MAX_PERSONS ? EDGE_MAX_PERSONS : active_count;
}
/* ---- Multi-person counting gates (issue #998) ----
*
* Over-counting root cause: the multi-person path used to split the top-K
@@ -238,6 +285,12 @@ bool edge_enqueue_csi(const uint8_t *iq_data, uint16_t iq_len,
*/
bool edge_get_vitals(edge_vitals_pkt_t *pkt);
/**
* Return the timestamp-derived CSI cadence used to design temporal filters.
* This is diagnostic evidence, not the raw callback or network delivery rate.
*/
float edge_get_sample_rate_hz(void);
/**
* Get multi-person vitals array.
*

View File

@@ -23,9 +23,6 @@ static const char *TAG = "ota_update";
/** OTA HTTP server port. */
#define OTA_PORT 8032
/** Maximum firmware size (900 KB — matches CI binary size gate). */
#define OTA_MAX_SIZE (900 * 1024)
/** NVS namespace and key for the OTA pre-shared key. */
#define OTA_NVS_NAMESPACE "security"
#define OTA_NVS_KEY "ota_psk"
@@ -95,11 +92,11 @@ static esp_err_t ota_status_handler(httpd_req_t *req)
int len = snprintf(response, sizeof(response),
"{\"version\":\"%s\",\"date\":\"%s\",\"time\":\"%s\","
"\"running_partition\":\"%s\",\"next_partition\":\"%s\","
"\"max_size\":%d}",
"\"max_size\":%lu}",
app->version, app->date, app->time,
running ? running->label : "unknown",
update ? update->label : "none",
OTA_MAX_SIZE);
(unsigned long)(update ? update->size : 0));
httpd_resp_set_type(req, "application/json");
httpd_resp_send(req, response, len);
@@ -121,12 +118,6 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
ESP_LOGI(TAG, "OTA update started, content_length=%d", req->content_len);
if (req->content_len <= 0 || req->content_len > OTA_MAX_SIZE) {
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST,
"Invalid firmware size (must be 1B - 900KB)");
return ESP_FAIL;
}
const esp_partition_t *update_partition = esp_ota_get_next_update_partition(NULL);
if (update_partition == NULL) {
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR,
@@ -134,6 +125,15 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
return ESP_FAIL;
}
if (req->content_len <= 0 || (size_t)req->content_len > update_partition->size) {
ESP_LOGW(TAG, "OTA rejected: content_length=%d exceeds partition '%s' size=%lu",
req->content_len, update_partition->label,
(unsigned long)update_partition->size);
httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST,
"Invalid firmware size for OTA partition");
return ESP_FAIL;
}
esp_ota_handle_t ota_handle;
esp_err_t err = esp_ota_begin(update_partition, OTA_WITH_SEQUENTIAL_WRITES, &ota_handle);
if (err != ESP_OK) {

View File

@@ -58,6 +58,10 @@ CONFIG_ULP_COPROC_RESERVE_MEM=8192
# CONFIG_DISPLAY_ENABLE is not set
# CONFIG_WASM_ENABLE is not set
# Physical Tier 2 qualification on ESP32-C6 rev 0.2 converges at 8 Hz while
# leaving the raw network CSI stream independent at roughly 30-40 pps.
CONFIG_EDGE_DSP_SAMPLE_HZ=8
# ── Compiler ──
CONFIG_COMPILER_OPTIMIZATION_SIZE=y

View File

@@ -266,6 +266,19 @@ static void test_debounce_flapping_stays_stable(void)
CHECK_EQ_U8("flapping count stays at 1", out, 1);
}
/* The packet count is evidence subordinated to presence, never an independent
* occupancy assertion. This guards the field failure where a node emitted
* presence=false with n_persons=3 or 4. */
static void test_person_count_fails_closed_without_presence(void)
{
CHECK_EQ_U8("absent with four active slots -> zero",
edge_evidence_person_count(false, 4), 0);
CHECK_EQ_U8("present preserves a bounded count",
edge_evidence_person_count(true, 3), 3);
CHECK_EQ_U8("present count clamps to protocol maximum",
edge_evidence_person_count(true, 255), EDGE_MAX_PERSONS);
}
/* ──────────────────────────────────────────────────────────────────────
* #996 — presence_flag_update: dithering score must NOT flicker the flag
* ────────────────────────────────────────────────────────────────────── */
@@ -357,6 +370,36 @@ static void test_presence_dead_band_holds_state(void)
CHECK_TRUE("dead band does not clear from true", flag);
}
/* The physical C6 delivered 28-37 CSI frames/s while the former estimator was
* capped at 30 Hz. A 34 Hz stream must converge above that old ceiling. */
static void test_sample_rate_tracks_above_thirty_hz(void)
{
float rate = 20.0f;
for (int i = 0; i < 12; i++) {
rate = edge_sample_rate_window_update(rate, 34U, 1000000U);
}
CHECK_TRUE("sample rate follows measured 34 Hz cadence", rate > 33.0f && rate < 35.0f);
}
static void test_sample_rate_requires_complete_window(void)
{
float rate = 34.0f;
CHECK_TRUE("short window rejected",
edge_sample_rate_window_update(rate, 10U, 200000U) == rate);
CHECK_TRUE("stalled window rejected",
edge_sample_rate_window_update(rate, 10U, 4000000U) == rate);
}
static void test_sample_rate_is_bounded(void)
{
float rate = EDGE_SAMPLE_RATE_MAX_HZ;
CHECK_TRUE("sample rate upper bound holds",
edge_sample_rate_window_update(rate, 1000U, 1000000U) <= EDGE_SAMPLE_RATE_MAX_HZ);
rate = EDGE_SAMPLE_RATE_MIN_HZ;
CHECK_TRUE("sample rate lower bound holds",
edge_sample_rate_window_update(rate, 1U, 1000000U) >= EDGE_SAMPLE_RATE_MIN_HZ);
}
/* ──────────────────────────────────────────────────────────────────────
* main
* ────────────────────────────────────────────────────────────────────── */
@@ -375,6 +418,7 @@ int main(void)
test_debounce_rejects_transient_spike();
test_debounce_accepts_sustained_change();
test_debounce_flapping_stays_stable();
test_person_count_fails_closed_without_presence();
/* #996 presence hysteresis */
test_presence_no_flicker_on_dither();
@@ -382,6 +426,11 @@ int main(void)
test_presence_genuine_departure_clears();
test_presence_dead_band_holds_state();
/* Timestamp-derived temporal calibration */
test_sample_rate_tracks_above_thirty_hz();
test_sample_rate_requires_complete_window();
test_sample_rate_is_bounded();
printf("\n%d passed, %d failed\n", g_passed, g_failed);
return g_failed == 0 ? 0 : 1;
}

View File

@@ -1 +1 @@
0.8.4
0.8.8

146
v2/Cargo.lock generated
View File

@@ -1553,7 +1553,7 @@ version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -1791,36 +1791,36 @@ dependencies = [
[[package]]
name = "cranelift-assembler-x64"
version = "0.123.13"
version = "0.123.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f8e1303ae2128891cb59691a74de4547dd208bc8511a2f287cca2b93bb3c728"
checksum = "6835dba958b2ab7ab523e7e99296e0524317f60430a00cf5850562ef78ea7001"
dependencies = [
"cranelift-assembler-x64-meta",
]
[[package]]
name = "cranelift-assembler-x64-meta"
version = "0.123.13"
version = "0.123.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "58b2740a5936332028d9a1e8f29a199de2fd386e426d44da5fea70cf8e3f8e75"
checksum = "0b6e4ce8ee6d899381fbdd9e6561336c651189d46cecaeee09b29e8d80aa786e"
dependencies = [
"cranelift-srcgen",
]
[[package]]
name = "cranelift-bforest"
version = "0.123.13"
version = "0.123.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37fd128d3629fb105e433bda09744c5a2959cd1da04617a455c4cc17dff1ebef"
checksum = "0cb6d37015df7ea4b60450c1229ad5f5819a1fb27434b063f8e6216dfbd0c42a"
dependencies = [
"cranelift-entity",
]
[[package]]
name = "cranelift-bitset"
version = "0.123.13"
version = "0.123.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24a88f6d5a6cf6fcbc6386415d48948094721dfa4585d6615938b11ca938f20a"
checksum = "986bea0b0858b55192782120032ce9c15943fa073f186f6e479653c59e62c329"
dependencies = [
"serde",
"serde_derive",
@@ -1828,9 +1828,9 @@ dependencies = [
[[package]]
name = "cranelift-codegen"
version = "0.123.13"
version = "0.123.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daa4a357d030bdd586d8fe3da56b394f7f6b6ded506e59f945e7b32b1e126b71"
checksum = "9f30aeb2de7f97d6f26b4a1642615834daad58e2e4d7c027810010a3a32f22be"
dependencies = [
"bumpalo",
"cranelift-assembler-x64",
@@ -1855,9 +1855,9 @@ dependencies = [
[[package]]
name = "cranelift-codegen-meta"
version = "0.123.13"
version = "0.123.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4121e36a8757dea6fb237435ee5095eba25bc13ecc2eaee57eb9bffd4b27784f"
checksum = "cd5dd137fcdedef33b6fd40edf1ced024460d764ceb75833e8198a843395945c"
dependencies = [
"cranelift-assembler-x64-meta",
"cranelift-codegen-shared",
@@ -1868,24 +1868,24 @@ dependencies = [
[[package]]
name = "cranelift-codegen-shared"
version = "0.123.13"
version = "0.123.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5173265fc30b9e42205cf06dfca9a272ee949667ce4115d975ed2c08d466e2f8"
checksum = "ab54b260ef23a8f0f536679b9fc3b3b3e05353e8d1448f3ab83df02078e8be9b"
[[package]]
name = "cranelift-control"
version = "0.123.13"
version = "0.123.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b6ca8a393e66dc13f915c6f456bdcf496d78fac4995792f7022b7806352d7a4"
checksum = "3f3e569779ad70537f34a670d444ee3d75ae583b2023913f4682814b0979f7e8"
dependencies = [
"arbitrary",
]
[[package]]
name = "cranelift-entity"
version = "0.123.13"
version = "0.123.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e609d9ba416bc26d774f343295a1d411515248a6a6d83d5f7492a0dd919569fa"
checksum = "2ff53acc85f5c5f7d9315ff133a6671d329a0f04aa2d1a8a2e81d59709ccddcb"
dependencies = [
"cranelift-bitset",
"serde",
@@ -1894,9 +1894,9 @@ dependencies = [
[[package]]
name = "cranelift-frontend"
version = "0.123.13"
version = "0.123.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90a3a277b1a0aff1123f6bae61c080a4bcb6df964829ed427f98e18dff14257f"
checksum = "ab5976c0ff5bfadf61cd8bda81fea78ee5a07018b9cd03e66c0952c56684928b"
dependencies = [
"cranelift-codegen",
"log",
@@ -1906,15 +1906,15 @@ dependencies = [
[[package]]
name = "cranelift-isle"
version = "0.123.13"
version = "0.123.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be53dd9b3a4cbeb9ced45c4a543ea8417ccfb334c3ba1cbb2233f4b25fb2531f"
checksum = "77b4f73d2288e9480fd2d1d9ab576394dce4805443d6148c6d819dbf78865ce4"
[[package]]
name = "cranelift-native"
version = "0.123.13"
version = "0.123.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca62ab1d9f48cad97da5843b913ccf96c3dfde935af5d750cb5a6d367ccfb262"
checksum = "fe9650c2baf22fa1e2542a5bdd8152616ec2023d929c4cbb450ff677ad8d9c21"
dependencies = [
"cranelift-codegen",
"libc",
@@ -1923,9 +1923,9 @@ dependencies = [
[[package]]
name = "cranelift-srcgen"
version = "0.123.13"
version = "0.123.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13b72860f54a2a19d3756bd47575fd8bddeafd6e5bbbf16372cdfebc482628a"
checksum = "4ad4f61ae701d73c326d3df08c366b29ad10f1ba06c245092f217b8d2306746b"
[[package]]
name = "crc"
@@ -3333,7 +3333,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -5549,7 +5549,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -8051,7 +8051,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
dependencies = [
"anyhow",
"itertools 0.14.0",
"itertools 0.10.5",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -8088,9 +8088,9 @@ dependencies = [
[[package]]
name = "pulley-interpreter"
version = "36.0.13"
version = "36.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2662666315cb90dfb4d99a652ee053d4d8598f71c474209e84da031ca56ae5a4"
checksum = "eb0a4b56042e461cc64456650182938e2d1ede98fa0c8a975027416a2809c414"
dependencies = [
"cranelift-bitset",
"log",
@@ -8100,9 +8100,9 @@ dependencies = [
[[package]]
name = "pulley-macros"
version = "36.0.13"
version = "36.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb9a7d9ed2618f94b6d054aba3eb2768c9b489fe16b4ef8847fbc6ed41b707bb"
checksum = "244667bea2e214273442a71f26adb12b88a41f66718fb2c6eea47c00f0dc325f"
dependencies = [
"proc-macro2",
"quote",
@@ -8240,7 +8240,7 @@ dependencies = [
"once_cell",
"socket2 0.5.10",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -9104,7 +9104,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -9162,7 +9162,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -11116,7 +11116,7 @@ dependencies = [
"getrandom 0.4.1",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -12482,9 +12482,9 @@ dependencies = [
[[package]]
name = "wasmtime"
version = "36.0.13"
version = "36.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93d881c5dcff5f230368d84fcf110ca25fe47badc694e7d559c3891492159916"
checksum = "7d05c745dc0978e589ef295958f3130122afc33d96af6bad3f0f06dbe7ac43a8"
dependencies = [
"addr2line",
"anyhow",
@@ -12537,9 +12537,9 @@ dependencies = [
[[package]]
name = "wasmtime-environ"
version = "36.0.13"
version = "36.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4b25534a3ff9dd844701c2bc997f76cb1f97bf2af0546a7066ae96f49c2e068"
checksum = "9fd1d43cfaa1a0859d2f4fccc15e7e571e2a88b357e81bc88ba6c501b83d925d"
dependencies = [
"anyhow",
"cpp_demangle",
@@ -12564,18 +12564,18 @@ dependencies = [
[[package]]
name = "wasmtime-internal-asm-macros"
version = "36.0.13"
version = "36.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d02832d760351fb3a1aa99eb8f7b95596c0f4e4e52df1547fcdb295ea5c780c"
checksum = "515dd7158bf1719b41290cd2e6a2a46ec944484146816992f195af3720e49b3f"
dependencies = [
"cfg-if",
]
[[package]]
name = "wasmtime-internal-cache"
version = "36.0.13"
version = "36.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "651f8a16b51cde3ee8bd5b775bcedc24b66040d7dca1f13ad855b10c660e1d01"
checksum = "12a53145473629ea40f445235ed95182b76940f00a67c3c9c6c4857dae0ad823"
dependencies = [
"anyhow",
"base64 0.22.1",
@@ -12593,9 +12593,9 @@ dependencies = [
[[package]]
name = "wasmtime-internal-component-macro"
version = "36.0.13"
version = "36.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba2ed6dbe24607573f7bcc59222f3bc2e7f7d31609466055c67b9484045a374a"
checksum = "dfca017b7daa80ff217c66f105ee20e674d87b7c11dca85bbb9d0146e9f443fb"
dependencies = [
"anyhow",
"proc-macro2",
@@ -12608,15 +12608,15 @@ dependencies = [
[[package]]
name = "wasmtime-internal-component-util"
version = "36.0.13"
version = "36.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e21aadbf677ee3503ef2580ce3c6955508e90a2810e961739b30f79585af254c"
checksum = "1c3e218b51d2ef9eb181499e42c691512c1bc11dd6dc7746807a9b2b9290369c"
[[package]]
name = "wasmtime-internal-cranelift"
version = "36.0.13"
version = "36.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65821bab751956cddfc6ee957beb13a0e2a6cf682050d75dfb7a0228db2ed499"
checksum = "5ba1736927b58e50e741e407da7c037c0250f3e213833a09c89dcd8f73ae2eac"
dependencies = [
"anyhow",
"cfg-if",
@@ -12641,9 +12641,9 @@ dependencies = [
[[package]]
name = "wasmtime-internal-fiber"
version = "36.0.13"
version = "36.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18cf73e5e8d28a2b30d86454430c7dea3b593598dcbbc0ebb927ca2716222d41"
checksum = "7b238e4c20bddb900ec0cb380252d63e8d0644fd94de001119574f5921e895d9"
dependencies = [
"anyhow",
"cc",
@@ -12657,9 +12657,9 @@ dependencies = [
[[package]]
name = "wasmtime-internal-jit-debug"
version = "36.0.13"
version = "36.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4279dc3147ddaa21e5b3d2c0eaff117881c4f937747bdd2379eb361665843bd3"
checksum = "8f259b13685ad51e3dcf58cb69031279ed0d79c25bc3ccc8b50e7160ed04fbfe"
dependencies = [
"cc",
"object",
@@ -12669,9 +12669,9 @@ dependencies = [
[[package]]
name = "wasmtime-internal-jit-icache-coherence"
version = "36.0.13"
version = "36.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8eb677944201839c0be19b39b0f19dcd663efbcbc05a09c73f26fec7bdf0331"
checksum = "41fed85537936b16460bac352ad149052c025db50467c7bc539dd47b31439374"
dependencies = [
"anyhow",
"cfg-if",
@@ -12681,24 +12681,24 @@ dependencies = [
[[package]]
name = "wasmtime-internal-math"
version = "36.0.13"
version = "36.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db9a153e184878df396a11f8912444531c2e4d0c7a1d9e9a52b30d9853d89cb9"
checksum = "82fff10da41d0d15d90ebba70946a0aa16ed0957ae7b77e0b6d2a46e8221e555"
dependencies = [
"libm",
]
[[package]]
name = "wasmtime-internal-slab"
version = "36.0.13"
version = "36.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b771494bead25e1f0c4c89a9476dd0b65eacca314ed82125f1e197fbc3f0396b"
checksum = "e44a8c097bab08d349d57dce1ab818859fefbe261ab3632b38fe127b1b551108"
[[package]]
name = "wasmtime-internal-unwinder"
version = "36.0.13"
version = "36.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90199caed6925420434a923861d8ad813689ca8533d2c679f805d12e35b40a4b"
checksum = "7f40a57d5e7c221ce56391d7dca0a918ba17ea00185462c7facbf534d7745184"
dependencies = [
"anyhow",
"cfg-if",
@@ -12709,9 +12709,9 @@ dependencies = [
[[package]]
name = "wasmtime-internal-versioned-export-macros"
version = "36.0.13"
version = "36.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae71687aa834f9cc9eb5b0f97c85184d7dc70b84d32fd8927af0887998a1ba35"
checksum = "e085bfce1cb2089dbeef6e280a5d598666923d3dcd308712fe429fe43c9d19f5"
dependencies = [
"proc-macro2",
"quote",
@@ -12720,9 +12720,9 @@ dependencies = [
[[package]]
name = "wasmtime-internal-winch"
version = "36.0.13"
version = "36.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d0ba45c0d766dd56257d97702d3284b6f69efdd4c53ca981a0754cc0a139df0"
checksum = "c4916cd526e1ce294984cc5b70264cfc0ca103b41ca665b58728bde250b6b82f"
dependencies = [
"anyhow",
"cranelift-codegen",
@@ -12737,9 +12737,9 @@ dependencies = [
[[package]]
name = "wasmtime-internal-wit-bindgen"
version = "36.0.13"
version = "36.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41a64d5112c06f9d54b41e61f0b757d3a5a52361bf359f01131cc7cb8551ac73"
checksum = "39ad2f9d9c3baa70ee4d157b55b4c08e5dc00f1d80aad3692b1e0806393914ea"
dependencies = [
"anyhow",
"bitflags 2.11.0",
@@ -13624,7 +13624,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -13635,9 +13635,9 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "winch-codegen"
version = "36.0.13"
version = "36.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82ea9625459ce35d6a4188cf0f07c9095cbb0e5afd86f711d63955bfc4216e64"
checksum = "e826c012c68403725e77adf6b904c2ea809e5d464aaf25aa6eda14559300b3df"
dependencies = [
"anyhow",
"cranelift-assembler-x64",

View File

@@ -9,6 +9,17 @@ use crate::adaptive_classifier;
use crate::types::*;
use crate::vital_signs::VitalSigns;
const EDGE_MAX_PERSONS: u8 = 4;
/// Person count is supporting evidence, never an independent occupancy claim.
/// Return zero and mark invalid for contradictory or out-of-range firmware.
fn sanitize_edge_person_count(presence: bool, raw: u8) -> (u8, bool) {
if raw > EDGE_MAX_PERSONS || (!presence && raw != 0) {
return (0, false);
}
(raw, true)
}
// ── ESP32 UDP frame parsers ─────────────────────────────────────────────────
/// Parse a 32-byte edge vitals packet (magic 0xC511_0002).
@@ -26,26 +37,63 @@ pub fn parse_esp32_vitals(buf: &[u8]) -> Option<Esp32VitalsPacket> {
let breathing_raw = u16::from_le_bytes([buf[6], buf[7]]);
let heartrate_raw = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
let rssi = buf[12] as i8;
let n_persons = buf[13];
let presence = (flags & 0x01) != 0;
let (n_persons, person_count_valid) = sanitize_edge_person_count(presence, buf[13]);
let motion_energy = f32::from_le_bytes([buf[16], buf[17], buf[18], buf[19]]);
let presence_score = f32::from_le_bytes([buf[20], buf[21], buf[22], buf[23]]);
let timestamp_ms = u32::from_le_bytes([buf[24], buf[25], buf[26], buf[27]]);
Some(Esp32VitalsPacket {
node_id,
presence: (flags & 0x01) != 0,
presence,
fall_detected: (flags & 0x02) != 0,
motion: (flags & 0x04) != 0,
breathing_rate_bpm: breathing_raw as f64 / 100.0,
heartrate_bpm: heartrate_raw as f64 / 10000.0,
rssi,
n_persons,
person_count_valid,
motion_energy,
presence_score,
timestamp_ms,
})
}
#[cfg(test)]
mod edge_vitals_integrity_tests {
use super::*;
fn packet(presence: bool, n_persons: u8) -> Vec<u8> {
let mut buf = vec![0u8; 32];
buf[0..4].copy_from_slice(&0xC511_0002u32.to_le_bytes());
buf[4] = 4;
buf[5] = u8::from(presence);
buf[13] = n_persons;
buf
}
#[test]
fn contradictory_count_fails_closed() {
let parsed = parse_esp32_vitals(&packet(false, 4)).expect("valid packet");
assert_eq!(parsed.n_persons, 0);
assert!(!parsed.person_count_valid);
}
#[test]
fn bounded_present_count_is_preserved() {
let parsed = parse_esp32_vitals(&packet(true, 3)).expect("valid packet");
assert_eq!(parsed.n_persons, 3);
assert!(parsed.person_count_valid);
}
#[test]
fn out_of_range_count_fails_closed() {
let parsed = parse_esp32_vitals(&packet(true, 5)).expect("valid packet");
assert_eq!(parsed.n_persons, 0);
assert!(!parsed.person_count_valid);
}
}
/// Parse a WASM output packet (magic 0xC511_0007 — reassigned per issue #928;
/// the original 0xC511_0004 collided with ADR-063 fused vitals).
pub fn parse_wasm_output(buf: &[u8]) -> Option<WasmOutputPacket> {

View File

@@ -1713,11 +1713,21 @@ struct Esp32VitalsPacket {
heartrate_bpm: f64,
rssi: i8,
n_persons: u8,
person_count_valid: bool,
motion_energy: f32,
presence_score: f32,
timestamp_ms: u32,
}
const EDGE_MAX_PERSONS: u8 = 4;
fn sanitize_edge_person_count(presence: bool, raw: u8) -> (u8, bool) {
if raw > EDGE_MAX_PERSONS || (!presence && raw != 0) {
return (0, false);
}
(raw, true)
}
/// Parse a 32-byte edge vitals packet (magic 0xC511_0002).
fn parse_esp32_vitals(buf: &[u8]) -> Option<Esp32VitalsPacket> {
if buf.len() < 32 {
@@ -1733,26 +1743,56 @@ fn parse_esp32_vitals(buf: &[u8]) -> Option<Esp32VitalsPacket> {
let breathing_raw = u16::from_le_bytes([buf[6], buf[7]]);
let heartrate_raw = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
let rssi = buf[12] as i8;
let n_persons = buf[13];
let presence = (flags & 0x01) != 0;
let (n_persons, person_count_valid) = sanitize_edge_person_count(presence, buf[13]);
let motion_energy = f32::from_le_bytes([buf[16], buf[17], buf[18], buf[19]]);
let presence_score = f32::from_le_bytes([buf[20], buf[21], buf[22], buf[23]]);
let timestamp_ms = u32::from_le_bytes([buf[24], buf[25], buf[26], buf[27]]);
Some(Esp32VitalsPacket {
node_id,
presence: (flags & 0x01) != 0,
presence,
fall_detected: (flags & 0x02) != 0,
motion: (flags & 0x04) != 0,
breathing_rate_bpm: breathing_raw as f64 / 100.0,
heartrate_bpm: heartrate_raw as f64 / 10000.0,
rssi,
n_persons,
person_count_valid,
motion_energy,
presence_score,
timestamp_ms,
})
}
#[cfg(test)]
mod edge_vitals_integrity_tests {
use super::*;
fn packet(presence: bool, n_persons: u8) -> Vec<u8> {
let mut buf = vec![0u8; 32];
buf[0..4].copy_from_slice(&0xC511_0002u32.to_le_bytes());
buf[4] = 4;
buf[5] = u8::from(presence);
buf[13] = n_persons;
buf
}
#[test]
fn contradictory_count_fails_closed() {
let parsed = parse_esp32_vitals(&packet(false, 4)).expect("valid packet");
assert_eq!(parsed.n_persons, 0);
assert!(!parsed.person_count_valid);
}
#[test]
fn present_count_is_preserved() {
let parsed = parse_esp32_vitals(&packet(true, 3)).expect("valid packet");
assert_eq!(parsed.n_persons, 3);
assert!(parsed.person_count_valid);
}
}
// ── ADR-040: WASM Output Packet (magic 0xC511_0007 — reassigned per #928) ─────
/// Single WASM event (type + value).
@@ -1829,6 +1869,7 @@ struct EdgeFusedVitalsPacket {
heartrate_bpm: f32,
rssi: i8,
n_persons: u8,
person_count_valid: bool,
/// `mmwave_type_t` enum value from firmware.
mmwave_type: u8,
/// 0-100 fusion quality score.
@@ -1863,7 +1904,8 @@ fn parse_edge_fused_vitals(buf: &[u8]) -> Option<EdgeFusedVitalsPacket> {
let breathing_raw = u16::from_le_bytes([buf[6], buf[7]]);
let heartrate_raw = u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]);
let rssi = buf[12] as i8;
let n_persons = buf[13];
let any_presence = (flags & 0x09) != 0;
let (n_persons, person_count_valid) = sanitize_edge_person_count(any_presence, buf[13]);
let mmwave_type = buf[14];
let fusion_confidence = buf[15];
let motion_energy = f32::from_le_bytes([buf[16], buf[17], buf[18], buf[19]]);
@@ -1883,6 +1925,7 @@ fn parse_edge_fused_vitals(buf: &[u8]) -> Option<EdgeFusedVitalsPacket> {
heartrate_bpm: heartrate_raw as f32 / 10000.0,
rssi,
n_persons,
person_count_valid,
mmwave_type,
fusion_confidence,
motion_energy,
@@ -1939,6 +1982,7 @@ mod issue_928_magic_collision_tests {
assert!((pkt.heartrate_bpm - 72.0).abs() < 1e-3, "heartrate scale 10000");
assert_eq!(pkt.rssi, -55);
assert_eq!(pkt.n_persons, 1);
assert!(pkt.person_count_valid);
assert_eq!(pkt.mmwave_type, 2);
assert_eq!(pkt.fusion_confidence, 85);
assert!((pkt.motion_energy - 0.42).abs() < 1e-6);
@@ -6194,6 +6238,9 @@ async fn nodes_endpoint(State(state): State<SharedState>) -> Json<serde_json::Va
"rssi_dbm": rssi,
"motion_level": &ns.current_motion_level,
"person_count": ns.prev_person_count,
"person_count_valid": ns.edge_vitals
.as_ref()
.map(|vitals| vitals.person_count_valid),
})
})
.collect();
@@ -6353,6 +6400,7 @@ async fn udp_receiver_task(
"breathing_rate_bpm": vitals.breathing_rate_bpm,
"heartrate_bpm": vitals.heartrate_bpm,
"n_persons": vitals.n_persons,
"person_count_valid": vitals.person_count_valid,
"motion_energy": vitals.motion_energy,
"presence_score": vitals.presence_score,
"rssi": vitals.rssi,
@@ -6652,6 +6700,7 @@ async fn udp_receiver_task(
"breathing_rate_bpm": fused.breathing_rate_bpm,
"heartrate_bpm": fused.heartrate_bpm,
"n_persons": fused.n_persons,
"person_count_valid": fused.person_count_valid,
"fusion_confidence": fused.fusion_confidence,
"mmwave": {
"hr_bpm": fused.mmwave_hr_bpm,

View File

@@ -258,6 +258,9 @@ pub struct Esp32VitalsPacket {
pub heartrate_bpm: f64,
pub rssi: i8,
pub n_persons: u8,
/// True only when the firmware count is protocol-bounded and consistent
/// with the packet presence flag.
pub person_count_valid: bool,
pub motion_energy: f32,
pub presence_score: f32,
pub timestamp_ms: u32,