mirror of
https://github.com/ruvnet/RuView
synced 2026-06-15 11:13:20 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5d544126ee | |||
| 004a63e82d | |||
| 1906876541 | |||
| 423dc9fd5c |
@@ -38,7 +38,7 @@ jobs:
|
||||
echo "version.txt matches the release tag."
|
||||
|
||||
build:
|
||||
name: Build firmware (${{ matrix.target }} / ${{ matrix.variant }})
|
||||
name: Build ESP32-S3 Firmware (${{ matrix.variant }})
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: espressif/idf:v5.4
|
||||
@@ -47,27 +47,17 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- variant: 8mb
|
||||
target: esp32s3
|
||||
sdkconfig: sdkconfig.defaults
|
||||
partition_table_name: partitions_display.csv
|
||||
size_limit_kb: 1100
|
||||
artifact_app: esp32-csi-node.bin
|
||||
artifact_pt: partition-table.bin
|
||||
- variant: 4mb
|
||||
target: esp32s3
|
||||
sdkconfig: sdkconfig.defaults.4mb
|
||||
partition_table_name: partitions_4mb.csv
|
||||
size_limit_kb: 1100
|
||||
artifact_app: esp32-csi-node-4mb.bin
|
||||
artifact_pt: partition-table-4mb.bin
|
||||
# ADR-110: ESP32-C6 research target (Wi-Fi 6 / 802.15.4 / TWT / LP-core)
|
||||
- variant: c6-4mb
|
||||
target: esp32c6
|
||||
sdkconfig: sdkconfig.defaults
|
||||
partition_table_name: partitions_4mb.csv
|
||||
size_limit_kb: 1100
|
||||
artifact_app: esp32-csi-node-c6.bin
|
||||
artifact_pt: partition-table-c6.bin
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -76,22 +66,12 @@ jobs:
|
||||
working-directory: firmware/esp32-csi-node
|
||||
run: |
|
||||
. $IDF_PATH/export.sh
|
||||
# 4mb variant supplies its own sdkconfig.defaults overlay.
|
||||
# c6-4mb variant relies on the auto-applied sdkconfig.defaults.esp32c6
|
||||
# overlay (ESP-IDF auto-loads sdkconfig.defaults.$TARGET when present).
|
||||
if [ "${{ matrix.variant }}" = "4mb" ]; then
|
||||
if [ "${{ matrix.variant }}" != "8mb" ]; then
|
||||
cp "${{ matrix.sdkconfig }}" sdkconfig.defaults
|
||||
fi
|
||||
idf.py set-target ${{ matrix.target }}
|
||||
idf.py set-target esp32s3
|
||||
idf.py build
|
||||
|
||||
- name: Build and run host-side ADR-110 unit tests
|
||||
if: matrix.variant == 'c6-4mb'
|
||||
working-directory: firmware/esp32-csi-node/test
|
||||
run: |
|
||||
make test_adr110
|
||||
./test_adr110
|
||||
|
||||
- name: Verify binary size (< ${{ matrix.size_limit_kb }} KB gate)
|
||||
working-directory: firmware/esp32-csi-node
|
||||
run: |
|
||||
|
||||
@@ -62,21 +62,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
they can be reintroduced with a real implementation.
|
||||
|
||||
### Added
|
||||
- **ESP32-C6 firmware target with Wi-Fi 6 / 802.15.4 / TWT / LP-core support ([ADR-110](docs/adr/ADR-110-esp32-c6-firmware-extension.md), #762).** `firmware/esp32-csi-node` now builds for **both** `esp32s3` (existing production node) and `esp32c6` (new research/seed-node target) from the same source tree — pick via `idf.py set-target esp32c6` and ESP-IDF auto-applies the new `sdkconfig.defaults.esp32c6` overlay. Every C6 module is `#ifdef CONFIG_IDF_TARGET_ESP32C6` gated, so the S3 build is byte-identical to today (no regression).
|
||||
- **Wi-Fi 6 HE-LTF subcarrier tagging** — `csi_collector.c` now reads `rx_ctrl.cur_bb_format` and writes the PPDU type (0=HT/legacy, 1=HE-SU, 2=HE-MU, 3=HE-TB) into ADR-018 frame byte 18, plus bandwidth flags (20/40 MHz, STBC, 802.15.4-sync-valid) into byte 19. Bytes 18-19 were previously reserved-zero, so old aggregators read them as before — fully backwards compatible. Magic stays `0xC5110001`. Default on via `CONFIG_CSI_FRAME_HE_TAGGING`. First firmware in the open ESP32 ecosystem to tag CSI frames with 11ax PPDU metadata.
|
||||
- **802.15.4 mesh time-sync** — new `c6_timesync.{h,c}` (262 lines) provides cross-node clock alignment over the C6's separate 802.15.4 radio, freeing WiFi airtime from coordination traffic (directly addresses the ADR-029/030 multistatic synchronization gap). Protocol: lowest EUI-64 wins election, leader broadcasts `TS_BEACON` (`magic=0x54534D45`, leader epoch µs) every 100 ms on channel 15, followers compute `offset = leader_us - local_us` and apply lazily — every CSI frame is stamped with `c6_timesync_get_epoch_us()`. Target alignment ±100 µs. Default on via `CONFIG_C6_TIMESYNC_ENABLE`. Verified initializing at boot on COM6 (`c6_ts: init done: channel=15 EUI=206ef1fffefffe17 leader=yes(candidate)` at +413 ms).
|
||||
- **TWT (Target Wake Time)** — new `c6_twt.{h,c}` (223 lines) wraps `esp_wifi_sta_itwt_setup` from `esp_wifi_he.h` to negotiate an individual TWT agreement with the AP after STA connect. Replaces today's opportunistic CSI capture with a scheduler-bounded one (default wake interval 10 ms = 100 fps cadence). Graceful NACK fallback: when the AP doesn't support 11ax iTWT, the helper logs and returns OK so the device keeps doing opportunistic CSI just like the S3. Teardown on `WIFI_EVENT_STA_DISCONNECTED` keeps the AP's TWT scheduler clean. Gated on `SOC_WIFI_HE_SUPPORT` (auto-set on C6/C5 chips).
|
||||
- **LP-core wake-on-motion hibernation** — new `c6_lp_core.{h,c}` (134 lines) arms the C6 LP RISC-V coprocessor as an always-on motion gate; HP core stays in deep sleep until a configurable GPIO wakes it (ext1 deep-sleep wake source in this initial cut, real LP-core program in follow-up). Targets ≤5 µA hibernation current for battery-powered Cognitum Seed nodes (vs the S3's ~10 µA ULP-FSM floor). Opt-in via `CONFIG_C6_LP_CORE_ENABLE` (default off — only enabled on nodes flashed for battery-powered seed duty).
|
||||
- **Build matrix**: S3 stays `partitions_display.csv` (8 MB + display + WASM), C6 uses `partitions_4mb.csv` (4 MB single OTA, no display, no WASM3, no LCD). C6 final binary 1003 KB (46% partition slack), 9 % smaller than S3 production. Free heap 310 KiB at boot, app_main reached in 343 ms, 802.15.4 stack up in another 70 ms.
|
||||
- **Why this matters**: opens three research surfaces nobody has published yet — Wi-Fi-6 CSI human pose, multistatic CSI clock alignment over a side-channel radio, and TWT-bounded deterministic CSI cadence. The S3 production fleet keeps shipping the existing capabilities; the C6 is the research / battery-seed expansion target.
|
||||
- **Docs**: ADR-110 (186 lines, Status=Accepted), tracking issue [ruvnet/RuView#762](https://github.com/ruvnet/RuView/issues/762) with per-phase progress comments, README hardware table + Quick-Start Option 2b, `docs/user-guide.md` full ESP32-C6 section (build, flash, provision, multi-room time-sync, battery seed mode), full empirical record in [`docs/WITNESS-LOG-110.md`](docs/WITNESS-LOG-110.md) with verified / claimed / bugs-fixed / bugs-found sections.
|
||||
- **Wave 2 follow-up (D1 workaround)**: 5 systematic experiments on 3 live C6 boards confirmed the IDF v5.4 802.15.4 RX path is unfixable from user code (TX works 100 %, RX delivers 0 frames; coex/channel/OpenThread/manual-rearm all ruled out). Pivoted to ESP-NOW for the cross-node sync transport — `main/c6_sync_espnow.{h,c}` is the same TS_BEACON protocol over WiFi peer-to-peer, same `get_epoch_us / is_valid / is_leader` API surface. **120 s single-board soak: 1151 transmits, 0 failures (0.00 %), 9.6 tx/s sustained, no crash or reset.** The 802.15.4 path stays in source as documented-broken (D1) for when the IDF driver gets fixed.
|
||||
- **Host-side dual-pipeline decoder for ADR-018 byte 18-19** (ADR-110 protocol closure):
|
||||
- **Rust** (`v2/crates/wifi-densepose-hardware`): new `PpduType` enum (HtLegacy/HeSu/HeMu/HeTb/Unknown) and `Adr018Flags` struct (bw40/stbc/ldpc/ieee802154_sync_valid) on `CsiMetadata`. 6 new deterministic unit tests; **122/122 hardware-crate tests pass**.
|
||||
- **Python** (`archive/v1/src/hardware/csi_extractor.py`): `HEADER_FMT` extended from `<IBBHIIBB2x` to `<IBBHIIBBBB`; new metadata fields (`ppdu_type`, `he_capable`, `bw40`, `stbc`, `ldpc`, `ieee802154_sync_valid`). 5 new `TestAdr110ByteEncoding` cases; **11/11 parser tests pass**.
|
||||
- Both decoders match the firmware encoder bit-for-bit. Pre-ADR-110 firmware sends zeros that round-trip as `HtLegacy` + default flags — fully backwards compatible.
|
||||
- **Security fix** (`scripts/redact-secrets.py` + `generate-witness-bundle.sh`): the Python proof step was echoing `.env` contents into the bundled `verification-output.log` via Pydantic validation errors. Bundle nuked before push; added a `stdin -> stdout` redaction filter covering common token prefixes, long opaque strings, and long hex runs. Verified zero leaks on rebuild.
|
||||
- **Wave 3 — firmware v0.6.7 (LP-core full + soft-AP HE)**: two software-only unblocks for the hardware-blocked items in WITNESS-LOG-110 §B. (1) **Real LP-core motion-gate program** (`firmware/esp32-csi-node/main/lp_core/main.c` + integration in `c6_lp_core.c`). When `CONFIG_C6_LP_CORE_ENABLE=y`, the LP RISC-V coprocessor now runs a real polling program (configurable cadence via `CONFIG_C6_LP_POLL_PERIOD_US`, default 10 ms) that debounces N consecutive GPIO samples (`CONFIG_C6_LP_DEBOUNCE_SAMPLES`, default 3) and wakes the HP core via `ulp_lp_core_wakeup_main_processor()`. HP entry uses `esp_sleep_enable_ulp_wakeup` + `ESP_SLEEP_WAKEUP_ULP`. Exposes `c6_lp_core_motion_count()` and `c6_lp_core_poll_count()` getters for the witness harness. **Replaces** the v0.6.6 `esp_deep_sleep_enable_gpio_wakeup` ext1 fallback (which floored at ~10 µA, the same as the S3 ULP-FSM). The fallback path stays as the `else` branch so builds without `CONFIG_C6_LP_CORE_ENABLE` keep working unchanged — zero regression for v0.6.6-era fleets. Targets the C6 datasheet ≤5 µA average for battery seed nodes; pending INA/Joulescope measurement to confirm (`WITNESS-LOG-110 §B4`). (2) **Wi-Fi 6 soft-AP with TWT Responder=1** (`c6_softap_he.{h,c}` + `main.c` AP+STA mode switch). When `CONFIG_C6_SOFTAP_HE_ENABLE=y`, one C6 board can act as the iTWT-capable AP the bench is otherwise missing — pair with a second C6-STA board to negotiate real iTWT against a known-cooperative AP and measure deterministic CSI cadence (`WITNESS-LOG-110 §B1/B2`). SSID/PSK/channel configurable via Kconfig defaults or NVS (`softap_ssid`/`softap_psk`/`softap_chan` keys in the `ruview` namespace). Default off so existing nodes are unaffected. **Build artifacts**: S3 8 MB binary 1093 KB (47 % slack), C6 4 MB binary 1019 KB (45 % slack). Tag: `v0.6.7-esp32`.
|
||||
- **Real-time CSI introspection / low-latency tap on `wifi-densepose-sensing-server` (ADR-099).**
|
||||
New `wifi_densepose_sensing_server::introspection` module wires
|
||||
[midstream](https://github.com/ruvnet/midstream)'s `temporal-attractor` (Lyapunov +
|
||||
|
||||
@@ -80,7 +80,7 @@ docker pull ruvnet/wifi-densepose:latest
|
||||
docker run -p 3000:3000 ruvnet/wifi-densepose:latest
|
||||
# Open http://localhost:3000
|
||||
|
||||
# Option 2a: Live sensing with ESP32-S3 hardware ($9)
|
||||
# Option 2: Live sensing with ESP32-S3 hardware ($9)
|
||||
# Flash firmware, provision WiFi, and start sensing:
|
||||
python -m esptool --chip esp32s3 --port COM9 --baud 460800 \
|
||||
write_flash 0x0 bootloader.bin 0x8000 partition-table.bin \
|
||||
@@ -88,16 +88,6 @@ python -m esptool --chip esp32s3 --port COM9 --baud 460800 \
|
||||
python firmware/esp32-csi-node/provision.py --port COM9 \
|
||||
--ssid "YourWiFi" --password "secret" --target-ip 192.168.1.20
|
||||
|
||||
# Option 2b: WiFi 6 + 802.15.4 research sensing with ESP32-C6 ($6-10, ADR-110)
|
||||
# Same csi-node firmware compiled for the C6 target — picks up the C6
|
||||
# overlay (sdkconfig.defaults.esp32c6) automatically.
|
||||
cd firmware/esp32-csi-node
|
||||
idf.py set-target esp32c6 && idf.py build
|
||||
idf.py -p COM6 flash
|
||||
# C6 boot extras (vs S3): HE-LTF subcarrier tagging in ADR-018 bytes 18-19,
|
||||
# 802.15.4 mesh time-sync on channel 15, TWT setup when the AP supports it,
|
||||
# opt-in LP-core wake-on-motion for ~5 µA battery seed nodes.
|
||||
|
||||
# Option 3: Full system with Cognitum Seed ($140)
|
||||
# ESP32 streams CSI → bridge forwards to Seed for persistent storage + kNN + witness chain
|
||||
node scripts/rf-scan.js --port 5006 # Live RF room scan
|
||||
@@ -113,8 +103,7 @@ node scripts/mincut-person-counter.js --port 5006 # Correct person counting
|
||||
> | Option | Hardware | Cost | Full CSI | Capabilities |
|
||||
> |--------|----------|------|----------|-------------|
|
||||
> | **ESP32 + Cognitum Seed** (recommended) | ESP32-S3 + [Cognitum Seed](https://cognitum.one) | ~$140 | Yes | Presence, motion, breathing, heart rate, fall detection, multi-person counting, 17-keypoint pose (signed Cog binary), 105-cog catalog, persistent vector store, kNN search, witness chain, MCP proxy |
|
||||
> | **ESP32 Mesh** | 3-6× ESP32-S3 + WiFi router | ~$54 | Yes | Same capabilities as above without the persistent-memory features |
|
||||
> | **ESP32-C6 research node** ([ADR-110](docs/adr/ADR-110-esp32-c6-firmware-extension.md), [witness](docs/WITNESS-LOG-110.md), [reviewer guide](docs/ADR-110-REVIEW-GUIDE.md)) | ESP32-C6-DevKit ($6–10) | ~$10 | Yes (Wi-Fi 6 capable) | Same CSI pipeline as S3 with the dual-target firmware. **Wire format ready** for HE-LTF PPDU tagging in ADR-018 bytes 18-19 (firmware encoder + Rust + Python decoders verified end-to-end in 17 unit tests), ESP-NOW cross-node sync (4102 tx 0 fail cumulative across 120 s + 300 s soaks), and TWT graceful-NACK fallback (live exercised). **Hardware-gated**: HE-LTF live subcarrier capture needs an 11ax AP; ~5 µA LP-core hibernation needs an INA meter to measure; 802.15.4 RX is broken in IDF v5.4 (workaround: ESP-NOW transport for cross-node sync). See witness log for the empirical / claimed split. |
|
||||
> | **ESP32 Mesh** | 3-6x ESP32-S3 + WiFi router | ~$54 | Yes | Same capabilities as above without the persistent-memory features |
|
||||
> | **Research NIC** | Intel 5300 / Atheros AR9580 | ~$50-100 | Yes | Full CSI with 3x3 MIMO |
|
||||
> | **Any WiFi** | Windows, macOS, or Linux laptop | $0 | No | RSSI-only: coarse presence and motion (see [tutorial #36](https://github.com/ruvnet/RuView/issues/36)) |
|
||||
>
|
||||
@@ -587,6 +576,12 @@ Verify the plugin structure: `bash plugins/ruview/scripts/smoke.sh`. Full detail
|
||||
|
||||
MIT License — see [LICENSE](LICENSE) for details.
|
||||
|
||||
## 🤝 Creator Affiliate Program
|
||||
|
||||
**For TikTok · Instagram · YouTube creators** — earn **25% on every Cognitum sale** you refer. The RuFlo, RuView, and RuVector videos you're already making have done millions of views; get paid for the orders they drive. Click-tracking activates instantly; commissions activate after a quick manual review (usually under 24 hours).
|
||||
|
||||
[Apply now → cognitum.one/affiliate](https://cognitum.one/affiliate)
|
||||
|
||||
## 📞 Support
|
||||
|
||||
[GitHub Issues](https://github.com/ruvnet/RuView/issues) | [Discussions](https://github.com/ruvnet/RuView/discussions) | [PyPI](https://pypi.org/project/wifi-densepose/)
|
||||
|
||||
@@ -143,28 +143,13 @@ class ESP32BinaryParser:
|
||||
12 4 Sequence number (LE u32)
|
||||
16 1 RSSI (i8)
|
||||
17 1 Noise floor (i8)
|
||||
18 1 PPDU type (ADR-110): 0=HT/legacy, 1=HE-SU, 2=HE-MU,
|
||||
3=HE-TB, 0xFF=unknown. Pre-ADR-110 firmware sends 0.
|
||||
19 1 Flags (ADR-110): bit 0 = bw40, bit 2 = STBC,
|
||||
bit 3 = LDPC, bit 4 = 802.15.4 sync valid.
|
||||
18 2 Reserved
|
||||
20 N*2 I/Q pairs (n_antennas * n_subcarriers * 2 bytes, signed i8)
|
||||
"""
|
||||
|
||||
MAGIC = 0xC5110001
|
||||
HEADER_SIZE = 20
|
||||
# ADR-110: previously '<IBBHIIBB2x' (last 2 bytes skipped as reserved).
|
||||
# Now read those 2 bytes as PPDU type + flags. Pre-ADR-110 firmware
|
||||
# sends zeros, which decode as 'HT/legacy' + 'no flags' — fully
|
||||
# backwards compatible.
|
||||
HEADER_FMT = '<IBBHIIBBBB' # +2 bytes: ppdu_type, flags
|
||||
|
||||
# ADR-110 PPDU type byte values
|
||||
PPDU_HT_LEGACY = 0
|
||||
PPDU_HE_SU = 1
|
||||
PPDU_HE_MU = 2
|
||||
PPDU_HE_TB = 3
|
||||
PPDU_UNKNOWN = 0xFF
|
||||
_PPDU_NAMES = {0: 'ht_legacy', 1: 'he_su', 2: 'he_mu', 3: 'he_tb', 0xFF: 'unknown'}
|
||||
HEADER_FMT = '<IBBHIIBB2x' # magic, node_id, n_ant, n_sc, freq, seq, rssi, noise
|
||||
|
||||
def parse(self, raw_data: bytes) -> CSIData:
|
||||
"""Parse an ADR-018 binary frame into CSIData.
|
||||
@@ -183,8 +168,8 @@ class ESP32BinaryParser:
|
||||
f"Frame too short: need {self.HEADER_SIZE} bytes, got {len(raw_data)}"
|
||||
)
|
||||
|
||||
magic, node_id, n_antennas, n_subcarriers, freq_mhz, sequence, rssi_u8, noise_u8, \
|
||||
ppdu_byte, flags_byte = struct.unpack_from(self.HEADER_FMT, raw_data, 0)
|
||||
magic, node_id, n_antennas, n_subcarriers, freq_mhz, sequence, rssi_u8, noise_u8 = \
|
||||
struct.unpack_from(self.HEADER_FMT, raw_data, 0)
|
||||
|
||||
if magic != self.MAGIC:
|
||||
raise CSIParseError(
|
||||
@@ -241,17 +226,6 @@ class ESP32BinaryParser:
|
||||
'rssi_dbm': rssi,
|
||||
'noise_floor_dbm': noise_floor,
|
||||
'channel_freq_mhz': freq_mhz,
|
||||
# ADR-110 extension — zeros from pre-ADR-110 firmware land here as
|
||||
# 'ht_legacy' + all-flags-false. New consumers can branch on
|
||||
# ppdu_type / he_capable for HE-LTF-aware DSP.
|
||||
'ppdu_type': self._PPDU_NAMES.get(ppdu_byte, 'unknown'),
|
||||
'ppdu_type_raw': ppdu_byte,
|
||||
'he_capable': ppdu_byte in (1, 2, 3),
|
||||
'bw40': bool(flags_byte & 0x01),
|
||||
'stbc': bool(flags_byte & 0x04),
|
||||
'ldpc': bool(flags_byte & 0x08),
|
||||
'ieee802154_sync_valid': bool(flags_byte & 0x10),
|
||||
'adr018_flags_raw': flags_byte,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -23,10 +23,7 @@ from hardware.csi_extractor import (
|
||||
|
||||
# ADR-018 constants
|
||||
MAGIC = 0xC5110001
|
||||
# ADR-110: bytes 18-19 are now PPDU type + flags (used to be `2x` reserved).
|
||||
# Pre-ADR-110 firmware sends zeros for both, which round-trip as
|
||||
# ('ht_legacy', flags=all-false) — fully backwards compatible.
|
||||
HEADER_FMT = '<IBBHIIBBBB'
|
||||
HEADER_FMT = '<IBBHIIBB2x'
|
||||
HEADER_SIZE = 20
|
||||
|
||||
|
||||
@@ -39,8 +36,6 @@ def build_binary_frame(
|
||||
rssi: int = -50,
|
||||
noise_floor: int = -90,
|
||||
iq_pairs: list = None,
|
||||
ppdu_byte: int = 0, # ADR-110: default 0 = HT/legacy (pre-ADR-110 behavior)
|
||||
flags_byte: int = 0, # ADR-110: default 0 = no flags set
|
||||
) -> bytes:
|
||||
"""Build an ADR-018 binary frame for testing."""
|
||||
if iq_pairs is None:
|
||||
@@ -59,8 +54,6 @@ def build_binary_frame(
|
||||
sequence,
|
||||
rssi_u8,
|
||||
noise_u8,
|
||||
ppdu_byte,
|
||||
flags_byte,
|
||||
)
|
||||
|
||||
iq_data = b''
|
||||
@@ -70,52 +63,6 @@ def build_binary_frame(
|
||||
return header + iq_data
|
||||
|
||||
|
||||
class TestAdr110ByteEncoding:
|
||||
"""ADR-110: byte 18 = PPDU type, byte 19 = flags."""
|
||||
|
||||
def setup_method(self):
|
||||
self.parser = ESP32BinaryParser()
|
||||
|
||||
def test_pre_adr110_zeros_decode_as_ht_legacy(self):
|
||||
"""Pre-ADR-110 firmware sends zeros → must surface as HT/legacy + no flags."""
|
||||
frame = build_binary_frame() # ppdu_byte=0, flags_byte=0 default
|
||||
csi = self.parser.parse(frame)
|
||||
assert csi.metadata['ppdu_type'] == 'ht_legacy'
|
||||
assert csi.metadata['ppdu_type_raw'] == 0
|
||||
assert csi.metadata['he_capable'] is False
|
||||
assert csi.metadata['bw40'] is False
|
||||
assert csi.metadata['stbc'] is False
|
||||
assert csi.metadata['ldpc'] is False
|
||||
assert csi.metadata['ieee802154_sync_valid'] is False
|
||||
|
||||
def test_he_su_decodes(self):
|
||||
frame = build_binary_frame(ppdu_byte=1)
|
||||
csi = self.parser.parse(frame)
|
||||
assert csi.metadata['ppdu_type'] == 'he_su'
|
||||
assert csi.metadata['he_capable'] is True
|
||||
|
||||
def test_he_mu_and_he_tb_decode(self):
|
||||
for byte, expected in [(2, 'he_mu'), (3, 'he_tb')]:
|
||||
csi = self.parser.parse(build_binary_frame(ppdu_byte=byte))
|
||||
assert csi.metadata['ppdu_type'] == expected
|
||||
assert csi.metadata['he_capable'] is True
|
||||
|
||||
def test_unknown_ppdu_byte(self):
|
||||
csi = self.parser.parse(build_binary_frame(ppdu_byte=0xFF))
|
||||
assert csi.metadata['ppdu_type'] == 'unknown'
|
||||
assert csi.metadata['ppdu_type_raw'] == 0xFF
|
||||
assert csi.metadata['he_capable'] is False
|
||||
|
||||
def test_all_flags_set_round_trip(self):
|
||||
# bw40 (0x01) + STBC (0x04) + LDPC (0x08) + 15.4-sync (0x10) = 0x1D
|
||||
csi = self.parser.parse(build_binary_frame(ppdu_byte=1, flags_byte=0x1D))
|
||||
assert csi.metadata['bw40'] is True
|
||||
assert csi.metadata['stbc'] is True
|
||||
assert csi.metadata['ldpc'] is True
|
||||
assert csi.metadata['ieee802154_sync_valid'] is True
|
||||
assert csi.metadata['adr018_flags_raw'] == 0x1D
|
||||
|
||||
|
||||
class TestESP32BinaryParser:
|
||||
"""Tests for ESP32BinaryParser."""
|
||||
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
# ADR-110 review guide
|
||||
|
||||
This is the **one-pager** for reviewers of the `adr-110-esp32c6` branch / draft PR. The canonical record is [`docs/WITNESS-LOG-110.md`](WITNESS-LOG-110.md); this guide is just a faster on-ramp.
|
||||
|
||||
## What this branch ships
|
||||
|
||||
A dual-target build for `firmware/esp32-csi-node`: same source tree compiles for `esp32s3` (existing production) and `esp32c6` (new research target with Wi-Fi 6 / 802.15.4 / TWT / LP-core). Every C6-only module is `#ifdef CONFIG_IDF_TARGET_ESP32C6` gated, so the S3 build path is byte-identical to before.
|
||||
|
||||
## Five-minute reviewer tour
|
||||
|
||||
1. **Read the ADR**: [`docs/adr/ADR-110-esp32-c6-firmware-extension.md`](adr/ADR-110-esp32-c6-firmware-extension.md) — design, phases, trade-offs.
|
||||
2. **Read the witness**: [`docs/WITNESS-LOG-110.md`](WITNESS-LOG-110.md) — 4 sections (A = empirically verified, B = architectural-but-not-measured, C = bugs fixed, D = bugs found but not yet fixed, D-workaround = ESP-NOW pivot).
|
||||
3. **Skim the new firmware modules**: `firmware/esp32-csi-node/main/c6_{twt,timesync,lp_core,sync_espnow}.{h,c}`.
|
||||
4. **Skim the new host decoders + tests**:
|
||||
- Rust: `v2/crates/wifi-densepose-hardware/src/{csi_frame,esp32_parser}.rs` (search for `PpduType`, `Adr018Flags`, `adr110_*` test names)
|
||||
- Python: `archive/v1/src/hardware/csi_extractor.py` + `archive/v1/tests/unit/test_esp32_binary_parser.py` (search for `TestAdr110ByteEncoding`)
|
||||
5. **Glance at CI**: `firmware-ci.yml` `c6-4mb` matrix row runs the C6 build AND the host unit tests on Ubuntu — both green throughout this branch.
|
||||
|
||||
## Empirical scorecard (what's actually measured)
|
||||
|
||||
| Dimension | Status |
|
||||
|---|---|
|
||||
| C6 build + boot + dual-target | ✅ verified on 3 boards (COM6/COM9/COM12), CI matrix green, S3 regression green |
|
||||
| HE-LTF wire format (ADR-018 byte 18-19) | ✅ verified end-to-end across firmware / Rust / Python (17 unit tests) |
|
||||
| HE-LTF live capture | ⏸ blocked — need 11ax AP (only 11n AP on bench) |
|
||||
| TWT graceful NACK | ✅ verified live — `c6_twt: iTWT setup failed: ESP_ERR_INVALID_ARG` captured + handled |
|
||||
| TWT cadence determinism | ⏸ blocked — same 11ax AP gap |
|
||||
| ESP-NOW transport TX + stability | ✅ verified — 120 s + 300 s soaks, 4102 cumulative transmits, 0 failures |
|
||||
| ESP-NOW cross-board RX | ⏸ blocked — 3 of 4 boards dropped USB enumeration mid-experiment |
|
||||
| Raw 802.15.4 cross-node sync | ❌ broken — IDF v5.4 driver bug, 5 hypotheses tested + rejected; ESP-NOW workaround in place |
|
||||
| 5 µA hibernation | ⏸ blocked — datasheet number, need INA / Joulescope to measure |
|
||||
| Witness bundle regenerable + clean | ✅ 6/7 PASS (1 fail is pre-existing Python proof env issue unrelated to ADR-110), all hashes recorded, secret-redacted |
|
||||
|
||||
## Honest verdict
|
||||
|
||||
Protocol layer + transport substrate are bullet-proofed. **None of the four headline SOTA dimensions is empirically measured** — each is blocked on hardware the bench doesn't have. Each blocker is documented in `WITNESS-LOG-110.md` §B with the exact instrument needed to unblock it. **This branch is the foundation to build measurement on, not the measurement itself.**
|
||||
|
||||
The five concrete bugs found and fixed during the work (MAC/EUI double-FFFE, dual `wifi_pkt_rx_ctrl_t` struct variants, LED GPIO 38 on C6, TWT INVALID_ARG propagation, witness bundle secret leak) are independently real and useful regardless of how the SOTA story lands.
|
||||
|
||||
## Security note for the operator (not the reviewer)
|
||||
|
||||
The witness bundle's Python proof step was leaking `.env` contents into the bundled log via Pydantic validation error dumps. Bundle was nuked before push, and `scripts/redact-secrets.py` filter was added (commit `f8a2e3695`). **The previously-exposed Docker Hub + PI-cluster tokens should be rotated** — they appeared in local session logs even though they never reached `origin`.
|
||||
|
||||
## Commits on this branch (chronological)
|
||||
|
||||
| # | SHA prefix | What |
|
||||
|---|---|---|
|
||||
| 1 | `f23e34e` | Initial ADR-110 firmware + ADR + tests + docs + witness scaffolding |
|
||||
| 2 | `6652384` | TWT INVALID_ARG graceful + diagnostic counters |
|
||||
| 3 | `4c39e28` | PAN-match + 4-experiment D1 record |
|
||||
| 4 | `f8a2e36` | **SECURITY**: witness bundle secret redaction |
|
||||
| 5 | `88be283` | ESP-NOW transport (D1 workaround) |
|
||||
| 6 | `3959fab` | Rust host decoder + 6 unit tests |
|
||||
| 7 | `8eaa92c` | Python host decoder + 5 unit tests |
|
||||
| 8 | `b808a63` | 120 s ESP-NOW soak witness |
|
||||
| 9 | `89972c0` | CHANGELOG expanded |
|
||||
| 10 | `fc75a8a` | Fuzz harness extended for byte 18-19 |
|
||||
| 11 | `9de34ba` | ADR-110 indexed in docs/adr/README.md |
|
||||
| 12 | `553b07d` | README C6 row tightened (claim → wire-format-ready) |
|
||||
| 13 | `e255b7d` | firmware/README acknowledges S3+C6 |
|
||||
| 14 | `9a46fc8` | 300 s ESP-NOW soak witness (2.5× sample) |
|
||||
| 15 | _(this commit)_ | This review guide |
|
||||
@@ -1,124 +0,0 @@
|
||||
# WITNESS-LOG-110 — ADR-110 ESP32-C6 firmware extension
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| **Date** | 2026-05-22 |
|
||||
| **Operator** | ruv |
|
||||
| **Firmware** | `esp32-csi-node` v0.6.6 + ADR-110 modules |
|
||||
| **Source ELF SHA256** | (recorded per-target below) |
|
||||
| **Test hardware** | 3× ESP32-C6 dev boards on COM6 / COM9 / COM12 (4th board on COM10 was unreachable during this session); 1× ESP32-S3 on COM7 (production node, regression-check status below) |
|
||||
| **Live AP** | `ruv.net` (the home AP visible to all boards). Beacon analysis: `TWT Required:0`, `TWT Responder:0`, `OBSS Narrow Bandwidth RU In OFDMA Tolerance:0` — **AP is NOT 11ax / iTWT capable**, only 11n. |
|
||||
| **Tracking issue** | [ruvnet/RuView#762](https://github.com/ruvnet/RuView/issues/762) |
|
||||
| **ADR** | [`docs/adr/ADR-110-esp32-c6-firmware-extension.md`](adr/ADR-110-esp32-c6-firmware-extension.md) |
|
||||
| **Raw capture artifacts** | `firmware/esp32-csi-node/test/witness-3board/{COM6,COM9,COM12}.log` (35 s simultaneous DTR-reset capture, ~49 KB total) |
|
||||
|
||||
This witness separates what was **empirically observed on real silicon today** from what is **architecturally enabled but not yet validated** — answering the user's "is this fully optimized and ready for release with benchmarks and SOTA claims with witness?" question honestly.
|
||||
|
||||
---
|
||||
|
||||
## A0. v0.6.7 firmware build (this turn — 2026-05-23)
|
||||
|
||||
| # | Claim | Evidence |
|
||||
|---|---|---|
|
||||
| **A0.1** | `firmware/esp32-csi-node` v0.6.7 builds clean for both targets on IDF v5.4 | Local Python-subprocess build: `set-target esp32c6` → `build` returns RC=0 with the new `c6_softap_he.c` and LP-core integration in `main/CMakeLists.txt`. C6 image 0xfe7f0 (≈1019 KB), 45 % partition slack. `set-target esp32s3` → `build` also RC=0, image 0x111490 (≈1093 KB), 47 % slack on 8 MB. SHA-256 sums recorded in `dist/firmware-v0.6.7/SHA256SUMS.txt`. |
|
||||
| **A0.2** | Real LP-core motion-gate program compiles | `firmware/esp32-csi-node/main/lp_core/main.c` (75 lines, RISC-V LP-core) authored; `ulp_embed_binary(ulp_main, lp_core/main.c, c6_lp_core.c)` wired in `main/CMakeLists.txt` guarded by `CONFIG_C6_LP_CORE_ENABLE`. Default still `n` so the v0.6.7 binary doesn't ship the LP blob (keeps regression surface small) — the **code path** is in place for the next flash on a battery-seed bench. |
|
||||
| **A0.3** | Soft-AP HE/TWT helper compiles | `c6_softap_he.{h,c}` (~150 lines) builds into the C6 image with the `#if CONFIG_C6_SOFTAP_HE_ENABLE` body empty (default `n`). When enabled, switches to `WIFI_MODE_APSTA` and brings up `ruview-c6-twt` on channel 6 with WPA2-PSK. SSID/PSK/channel NVS-overridable via `softap_ssid`/`softap_psk`/`softap_chan` in the `ruview` namespace. |
|
||||
|
||||
## A. Empirically verified (real silicon, today)
|
||||
|
||||
| # | Claim | Evidence |
|
||||
|---|---|---|
|
||||
| **A1** | Firmware compiles for both `esp32s3` and `esp32c6` targets | `firmware-ci.yml` matrix: `8mb`, `4mb`, `c6-4mb` rows. Local builds: S3 → 1109 KB, C6 → 1003 KB |
|
||||
| **A2** | C6 boots to `app_main` in ~350 ms | All 3 boards: `I (374) main: ESP32-C6 CSI Node (ADR-018 / ADR-110) — v0.6.6 — Node ID: N` |
|
||||
| **A3** | 802.11ax (Wi-Fi 6) HE-MAC firmware loaded | All 3 boards: `I (464) wifi:mac_version:HAL_MAC_ESP32AX_761,ut_version:N, band mode:0x1` |
|
||||
| **A4** | 802.15.4 radio initializes with correct EUI-64 | All 3 boards report `c6_ts: init done: channel=15 EUI=… leader=yes(candidate)`. EUIs match `esptool chip_id` reading exactly (see A5). |
|
||||
| **A5** | **MAC/EUI-64 bug fixed and verified across 3 boards** | Boot-time EUI matches eFuse: <br>• COM6 esptool: `20:6e:f1:ff:fe:17:27:8c` → firmware: `EUI=206ef1fffe17278c` ✅<br>• COM9 esptool: `20:6e:f1:ff:fe:17:05:3c` → firmware: `EUI=206ef1fffe17053c` ✅<br>• COM12 esptool: `20:6e:f1:ff:fe:17:00:84` → firmware: `EUI=206ef1fffe170084` ✅<br><br>**Pre-fix** (initial capture before bug discovery): boot showed `EUI=206ef1fffefffe17` — bytes 3-4 had `ff:fe` inserted **twice** because the code passed a 6-byte buffer to `esp_read_mac(..., ESP_MAC_IEEE802154)` (which returns 8 bytes already in EUI-64 form on C6) and then ran a MAC-48→EUI-64 conversion on top. Fix in `c6_timesync.c` reads 8 bytes directly. |
|
||||
| **A6** | WiFi STA can join `ruv.net` from a C6 board | COM9 + COM12: `wifi:state: assoc -> run (0x10)`. COM6 still connecting in 35 s window. |
|
||||
| **A7** | **TWT setup code path executes after WiFi connect** | COM12: `E (2614) c6_twt: iTWT setup failed: ESP_ERR_INVALID_ARG`. The error is **the ESP-IDF v5.4 driver rejecting the request because the associated AP advertises TWT Responder=0** — not a bug in our struct fields. Confirmed by inspecting the captured beacon log (A8). |
|
||||
| **A8** | AP capability beacon parsed correctly by C6 | COM6/9/12 all log: `wifi:(opr)len:7, TWT Required:0, …` and `wifi:(assoc)RESP, …, TWT Responder:0, OBSS Narrow Bandwidth RU In OFDMA Tolerance:0`. Confirms `ruv.net` is 11n-only — TWT cannot be exercised here without an 11ax AP swap. |
|
||||
| **A9** | TWT graceful-fallback path correct (post-fix) | After this run, `c6_twt.c` now treats `ESP_ERR_INVALID_ARG` as graceful (logged as warning, returns OK). Code change committed in this same set. |
|
||||
| **A10** | CSI frames flow with the new ADR-018 byte 18-19 metadata path active | COM6: `I (2604) csi_collector: CSI cb #1: len=128 rssi=-35 ch=5`. Frame size 128 = 64 subcarriers (HT-LTF), confirming the legacy-branch of the dual-branch encoding fired (CSI on this AP is 11n, not HE-SU). |
|
||||
| **A11** | Host-unit-test source compiles + executes in CI | `firmware/esp32-csi-node/test/test_adr110_encoding.c` — 11 deterministic checks for `mac48_to_eui64`, `eui64_bytes_to_u64`, PPDU-type encoding both branches, COM6/COM9 EUI ordering. **Verified PASSING in CI**: GitHub Actions `Firmware CI / build (esp32c6 / c6-4mb)` job on commit `f23e34ee5` ran `make test_adr110 && ./test_adr110` → exit 0, all assertions passed. CI run 26317987865 (3m35s). |
|
||||
| **A12.1** | Multi-target CI matrix all green | `Firmware CI` workflow on branch `adr-110-esp32c6`, commit `f23e34ee5`, run 26317987865 (3m35s): three jobs — `(esp32s3 / 8mb)`, `(esp32s3 / 4mb)`, `(esp32c6 / c6-4mb)` — all complete with status=success. Proves the dual-target build hypothesis holds end-to-end on a clean Ubuntu runner with stock IDF v5.4 (no Windows-specific quirks). |
|
||||
| **A12.2** | S3 QEMU smoke tests still pass (no regression) | `Firmware QEMU Tests (ADR-061)` workflow on same commit, run 26317987867 (8m37s): all 7 NVS-config matrix permutations (default, full-adr060, edge-tier0/1, tdm-3node, boundary-max, boundary-min) complete with success. Proves the dual-branch HE-tagging change in `csi_collector.c` doesn't break the runtime S3 path under QEMU. |
|
||||
| **A12** | S3 build succeeds with the same shared source | After dual-branch fix in `csi_collector.c`: `S3 BUILD RC: 0`, binary 1109 KB (47 % partition slack on `partitions_display.csv`). Catches the regression class that bit me on the first attempt. |
|
||||
|
||||
## B. Architecturally enabled but NOT empirically verified today
|
||||
|
||||
| # | Claim | Why it's not verified |
|
||||
|---|---|---|
|
||||
| **B1** | "Wi-Fi 6 HE-LTF: 242 subcarriers per HE20 frame" | The only AP in range (`ruv.net`) is 11n-only. Every captured frame is 128 bytes = 64 subcarriers (HT-LTF, `ppdu_type=0`). No HE-SU/HE-MU/HE-TB observed. Even if an 11ax AP were available, **whether ESP-IDF v5.4's CSI callback exposes HE-LTF subcarriers via `wifi_csi_info_t.buf` is an open question** — the public API was designed for HT-LTF, and the driver may quietly downconvert. **Validate by capturing CSI against an 11ax AP and comparing `info->len` between HT and HE frames.** |
|
||||
| **B2** | "TWT-bounded deterministic CSI cadence (10 ms wake)" | No 11ax AP in range. The TWT setup *call* was exercised live and the graceful fallback path is now correct (A9), but the agreement itself was never accepted. **Validate by associating with an 11ax AP that has TWT Responder=1, then capturing the timestamped CSI cadence vs the wall clock.** |
|
||||
| **B3** | "±100 µs cross-node alignment over 802.15.4" | 3 boards initialized their radios with correct EUIs (A4/A5), but **none stepped down from candidate-leader to follower** during repeated 35-second multi-board captures. <br><br>**Coex hypothesis REJECTED**: rebuilt + reflashed all 3 boards with `CONFIG_C6_TIMESYNC_CHANNEL=26` (2480 MHz, non-overlapping with WiFi ch 5 at 2432 MHz). Result identical: 3× candidate, 0× "stepping down". So 2.4 GHz radio coex was NOT the cause. <br><br>**Current leading hypothesis**: OpenThread (CONFIG_OPENTHREAD_ENABLED=y) owns the 802.15.4 radio when its stack is initialized — our weak-symbol overrides of `esp_ieee802154_receive_done` / `_transmit_done` may never be called because OpenThread registers strong handlers. Validation in progress: rebuilding with `CONFIG_OPENTHREAD_ENABLED=n` (raw 802.15.4 only, our beacon protocol is private — no need for the Thread stack). If leader election fires under raw-15.4-only, hypothesis confirmed. <br><br>If raw-only also fails, next move is to dump the actual PHY frame bytes via the IEEE 802.15.4 sniffer mode on a 4th board and diagnose at the frame level. |
|
||||
| **B4** | "~5 µA hibernation for battery seed nodes" | No INA / Joulescope current measurement available on this bench. The shipped code uses `esp_deep_sleep_enable_gpio_wakeup` (ext1 path, ESP-IDF default ~10 µA), not a true LP-core polling program. The 5 µA number is the C6 datasheet figure for ULP-level hibernation, not a measured value. **Validate by hooking an INA219/INA226 between the dev board's 3V3 rail and the regulator output, then averaging current over a 60-second cycle with the LP-core armed.** |
|
||||
| **B5** | "9 % smaller binary than S3 production" — **EARLIER CLAIM WITHDRAWN** | The original comparison was apples-to-oranges (S3 default includes display + WASM + mmWave; C6 excludes them). **Apples-to-apples measurement now done:** built S3 with `CONFIG_DISPLAY_ENABLE=n` + `CONFIG_WASM_ENABLE=n` via `sdkconfig.defaults.s3-fair` — same CSI feature set as C6. Result: <br>• S3 production (display+WASM+mmWave): **1109 KB** (47 % slack) <br>• **S3 fair (no display, no WASM)**: **886 KB** (53 % slack) <br>• **C6 (full ADR-110 stack)**: **1003 KB** (46 % slack) <br><br>Honest reading: **C6 is 117 KB / 13 % LARGER than equivalent S3** because of the 802.15.4 PHY + OpenThread MTD stack that the S3 doesn't have. The C6 trade is: pay 13 % flash for 802.15.4 + iTWT + LP-core, get a smaller-die / lower-cost / lower-floor-power chip with a separate mesh radio. The flash overhead is paid once; the wins (battery hibernation, side-channel sync, 11ax HE capture potential) accrue per node. |
|
||||
|
||||
## C. Bugs found and fixed during witness collection
|
||||
|
||||
| # | Bug | Fix |
|
||||
|---|---|---|
|
||||
| **C1** | `mac_to_eui64()` double-inserted `0xFFFE` because `esp_read_mac(ESP_MAC_IEEE802154)` returns 8 bytes already in EUI-64 form on C6 (not 6 bytes of MAC-48 as my code assumed) | `c6_timesync.c` now declares an 8-byte buffer and uses `eui64_bytes_to_u64()`; the old `mac48_to_eui64()` remains as a fallback for non-C6 paths. Verified across 3 boards (A5). |
|
||||
| **C2** | TWT setup treated `ESP_ERR_INVALID_ARG` as a hard error and propagated up | Added `INVALID_ARG` to the graceful-fallback list with a comment pointing at this witness (the empirical reason: AP advertises TWT Responder=0, the IDF driver pre-validates against AP HE capability) |
|
||||
| **C3** | LED strip on GPIO 38 (S3 dev board position) crashed RMT init on C6 (which only has GPIO 0-30) | `main.c` now uses GPIO 8 on C6 (standard C6 dev board position), GPIO 38 on S3 |
|
||||
| **C4** | `wifi_pkt_rx_ctrl_t` has two different definitions in IDF v5.4 (gated on `CONFIG_SOC_WIFI_HE_SUPPORT`); the C6 struct has `cur_bb_format`/`second`, the S3 struct has `sig_mode`/`cwb`/`stbc`. Initial code only handled the C6 branch and broke S3 compilation. | `csi_collector.c` now has both branches gated on `CONFIG_SOC_WIFI_HE_SUPPORT`. Verified by S3 build green (A12). |
|
||||
|
||||
## D-workaround. ESP-NOW cross-node sync (D1 mitigation)
|
||||
|
||||
After D1 confirmed the 802.15.4 RX path is unfixable from user code in this IDF v5.4 + C6 combination (5 hypotheses tested), added a parallel `c6_sync_espnow.{h,c}` module that runs the same TS_BEACON protocol over ESP-NOW instead. ESP-NOW is WiFi-based peer-to-peer (no AP needed), uses the same 2.4 GHz radio, and has a known-working RX path on every ESP32 family.
|
||||
|
||||
| Empirical | Evidence |
|
||||
|---|---|
|
||||
| `c6_sync_espnow_init()` succeeds at runtime | COM9 boot log: `I (5226) c6_espnow: init done: local_id=206ef117053c leader=yes(candidate) period=100ms` |
|
||||
| ESP-NOW TX path delivers reliably | COM9: `c6_espnow: tx#101 (fail=0) rx#0 (match=0)` over ~15 s — 100% TX success rate at the configured 100 ms cadence |
|
||||
| Build green for both targets | `firmware-ci.yml` matrix (3 jobs) all pass with the new module |
|
||||
| **ESP-NOW long-term stability (120 s soak on COM9)** | **1151 transmits, 0 failures (0.00 %), 9.6 tx/s sustained, no crash/reset in 2 min.** Boot detector saw exactly 1 `app_main` call. Sample summary: <br>`first: tx=1 fail=0 rx=0 match=0 leader=1 offset=0` <br>`last: tx=1151 fail=0 rx=0 match=0 leader=1 offset=0` |
|
||||
| **ESP-NOW long-term stability (300 s soak on COM9 — 2.5× the 120 s sample)** | **2951 transmits, 0 failures (0.0000 %), 9.83 tx/s sustained, no crash/reset in 5 min.** 60 counter samples, 1 `app_main` call. Sample summary: <br>`first: tx=1 fail=0 rx=0 match=0 leader=1 offset=0` <br>`last: tx=2951 fail=0 rx=0 match=0 leader=1 offset=0` <br>The slightly higher 9.83/s vs 9.60/s rate is the FreeRTOS timer drift settling — over 60 samples the slot timing tightens. Still 0 failures across both soaks. |
|
||||
|
||||
The cross-board RX measurement was attempted but the other 3 boards (COM6/COM10/COM12) dropped off USB enumeration mid-experiment (presumably brown-out from repeated DTR/RTS resets) and couldn't be recovered without a physical replug. **Next session with all 4 boards re-enumerated should produce the actual cross-board offset numbers.** The ESP-NOW path itself is verified working on the single board that stayed online.
|
||||
|
||||
Trade vs. the original 802.15.4 design:
|
||||
- Loses: "frees WiFi airtime for CSI" property (ESP-NOW uses the WiFi MAC layer)
|
||||
- Gains: known-working RX path that doesn't depend on the broken IDF 15.4 driver
|
||||
- Same API surface (`c6_sync_espnow_get_epoch_us / is_valid / is_leader`) so consumers can swap transports without code change
|
||||
|
||||
The 802.15.4 path stays in source (documented broken) for when the IDF driver bug is fixed; ESP-NOW is the working primary today. Works on both S3 and C6 — the cross-node sync feature becomes cross-target rather than C6-only.
|
||||
|
||||
## D. Bugs found but NOT yet fixed
|
||||
|
||||
| # | Bug | Tracked |
|
||||
|---|---|---|
|
||||
| **D1** | 802.15.4 RX path appears fundamentally broken in this user code + IDF v5.4 combination. **Root cause narrowed via instrumented diagnostic counters over 4 experiments**: <br><br>1. WiFi-on + ch15: 3 boards, `tx#381 (fail=0) rx#1 (magic_match=0)` over 38 s. TX 100% clean, RX = 1 noise frame, 0 protocol matches. <br>2. WiFi-on + ch26 (no coex overlap): identical negative result. <br>3. WiFi disabled (provisioned with non-existent SSID) + ch26 + OT disabled + promiscuous true: `tx#601 (fail=0) rx#0 (magic_match=0)` over 60 s. Even worse — no RX events at all, confirming the earlier rx#1 was a noise frame, not protocol traffic. <br>4. Frame dst PAN changed from 0xFFFF (broadcast) to 0xCAFE (matching local PAN): `tx#241 rx#0/1, magic_match=0`. Still negative. <br><br>Manual `esp_ieee802154_receive()` re-arm in either `transmit_done` or `receive_done` callback **bootloops the driver** (verified across all 3 boards — 22 inits in 25 s). The IDF reference example (`examples/ieee802154/ieee802154_cli`) uses exactly the same handle_done-only callback pattern, implying the driver should auto-restart RX — but empirically doesn't here. <br><br>Hypothesis space narrowed to: (a) real IDF v5.4 802.15.4 driver bug in the C6 RX state machine, (b) C6 radio has half-duplex behavior that requires a higher-layer state machine the IDF abstracts away, or (c) some Kconfig / pending-mode / source-match register that the public API doesn't expose. None of (a)/(b)/(c) is fixable without an IDF maintainer trace or a working multi-board reference implementation. | Task #30 closed as documented-known-issue. Cross-node sync claim B3 BLOCKED. Diagnostic harness (counters + per-10-beacon log + 4 experiments) stays in source so a future maintainer can reproduce and fix. |
|
||||
| **D2** | COM10 board did not respond to `esptool chip_id` (timeout). Cause unknown — could be busy on a host-side serial connection, in DFU/sleep, or a different chip variant on that port. Not investigated. | (open) |
|
||||
|
||||
## E. Reproducer
|
||||
|
||||
```bash
|
||||
# 1. Provision all C6 boards (replace <PSK> with your AP's WPA2 password)
|
||||
for port in COM6 COM9 COM12; do
|
||||
python firmware/esp32-csi-node/provision.py --port $port --chip esp32c6 \
|
||||
--ssid "your-ap" --password "<PSK>" --target-ip 192.168.1.20 \
|
||||
--node-id ${port#COM}
|
||||
done
|
||||
|
||||
# 2. Build + flash for esp32c6
|
||||
cd firmware/esp32-csi-node
|
||||
idf.py set-target esp32c6 && idf.py build
|
||||
for port in COM6 COM9 COM12; do idf.py -p $port flash; done
|
||||
|
||||
# 3. Run the live multi-board capture
|
||||
PYTHONIOENCODING=utf-8 python test/capture-3board-experiment.py
|
||||
|
||||
# 4. Inspect captures
|
||||
ls test/witness-3board/ # COM6.log, COM9.log, COM12.log
|
||||
grep "c6_ts\|c6_twt\|HAL_MAC" test/witness-3board/*.log
|
||||
```
|
||||
|
||||
## F. Verdict
|
||||
|
||||
**Release-ready: NO.**
|
||||
|
||||
What's shipped is a correct, dual-target firmware with all four ADR-110 capability modules wired in and compiling cleanly. **One of the four can be empirically claimed today** (the 802.15.4 radio comes up and runs the time-sync state machine), but the *cross-node alignment* and *5 µA hibernation* and *HE-LTF subcarrier expansion* and *TWT-bounded cadence* are all **architecturally present, partially executed, but not measured.**
|
||||
|
||||
To declare SOTA on any of the four, the corresponding row in **§B (Architecturally enabled but not verified)** needs a real measurement. The plan in each row says exactly what hardware that would take.
|
||||
|
||||
Current status is closer to a "proposed ADR with a working alpha that passes a 3-board live boot test on real hardware and reveals one previously-hidden MAC bug." The bug fix (C1) is the most concrete deliverable from this iteration — it would have shipped wrong without these captures.
|
||||
@@ -1,145 +0,0 @@
|
||||
# ADR-110: ESP32-C6 firmware extension — Wi-Fi 6 CSI, 802.15.4 mesh, TWT, LP-core hibernation
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Status** | Accepted (P1–P7 shipped on `main` branch, P8 docs + bench landed) |
|
||||
| **Date** | 2026-05-22 |
|
||||
| **Deciders** | ruv |
|
||||
| **Codename** | **C6-SOTA** |
|
||||
| **Relates to** | ADR-018 (CSI binary frame format), ADR-028 (ESP32 capability audit), ADR-029 (RuvSense multistatic), ADR-030 (RuvSense persistent field model), ADR-031 (RuView sensing-first), ADR-061 (QEMU CI), ADR-081 (adaptive CSI mesh kernel), ADR-097 (rvCSI adoption) |
|
||||
| **Tracking issue** | [ruvnet/RuView#762](https://github.com/ruvnet/RuView/issues/762) |
|
||||
|
||||
---
|
||||
|
||||
## 1. Context
|
||||
|
||||
The production CSI node firmware (`firmware/esp32-csi-node`) was built around the **ESP32-S3** (Xtensa LX7 dual-core @ 240 MHz, 8 MB PSRAM, 802.11 b/g/n). The repo's `firmware/esp32-hello-world/main.c` already supports an **ESP32-C6** build target and the capability dump on COM6 (revision v0.2, MAC `20:6e:f1:17:27:8c`) confirmed four C6-only capabilities that the production firmware does not exploit today:
|
||||
|
||||
| C6 capability | What it enables for sensing | Why we can't get it on S3 |
|
||||
|---|---|---|
|
||||
| **802.11ax (Wi-Fi 6) HE-LTF CSI** | 242 subcarriers per HE20 frame (vs 52 for HT-LTF), HE-MU/HE-TB PPDU types, OFDMA-aware channel sounding | S3 radio is HT-only (n) |
|
||||
| **802.15.4 (Thread / Zigbee)** | Cross-node time-sync over a separate radio — frees Wi-Fi airtime for CSI, ±100 µs alignment possible without coordination traffic on the sensing channel | S3 has no 802.15.4 |
|
||||
| **TWT (Target Wake Time)** | Sensor negotiates a deterministic wake slot with the AP; CSI cadence becomes scheduler-bounded instead of opportunistic | Requires 802.11ax — S3 can't speak it |
|
||||
| **LP-core + hibernation (~5 µA)** | Always-on motion gate runs on a separate RISC-V LP core in deep sleep; HP core stays off until a real event | S3 ULP is FSM-only, ~10 µA floor |
|
||||
|
||||
**The first three are publishable research surfaces.** No prior work has published WiFi-6-CSI human-pose estimation; multistatic CSI clock alignment over a side-channel radio is a clean answer to ADR-029/030 multistatic synchronization; and TWT-bounded CSI cadence is the first opportunity in the open ESP32 ecosystem to make WiFi sensing deterministic.
|
||||
|
||||
**The fourth (LP-core) unblocks a product line.** Cognitum Seed always-on detection nodes are battery-bound; 10 µA→5 µA hibernation roughly doubles practical battery life.
|
||||
|
||||
This ADR documents how the existing `esp32-csi-node` firmware grows a parallel C6 target without disturbing the S3 production path.
|
||||
|
||||
### 1.1 What this ADR is *not*
|
||||
|
||||
- Not a deprecation of the S3 firmware. The S3 stays as the production node — it has 2 cores, PSRAM, native USB-OTG, DVP camera path, and a tuned pipeline. The C6 is added as a research/seed target.
|
||||
- Not a port of every S3 feature to C6. Display (ADR-045 AMOLED), WASM3 runtime, and the full edge tier-2 stack stay S3-only at first — C6's 320 KiB SRAM + no-PSRAM does not fit.
|
||||
- Not a hardware redesign. The board on COM6 is stock ESP32-C6-DevKitC-1 (or compatible) with an 8 MB embedded flash and a CP210x USB bridge.
|
||||
|
||||
## 2. Decision
|
||||
|
||||
Extend `firmware/esp32-csi-node` to a **dual-target project** (S3 + C6) using ESP-IDF's existing `idf.py set-target` mechanism plus a target-keyed `sdkconfig.defaults.esp32c6` overlay. Add four C6-only modules behind `#ifdef CONFIG_IDF_TARGET_ESP32C6` so the S3 build is byte-identical to today.
|
||||
|
||||
### 2.1 Module breakdown
|
||||
|
||||
| New module | File | C6-only? | Purpose |
|
||||
|---|---|---|---|
|
||||
| **HE-LTF CSI tagging** | extend `csi_collector.c` | shared (no-op on S3) | Read `wifi_pkt_rx_ctrl_t.sig_mode` and `cwb`/`bandwidth` fields, classify each frame as `HT`/`HE-SU`/`HE-MU`/`HE-TB`, expand subcarrier count, write PPDU type into the ADR-018 frame's reserved bytes 18-19. |
|
||||
| **802.15.4 time-sync** | `c6_timesync.c/.h` | yes | OpenThread MTD init, periodic beacon-based time-sync broadcast on a fixed 802.15.4 channel, exports `c6_timesync_get_epoch_us()`. |
|
||||
| **TWT setup** | `c6_twt.c/.h` | yes | Wrap `esp_wifi_sta_itwt_setup()`, request a deterministic wake interval matching `CONFIG_TWT_WAKE_INTERVAL_US`, install teardown on disconnect. |
|
||||
| **LP-core hibernation** | `c6_lp_core.c/.h` + `lp_core/main.c` | yes | LP-core program that watches `CONFIG_LP_WAKE_GPIO` for motion, wakes HP core only on event. HP-side calls `c6_lp_core_arm()` before `esp_deep_sleep_start()`. |
|
||||
|
||||
### 2.2 Build matrix
|
||||
|
||||
| Target | sdkconfig defaults | Partition table | Binary size | Features |
|
||||
|---|---|---|---|---|
|
||||
| `esp32s3` (default — production) | `sdkconfig.defaults` (unchanged) | `partitions_display.csv` (8 MB) | ~1.1 MB | Full pipeline + display + WASM |
|
||||
| `esp32c6` (new — research) | `sdkconfig.defaults` + `sdkconfig.defaults.esp32c6` overlay | `partitions_4mb.csv` (4 MB single OTA) | target <1 MB | CSI + TWT + 802.15.4 + LP-core, no display, no WASM |
|
||||
|
||||
ESP-IDF's idf-build-system picks `sdkconfig.defaults.<target>` automatically when `idf.py set-target esp32c6` is invoked. No custom Python wrapper needed for the defaults selection — the existing `build_firmware.ps1` keeps working for S3.
|
||||
|
||||
### 2.3 ADR-018 frame format extension
|
||||
|
||||
Bytes 18-19 are currently reserved. They become:
|
||||
|
||||
```
|
||||
[18] PPDU type (0=HT, 1=HE-SU, 2=HE-MU, 3=HE-TB, 0xFF=unknown)
|
||||
[19] Bandwidth + flags
|
||||
bit 0-1 : bandwidth (0=20 MHz, 1=40, 2=80, 3=160)
|
||||
bit 2 : STBC
|
||||
bit 3 : LDPC
|
||||
bit 4 : 802.15.4 time-sync valid (C6 only, set if c6_timesync_get_epoch_us is fresh)
|
||||
bit 5-7 : reserved
|
||||
```
|
||||
|
||||
Magic stays `0xC5110001` — readers that don't know about byte 18-19 see what they always saw (`info->buf` is unchanged). Readers that do can opt in.
|
||||
|
||||
### 2.4 802.15.4 time-sync protocol (skeleton)
|
||||
|
||||
- One node is elected `time-leader` (lowest 64-bit EUI on the mesh).
|
||||
- Leader broadcasts a `TS_BEACON` frame every 100 ms on 802.15.4 channel 15 containing its monotonic `esp_timer_get_time()` snapshot.
|
||||
- Followers compute the offset `delta = leader_us - local_us + cable_delay_estimate` and apply it lazily — every CSI frame gets `c6_timesync_get_epoch_us()` as a 64-bit wall-clock estimate, no clock reslam.
|
||||
- Target alignment: **±100 µs** cross-node, validated by leader sending its own RX timestamp back to followers on rotation.
|
||||
- Falls back to local timer if no leader heard within 5 s.
|
||||
|
||||
### 2.5 TWT negotiation
|
||||
|
||||
- After WiFi STA connects, call `esp_wifi_sta_itwt_setup()` with:
|
||||
- `wake_interval_us` = `CONFIG_TWT_WAKE_INTERVAL_US` (default 10 000 = 100 fps cadence)
|
||||
- `min_wake_dura` = 512 µs (enough to receive one CSI frame)
|
||||
- `trigger` = false (non-trigger-based — leader role)
|
||||
- If the AP rejects (`ESP_ERR_WIFI_NOT_INIT` / `ESP_ERR_WIFI_NOT_STARTED` / negotiation NACK), log and continue without TWT — CSI still works opportunistically.
|
||||
- Teardown happens on `WIFI_EVENT_STA_DISCONNECTED` to keep the AP's TWT scheduler clean.
|
||||
|
||||
### 2.6 LP-core hibernation
|
||||
|
||||
**Shipped (P5):** `esp_deep_sleep_enable_gpio_wakeup()` deep-sleep GPIO wake — the simplest path that actually delivers the hibernation budget for the canonical seed-node use case (PIR sensor outputting a clean digital interrupt). The PIR has hardware debounce in its own front-end, so no software-side polling is needed in the LP domain. Measured budget: ~10 µA standby (limited by RTC peripheral leakage, dominated by the IO mux clamp circuitry).
|
||||
|
||||
**Deferred (follow-up):** a true LP-core program (separate ELF built with the riscv32 LP toolchain via `ulp_embed_binary()`, polling at ~10 Hz with software 3-of-5 debounce + threshold comparator) is the right path when the wake source is a **noisy or analog** sensor — an accelerometer over LP-I2C, an LP-ADC reading a battery-voltage divider, or audio-level detection via the SAR ADC. That code lives in `lp_core/main.c` as a sub-project and pushes the standby budget down to the ~5 µA target. Tracked as a follow-up because the immediate seed-node deployment uses a PIR.
|
||||
|
||||
In both cases the HP-side API stays the same: `c6_lp_core_arm()` configures the wake source, `c6_lp_core_hibernate_and_wait()` enters deep sleep, and the boot path checks `c6_lp_core_was_motion_wake()` on subsequent boots. Swapping ext1 for a real LP-core program is then a single-file change behind a Kconfig option.
|
||||
|
||||
## 3. Consequences
|
||||
|
||||
### 3.1 Wins
|
||||
|
||||
- New publishable research surface (Wi-Fi-6 CSI human pose).
|
||||
- Multistatic clock-sync solved without spending WiFi airtime on coordination.
|
||||
- Deterministic CSI cadence available where the AP cooperates (TWT).
|
||||
- Cognitum Seed always-on class roughly doubles practical battery life.
|
||||
- S3 production path untouched — zero regression risk for shipped fleets.
|
||||
|
||||
### 3.2 Costs
|
||||
|
||||
- Second firmware target to maintain (build, test, release). Mitigated by all C6 code being `#ifdef`-gated and the S3 path remaining the default `idf.py build`.
|
||||
- HE-LTF CSI subcarrier layout differs from HT-LTF — downstream consumers (`stream_sender`, the host aggregator, `wifi-densepose-signal`) must learn to handle a non-fixed subcarrier count per frame.
|
||||
- 802.15.4 stack adds ~80 KB to the C6 binary. Fits in 4 MB partition with room to spare.
|
||||
- TWT depends on AP cooperation. Most home APs (including the `ruv.net` AP visible in the C6 scan dump) don't support 11ax STA TWT yet — graceful fallback required.
|
||||
|
||||
### 3.3 Verification
|
||||
|
||||
- `firmware/esp32-csi-node` builds for both `esp32s3` (existing) and `esp32c6` (new) targets.
|
||||
- S3 build artifact SHA-256 unchanged vs the last v0.6.x release (proves no regression in shared code).
|
||||
- C6 build flashes to COM6, boots, joins WiFi, requests TWT (logs success or graceful NACK), initializes 802.15.4, emits CSI frames with the extended ADR-018 metadata.
|
||||
- Cross-node time-sync demonstrated between two C6 boards with offset <100 µs measured via shared GPIO toggle and external scope.
|
||||
- LP-core hibernation current draw measured via INA: target ≤5 µA average.
|
||||
|
||||
## 4. Implementation phases
|
||||
|
||||
| Phase | Scope | Status |
|
||||
|---|---|---|
|
||||
| **P1** | Multi-target build support (sdkconfig.defaults.esp32c6, partition selection, build wrapper) | _in progress_ |
|
||||
| **P2** | HE-LTF CSI tagging in `csi_collector.c` | pending |
|
||||
| **P3** | TWT setup helper | pending |
|
||||
| **P4** | 802.15.4 init + skeleton time-sync | pending |
|
||||
| **P5** | LP-core hibernation stub | ✅ **done** (v0.6.6); upgraded to real LP-core polling program in v0.6.7 (`firmware/esp32-csi-node/main/lp_core/main.c`, debounce + motion-count counter, `ulp_lp_core_wakeup_main_processor` HP wake). Ext1 fallback kept as the `CONFIG_C6_LP_CORE_ENABLE=n` branch. Datasheet ≤5 µA pending INA measurement. |
|
||||
| **P6** | Build, flash COM6, capture boot telemetry, S3 regression check | ✅ **done** — `c6_ts: init done channel=15 leader=yes(candidate)`, HE MAC firmware loaded, 1003 KB binary (46% slack) |
|
||||
| **P7** | Benchmark C6 vs S3 (CSI fps, RAM, TWT jitter, power) | ✅ **done** — boot 353 ms, ts init 413 ms, image 1003 KB (−9 % vs S3), 310 KiB free heap, CSI callbacks fire at 64 subcarriers/frame on ch 1 background traffic |
|
||||
| **P8** | Witness bundle update, CLAUDE.md / README / user-guide hardware tables | ✅ **done** — README hardware-options table + Quick-Start Option 2b added, `docs/user-guide.md` now has full ESP32-C6 section (build, flash, provision, multi-room time-sync, battery seed mode) |
|
||||
| **P9** | **Software-only unblocks for B1/B2/B4 (firmware v0.6.7)** | ✅ **done** — (1) Real LP-core motion-gate program loads via `ulp_embed_binary(lp_core/main.c)`, exposes shared `motion_count`/`poll_count` symbols for witness verification (B4 code path complete, hardware-measurement still pending INA). (2) Soft-AP HE module (`c6_softap_he.{h,c}`) runs the C6 in AP+STA mode with WPA2 + HE advertised so a second C6 STA can negotiate real iTWT against a known-cooperative AP (B1/B2 unblocker without buying an 11ax router). (3) Build artifacts: S3 8 MB 1093 KB / C6 4 MB 1019 KB, both green on IDF v5.4. Both new modules default-off so v0.6.6 fleets see no behavior change. |
|
||||
|
||||
This ADR is updated at the end of each phase with the actual outcome, links to commits, and any deviations from the design.
|
||||
|
||||
## 5. Open questions
|
||||
|
||||
- Should the HE-LTF subcarrier expansion ship in the default ADR-018 payload, or behind a runtime flag while the host aggregator catches up? **Tentative: behind a flag (default off) for v1, default on once `wifi-densepose-signal` knows about HE PPDUs.**
|
||||
- Should the 802.15.4 time-sync channel be configurable, or hard-coded to 15? **Tentative: NVS-configurable, default 15, validated at boot against a no-overlap policy with the WiFi channel.**
|
||||
- Does the rvCSI vendored submodule (ADR-097) want to grow an `rvcsi-adapter-esp32c6` crate to consume the HE-LTF frames natively? **Out of scope for this ADR; revisit in a follow-up.**
|
||||
@@ -50,7 +50,6 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
|
||||
| [ADR-040](ADR-040-wasm-programmable-sensing.md) | WASM Programmable Sensing (Tier 3) | Accepted |
|
||||
| [ADR-041](ADR-041-wasm-module-collection.md) | WASM Module Collection (65 edge modules) | Accepted (hardware-validated) |
|
||||
| [ADR-044](ADR-044-provisioning-tool-enhancements.md) | Provisioning Tool Enhancements | Proposed |
|
||||
| [ADR-110](ADR-110-esp32-c6-firmware-extension.md) | ESP32-C6 firmware extension — Wi-Fi 6 / 802.15.4 / TWT / LP-core | Accepted (firmware shipped, live capture hardware-blocked — see [`WITNESS-LOG-110`](../WITNESS-LOG-110.md)) |
|
||||
|
||||
### Signal processing and sensing
|
||||
|
||||
|
||||
@@ -1094,15 +1094,6 @@ An RVF file contains: model weights, HNSW vector index, quantization codebooks,
|
||||
|
||||
## Hardware Setup
|
||||
|
||||
### Supported targets
|
||||
|
||||
| Target | Use case | Source target flag | Notes |
|
||||
|---|---|---|---|
|
||||
| **ESP32-S3** (default) | Production CSI mesh, 17-keypoint pose | `idf.py set-target esp32s3` | Dual-core 240 MHz, PSRAM, native USB-OTG, DVP camera path |
|
||||
| **ESP32-C6** ([ADR-110](adr/ADR-110-esp32-c6-firmware-extension.md)) | Wi-Fi 6 / 802.15.4 research, battery seed nodes | `idf.py set-target esp32c6` | Single-core 160 MHz, no PSRAM, 802.11ax HE PHY, 802.15.4 (Thread/Zigbee), LP-core hibernation ~5 µA |
|
||||
|
||||
The same `firmware/esp32-csi-node` source tree builds for both. ESP-IDF picks up `sdkconfig.defaults.esp32c6` automatically when the target is set to `esp32c6`; otherwise it uses `sdkconfig.defaults` (S3). All C6-only modules are `#ifdef`-gated, so the S3 build is byte-identical to today.
|
||||
|
||||
### ESP32-S3 Mesh
|
||||
|
||||
A 3-6 node ESP32-S3 mesh provides full CSI at 20 Hz. Total cost: ~$54 for a 3-node setup.
|
||||
@@ -1164,56 +1155,6 @@ python firmware/esp32-csi-node/provision.py --port COM7 \
|
||||
|
||||
All nodes in a mesh must share the same 256-bit mesh key for HMAC-SHA256 beacon authentication. The key is stored in ESP32 NVS flash and zeroed on firmware erase.
|
||||
|
||||
### ESP32-C6 (Wi-Fi 6 + 802.15.4 research target — ADR-110)
|
||||
|
||||
The C6 build adds four capabilities to the existing csi-node firmware, all opt-in via `idf.py menuconfig → ESP32-C6 capabilities (ADR-110)`:
|
||||
|
||||
| Capability | Kconfig | What it does |
|
||||
|---|---|---|
|
||||
| **Wi-Fi 6 HE-LTF tagging** | `CSI_FRAME_HE_TAGGING` (default on) | Each ADR-018 frame's previously-reserved bytes 18-19 now carry PPDU type (HT / HE-SU / HE-MU / HE-TB) + bandwidth flags. Magic stays `0xC5110001` — old aggregators see zeros and ignore. |
|
||||
| **802.15.4 mesh time-sync** | `C6_TIMESYNC_ENABLE` (default on, channel 15) | Beacon-based cross-node clock alignment over the 802.15.4 radio. Frees the WiFi channel from coordination traffic — solves the ADR-029/030 multistatic clock-sync problem. |
|
||||
| **TWT (Target Wake Time)** | `C6_TWT_ENABLE` (default on, 10 ms wake interval) | After WiFi connect, negotiates an individual TWT agreement with the AP for deterministic CSI cadence. Graceful NACK fallback if the AP doesn't support 11ax TWT. |
|
||||
| **LP-core wake-on-motion hibernation** | `C6_LP_CORE_ENABLE` (default off) | Always-on motion gate on the LP RISC-V core; HP core stays in deep sleep until the configured GPIO wakes it. Targets ~5 µA for battery-powered Cognitum Seed nodes. |
|
||||
|
||||
**Build + flash:**
|
||||
|
||||
```bash
|
||||
cd firmware/esp32-csi-node
|
||||
idf.py set-target esp32c6
|
||||
idf.py build # ~1.0 MB binary, 46% partition slack on 4 MB flash
|
||||
idf.py -p COM6 flash
|
||||
# Then provision the same way as S3 (provision.py works for both targets):
|
||||
python provision.py --port COM6 --ssid "YourWiFi" --password "secret" --target-ip 192.168.1.20
|
||||
```
|
||||
|
||||
**Verifying the C6 modules came up** — `idf.py -p COM6 monitor` should show:
|
||||
|
||||
```
|
||||
I (353) main: ESP32-C6 CSI Node (ADR-018 / ADR-110) — v0.6.6 — Node ID: 1
|
||||
I (413) c6_ts: init done: channel=15 EUI=<your-EUI64> leader=yes(candidate)
|
||||
I (463) wifi: mac_version:HAL_MAC_ESP32AX_761 ← 802.11ax MAC firmware loaded
|
||||
```
|
||||
|
||||
The `c6_ts: init done` line confirms the 802.15.4 stack is up; if TWT succeeds you'll also see an `iTWT setup event received from AP` line after the WiFi connect completes.
|
||||
|
||||
**Multi-room time-aligned multistatic capture (preview):**
|
||||
|
||||
Flash two or more C6 boards, leave them on the same 802.15.4 channel (default 15). One will elect itself leader (lowest EUI-64) and broadcast `TS_BEACON` frames every 100 ms; the others compute and apply offsets. Each CSI frame from a follower carries a `c6_timesync_get_epoch_us()` wall-clock estimate aligned to within ±100 µs of the leader's monotonic time. Target use case: ADR-029/030 multistatic fusion without burning WiFi airtime on coordination.
|
||||
|
||||
**Battery seed-node mode:**
|
||||
|
||||
```bash
|
||||
# Enable LP-core hibernation in menuconfig:
|
||||
# ESP32-C6 capabilities (ADR-110) → Enable LP-core wake-on-motion hibernation
|
||||
# → LP-core wake GPIO (default 4 — connect a PIR or accelerometer INT line here)
|
||||
idf.py menuconfig
|
||||
idf.py build flash
|
||||
```
|
||||
|
||||
When enabled, the C6 boots, takes one CSI burst, then enters deep sleep with the LP-core armed. Target standby current ~5 µA.
|
||||
|
||||
**What's NOT on the C6 build** (vs S3 production): no AMOLED display (ADR-045 needs 8 MB + LCD touch driver), no WASM3 (ADR-040 needs PSRAM), no Seeed mmWave fusion (separate board). The C6 is a research/seed target, not a drop-in replacement for the S3 production node.
|
||||
|
||||
**TDM slot assignment:**
|
||||
|
||||
Each node in a multistatic mesh needs a unique TDM slot ID (0-based):
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# ESP32 CSI Node Firmware
|
||||
# ESP32-S3 CSI Node Firmware
|
||||
|
||||
**Turn a $7 microcontroller into a privacy-first human sensing node.**
|
||||
|
||||
This firmware captures WiFi Channel State Information (CSI) from an ESP32-S3 (production) or ESP32-C6 (research target — Wi-Fi 6 / 802.15.4 / TWT / LP-core hibernation, see [ADR-110](../../docs/adr/ADR-110-esp32-c6-firmware-extension.md)) and transforms it into real-time presence detection, vital sign monitoring, and programmable sensing -- all without cameras or wearables. Part of the [WiFi-DensePose](../../README.md) project.
|
||||
This firmware captures WiFi Channel State Information (CSI) from an ESP32-S3 and transforms it into real-time presence detection, vital sign monitoring, and programmable sensing -- all without cameras or wearables. Part of the [WiFi-DensePose](../../README.md) project.
|
||||
|
||||
[](https://docs.espressif.com/projects/esp-idf/en/v5.2/)
|
||||
[](https://www.espressif.com/en/products/socs/esp32-s3)
|
||||
[](https://www.espressif.com/en/products/socs/esp32-s3)
|
||||
[](../../LICENSE)
|
||||
[](#memory-budget)
|
||||
[](../../.github/workflows/firmware-ci.yml)
|
||||
|
||||
@@ -9,14 +9,6 @@ set(SRCS
|
||||
"rv_feature_state.c"
|
||||
"rv_mesh.c"
|
||||
"adaptive_controller.c"
|
||||
# ADR-110 — ESP32-C6 capability modules (no-op stubs on other targets via #ifdef)
|
||||
"c6_twt.c"
|
||||
"c6_timesync.c"
|
||||
"c6_lp_core.c"
|
||||
# ADR-110 D1 workaround — ESP-NOW cross-node sync (works on S3+C6)
|
||||
"c6_sync_espnow.c"
|
||||
# ADR-110 B1/B2 unblock — soft-AP HE/TWT (C6-only when enabled)
|
||||
"c6_softap_he.c"
|
||||
)
|
||||
|
||||
# ESP-IDF v6+: headers must resolve via explicit REQUIRES (no implicit deps).
|
||||
@@ -40,13 +32,6 @@ set(REQUIRES
|
||||
mbedtls
|
||||
)
|
||||
|
||||
# ADR-110: C6-only components — pulled in when building for esp32c6.
|
||||
# Note: CONFIG_* symbols are not available in main CMakeLists.txt evaluation —
|
||||
# we use the IDF_TARGET variable that idf.py sets from sdkconfig.defaults / set-target.
|
||||
if(IDF_TARGET STREQUAL "esp32c6")
|
||||
list(APPEND REQUIRES ieee802154 ulp esp_hw_support)
|
||||
endif()
|
||||
|
||||
# ADR-061: Mock CSI generator for QEMU testing + ADR-081 mock radio binding
|
||||
if(CONFIG_CSI_MOCK_ENABLED)
|
||||
list(APPEND SRCS "mock_csi.c" "rv_radio_ops_mock.c")
|
||||
@@ -67,15 +52,3 @@ idf_component_register(
|
||||
INCLUDE_DIRS "."
|
||||
REQUIRES ${REQUIRES}
|
||||
)
|
||||
|
||||
# ADR-110 P5 (full): embed the LP-core motion-gate program when enabled.
|
||||
# `ulp_embed_binary` compiles lp_core/main.c with the RISC-V LP toolchain
|
||||
# and links the resulting binary into the HP image, exposing shared symbols
|
||||
# via the auto-generated `ulp_main.h` header.
|
||||
if(IDF_TARGET STREQUAL "esp32c6" AND CONFIG_C6_LP_CORE_ENABLE)
|
||||
set(ulp_app_name ulp_main)
|
||||
set(ulp_sources "lp_core/main.c")
|
||||
# Source files in the HP component that include the generated ulp_main.h
|
||||
set(ulp_exp_dep_srcs "c6_lp_core.c")
|
||||
ulp_embed_binary(${ulp_app_name} "${ulp_sources}" "${ulp_exp_dep_srcs}")
|
||||
endif()
|
||||
|
||||
@@ -287,137 +287,6 @@ menu "WASM Programmable Sensing (ADR-040)"
|
||||
|
||||
endmenu
|
||||
|
||||
menu "ESP32-C6 capabilities (ADR-110)"
|
||||
depends on IDF_TARGET_ESP32C6
|
||||
|
||||
config C6_TWT_ENABLE
|
||||
bool "Enable TWT (Target Wake Time) negotiation"
|
||||
default y
|
||||
# SOC_WIFI_HE_SUPPORT is auto-set on chips with HE (Wi-Fi 6) PHY (C6/C5)
|
||||
depends on SOC_WIFI_HE_SUPPORT
|
||||
help
|
||||
After WiFi STA connect, request an individual TWT agreement
|
||||
with the AP for deterministic CSI cadence. Falls back
|
||||
gracefully if the AP doesn't support 11ax TWT.
|
||||
|
||||
config C6_TWT_WAKE_INTERVAL_US
|
||||
int "TWT wake interval (microseconds)"
|
||||
default 10000
|
||||
range 1024 1048576
|
||||
depends on C6_TWT_ENABLE
|
||||
help
|
||||
Period between TWT wake events. 10000 µs = 100 Hz CSI cadence.
|
||||
|
||||
config C6_TWT_MIN_WAKE_DURA_US
|
||||
int "TWT minimum wake duration (microseconds)"
|
||||
default 512
|
||||
range 256 16384
|
||||
depends on C6_TWT_ENABLE
|
||||
help
|
||||
Minimum awake duration per TWT wake. 512 µs is enough to
|
||||
capture one CSI frame.
|
||||
|
||||
config C6_TIMESYNC_ENABLE
|
||||
bool "Enable 802.15.4 mesh time-sync"
|
||||
default y
|
||||
depends on IEEE802154_ENABLED
|
||||
help
|
||||
Cross-node clock alignment over the 802.15.4 radio. Frees
|
||||
WiFi airtime from coordination traffic — relevant to
|
||||
ADR-029/030 multistatic sensing.
|
||||
|
||||
config C6_TIMESYNC_CHANNEL
|
||||
int "802.15.4 time-sync channel (11-26)"
|
||||
default 15
|
||||
range 11 26
|
||||
depends on C6_TIMESYNC_ENABLE
|
||||
|
||||
config C6_LP_CORE_ENABLE
|
||||
bool "Enable LP-core wake-on-motion hibernation"
|
||||
default n
|
||||
depends on ULP_COPROC_TYPE_LP_CORE
|
||||
help
|
||||
Arm the LP RISC-V coprocessor as an always-on motion gate
|
||||
in deep sleep. Targets ~5 µA hibernation for battery
|
||||
seed nodes. Requires a motion sensor on a wake-capable GPIO.
|
||||
|
||||
config C6_LP_WAKE_GPIO
|
||||
int "LP-core wake GPIO"
|
||||
default 4
|
||||
range 0 23
|
||||
depends on C6_LP_CORE_ENABLE
|
||||
|
||||
config C6_LP_WAKE_ACTIVE_HIGH
|
||||
bool "Wake on rising edge"
|
||||
default y
|
||||
depends on C6_LP_CORE_ENABLE
|
||||
|
||||
config C6_LP_POLL_PERIOD_US
|
||||
int "LP-core poll period (microseconds)"
|
||||
default 10000
|
||||
range 1000 1000000
|
||||
depends on C6_LP_CORE_ENABLE
|
||||
help
|
||||
How often the LP-core program reads the wake GPIO.
|
||||
10000 µs = 100 Hz. Lower values give faster response
|
||||
but increase the average LP-core duty cycle (and
|
||||
current). 10 ms is a good balance for PIR sensors.
|
||||
|
||||
config C6_LP_DEBOUNCE_SAMPLES
|
||||
int "LP-core debounce sample count"
|
||||
default 3
|
||||
range 1 32
|
||||
depends on C6_LP_CORE_ENABLE
|
||||
help
|
||||
How many consecutive matching GPIO reads are required
|
||||
before the LP-core wakes the HP core. 3 = ~30 ms at the
|
||||
default 10 ms poll period.
|
||||
|
||||
config C6_SOFTAP_HE_ENABLE
|
||||
bool "Run as Wi-Fi 6 soft-AP with TWT Responder (two-board bench)"
|
||||
default n
|
||||
depends on SOC_WIFI_HE_SUPPORT
|
||||
help
|
||||
When set, the C6 starts in AP+STA mode and advertises a
|
||||
soft-AP that announces HE (Wi-Fi 6) capability with
|
||||
TWT Responder=1. Lets a second C6 station-mode board
|
||||
negotiate a real iTWT agreement against a known-cooperative
|
||||
AP, unblocking ADR-110 §B1/B2 measurement without
|
||||
buying an 11ax router. SSID/PSK configured via NVS
|
||||
(keys `softap_ssid` / `softap_psk`) or the defaults below.
|
||||
|
||||
config C6_SOFTAP_HE_SSID
|
||||
string "Soft-AP SSID (when C6_SOFTAP_HE_ENABLE)"
|
||||
default "ruview-c6-twt"
|
||||
depends on C6_SOFTAP_HE_ENABLE
|
||||
|
||||
config C6_SOFTAP_HE_PSK
|
||||
string "Soft-AP WPA2 password (>= 8 chars)"
|
||||
default "ruviewtwt"
|
||||
depends on C6_SOFTAP_HE_ENABLE
|
||||
|
||||
config C6_SOFTAP_HE_CHANNEL
|
||||
int "Soft-AP channel (1-13)"
|
||||
default 6
|
||||
range 1 13
|
||||
depends on C6_SOFTAP_HE_ENABLE
|
||||
|
||||
endmenu
|
||||
|
||||
menu "ADR-018 frame extensions (ADR-110)"
|
||||
|
||||
config CSI_FRAME_HE_TAGGING
|
||||
bool "Tag ADR-018 frames with HE PPDU metadata"
|
||||
default y
|
||||
help
|
||||
When the WiFi driver reports an 802.11ax HE-SU/HE-MU/HE-TB
|
||||
PPDU, write the PPDU type + bandwidth into ADR-018 frame
|
||||
bytes 18-19 (previously reserved). Readers that don't know
|
||||
about this extension see the bytes as zero — fully
|
||||
backwards compatible.
|
||||
|
||||
endmenu
|
||||
|
||||
menu "Mock CSI (QEMU Testing)"
|
||||
config CSI_MOCK_ENABLED
|
||||
bool "Enable mock CSI generator (for QEMU testing)"
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
/**
|
||||
* @file c6_lp_core.c
|
||||
* @brief LP-core wake-on-motion hibernation — ADR-110 Phase 5 (full).
|
||||
*
|
||||
* Two operating modes, controlled by CONFIG_C6_LP_CORE_ENABLE:
|
||||
*
|
||||
* 1. ENABLED — real LP-core RISC-V program polls the wake GPIO at
|
||||
* LP_TIMER cadence (default 10 ms), debounces N matching samples,
|
||||
* and triggers an HP wake via `ulp_lp_core_wakeup_main_processor()`.
|
||||
* HP enters deep sleep with `ESP_SLEEP_WAKEUP_ULP` as the source.
|
||||
* Targets ~5 µA average current (datasheet figure for LP-core +
|
||||
* RTC peripherals powered down). The LP binary is built by
|
||||
* `ulp_embed_binary(...)` in main/CMakeLists.txt from lp_core/main.c.
|
||||
*
|
||||
* 2. DISABLED — falls back to plain deep-sleep + GPIO wake-up
|
||||
* (`esp_deep_sleep_enable_gpio_wakeup`). No debounce, no
|
||||
* sub-10 µA floor, but no LP toolchain dependency either.
|
||||
* This is the path the v0.6.6 firmware shipped with.
|
||||
*
|
||||
* Both paths share `c6_lp_core_arm()` / `c6_lp_core_hibernate_and_wait()`
|
||||
* so call sites in main.c don't change between modes.
|
||||
*/
|
||||
|
||||
#include "sdkconfig.h"
|
||||
|
||||
#if defined(CONFIG_IDF_TARGET_ESP32C6) && defined(CONFIG_ULP_COPROC_TYPE_LP_CORE)
|
||||
|
||||
#include "c6_lp_core.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_sleep.h"
|
||||
#include "driver/rtc_io.h"
|
||||
#include "soc/soc_caps.h"
|
||||
#include <string.h>
|
||||
|
||||
#if defined(CONFIG_C6_LP_CORE_ENABLE)
|
||||
#include "ulp_lp_core.h"
|
||||
/* ulp_main.h is auto-generated by `ulp_embed_binary(ulp_main, ...)` and
|
||||
* exports every `volatile` global from lp_core/main.c with the `ulp_`
|
||||
* prefix. Include is guarded so disabled builds don't try to find a
|
||||
* file the build system hasn't generated. */
|
||||
#include "ulp_main.h"
|
||||
extern const uint8_t ulp_main_bin_start[] asm("_binary_ulp_main_bin_start");
|
||||
extern const uint8_t ulp_main_bin_end[] asm("_binary_ulp_main_bin_end");
|
||||
#endif
|
||||
|
||||
static const char *TAG = "c6_lp";
|
||||
|
||||
static int s_wake_gpio = -1;
|
||||
static bool s_active_high = true;
|
||||
static bool s_armed = false;
|
||||
|
||||
#ifndef CONFIG_C6_LP_POLL_PERIOD_US
|
||||
#define CONFIG_C6_LP_POLL_PERIOD_US 10000 /* 100 Hz default poll cadence */
|
||||
#endif
|
||||
|
||||
#ifndef CONFIG_C6_LP_DEBOUNCE_SAMPLES
|
||||
#define CONFIG_C6_LP_DEBOUNCE_SAMPLES 3
|
||||
#endif
|
||||
|
||||
esp_err_t c6_lp_core_arm(int wake_gpio, bool active_high)
|
||||
{
|
||||
if (wake_gpio < 0) {
|
||||
ESP_LOGE(TAG, "invalid wake_gpio=%d", wake_gpio);
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
s_wake_gpio = wake_gpio;
|
||||
s_active_high = active_high;
|
||||
|
||||
/* GPIO must be in the LP/RTC domain for either wake path. */
|
||||
esp_err_t ret = rtc_gpio_init(wake_gpio);
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGE(TAG, "rtc_gpio_init(%d) failed: %s", wake_gpio, esp_err_to_name(ret));
|
||||
return ret;
|
||||
}
|
||||
rtc_gpio_set_direction(wake_gpio, RTC_GPIO_MODE_INPUT_ONLY);
|
||||
/* Floating inputs in deep sleep are an antenna — disable internal pulls
|
||||
* only if the user has an external pull on the motion line; we leave
|
||||
* default pulls so a disconnected pin doesn't toggle randomly. */
|
||||
|
||||
#if defined(CONFIG_C6_LP_CORE_ENABLE)
|
||||
/* --- Real LP-core path --- */
|
||||
|
||||
/* On C6, LP-IO maps 1:1 to GPIO for indices 0..7. Validate. */
|
||||
if (wake_gpio > 7) {
|
||||
ESP_LOGE(TAG, "LP-core path requires LP-IO 0..7, got GPIO %d", wake_gpio);
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
|
||||
/* Load the LP-core binary blob. */
|
||||
esp_err_t err = ulp_lp_core_load_binary(
|
||||
ulp_main_bin_start,
|
||||
(size_t)(ulp_main_bin_end - ulp_main_bin_start));
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "ulp_lp_core_load_binary failed: %s", esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
|
||||
/* Hand the GPIO parameters to the LP program via shared symbols.
|
||||
* These are declared `volatile` in lp_core/main.c so the HP write
|
||||
* is observed by LP on the next iteration. */
|
||||
ulp_wake_gpio_num = (uint32_t)wake_gpio;
|
||||
ulp_wake_active_high = active_high ? 1u : 0u;
|
||||
ulp_debounce_samples = CONFIG_C6_LP_DEBOUNCE_SAMPLES;
|
||||
ulp_motion_count = 0;
|
||||
ulp_poll_count = 0;
|
||||
ulp_last_gpio_level = 0;
|
||||
|
||||
/* Configure LP-timer wakeup at the configured poll period and start the
|
||||
* LP-core. `ulp_lp_core_run` is non-blocking; the LP core begins running
|
||||
* the program immediately and the HP core can proceed to deep sleep. */
|
||||
ulp_lp_core_cfg_t cfg = {
|
||||
.wakeup_source = ULP_LP_CORE_WAKEUP_SOURCE_LP_TIMER,
|
||||
.lp_timer_sleep_duration_us = CONFIG_C6_LP_POLL_PERIOD_US,
|
||||
};
|
||||
err = ulp_lp_core_run(&cfg);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "ulp_lp_core_run failed: %s", esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
|
||||
/* Tell deep-sleep that the LP-core is our wake source. */
|
||||
err = esp_sleep_enable_ulp_wakeup();
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "esp_sleep_enable_ulp_wakeup failed: %s", esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
|
||||
s_armed = true;
|
||||
ESP_LOGI(TAG, "LP-core armed: gpio=%d active_%s debounce=%d poll=%d µs",
|
||||
wake_gpio, active_high ? "high" : "low",
|
||||
CONFIG_C6_LP_DEBOUNCE_SAMPLES, CONFIG_C6_LP_POLL_PERIOD_US);
|
||||
return ESP_OK;
|
||||
|
||||
#else
|
||||
/* --- Fallback path: plain deep-sleep GPIO wakeup (~10 µA floor) --- */
|
||||
uint64_t mask = 1ULL << wake_gpio;
|
||||
esp_deepsleep_gpio_wake_up_mode_t mode = active_high
|
||||
? ESP_GPIO_WAKEUP_GPIO_HIGH
|
||||
: ESP_GPIO_WAKEUP_GPIO_LOW;
|
||||
esp_err_t err = esp_deep_sleep_enable_gpio_wakeup(mask, mode);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "enable_gpio_wakeup failed: %s", esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
s_armed = true;
|
||||
ESP_LOGI(TAG, "GPIO-wakeup armed (no LP-core): gpio=%d active_%s",
|
||||
wake_gpio, active_high ? "high" : "low");
|
||||
return ESP_OK;
|
||||
#endif
|
||||
}
|
||||
|
||||
void c6_lp_core_hibernate_and_wait(void)
|
||||
{
|
||||
if (!s_armed) {
|
||||
ESP_LOGW(TAG, "hibernate called without arm — sleeping with no wake source");
|
||||
}
|
||||
/* Power down the RTC peripheral domain — the LP-core itself stays
|
||||
* powered on the LP power domain so it can keep polling. */
|
||||
esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_OFF);
|
||||
|
||||
#if defined(CONFIG_C6_LP_CORE_ENABLE)
|
||||
ESP_LOGI(TAG, "entering deep sleep — LP-core polling, target ≤5 µA");
|
||||
#else
|
||||
ESP_LOGI(TAG, "entering deep sleep — GPIO wakeup, target ~10 µA");
|
||||
#endif
|
||||
esp_deep_sleep_start();
|
||||
/* Never returns. */
|
||||
}
|
||||
|
||||
bool c6_lp_core_was_motion_wake(void)
|
||||
{
|
||||
esp_sleep_wakeup_cause_t cause = esp_sleep_get_wakeup_cause();
|
||||
#if defined(CONFIG_C6_LP_CORE_ENABLE)
|
||||
/* Real LP-core path: wakeup cause is ULP (LP-core triggered HP). */
|
||||
if (cause == ESP_SLEEP_WAKEUP_ULP) return true;
|
||||
#endif
|
||||
/* Fallback path or alternate GPIO wakeup. */
|
||||
return cause == ESP_SLEEP_WAKEUP_GPIO || cause == ESP_SLEEP_WAKEUP_EXT1;
|
||||
}
|
||||
|
||||
#if defined(CONFIG_C6_LP_CORE_ENABLE)
|
||||
uint32_t c6_lp_core_motion_count(void)
|
||||
{
|
||||
return (uint32_t)ulp_motion_count;
|
||||
}
|
||||
|
||||
uint32_t c6_lp_core_poll_count(void)
|
||||
{
|
||||
return (uint32_t)ulp_poll_count;
|
||||
}
|
||||
#else
|
||||
uint32_t c6_lp_core_motion_count(void) { return 0; }
|
||||
uint32_t c6_lp_core_poll_count(void) { return 0; }
|
||||
#endif
|
||||
|
||||
#endif /* CONFIG_IDF_TARGET_ESP32C6 && CONFIG_ULP_COPROC_TYPE_LP_CORE */
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* @file c6_lp_core.h
|
||||
* @brief LP-core wake-on-motion hibernation helper — ADR-110 Phase 5.
|
||||
*
|
||||
* Arms the C6 LP RISC-V coprocessor as an always-on watchdog that
|
||||
* monitors a GPIO (typically a PIR or accelerometer interrupt line) and
|
||||
* wakes the HP core only when motion is detected. Targets ~5 µA
|
||||
* hibernation current for battery-powered Cognitum Seed nodes.
|
||||
*
|
||||
* Only built when CONFIG_IDF_TARGET_ESP32C6 + CONFIG_ULP_COPROC_TYPE_LP_CORE.
|
||||
*
|
||||
* P5 skeleton: the LP-core program is shipped as inline C compiled into
|
||||
* the main image. A follow-up turn migrates it to a separate
|
||||
* lp_core/main.c subproject with its own CMake.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#include "esp_err.h"
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
#if defined(CONFIG_IDF_TARGET_ESP32C6) && defined(CONFIG_ULP_COPROC_TYPE_LP_CORE)
|
||||
|
||||
/**
|
||||
* Configure the LP-core wake-on-motion watcher.
|
||||
*
|
||||
* @param wake_gpio GPIO pin to monitor (must be an RTC/LP-domain GPIO).
|
||||
* @param active_high true = wake on rising edge, false = falling.
|
||||
* @return ESP_OK on success.
|
||||
*/
|
||||
esp_err_t c6_lp_core_arm(int wake_gpio, bool active_high);
|
||||
|
||||
/**
|
||||
* Enter deep sleep with the LP-core armed as the wake source. Does not
|
||||
* return — the next boot will see ESP_SLEEP_WAKEUP_LP_CORE in
|
||||
* esp_sleep_get_wakeup_cause().
|
||||
*/
|
||||
void c6_lp_core_hibernate_and_wait(void);
|
||||
|
||||
/**
|
||||
* Returns true if the most recent boot was a wake from LP-core motion
|
||||
* detection (vs a cold boot or different wake source).
|
||||
*/
|
||||
bool c6_lp_core_was_motion_wake(void);
|
||||
|
||||
/**
|
||||
* Monotonic counter of wake-triggering motion events observed by the
|
||||
* LP-core program since the last cold boot. Returns 0 when
|
||||
* CONFIG_C6_LP_CORE_ENABLE is unset (fallback path).
|
||||
*/
|
||||
uint32_t c6_lp_core_motion_count(void);
|
||||
|
||||
/**
|
||||
* Total LP-timer poll iterations executed by the LP-core program.
|
||||
* Useful as a sanity check that the LP-core is actually running;
|
||||
* returns 0 on the fallback path.
|
||||
*/
|
||||
uint32_t c6_lp_core_poll_count(void);
|
||||
|
||||
#else
|
||||
|
||||
static inline esp_err_t c6_lp_core_arm(int g, bool h) { (void)g; (void)h; return ESP_OK; }
|
||||
static inline void c6_lp_core_hibernate_and_wait(void) { }
|
||||
static inline bool c6_lp_core_was_motion_wake(void) { return false; }
|
||||
static inline uint32_t c6_lp_core_motion_count(void) { return 0; }
|
||||
static inline uint32_t c6_lp_core_poll_count(void) { return 0; }
|
||||
|
||||
#endif
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -1,171 +0,0 @@
|
||||
/**
|
||||
* @file c6_softap_he.c
|
||||
* @brief ESP32-C6 soft-AP with HE/TWT — ADR-110 B1/B2 cheap-unblock.
|
||||
*
|
||||
* Pairs with c6_softap_he.h. Builds only when both targets are set:
|
||||
*
|
||||
* CONFIG_IDF_TARGET_ESP32C6 (selected by `idf.py set-target esp32c6`)
|
||||
* CONFIG_C6_SOFTAP_HE_ENABLE (Kconfig, default n)
|
||||
*
|
||||
* The IDF v5.4 soft-AP path advertises HE automatically on chips with
|
||||
* SOC_WIFI_HE_SUPPORT; the operator-side concern here is making sure
|
||||
* the beacon also advertises `TWT Responder=1` so a STA-side
|
||||
* `esp_wifi_sta_itwt_setup()` call doesn't bounce with `INVALID_ARG`
|
||||
* the same way it did against `ruv.net` (the bench's 11n-only AP).
|
||||
*
|
||||
* TWT Responder advertisement in IDF v5.4 is gated by
|
||||
* `wifi_he_ap_config_t.twt_responder = 1`. When the IDF header doesn't
|
||||
* expose that struct (older v5.3), the AP still comes up with HE but
|
||||
* without TWT Responder — we log a warning and continue so the build
|
||||
* stays portable.
|
||||
*/
|
||||
|
||||
#include "sdkconfig.h"
|
||||
|
||||
#if defined(CONFIG_IDF_TARGET_ESP32C6) && defined(CONFIG_C6_SOFTAP_HE_ENABLE)
|
||||
|
||||
#include "c6_softap_he.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "esp_wifi_types.h"
|
||||
#include "esp_event.h"
|
||||
#include "esp_netif.h"
|
||||
#include "nvs_flash.h"
|
||||
#include "nvs.h"
|
||||
#include <string.h>
|
||||
|
||||
static const char *TAG = "c6_softap";
|
||||
|
||||
static bool s_started = false;
|
||||
static uint8_t s_sta_count = 0;
|
||||
static uint8_t s_channel = 0;
|
||||
|
||||
#ifndef CONFIG_C6_SOFTAP_HE_SSID
|
||||
#define CONFIG_C6_SOFTAP_HE_SSID "ruview-c6-twt"
|
||||
#endif
|
||||
#ifndef CONFIG_C6_SOFTAP_HE_PSK
|
||||
#define CONFIG_C6_SOFTAP_HE_PSK "ruviewtwt"
|
||||
#endif
|
||||
#ifndef CONFIG_C6_SOFTAP_HE_CHANNEL
|
||||
#define CONFIG_C6_SOFTAP_HE_CHANNEL 6
|
||||
#endif
|
||||
|
||||
static void load_nvs_override(const char *key, char *dst, size_t dst_len)
|
||||
{
|
||||
nvs_handle_t h;
|
||||
if (nvs_open("ruview", NVS_READONLY, &h) != ESP_OK) return;
|
||||
size_t n = dst_len;
|
||||
esp_err_t err = nvs_get_str(h, key, dst, &n);
|
||||
if (err == ESP_OK) {
|
||||
ESP_LOGI(TAG, "nvs override: %s=\"%s\"", key, dst);
|
||||
}
|
||||
nvs_close(h);
|
||||
}
|
||||
|
||||
static uint8_t load_nvs_u8(const char *key, uint8_t fallback)
|
||||
{
|
||||
nvs_handle_t h;
|
||||
if (nvs_open("ruview", NVS_READONLY, &h) != ESP_OK) return fallback;
|
||||
uint8_t v = fallback;
|
||||
if (nvs_get_u8(h, key, &v) == ESP_OK) {
|
||||
ESP_LOGI(TAG, "nvs override: %s=%u", key, v);
|
||||
}
|
||||
nvs_close(h);
|
||||
return v;
|
||||
}
|
||||
|
||||
static void on_wifi_event(void *arg, esp_event_base_t base,
|
||||
int32_t event_id, void *event_data)
|
||||
{
|
||||
(void)arg; (void)base; (void)event_data;
|
||||
switch (event_id) {
|
||||
case WIFI_EVENT_AP_START:
|
||||
s_started = true;
|
||||
ESP_LOGI(TAG, "AP started on channel %u", s_channel);
|
||||
break;
|
||||
case WIFI_EVENT_AP_STOP:
|
||||
s_started = false;
|
||||
ESP_LOGI(TAG, "AP stopped");
|
||||
break;
|
||||
case WIFI_EVENT_AP_STACONNECTED:
|
||||
if (s_sta_count < 255) s_sta_count++;
|
||||
ESP_LOGI(TAG, "STA connected — total=%u", s_sta_count);
|
||||
break;
|
||||
case WIFI_EVENT_AP_STADISCONNECTED:
|
||||
if (s_sta_count > 0) s_sta_count--;
|
||||
ESP_LOGI(TAG, "STA disconnected — total=%u", s_sta_count);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t c6_softap_he_start(uint8_t *out_channel)
|
||||
{
|
||||
if (s_started) {
|
||||
if (out_channel) *out_channel = s_channel;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* Resolve config: NVS overrides Kconfig defaults. */
|
||||
char ssid[33] = CONFIG_C6_SOFTAP_HE_SSID;
|
||||
char psk[64] = CONFIG_C6_SOFTAP_HE_PSK;
|
||||
load_nvs_override("softap_ssid", ssid, sizeof(ssid));
|
||||
load_nvs_override("softap_psk", psk, sizeof(psk));
|
||||
s_channel = load_nvs_u8("softap_chan", CONFIG_C6_SOFTAP_HE_CHANNEL);
|
||||
if (s_channel < 1 || s_channel > 13) s_channel = CONFIG_C6_SOFTAP_HE_CHANNEL;
|
||||
|
||||
/* AP+STA so the existing STA path keeps working (NVS-provisioned upstream). */
|
||||
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_APSTA));
|
||||
|
||||
wifi_config_t ap_cfg = {0};
|
||||
size_t ssid_len = strlen(ssid);
|
||||
if (ssid_len > 32) ssid_len = 32;
|
||||
memcpy(ap_cfg.ap.ssid, ssid, ssid_len);
|
||||
ap_cfg.ap.ssid_len = (uint8_t)ssid_len;
|
||||
strncpy((char *)ap_cfg.ap.password, psk, sizeof(ap_cfg.ap.password) - 1);
|
||||
ap_cfg.ap.channel = s_channel;
|
||||
ap_cfg.ap.max_connection = 4;
|
||||
ap_cfg.ap.authmode = strlen(psk) >= 8 ? WIFI_AUTH_WPA2_PSK : WIFI_AUTH_OPEN;
|
||||
ap_cfg.ap.beacon_interval = 100;
|
||||
/* pmf_cfg.required = false keeps backward compatibility for STA clients
|
||||
* that don't speak PMF. */
|
||||
ap_cfg.ap.pmf_cfg.required = false;
|
||||
|
||||
/* Register the event handler before bringing the AP up so we don't
|
||||
* miss WIFI_EVENT_AP_START. */
|
||||
ESP_ERROR_CHECK(esp_event_handler_instance_register(
|
||||
WIFI_EVENT, ESP_EVENT_ANY_ID, on_wifi_event, NULL, NULL));
|
||||
|
||||
esp_err_t err = esp_wifi_set_config(WIFI_IF_AP, &ap_cfg);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "set_config(AP) failed: %s", esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
|
||||
/* On IDF v5.4 with SOC_WIFI_HE_SUPPORT, HE advertisement is automatic
|
||||
* once the AP is started in HE-capable mode. TWT Responder advertise
|
||||
* is automatic when the AP is on an HE-capable channel and the IDF
|
||||
* SOC config has SOC_WIFI_HE_SUPPORT — verified by sniffing the beacon
|
||||
* and confirming `TWT Responder=1`. If a future IDF exposes
|
||||
* `esp_wifi_ap_set_he_config()` or similar, hook it here.
|
||||
*
|
||||
* Empirically against IDF v5.4 / C6 silicon: the beacon advertises
|
||||
* HE capability when the band is 2.4 GHz and the AP is on an
|
||||
* 11ax-capable channel, and TWT Responder follows. */
|
||||
ESP_LOGI(TAG, "soft-AP starting: ssid=\"%s\" channel=%u auth=%s",
|
||||
ssid, s_channel,
|
||||
ap_cfg.ap.authmode == WIFI_AUTH_OPEN ? "open" : "wpa2-psk");
|
||||
|
||||
/* Don't call esp_wifi_start() here — main.c brings the WiFi up once
|
||||
* for both AP and STA. We just configured the AP iface so it joins
|
||||
* the existing start. */
|
||||
|
||||
if (out_channel) *out_channel = s_channel;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
bool c6_softap_he_is_up(void) { return s_started; }
|
||||
uint8_t c6_softap_he_sta_count(void) { return s_sta_count; }
|
||||
|
||||
#endif /* CONFIG_IDF_TARGET_ESP32C6 && CONFIG_C6_SOFTAP_HE_ENABLE */
|
||||
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* @file c6_softap_he.h
|
||||
* @brief ESP32-C6 soft-AP with Wi-Fi 6 (HE) capability + TWT Responder.
|
||||
*
|
||||
* ADR-110 §B1/B2 cheap-unblock: turn one C6 board into the iTWT-capable
|
||||
* AP that the C6-DevKit-on-the-shelf-only bench is missing. A second C6
|
||||
* board in STA mode can then negotiate a real iTWT agreement against
|
||||
* this AP and measure deterministic CSI cadence — without buying an
|
||||
* 11ax router.
|
||||
*
|
||||
* Build-gated by CONFIG_C6_SOFTAP_HE_ENABLE (default n). When disabled,
|
||||
* all functions become no-ops so non-AP firmwares pay zero overhead.
|
||||
*
|
||||
* NVS overrides (read at boot if present, fall back to Kconfig defaults):
|
||||
* softap_ssid (string, up to 32 chars)
|
||||
* softap_psk (string, 8..63 chars)
|
||||
* softap_chan (u8, 1..13)
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#include "esp_err.h"
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
#if defined(CONFIG_IDF_TARGET_ESP32C6) && defined(CONFIG_C6_SOFTAP_HE_ENABLE)
|
||||
|
||||
/**
|
||||
* Bring up the soft-AP in AP+STA mode with HE (Wi-Fi 6) advertised and
|
||||
* TWT Responder=1 if the IDF build supports it. Idempotent — safe to
|
||||
* call once during boot after `esp_wifi_init()`. Returns the channel
|
||||
* the AP is actually running on (may differ from Kconfig if the IDF
|
||||
* scanner picks a clearer channel).
|
||||
*/
|
||||
esp_err_t c6_softap_he_start(uint8_t *out_channel);
|
||||
|
||||
/**
|
||||
* True after the IDF reports the AP has started successfully.
|
||||
*/
|
||||
bool c6_softap_he_is_up(void);
|
||||
|
||||
/**
|
||||
* Number of currently associated stations (read-only, refreshed on the
|
||||
* WIFI_EVENT_AP_STACONNECTED/DISCONNECTED events).
|
||||
*/
|
||||
uint8_t c6_softap_he_sta_count(void);
|
||||
|
||||
#else /* disabled — no-op stubs */
|
||||
|
||||
static inline esp_err_t c6_softap_he_start(uint8_t *out_channel)
|
||||
{
|
||||
if (out_channel) *out_channel = 0;
|
||||
return ESP_OK;
|
||||
}
|
||||
static inline bool c6_softap_he_is_up(void) { return false; }
|
||||
static inline uint8_t c6_softap_he_sta_count(void) { return 0; }
|
||||
|
||||
#endif
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -1,208 +0,0 @@
|
||||
/**
|
||||
* @file c6_sync_espnow.c
|
||||
* @brief ESP-NOW cross-node time-sync — ADR-110 D1 workaround.
|
||||
*
|
||||
* Same protocol as c6_timesync.c (TS_BEACON every 100 ms with leader epoch),
|
||||
* but over ESP-NOW instead of 802.15.4 because the IDF v5.4 ieee802154 RX
|
||||
* path doesn't deliver frames to user-space (see WITNESS-LOG-110 §D1).
|
||||
*
|
||||
* Frame layout (16 bytes payload, broadcast MAC FF:FF:FF:FF:FF:FF):
|
||||
* [0..3] Magic 0x53454E50 ('SENP' — Sync via ESP-NOW)
|
||||
* [4] Protocol ver 0x01
|
||||
* [5] Leader flag 1 if sender claims leader
|
||||
* [6..7] Reserved
|
||||
* [8..15] Leader epoch µs (LE u64)
|
||||
*/
|
||||
|
||||
#include "sdkconfig.h"
|
||||
#include "c6_sync_espnow.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_now.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "esp_mac.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/timers.h"
|
||||
#include <string.h>
|
||||
|
||||
static const char *TAG = "c6_espnow";
|
||||
|
||||
#define BEACON_MAGIC 0x53454E50u /* 'SENP' little-endian */
|
||||
#define BEACON_PROTO_VER 0x01
|
||||
#define BEACON_PERIOD_MS 100
|
||||
#define VALID_WINDOW_MS 3000
|
||||
|
||||
typedef struct __attribute__((packed)) {
|
||||
uint32_t magic;
|
||||
uint8_t proto_ver;
|
||||
uint8_t leader_flag;
|
||||
uint16_t _reserved;
|
||||
uint64_t leader_epoch_us;
|
||||
} espnow_beacon_t;
|
||||
|
||||
static const uint8_t s_broadcast_mac[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
|
||||
|
||||
static uint64_t s_local_id = 0; /* 6-byte MAC packed into u64 */
|
||||
static uint64_t s_leader_id = 0;
|
||||
static int64_t s_offset_us = 0;
|
||||
static uint64_t s_last_seen_us = 0;
|
||||
static bool s_is_leader = false;
|
||||
static TimerHandle_t s_beacon_timer = NULL;
|
||||
|
||||
static uint32_t s_tx_count = 0;
|
||||
static uint32_t s_tx_fail = 0;
|
||||
static uint32_t s_rx_count = 0;
|
||||
static uint32_t s_rx_magic_match = 0;
|
||||
|
||||
static uint64_t mac6_to_u64(const uint8_t mac[6])
|
||||
{
|
||||
return ((uint64_t)mac[0] << 40) | ((uint64_t)mac[1] << 32) |
|
||||
((uint64_t)mac[2] << 24) | ((uint64_t)mac[3] << 16) |
|
||||
((uint64_t)mac[4] << 8) | (uint64_t)mac[5];
|
||||
}
|
||||
|
||||
static void send_beacon(void)
|
||||
{
|
||||
espnow_beacon_t b = {
|
||||
.magic = BEACON_MAGIC,
|
||||
.proto_ver = BEACON_PROTO_VER,
|
||||
.leader_flag = s_is_leader ? 1 : 0,
|
||||
._reserved = 0,
|
||||
.leader_epoch_us = (uint64_t)esp_timer_get_time(),
|
||||
};
|
||||
esp_err_t r = esp_now_send(s_broadcast_mac, (uint8_t *)&b, sizeof(b));
|
||||
s_tx_count++;
|
||||
if (r != ESP_OK) s_tx_fail++;
|
||||
/* Diag log every 50 beacons. */
|
||||
if ((s_tx_count % 50) == 1) {
|
||||
ESP_LOGI(TAG, "tx#%lu (fail=%lu) rx#%lu (match=%lu) leader=%d offset_us=%lld",
|
||||
(unsigned long)s_tx_count, (unsigned long)s_tx_fail,
|
||||
(unsigned long)s_rx_count, (unsigned long)s_rx_magic_match,
|
||||
(int)s_is_leader, (long long)s_offset_us);
|
||||
}
|
||||
}
|
||||
|
||||
/* IDF v5.4 ESP-NOW recv callback signature uses esp_now_recv_info_t.
|
||||
* Falls back to the older signature on older IDF via ifdef. */
|
||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 0, 0)
|
||||
static void on_recv(const esp_now_recv_info_t *info,
|
||||
const uint8_t *data, int len)
|
||||
{
|
||||
const uint8_t *src_mac = info ? info->src_addr : NULL;
|
||||
#else
|
||||
static void on_recv(const uint8_t *src_mac, const uint8_t *data, int len)
|
||||
{
|
||||
#endif
|
||||
s_rx_count++;
|
||||
if (data == NULL || len < (int)sizeof(espnow_beacon_t)) return;
|
||||
const espnow_beacon_t *b = (const espnow_beacon_t *)data;
|
||||
if (b->magic != BEACON_MAGIC || b->proto_ver != BEACON_PROTO_VER) return;
|
||||
s_rx_magic_match++;
|
||||
uint64_t sender_id = src_mac ? mac6_to_u64(src_mac) : 0;
|
||||
uint64_t now_us = (uint64_t)esp_timer_get_time();
|
||||
|
||||
/* Adopt sender as leader if it's claiming leadership AND its ID is
|
||||
* lower than our current leader (or we have no leader). Lowest MAC
|
||||
* wins — deterministic. */
|
||||
if (b->leader_flag && (s_leader_id == 0 || sender_id < s_leader_id)) {
|
||||
if (s_is_leader && sender_id < s_local_id) {
|
||||
ESP_LOGI(TAG, "stepping down: heard lower-id leader %012llx (we are %012llx)",
|
||||
(unsigned long long)sender_id, (unsigned long long)s_local_id);
|
||||
s_is_leader = false;
|
||||
}
|
||||
s_leader_id = sender_id;
|
||||
}
|
||||
|
||||
/* If accepted leader, compute offset from their epoch (only for non-leader). */
|
||||
if (b->leader_flag && !s_is_leader && sender_id == s_leader_id) {
|
||||
s_offset_us = (int64_t)b->leader_epoch_us - (int64_t)now_us;
|
||||
s_last_seen_us = now_us;
|
||||
}
|
||||
}
|
||||
|
||||
static void on_send(const uint8_t *mac, esp_now_send_status_t status)
|
||||
{
|
||||
(void)mac;
|
||||
if (status != ESP_NOW_SEND_SUCCESS) s_tx_fail++;
|
||||
}
|
||||
|
||||
static void beacon_timer_cb(TimerHandle_t t)
|
||||
{
|
||||
(void)t;
|
||||
uint64_t now = (uint64_t)esp_timer_get_time();
|
||||
/* Promote self if no leader beacon for VALID_WINDOW_MS and we have lowest known id. */
|
||||
if (!s_is_leader && (now - s_last_seen_us) > (VALID_WINDOW_MS * 1000ULL)) {
|
||||
if (s_leader_id == 0 || s_local_id < s_leader_id) {
|
||||
s_is_leader = true;
|
||||
s_leader_id = s_local_id;
|
||||
s_offset_us = 0;
|
||||
ESP_LOGI(TAG, "promoting self to leader (no beacons for %u ms; local_id=%012llx)",
|
||||
(unsigned)VALID_WINDOW_MS, (unsigned long long)s_local_id);
|
||||
}
|
||||
}
|
||||
send_beacon();
|
||||
}
|
||||
|
||||
esp_err_t c6_sync_espnow_init(void)
|
||||
{
|
||||
uint8_t mac[6];
|
||||
esp_read_mac(mac, ESP_MAC_WIFI_STA);
|
||||
s_local_id = mac6_to_u64(mac);
|
||||
|
||||
esp_err_t r = esp_now_init();
|
||||
if (r != ESP_OK) {
|
||||
ESP_LOGE(TAG, "esp_now_init failed: %s", esp_err_to_name(r));
|
||||
return r;
|
||||
}
|
||||
esp_now_register_recv_cb(on_recv);
|
||||
esp_now_register_send_cb(on_send);
|
||||
|
||||
/* Add broadcast peer so esp_now_send to FF:FF:FF:FF:FF:FF works. */
|
||||
esp_now_peer_info_t peer = {0};
|
||||
memcpy(peer.peer_addr, s_broadcast_mac, 6);
|
||||
peer.channel = 0; /* current STA channel */
|
||||
peer.ifidx = WIFI_IF_STA;
|
||||
peer.encrypt = false;
|
||||
r = esp_now_add_peer(&peer);
|
||||
if (r != ESP_OK && r != ESP_ERR_ESPNOW_EXIST) {
|
||||
ESP_LOGW(TAG, "esp_now_add_peer(broadcast) failed: %s", esp_err_to_name(r));
|
||||
}
|
||||
|
||||
/* Start as candidate leader — will step down on receiving lower-id beacon. */
|
||||
s_is_leader = true;
|
||||
s_leader_id = s_local_id;
|
||||
s_last_seen_us = (uint64_t)esp_timer_get_time();
|
||||
|
||||
s_beacon_timer = xTimerCreate("c6_espnow_beacon",
|
||||
pdMS_TO_TICKS(BEACON_PERIOD_MS),
|
||||
pdTRUE, NULL, beacon_timer_cb);
|
||||
if (s_beacon_timer == NULL) {
|
||||
ESP_LOGE(TAG, "xTimerCreate failed");
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
xTimerStart(s_beacon_timer, 0);
|
||||
|
||||
ESP_LOGI(TAG, "init done: local_id=%012llx leader=yes(candidate) period=%ums",
|
||||
(unsigned long long)s_local_id, (unsigned)BEACON_PERIOD_MS);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
uint64_t c6_sync_espnow_get_epoch_us(void)
|
||||
{
|
||||
return (uint64_t)((int64_t)esp_timer_get_time() + s_offset_us);
|
||||
}
|
||||
|
||||
bool c6_sync_espnow_is_leader(void) { return s_is_leader; }
|
||||
int64_t c6_sync_espnow_get_offset_us(void) { return s_offset_us; }
|
||||
|
||||
bool c6_sync_espnow_is_valid(void)
|
||||
{
|
||||
if (s_is_leader) return true;
|
||||
uint64_t now = (uint64_t)esp_timer_get_time();
|
||||
return (now - s_last_seen_us) < (VALID_WINDOW_MS * 1000ULL);
|
||||
}
|
||||
|
||||
uint32_t c6_sync_espnow_tx_count(void) { return s_tx_count; }
|
||||
uint32_t c6_sync_espnow_tx_fail(void) { return s_tx_fail; }
|
||||
uint32_t c6_sync_espnow_rx_count(void) { return s_rx_count; }
|
||||
uint32_t c6_sync_espnow_rx_magic_match(void) { return s_rx_magic_match; }
|
||||
@@ -1,59 +0,0 @@
|
||||
/**
|
||||
* @file c6_sync_espnow.h
|
||||
* @brief ESP-NOW based cross-node time-sync — ADR-110 D1 workaround.
|
||||
*
|
||||
* After 4 systematic experiments confirmed the 802.15.4 RX path is broken
|
||||
* in this user-code + IDF v5.4 combination (see WITNESS-LOG-110 §D1), the
|
||||
* cross-node sync claim was unblocked by switching transport from IEEE
|
||||
* 802.15.4 to ESP-NOW (WiFi-based peer-to-peer, runs on the same 2.4 GHz
|
||||
* radio but uses the WiFi MAC layer that ESP-IDF's 802.11 driver fully
|
||||
* supports).
|
||||
*
|
||||
* Trade vs. 802.15.4:
|
||||
* - Loses the "frees WiFi airtime for CSI" property (uses WiFi for sync)
|
||||
* - Gains a known-working RX path on every ESP32 family
|
||||
* - Same API surface (epoch_us, is_valid, is_leader) so call sites that
|
||||
* used to depend on c6_timesync drop in unchanged
|
||||
*
|
||||
* Works on both ESP32-S3 and ESP32-C6 — the cross-node sync becomes a
|
||||
* cross-target feature, not C6-only.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#include "esp_err.h"
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
/**
|
||||
* Initialize the ESP-NOW sync module. Must be called AFTER WiFi STA is
|
||||
* connected (ESP-NOW needs the WiFi driver active).
|
||||
*
|
||||
* @return ESP_OK on success.
|
||||
*/
|
||||
esp_err_t c6_sync_espnow_init(void);
|
||||
|
||||
/**
|
||||
* Returns the synced wall-clock estimate in microseconds.
|
||||
* If no leader heard within the timeout, returns the local
|
||||
* esp_timer_get_time() value unchanged (offset = 0).
|
||||
*/
|
||||
uint64_t c6_sync_espnow_get_epoch_us(void);
|
||||
|
||||
bool c6_sync_espnow_is_leader(void);
|
||||
bool c6_sync_espnow_is_valid(void);
|
||||
int64_t c6_sync_espnow_get_offset_us(void);
|
||||
|
||||
/* Counters for the witness harness — exposed for tests/diagnostics. */
|
||||
uint32_t c6_sync_espnow_tx_count(void);
|
||||
uint32_t c6_sync_espnow_tx_fail(void);
|
||||
uint32_t c6_sync_espnow_rx_count(void);
|
||||
uint32_t c6_sync_espnow_rx_magic_match(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -1,265 +0,0 @@
|
||||
/**
|
||||
* @file c6_timesync.c
|
||||
* @brief 802.15.4 mesh time-sync skeleton — ADR-110 Phase 4.
|
||||
*
|
||||
* P4 ships the API surface, role election, and the leader-broadcast +
|
||||
* follower-receive paths using esp_ieee802154 raw frames. Full
|
||||
* OpenThread MTD attachment with a real network key is deferred to a
|
||||
* follow-up turn — the skeleton already exercises the radio init and
|
||||
* the offset-tracking math.
|
||||
*
|
||||
* Beacon frame layout (12 bytes payload + 802.15.4 MAC header):
|
||||
* [0..3] Magic 0x54534D45 ('TSME' — Time Sync MEsh)
|
||||
* [4] Protocol ver 0x01
|
||||
* [5] Leader flag 1 if sender is current leader
|
||||
* [6..7] Reserved
|
||||
* [8..15] Leader epoch µs (LE u64)
|
||||
*/
|
||||
|
||||
#include "sdkconfig.h"
|
||||
|
||||
#if defined(CONFIG_IDF_TARGET_ESP32C6) && defined(CONFIG_IEEE802154_ENABLED)
|
||||
|
||||
#include "c6_timesync.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_mac.h"
|
||||
#include "esp_timer.h"
|
||||
#include "esp_ieee802154.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "freertos/timers.h"
|
||||
#include <string.h>
|
||||
|
||||
static const char *TAG = "c6_ts";
|
||||
|
||||
#define TS_MAGIC 0x54534D45u
|
||||
#define TS_PROTO_VER 0x01
|
||||
#define TS_BEACON_MS 100
|
||||
#define TS_VALID_WINDOW_MS 3000 /* drop to invalid if no beacon in 3 s */
|
||||
|
||||
typedef struct __attribute__((packed)) {
|
||||
uint32_t magic;
|
||||
uint8_t proto_ver;
|
||||
uint8_t leader_flag;
|
||||
uint16_t _reserved;
|
||||
uint64_t leader_epoch_us;
|
||||
} ts_beacon_t;
|
||||
|
||||
static uint64_t s_local_eui = 0;
|
||||
static uint64_t s_leader_eui = 0; /* 0 = unknown */
|
||||
static int64_t s_offset_us = 0; /* leader_us - local_us */
|
||||
static uint64_t s_last_seen_us = 0;
|
||||
static bool s_is_leader = false;
|
||||
static uint8_t s_channel = 15;
|
||||
static TimerHandle_t s_beacon_timer = NULL;
|
||||
|
||||
/* IEEE EUI-64 from a 6-byte MAC-48: insert 0xFFFE between bytes 2 and 3.
|
||||
* Used only as a fallback when esp_read_mac(..., ESP_MAC_IEEE802154) is
|
||||
* unavailable. The C6's native call returns 8 bytes already in EUI-64
|
||||
* format, so prefer that path (see c6_timesync_init). */
|
||||
static uint64_t mac48_to_eui64(const uint8_t mac[6])
|
||||
{
|
||||
return ((uint64_t)mac[0] << 56) | ((uint64_t)mac[1] << 48) |
|
||||
((uint64_t)mac[2] << 40) | ((uint64_t)0xFF << 32) |
|
||||
((uint64_t)0xFE << 24) | ((uint64_t)mac[3] << 16) |
|
||||
((uint64_t)mac[4] << 8 ) | (uint64_t)mac[5];
|
||||
}
|
||||
|
||||
/* Pack 8 already-EUI-64 bytes into a uint64. */
|
||||
static uint64_t eui64_bytes_to_u64(const uint8_t eui[8])
|
||||
{
|
||||
return ((uint64_t)eui[0] << 56) | ((uint64_t)eui[1] << 48) |
|
||||
((uint64_t)eui[2] << 40) | ((uint64_t)eui[3] << 32) |
|
||||
((uint64_t)eui[4] << 24) | ((uint64_t)eui[5] << 16) |
|
||||
((uint64_t)eui[6] << 8 ) | (uint64_t)eui[7];
|
||||
}
|
||||
|
||||
static uint32_t s_tx_count = 0;
|
||||
static uint32_t s_tx_fail = 0;
|
||||
static uint32_t s_rx_count = 0;
|
||||
static uint32_t s_rx_magic_match = 0;
|
||||
|
||||
static void send_beacon(void)
|
||||
{
|
||||
uint8_t frame[32];
|
||||
/* Minimal 802.15.4 MAC header: FCF + seq + dst PAN + dst short addr. */
|
||||
frame[0] = 0x41; /* FCF lo: data frame, no security, no ack */
|
||||
frame[1] = 0x88; /* FCF hi: short addrs, intra-PAN */
|
||||
frame[2] = 0x00; /* seq number — placeholder */
|
||||
/* Empirically (rx#0 over 60s on all 3 boards), the IDF v5.4 receiver
|
||||
* was rejecting the dst-PAN-broadcast (0xFFFF) frames even in
|
||||
* promiscuous mode. Match our configured PAN ID 0xCAFE here — short
|
||||
* dst stays 0xFFFF for intra-PAN broadcast. PAN bytes are LE. */
|
||||
frame[3] = 0xFE; frame[4] = 0xCA; /* dst PAN = 0xCAFE (matches local) */
|
||||
frame[5] = 0xFF; frame[6] = 0xFF; /* dst short broadcast */
|
||||
frame[7] = 0x00; frame[8] = 0x00; /* src short = 0x0000 */
|
||||
ts_beacon_t *b = (ts_beacon_t *)&frame[9];
|
||||
b->magic = TS_MAGIC;
|
||||
b->proto_ver = TS_PROTO_VER;
|
||||
b->leader_flag = 1;
|
||||
b->_reserved = 0;
|
||||
b->leader_epoch_us = (uint64_t)esp_timer_get_time();
|
||||
size_t total = 9 + sizeof(ts_beacon_t);
|
||||
/* ESP-IDF esp_ieee802154 transmit: first byte is the PHY length. */
|
||||
uint8_t tx_buf[64];
|
||||
tx_buf[0] = (uint8_t)(total + 2); /* +2 for FCS appended by HW */
|
||||
memcpy(&tx_buf[1], frame, total);
|
||||
esp_err_t r = esp_ieee802154_transmit(tx_buf, false);
|
||||
s_tx_count++;
|
||||
if (r != ESP_OK) s_tx_fail++;
|
||||
/* Diag log every 10 beacons. */
|
||||
if ((s_tx_count % 10) == 1) {
|
||||
ESP_LOGI(TAG, "tx#%lu (fail=%lu) rx#%lu (magic_match=%lu) is_leader=%d",
|
||||
(unsigned long)s_tx_count, (unsigned long)s_tx_fail,
|
||||
(unsigned long)s_rx_count, (unsigned long)s_rx_magic_match,
|
||||
(int)s_is_leader);
|
||||
}
|
||||
}
|
||||
|
||||
/* KNOWN ISSUE (see WITNESS-LOG-110 §D1 / task #30):
|
||||
* Empirically observed on 3 C6 boards with channel=26, OpenThread disabled,
|
||||
* promiscuous=true, and IDF v5.4 reference RX/TX callback pattern: only 1
|
||||
* RX event ever fires after init, despite ~381 successful TX events from
|
||||
* the other boards in the same 38-second window. Manual re-arm with
|
||||
* esp_ieee802154_receive() in either callback context bootloops the
|
||||
* driver. Hypothesis: half-duplex radio + driver state-machine issue;
|
||||
* needs an IDF maintainer trace or a working multi-board reference.
|
||||
* Cross-node sync claim (ADR-110 §B3) is BLOCKED on this. */
|
||||
void esp_ieee802154_receive_done(uint8_t *frame, esp_ieee802154_frame_info_t *frame_info)
|
||||
{
|
||||
s_rx_count++;
|
||||
/* PHY length is frame[0]; payload starts at frame[1]. */
|
||||
if (frame == NULL || frame[0] < (9 + sizeof(ts_beacon_t) + 2)) {
|
||||
if (frame) esp_ieee802154_receive_handle_done(frame);
|
||||
return;
|
||||
}
|
||||
const ts_beacon_t *b = (const ts_beacon_t *)&frame[1 + 9];
|
||||
if (b->magic != TS_MAGIC || b->proto_ver != TS_PROTO_VER) {
|
||||
esp_ieee802154_receive_handle_done(frame);
|
||||
return;
|
||||
}
|
||||
s_rx_magic_match++;
|
||||
uint64_t now = (uint64_t)esp_timer_get_time();
|
||||
if (b->leader_flag) {
|
||||
/* Adopt this leader if its EUI is lower than ours (or unknown). */
|
||||
if (s_leader_eui == 0 || b->leader_epoch_us > 0) {
|
||||
s_offset_us = (int64_t)b->leader_epoch_us - (int64_t)now;
|
||||
s_last_seen_us = now;
|
||||
if (s_is_leader) {
|
||||
/* Step down — somebody else is broadcasting; lowest EUI wins
|
||||
* (deferred — for now last-heard wins). */
|
||||
s_is_leader = false;
|
||||
ESP_LOGI(TAG, "stepping down — heard another leader beacon");
|
||||
}
|
||||
}
|
||||
}
|
||||
/* handle_done auto-restarts RX in the IDF driver; calling
|
||||
* esp_ieee802154_receive() here would double-arm and panic
|
||||
* (verified empirically — 25 reboot loops observed). */
|
||||
esp_ieee802154_receive_handle_done(frame);
|
||||
}
|
||||
|
||||
void esp_ieee802154_transmit_done(const uint8_t *frame,
|
||||
const uint8_t *ack,
|
||||
esp_ieee802154_frame_info_t *ack_frame_info)
|
||||
{
|
||||
(void)frame; (void)ack; (void)ack_frame_info;
|
||||
/* Note: do NOT call esp_ieee802154_receive() here — it panics the
|
||||
* driver (verified empirically, all 3 boards bootloop). The IDF
|
||||
* driver internally manages RX/TX state transitions. */
|
||||
}
|
||||
|
||||
void esp_ieee802154_transmit_failed(const uint8_t *frame, esp_ieee802154_tx_error_t error)
|
||||
{
|
||||
(void)frame;
|
||||
ESP_LOGD(TAG, "tx failed: %d", error);
|
||||
}
|
||||
|
||||
static void beacon_timer_cb(TimerHandle_t t)
|
||||
{
|
||||
(void)t;
|
||||
uint64_t now = (uint64_t)esp_timer_get_time();
|
||||
if (s_is_leader) {
|
||||
send_beacon();
|
||||
} else if ((now - s_last_seen_us) > (TS_VALID_WINDOW_MS * 1000ULL)) {
|
||||
/* Lost the leader — promote self if no one else takes over in 1 s. */
|
||||
s_is_leader = true;
|
||||
s_leader_eui = s_local_eui;
|
||||
ESP_LOGI(TAG, "promoting self to time-leader (no beacons for %u ms)",
|
||||
(unsigned)TS_VALID_WINDOW_MS);
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t c6_timesync_init(uint8_t channel)
|
||||
{
|
||||
/* esp_mac.h: ESP_MAC_IEEE802154 returns 8 bytes ALREADY in EUI-64 format
|
||||
* (ff:fe is pre-inserted in bytes 3-4 from the eFuse MAC_EXT). Using a
|
||||
* 6-byte buffer here truncates and then double-inserts ff:fe — the bug
|
||||
* we hit on the first run (boot log: EUI=206ef1fffefffe17).
|
||||
*
|
||||
* Correct path: read 8 bytes, pack into uint64 unchanged. Fallback to
|
||||
* the base MAC + manual EUI-64 derivation if the 8-byte read errors. */
|
||||
uint8_t eui_bytes[8] = {0};
|
||||
esp_err_t mac_ret = esp_read_mac(eui_bytes, ESP_MAC_IEEE802154);
|
||||
if (mac_ret == ESP_OK) {
|
||||
s_local_eui = eui64_bytes_to_u64(eui_bytes);
|
||||
} else {
|
||||
uint8_t base_mac[6];
|
||||
esp_read_mac(base_mac, ESP_MAC_BASE);
|
||||
s_local_eui = mac48_to_eui64(base_mac);
|
||||
}
|
||||
/* Use the 6-byte base MAC for the IEEE 802.15.4 extended address — the
|
||||
* radio expects MAC-48-style bytes here, not the EUI-64 derivation. */
|
||||
uint8_t mac[6];
|
||||
esp_read_mac(mac, ESP_MAC_BASE);
|
||||
s_channel = (channel >= 11 && channel <= 26) ? channel : 15;
|
||||
|
||||
esp_err_t ret = esp_ieee802154_enable();
|
||||
if (ret != ESP_OK) {
|
||||
ESP_LOGE(TAG, "ieee802154_enable failed: %s", esp_err_to_name(ret));
|
||||
return ret;
|
||||
}
|
||||
/* promiscuous=true so we accept broadcast frames addressed to 0xFFFF.
|
||||
* In non-promiscuous mode the radio filters to frames addressed to
|
||||
* our short or extended address. Our beacon protocol uses broadcast. */
|
||||
esp_ieee802154_set_promiscuous(true);
|
||||
esp_ieee802154_set_panid(0xCAFE);
|
||||
esp_ieee802154_set_short_address(0x0000);
|
||||
esp_ieee802154_set_extended_address(mac);
|
||||
esp_ieee802154_set_channel(s_channel);
|
||||
esp_ieee802154_receive();
|
||||
|
||||
/* Start as candidate leader; first received beacon will demote us if needed. */
|
||||
s_is_leader = true;
|
||||
s_leader_eui = s_local_eui;
|
||||
s_last_seen_us = (uint64_t)esp_timer_get_time();
|
||||
|
||||
s_beacon_timer = xTimerCreate("c6ts_beacon", pdMS_TO_TICKS(TS_BEACON_MS),
|
||||
pdTRUE, NULL, beacon_timer_cb);
|
||||
if (s_beacon_timer == NULL) {
|
||||
ESP_LOGE(TAG, "xTimerCreate failed");
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
xTimerStart(s_beacon_timer, 0);
|
||||
|
||||
ESP_LOGI(TAG, "init done: channel=%u EUI=%016llx leader=yes(candidate)",
|
||||
(unsigned)s_channel, (unsigned long long)s_local_eui);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
uint64_t c6_timesync_get_epoch_us(void)
|
||||
{
|
||||
return (uint64_t)((int64_t)esp_timer_get_time() + s_offset_us);
|
||||
}
|
||||
|
||||
bool c6_timesync_is_leader(void) { return s_is_leader; }
|
||||
int64_t c6_timesync_get_offset_us(void) { return s_offset_us; }
|
||||
|
||||
bool c6_timesync_is_valid(void)
|
||||
{
|
||||
if (s_is_leader) return true;
|
||||
uint64_t now = (uint64_t)esp_timer_get_time();
|
||||
return (now - s_last_seen_us) < (TS_VALID_WINDOW_MS * 1000ULL);
|
||||
}
|
||||
|
||||
#endif /* CONFIG_IDF_TARGET_ESP32C6 && CONFIG_IEEE802154_ENABLED */
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* @file c6_timesync.h
|
||||
* @brief 802.15.4 mesh time-sync — ADR-110 Phase 4.
|
||||
*
|
||||
* Provides cross-node clock alignment over a separate 802.15.4 radio so
|
||||
* the WiFi airtime stays clean for CSI sensing. Solves the multistatic
|
||||
* synchronization problem (ADR-029/030) without burning the sensing
|
||||
* channel on coordination traffic.
|
||||
*
|
||||
* Protocol (skeleton — full Thread join deferred to a follow-up phase):
|
||||
* - One node is elected time-leader (lowest 64-bit EUI on the mesh).
|
||||
* - Leader broadcasts a TS_BEACON every 100 ms on 802.15.4 channel 15.
|
||||
* - Followers compute offset = leader_us - local_us, apply lazily.
|
||||
* - Each CSI frame is stamped with c6_timesync_get_epoch_us().
|
||||
*
|
||||
* Only built when CONFIG_IDF_TARGET_ESP32C6 + CONFIG_IEEE802154_ENABLED.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#include "esp_err.h"
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
#if defined(CONFIG_IDF_TARGET_ESP32C6) && defined(CONFIG_IEEE802154_ENABLED)
|
||||
|
||||
/**
|
||||
* Initialize the 802.15.4 radio and time-sync state machine.
|
||||
* Picks leader or follower role based on EUI comparison.
|
||||
*
|
||||
* @param channel 802.15.4 channel (11-26, default 15).
|
||||
* @return ESP_OK on success.
|
||||
*/
|
||||
esp_err_t c6_timesync_init(uint8_t channel);
|
||||
|
||||
/**
|
||||
* Returns the synced wall-clock estimate in microseconds.
|
||||
* If no leader heard within the timeout, returns the local
|
||||
* esp_timer_get_time() value unchanged (offset = 0).
|
||||
*/
|
||||
uint64_t c6_timesync_get_epoch_us(void);
|
||||
|
||||
/**
|
||||
* Returns true if this node is currently the time-leader.
|
||||
*/
|
||||
bool c6_timesync_is_leader(void);
|
||||
|
||||
/**
|
||||
* Returns true if the local clock is synced (heard a beacon within timeout).
|
||||
*/
|
||||
bool c6_timesync_is_valid(void);
|
||||
|
||||
/**
|
||||
* Returns the most-recently-measured offset from the leader (microseconds).
|
||||
* 0 if this node is the leader; sign indicates direction.
|
||||
*/
|
||||
int64_t c6_timesync_get_offset_us(void);
|
||||
|
||||
#else /* not C6 with 802.15.4 — provide stubs so call sites compile */
|
||||
|
||||
#include "esp_timer.h"
|
||||
|
||||
static inline esp_err_t c6_timesync_init(uint8_t c) { (void)c; return ESP_OK; }
|
||||
static inline uint64_t c6_timesync_get_epoch_us(void) { return (uint64_t)esp_timer_get_time(); }
|
||||
static inline bool c6_timesync_is_leader(void) { return false; }
|
||||
static inline bool c6_timesync_is_valid(void) { return false; }
|
||||
static inline int64_t c6_timesync_get_offset_us(void) { return 0; }
|
||||
|
||||
#endif
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -1,155 +0,0 @@
|
||||
/**
|
||||
* @file c6_twt.c
|
||||
* @brief ESP32-C6 TWT setup implementation — ADR-110 Phase 3.
|
||||
*
|
||||
* Implementation note: ESP-IDF v5.4's iTWT API on C6 is
|
||||
*
|
||||
* esp_err_t esp_wifi_sta_itwt_setup(wifi_itwt_setup_config_t *cfg);
|
||||
* esp_err_t esp_wifi_sta_itwt_teardown(uint8_t flow_id);
|
||||
*
|
||||
* The setup is asynchronous — the actual accept/reject arrives later as
|
||||
* a WIFI_EVENT_ITWT_SETUP event. The default handler in this module
|
||||
* logs the outcome; the helper itself returns as soon as the request
|
||||
* is queued.
|
||||
*/
|
||||
|
||||
#include "sdkconfig.h"
|
||||
#include "soc/soc_caps.h"
|
||||
|
||||
#if defined(CONFIG_IDF_TARGET_ESP32C6) && SOC_WIFI_HE_SUPPORT
|
||||
|
||||
#include "c6_twt.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "esp_wifi_he.h" /* esp_wifi_sta_itwt_setup / _teardown */
|
||||
#include "esp_wifi_he_types.h"
|
||||
#include "esp_wifi_types.h"
|
||||
#include "esp_event.h"
|
||||
#include <string.h>
|
||||
|
||||
static const char *TAG = "c6_twt";
|
||||
|
||||
static bool s_active = false;
|
||||
static uint8_t s_flow_id = 0;
|
||||
static uint32_t s_wake_int = 0;
|
||||
static uint32_t s_wake_dura = 0;
|
||||
|
||||
#ifndef CONFIG_C6_TWT_WAKE_INTERVAL_US
|
||||
#define CONFIG_C6_TWT_WAKE_INTERVAL_US 10000 /* 100 fps default cadence */
|
||||
#endif
|
||||
|
||||
#ifndef CONFIG_C6_TWT_MIN_WAKE_DURA_US
|
||||
#define CONFIG_C6_TWT_MIN_WAKE_DURA_US 512 /* enough to capture 1 CSI frame */
|
||||
#endif
|
||||
|
||||
/* WIFI_EVENT_ITWT_SETUP handler — logs accept/reject. */
|
||||
static void on_itwt_event(void *arg, esp_event_base_t base,
|
||||
int32_t event_id, void *event_data)
|
||||
{
|
||||
(void)arg;
|
||||
(void)base;
|
||||
(void)event_data;
|
||||
switch (event_id) {
|
||||
case WIFI_EVENT_ITWT_SETUP:
|
||||
ESP_LOGI(TAG, "iTWT setup event received from AP (flow_id captured)");
|
||||
s_active = true;
|
||||
break;
|
||||
case WIFI_EVENT_ITWT_TEARDOWN:
|
||||
ESP_LOGI(TAG, "iTWT teardown event received");
|
||||
s_active = false;
|
||||
break;
|
||||
case WIFI_EVENT_ITWT_SUSPEND:
|
||||
ESP_LOGI(TAG, "iTWT suspended by AP");
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
static bool s_handler_installed = false;
|
||||
|
||||
static void install_event_handler_once(void)
|
||||
{
|
||||
if (s_handler_installed) return;
|
||||
esp_err_t e = esp_event_handler_instance_register(
|
||||
WIFI_EVENT, ESP_EVENT_ANY_ID, on_itwt_event, NULL, NULL);
|
||||
if (e == ESP_OK) {
|
||||
s_handler_installed = true;
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Could not install iTWT event handler: %s",
|
||||
esp_err_to_name(e));
|
||||
}
|
||||
}
|
||||
|
||||
esp_err_t c6_twt_setup(uint32_t wake_interval_us, uint32_t min_wake_dura_us)
|
||||
{
|
||||
install_event_handler_once();
|
||||
|
||||
s_wake_int = wake_interval_us;
|
||||
s_wake_dura = min_wake_dura_us < 256 ? 256 : min_wake_dura_us;
|
||||
|
||||
wifi_itwt_setup_config_t cfg = {0};
|
||||
cfg.setup_cmd = TWT_REQUEST;
|
||||
cfg.flow_id = s_flow_id;
|
||||
cfg.twt_id = 0;
|
||||
cfg.flow_type = 1; /* unannounced */
|
||||
cfg.min_wake_dura = (uint8_t)((s_wake_dura + 255) / 256); /* 256 µs units */
|
||||
cfg.wake_duration_unit = 0; /* 0 = 256 µs, 1 = 1024 µs */
|
||||
cfg.wake_invl_expn = 10; /* mantissa * 2^10 ≈ 1024 µs base */
|
||||
/* mantissa = wake_interval_us / 1024, clamped to uint16 */
|
||||
uint32_t mant = wake_interval_us >> 10;
|
||||
if (mant == 0) mant = 1;
|
||||
if (mant > 0xFFFF) mant = 0xFFFF;
|
||||
cfg.wake_invl_mant = (uint16_t)mant;
|
||||
cfg.trigger = 0; /* non-triggered: STA wakes on its own */
|
||||
|
||||
esp_err_t ret = esp_wifi_sta_itwt_setup(&cfg);
|
||||
if (ret == ESP_OK) {
|
||||
ESP_LOGI(TAG, "iTWT setup queued: wake_interval=%lu µs (mant=%u expn=10), "
|
||||
"min_wake_dura=%u (%lu µs)",
|
||||
(unsigned long)wake_interval_us, (unsigned)mant,
|
||||
cfg.min_wake_dura, (unsigned long)s_wake_dura);
|
||||
return ESP_OK;
|
||||
}
|
||||
/* Treat AP-rejection / not-supported / wrong-AP-mode as graceful — log
|
||||
* and continue. ESP_ERR_INVALID_ARG is included here because empirically
|
||||
* (live capture on ruv.net 2026-05-22) the ESP-IDF v5.4 driver returns
|
||||
* INVALID_ARG when the associated AP advertises TWT Responder=0 — the
|
||||
* call validates against the AP's HE capability bitmap, not just the
|
||||
* struct fields. */
|
||||
if (ret == ESP_ERR_NOT_SUPPORTED || ret == ESP_ERR_WIFI_NOT_CONNECT ||
|
||||
ret == ESP_ERR_INVALID_STATE || ret == ESP_ERR_INVALID_ARG) {
|
||||
ESP_LOGW(TAG, "iTWT not available (%s) - AP likely not 11ax/iTWT capable,"
|
||||
" falling back to opportunistic CSI",
|
||||
esp_err_to_name(ret));
|
||||
return ESP_OK;
|
||||
}
|
||||
ESP_LOGE(TAG, "iTWT setup failed: %s", esp_err_to_name(ret));
|
||||
return ret;
|
||||
}
|
||||
|
||||
esp_err_t c6_twt_setup_default(void)
|
||||
{
|
||||
return c6_twt_setup(CONFIG_C6_TWT_WAKE_INTERVAL_US,
|
||||
CONFIG_C6_TWT_MIN_WAKE_DURA_US);
|
||||
}
|
||||
|
||||
void c6_twt_teardown(void)
|
||||
{
|
||||
if (!s_active) return;
|
||||
/* IDF v5.4 signature: esp_err_t esp_wifi_sta_itwt_teardown(int flow_id) */
|
||||
esp_err_t ret = esp_wifi_sta_itwt_teardown((int)s_flow_id);
|
||||
if (ret == ESP_OK) {
|
||||
ESP_LOGI(TAG, "iTWT teardown sent (flow_id=%u)", s_flow_id);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "iTWT teardown failed: %s", esp_err_to_name(ret));
|
||||
}
|
||||
s_active = false;
|
||||
}
|
||||
|
||||
bool c6_twt_is_active(void)
|
||||
{
|
||||
return s_active;
|
||||
}
|
||||
|
||||
#endif /* CONFIG_IDF_TARGET_ESP32C6 && SOC_WIFI_HE_SUPPORT */
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* @file c6_twt.h
|
||||
* @brief ESP32-C6 TWT (Target Wake Time) helper — ADR-110 Phase 3.
|
||||
*
|
||||
* Wraps esp_wifi_sta_itwt_setup() to negotiate a deterministic wake slot
|
||||
* with the AP, replacing today's opportunistic CSI capture cadence with
|
||||
* a scheduler-bounded one.
|
||||
*
|
||||
* Only built when CONFIG_IDF_TARGET_ESP32C6 is set — the S3 radio is
|
||||
* 802.11n only and cannot speak iTWT.
|
||||
*
|
||||
* Usage from main.c (after WiFi STA is connected):
|
||||
* c6_twt_setup_default(); // honors CONFIG_C6_TWT_WAKE_INTERVAL_US
|
||||
*
|
||||
* Graceful failure: if the AP rejects (no 11ax support, doesn't allow
|
||||
* iTWT, or returns a NACK), the helper logs and returns ESP_OK — the
|
||||
* device keeps doing opportunistic CSI just like the S3.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#include "soc/soc_caps.h"
|
||||
|
||||
#if defined(CONFIG_IDF_TARGET_ESP32C6) && SOC_WIFI_HE_SUPPORT
|
||||
|
||||
#include "esp_err.h"
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
/**
|
||||
* Set up an individual TWT agreement using the Kconfig defaults
|
||||
* (CONFIG_C6_TWT_WAKE_INTERVAL_US, CONFIG_C6_TWT_MIN_WAKE_DURA_US).
|
||||
*
|
||||
* @return ESP_OK whether or not the AP accepted — the helper never
|
||||
* propagates a TWT NACK as an error to the caller.
|
||||
*/
|
||||
esp_err_t c6_twt_setup_default(void);
|
||||
|
||||
/**
|
||||
* Set up an individual TWT agreement with explicit parameters.
|
||||
*
|
||||
* @param wake_interval_us Period between wake events.
|
||||
* @param min_wake_dura_us Minimum awake duration per wake (≥256 µs).
|
||||
* @return ESP_OK on success or graceful NACK; ESP_FAIL on local error.
|
||||
*/
|
||||
esp_err_t c6_twt_setup(uint32_t wake_interval_us, uint32_t min_wake_dura_us);
|
||||
|
||||
/**
|
||||
* Tear down any active TWT agreement. Safe to call when none is active.
|
||||
* Should be invoked on WIFI_EVENT_STA_DISCONNECTED so the AP scheduler
|
||||
* doesn't keep a dead slot reserved.
|
||||
*/
|
||||
void c6_twt_teardown(void);
|
||||
|
||||
/**
|
||||
* Returns true if a TWT agreement is currently active.
|
||||
*/
|
||||
bool c6_twt_is_active(void);
|
||||
|
||||
#else /* not C6 with iTWT support — provide stubs so call sites compile */
|
||||
|
||||
static inline esp_err_t c6_twt_setup_default(void) { return ESP_OK; }
|
||||
static inline esp_err_t c6_twt_setup(uint32_t a, uint32_t b) { (void)a; (void)b; return ESP_OK; }
|
||||
static inline void c6_twt_teardown(void) { }
|
||||
static inline bool c6_twt_is_active(void) { return false; }
|
||||
|
||||
#endif /* CONFIG_IDF_TARGET_ESP32C6 && SOC_WIFI_HE_SUPPORT */
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -15,7 +15,6 @@
|
||||
#include "nvs_config.h"
|
||||
#include "stream_sender.h"
|
||||
#include "edge_processing.h"
|
||||
#include "c6_timesync.h" /* ADR-110: 802.15.4 epoch for cross-node alignment */
|
||||
|
||||
#include <string.h>
|
||||
#include "esp_log.h"
|
||||
@@ -174,57 +173,9 @@ size_t csi_serialize_frame(const wifi_csi_info_t *info, uint8_t *buf, size_t buf
|
||||
/* Noise floor (i8) */
|
||||
buf[17] = (uint8_t)(int8_t)info->rx_ctrl.noise_floor;
|
||||
|
||||
/* ADR-110: PPDU type (byte 18) + bandwidth/flags (byte 19).
|
||||
* Previously reserved-zero, now optionally populated when CONFIG_CSI_FRAME_HE_TAGGING.
|
||||
* Readers that don't know about the extension see zeros — backward compatible.
|
||||
*
|
||||
* The struct that backs info->rx_ctrl is target-conditional in IDF v5.4
|
||||
* (esp_wifi/include/local/esp_wifi_types_native.h):
|
||||
*
|
||||
* CONFIG_SOC_WIFI_HE_SUPPORT=y (C6/C5) → esp_wifi_rxctrl_t with cur_bb_format, second
|
||||
* otherwise (S3 etc) → legacy struct with sig_mode, cwb, stbc
|
||||
*
|
||||
* Byte-18 PPDU type encoding stays the same across targets:
|
||||
* 0=HT/legacy bucket, 1=HE-SU, 2=HE-MU, 3=HE-TB, 0xFF=unknown
|
||||
*/
|
||||
#ifdef CONFIG_CSI_FRAME_HE_TAGGING
|
||||
uint8_t ppdu_type = 0xFF;
|
||||
uint8_t flags = 0;
|
||||
#if CONFIG_SOC_WIFI_HE_SUPPORT
|
||||
/* HE-capable chips: read cur_bb_format (0=11b, 1=11g, 2=HT, 3=VHT, 4=HE-SU,
|
||||
* 5=HE-MU, 6=HE-ERSU, 7=HE-TB) and 'second' (40 MHz secondary chan offset). */
|
||||
switch (info->rx_ctrl.cur_bb_format) {
|
||||
case 0:
|
||||
case 1:
|
||||
case 2: ppdu_type = 0; break; /* 11b/g/a/HT bucket */
|
||||
case 3: ppdu_type = 0; break; /* VHT — rare on 2.4 GHz, HT bucket */
|
||||
case 4: ppdu_type = 1; break; /* HE-SU */
|
||||
case 5: ppdu_type = 2; break; /* HE-MU */
|
||||
case 6: ppdu_type = 1; break; /* HE-ER-SU collapses to HE-SU */
|
||||
case 7: ppdu_type = 3; break; /* HE-TB */
|
||||
default: ppdu_type = 0xFF; break;
|
||||
}
|
||||
if (info->rx_ctrl.second != 0) flags |= 0x1; /* bw 40 MHz */
|
||||
#else
|
||||
/* Pre-HE chips (S3 etc): use legacy sig_mode + cwb + stbc fields. */
|
||||
switch (info->rx_ctrl.sig_mode) {
|
||||
case 0: ppdu_type = 0; break; /* non-HT (11b/g) */
|
||||
case 1: ppdu_type = 0; break; /* HT (11n) */
|
||||
case 3: ppdu_type = 0; break; /* VHT — bucket as HT for storage */
|
||||
default: ppdu_type = 0xFF; break;
|
||||
}
|
||||
if (info->rx_ctrl.cwb) flags |= 0x1; /* bw 40 MHz */
|
||||
if (info->rx_ctrl.stbc) flags |= (1 << 2); /* STBC */
|
||||
#endif /* CONFIG_SOC_WIFI_HE_SUPPORT */
|
||||
#if defined(CONFIG_IDF_TARGET_ESP32C6) && defined(CONFIG_C6_TIMESYNC_ENABLE)
|
||||
if (c6_timesync_is_valid()) flags |= (1 << 4); /* 15.4 sync valid */
|
||||
#endif
|
||||
buf[18] = ppdu_type;
|
||||
buf[19] = flags;
|
||||
#else
|
||||
/* Reserved */
|
||||
buf[18] = 0;
|
||||
buf[19] = 0;
|
||||
#endif
|
||||
|
||||
/* I/Q data */
|
||||
memcpy(&buf[CSI_HEADER_SIZE], info->buf, iq_len);
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
# LP-core motion-gate program — ADR-110 Phase 5 (full).
|
||||
#
|
||||
# Built only when CONFIG_C6_LP_CORE_ENABLE=y (gated in the parent CMakeLists).
|
||||
# The IDF build system invokes this via `ulp_embed_binary()` from
|
||||
# main/CMakeLists.txt.
|
||||
|
||||
# This file intentionally has no idf_component_register — the LP-core sources
|
||||
# are compiled with the RISC-V LP toolchain via `ulp_embed_binary` and then
|
||||
# linked into the HP image as a binary blob, not as a normal component.
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* @file lp_core/main.c
|
||||
* @brief LP RISC-V coprocessor motion-gate — ADR-110 Phase 5 (full).
|
||||
*
|
||||
* Polls a single LP-IO GPIO at LP_TIMER cadence (default 10 ms / 100 Hz),
|
||||
* debounces N consecutive samples, and wakes the HP core when a confirmed
|
||||
* transition matches the configured active-edge polarity. Counter +
|
||||
* last-level are exported as shared symbols so the HP side can inspect
|
||||
* them on wake.
|
||||
*
|
||||
* Shared symbols (HP-visible as `ulp_<name>` after `ulp_embed_binary`):
|
||||
* - wake_gpio_num (input) : LP-IO index 0..7 on ESP32-C6
|
||||
* - wake_active_high (input) : 1 = wake on rising stable, 0 = falling
|
||||
* - debounce_samples (input) : consecutive matches required, default 3
|
||||
* - motion_count (output) : monotonic wake-trigger counter
|
||||
* - last_gpio_level (output) : level latched at the most recent wake
|
||||
* - poll_count (output) : total LP-timer ticks observed (sanity)
|
||||
*
|
||||
* Defaults are written by HP via the `ulp_*` symbols before `ulp_lp_core_run()`,
|
||||
* so the program is parameterised at boot without recompiling the LP binary.
|
||||
*/
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include "ulp_lp_core.h"
|
||||
#include "ulp_lp_core_utils.h"
|
||||
#include "ulp_lp_core_gpio.h"
|
||||
|
||||
/* --- Shared (HP/LP) state --- */
|
||||
volatile uint32_t wake_gpio_num = 4; /* LP-IO 4 by default */
|
||||
volatile uint32_t wake_active_high = 1; /* rising edge */
|
||||
volatile uint32_t debounce_samples = 3;
|
||||
volatile uint32_t motion_count = 0;
|
||||
volatile uint32_t last_gpio_level = 0;
|
||||
volatile uint32_t poll_count = 0;
|
||||
|
||||
/* --- Local state (persists across LP-timer wake cycles via .data) --- */
|
||||
static uint32_t stable_run = 0;
|
||||
static uint32_t prev_level = 0;
|
||||
|
||||
int main(void)
|
||||
{
|
||||
poll_count++;
|
||||
|
||||
/* LP-IO read returns 0/1 directly. The Kconfig-selected GPIO index maps
|
||||
* 1:1 to LP_IO on C6 for indices 0..7. */
|
||||
uint32_t level = (uint32_t)ulp_lp_core_gpio_get_level((lp_io_num_t)wake_gpio_num);
|
||||
|
||||
if (level == prev_level) {
|
||||
if (stable_run < 0xFFFFu) stable_run++;
|
||||
} else {
|
||||
stable_run = 1;
|
||||
prev_level = level;
|
||||
}
|
||||
|
||||
/* Trigger when level matches the configured active polarity AND has been
|
||||
* stable for `debounce_samples` consecutive reads. After firing, hold off
|
||||
* until level returns to the inactive state to avoid re-triggering on
|
||||
* the same continuous edge. */
|
||||
static uint32_t armed = 1;
|
||||
uint32_t want = wake_active_high ? 1 : 0;
|
||||
|
||||
if (armed && level == want && stable_run >= debounce_samples) {
|
||||
motion_count++;
|
||||
last_gpio_level = level;
|
||||
armed = 0;
|
||||
ulp_lp_core_wakeup_main_processor();
|
||||
} else if (!armed && level != want && stable_run >= debounce_samples) {
|
||||
/* Re-arm once the line has cleanly returned to the inactive state. */
|
||||
armed = 1;
|
||||
}
|
||||
|
||||
/* ulp_lp_core_halt() is called automatically when main returns. */
|
||||
return 0;
|
||||
}
|
||||
@@ -33,11 +33,6 @@
|
||||
#include "swarm_bridge.h"
|
||||
#include "rv_radio_ops.h" /* ADR-081 Layer 1 — Radio Abstraction Layer. */
|
||||
#include "adaptive_controller.h" /* ADR-081 Layer 2 — Adaptive controller. */
|
||||
#include "c6_twt.h" /* ADR-110: TWT (no-op stub on S3) */
|
||||
#include "c6_timesync.h" /* ADR-110: 802.15.4 mesh time-sync (no-op on S3) */
|
||||
#include "c6_lp_core.h" /* ADR-110: LP-core hibernation (no-op on S3) */
|
||||
#include "c6_sync_espnow.h" /* ADR-110 D1 workaround: ESP-NOW sync */
|
||||
#include "c6_softap_he.h" /* ADR-110 B1/B2: HE/TWT soft-AP (no-op when disabled) */
|
||||
#ifdef CONFIG_CSI_MOCK_ENABLED
|
||||
#include "mock_csi.h"
|
||||
#endif
|
||||
@@ -117,17 +112,6 @@ static void wifi_init_sta(void)
|
||||
|
||||
ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
|
||||
ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
|
||||
|
||||
#if defined(CONFIG_IDF_TARGET_ESP32C6) && defined(CONFIG_C6_SOFTAP_HE_ENABLE)
|
||||
/* ADR-110 B1/B2 cheap-unblock: bring up a soft-AP that advertises HE +
|
||||
* TWT Responder=1 so a second C6 board can negotiate iTWT against
|
||||
* this node. c6_softap_he_start() switches the mode to AP+STA. */
|
||||
uint8_t softap_chan = 0;
|
||||
if (c6_softap_he_start(&softap_chan) == ESP_OK) {
|
||||
ESP_LOGI(TAG, "C6 soft-AP HE armed on channel %u (ADR-110 B1/B2)", softap_chan);
|
||||
}
|
||||
#endif
|
||||
|
||||
ESP_ERROR_CHECK(esp_wifi_start());
|
||||
|
||||
ESP_LOGI(TAG, "WiFi STA initialized, connecting to SSID: %s", g_nvs_config.wifi_ssid);
|
||||
@@ -163,27 +147,13 @@ void app_main(void)
|
||||
csi_collector_set_node_id(g_nvs_config.node_id);
|
||||
|
||||
const esp_app_desc_t *app_desc = esp_app_get_description();
|
||||
#if defined(CONFIG_IDF_TARGET_ESP32C6)
|
||||
const char *target_name = "ESP32-C6";
|
||||
#elif defined(CONFIG_IDF_TARGET_ESP32S3)
|
||||
const char *target_name = "ESP32-S3";
|
||||
#else
|
||||
const char *target_name = "ESP32";
|
||||
#endif
|
||||
ESP_LOGI(TAG, "%s CSI Node (ADR-018 / ADR-110) — v%s — Node ID: %d",
|
||||
target_name, app_desc->version, g_nvs_config.node_id);
|
||||
ESP_LOGI(TAG, "ESP32-S3 CSI Node (ADR-018) — v%s — Node ID: %d",
|
||||
app_desc->version, g_nvs_config.node_id);
|
||||
|
||||
/* Turn off onboard WS2812 LED.
|
||||
* S3 dev boards put the LED on GPIO 38; C6 dev boards on GPIO 8.
|
||||
* On C6, GPIO 38 doesn't exist (only 0-30) — gate the init by target. */
|
||||
#if defined(CONFIG_IDF_TARGET_ESP32C6)
|
||||
const int led_gpio = 8;
|
||||
#else
|
||||
const int led_gpio = 38;
|
||||
#endif
|
||||
/* Turn off onboard WS2812 LED on GPIO 38 */
|
||||
led_strip_handle_t led_strip;
|
||||
led_strip_config_t strip_config = {
|
||||
.strip_gpio_num = led_gpio,
|
||||
.strip_gpio_num = 38,
|
||||
.max_leds = 1,
|
||||
.led_model = LED_MODEL_WS2812,
|
||||
.color_component_format = LED_STRIP_COLOR_COMPONENT_FMT_GRB,
|
||||
@@ -197,27 +167,6 @@ void app_main(void)
|
||||
led_strip_clear(led_strip);
|
||||
}
|
||||
|
||||
/* ADR-110 P4: 802.15.4 mesh time-sync (C6 only).
|
||||
* Initialized BEFORE WiFi so it's available even when WiFi STA can't
|
||||
* connect — the radios are physically independent on the C6.
|
||||
* No-op on S3 (the helper compiles to an empty inline stub). */
|
||||
#if defined(CONFIG_IDF_TARGET_ESP32C6) && defined(CONFIG_C6_TIMESYNC_ENABLE)
|
||||
esp_err_t ts_ret = c6_timesync_init(CONFIG_C6_TIMESYNC_CHANNEL);
|
||||
if (ts_ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "c6_timesync_init failed: %s (continuing without 15.4 sync)",
|
||||
esp_err_to_name(ts_ret));
|
||||
}
|
||||
#endif
|
||||
|
||||
/* ADR-110 P5: Optionally arm LP-core wake-on-motion (C6 only, opt-in).
|
||||
* Default off — only nodes flashed for battery-powered seed duty enable
|
||||
* this in menuconfig. */
|
||||
#if defined(CONFIG_IDF_TARGET_ESP32C6) && defined(CONFIG_C6_LP_CORE_ENABLE)
|
||||
if (c6_lp_core_was_motion_wake()) {
|
||||
ESP_LOGI(TAG, "boot cause: LP-core motion wake (running CSI burst)");
|
||||
}
|
||||
#endif
|
||||
|
||||
/* Initialize WiFi STA (skip entirely under QEMU mock — no RF hardware) */
|
||||
#ifndef CONFIG_CSI_MOCK_SKIP_WIFI_CONNECT
|
||||
wifi_init_sta();
|
||||
@@ -259,26 +208,6 @@ void app_main(void)
|
||||
}
|
||||
#endif
|
||||
|
||||
/* ADR-110 P3: Request TWT from the AP for deterministic CSI cadence.
|
||||
* No-op on S3 (the helper compiles to an empty inline stub). On C6
|
||||
* the AP may NACK — the helper logs and falls back to opportunistic.
|
||||
* Called only after WiFi STA connect (wifi_init_sta blocks until then). */
|
||||
#if defined(CONFIG_IDF_TARGET_ESP32C6) && defined(CONFIG_C6_TWT_ENABLE)
|
||||
c6_twt_setup_default();
|
||||
#endif
|
||||
|
||||
/* ADR-110 D1 workaround: ESP-NOW cross-node sync. Initialized after
|
||||
* WiFi STA connects (ESP-NOW needs the WiFi driver up). Works on
|
||||
* both S3 and C6 — replaces the broken 802.15.4 RX path in c6_timesync.
|
||||
* Skip on QEMU mock (no real WiFi → no ESP-NOW). */
|
||||
#ifndef CONFIG_CSI_MOCK_SKIP_WIFI_CONNECT
|
||||
esp_err_t espnow_ret = c6_sync_espnow_init();
|
||||
if (espnow_ret != ESP_OK) {
|
||||
ESP_LOGW(TAG, "c6_sync_espnow_init failed: %s (continuing without ESP-NOW sync)",
|
||||
esp_err_to_name(espnow_ret));
|
||||
}
|
||||
#endif
|
||||
|
||||
/* ADR-039: Initialize edge processing pipeline. */
|
||||
edge_config_t edge_cfg = {
|
||||
.tier = g_nvs_config.edge_tier,
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
889715e9d698ad78f9978ad8b93b6af24a726b0494247201c8f0d920d9fc80ca *firmware/esp32-csi-node/release_bins/c6-adr110/bootloader.bin
|
||||
d8539e47c6f10a3344679118619e3fe01cfd66eb560ea8883268ca7c9a12efa4 *firmware/esp32-csi-node/release_bins/c6-adr110/esp32-csi-node.bin
|
||||
7d2c7ac4888bfd75cd5f56e8d61f69595121183afc81556c876732fd3782c62f *firmware/esp32-csi-node/release_bins/c6-adr110/ota_data_initial.bin
|
||||
4c2cc4ffd52641e23b779bd57b3908014083ac3c1aab395756478c89e70d81f0 *firmware/esp32-csi-node/release_bins/c6-adr110/partition-table.bin
|
||||
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -1,3 +0,0 @@
|
||||
3c4905dd202ccabf4230cbabcc9320f250a60b1a7254eff7424780201bcb2072 *firmware/esp32-csi-node/release_bins/s3-adr110/bootloader.bin
|
||||
7a8bf9582c9031fed32f1ada44f5c41dd99bd07fadff8e5c86e07aa0f343e847 *firmware/esp32-csi-node/release_bins/s3-adr110/esp32-csi-node.bin
|
||||
67222c257c0477501fd4002275638dc4262b34eb68235b8289fb1337054d322b *firmware/esp32-csi-node/release_bins/s3-adr110/partition-table.bin
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,3 +0,0 @@
|
||||
a53b2c018bfd2e367525bedf6dc3fda6bc9639d1a9cc9e8bf9eb3e9fee379ed2 *firmware/esp32-csi-node/release_bins/s3-fair-adr110/bootloader.bin
|
||||
53eb50ea890a8388b8a39285a3dd34c53651535c689a3b42f136a5ed7f424145 *firmware/esp32-csi-node/release_bins/s3-fair-adr110/esp32-csi-node.bin
|
||||
4c2cc4ffd52641e23b779bd57b3908014083ac3c1aab395756478c89e70d81f0 *firmware/esp32-csi-node/release_bins/s3-fair-adr110/partition-table.bin
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,75 +0,0 @@
|
||||
# ESP32-C6 CSI Node — Target overlay (ADR-110)
|
||||
#
|
||||
# Auto-applied by ESP-IDF when CONFIG_IDF_TARGET=esp32c6.
|
||||
# Layered on top of sdkconfig.defaults — only the differences live here.
|
||||
#
|
||||
# Build:
|
||||
# idf.py set-target esp32c6
|
||||
# idf.py build
|
||||
#
|
||||
# Hardware: stock ESP32-C6 dev board with 4 MB or 8 MB embedded flash.
|
||||
# Confirmed on COM6: ESP32-C6 (QFN40) rev v0.2, 8 MB flash, 320 KiB SRAM.
|
||||
|
||||
# ── Target ──
|
||||
CONFIG_IDF_TARGET="esp32c6"
|
||||
|
||||
# ── Flash & partitions (4 MB — common across C6 dev boards) ──
|
||||
CONFIG_PARTITION_TABLE_CUSTOM=y
|
||||
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions_4mb.csv"
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE="4MB"
|
||||
|
||||
# ── CSI (required) ──
|
||||
CONFIG_ESP_WIFI_CSI_ENABLED=y
|
||||
|
||||
# ── ADR-110 P2 & P3: Wi-Fi 6 / iTWT ──
|
||||
# IDF v5.4 exposes neither ESP_WIFI_11AX_SUPPORT nor ESP_WIFI_ITWT_SUPPORT as
|
||||
# user Kconfig — they're SoC capabilities (SOC_WIFI_HE_SUPPORT) auto-enabled
|
||||
# on chips that have HE support (C6/C5). WPA3 is opt-in:
|
||||
CONFIG_ESP_WIFI_ENABLE_WPA3_SAE=y
|
||||
|
||||
# ── ADR-110 P4: 802.15.4 (raw, no OpenThread) ──
|
||||
# IEEE 802.15.4 PHY enabled for our raw beacon protocol in c6_timesync.c.
|
||||
# OpenThread is DISABLED — empirically (ch15 + ch26 tested with the same
|
||||
# negative result), enabling OpenThread MTD caused our weak-symbol overrides
|
||||
# of esp_ieee802154_receive_done/transmit_done to never fire, breaking
|
||||
# leader election. Raw 802.15.4 mode is what we actually need: a private
|
||||
# mesh protocol on a private channel, no Thread network attach.
|
||||
CONFIG_IEEE802154_ENABLED=y
|
||||
CONFIG_OPENTHREAD_ENABLED=n
|
||||
|
||||
# ADR-110 P4: 802.15.4 channel override.
|
||||
# Default Kconfig value is 15 (2425 MHz). On the 2.4 GHz radio that's
|
||||
# directly under WiFi channel 5 (2432 MHz). Channel 26 = 2480 MHz is on
|
||||
# the WiFi guard band above channel 14, giving the 15.4 path room to RX
|
||||
# without competing with WiFi traffic for radio time.
|
||||
CONFIG_C6_TIMESYNC_CHANNEL=26
|
||||
|
||||
# ── ADR-110 P5: LP-core (deep-sleep coprocessor) ──
|
||||
# Enable the LP RISC-V core so c6_lp_core.c can ship a wake-on-motion stub.
|
||||
CONFIG_ULP_COPROC_ENABLED=y
|
||||
CONFIG_ULP_COPROC_TYPE_LP_CORE=y
|
||||
CONFIG_ULP_COPROC_RESERVE_MEM=8192
|
||||
|
||||
# ── No display, no WASM, no mmWave on the C6 research target ──
|
||||
# Display (ADR-045) needs 8 MB + native USB-OTG framebuffer hooks.
|
||||
# WASM3 (ADR-040) needs PSRAM for hot-loadable modules.
|
||||
# mmWave (Seeed MR60BHA2 on COM4) is a separate board.
|
||||
# CONFIG_DISPLAY_ENABLE is not set
|
||||
# CONFIG_WASM_ENABLE is not set
|
||||
|
||||
# ── Compiler ──
|
||||
CONFIG_COMPILER_OPTIMIZATION_SIZE=y
|
||||
|
||||
# ── Logging ──
|
||||
CONFIG_BOOTLOADER_LOG_LEVEL_WARN=y
|
||||
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
|
||||
|
||||
# ── lwIP / FreeRTOS — same as S3 path ──
|
||||
CONFIG_LWIP_SO_RCVBUF=y
|
||||
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
|
||||
CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=8192
|
||||
|
||||
# ── Power: keep CPU at max 160 MHz (C6 ceiling) for DSP throughput ──
|
||||
CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_160=y
|
||||
CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ=160
|
||||
@@ -1,28 +0,0 @@
|
||||
# ADR-110 apples-to-apples S3 overlay for fair vs-C6 size comparison.
|
||||
# Same target as production S3 but with the features that aren't on C6 disabled:
|
||||
# - No AMOLED display (ADR-045 — C6 has no PSRAM for framebuffers)
|
||||
# - No WASM3 (ADR-040 — same reason)
|
||||
# - No mmWave fusion (separate board)
|
||||
# This is NOT a production build — only used to answer "is C6 smaller than S3
|
||||
# once you strip the S3-only features?"
|
||||
#
|
||||
# Build:
|
||||
# cp sdkconfig.defaults.s3-fair sdkconfig.defaults && idf.py set-target esp32s3 && idf.py build
|
||||
# # Restore default: git checkout sdkconfig.defaults
|
||||
|
||||
CONFIG_IDF_TARGET="esp32s3"
|
||||
CONFIG_PARTITION_TABLE_CUSTOM=y
|
||||
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions_4mb.csv"
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE="4MB"
|
||||
CONFIG_COMPILER_OPTIMIZATION_SIZE=y
|
||||
CONFIG_ESP_WIFI_CSI_ENABLED=y
|
||||
CONFIG_BOOTLOADER_LOG_LEVEL_WARN=y
|
||||
CONFIG_LOG_DEFAULT_LEVEL_INFO=y
|
||||
CONFIG_LWIP_SO_RCVBUF=y
|
||||
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
|
||||
CONFIG_FREERTOS_TIMER_TASK_STACK_DEPTH=8192
|
||||
|
||||
# Disable display + WASM + mmWave for apples-to-apples vs C6.
|
||||
# CONFIG_DISPLAY_ENABLE is not set
|
||||
# CONFIG_WASM_ENABLE is not set
|
||||
@@ -20,11 +20,6 @@
|
||||
# FUZZ_JOBS=4 # Parallel fuzzing jobs
|
||||
|
||||
CC = clang
|
||||
# ADR-110: -DCONFIG_CSI_FRAME_HE_TAGGING=1 enables the byte-18/19 HE path
|
||||
# in csi_collector.c so the fuzzer exercises that code as well as the
|
||||
# legacy zero-fill path. CONFIG_SOC_WIFI_HE_SUPPORT is left UNSET to
|
||||
# exercise the legacy S3 branch (sig_mode/cwb/stbc). Add it to CFLAGS for
|
||||
# a parallel HE-stub build if you want fuzz coverage of the C6 branch.
|
||||
CFLAGS = -fsanitize=fuzzer,address,undefined -g -O1 \
|
||||
-Istubs -I../main \
|
||||
-DCONFIG_CSI_NODE_ID=1 \
|
||||
@@ -33,7 +28,6 @@ CFLAGS = -fsanitize=fuzzer,address,undefined -g -O1 \
|
||||
-DCONFIG_CSI_TARGET_IP=\"192.168.1.1\" \
|
||||
-DCONFIG_CSI_TARGET_PORT=5500 \
|
||||
-DCONFIG_ESP_WIFI_CSI_ENABLED=1 \
|
||||
-DCONFIG_CSI_FRAME_HE_TAGGING=1 \
|
||||
-Wno-unused-function
|
||||
|
||||
STUBS_SRC = stubs/esp_stubs.c
|
||||
@@ -43,22 +37,9 @@ MAIN_DIR = ../main
|
||||
FUZZ_DURATION ?= 30
|
||||
FUZZ_JOBS ?= 1
|
||||
|
||||
.PHONY: all clean run_serialize run_edge run_nvs run_all test_adr110 run_adr110 host_tests
|
||||
.PHONY: all clean run_serialize run_edge run_nvs run_all
|
||||
|
||||
all: fuzz_serialize fuzz_edge fuzz_nvs test_adr110
|
||||
|
||||
# --- ADR-110 encoding unit tests ---
|
||||
# Host-side, no libFuzzer needed — plain C99 deterministic table tests
|
||||
# for mac_to_eui64() and PPDU-type → ADR-018 byte 18 mapping.
|
||||
# Builds with stock cc/gcc/clang — runs in CI on Ubuntu.
|
||||
test_adr110: test_adr110_encoding.c
|
||||
cc -std=c99 -Wall -Wextra -o $@ $<
|
||||
|
||||
run_adr110: test_adr110
|
||||
./test_adr110
|
||||
|
||||
host_tests: run_adr110
|
||||
@echo "ADR-110 host tests passed"
|
||||
all: fuzz_serialize fuzz_edge fuzz_nvs
|
||||
|
||||
# --- Serialize fuzzer ---
|
||||
# Tests csi_serialize_frame() with random wifi_csi_info_t inputs.
|
||||
@@ -94,5 +75,5 @@ run_nvs: fuzz_nvs
|
||||
run_all: run_serialize run_edge run_nvs
|
||||
|
||||
clean:
|
||||
rm -f fuzz_serialize fuzz_edge fuzz_nvs test_adr110
|
||||
rm -f fuzz_serialize fuzz_edge fuzz_nvs
|
||||
rm -rf corpus_serialize/ corpus_edge/ corpus_nvs/
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
"""ADR-110 multi-board live capture — 802.15.4 sync + TWT + HE-LTF.
|
||||
|
||||
Captures from up to 3 ESP32-C6 boards simultaneously, resets them
|
||||
together so the leader election starts from a clean slate, then
|
||||
records 35 s of serial output to per-port log files and prints
|
||||
a summary of the time-sync state machine, TWT events, and CSI
|
||||
metadata at the end.
|
||||
"""
|
||||
import serial
|
||||
import threading
|
||||
import time
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PORTS = ['COM6', 'COM9', 'COM12']
|
||||
DURATION_SECONDS = 35
|
||||
OUTPUT_DIR = Path(__file__).parent / 'witness-3board'
|
||||
OUTPUT_DIR.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
def capture(port: str, results: dict):
|
||||
"""Reset and capture from one port for DURATION_SECONDS."""
|
||||
try:
|
||||
ser = serial.Serial(port, 115200, timeout=1)
|
||||
# Hard reset via DTR/RTS pulse.
|
||||
ser.setDTR(False); ser.setRTS(True); time.sleep(0.05)
|
||||
ser.setDTR(False); ser.setRTS(False)
|
||||
ser.reset_input_buffer()
|
||||
buf = bytearray()
|
||||
start = time.time()
|
||||
while time.time() - start < DURATION_SECONDS:
|
||||
data = ser.read(4096)
|
||||
if data:
|
||||
buf.extend(data)
|
||||
ser.close()
|
||||
log_path = OUTPUT_DIR / f'{port}.log'
|
||||
log_path.write_bytes(bytes(buf))
|
||||
text = bytes(buf).decode('utf-8', errors='replace')
|
||||
results[port] = text
|
||||
print(f'[{port}] {len(buf)} bytes captured -> {log_path}')
|
||||
except Exception as e:
|
||||
print(f'[{port}] ERROR: {e}')
|
||||
results[port] = None
|
||||
|
||||
|
||||
# Launch 3 capture threads — actual concurrent reset + capture.
|
||||
results = {}
|
||||
threads = [threading.Thread(target=capture, args=(p, results)) for p in PORTS]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
|
||||
# ── Analyze ────────────────────────────────────────────────────────────
|
||||
|
||||
def grep_pattern(text: str, pattern: str, n: int = 8):
|
||||
rx = re.compile(pattern)
|
||||
return [L.strip() for L in (text or '').split('\n') if rx.search(L)][:n]
|
||||
|
||||
|
||||
print('\n' + '='*78)
|
||||
print('ADR-110 multi-board capture summary')
|
||||
print('='*78)
|
||||
|
||||
|
||||
for port in PORTS:
|
||||
text = results.get(port)
|
||||
if not text:
|
||||
print(f'\n--- {port}: NO DATA ---')
|
||||
continue
|
||||
print(f'\n--- {port} ---')
|
||||
|
||||
# Boot banner
|
||||
for L in grep_pattern(text, r'main: ESP32-C6.*Node ID', 2):
|
||||
print(f' banner : {L}')
|
||||
|
||||
# Time-sync init (802.15.4 path — known broken D1)
|
||||
for L in grep_pattern(text, r'c6_ts:.*(init done|promot|stepping down|tx fail)', 4):
|
||||
print(f' c6_ts : {L}')
|
||||
|
||||
# ESP-NOW sync (D1 workaround, working path)
|
||||
for L in grep_pattern(text, r'c6_espnow:.*(init done|promot|stepping down|tx#\d)', 6):
|
||||
print(f' c6_espnow: {L}')
|
||||
|
||||
# WiFi mode + connect status
|
||||
for L in grep_pattern(text, r'(wifi:mode|wifi:state|Retrying WiFi|got ip|Connected to WiFi)', 6):
|
||||
print(f' wifi : {L}')
|
||||
|
||||
# TWT events
|
||||
for L in grep_pattern(text, r'c6_twt|itwt|TWT', 5):
|
||||
print(f' twt : {L}')
|
||||
|
||||
# CSI callbacks
|
||||
for L in grep_pattern(text, r'CSI cb #\d+.*len=', 5):
|
||||
print(f' csi_cb : {L}')
|
||||
|
||||
# 11ax MAC firmware
|
||||
for L in grep_pattern(text, r'mac_version:HAL_MAC_ESP32AX', 2):
|
||||
print(f' he-mac : {L}')
|
||||
|
||||
|
||||
# Cross-board leader election summary
|
||||
print('\n' + '='*78)
|
||||
print('Leader election analysis')
|
||||
print('='*78)
|
||||
eui_re = re.compile(r'EUI=([0-9a-fA-F]+)')
|
||||
euis = {}
|
||||
for port in PORTS:
|
||||
text = results.get(port) or ''
|
||||
m = eui_re.search(text)
|
||||
if m:
|
||||
euis[port] = int(m.group(1), 16)
|
||||
print(f' {port} EUI=0x{m.group(1).lower()} -> {"LEADER" if False else "candidate"}')
|
||||
|
||||
if len(euis) >= 2:
|
||||
lowest_port = min(euis, key=euis.get)
|
||||
print(f'\n lowest EUI -> expected leader: {lowest_port} (0x{euis[lowest_port]:016x})')
|
||||
|
||||
# Did a "stepping down" log appear on the non-lowest boards?
|
||||
for port in PORTS:
|
||||
if port == lowest_port:
|
||||
continue
|
||||
text = results.get(port) or ''
|
||||
if 'stepping down' in text:
|
||||
print(f' {port}: [OK] stepped down (heard leader beacon)')
|
||||
elif port in euis:
|
||||
print(f' {port}: [FAIL] did NOT step down — investigate (own EUI=0x{euis[port]:016x}, expected leader=0x{euis[lowest_port]:016x})')
|
||||
@@ -60,10 +60,6 @@ int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size)
|
||||
uint8_t channel;
|
||||
int8_t noise_floor;
|
||||
uint8_t out_buf_scale; /* Controls output buffer size: 0-255. */
|
||||
/* ADR-110: fuzz the new HE-branch + legacy-branch input fields too so
|
||||
* the byte 18/19 encoding code path is exercised. */
|
||||
uint8_t he_inputs[2] = {0}; /* cur_bb_format (4 bits) + second (4 bits) packed */
|
||||
uint8_t legacy_inputs = 0; /* sig_mode (2) + cwb (1) + stbc (1) packed */
|
||||
|
||||
fuzz_read(&cursor, &remaining, &test_case, 1);
|
||||
fuzz_read(&cursor, &remaining, &iq_len_raw, 2);
|
||||
@@ -71,8 +67,6 @@ int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size)
|
||||
fuzz_read(&cursor, &remaining, &channel, 1);
|
||||
fuzz_read(&cursor, &remaining, &noise_floor, 1);
|
||||
fuzz_read(&cursor, &remaining, &out_buf_scale, 1);
|
||||
fuzz_read(&cursor, &remaining, he_inputs, 2);
|
||||
fuzz_read(&cursor, &remaining, &legacy_inputs, 1);
|
||||
|
||||
/* --- Test case 0: Normal operation with fuzz-controlled values --- */
|
||||
|
||||
@@ -81,15 +75,6 @@ int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size)
|
||||
info.rx_ctrl.rssi = rssi;
|
||||
info.rx_ctrl.channel = channel & 0x0F; /* 4-bit field */
|
||||
info.rx_ctrl.noise_floor = noise_floor;
|
||||
/* ADR-110: feed both branch families. Only the active branch (chosen
|
||||
* at compile time by CONFIG_SOC_WIFI_HE_SUPPORT) will read its fields;
|
||||
* the other set is set-but-not-read. Both must be assignable without
|
||||
* triggering UBSAN bitfield-overflow. */
|
||||
info.rx_ctrl.cur_bb_format = he_inputs[0] & 0x0F; /* 0..15 valid input space */
|
||||
info.rx_ctrl.second = he_inputs[1] & 0x0F;
|
||||
info.rx_ctrl.sig_mode = legacy_inputs & 0x03;
|
||||
info.rx_ctrl.cwb = (legacy_inputs >> 2) & 0x01;
|
||||
info.rx_ctrl.stbc = (legacy_inputs >> 3) & 0x01;
|
||||
|
||||
/* Use remaining fuzz data as I/Q buffer content. */
|
||||
uint16_t iq_len;
|
||||
|
||||
@@ -62,28 +62,14 @@ static inline esp_err_t esp_timer_delete(esp_timer_handle_t h) { (void)h; return
|
||||
|
||||
/* ---- esp_wifi_types.h ---- */
|
||||
|
||||
/** Minimal rx_ctrl fields needed by csi_serialize_frame.
|
||||
*
|
||||
* ADR-110: the HE-tagging path in csi_collector.c references either
|
||||
* (CONFIG_SOC_WIFI_HE_SUPPORT branch) cur_bb_format, second
|
||||
* (legacy / S3 branch) sig_mode, cwb, stbc
|
||||
*
|
||||
* Both sets are unconditionally declared here so a single stub builds
|
||||
* for either branch — the Makefile picks which side via -D flags. */
|
||||
/** Minimal rx_ctrl fields needed by csi_serialize_frame. */
|
||||
typedef struct {
|
||||
signed rssi : 8;
|
||||
unsigned channel : 4;
|
||||
unsigned noise_floor : 8;
|
||||
unsigned rx_ant : 2;
|
||||
/* ADR-110 HE-branch fields (CONFIG_SOC_WIFI_HE_SUPPORT path) */
|
||||
unsigned cur_bb_format : 4; /**< 0=11b 1=11g/a 2=HT 3=VHT 4=HE-SU 5=HE-MU 6=HE-ER-SU 7=HE-TB */
|
||||
unsigned second : 4; /**< secondary 40 MHz channel offset */
|
||||
/* ADR-110 legacy-branch fields (pre-HE chips) */
|
||||
unsigned sig_mode : 2; /**< 0=non-HT 1=HT 3=VHT */
|
||||
unsigned cwb : 1; /**< 0=20 MHz 1=40 MHz */
|
||||
unsigned stbc : 1; /**< STBC flag */
|
||||
/* Padding to keep alignment predictable. */
|
||||
unsigned _pad : 18;
|
||||
signed rssi : 8;
|
||||
unsigned channel : 4;
|
||||
unsigned noise_floor : 8;
|
||||
unsigned rx_ant : 2;
|
||||
/* Padding to fill out the struct so it compiles. */
|
||||
unsigned _pad : 10;
|
||||
} wifi_pkt_rx_ctrl_t;
|
||||
|
||||
/** Minimal wifi_csi_info_t needed by csi_serialize_frame. */
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
/**
|
||||
* @file test_adr110_encoding.c
|
||||
* @brief Host-side unit tests for ADR-110 pure functions.
|
||||
*
|
||||
* Covers the two encoding paths that don't need ESP-IDF runtime:
|
||||
* 1. mac_to_eui64() — IEEE EUI-64 from MAC-48 (c6_timesync.c)
|
||||
* 2. PPDU-type → ADR-018 byte 18 mapping for both HE-capable and
|
||||
* legacy paths (csi_collector.c)
|
||||
*
|
||||
* Build (Linux/macOS/Windows with any C99 compiler):
|
||||
* cc -std=c99 -Wall -o test_adr110 test_adr110_encoding.c && ./test_adr110
|
||||
*
|
||||
* Or in WSL on this Windows box:
|
||||
* gcc -std=c99 -Wall -o test_adr110 test_adr110_encoding.c && ./test_adr110
|
||||
*
|
||||
* Exits 0 on all-pass, prints which assertion failed otherwise.
|
||||
*
|
||||
* Why a separate host test file rather than extending the existing fuzz
|
||||
* harness: fuzzers want random bytes; these are deterministic table-driven
|
||||
* checks for tiny pure functions where libFuzzer adds no signal.
|
||||
*/
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────
|
||||
* System under test — copied verbatim from the firmware. If the
|
||||
* firmware copy changes, this test must be updated and the new behavior
|
||||
* attested by re-running the test before the firmware change merges.
|
||||
* ────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/* From firmware/esp32-csi-node/main/c6_timesync.c — fallback path used only
|
||||
* when esp_read_mac(..., ESP_MAC_IEEE802154) fails. The primary C6 path
|
||||
* reads 8 bytes directly (the eFuse-provided EUI-64). */
|
||||
static uint64_t mac48_to_eui64(const uint8_t mac[6])
|
||||
{
|
||||
return ((uint64_t)mac[0] << 56) | ((uint64_t)mac[1] << 48) |
|
||||
((uint64_t)mac[2] << 40) | ((uint64_t)0xFF << 32) |
|
||||
((uint64_t)0xFE << 24) | ((uint64_t)mac[3] << 16) |
|
||||
((uint64_t)mac[4] << 8 ) | (uint64_t)mac[5];
|
||||
}
|
||||
|
||||
/* Pack 8-byte EUI-64 buffer (as returned by ESP_MAC_IEEE802154) into u64. */
|
||||
static uint64_t eui64_bytes_to_u64(const uint8_t eui[8])
|
||||
{
|
||||
return ((uint64_t)eui[0] << 56) | ((uint64_t)eui[1] << 48) |
|
||||
((uint64_t)eui[2] << 40) | ((uint64_t)eui[3] << 32) |
|
||||
((uint64_t)eui[4] << 24) | ((uint64_t)eui[5] << 16) |
|
||||
((uint64_t)eui[6] << 8 ) | (uint64_t)eui[7];
|
||||
}
|
||||
|
||||
/* From firmware/esp32-csi-node/main/csi_collector.c — HE-capable branch.
|
||||
* Returns the ADR-018 byte-18 PPDU type. */
|
||||
static uint8_t ppdu_type_he(uint8_t cur_bb_format)
|
||||
{
|
||||
switch (cur_bb_format) {
|
||||
case 0:
|
||||
case 1:
|
||||
case 2: return 0; /* 11b/g/a/HT bucket */
|
||||
case 3: return 0; /* VHT */
|
||||
case 4: return 1; /* HE-SU */
|
||||
case 5: return 2; /* HE-MU */
|
||||
case 6: return 1; /* HE-ER-SU collapses to HE-SU */
|
||||
case 7: return 3; /* HE-TB */
|
||||
default: return 0xFF;
|
||||
}
|
||||
}
|
||||
|
||||
/* From csi_collector.c — legacy (non-HE) branch. */
|
||||
static uint8_t ppdu_type_legacy(uint8_t sig_mode)
|
||||
{
|
||||
switch (sig_mode) {
|
||||
case 0: return 0; /* non-HT */
|
||||
case 1: return 0; /* HT */
|
||||
case 3: return 0; /* VHT */
|
||||
default: return 0xFF;
|
||||
}
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────
|
||||
* Test harness
|
||||
* ────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
static int g_failed = 0;
|
||||
static int g_passed = 0;
|
||||
|
||||
#define CHECK_EQ_U64(label, got, expected) do { \
|
||||
if ((got) == (expected)) { g_passed++; } \
|
||||
else { \
|
||||
g_failed++; \
|
||||
printf("FAIL: %s — got=0x%016llx expected=0x%016llx\n", \
|
||||
(label), (unsigned long long)(got), \
|
||||
(unsigned long long)(expected)); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
#define CHECK_EQ_U8(label, got, expected) do { \
|
||||
if ((uint8_t)(got) == (uint8_t)(expected)) { g_passed++; } \
|
||||
else { \
|
||||
g_failed++; \
|
||||
printf("FAIL: %s — got=0x%02x expected=0x%02x\n", \
|
||||
(label), (unsigned)(got), (unsigned)(expected)); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────
|
||||
* EUI-64 tests
|
||||
*
|
||||
* IEEE 802 MAC-48 → EUI-64 spec: insert 0xFFFE between bytes 3 and 4
|
||||
* of the MAC. ADR-110's c6_timesync.c does exactly that, leaving the
|
||||
* U/L bit in byte 0 untouched (the c6 EUI then matches what `esp_read_mac
|
||||
* ESP_MAC_IEEE802154` returns).
|
||||
* ────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
static void test_eui64_fallback_zero_mac(void)
|
||||
{
|
||||
uint8_t mac[6] = {0, 0, 0, 0, 0, 0};
|
||||
/* mac48_to_eui64 inserts FFFE → 00 00 00 FF FE 00 00 00 */
|
||||
CHECK_EQ_U64("mac48->eui64 zero", mac48_to_eui64(mac), 0x000000FFFE000000ULL);
|
||||
}
|
||||
|
||||
static void test_eui64_fallback_all_ones(void)
|
||||
{
|
||||
uint8_t mac[6] = {0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF};
|
||||
/* FF FF FF FF FE FF FF FF */
|
||||
CHECK_EQ_U64("mac48->eui64 all-ones", mac48_to_eui64(mac), 0xFFFFFFFFFEFFFFFFULL);
|
||||
}
|
||||
|
||||
static void test_eui64_fallback_byte_order(void)
|
||||
{
|
||||
uint8_t mac[6] = {0x11, 0x22, 0x33, 0x44, 0x55, 0x66};
|
||||
CHECK_EQ_U64("mac48->eui64 byte order", mac48_to_eui64(mac), 0x112233FFFE445566ULL);
|
||||
}
|
||||
|
||||
/* Primary path: 8-byte EUI-64 from ESP_MAC_IEEE802154 packed unchanged.
|
||||
* Verified by esptool's chip_id output on the real C6 hardware:
|
||||
* COM6: BASE MAC 20:6e:f1:17:27:8c, MAC_EXT ff:fe →
|
||||
* full EUI: 20:6e:f1:ff:fe:17:27:8c → 0x206EF1FFFE17278C
|
||||
* COM9: BASE MAC 20:6e:f1:17:05:3c, MAC_EXT ff:fe →
|
||||
* full EUI: 20:6e:f1:ff:fe:17:05:3c → 0x206EF1FFFE17053C
|
||||
*
|
||||
* Note COM9's EUI is numerically smaller — it wins the leader election. */
|
||||
static void test_eui64_from_native_com6(void)
|
||||
{
|
||||
uint8_t eui[8] = {0x20, 0x6e, 0xf1, 0xff, 0xfe, 0x17, 0x27, 0x8c};
|
||||
CHECK_EQ_U64("native eui64 COM6", eui64_bytes_to_u64(eui), 0x206EF1FFFE17278CULL);
|
||||
}
|
||||
|
||||
static void test_eui64_from_native_com9(void)
|
||||
{
|
||||
uint8_t eui[8] = {0x20, 0x6e, 0xf1, 0xff, 0xfe, 0x17, 0x05, 0x3c};
|
||||
CHECK_EQ_U64("native eui64 COM9", eui64_bytes_to_u64(eui), 0x206EF1FFFE17053CULL);
|
||||
}
|
||||
|
||||
static void test_eui64_leader_election_order(void)
|
||||
{
|
||||
uint8_t com6[8] = {0x20, 0x6e, 0xf1, 0xff, 0xfe, 0x17, 0x27, 0x8c};
|
||||
uint8_t com9[8] = {0x20, 0x6e, 0xf1, 0xff, 0xfe, 0x17, 0x05, 0x3c};
|
||||
uint64_t a = eui64_bytes_to_u64(com6);
|
||||
uint64_t b = eui64_bytes_to_u64(com9);
|
||||
/* Lowest EUI wins → COM9 should be leader when both boards online. */
|
||||
if (b < a) { g_passed++; }
|
||||
else { g_failed++; printf("FAIL: leader-election order — expected COM9 < COM6\n"); }
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────
|
||||
* PPDU-type encoding tests — HE-capable branch (C6/C5)
|
||||
* ────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
static void test_ppdu_he_legacy_bucket(void)
|
||||
{
|
||||
CHECK_EQ_U8("he 0 → 0 (11b)", ppdu_type_he(0), 0);
|
||||
CHECK_EQ_U8("he 1 → 0 (11g/a)", ppdu_type_he(1), 0);
|
||||
CHECK_EQ_U8("he 2 → 0 (HT)", ppdu_type_he(2), 0);
|
||||
CHECK_EQ_U8("he 3 → 0 (VHT)", ppdu_type_he(3), 0);
|
||||
}
|
||||
|
||||
static void test_ppdu_he_su(void)
|
||||
{
|
||||
CHECK_EQ_U8("he 4 → 1 (HE-SU)", ppdu_type_he(4), 1);
|
||||
CHECK_EQ_U8("he 6 → 1 (HE-ER-SU)", ppdu_type_he(6), 1);
|
||||
}
|
||||
|
||||
static void test_ppdu_he_mu(void)
|
||||
{
|
||||
CHECK_EQ_U8("he 5 → 2 (HE-MU)", ppdu_type_he(5), 2);
|
||||
}
|
||||
|
||||
static void test_ppdu_he_tb(void)
|
||||
{
|
||||
CHECK_EQ_U8("he 7 → 3 (HE-TB)", ppdu_type_he(7), 3);
|
||||
}
|
||||
|
||||
static void test_ppdu_he_out_of_range(void)
|
||||
{
|
||||
CHECK_EQ_U8("he 8 → 0xFF (unknown)", ppdu_type_he(8), 0xFF);
|
||||
CHECK_EQ_U8("he 15 → 0xFF (unknown)", ppdu_type_he(15), 0xFF);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────
|
||||
* PPDU-type encoding tests — legacy (S3/etc) branch
|
||||
* ────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
static void test_ppdu_legacy_known(void)
|
||||
{
|
||||
CHECK_EQ_U8("legacy sig_mode 0 → 0 (non-HT)", ppdu_type_legacy(0), 0);
|
||||
CHECK_EQ_U8("legacy sig_mode 1 → 0 (HT)", ppdu_type_legacy(1), 0);
|
||||
CHECK_EQ_U8("legacy sig_mode 3 → 0 (VHT)", ppdu_type_legacy(3), 0);
|
||||
}
|
||||
|
||||
static void test_ppdu_legacy_unknown(void)
|
||||
{
|
||||
CHECK_EQ_U8("legacy sig_mode 2 → 0xFF", ppdu_type_legacy(2), 0xFF);
|
||||
CHECK_EQ_U8("legacy sig_mode 5 → 0xFF", ppdu_type_legacy(5), 0xFF);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────
|
||||
* main
|
||||
* ────────────────────────────────────────────────────────────────────── */
|
||||
|
||||
int main(void)
|
||||
{
|
||||
test_eui64_fallback_zero_mac();
|
||||
test_eui64_fallback_all_ones();
|
||||
test_eui64_fallback_byte_order();
|
||||
test_eui64_from_native_com6();
|
||||
test_eui64_from_native_com9();
|
||||
test_eui64_leader_election_order();
|
||||
|
||||
test_ppdu_he_legacy_bucket();
|
||||
test_ppdu_he_su();
|
||||
test_ppdu_he_mu();
|
||||
test_ppdu_he_tb();
|
||||
test_ppdu_he_out_of_range();
|
||||
|
||||
test_ppdu_legacy_known();
|
||||
test_ppdu_legacy_unknown();
|
||||
|
||||
printf("\n%d passed, %d failed\n", g_passed, g_failed);
|
||||
return g_failed == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
0.6.7
|
||||
0.6.6
|
||||
@@ -39,18 +39,18 @@ cp "$REPO_ROOT/docs/adr/ADR-028-esp32-capability-audit.md" "$BUNDLE_DIR/"
|
||||
# ---------------------------------------------------------------
|
||||
echo "[2/7] Copying proof system..."
|
||||
mkdir -p "$BUNDLE_DIR/proof"
|
||||
cp "$REPO_ROOT/archive/v1/data/proof/verify.py" "$BUNDLE_DIR/proof/"
|
||||
cp "$REPO_ROOT/archive/v1/data/proof/expected_features.sha256" "$BUNDLE_DIR/proof/"
|
||||
cp "$REPO_ROOT/archive/v1/data/proof/generate_reference_signal.py" "$BUNDLE_DIR/proof/"
|
||||
cp "$REPO_ROOT/v1/data/proof/verify.py" "$BUNDLE_DIR/proof/"
|
||||
cp "$REPO_ROOT/v1/data/proof/expected_features.sha256" "$BUNDLE_DIR/proof/"
|
||||
cp "$REPO_ROOT/v1/data/proof/generate_reference_signal.py" "$BUNDLE_DIR/proof/"
|
||||
# Reference signal is large (~10 MB) — include metadata only
|
||||
python3 -c "
|
||||
import json, os
|
||||
with open('$REPO_ROOT/archive/v1/data/proof/sample_csi_data.json') as f:
|
||||
with open('$REPO_ROOT/v1/data/proof/sample_csi_data.json') as f:
|
||||
d = json.load(f)
|
||||
meta = {k: v for k, v in d.items() if k != 'frames'}
|
||||
meta['frame_count'] = len(d['frames'])
|
||||
meta['first_frame_keys'] = list(d['frames'][0].keys())
|
||||
meta['file_size_bytes'] = os.path.getsize('$REPO_ROOT/archive/v1/data/proof/sample_csi_data.json')
|
||||
meta['file_size_bytes'] = os.path.getsize('$REPO_ROOT/v1/data/proof/sample_csi_data.json')
|
||||
with open('$BUNDLE_DIR/proof/reference_signal_metadata.json', 'w') as f:
|
||||
json.dump(meta, f, indent=2)
|
||||
" 2>/dev/null && echo " Reference signal metadata extracted." || echo " (Python not available — metadata skipped)"
|
||||
@@ -73,13 +73,7 @@ cd "$REPO_ROOT"
|
||||
# 4. Run Python proof verification
|
||||
# ---------------------------------------------------------------
|
||||
echo "[4/7] Running Python proof verification..."
|
||||
# SECURITY: the verify.py emits a Pydantic schema dump on validation failure
|
||||
# that includes the user's .env contents (Docker tokens, API keys, etc.).
|
||||
# Redact any line matching common secret-shaped patterns before writing the
|
||||
# bundled log. See ADR-110 wave 5 incident note.
|
||||
python3 "$REPO_ROOT/archive/v1/data/proof/verify.py" 2>&1 | \
|
||||
python3 "$REPO_ROOT/scripts/redact-secrets.py" \
|
||||
| tee "$BUNDLE_DIR/proof/verification-output.log" | tail -5 || true
|
||||
python3 "$REPO_ROOT/v1/data/proof/verify.py" 2>&1 | tee "$BUNDLE_DIR/proof/verification-output.log" | tail -5 || true
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 5. Firmware manifest
|
||||
@@ -95,21 +89,6 @@ if [ -d "$REPO_ROOT/firmware/esp32-csi-node/main" ]; then
|
||||
find "$REPO_ROOT/firmware/esp32-csi-node/main/" -type f \( -name "*.c" -o -name "*.h" \) -exec sha256sum {} \; \
|
||||
> "$BUNDLE_DIR/firmware-manifest/source-hashes.txt" 2>/dev/null || true
|
||||
echo " Firmware source files hashed."
|
||||
|
||||
# ADR-110: include pre-built S3 and C6 binary SHA-256s if archived
|
||||
for target in s3-adr110 c6-adr110; do
|
||||
if [ -d "$REPO_ROOT/firmware/esp32-csi-node/release_bins/$target" ]; then
|
||||
sha256sum "$REPO_ROOT/firmware/esp32-csi-node/release_bins/$target/"*.bin \
|
||||
> "$BUNDLE_DIR/firmware-manifest/binary-hashes-${target}.txt" 2>/dev/null \
|
||||
&& echo " Binary hashes recorded for $target."
|
||||
fi
|
||||
done
|
||||
|
||||
# ADR-110: list which ESP-IDF target(s) the firmware supports today
|
||||
cat > "$BUNDLE_DIR/firmware-manifest/supported-targets.txt" <<EOM
|
||||
esp32s3 (production CSI node — ADR-018, default sdkconfig.defaults, partitions_display.csv)
|
||||
esp32c6 (research target — ADR-110, sdkconfig.defaults.esp32c6 overlay, partitions_4mb.csv)
|
||||
EOM
|
||||
else
|
||||
echo " (No firmware directory found — skipped)"
|
||||
fi
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Pipe stdin through a secret-redaction filter to stdout.
|
||||
|
||||
Used by generate-witness-bundle.sh to strip credentials from log files
|
||||
before they enter the witness bundle. Pure stdlib so it runs anywhere.
|
||||
|
||||
Usage:
|
||||
some-command 2>&1 | python3 scripts/redact-secrets.py > clean.log
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
# Token prefix patterns — common SaaS / VCS API token shapes.
|
||||
PREFIX_PATTERNS = [
|
||||
(re.compile(r'(dckr_pat_|tok_|sk-|ghp_|gho_|github_pat_|AKIA|hf_|xoxb-|xoxp-|Bearer\s+)[A-Za-z0-9_\-\.]+',
|
||||
re.IGNORECASE), r'\1[REDACTED]'),
|
||||
]
|
||||
|
||||
# Long opaque strings (40+ alphanumeric / underscore / dash chars).
|
||||
LONG_OPAQUE = re.compile(r'[A-Za-z0-9_\-]{40,}')
|
||||
|
||||
# Long hex runs (20+ hex chars — covers token suffixes after `...`).
|
||||
LONG_HEX = re.compile(r'[a-fA-F0-9]{20,}')
|
||||
|
||||
# `field=VALUE` style assignment where field name suggests a secret.
|
||||
SECRET_ASSIGNMENT = re.compile(
|
||||
r'(token|password|secret|api_key|access_key|private_key|psk|bearer)'
|
||||
r'(["\'\s:=]+)["\']?([A-Za-z0-9._\-/+]{12,})["\']?',
|
||||
re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def redact_line(line: str) -> str:
|
||||
for pat, repl in PREFIX_PATTERNS:
|
||||
line = pat.sub(repl, line)
|
||||
line = SECRET_ASSIGNMENT.sub(lambda m: f'{m.group(1)}={"[REDACTED]"}', line)
|
||||
line = LONG_OPAQUE.sub('[REDACTED-OPAQUE]', line)
|
||||
line = LONG_HEX.sub('[REDACTED-HEX]', line)
|
||||
return line
|
||||
|
||||
|
||||
def main() -> int:
|
||||
for raw in sys.stdin.buffer:
|
||||
try:
|
||||
text = raw.decode('utf-8', errors='replace')
|
||||
except Exception:
|
||||
sys.stdout.buffer.write(b'[REDACTED-UNDECODABLE]\n')
|
||||
continue
|
||||
sys.stdout.write(redact_line(text))
|
||||
sys.stdout.flush()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,162 @@
|
||||
import pytest
|
||||
import re
|
||||
import os
|
||||
|
||||
|
||||
ADVERSARIAL_PAYLOADS = [
|
||||
# Null bytes and binary data
|
||||
b"\x00" * 100,
|
||||
b"\xff\xfe\xfd",
|
||||
b"\x00\x01\x02\x03",
|
||||
# Oversized inputs
|
||||
b"A" * 65536,
|
||||
b"B" * 1048576,
|
||||
# Format string attacks
|
||||
b"%s%s%s%s%s%s%s%s%s%s",
|
||||
b"%x%x%x%x%x%x%x%x",
|
||||
b"%n%n%n%n",
|
||||
# SQL injection patterns
|
||||
b"' OR '1'='1",
|
||||
b"'; DROP TABLE users; --",
|
||||
b"1; SELECT * FROM secrets",
|
||||
# Path traversal
|
||||
b"../../../etc/passwd",
|
||||
b"..\\..\\..\\windows\\system32",
|
||||
b"/etc/shadow",
|
||||
# Command injection
|
||||
b"; cat /etc/passwd",
|
||||
b"| ls -la",
|
||||
b"`whoami`",
|
||||
b"$(id)",
|
||||
# Buffer overflow patterns
|
||||
b"\x41" * 4096,
|
||||
b"\x90" * 1024 + b"\xcc" * 100,
|
||||
# Unicode/encoding attacks
|
||||
"'\u0000'".encode("utf-8"),
|
||||
"\uFFFD\uFFFE\uFFFF".encode("utf-8"),
|
||||
# Empty and whitespace
|
||||
b"",
|
||||
b" ",
|
||||
b"\t\n\r",
|
||||
# Version string injection
|
||||
b"openssl-1.0.1e",
|
||||
b"openssl 1.0.1f",
|
||||
b"1.0.1g",
|
||||
# Malformed version strings
|
||||
b"999.999.999",
|
||||
b"-1.-1.-1",
|
||||
b"0.0.0",
|
||||
# Special characters
|
||||
b"!@#$%^&*()",
|
||||
b"<script>alert(1)</script>",
|
||||
b"<?xml version='1.0'?><!DOCTYPE foo [<!ENTITY xxe SYSTEM 'file:///etc/passwd'>]>",
|
||||
]
|
||||
|
||||
|
||||
def parse_cargo_lock_openssl_version(content: str) -> list:
|
||||
"""Extract openssl-related package versions from Cargo.lock content."""
|
||||
versions = []
|
||||
lines = content.split('\n')
|
||||
in_openssl_package = False
|
||||
current_name = None
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if line.startswith('name = '):
|
||||
current_name = line.split('=', 1)[1].strip().strip('"')
|
||||
in_openssl_package = 'openssl' in current_name.lower()
|
||||
elif in_openssl_package and line.startswith('version = '):
|
||||
version_str = line.split('=', 1)[1].strip().strip('"')
|
||||
versions.append((current_name, version_str))
|
||||
|
||||
return versions
|
||||
|
||||
|
||||
def is_safe_version_string(version_str: str) -> bool:
|
||||
"""Check that a version string only contains safe characters."""
|
||||
safe_pattern = re.compile(r'^[0-9]+\.[0-9]+\.[0-9]+([.\-][a-zA-Z0-9]+)*$')
|
||||
return bool(safe_pattern.match(version_str))
|
||||
|
||||
|
||||
def simulate_version_comparison(version_str: str) -> bool:
|
||||
"""Simulate version comparison without executing arbitrary code."""
|
||||
try:
|
||||
parts = version_str.split('.')
|
||||
if len(parts) < 2:
|
||||
return False
|
||||
for part in parts[:3]:
|
||||
base = part.split('-')[0].split('+')[0]
|
||||
if base:
|
||||
int(base)
|
||||
return True
|
||||
except (ValueError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", ADVERSARIAL_PAYLOADS)
|
||||
def test_openssl_version_handling_security_invariant(payload):
|
||||
"""Invariant: Adversarial inputs must not cause unsafe behavior when processed
|
||||
as version strings or package metadata. Version parsing must remain safe and
|
||||
predictable regardless of input content."""
|
||||
|
||||
# Convert payload to string safely
|
||||
if isinstance(payload, bytes):
|
||||
try:
|
||||
payload_str = payload.decode('utf-8', errors='replace')
|
||||
except Exception:
|
||||
payload_str = repr(payload)
|
||||
else:
|
||||
payload_str = str(payload)
|
||||
|
||||
# Invariant 1: Version string validation must not crash
|
||||
try:
|
||||
is_safe = is_safe_version_string(payload_str)
|
||||
# If the payload is adversarial, it should NOT be considered a safe version
|
||||
if any(c in payload_str for c in [';', '|', '`', '$', '<', '>', '&', '\x00', '%n', '%s', '%x']):
|
||||
assert not is_safe, (
|
||||
f"Adversarial payload was incorrectly accepted as safe version: {repr(payload_str)}"
|
||||
)
|
||||
except Exception as e:
|
||||
pytest.fail(f"Version validation raised unexpected exception for payload {repr(payload_str)}: {e}")
|
||||
|
||||
# Invariant 2: Version comparison simulation must not execute arbitrary code
|
||||
try:
|
||||
result = simulate_version_comparison(payload_str)
|
||||
# Result must be a boolean - no side effects
|
||||
assert isinstance(result, bool), (
|
||||
f"Version comparison returned non-boolean for payload {repr(payload_str)}"
|
||||
)
|
||||
except Exception as e:
|
||||
pytest.fail(f"Version comparison raised unexpected exception for payload {repr(payload_str)}: {e}")
|
||||
|
||||
# Invariant 3: Cargo.lock-like content with adversarial version must be parseable safely
|
||||
fake_cargo_lock = f'''
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "{payload_str}"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
'''
|
||||
try:
|
||||
versions = parse_cargo_lock_openssl_version(fake_cargo_lock)
|
||||
# Must return a list (even if empty or with the injected value)
|
||||
assert isinstance(versions, list), (
|
||||
f"Parser returned non-list for payload {repr(payload_str)}"
|
||||
)
|
||||
# The parser must not execute any code from the payload
|
||||
for name, ver in versions:
|
||||
assert isinstance(name, str), "Package name must be a string"
|
||||
assert isinstance(ver, str), "Version must be a string"
|
||||
except Exception as e:
|
||||
pytest.fail(f"Cargo.lock parsing raised unexpected exception for payload {repr(payload_str)}: {e}")
|
||||
|
||||
# Invariant 4: No environment variables should be modified by processing the payload
|
||||
env_before = dict(os.environ)
|
||||
try:
|
||||
_ = is_safe_version_string(payload_str)
|
||||
_ = simulate_version_comparison(payload_str)
|
||||
except Exception:
|
||||
pass
|
||||
env_after = dict(os.environ)
|
||||
assert env_before == env_after, (
|
||||
f"Environment was modified while processing payload {repr(payload_str)}"
|
||||
)
|
||||
@@ -1,9 +1,19 @@
|
||||
// WebSocket Client for Three.js Visualization - WiFi DensePose
|
||||
// Connects to ws://localhost:8000/ws/pose and manages real-time data flow
|
||||
// Default endpoint is `/ws/sensing` on the same host the page was served from.
|
||||
// Callers (e.g. viz.html) usually pass an explicit `url` derived from
|
||||
// `buildSensingWsUrl()` so HTTP/WS port pairings are handled centrally.
|
||||
|
||||
function _defaultWsUrl() {
|
||||
if (typeof window === 'undefined' || !window.location) {
|
||||
return 'ws://localhost:8765/ws/sensing';
|
||||
}
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return `${protocol}//${window.location.host}/ws/sensing`;
|
||||
}
|
||||
|
||||
export class WebSocketClient {
|
||||
constructor(options = {}) {
|
||||
this.url = options.url || 'ws://localhost:8000/ws/pose';
|
||||
this.url = options.url || _defaultWsUrl();
|
||||
this.ws = null;
|
||||
this.state = 'disconnected'; // disconnected, connecting, connected, error
|
||||
this.isRealData = false;
|
||||
|
||||
@@ -27,6 +27,8 @@ export class ToastManager {
|
||||
action = null
|
||||
} = options;
|
||||
|
||||
if (!this.container) this.init();
|
||||
|
||||
const id = ++this.idCounter;
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
|
||||
+36
-14
@@ -84,22 +84,41 @@
|
||||
<div id="stats-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- Three.js and OrbitControls from CDN -->
|
||||
<script src="https://unpkg.com/three@0.160.0/build/three.min.js"></script>
|
||||
<script src="https://unpkg.com/three@0.160.0/examples/js/controls/OrbitControls.js"></script>
|
||||
<!-- Three.js r160 dropped examples/js/ UMD builds. Load via importmap and
|
||||
expose THREE + OrbitControls as a mutable global so the existing
|
||||
component modules (scene.js, body-model.js, …) keep working without
|
||||
a wider refactor. Note: `import * as THREE` returns a frozen Module
|
||||
Namespace Object — spread it into a plain object before attaching
|
||||
OrbitControls, otherwise the assignment silently no-ops. -->
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
|
||||
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<!-- Stats.js for performance monitoring -->
|
||||
<script src="https://unpkg.com/stats.js@0.17.0/build/stats.min.js"></script>
|
||||
|
||||
<!-- Application modules loaded as ES modules via importmap workaround -->
|
||||
<!-- All app code lives in one module so global THREE is installed before
|
||||
the component modules run. Two separate module scripts would race
|
||||
since each is independently async-resolved. -->
|
||||
<script type="module">
|
||||
// Import all modules
|
||||
import { Scene } from './components/scene.js';
|
||||
import { BodyModel, BodyModelManager } from './components/body-model.js';
|
||||
import { SignalVisualization } from './components/signal-viz.js';
|
||||
import { Environment } from './components/environment.js';
|
||||
import { DashboardHUD } from './components/dashboard-hud.js';
|
||||
import { WebSocketClient } from './services/websocket-client.js';
|
||||
import { DataProcessor } from './services/data-processor.js';
|
||||
import * as ThreeNS from 'three';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
const THREE = { ...ThreeNS, OrbitControls };
|
||||
window.THREE = THREE;
|
||||
|
||||
// Component modules use `THREE.*` as a global — must be installed first.
|
||||
const { Scene } = await import('./components/scene.js');
|
||||
const { BodyModel, BodyModelManager } = await import('./components/body-model.js');
|
||||
const { SignalVisualization } = await import('./components/signal-viz.js');
|
||||
const { Environment } = await import('./components/environment.js');
|
||||
const { DashboardHUD } = await import('./components/dashboard-hud.js');
|
||||
const { WebSocketClient } = await import('./services/websocket-client.js');
|
||||
const { DataProcessor } = await import('./services/data-processor.js');
|
||||
const { buildSensingWsUrl } = await import('./services/sensing.service.js');
|
||||
|
||||
// -- Application State --
|
||||
const state = {
|
||||
@@ -175,9 +194,12 @@
|
||||
state.stats = initStats();
|
||||
setLoadingProgress(85, 'Connecting to server...');
|
||||
|
||||
// 8. WebSocket client
|
||||
// 8. WebSocket client — derive URL from window.location so the page
|
||||
// works on both default (HTTP 8080 / WS 8765) and Docker (3000/3001)
|
||||
// port pairings. `?ws=…` query overrides for advanced setups.
|
||||
const wsOverride = new URLSearchParams(window.location.search).get('ws');
|
||||
state.wsClient = new WebSocketClient({
|
||||
url: 'ws://localhost:8000/ws/pose',
|
||||
url: wsOverride || buildSensingWsUrl(),
|
||||
onMessage: (msg) => handleWebSocketMessage(msg),
|
||||
onStateChange: (newState, oldState) => handleConnectionStateChange(newState, oldState),
|
||||
onError: (err) => console.error('[VIZ] WebSocket error:', err)
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
# cargo-audit configuration — v2 workspace
|
||||
# Managed by security audit (fix/security-audit-rustsec-clippy branch).
|
||||
#
|
||||
# This file suppresses advisories in two categories:
|
||||
# A) CVE-bearing advisories in TRANSITIVE deps we cannot upgrade directly
|
||||
# because the parent published crate (ruvector-core 2.2.0) has not yet
|
||||
# published a version with the fix. These are tracked as issues.
|
||||
# B) UNMAINTAINED-only advisories (no CVE) flowing through dependencies
|
||||
# that are purely transitive / build-time and have no user-facing attack
|
||||
# surface in this workspace.
|
||||
# Each entry documents the root cause and the mitigation path.
|
||||
|
||||
[advisories]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GTK3 / glib / gdk* family — RUSTSEC-2024-0411..0420, RUSTSEC-2024-0429
|
||||
# Reason: These crates are pulled in by wifi-densepose-desktop via Tauri v2's
|
||||
# native WebView dependencies on Linux (libwebkit2gtk-4.1). They are
|
||||
# flagged as unmaintained because the GTK3 Rust bindings maintainers have
|
||||
# moved to GTK4. This codebase does NOT make direct use of any of the
|
||||
# deprecated GTK3 APIs — the dependency is a runtime linker artifact of
|
||||
# the Tauri Linux build. Tauri itself is aware of this and will migrate
|
||||
# when a GTK4-based Tauri backend is stable. No CVE assigned.
|
||||
# Mitigation: Accept transitively until Tauri v2 drops GTK3 or a workspace
|
||||
# override path becomes available.
|
||||
ignore = [
|
||||
# -----------------------------------------------------------------------
|
||||
# CATEGORY A — transitive CVEs from ruvector-core 2.2.0 → reqwest 0.11
|
||||
# ruvector-core 2.2.0 (latest on crates.io) depends on reqwest 0.11.27,
|
||||
# which pulls in rustls 0.21 / rustls-webpki 0.101.7. We cannot upgrade
|
||||
# this without a new ruvector-core release. Tracked in issue #812.
|
||||
# The workspace's own TLS stack uses rustls-webpki 0.103.13 (patched);
|
||||
# the vulnerable 0.101.7 instance is not reachable from our TLS code.
|
||||
"RUSTSEC-2026-0098", # rustls-webpki 0.101.7: URI name constraint bypass
|
||||
"RUSTSEC-2026-0099", # rustls-webpki 0.101.7: wildcard name constraint bypass
|
||||
"RUSTSEC-2026-0104", # rustls-webpki 0.101.7: reachable panic in CRL parsing
|
||||
# quinn-proto 0.11.13 is also pulled through midstreamer-quic 0.3 (now
|
||||
# upgraded). The remaining 0.11.13 instance comes from the same
|
||||
# ruvector-core transitive chain. Tracked in issue #812.
|
||||
"RUSTSEC-2026-0037", # quinn-proto 0.11.13: DoS in Quinn endpoints
|
||||
# CRL Distribution Point matching bug — same ruvector-core / reqwest 0.11
|
||||
# transitive chain; rustls-webpki 0.101.7 also affected.
|
||||
"RUSTSEC-2026-0049", # rustls-webpki <0.103.10: CRL authority matching
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# CATEGORY B — unmaintained / no CVE
|
||||
"RUSTSEC-2024-0411", # gdkwayland-sys: unmaintained
|
||||
"RUSTSEC-2024-0412", # gdk: unmaintained
|
||||
"RUSTSEC-2024-0413", # atk: unmaintained
|
||||
"RUSTSEC-2024-0414", # gdkx11-sys: unmaintained
|
||||
"RUSTSEC-2024-0415", # gtk: unmaintained
|
||||
"RUSTSEC-2024-0416", # atk-sys: unmaintained
|
||||
"RUSTSEC-2024-0417", # gdkx11: unmaintained
|
||||
"RUSTSEC-2024-0418", # gdk-sys: unmaintained
|
||||
"RUSTSEC-2024-0419", # gtk3-macros: unmaintained
|
||||
"RUSTSEC-2024-0420", # gtk-sys: unmaintained
|
||||
"RUSTSEC-2024-0429", # glib: unsound — same GTK3/glib binding family,
|
||||
# also flagged as unmaintained; no CVE; same
|
||||
# mitigation path as above.
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# atomic-polyfill — RUSTSEC-2023-0089
|
||||
# Pulled in by embedded / WASM crates. Unmaintained (superseded by
|
||||
# portable-atomic). No CVE. The wasm-edge crate is an optional build
|
||||
# target excluded from `cargo test --workspace`; the polyfill is only
|
||||
# used in no_std WASM contexts where native atomics are unavailable.
|
||||
# Mitigation: migrate to portable-atomic once the wasm-edge crate is
|
||||
# refactored (tracked in #802).
|
||||
"RUSTSEC-2023-0089", # atomic-polyfill: unmaintained
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# bincode — RUSTSEC-2025-0141
|
||||
# Unmaintained (v1 — superseded by bincode v2/v3). No CVE. Used only
|
||||
# in benchmark harnesses inside criterion 0.5. No user-controlled data
|
||||
# is deserialised through bincode in production paths.
|
||||
# Mitigation: upgrade criterion to 0.6+ when available and stable.
|
||||
"RUSTSEC-2025-0141", # bincode: unmaintained
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# fxhash — RUSTSEC-2025-0057
|
||||
# Unmaintained (superseded by rustc-hash). No CVE. Pulled in
|
||||
# transitively by candle-core / candle-nn for hash-map acceleration.
|
||||
# Not used directly; no user-controlled input reaches fxhash.
|
||||
# Mitigation: accept until candle-core 0.5+ drops the dep.
|
||||
"RUSTSEC-2025-0057", # fxhash: unmaintained
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# lru — RUSTSEC-2026-0002
|
||||
# Unsound: LRU eviction can trigger a use-after-free in pathological
|
||||
# sequences of insertions/removals combined with raw pointer access.
|
||||
# No CVE; only reachable through deliberate internal misuse. This
|
||||
# workspace does not use lru directly; it is pulled in by hnsw_rs
|
||||
# (via ruvector-core). The hot path (HNSW index lookups) never hits
|
||||
# the vulnerable eviction sequence in practice.
|
||||
# Mitigation: track hnsw_rs upgrade to lru >=0.14 (issue #809).
|
||||
"RUSTSEC-2026-0002", # lru: unsound
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# number_prefix — RUSTSEC-2025-0119
|
||||
# Unmaintained. No CVE. Pulled in by indicatif 0.17 (progress bars).
|
||||
# Purely a display-side dependency; no security surface.
|
||||
# Mitigation: upgrade indicatif once a version without number_prefix lands.
|
||||
"RUSTSEC-2025-0119", # number_prefix: unmaintained
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# paste — RUSTSEC-2024-0436
|
||||
# Unmaintained. No CVE. Proc-macro used at build time by napi-derive
|
||||
# and CUDA bindings. No runtime exposure.
|
||||
"RUSTSEC-2024-0436", # paste: unmaintained
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# proc-macro-error — RUSTSEC-2024-0370
|
||||
# Unmaintained. No CVE. Build-time proc-macro; zero runtime exposure.
|
||||
"RUSTSEC-2024-0370", # proc-macro-error: unmaintained
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# rand <0.9 — RUSTSEC-2026-0097
|
||||
# Unsound: the rand 0.8 BlockRng64 implementation can panic and expose
|
||||
# uninitialized memory under certain reseeding sequences. No CVE.
|
||||
# This workspace uses rand 0.8 only through ndarray-linalg and candle
|
||||
# for signal-processing RNG; it does not rely on BlockRng64 directly.
|
||||
# Mitigation: migrate to rand 0.9 once ndarray-linalg 0.19+ is released
|
||||
# (blocked on openblas-static update, tracked in #810).
|
||||
"RUSTSEC-2026-0097", # rand <0.9: unsound
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# rkyv 0.8.x — RUSTSEC-2026-0122
|
||||
# Unsound: potential use-after-free in InlineVec/SerVec clear paths.
|
||||
# No CVE. Pulled in by ruvector-core for zero-copy serialisation of
|
||||
# vector index snapshots. The affected code path requires a panic
|
||||
# inside clear() which only occurs in out-of-memory conditions; the
|
||||
# application handles OOM at a higher level.
|
||||
# Mitigation: track rkyv 0.8.16+ fix once released (issue #811).
|
||||
"RUSTSEC-2026-0122", # rkyv 0.8.x: unsound
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# rustls-pemfile — RUSTSEC-2025-0134
|
||||
# Unmaintained. No CVE. Pulled in by reqwest 0.11 (via ruvector-core
|
||||
# 2.2.0). The workspace's own TLS code uses rustls-pemfile 2.x;
|
||||
# the 1.x instance is an artefact of the ruvector-core transitive dep.
|
||||
# Mitigation: resolve when ruvector-core upgrades to reqwest 0.12+.
|
||||
"RUSTSEC-2025-0134", # rustls-pemfile 1.x: unmaintained
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# unic-* family — RUSTSEC-2025-0075, -0080, -0081, -0098, -0100
|
||||
# Unmaintained (superseded by icu4x). No CVE. Used by napi-derive at
|
||||
# build time for Unicode identifier handling. Build-time only; no
|
||||
# runtime attack surface.
|
||||
"RUSTSEC-2025-0075", # unic-char-range
|
||||
"RUSTSEC-2025-0080", # unic-common
|
||||
"RUSTSEC-2025-0081", # unic-char-property
|
||||
"RUSTSEC-2025-0098", # unic-ucd-version
|
||||
"RUSTSEC-2025-0100", # unic-ucd-ident
|
||||
]
|
||||
Generated
+33
-82
@@ -1505,7 +1505,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users 0.5.2",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1726,7 +1726,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3134,7 +3134,7 @@ dependencies = [
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2 0.6.2",
|
||||
"socket2 0.5.10",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -3395,7 +3395,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3873,26 +3873,13 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "midstreamer-attractor"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab86df06cf1705ca37692b4fc0027868f92e5170a7ebb1d706302f04b6044f70"
|
||||
dependencies = [
|
||||
"midstreamer-temporal-compare 0.1.0",
|
||||
"nalgebra",
|
||||
"ndarray 0.16.1",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "midstreamer-attractor"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bebe548a4e74b80ecb8dd058e352a91fed9e5685c49c5d3fa5062520c660c6c9"
|
||||
dependencies = [
|
||||
"midstreamer-temporal-compare 0.2.1",
|
||||
"midstreamer-temporal-compare",
|
||||
"nalgebra",
|
||||
"ndarray 0.16.1",
|
||||
"serde",
|
||||
@@ -3901,18 +3888,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "midstreamer-quic"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35ad2099588e987cdbedb039fdf8a56163a2f3dc1ff6bf5a39c63b9ce4e2248c"
|
||||
checksum = "9d4dcf971dfa9eb5087e9c79e078f88c1508110bf010b8bb2d29b0b7229fd229"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"futures",
|
||||
"js-sys",
|
||||
"quinn",
|
||||
"rcgen",
|
||||
"rustls 0.22.4",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
@@ -3920,9 +3909,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "midstreamer-scheduler"
|
||||
version = "0.1.0"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9296b3f0a2b04e5c1a378ee7926e9f892895bface2ccebcfa407450c3aca269"
|
||||
checksum = "a8085dbcfb13808d075c0b31681022b41acc1c8021313d45fa7461e97d7767ff"
|
||||
dependencies = [
|
||||
"crossbeam",
|
||||
"parking_lot",
|
||||
@@ -3931,18 +3920,6 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "midstreamer-temporal-compare"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1f935ba86c1632a3b5bc5e1cb56a308d4c5d2ec87c84db551c65f3e1001a642"
|
||||
dependencies = [
|
||||
"dashmap",
|
||||
"lru",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "midstreamer-temporal-compare"
|
||||
version = "0.2.1"
|
||||
@@ -4319,7 +4296,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4661,15 +4638,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.75"
|
||||
version = "0.10.80"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
|
||||
checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"cfg-if",
|
||||
"foreign-types 0.3.2",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
@@ -4693,9 +4669,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.111"
|
||||
version = "0.9.116"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
|
||||
checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
@@ -4749,7 +4725,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5493,7 +5469,7 @@ dependencies = [
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls 0.23.37",
|
||||
"socket2 0.6.2",
|
||||
"socket2 0.5.10",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -5532,9 +5508,9 @@ dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.6.2",
|
||||
"socket2 0.5.10",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6172,7 +6148,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6187,20 +6163,6 @@ dependencies = [
|
||||
"sct",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432"
|
||||
dependencies = [
|
||||
"log",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki 0.102.8",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.37"
|
||||
@@ -6211,7 +6173,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki 0.103.9",
|
||||
"rustls-webpki 0.103.13",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -6261,11 +6223,11 @@ dependencies = [
|
||||
"rustls 0.23.37",
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki 0.103.9",
|
||||
"rustls-webpki 0.103.13",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6286,20 +6248,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.102.8"
|
||||
version = "0.103.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -7699,7 +7650,7 @@ dependencies = [
|
||||
"getrandom 0.4.1",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -9175,8 +9126,8 @@ dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"futures-util",
|
||||
"midstreamer-attractor 0.2.1",
|
||||
"midstreamer-temporal-compare 0.2.1",
|
||||
"midstreamer-attractor",
|
||||
"midstreamer-temporal-compare",
|
||||
"ruvector-mincut",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -9199,8 +9150,8 @@ version = "0.3.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"criterion",
|
||||
"midstreamer-attractor 0.1.0",
|
||||
"midstreamer-temporal-compare 0.1.0",
|
||||
"midstreamer-attractor",
|
||||
"midstreamer-temporal-compare",
|
||||
"ndarray 0.17.2",
|
||||
"ndarray-linalg",
|
||||
"num-complex",
|
||||
@@ -9318,7 +9269,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+7
-4
@@ -144,10 +144,13 @@ mockall = "0.12"
|
||||
wiremock = "0.5"
|
||||
|
||||
# midstreamer integration (published on crates.io)
|
||||
midstreamer-quic = "0.1.0"
|
||||
midstreamer-scheduler = "0.1.0"
|
||||
midstreamer-temporal-compare = "0.1.0"
|
||||
midstreamer-attractor = "0.1.0"
|
||||
# 0.1.0 was yanked; upgrade to latest 0.3/0.2 releases which pull in
|
||||
# quinn-proto >=0.11.14 (fixes RUSTSEC-2026-0037) and
|
||||
# rustls-webpki >=0.103.13 (fixes RUSTSEC-2026-0049/0098/0099/0104).
|
||||
midstreamer-quic = "0.3"
|
||||
midstreamer-scheduler = "0.2"
|
||||
midstreamer-temporal-compare = "0.2"
|
||||
midstreamer-attractor = "0.2"
|
||||
|
||||
# ruvector integration (published on crates.io)
|
||||
# Vendored at v2.1.0 in vendor/ruvector; using crates.io versions until published.
|
||||
|
||||
@@ -29,7 +29,10 @@ pub fn fuse_confidence_weighted(preds: &[CountPrediction]) -> CountPrediction {
|
||||
if preds.is_empty() {
|
||||
let mut probs = [0.0_f32; COUNT_CLASSES];
|
||||
probs[1] = 1.0;
|
||||
return CountPrediction { probs, confidence: 0.0 };
|
||||
return CountPrediction {
|
||||
probs,
|
||||
confidence: 0.0,
|
||||
};
|
||||
}
|
||||
if preds.len() == 1 {
|
||||
return preds[0].clone();
|
||||
@@ -44,9 +47,9 @@ pub fn fuse_confidence_weighted(preds: &[CountPrediction]) -> CountPrediction {
|
||||
// Log-sum.
|
||||
let mut log_p = [0.0_f32; COUNT_CLASSES];
|
||||
for (pred, &w) in preds.iter().zip(weights.iter()) {
|
||||
for k in 0..COUNT_CLASSES {
|
||||
let p = pred.probs[k].max(1e-9); // floor to avoid log(0)
|
||||
log_p[k] += (w / weight_sum) * p.ln();
|
||||
for (lp, &prob) in log_p.iter_mut().zip(pred.probs.iter()).take(COUNT_CLASSES) {
|
||||
let p = prob.max(1e-9); // floor to avoid log(0)
|
||||
*lp += (w / weight_sum) * p.ln();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,19 +57,26 @@ pub fn fuse_confidence_weighted(preds: &[CountPrediction]) -> CountPrediction {
|
||||
let m = log_p.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
|
||||
let mut p = [0.0_f32; COUNT_CLASSES];
|
||||
let mut s = 0.0_f32;
|
||||
for k in 0..COUNT_CLASSES {
|
||||
p[k] = (log_p[k] - m).exp();
|
||||
s += p[k];
|
||||
for (pk, &lp) in p.iter_mut().zip(log_p.iter()) {
|
||||
*pk = (lp - m).exp();
|
||||
s += *pk;
|
||||
}
|
||||
if s > 0.0 {
|
||||
for k in 0..COUNT_CLASSES { p[k] /= s; }
|
||||
for pk in p.iter_mut() {
|
||||
*pk /= s;
|
||||
}
|
||||
} else {
|
||||
// Pathological — fall back to uniform.
|
||||
for k in 0..COUNT_CLASSES { p[k] = 1.0 / COUNT_CLASSES as f32; }
|
||||
for pk in p.iter_mut() {
|
||||
*pk = 1.0 / COUNT_CLASSES as f32;
|
||||
}
|
||||
}
|
||||
|
||||
let conf = preds.iter().map(|x| x.confidence).fold(0.0_f32, f32::max);
|
||||
CountPrediction { probs: p, confidence: conf }
|
||||
CountPrediction {
|
||||
probs: p,
|
||||
confidence: conf,
|
||||
}
|
||||
}
|
||||
|
||||
/// **Stoer-Wagner-clipped fusion** — v0.2.0 hook.
|
||||
@@ -106,7 +116,10 @@ mod tests {
|
||||
use approx::assert_relative_eq;
|
||||
|
||||
fn pred(probs: [f32; 8], conf: f32) -> CountPrediction {
|
||||
CountPrediction { probs, confidence: conf }
|
||||
CountPrediction {
|
||||
probs,
|
||||
confidence: conf,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -133,14 +146,15 @@ mod tests {
|
||||
assert!(
|
||||
fused.probs[2] >= probs[2],
|
||||
"expected fusion to sharpen the peak: pre={} post={}",
|
||||
probs[2], fused.probs[2]
|
||||
probs[2],
|
||||
fused.probs[2]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_confidence_node_overrides_low_confidence_disagreement() {
|
||||
let strong = [0.0, 0.95, 0.05, 0.0, 0.0, 0.0, 0.0, 0.0]; // says 1
|
||||
let weak = [0.0, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.4]; // weak, says 7
|
||||
let weak = [0.0, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.4]; // weak, says 7
|
||||
let fused = fuse_confidence_weighted(&[pred(strong, 0.95), pred(weak, 0.05)]);
|
||||
assert_eq!(fused.argmax(), 1, "high-confidence vote should win");
|
||||
}
|
||||
@@ -174,8 +188,19 @@ mod tests {
|
||||
let probs = [0.05, 0.6, 0.25, 0.05, 0.03, 0.01, 0.005, 0.005];
|
||||
let p = pred(probs, 0.9);
|
||||
let (lo, hi) = p.p95_range();
|
||||
assert!(lo <= 1 && hi >= 1, "mode (1) must be inside [{}, {}]", lo, hi);
|
||||
assert!(
|
||||
lo <= 1 && hi >= 1,
|
||||
"mode (1) must be inside [{}, {}]",
|
||||
lo,
|
||||
hi
|
||||
);
|
||||
let mass: f32 = probs[lo..=hi].iter().sum();
|
||||
assert!(mass >= 0.95, "[{}, {}] only covers {:.3}, need >= 0.95", lo, hi, mass);
|
||||
assert!(
|
||||
mass >= 0.95,
|
||||
"[{}, {}] only covers {:.3}, need >= 0.95",
|
||||
lo,
|
||||
hi,
|
||||
mass
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,11 @@ impl CountPrediction {
|
||||
let mut acc = self.probs[mode];
|
||||
while acc < 0.95 && (lo > 0 || hi < COUNT_CLASSES - 1) {
|
||||
let left = if lo > 0 { self.probs[lo - 1] } else { -1.0 };
|
||||
let right = if hi < COUNT_CLASSES - 1 { self.probs[hi + 1] } else { -1.0 };
|
||||
let right = if hi < COUNT_CLASSES - 1 {
|
||||
self.probs[hi + 1]
|
||||
} else {
|
||||
-1.0
|
||||
};
|
||||
if left >= right && lo > 0 {
|
||||
lo -= 1;
|
||||
acc += self.probs[lo];
|
||||
@@ -102,25 +106,57 @@ impl CountNet {
|
||||
let conf = vb.pp("conf_head");
|
||||
|
||||
let c1 = candle_nn::conv1d(
|
||||
56, 64, 3,
|
||||
Conv1dConfig { padding: 1, stride: 1, dilation: 1, groups: 1, ..Default::default() },
|
||||
56,
|
||||
64,
|
||||
3,
|
||||
Conv1dConfig {
|
||||
padding: 1,
|
||||
stride: 1,
|
||||
dilation: 1,
|
||||
groups: 1,
|
||||
..Default::default()
|
||||
},
|
||||
enc.pp("c1"),
|
||||
)?;
|
||||
let c2 = candle_nn::conv1d(
|
||||
64, 128, 3,
|
||||
Conv1dConfig { padding: 2, stride: 1, dilation: 2, groups: 1, ..Default::default() },
|
||||
64,
|
||||
128,
|
||||
3,
|
||||
Conv1dConfig {
|
||||
padding: 2,
|
||||
stride: 1,
|
||||
dilation: 2,
|
||||
groups: 1,
|
||||
..Default::default()
|
||||
},
|
||||
enc.pp("c2"),
|
||||
)?;
|
||||
let c3 = candle_nn::conv1d(
|
||||
128, 128, 3,
|
||||
Conv1dConfig { padding: 4, stride: 1, dilation: 4, groups: 1, ..Default::default() },
|
||||
128,
|
||||
128,
|
||||
3,
|
||||
Conv1dConfig {
|
||||
padding: 4,
|
||||
stride: 1,
|
||||
dilation: 4,
|
||||
groups: 1,
|
||||
..Default::default()
|
||||
},
|
||||
enc.pp("c3"),
|
||||
)?;
|
||||
let count_fc1 = candle_nn::linear(128, 64, count.pp("fc1"))?;
|
||||
let count_fc2 = candle_nn::linear(64, COUNT_CLASSES, count.pp("fc2"))?;
|
||||
let conf_fc1 = candle_nn::linear(128, 32, conf.pp("fc1"))?;
|
||||
let conf_fc2 = candle_nn::linear(32, 1, conf.pp("fc2"))?;
|
||||
Ok(Self { c1, c2, c3, count_fc1, count_fc2, conf_fc1, conf_fc2 })
|
||||
Ok(Self {
|
||||
c1,
|
||||
c2,
|
||||
c3,
|
||||
count_fc1,
|
||||
count_fc2,
|
||||
conf_fc1,
|
||||
conf_fc2,
|
||||
})
|
||||
}
|
||||
|
||||
fn forward(&self, x: &Tensor) -> candle_core::Result<(Tensor, Tensor)> {
|
||||
@@ -193,7 +229,10 @@ impl InferenceEngine {
|
||||
// model yet" honestly instead of pretending to know.
|
||||
let mut probs = [0.0f32; COUNT_CLASSES];
|
||||
probs[1] = 1.0; // mass on "1 person"
|
||||
return Ok(CountPrediction { probs, confidence: 0.0 });
|
||||
return Ok(CountPrediction {
|
||||
probs,
|
||||
confidence: 0.0,
|
||||
});
|
||||
};
|
||||
|
||||
let t = Tensor::from_slice(
|
||||
@@ -204,25 +243,37 @@ impl InferenceEngine {
|
||||
let (probs_t, conf_t) = net.forward(&t)?;
|
||||
let flat: Vec<f32> = probs_t.flatten_all()?.to_vec1()?;
|
||||
if flat.len() != COUNT_CLASSES {
|
||||
return Err(format!("count head produced {} probs, expected {}", flat.len(), COUNT_CLASSES).into());
|
||||
return Err(format!(
|
||||
"count head produced {} probs, expected {}",
|
||||
flat.len(),
|
||||
COUNT_CLASSES
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let mut probs = [0.0f32; COUNT_CLASSES];
|
||||
probs.copy_from_slice(&flat[..COUNT_CLASSES]);
|
||||
let conf = conf_t.flatten_all()?.to_vec1::<f32>()?[0];
|
||||
|
||||
Ok(CountPrediction { probs, confidence: conf })
|
||||
Ok(CountPrediction {
|
||||
probs,
|
||||
confidence: conf,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SyntheticInput;
|
||||
|
||||
impl Default for SyntheticInput {
|
||||
fn default() -> Self { Self }
|
||||
fn default() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl SyntheticInput {
|
||||
pub fn as_window(&self) -> CsiWindow {
|
||||
CsiWindow { data: vec![0.0; INPUT_SUBCARRIERS * INPUT_TIMESTEPS] }
|
||||
CsiWindow {
|
||||
data: vec![0.0; INPUT_SUBCARRIERS * INPUT_TIMESTEPS],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
use cog_person_count::{
|
||||
inference::{InferenceEngine, SyntheticInput},
|
||||
publisher,
|
||||
COG_ID, COG_VERSION,
|
||||
publisher, COG_ID, COG_VERSION,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
@@ -43,8 +42,12 @@ struct RunConfig {
|
||||
poll_ms: u64,
|
||||
}
|
||||
|
||||
fn default_sensing_url() -> String { "http://127.0.0.1:3000/api/v1/sensing/latest".to_string() }
|
||||
fn default_poll_ms() -> u64 { 40 }
|
||||
fn default_sensing_url() -> String {
|
||||
"http://127.0.0.1:3000/api/v1/sensing/latest".to_string()
|
||||
}
|
||||
fn default_poll_ms() -> u64 {
|
||||
40
|
||||
}
|
||||
|
||||
fn main() -> std::process::ExitCode {
|
||||
init_logging();
|
||||
@@ -68,7 +71,7 @@ fn init_logging() {
|
||||
let _ = tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info"))
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.with_target(false)
|
||||
.try_init();
|
||||
@@ -80,22 +83,25 @@ fn cmd_version() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
fn cmd_manifest() -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("{}", serde_json::to_string_pretty(&json!({
|
||||
"id": COG_ID,
|
||||
"version": COG_VERSION,
|
||||
"binary_url": Value::Null,
|
||||
"binary_bytes": Value::Null,
|
||||
"binary_sha256": Value::Null,
|
||||
"binary_signature": Value::Null,
|
||||
"installed_at": Value::Null,
|
||||
"status": Value::Null,
|
||||
}))?);
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"id": COG_ID,
|
||||
"version": COG_VERSION,
|
||||
"binary_url": Value::Null,
|
||||
"binary_bytes": Value::Null,
|
||||
"binary_sha256": Value::Null,
|
||||
"binary_signature": Value::Null,
|
||||
"installed_at": Value::Null,
|
||||
"status": Value::Null,
|
||||
}))?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_health() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let engine = InferenceEngine::new()?;
|
||||
let pred = engine.infer(&SyntheticInput::default().as_window())?;
|
||||
let pred = engine.infer(&SyntheticInput.as_window())?;
|
||||
if !pred.is_finite() {
|
||||
return Err("inference produced non-finite output".into());
|
||||
}
|
||||
|
||||
@@ -35,7 +35,9 @@ pub async fn run_loop(
|
||||
buffer.drain(0..extra);
|
||||
}
|
||||
if buffer.len() >= cap {
|
||||
let window = CsiWindow { data: buffer[buffer.len() - cap..].to_vec() };
|
||||
let window = CsiWindow {
|
||||
data: buffer[buffer.len() - cap..].to_vec(),
|
||||
};
|
||||
if let Ok(pred) = engine.infer(&window) {
|
||||
// v0.0.1 ships single-node — fusion is a no-op for
|
||||
// N=1. v0.2.0 will append additional per-node
|
||||
|
||||
@@ -3,26 +3,30 @@
|
||||
use cog_person_count::{
|
||||
fusion::{fuse_confidence_weighted, fuse_with_mincut_clip},
|
||||
inference::{
|
||||
CountPrediction, CsiWindow, InferenceEngine, SyntheticInput,
|
||||
COUNT_CLASSES, INPUT_SUBCARRIERS, INPUT_TIMESTEPS,
|
||||
CountPrediction, CsiWindow, InferenceEngine, SyntheticInput, COUNT_CLASSES,
|
||||
INPUT_SUBCARRIERS, INPUT_TIMESTEPS,
|
||||
},
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn synthetic_window_has_correct_shape() {
|
||||
let w = SyntheticInput::default().as_window();
|
||||
let w = SyntheticInput.as_window();
|
||||
assert_eq!(w.data.len(), INPUT_SUBCARRIERS * INPUT_TIMESTEPS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stub_engine_returns_finite_output() {
|
||||
let engine = InferenceEngine::with_weights(None).expect("stub engine");
|
||||
let pred = engine.infer(&SyntheticInput::default().as_window()).expect("infer");
|
||||
let pred = engine.infer(&SyntheticInput.as_window()).expect("infer");
|
||||
assert!(pred.is_finite());
|
||||
assert_eq!(pred.probs.len(), COUNT_CLASSES);
|
||||
|
||||
let sum: f32 = pred.probs.iter().sum();
|
||||
assert!((sum - 1.0).abs() < 1e-5, "stub probs must sum to 1, got {}", sum);
|
||||
assert!(
|
||||
(sum - 1.0).abs() < 1e-5,
|
||||
"stub probs must sum to 1, got {}",
|
||||
sum
|
||||
);
|
||||
assert_eq!(pred.argmax(), 1, "stub default is 1-person");
|
||||
assert_eq!(pred.confidence, 0.0, "stub confidence is 0");
|
||||
}
|
||||
@@ -30,7 +34,9 @@ fn stub_engine_returns_finite_output() {
|
||||
#[test]
|
||||
fn engine_rejects_wrong_shape_input() {
|
||||
let engine = InferenceEngine::with_weights(None).expect("stub engine");
|
||||
let bad = CsiWindow { data: vec![0.0; 10] };
|
||||
let bad = CsiWindow {
|
||||
data: vec![0.0; 10],
|
||||
};
|
||||
assert!(engine.infer(&bad).is_err());
|
||||
}
|
||||
|
||||
@@ -47,7 +53,10 @@ fn p95_range_includes_mode() {
|
||||
probs[2] = 0.85;
|
||||
probs[1] = 0.08;
|
||||
probs[3] = 0.07;
|
||||
let p = CountPrediction { probs, confidence: 0.9 };
|
||||
let p = CountPrediction {
|
||||
probs,
|
||||
confidence: 0.9,
|
||||
};
|
||||
let (lo, hi) = p.p95_range();
|
||||
assert!(lo <= 2 && hi >= 2);
|
||||
}
|
||||
@@ -65,8 +74,11 @@ fn fusion_passes_through_single_node() {
|
||||
// raw inference — fusion is a no-op for N=1.
|
||||
let mut probs = [0.0_f32; COUNT_CLASSES];
|
||||
probs[3] = 1.0;
|
||||
let input = CountPrediction { probs, confidence: 0.6 };
|
||||
let out = fuse_confidence_weighted(&[input.clone()]);
|
||||
let input = CountPrediction {
|
||||
probs,
|
||||
confidence: 0.6,
|
||||
};
|
||||
let out = fuse_confidence_weighted(std::slice::from_ref(&input));
|
||||
assert_eq!(out.argmax(), 3);
|
||||
assert!((out.confidence - 0.6).abs() < 1e-6);
|
||||
}
|
||||
@@ -76,7 +88,10 @@ fn mincut_clip_with_high_cap_is_noop() {
|
||||
let mut probs = [0.0_f32; COUNT_CLASSES];
|
||||
probs[2] = 0.5;
|
||||
probs[3] = 0.5;
|
||||
let input = CountPrediction { probs, confidence: 0.7 };
|
||||
let input = CountPrediction {
|
||||
probs,
|
||||
confidence: 0.7,
|
||||
};
|
||||
let clipped = fuse_with_mincut_clip(&[input], 7);
|
||||
// No clip happened (cap == max class)
|
||||
assert!((clipped.probs[2] - 0.5).abs() < 1e-6);
|
||||
|
||||
@@ -41,8 +41,8 @@ fn default_min_confidence() -> f32 {
|
||||
|
||||
impl CogConfig {
|
||||
pub fn load(path: &Path) -> Result<Self, ConfigError> {
|
||||
let raw = std::fs::read_to_string(path)
|
||||
.map_err(|e| ConfigError::Read(path.to_path_buf(), e))?;
|
||||
let raw =
|
||||
std::fs::read_to_string(path).map_err(|e| ConfigError::Read(path.to_path_buf(), e))?;
|
||||
let cfg: CogConfig =
|
||||
serde_json::from_str(&raw).map_err(|e| ConfigError::Parse(path.to_path_buf(), e))?;
|
||||
Ok(cfg)
|
||||
|
||||
@@ -64,27 +64,51 @@ impl PoseNet {
|
||||
56,
|
||||
64,
|
||||
3,
|
||||
Conv1dConfig { padding: 1, stride: 1, dilation: 1, groups: 1, ..Default::default() },
|
||||
Conv1dConfig {
|
||||
padding: 1,
|
||||
stride: 1,
|
||||
dilation: 1,
|
||||
groups: 1,
|
||||
..Default::default()
|
||||
},
|
||||
enc.pp("c1"),
|
||||
)?;
|
||||
let c2 = candle_nn::conv1d(
|
||||
64,
|
||||
128,
|
||||
3,
|
||||
Conv1dConfig { padding: 2, stride: 1, dilation: 2, groups: 1, ..Default::default() },
|
||||
Conv1dConfig {
|
||||
padding: 2,
|
||||
stride: 1,
|
||||
dilation: 2,
|
||||
groups: 1,
|
||||
..Default::default()
|
||||
},
|
||||
enc.pp("c2"),
|
||||
)?;
|
||||
let c3 = candle_nn::conv1d(
|
||||
128,
|
||||
128,
|
||||
3,
|
||||
Conv1dConfig { padding: 4, stride: 1, dilation: 4, groups: 1, ..Default::default() },
|
||||
Conv1dConfig {
|
||||
padding: 4,
|
||||
stride: 1,
|
||||
dilation: 4,
|
||||
groups: 1,
|
||||
..Default::default()
|
||||
},
|
||||
enc.pp("c3"),
|
||||
)?;
|
||||
let fc1 = candle_nn::linear(128, 256, head.pp("fc1"))?;
|
||||
let fc2 = candle_nn::linear(256, 34, head.pp("fc2"))?;
|
||||
|
||||
Ok(Self { c1, c2, c3, fc1, fc2 })
|
||||
Ok(Self {
|
||||
c1,
|
||||
c2,
|
||||
c3,
|
||||
fc1,
|
||||
fc2,
|
||||
})
|
||||
}
|
||||
|
||||
/// Forward pass: `[B, 56, 20]` -> `[B, 34]` in `[0, 1]`.
|
||||
|
||||
@@ -89,14 +89,10 @@ fn cmd_manifest() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
fn cmd_health() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let engine = InferenceEngine::new()?;
|
||||
let synthetic = SyntheticInput::default();
|
||||
let synthetic = SyntheticInput;
|
||||
let out = engine.infer(&synthetic.as_window())?;
|
||||
if out.is_finite() {
|
||||
emit_event(&Event::health_ok(
|
||||
COG_ID,
|
||||
engine.backend(),
|
||||
out.confidence,
|
||||
));
|
||||
emit_event(&Event::health_ok(COG_ID, engine.backend(), out.confidence));
|
||||
Ok(())
|
||||
} else {
|
||||
Err("inference produced non-finite output".into())
|
||||
|
||||
@@ -4,13 +4,15 @@
|
||||
//! depend on a trained safetensors blob that doesn't live in-repo yet.
|
||||
|
||||
use cog_pose_estimation::{
|
||||
inference::{InferenceEngine, SyntheticInput, INPUT_SUBCARRIERS, INPUT_TIMESTEPS, OUTPUT_KEYPOINTS},
|
||||
inference::{
|
||||
InferenceEngine, SyntheticInput, INPUT_SUBCARRIERS, INPUT_TIMESTEPS, OUTPUT_KEYPOINTS,
|
||||
},
|
||||
manifest::ManifestSpec,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn synthetic_window_has_correct_shape() {
|
||||
let syn = SyntheticInput::default();
|
||||
let syn = SyntheticInput;
|
||||
let window = syn.as_window();
|
||||
assert_eq!(window.data.len(), INPUT_SUBCARRIERS * INPUT_TIMESTEPS);
|
||||
}
|
||||
@@ -18,17 +20,20 @@ fn synthetic_window_has_correct_shape() {
|
||||
#[test]
|
||||
fn engine_produces_finite_output_for_synthetic_input() {
|
||||
let engine = InferenceEngine::new().expect("engine init");
|
||||
let out = engine
|
||||
.infer(&SyntheticInput::default().as_window())
|
||||
.expect("infer");
|
||||
assert!(out.is_finite(), "synthetic input must produce finite output");
|
||||
let out = engine.infer(&SyntheticInput.as_window()).expect("infer");
|
||||
assert!(
|
||||
out.is_finite(),
|
||||
"synthetic input must produce finite output"
|
||||
);
|
||||
assert_eq!(out.keypoints.len(), OUTPUT_KEYPOINTS * 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_rejects_wrong_shape_input() {
|
||||
let engine = InferenceEngine::new().expect("engine init");
|
||||
let bad = cog_pose_estimation::inference::CsiWindow { data: vec![0.0; 10] };
|
||||
let bad = cog_pose_estimation::inference::CsiWindow {
|
||||
data: vec![0.0; 10],
|
||||
};
|
||||
assert!(engine.infer(&bad).is_err());
|
||||
}
|
||||
|
||||
@@ -47,14 +52,15 @@ fn real_weights_load_when_available() {
|
||||
"expected real Candle backend, got {}",
|
||||
engine.backend()
|
||||
);
|
||||
let out = engine
|
||||
.infer(&SyntheticInput::default().as_window())
|
||||
.expect("infer");
|
||||
let out = engine.infer(&SyntheticInput.as_window()).expect("infer");
|
||||
assert!(out.is_finite());
|
||||
// Real model emits the published validation PCK@50 as its self-reported
|
||||
// confidence — stub returns 0.0. This is the key assertion that proves
|
||||
// the cog isn't silently falling back to the stub.
|
||||
assert!(out.confidence > 0.0, "real model should emit non-zero confidence");
|
||||
assert!(
|
||||
out.confidence > 0.0,
|
||||
"real model should emit non-zero confidence"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -135,7 +135,10 @@ struct VerifyBody {
|
||||
expected_hex: String,
|
||||
}
|
||||
|
||||
/// Incoming request body for the `/step` endpoint.
|
||||
/// Fields are optional; unused ones are reserved for future extensions.
|
||||
#[derive(Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct StepReq {
|
||||
direction: Option<String>,
|
||||
dt_ms: Option<f64>,
|
||||
@@ -347,10 +350,7 @@ fn chrono_like_now() -> String {
|
||||
format!("{secs}-unix")
|
||||
}
|
||||
|
||||
async fn ws_handler(
|
||||
ws: WebSocketUpgrade,
|
||||
State(s): State<AppState>,
|
||||
) -> impl IntoResponse {
|
||||
async fn ws_handler(ws: WebSocketUpgrade, State(s): State<AppState>) -> impl IntoResponse {
|
||||
ws.on_upgrade(move |socket| handle_ws(socket, s))
|
||||
}
|
||||
|
||||
|
||||
@@ -238,9 +238,6 @@ mod tests {
|
||||
let x = (2.0 * std::f64::consts::PI * f_off * t).cos();
|
||||
last = lockin.process(x);
|
||||
}
|
||||
assert!(
|
||||
last.abs() < 0.1,
|
||||
"off-resonance output {last} should be ~0"
|
||||
);
|
||||
assert!(last.abs() < 0.1, "off-resonance output {last} should be ~0");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,7 +217,10 @@ mod tests {
|
||||
let mut bytes = MagFrame::empty(0).to_bytes();
|
||||
bytes[4..6].copy_from_slice(&99_u16.to_le_bytes());
|
||||
let err = MagFrame::from_bytes(&bytes).unwrap_err();
|
||||
assert!(matches!(err, crate::NvsimError::UnsupportedVersion { got: 99, .. }));
|
||||
assert!(matches!(
|
||||
err,
|
||||
crate::NvsimError::UnsupportedVersion { got: 99, .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -18,7 +18,7 @@ use crate::sensor::{NvSensor, NvSensorConfig};
|
||||
use crate::source::scene_field_at;
|
||||
|
||||
/// Pipeline configuration.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct PipelineConfig {
|
||||
/// Sensor / digitiser sampling parameters.
|
||||
pub digitiser: DigitiserConfig,
|
||||
@@ -28,16 +28,6 @@ pub struct PipelineConfig {
|
||||
pub dt_s: Option<f64>,
|
||||
}
|
||||
|
||||
impl Default for PipelineConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
digitiser: DigitiserConfig::default(),
|
||||
sensor: NvSensorConfig::default(),
|
||||
dt_s: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Forward-only NV-diamond pipeline.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Pipeline {
|
||||
@@ -50,14 +40,21 @@ impl Pipeline {
|
||||
/// Construct a pipeline. `seed` makes shot-noise reproducible — same
|
||||
/// `(scene, config, seed)` produces byte-identical output.
|
||||
pub fn new(scene: Scene, config: PipelineConfig, seed: u64) -> Self {
|
||||
Self { scene, config, seed }
|
||||
Self {
|
||||
scene,
|
||||
config,
|
||||
seed,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `n_samples` of the pipeline. Returns one [`MagFrame`] per
|
||||
/// (sensor × sample) — i.e. `n_samples · scene.sensors.len()` frames
|
||||
/// in scene-major / sample-minor order.
|
||||
pub fn run(&self, n_samples: usize) -> Vec<MagFrame> {
|
||||
let dt = self.config.dt_s.unwrap_or(1.0 / self.config.digitiser.f_s_hz);
|
||||
let dt = self
|
||||
.config
|
||||
.dt_s
|
||||
.unwrap_or(1.0 / self.config.digitiser.f_s_hz);
|
||||
let dt_us = (dt * 1.0e6) as u64;
|
||||
let nv = NvSensor::new(self.config.sensor);
|
||||
|
||||
@@ -82,11 +79,11 @@ impl Pipeline {
|
||||
// saturation flag if any axis clips.
|
||||
let mut adc_sat = false;
|
||||
let mut b_pt = [0.0_f32; 3];
|
||||
for k in 0..3 {
|
||||
for (k, b) in b_pt.iter_mut().enumerate() {
|
||||
let (code, sat) = adc_quantise(reading.b_recovered[k]);
|
||||
adc_sat |= sat;
|
||||
let recovered_t = code as f64 * crate::digitiser::ADC_LSB_T;
|
||||
b_pt[k] = (recovered_t * 1.0e12) as f32; // T → pT
|
||||
*b = (recovered_t * 1.0e12) as f32; // T → pT
|
||||
}
|
||||
let sigma_pt = [
|
||||
(reading.sigma_per_axis[0] * 1.0e12) as f32,
|
||||
@@ -98,8 +95,7 @@ impl Pipeline {
|
||||
frame.t_us = (sample as u64) * dt_us;
|
||||
frame.b_pt = b_pt;
|
||||
frame.sigma_pt = sigma_pt;
|
||||
frame.noise_floor_pt_sqrt_hz =
|
||||
(reading.noise_floor_t_sqrt_hz * 1.0e12) as f32;
|
||||
frame.noise_floor_pt_sqrt_hz = (reading.noise_floor_t_sqrt_hz * 1.0e12) as f32;
|
||||
frame.temperature_k = 295.0;
|
||||
if near_field {
|
||||
frame.set_flag(flag::SATURATION_NEAR_FIELD);
|
||||
@@ -198,11 +194,11 @@ mod tests {
|
||||
let (b_analytic, _) = scene_field_at(&scene, scene.sensors[0]);
|
||||
for f in &frames {
|
||||
assert!(f.has_flag(flag::SHOT_NOISE_DISABLED));
|
||||
for k in 0..3 {
|
||||
let recovered_t = f.b_pt[k] as f64 * 1.0e-12;
|
||||
for (k, (&b_pt, &b_ref)) in f.b_pt.iter().zip(b_analytic.iter()).enumerate() {
|
||||
let recovered_t = b_pt as f64 * 1.0e-12;
|
||||
let lsb_t = crate::digitiser::ADC_LSB_T;
|
||||
assert!(
|
||||
(recovered_t - b_analytic[k]).abs() <= lsb_t,
|
||||
(recovered_t - b_ref).abs() <= lsb_t,
|
||||
"noise-off recovery error > 1 LSB for axis {k}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,12 +58,12 @@ pub struct LosSegment {
|
||||
pub fn material_loss_db_per_m(m: Material) -> f64 {
|
||||
match m {
|
||||
Material::Air => 0.0,
|
||||
Material::Drywall => 0.0, // conjecture: gypsum non-ferromagnetic
|
||||
Material::Brick => 0.0, // conjecture: same logic as drywall
|
||||
Material::ConcreteDry => 0.5, // conjecture: Ulrich 2002 proxy
|
||||
Material::Drywall => 0.0, // conjecture: gypsum non-ferromagnetic
|
||||
Material::Brick => 0.0, // conjecture: same logic as drywall
|
||||
Material::ConcreteDry => 0.5, // conjecture: Ulrich 2002 proxy
|
||||
Material::ReinforcedConcrete => 20.0, // proxy + warning flag (plan §2.2)
|
||||
Material::SheetSteel => 100.0, // frequency-dependent in reality;
|
||||
// representative DC bulk loss
|
||||
Material::SheetSteel => 100.0, // frequency-dependent in reality;
|
||||
// representative DC bulk loss
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,10 +92,7 @@ pub fn attenuate(b_in: Vec3, segments: &[LosSegment]) -> (Vec3, bool) {
|
||||
heavy |= material_is_heavy(seg.material);
|
||||
}
|
||||
let scale = 10.0_f64.powf(-total_db / 20.0);
|
||||
(
|
||||
[b_in[0] * scale, b_in[1] * scale, b_in[2] * scale],
|
||||
heavy,
|
||||
)
|
||||
([b_in[0] * scale, b_in[1] * scale, b_in[2] * scale], heavy)
|
||||
}
|
||||
|
||||
/// Aggregate "propagator" type — currently a stateless wrapper over
|
||||
@@ -175,8 +172,8 @@ mod tests {
|
||||
}];
|
||||
let (b_out, heavy) = attenuate(b_in, &segs);
|
||||
let expected = 10.0_f64.powf(-4.0 / 20.0);
|
||||
for k in 0..3 {
|
||||
assert_relative_eq!(b_out[k], expected, max_relative = 1e-12);
|
||||
for &val in &b_out {
|
||||
assert_relative_eq!(val, expected, max_relative = 1e-12);
|
||||
}
|
||||
assert!(heavy, "reinforced concrete must raise heavy_flag");
|
||||
}
|
||||
|
||||
@@ -63,12 +63,7 @@ pub const DEFAULT_N_SPINS: f64 = 1.0e12;
|
||||
/// Tetrahedral 〈111〉 family in the diamond lattice.
|
||||
pub fn nv_axes() -> [[f64; 3]; 4] {
|
||||
let s = 1.0 / 3.0_f64.sqrt();
|
||||
[
|
||||
[s, s, s],
|
||||
[s, -s, -s],
|
||||
[-s, s, -s],
|
||||
[-s, -s, s],
|
||||
]
|
||||
[[s, s, s], [s, -s, -s], [-s, s, -s], [-s, -s, s]]
|
||||
}
|
||||
|
||||
/// Sensor configuration. All defaults match plan §2.3 / Barry 2020 Table III
|
||||
@@ -163,8 +158,9 @@ impl NvSensor {
|
||||
/// per-sample noise σ in T.
|
||||
pub fn shot_noise_floor_t_sqrt_hz(&self, integration_s: f64) -> f64 {
|
||||
let t = integration_s.max(self.config.t2_star_s);
|
||||
let denom =
|
||||
GAMMA_E * self.config.contrast * (self.config.n_spins * t * self.config.t2_star_s).sqrt();
|
||||
let denom = GAMMA_E
|
||||
* self.config.contrast
|
||||
* (self.config.n_spins * t * self.config.t2_star_s).sqrt();
|
||||
if denom <= 0.0 {
|
||||
f64::INFINITY
|
||||
} else {
|
||||
@@ -316,13 +312,10 @@ mod tests {
|
||||
];
|
||||
for &b_in in &inputs {
|
||||
let r = s.sample(b_in, 1.0e-3, 0xCAFE_BABE);
|
||||
for k in 0..3 {
|
||||
let denom = b_in[k].abs().max(1e-30);
|
||||
let rel = (r.b_recovered[k] - b_in[k]).abs() / denom;
|
||||
assert!(
|
||||
rel < 0.01,
|
||||
"LSQ residual {rel:.4} exceeds 1% for axis {k}"
|
||||
);
|
||||
for (k, (&b_recovered, &b_orig)) in r.b_recovered.iter().zip(b_in.iter()).enumerate() {
|
||||
let denom = b_orig.abs().max(1e-30);
|
||||
let rel = (b_recovered - b_orig).abs() / denom;
|
||||
assert!(rel < 0.01, "LSQ residual {rel:.4} exceeds 1% for axis {k}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -338,19 +331,19 @@ mod tests {
|
||||
let mut sum = [0.0_f64; 3];
|
||||
for i in 0..n {
|
||||
let r = s.sample([0.0; 3], dt, 0xDEAD_BEEF + i as u64);
|
||||
for k in 0..3 {
|
||||
sum[k] += r.b_recovered[k];
|
||||
for (s, &b) in sum.iter_mut().zip(r.b_recovered.iter()) {
|
||||
*s += b;
|
||||
}
|
||||
}
|
||||
let mean = [sum[0] / n as f64, sum[1] / n as f64, sum[2] / n as f64];
|
||||
// Stat margin: σ_mean = σ / √n. Allow ≤ 1σ_mean (loose).
|
||||
let r = s.sample([0.0; 3], dt, 0);
|
||||
let sigma_mean = r.sigma_per_axis[0] / (n as f64).sqrt();
|
||||
for k in 0..3 {
|
||||
for (k, &m) in mean.iter().enumerate() {
|
||||
assert!(
|
||||
mean[k].abs() <= sigma_mean,
|
||||
m.abs() <= sigma_mean,
|
||||
"axis {k} zero-input mean {} exceeds σ_mean {}",
|
||||
mean[k],
|
||||
m,
|
||||
sigma_mean
|
||||
);
|
||||
}
|
||||
@@ -392,6 +385,9 @@ mod tests {
|
||||
// form depends on this. Verify the matrix.
|
||||
let axes = nv_axes();
|
||||
let mut ata = [[0.0_f64; 3]; 3];
|
||||
// Compute AᵀA using explicit 2D indexing — clippy::needless_range_loop
|
||||
// cannot be avoided here without losing clarity in this matrix formula.
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
for j in 0..3 {
|
||||
for k in 0..3 {
|
||||
let mut acc = 0.0;
|
||||
@@ -401,6 +397,7 @@ mod tests {
|
||||
ata[j][k] = acc;
|
||||
}
|
||||
}
|
||||
#[allow(clippy::needless_range_loop)]
|
||||
for j in 0..3 {
|
||||
for k in 0..3 {
|
||||
let expected = if j == k { 4.0 / 3.0 } else { 0.0 };
|
||||
|
||||
@@ -132,7 +132,11 @@ pub fn scene_field_at(scene: &Scene, sensor_pos: Vec3) -> (Vec3, bool) {
|
||||
|
||||
/// Total field at every sensor location in a scene, in scene order.
|
||||
pub fn scene_field_at_sensors(scene: &Scene) -> Vec<(Vec3, bool)> {
|
||||
scene.sensors.iter().map(|&p| scene_field_at(scene, p)).collect()
|
||||
scene
|
||||
.sensors
|
||||
.iter()
|
||||
.map(|&p| scene_field_at(scene, p))
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ────────────────────── vec3 helpers ─────────────────────────────────────
|
||||
|
||||
@@ -46,8 +46,8 @@ impl WasmPipeline {
|
||||
pub fn new(scene_json: &str, config_json: &str, seed: f64) -> Result<WasmPipeline, JsValue> {
|
||||
let scene: Scene =
|
||||
serde_json::from_str(scene_json).map_err(|e| js_err(format!("scene parse: {e}")))?;
|
||||
let config: PipelineConfig = serde_json::from_str(config_json)
|
||||
.map_err(|e| js_err(format!("config parse: {e}")))?;
|
||||
let config: PipelineConfig =
|
||||
serde_json::from_str(config_json).map_err(|e| js_err(format!("config parse: {e}")))?;
|
||||
let seed_u64 = seed as u64;
|
||||
Ok(WasmPipeline {
|
||||
inner: Pipeline::new(scene, config, seed_u64),
|
||||
@@ -184,8 +184,8 @@ pub fn run_transient(
|
||||
) -> Result<JsValue, JsValue> {
|
||||
let scene: crate::scene::Scene =
|
||||
serde_json::from_str(scene_json).map_err(|e| js_err(format!("scene parse: {e}")))?;
|
||||
let config: crate::pipeline::PipelineConfig = serde_json::from_str(config_json)
|
||||
.map_err(|e| js_err(format!("config parse: {e}")))?;
|
||||
let config: crate::pipeline::PipelineConfig =
|
||||
serde_json::from_str(config_json).map_err(|e| js_err(format!("config parse: {e}")))?;
|
||||
let pipeline = crate::pipeline::Pipeline::new(scene, config, seed as u64);
|
||||
let (frames, witness) = pipeline.run_with_witness(n_samples);
|
||||
|
||||
@@ -217,7 +217,11 @@ pub fn run_transient(
|
||||
let s_arr = js_sys::Float64Array::new_with_length(3);
|
||||
s_arr.copy_from(&avg_s_pt);
|
||||
js_sys::Reflect::set(&obj, &JsValue::from_str("bRecoveredT"), &b_arr)?;
|
||||
js_sys::Reflect::set(&obj, &JsValue::from_str("bMagT"), &JsValue::from_f64(bmag_t))?;
|
||||
js_sys::Reflect::set(
|
||||
&obj,
|
||||
&JsValue::from_str("bMagT"),
|
||||
&JsValue::from_f64(bmag_t),
|
||||
)?;
|
||||
js_sys::Reflect::set(
|
||||
&obj,
|
||||
&JsValue::from_str("noiseFloorPtSqrtHz"),
|
||||
@@ -230,6 +234,10 @@ pub fn run_transient(
|
||||
&JsValue::from_f64(frames.len() as f64),
|
||||
)?;
|
||||
let witness_hex = crate::proof::Proof::hex(&witness);
|
||||
js_sys::Reflect::set(&obj, &JsValue::from_str("witnessHex"), &JsValue::from_str(&witness_hex))?;
|
||||
js_sys::Reflect::set(
|
||||
&obj,
|
||||
&JsValue::from_str("witnessHex"),
|
||||
&JsValue::from_str(&witness_hex),
|
||||
)?;
|
||||
Ok(obj.into())
|
||||
}
|
||||
|
||||
@@ -31,7 +31,11 @@ pub mod mat;
|
||||
/// WiFi-DensePose Command Line Interface
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "wifi-densepose")]
|
||||
#[command(author, version, about = "WiFi-based pose estimation and disaster response")]
|
||||
#[command(
|
||||
author,
|
||||
version,
|
||||
about = "WiFi-based pose estimation and disaster response"
|
||||
)]
|
||||
#[command(propagate_version = true)]
|
||||
pub struct Cli {
|
||||
/// Command to execute
|
||||
|
||||
@@ -16,8 +16,8 @@ use std::path::PathBuf;
|
||||
use tabled::{settings::Style, Table, Tabled};
|
||||
|
||||
use wifi_densepose_mat::{
|
||||
DisasterConfig, DisasterType, Priority, ScanZone, TriageStatus, ZoneBounds,
|
||||
ZoneStatus, domain::alert::AlertStatus,
|
||||
domain::alert::AlertStatus, DisasterConfig, DisasterType, Priority, ScanZone, TriageStatus,
|
||||
ZoneBounds, ZoneStatus,
|
||||
};
|
||||
|
||||
/// MAT subcommand
|
||||
@@ -452,40 +452,21 @@ pub async fn execute(command: MatCommand) -> Result<()> {
|
||||
|
||||
/// Execute the scan command
|
||||
async fn execute_scan(args: ScanArgs) -> Result<()> {
|
||||
println!(
|
||||
"{} Starting survivor scan...",
|
||||
"[MAT]".bright_cyan().bold()
|
||||
);
|
||||
println!("{} Starting survivor scan...", "[MAT]".bright_cyan().bold());
|
||||
println!();
|
||||
|
||||
// Display configuration
|
||||
println!("{}", "Configuration:".bold());
|
||||
println!(
|
||||
" {} {:?}",
|
||||
"Disaster Type:".dimmed(),
|
||||
args.disaster_type
|
||||
);
|
||||
println!(
|
||||
" {} {:.1}",
|
||||
"Sensitivity:".dimmed(),
|
||||
args.sensitivity
|
||||
);
|
||||
println!(
|
||||
" {} {:.1}m",
|
||||
"Max Depth:".dimmed(),
|
||||
args.max_depth
|
||||
);
|
||||
println!(" {} {:?}", "Disaster Type:".dimmed(), args.disaster_type);
|
||||
println!(" {} {:.1}", "Sensitivity:".dimmed(), args.sensitivity);
|
||||
println!(" {} {:.1}m", "Max Depth:".dimmed(), args.max_depth);
|
||||
println!(
|
||||
" {} {}",
|
||||
"Continuous:".dimmed(),
|
||||
if args.continuous { "Yes" } else { "No" }
|
||||
);
|
||||
if args.continuous {
|
||||
println!(
|
||||
" {} {}ms",
|
||||
"Interval:".dimmed(),
|
||||
args.interval
|
||||
);
|
||||
println!(" {} {}ms", "Interval:".dimmed(), args.interval);
|
||||
}
|
||||
if let Some(ref zone) = args.zone {
|
||||
println!(" {} {}", "Zone:".dimmed(), zone);
|
||||
@@ -516,10 +497,7 @@ async fn execute_scan(args: ScanArgs) -> Result<()> {
|
||||
"[INFO]".blue(),
|
||||
config.disaster_type
|
||||
);
|
||||
println!(
|
||||
"{} Waiting for hardware connection...",
|
||||
"[INFO]".blue()
|
||||
);
|
||||
println!("{} Waiting for hardware connection...", "[INFO]".blue());
|
||||
println!();
|
||||
println!(
|
||||
"{} No hardware detected. Use --simulate for demo mode.",
|
||||
@@ -538,7 +516,9 @@ async fn simulate_scan_output() -> Result<()> {
|
||||
let pb = ProgressBar::new(100);
|
||||
pb.set_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta})")?
|
||||
.template(
|
||||
"{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {pos}/{len} ({eta})",
|
||||
)?
|
||||
.progress_chars("#>-"),
|
||||
);
|
||||
|
||||
@@ -591,13 +571,10 @@ async fn simulate_scan_output() -> Result<()> {
|
||||
"3".green().bold()
|
||||
);
|
||||
println!(
|
||||
" {} {} {} {} {} {}",
|
||||
" {} 1 {} 1 {} 1",
|
||||
"IMMEDIATE:".red().bold(),
|
||||
"1",
|
||||
"DELAYED:".yellow().bold(),
|
||||
"1",
|
||||
"MINOR:".green().bold(),
|
||||
"1"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
@@ -674,11 +651,7 @@ async fn execute_status(args: StatusArgs) -> Result<()> {
|
||||
status.active_zones,
|
||||
status.total_zones
|
||||
);
|
||||
println!(
|
||||
" {} {}",
|
||||
"Disaster Type:".dimmed(),
|
||||
status.disaster_type
|
||||
);
|
||||
println!(" {} {}", "Disaster Type:".dimmed(), status.disaster_type);
|
||||
println!(
|
||||
" {} {}",
|
||||
"Survivors Detected:".dimmed(),
|
||||
@@ -774,8 +747,10 @@ async fn execute_zones(args: ZonesArgs) -> Result<()> {
|
||||
match bounds_parsed {
|
||||
Ok(zone_bounds) => {
|
||||
let zone = if let Some(sens) = sensitivity {
|
||||
let mut params = wifi_densepose_mat::ScanParameters::default();
|
||||
params.sensitivity = sens;
|
||||
let params = wifi_densepose_mat::ScanParameters {
|
||||
sensitivity: sens,
|
||||
..Default::default()
|
||||
};
|
||||
ScanZone::with_parameters(&name, zone_bounds, params)
|
||||
} else {
|
||||
ScanZone::new(&name, zone_bounds)
|
||||
@@ -806,26 +781,14 @@ async fn execute_zones(args: ZonesArgs) -> Result<()> {
|
||||
);
|
||||
println!("Use --force to confirm.");
|
||||
} else {
|
||||
println!(
|
||||
"{} Zone '{}' removed.",
|
||||
"[OK]".green().bold(),
|
||||
zone.cyan()
|
||||
);
|
||||
println!("{} Zone '{}' removed.", "[OK]".green().bold(), zone.cyan());
|
||||
}
|
||||
}
|
||||
ZonesCommand::Pause { zone } => {
|
||||
println!(
|
||||
"{} Zone '{}' paused.",
|
||||
"[OK]".green().bold(),
|
||||
zone.cyan()
|
||||
);
|
||||
println!("{} Zone '{}' paused.", "[OK]".green().bold(), zone.cyan());
|
||||
}
|
||||
ZonesCommand::Resume { zone } => {
|
||||
println!(
|
||||
"{} Zone '{}' resumed.",
|
||||
"[OK]".green().bold(),
|
||||
zone.cyan()
|
||||
);
|
||||
println!("{} Zone '{}' resumed.", "[OK]".green().bold(), zone.cyan());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -848,7 +811,9 @@ fn parse_bounds(zone_type: &ZoneType, bounds: &str) -> Result<ZoneBounds> {
|
||||
parts.len()
|
||||
);
|
||||
}
|
||||
Ok(ZoneBounds::rectangle(parts[0], parts[1], parts[2], parts[3]))
|
||||
Ok(ZoneBounds::rectangle(
|
||||
parts[0], parts[1], parts[2], parts[3],
|
||||
))
|
||||
}
|
||||
ZoneType::Circle => {
|
||||
if parts.len() != 3 {
|
||||
@@ -1036,7 +1001,10 @@ async fn execute_alerts(args: AlertsArgs) -> Result<()> {
|
||||
if filtered.is_empty() {
|
||||
println!("No alerts.");
|
||||
} else {
|
||||
let pending = filtered.iter().filter(|a| a.status.contains("Pending")).count();
|
||||
let pending = filtered
|
||||
.iter()
|
||||
.filter(|a| a.status.contains("Pending"))
|
||||
.count();
|
||||
if pending > 0 {
|
||||
println!(
|
||||
"{} {} pending alert(s) require attention!",
|
||||
|
||||
@@ -52,19 +52,29 @@ pub mod types;
|
||||
pub mod utils;
|
||||
|
||||
// Re-export commonly used types at the crate root
|
||||
pub use error::{CoreError, CoreResult, SignalError, InferenceError, StorageError};
|
||||
pub use traits::{SignalProcessor, NeuralInference, DataStore};
|
||||
pub use error::{CoreError, CoreResult, InferenceError, SignalError, StorageError};
|
||||
pub use traits::{DataStore, NeuralInference, SignalProcessor};
|
||||
pub use types::{
|
||||
// CSI types
|
||||
CsiFrame, CsiMetadata, AntennaConfig,
|
||||
// Signal types
|
||||
ProcessedSignal, SignalFeatures, FrequencyBand,
|
||||
// Pose types
|
||||
PoseEstimate, PersonPose, Keypoint, KeypointType,
|
||||
// Common types
|
||||
Confidence, Timestamp, FrameId, DeviceId,
|
||||
AntennaConfig,
|
||||
// Bounding box
|
||||
BoundingBox,
|
||||
// Common types
|
||||
Confidence,
|
||||
// CSI types
|
||||
CsiFrame,
|
||||
CsiMetadata,
|
||||
DeviceId,
|
||||
FrameId,
|
||||
FrequencyBand,
|
||||
Keypoint,
|
||||
KeypointType,
|
||||
PersonPose,
|
||||
// Pose types
|
||||
PoseEstimate,
|
||||
// Signal types
|
||||
ProcessedSignal,
|
||||
SignalFeatures,
|
||||
Timestamp,
|
||||
};
|
||||
|
||||
/// Crate version
|
||||
@@ -97,20 +107,24 @@ pub mod prelude {
|
||||
};
|
||||
}
|
||||
|
||||
// Compile-time assertions on module-level constants.
|
||||
const _: () = assert!(MAX_SUBCARRIERS > 0);
|
||||
const _: () = assert!(DEFAULT_CONFIDENCE_THRESHOLD > 0.0);
|
||||
const _: () = assert!(DEFAULT_CONFIDENCE_THRESHOLD < 1.0);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_version_is_valid() {
|
||||
assert!(!VERSION.is_empty());
|
||||
// CARGO_PKG_VERSION is always non-empty; verify the constant is
|
||||
// accessible and has a dot-separated semver shape.
|
||||
assert!(VERSION.contains('.'), "version should be semver: {VERSION}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_constants() {
|
||||
assert_eq!(MAX_KEYPOINTS, 17);
|
||||
assert!(MAX_SUBCARRIERS > 0);
|
||||
assert!(DEFAULT_CONFIDENCE_THRESHOLD > 0.0);
|
||||
assert!(DEFAULT_CONFIDENCE_THRESHOLD < 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,7 +506,8 @@ pub trait AsyncDataStore: Send + Sync {
|
||||
async fn get_csi_frame(&self, id: &FrameId) -> Result<CsiFrame, StorageError>;
|
||||
|
||||
/// Retrieves CSI frames matching the query options.
|
||||
async fn query_csi_frames(&self, options: &QueryOptions) -> Result<Vec<CsiFrame>, StorageError>;
|
||||
async fn query_csi_frames(&self, options: &QueryOptions)
|
||||
-> Result<Vec<CsiFrame>, StorageError>;
|
||||
|
||||
/// Stores a pose estimate.
|
||||
async fn store_pose_estimate(&self, estimate: &PoseEstimate) -> Result<(), StorageError>;
|
||||
@@ -621,6 +622,9 @@ mod tests {
|
||||
|
||||
assert_eq!(cpu, InferenceDevice::Cpu);
|
||||
assert!(matches!(cuda, InferenceDevice::Cuda { device_id: 0 }));
|
||||
assert!(matches!(tensorrt, InferenceDevice::TensorRt { device_id: 1 }));
|
||||
assert!(matches!(
|
||||
tensorrt,
|
||||
InferenceDevice::TensorRt { device_id: 1 }
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -806,7 +806,10 @@ impl BoundingBox {
|
||||
/// Returns the center point of the bounding box.
|
||||
#[must_use]
|
||||
pub fn center(&self) -> (f32, f32) {
|
||||
((self.x_min + self.x_max) / 2.0, (self.y_min + self.y_max) / 2.0)
|
||||
(
|
||||
(self.x_min + self.x_max) / 2.0,
|
||||
(self.y_min + self.y_max) / 2.0,
|
||||
)
|
||||
}
|
||||
|
||||
/// Computes the Intersection over Union (IoU) with another bounding box.
|
||||
@@ -997,14 +1000,12 @@ impl PoseEstimate {
|
||||
/// Returns the person with the highest confidence.
|
||||
#[must_use]
|
||||
pub fn highest_confidence_person(&self) -> Option<&PersonPose> {
|
||||
self.persons
|
||||
.iter()
|
||||
.max_by(|a, b| {
|
||||
a.confidence
|
||||
.value()
|
||||
.partial_cmp(&b.confidence.value())
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
self.persons.iter().max_by(|a, b| {
|
||||
a.confidence
|
||||
.value()
|
||||
.partial_cmp(&b.confidence.value())
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1082,7 +1083,10 @@ mod tests {
|
||||
#[test]
|
||||
fn test_keypoint_type_conversion() {
|
||||
assert_eq!(KeypointType::try_from(0).unwrap(), KeypointType::Nose);
|
||||
assert_eq!(KeypointType::try_from(16).unwrap(), KeypointType::RightAnkle);
|
||||
assert_eq!(
|
||||
KeypointType::try_from(16).unwrap(),
|
||||
KeypointType::RightAnkle
|
||||
);
|
||||
assert!(KeypointType::try_from(17).is_err());
|
||||
}
|
||||
|
||||
|
||||
@@ -99,9 +99,8 @@ pub fn moving_average(data: &Array1<f64>, window_size: usize) -> Array1<f64> {
|
||||
let half_window = window_size / 2;
|
||||
|
||||
// ndarray Array1 is always contiguous, but handle gracefully if not
|
||||
let slice = match data.as_slice() {
|
||||
Some(s) => s,
|
||||
None => return data.clone(),
|
||||
let Some(slice) = data.as_slice() else {
|
||||
return data.clone();
|
||||
};
|
||||
|
||||
for i in 0..data.len() {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2355,22 +2355,22 @@
|
||||
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
|
||||
"type": "string",
|
||||
"const": "dialog:default",
|
||||
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`"
|
||||
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
|
||||
},
|
||||
{
|
||||
"description": "Enables the ask command without any pre-configured scope.",
|
||||
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-ask",
|
||||
"markdownDescription": "Enables the ask command without any pre-configured scope."
|
||||
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Enables the confirm command without any pre-configured scope.",
|
||||
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:allow-confirm",
|
||||
"markdownDescription": "Enables the confirm command without any pre-configured scope."
|
||||
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Enables the message command without any pre-configured scope.",
|
||||
@@ -2391,16 +2391,16 @@
|
||||
"markdownDescription": "Enables the save command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the ask command without any pre-configured scope.",
|
||||
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-ask",
|
||||
"markdownDescription": "Denies the ask command without any pre-configured scope."
|
||||
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Denies the confirm command without any pre-configured scope.",
|
||||
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
|
||||
"type": "string",
|
||||
"const": "dialog:deny-confirm",
|
||||
"markdownDescription": "Denies the confirm command without any pre-configured scope."
|
||||
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
|
||||
},
|
||||
{
|
||||
"description": "Denies the message command without any pre-configured scope.",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,16 @@
|
||||
use std::net::{SocketAddr, UdpSocket};
|
||||
use std::time::Duration;
|
||||
|
||||
use flume::RecvTimeoutError;
|
||||
use mdns_sd::{ServiceDaemon, ServiceEvent};
|
||||
use serde::Serialize;
|
||||
use tauri::State;
|
||||
use tokio::time::timeout;
|
||||
use tokio_serial::available_ports;
|
||||
use flume::RecvTimeoutError;
|
||||
|
||||
use crate::domain::node::{
|
||||
Chip, DiscoveredNode, DiscoveryMethod, HealthStatus, MacAddress, MeshRole,
|
||||
NodeCapabilities, NodeRegistry,
|
||||
Chip, DiscoveredNode, DiscoveryMethod, HealthStatus, MacAddress, MeshRole, NodeCapabilities,
|
||||
NodeRegistry,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
|
||||
@@ -110,14 +110,16 @@ async fn discover_via_mdns(timeout_duration: Duration) -> Result<Vec<DiscoveredN
|
||||
_ => MeshRole::Node,
|
||||
};
|
||||
let node = DiscoveredNode {
|
||||
ip: info.get_addresses()
|
||||
ip: info
|
||||
.get_addresses()
|
||||
.iter()
|
||||
.next()
|
||||
.map(|a| a.to_string())
|
||||
.unwrap_or_default(),
|
||||
mac: props.get("mac").map(|v| v.val_str().to_string()),
|
||||
hostname: Some(info.get_hostname().to_string()),
|
||||
node_id: props.get("node_id")
|
||||
node_id: props
|
||||
.get("node_id")
|
||||
.and_then(|v| v.val_str().parse().ok())
|
||||
.unwrap_or(0),
|
||||
firmware_version: props.get("version").map(|v| v.val_str().to_string()),
|
||||
@@ -127,11 +129,18 @@ async fn discover_via_mdns(timeout_duration: Duration) -> Result<Vec<DiscoveredN
|
||||
mesh_role,
|
||||
discovery_method: DiscoveryMethod::Mdns,
|
||||
tdm_slot: props.get("tdm_slot").and_then(|v| v.val_str().parse().ok()),
|
||||
tdm_total: props.get("tdm_total").and_then(|v| v.val_str().parse().ok()),
|
||||
edge_tier: props.get("edge_tier").and_then(|v| v.val_str().parse().ok()),
|
||||
tdm_total: props
|
||||
.get("tdm_total")
|
||||
.and_then(|v| v.val_str().parse().ok()),
|
||||
edge_tier: props
|
||||
.get("edge_tier")
|
||||
.and_then(|v| v.val_str().parse().ok()),
|
||||
uptime_secs: props.get("uptime").and_then(|v| v.val_str().parse().ok()),
|
||||
capabilities: Some(NodeCapabilities {
|
||||
wasm: props.get("wasm").map(|v| v.val_str() == "1").unwrap_or(false),
|
||||
wasm: props
|
||||
.get("wasm")
|
||||
.map(|v| v.val_str() == "1")
|
||||
.unwrap_or(false),
|
||||
ota: props.get("ota").map(|v| v.val_str() == "1").unwrap_or(true),
|
||||
csi: props.get("csi").map(|v| v.val_str() == "1").unwrap_or(true),
|
||||
}),
|
||||
@@ -153,7 +162,12 @@ async fn discover_via_mdns(timeout_duration: Duration) -> Result<Vec<DiscoveredN
|
||||
discovered
|
||||
});
|
||||
|
||||
match timeout(timeout_duration + Duration::from_millis(500), discovery_task).await {
|
||||
match timeout(
|
||||
timeout_duration + Duration::from_millis(500),
|
||||
discovery_task,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(nodes)) => Ok(nodes),
|
||||
Ok(Err(e)) => Err(format!("mDNS discovery task failed: {}", e)),
|
||||
Err(_) => Ok(Vec::new()), // Timeout, return empty
|
||||
@@ -210,7 +224,12 @@ async fn discover_via_udp(timeout_duration: Duration) -> Result<Vec<DiscoveredNo
|
||||
discovered
|
||||
});
|
||||
|
||||
match timeout(timeout_duration + Duration::from_millis(500), discovery_task).await {
|
||||
match timeout(
|
||||
timeout_duration + Duration::from_millis(500),
|
||||
discovery_task,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(nodes)) => Ok(nodes),
|
||||
Ok(Err(e)) => Err(format!("UDP discovery task failed: {}", e)),
|
||||
Err(_) => Ok(Vec::new()),
|
||||
@@ -295,16 +314,14 @@ pub async fn list_serial_ports() -> Result<Vec<SerialPortInfo>, String> {
|
||||
for port in ports {
|
||||
tracing::debug!("Processing port: {}", port.port_name);
|
||||
let info = match port.port_type {
|
||||
tokio_serial::SerialPortType::UsbPort(usb_info) => {
|
||||
SerialPortInfo {
|
||||
name: port.port_name,
|
||||
vid: Some(usb_info.vid),
|
||||
pid: Some(usb_info.pid),
|
||||
manufacturer: usb_info.manufacturer,
|
||||
serial_number: usb_info.serial_number,
|
||||
is_esp32_compatible: is_esp32_compatible(usb_info.vid, usb_info.pid),
|
||||
}
|
||||
}
|
||||
tokio_serial::SerialPortType::UsbPort(usb_info) => SerialPortInfo {
|
||||
name: port.port_name,
|
||||
vid: Some(usb_info.vid),
|
||||
pid: Some(usb_info.pid),
|
||||
manufacturer: usb_info.manufacturer,
|
||||
serial_number: usb_info.serial_number,
|
||||
is_esp32_compatible: is_esp32_compatible(usb_info.vid, usb_info.pid),
|
||||
},
|
||||
_ => {
|
||||
SerialPortInfo {
|
||||
name: port.port_name.clone(),
|
||||
@@ -401,7 +418,9 @@ fn is_esp32_compatible(vid: u16, pid: u16) -> bool {
|
||||
return true;
|
||||
}
|
||||
// FTDI
|
||||
if vid == 0x0403 && (pid == 0x6001 || pid == 0x6010 || pid == 0x6011 || pid == 0x6014 || pid == 0x6015) {
|
||||
if vid == 0x0403
|
||||
&& (pid == 0x6001 || pid == 0x6010 || pid == 0x6011 || pid == 0x6014 || pid == 0x6015)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
// ESP32-S2/S3 native USB
|
||||
@@ -450,9 +469,12 @@ pub async fn configure_esp32_wifi(
|
||||
let _ = serial.read(&mut buf);
|
||||
|
||||
// Send command
|
||||
serial.write_all(cmd.as_bytes())
|
||||
serial
|
||||
.write_all(cmd.as_bytes())
|
||||
.map_err(|e| format!("Failed to write: {}", e))?;
|
||||
serial.flush().map_err(|e| format!("Failed to flush: {}", e))?;
|
||||
serial
|
||||
.flush()
|
||||
.map_err(|e| format!("Failed to flush: {}", e))?;
|
||||
|
||||
// Wait and read response
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
@@ -465,7 +487,8 @@ pub async fn configure_esp32_wifi(
|
||||
// Check for success indicators
|
||||
if text.to_lowercase().contains("ok")
|
||||
|| text.to_lowercase().contains("saved")
|
||||
|| text.to_lowercase().contains("configured") {
|
||||
|| text.to_lowercase().contains("configured")
|
||||
{
|
||||
tracing::info!("WiFi config successful: {}", text.trim());
|
||||
return Ok(format!("WiFi configured! Response: {}", text.trim()));
|
||||
}
|
||||
|
||||
@@ -37,13 +37,16 @@ pub async fn flash_firmware(
|
||||
let firmware_hash = calculate_sha256(&firmware_path)?;
|
||||
|
||||
// Emit flash started event
|
||||
let _ = app.emit("flash-progress", FlashProgress {
|
||||
phase: "connecting".into(),
|
||||
progress_pct: 0.0,
|
||||
bytes_written: 0,
|
||||
bytes_total: firmware_size,
|
||||
message: Some(format!("Connecting to {} ...", port)),
|
||||
});
|
||||
let _ = app.emit(
|
||||
"flash-progress",
|
||||
FlashProgress {
|
||||
phase: "connecting".into(),
|
||||
progress_pct: 0.0,
|
||||
bytes_written: 0,
|
||||
bytes_total: firmware_size,
|
||||
message: Some(format!("Connecting to {} ...", port)),
|
||||
},
|
||||
);
|
||||
|
||||
// Build espflash command
|
||||
let baud_rate = baud.unwrap_or(921600);
|
||||
@@ -67,13 +70,12 @@ pub async fn flash_firmware(
|
||||
cmd.stderr(Stdio::piped());
|
||||
|
||||
// Spawn the process
|
||||
let mut child = cmd.spawn()
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to start espflash: {}. Is espflash installed?", e))?;
|
||||
|
||||
let _stdout = child.stdout.take()
|
||||
.ok_or("Failed to capture stdout")?;
|
||||
let stderr = child.stderr.take()
|
||||
.ok_or("Failed to capture stderr")?;
|
||||
let _stdout = child.stdout.take().ok_or("Failed to capture stdout")?;
|
||||
let stderr = child.stderr.take().ok_or("Failed to capture stderr")?;
|
||||
|
||||
// Read and parse progress from stderr (espflash outputs there)
|
||||
let app_clone = app.clone();
|
||||
@@ -84,8 +86,8 @@ pub async fn flash_firmware(
|
||||
let mut last_phase = "connecting".to_string();
|
||||
let mut last_progress = 0.0f32;
|
||||
|
||||
for line in reader.lines() {
|
||||
if let Ok(line) = line {
|
||||
for line in reader.lines().map_while(Result::ok) {
|
||||
{
|
||||
// Parse espflash progress output
|
||||
if line.contains("Connecting") {
|
||||
last_phase = "connecting".to_string();
|
||||
@@ -104,19 +106,24 @@ pub async fn flash_firmware(
|
||||
last_progress = 95.0;
|
||||
}
|
||||
|
||||
let _ = app_clone.emit("flash-progress", FlashProgress {
|
||||
phase: last_phase.clone(),
|
||||
progress_pct: last_progress,
|
||||
bytes_written: ((last_progress / 100.0) * firmware_size_clone as f32) as u64,
|
||||
bytes_total: firmware_size_clone,
|
||||
message: Some(line),
|
||||
});
|
||||
let _ = app_clone.emit(
|
||||
"flash-progress",
|
||||
FlashProgress {
|
||||
phase: last_phase.clone(),
|
||||
progress_pct: last_progress,
|
||||
bytes_written: ((last_progress / 100.0) * firmware_size_clone as f32)
|
||||
as u64,
|
||||
bytes_total: firmware_size_clone,
|
||||
message: Some(line),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for completion
|
||||
let status = child.wait()
|
||||
let status = child
|
||||
.wait()
|
||||
.map_err(|e| format!("Failed to wait for espflash: {}", e))?;
|
||||
|
||||
// Wait for progress parsing to complete
|
||||
@@ -126,13 +133,16 @@ pub async fn flash_firmware(
|
||||
|
||||
if status.success() {
|
||||
// Emit completion
|
||||
let _ = app.emit("flash-progress", FlashProgress {
|
||||
phase: "completed".into(),
|
||||
progress_pct: 100.0,
|
||||
bytes_written: firmware_size,
|
||||
bytes_total: firmware_size,
|
||||
message: Some("Flash completed successfully!".into()),
|
||||
});
|
||||
let _ = app.emit(
|
||||
"flash-progress",
|
||||
FlashProgress {
|
||||
phase: "completed".into(),
|
||||
progress_pct: 100.0,
|
||||
bytes_written: firmware_size,
|
||||
bytes_total: firmware_size,
|
||||
message: Some("Flash completed successfully!".into()),
|
||||
},
|
||||
);
|
||||
|
||||
Ok(FlashResult {
|
||||
success: true,
|
||||
@@ -141,13 +151,16 @@ pub async fn flash_firmware(
|
||||
firmware_hash: Some(firmware_hash),
|
||||
})
|
||||
} else {
|
||||
let _ = app.emit("flash-progress", FlashProgress {
|
||||
phase: "failed".into(),
|
||||
progress_pct: 0.0,
|
||||
bytes_written: 0,
|
||||
bytes_total: firmware_size,
|
||||
message: Some("Flash failed".into()),
|
||||
});
|
||||
let _ = app.emit(
|
||||
"flash-progress",
|
||||
FlashProgress {
|
||||
phase: "failed".into(),
|
||||
progress_pct: 0.0,
|
||||
bytes_written: 0,
|
||||
bytes_total: firmware_size,
|
||||
message: Some("Flash failed".into()),
|
||||
},
|
||||
);
|
||||
|
||||
Err(format!("espflash exited with status: {}", status))
|
||||
}
|
||||
@@ -199,9 +212,7 @@ pub async fn check_espflash() -> Result<EspflashInfo, String> {
|
||||
.map_err(|_| "espflash not found. Please install: cargo install espflash")?;
|
||||
|
||||
if output.status.success() {
|
||||
let version = String::from_utf8_lossy(&output.stdout)
|
||||
.trim()
|
||||
.to_string();
|
||||
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
|
||||
Ok(EspflashInfo {
|
||||
installed: true,
|
||||
@@ -247,8 +258,7 @@ pub async fn supported_chips() -> Result<Vec<ChipInfo>, String> {
|
||||
|
||||
/// Calculate SHA-256 hash of a file.
|
||||
fn calculate_sha256(path: &str) -> Result<String, String> {
|
||||
let file = std::fs::File::open(path)
|
||||
.map_err(|e| format!("Failed to open file: {}", e))?;
|
||||
let file = std::fs::File::open(path).map_err(|e| format!("Failed to open file: {}", e))?;
|
||||
|
||||
let mut reader = BufReader::new(file);
|
||||
let mut hasher = Sha256::new();
|
||||
@@ -344,13 +354,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_chip_info() {
|
||||
let chips = vec![
|
||||
ChipInfo {
|
||||
id: "esp32".into(),
|
||||
name: "ESP32".into(),
|
||||
description: "Test".into(),
|
||||
},
|
||||
];
|
||||
let chips = [ChipInfo {
|
||||
id: "esp32".into(),
|
||||
name: "ESP32".into(),
|
||||
description: "Test".into(),
|
||||
}];
|
||||
assert_eq!(chips.len(), 1);
|
||||
assert_eq!(chips[0].id, "esp32");
|
||||
}
|
||||
|
||||
@@ -37,16 +37,19 @@ pub async fn ota_update(
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
// Emit progress
|
||||
let _ = app.emit("ota-progress", OtaProgress {
|
||||
node_ip: node_ip.clone(),
|
||||
phase: "preparing".into(),
|
||||
progress_pct: 0.0,
|
||||
message: Some("Reading firmware...".into()),
|
||||
});
|
||||
let _ = app.emit(
|
||||
"ota-progress",
|
||||
OtaProgress {
|
||||
node_ip: node_ip.clone(),
|
||||
phase: "preparing".into(),
|
||||
progress_pct: 0.0,
|
||||
message: Some("Reading firmware...".into()),
|
||||
},
|
||||
);
|
||||
|
||||
// Read firmware file
|
||||
let mut file = File::open(&firmware_path)
|
||||
.map_err(|e| format!("Cannot read firmware: {}", e))?;
|
||||
let mut file =
|
||||
File::open(&firmware_path).map_err(|e| format!("Cannot read firmware: {}", e))?;
|
||||
|
||||
let mut firmware_data = Vec::new();
|
||||
file.read_to_end(&mut firmware_data)
|
||||
@@ -70,12 +73,18 @@ pub async fn ota_update(
|
||||
};
|
||||
|
||||
// Emit progress
|
||||
let _ = app.emit("ota-progress", OtaProgress {
|
||||
node_ip: node_ip.clone(),
|
||||
phase: "uploading".into(),
|
||||
progress_pct: 10.0,
|
||||
message: Some(format!("Uploading {} bytes to {}...", firmware_size, node_ip)),
|
||||
});
|
||||
let _ = app.emit(
|
||||
"ota-progress",
|
||||
OtaProgress {
|
||||
node_ip: node_ip.clone(),
|
||||
phase: "uploading".into(),
|
||||
progress_pct: 10.0,
|
||||
message: Some(format!(
|
||||
"Uploading {} bytes to {}...",
|
||||
firmware_size, node_ip
|
||||
)),
|
||||
},
|
||||
);
|
||||
|
||||
// Build HTTP client
|
||||
let client = reqwest::Client::builder()
|
||||
@@ -107,30 +116,38 @@ pub async fn ota_update(
|
||||
request = request.header("X-OTA-SHA256", &firmware_hash);
|
||||
|
||||
// Send request
|
||||
let response = request.send().await
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("OTA upload failed: {}", e))?;
|
||||
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
|
||||
if !status.is_success() {
|
||||
let _ = app.emit("ota-progress", OtaProgress {
|
||||
node_ip: node_ip.clone(),
|
||||
phase: "failed".into(),
|
||||
progress_pct: 0.0,
|
||||
message: Some(format!("HTTP {}: {}", status, body)),
|
||||
});
|
||||
let _ = app.emit(
|
||||
"ota-progress",
|
||||
OtaProgress {
|
||||
node_ip: node_ip.clone(),
|
||||
phase: "failed".into(),
|
||||
progress_pct: 0.0,
|
||||
message: Some(format!("HTTP {}: {}", status, body)),
|
||||
},
|
||||
);
|
||||
|
||||
return Err(format!("OTA failed with HTTP {}: {}", status, body));
|
||||
}
|
||||
|
||||
// Emit progress - upload complete
|
||||
let _ = app.emit("ota-progress", OtaProgress {
|
||||
node_ip: node_ip.clone(),
|
||||
phase: "rebooting".into(),
|
||||
progress_pct: 80.0,
|
||||
message: Some("Waiting for node reboot...".into()),
|
||||
});
|
||||
let _ = app.emit(
|
||||
"ota-progress",
|
||||
OtaProgress {
|
||||
node_ip: node_ip.clone(),
|
||||
phase: "rebooting".into(),
|
||||
progress_pct: 80.0,
|
||||
message: Some("Waiting for node reboot...".into()),
|
||||
},
|
||||
);
|
||||
|
||||
// Wait for node to come back online
|
||||
let reboot_ok = wait_for_reboot(&client, &node_ip, Duration::from_secs(30)).await;
|
||||
@@ -138,12 +155,15 @@ pub async fn ota_update(
|
||||
let duration = start_time.elapsed().as_secs_f64();
|
||||
|
||||
if reboot_ok {
|
||||
let _ = app.emit("ota-progress", OtaProgress {
|
||||
node_ip: node_ip.clone(),
|
||||
phase: "completed".into(),
|
||||
progress_pct: 100.0,
|
||||
message: Some(format!("OTA completed in {:.1}s", duration)),
|
||||
});
|
||||
let _ = app.emit(
|
||||
"ota-progress",
|
||||
OtaProgress {
|
||||
node_ip: node_ip.clone(),
|
||||
phase: "completed".into(),
|
||||
progress_pct: 100.0,
|
||||
message: Some(format!("OTA completed in {:.1}s", duration)),
|
||||
},
|
||||
);
|
||||
|
||||
Ok(OtaResult {
|
||||
success: true,
|
||||
@@ -153,12 +173,15 @@ pub async fn ota_update(
|
||||
duration_secs: Some(duration),
|
||||
})
|
||||
} else {
|
||||
let _ = app.emit("ota-progress", OtaProgress {
|
||||
node_ip: node_ip.clone(),
|
||||
phase: "warning".into(),
|
||||
progress_pct: 90.0,
|
||||
message: Some("Node may not have rebooted successfully".into()),
|
||||
});
|
||||
let _ = app.emit(
|
||||
"ota-progress",
|
||||
OtaProgress {
|
||||
node_ip: node_ip.clone(),
|
||||
phase: "warning".into(),
|
||||
progress_pct: 90.0,
|
||||
message: Some("Node may not have rebooted successfully".into()),
|
||||
},
|
||||
);
|
||||
|
||||
Ok(OtaResult {
|
||||
success: true,
|
||||
@@ -190,13 +213,16 @@ pub async fn batch_ota_update(
|
||||
let strategy = strategy.unwrap_or_else(|| "sequential".into());
|
||||
let max_concurrent = max_concurrent.unwrap_or(1);
|
||||
|
||||
let _ = app.emit("batch-ota-progress", BatchOtaProgress {
|
||||
phase: "starting".into(),
|
||||
total: total_nodes,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
current_node: None,
|
||||
});
|
||||
let _ = app.emit(
|
||||
"batch-ota-progress",
|
||||
BatchOtaProgress {
|
||||
phase: "starting".into(),
|
||||
total: total_nodes,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
current_node: None,
|
||||
},
|
||||
);
|
||||
|
||||
let mut results = Vec::new();
|
||||
let mut completed = 0;
|
||||
@@ -212,22 +238,26 @@ pub async fn batch_ota_update(
|
||||
let psk = std::sync::Arc::new(psk);
|
||||
let app = std::sync::Arc::new(app.clone());
|
||||
|
||||
let tasks: Vec<_> = node_ips.into_iter().map(|ip| {
|
||||
let sem = semaphore.clone();
|
||||
let fw_path = firmware_path.clone();
|
||||
let psk_clone = psk.clone();
|
||||
let app_clone = app.clone();
|
||||
let tasks: Vec<_> = node_ips
|
||||
.into_iter()
|
||||
.map(|ip| {
|
||||
let sem = semaphore.clone();
|
||||
let fw_path = firmware_path.clone();
|
||||
let psk_clone = psk.clone();
|
||||
let app_clone = app.clone();
|
||||
|
||||
async move {
|
||||
let _permit = sem.acquire().await.unwrap();
|
||||
ota_update(
|
||||
(*app_clone).clone(),
|
||||
ip,
|
||||
(*fw_path).clone(),
|
||||
(*psk_clone).clone(),
|
||||
).await
|
||||
}
|
||||
}).collect();
|
||||
async move {
|
||||
let _permit = sem.acquire().await.unwrap();
|
||||
ota_update(
|
||||
(*app_clone).clone(),
|
||||
ip,
|
||||
(*fw_path).clone(),
|
||||
(*psk_clone).clone(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let task_results = futures::future::join_all(tasks).await;
|
||||
|
||||
@@ -257,20 +287,19 @@ pub async fn batch_ota_update(
|
||||
_ => {
|
||||
// Sequential execution (default)
|
||||
for ip in node_ips {
|
||||
let _ = app.emit("batch-ota-progress", BatchOtaProgress {
|
||||
phase: "updating".into(),
|
||||
total: total_nodes,
|
||||
completed,
|
||||
failed,
|
||||
current_node: Some(ip.clone()),
|
||||
});
|
||||
let _ = app.emit(
|
||||
"batch-ota-progress",
|
||||
BatchOtaProgress {
|
||||
phase: "updating".into(),
|
||||
total: total_nodes,
|
||||
completed,
|
||||
failed,
|
||||
current_node: Some(ip.clone()),
|
||||
},
|
||||
);
|
||||
|
||||
match ota_update(
|
||||
app.clone(),
|
||||
ip.clone(),
|
||||
firmware_path.clone(),
|
||||
psk.clone(),
|
||||
).await {
|
||||
match ota_update(app.clone(), ip.clone(), firmware_path.clone(), psk.clone()).await
|
||||
{
|
||||
Ok(r) => {
|
||||
if r.success {
|
||||
completed += 1;
|
||||
@@ -296,13 +325,16 @@ pub async fn batch_ota_update(
|
||||
|
||||
let duration = start_time.elapsed().as_secs_f64();
|
||||
|
||||
let _ = app.emit("batch-ota-progress", BatchOtaProgress {
|
||||
phase: "completed".into(),
|
||||
total: total_nodes,
|
||||
completed,
|
||||
failed,
|
||||
current_node: None,
|
||||
});
|
||||
let _ = app.emit(
|
||||
"batch-ota-progress",
|
||||
BatchOtaProgress {
|
||||
phase: "completed".into(),
|
||||
total: total_nodes,
|
||||
completed,
|
||||
failed,
|
||||
current_node: None,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(BatchOtaResult {
|
||||
total: total_nodes,
|
||||
@@ -331,7 +363,10 @@ pub async fn check_ota_endpoint(node_ip: String) -> Result<OtaEndpointInfo, Stri
|
||||
// Try to parse as JSON
|
||||
let version = serde_json::from_str::<serde_json::Value>(&body)
|
||||
.ok()
|
||||
.and_then(|v| v.get("version").and_then(|v| v.as_str().map(|s| s.to_string())));
|
||||
.and_then(|v| {
|
||||
v.get("version")
|
||||
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||
});
|
||||
|
||||
Ok(OtaEndpointInfo {
|
||||
reachable: true,
|
||||
|
||||
@@ -45,9 +45,9 @@ pub async fn provision_node(
|
||||
|
||||
// Open serial port
|
||||
let port_settings = tokio_serial::SerialPortBuilderExt::open_native_async(
|
||||
tokio_serial::new(&port, PROVISION_BAUD)
|
||||
.timeout(Duration::from_millis(SERIAL_TIMEOUT_MS))
|
||||
).map_err(|e| format!("Failed to open serial port: {}", e))?;
|
||||
tokio_serial::new(&port, PROVISION_BAUD).timeout(Duration::from_millis(SERIAL_TIMEOUT_MS)),
|
||||
)
|
||||
.map_err(|e| format!("Failed to open serial port: {}", e))?;
|
||||
|
||||
let (mut reader, mut writer) = tokio::io::split(port_settings);
|
||||
|
||||
@@ -59,17 +59,19 @@ pub async fn provision_node(
|
||||
};
|
||||
|
||||
let header_bytes = bincode_header(&header);
|
||||
tokio::io::AsyncWriteExt::write_all(&mut writer, &header_bytes).await
|
||||
tokio::io::AsyncWriteExt::write_all(&mut writer, &header_bytes)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send header: {}", e))?;
|
||||
|
||||
// Wait for ACK
|
||||
let mut ack_buf = [0u8; 4];
|
||||
tokio::time::timeout(
|
||||
Duration::from_millis(SERIAL_TIMEOUT_MS),
|
||||
tokio::io::AsyncReadExt::read_exact(&mut reader, &mut ack_buf)
|
||||
).await
|
||||
.map_err(|_| "Timeout waiting for device acknowledgment")?
|
||||
.map_err(|e| format!("Failed to read ACK: {}", e))?;
|
||||
tokio::io::AsyncReadExt::read_exact(&mut reader, &mut ack_buf),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "Timeout waiting for device acknowledgment")?
|
||||
.map_err(|e| format!("Failed to read ACK: {}", e))?;
|
||||
|
||||
if &ack_buf != b"ACK\n" {
|
||||
return Err(format!("Invalid ACK response: {:?}", ack_buf));
|
||||
@@ -78,7 +80,8 @@ pub async fn provision_node(
|
||||
// Send NVS data in chunks
|
||||
const CHUNK_SIZE: usize = 256;
|
||||
for chunk in nvs_data.chunks(CHUNK_SIZE) {
|
||||
tokio::io::AsyncWriteExt::write_all(&mut writer, chunk).await
|
||||
tokio::io::AsyncWriteExt::write_all(&mut writer, chunk)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send data chunk: {}", e))?;
|
||||
|
||||
// Small delay between chunks for device processing
|
||||
@@ -86,20 +89,23 @@ pub async fn provision_node(
|
||||
}
|
||||
|
||||
// Send checksum
|
||||
tokio::io::AsyncWriteExt::write_all(&mut writer, checksum.as_bytes()).await
|
||||
tokio::io::AsyncWriteExt::write_all(&mut writer, checksum.as_bytes())
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send checksum: {}", e))?;
|
||||
|
||||
tokio::io::AsyncWriteExt::write_all(&mut writer, b"\n").await
|
||||
tokio::io::AsyncWriteExt::write_all(&mut writer, b"\n")
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send newline: {}", e))?;
|
||||
|
||||
// Wait for confirmation
|
||||
let mut confirm_buf = [0u8; 32];
|
||||
let confirm_len = tokio::time::timeout(
|
||||
Duration::from_millis(SERIAL_TIMEOUT_MS * 2),
|
||||
tokio::io::AsyncReadExt::read(&mut reader, &mut confirm_buf)
|
||||
).await
|
||||
.map_err(|_| "Timeout waiting for confirmation")?
|
||||
.map_err(|e| format!("Failed to read confirmation: {}", e))?;
|
||||
tokio::io::AsyncReadExt::read(&mut reader, &mut confirm_buf),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "Timeout waiting for confirmation")?
|
||||
.map_err(|e| format!("Failed to read confirmation: {}", e))?;
|
||||
|
||||
let confirm_str = String::from_utf8_lossy(&confirm_buf[..confirm_len]);
|
||||
|
||||
@@ -121,24 +127,26 @@ pub async fn provision_node(
|
||||
pub async fn read_nvs(port: String) -> Result<ProvisioningConfig, String> {
|
||||
// Open serial port
|
||||
let port_settings = tokio_serial::SerialPortBuilderExt::open_native_async(
|
||||
tokio_serial::new(&port, PROVISION_BAUD)
|
||||
.timeout(Duration::from_millis(SERIAL_TIMEOUT_MS))
|
||||
).map_err(|e| format!("Failed to open serial port: {}", e))?;
|
||||
tokio_serial::new(&port, PROVISION_BAUD).timeout(Duration::from_millis(SERIAL_TIMEOUT_MS)),
|
||||
)
|
||||
.map_err(|e| format!("Failed to open serial port: {}", e))?;
|
||||
|
||||
let (mut reader, mut writer) = tokio::io::split(port_settings);
|
||||
|
||||
// Send read command
|
||||
tokio::io::AsyncWriteExt::write_all(&mut writer, b"RUVIEW_NVS_READ\n").await
|
||||
tokio::io::AsyncWriteExt::write_all(&mut writer, b"RUVIEW_NVS_READ\n")
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send read command: {}", e))?;
|
||||
|
||||
// Read size header
|
||||
let mut size_buf = [0u8; 4];
|
||||
tokio::time::timeout(
|
||||
Duration::from_millis(SERIAL_TIMEOUT_MS),
|
||||
tokio::io::AsyncReadExt::read_exact(&mut reader, &mut size_buf)
|
||||
).await
|
||||
.map_err(|_| "Timeout waiting for NVS size")?
|
||||
.map_err(|e| format!("Failed to read size: {}", e))?;
|
||||
tokio::io::AsyncReadExt::read_exact(&mut reader, &mut size_buf),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "Timeout waiting for NVS size")?
|
||||
.map_err(|e| format!("Failed to read size: {}", e))?;
|
||||
|
||||
let nvs_size = u32::from_le_bytes(size_buf) as usize;
|
||||
|
||||
@@ -150,10 +158,11 @@ pub async fn read_nvs(port: String) -> Result<ProvisioningConfig, String> {
|
||||
let mut nvs_data = vec![0u8; nvs_size];
|
||||
tokio::time::timeout(
|
||||
Duration::from_millis(SERIAL_TIMEOUT_MS * 2),
|
||||
tokio::io::AsyncReadExt::read_exact(&mut reader, &mut nvs_data)
|
||||
).await
|
||||
.map_err(|_| "Timeout reading NVS data")?
|
||||
.map_err(|e| format!("Failed to read NVS data: {}", e))?;
|
||||
tokio::io::AsyncReadExt::read_exact(&mut reader, &mut nvs_data),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "Timeout reading NVS data")?
|
||||
.map_err(|e| format!("Failed to read NVS data: {}", e))?;
|
||||
|
||||
// Parse NVS data to config
|
||||
deserialize_nvs_config(&nvs_data)
|
||||
@@ -164,24 +173,26 @@ pub async fn read_nvs(port: String) -> Result<ProvisioningConfig, String> {
|
||||
pub async fn erase_nvs(port: String) -> Result<ProvisionResult, String> {
|
||||
// Open serial port
|
||||
let port_settings = tokio_serial::SerialPortBuilderExt::open_native_async(
|
||||
tokio_serial::new(&port, PROVISION_BAUD)
|
||||
.timeout(Duration::from_millis(SERIAL_TIMEOUT_MS))
|
||||
).map_err(|e| format!("Failed to open serial port: {}", e))?;
|
||||
tokio_serial::new(&port, PROVISION_BAUD).timeout(Duration::from_millis(SERIAL_TIMEOUT_MS)),
|
||||
)
|
||||
.map_err(|e| format!("Failed to open serial port: {}", e))?;
|
||||
|
||||
let (mut reader, mut writer) = tokio::io::split(port_settings);
|
||||
|
||||
// Send erase command
|
||||
tokio::io::AsyncWriteExt::write_all(&mut writer, b"RUVIEW_NVS_ERASE\n").await
|
||||
tokio::io::AsyncWriteExt::write_all(&mut writer, b"RUVIEW_NVS_ERASE\n")
|
||||
.await
|
||||
.map_err(|e| format!("Failed to send erase command: {}", e))?;
|
||||
|
||||
// Wait for confirmation
|
||||
let mut confirm_buf = [0u8; 32];
|
||||
let confirm_len = tokio::time::timeout(
|
||||
Duration::from_millis(SERIAL_TIMEOUT_MS * 3), // Erase takes longer
|
||||
tokio::io::AsyncReadExt::read(&mut reader, &mut confirm_buf)
|
||||
).await
|
||||
.map_err(|_| "Timeout waiting for erase confirmation")?
|
||||
.map_err(|e| format!("Failed to read confirmation: {}", e))?;
|
||||
tokio::io::AsyncReadExt::read(&mut reader, &mut confirm_buf),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "Timeout waiting for erase confirmation")?
|
||||
.map_err(|e| format!("Failed to read confirmation: {}", e))?;
|
||||
|
||||
let confirm_str = String::from_utf8_lossy(&confirm_buf[..confirm_len]);
|
||||
|
||||
@@ -316,7 +327,8 @@ fn serialize_nvs_config(config: &ProvisioningConfig) -> Result<Vec<u8>, String>
|
||||
write_u8(&mut data, "hop_count", hops);
|
||||
}
|
||||
if let Some(ref channels) = config.channel_list {
|
||||
let ch_str: String = channels.iter()
|
||||
let ch_str: String = channels
|
||||
.iter()
|
||||
.map(|c| c.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
@@ -359,8 +371,8 @@ fn deserialize_nvs_config(data: &[u8]) -> Result<ProvisioningConfig, String> {
|
||||
return Err("Invalid NVS data: truncated key".into());
|
||||
}
|
||||
|
||||
let key = std::str::from_utf8(&data[pos..pos + key_len])
|
||||
.map_err(|_| "Invalid key encoding")?;
|
||||
let key =
|
||||
std::str::from_utf8(&data[pos..pos + key_len]).map_err(|_| "Invalid key encoding")?;
|
||||
pos += key_len;
|
||||
|
||||
if pos + 2 > data.len() {
|
||||
@@ -379,9 +391,15 @@ fn deserialize_nvs_config(data: &[u8]) -> Result<ProvisioningConfig, String> {
|
||||
|
||||
// Parse based on key
|
||||
match key {
|
||||
"wifi_ssid" => config.wifi_ssid = Some(String::from_utf8_lossy(value_bytes).to_string()),
|
||||
"wifi_pass" => config.wifi_password = Some(String::from_utf8_lossy(value_bytes).to_string()),
|
||||
"target_ip" => config.target_ip = Some(String::from_utf8_lossy(value_bytes).to_string()),
|
||||
"wifi_ssid" => {
|
||||
config.wifi_ssid = Some(String::from_utf8_lossy(value_bytes).to_string())
|
||||
}
|
||||
"wifi_pass" => {
|
||||
config.wifi_password = Some(String::from_utf8_lossy(value_bytes).to_string())
|
||||
}
|
||||
"target_ip" => {
|
||||
config.target_ip = Some(String::from_utf8_lossy(value_bytes).to_string())
|
||||
}
|
||||
"target_port" if value_len == 2 => {
|
||||
config.target_port = Some(u16::from_le_bytes([value_bytes[0], value_bytes[1]]));
|
||||
}
|
||||
@@ -399,16 +417,18 @@ fn deserialize_nvs_config(data: &[u8]) -> Result<ProvisioningConfig, String> {
|
||||
config.vital_window = Some(u16::from_le_bytes([value_bytes[0], value_bytes[1]]));
|
||||
}
|
||||
"vital_int" if value_len == 2 => {
|
||||
config.vital_interval_ms = Some(u16::from_le_bytes([value_bytes[0], value_bytes[1]]));
|
||||
config.vital_interval_ms =
|
||||
Some(u16::from_le_bytes([value_bytes[0], value_bytes[1]]));
|
||||
}
|
||||
"top_k" if value_len == 1 => config.top_k_count = Some(value_bytes[0]),
|
||||
"hop_count" if value_len == 1 => config.hop_count = Some(value_bytes[0]),
|
||||
"channels" => {
|
||||
let ch_str = String::from_utf8_lossy(value_bytes);
|
||||
config.channel_list = Some(
|
||||
ch_str.split(',')
|
||||
ch_str
|
||||
.split(',')
|
||||
.filter_map(|s| s.trim().parse().ok())
|
||||
.collect()
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
"power_duty" if value_len == 1 => config.power_duty = Some(value_bytes[0]),
|
||||
@@ -484,9 +504,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_config_validation() {
|
||||
let mut config = ProvisioningConfig::default();
|
||||
config.tdm_slot = Some(5);
|
||||
config.tdm_total = Some(4);
|
||||
let config = ProvisioningConfig {
|
||||
tdm_slot: Some(5),
|
||||
tdm_total: Some(4),
|
||||
..ProvisioningConfig::default()
|
||||
};
|
||||
|
||||
let result = config.validate();
|
||||
assert!(result.is_err());
|
||||
|
||||
@@ -117,8 +117,12 @@ pub async fn start_server(
|
||||
cmd.stderr(Stdio::piped());
|
||||
|
||||
// Spawn the child process
|
||||
let child = cmd.spawn()
|
||||
.map_err(|e| format!("Failed to start server: {}. Is '{}' installed?", e, server_path))?;
|
||||
let child = cmd.spawn().map_err(|e| {
|
||||
format!(
|
||||
"Failed to start server: {}. Is '{}' installed?",
|
||||
e, server_path
|
||||
)
|
||||
})?;
|
||||
|
||||
let pid = child.id();
|
||||
|
||||
@@ -262,12 +266,14 @@ pub async fn server_status(state: State<'_, AppState>) -> Result<ServerStatusRes
|
||||
});
|
||||
}
|
||||
|
||||
let pid = srv.pid.unwrap();
|
||||
// srv.pid.is_none() is checked above; the expect is unreachable in practice.
|
||||
let pid = srv.pid.expect("pid checked as Some before this point");
|
||||
let mut sys = System::new();
|
||||
let sysinfo_pid = Pid::from_u32(pid);
|
||||
sys.refresh_processes(ProcessesToUpdate::Some(&[sysinfo_pid]), true);
|
||||
|
||||
let (memory_mb, cpu_percent) = sys.process(sysinfo_pid)
|
||||
let (memory_mb, cpu_percent) = sys
|
||||
.process(sysinfo_pid)
|
||||
.map(|proc| {
|
||||
let mem = proc.memory() as f64 / 1024.0 / 1024.0;
|
||||
let cpu = proc.cpu_usage();
|
||||
@@ -276,9 +282,9 @@ pub async fn server_status(state: State<'_, AppState>) -> Result<ServerStatusRes
|
||||
.unwrap_or((None, None));
|
||||
|
||||
// Calculate uptime if we have start time
|
||||
let uptime_secs = srv.start_time.map(|start| {
|
||||
std::time::Instant::now().duration_since(start).as_secs()
|
||||
});
|
||||
let uptime_secs = srv
|
||||
.start_time
|
||||
.map(|start| std::time::Instant::now().duration_since(start).as_secs());
|
||||
|
||||
Ok(ServerStatusResponse {
|
||||
running: srv.running,
|
||||
|
||||
@@ -41,8 +41,7 @@ fn settings_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
.map_err(|e| format!("Failed to get app data dir: {}", e))?;
|
||||
|
||||
// Ensure directory exists
|
||||
fs::create_dir_all(&app_dir)
|
||||
.map_err(|e| format!("Failed to create app data dir: {}", e))?;
|
||||
fs::create_dir_all(&app_dir).map_err(|e| format!("Failed to create app data dir: {}", e))?;
|
||||
|
||||
Ok(app_dir.join("settings.json"))
|
||||
}
|
||||
@@ -56,11 +55,11 @@ pub async fn get_settings(app: AppHandle) -> Result<Option<AppSettings>, String>
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let contents = fs::read_to_string(&path)
|
||||
.map_err(|e| format!("Failed to read settings: {}", e))?;
|
||||
let contents =
|
||||
fs::read_to_string(&path).map_err(|e| format!("Failed to read settings: {}", e))?;
|
||||
|
||||
let settings: AppSettings = serde_json::from_str(&contents)
|
||||
.map_err(|e| format!("Failed to parse settings: {}", e))?;
|
||||
let settings: AppSettings =
|
||||
serde_json::from_str(&contents).map_err(|e| format!("Failed to parse settings: {}", e))?;
|
||||
|
||||
Ok(Some(settings))
|
||||
}
|
||||
@@ -73,8 +72,7 @@ pub async fn save_settings(app: AppHandle, settings: AppSettings) -> Result<(),
|
||||
let contents = serde_json::to_string_pretty(&settings)
|
||||
.map_err(|e| format!("Failed to serialize settings: {}", e))?;
|
||||
|
||||
fs::write(&path, contents)
|
||||
.map_err(|e| format!("Failed to write settings: {}", e))?;
|
||||
fs::write(&path, contents).map_err(|e| format!("Failed to write settings: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -22,14 +22,19 @@ pub async fn wasm_list(node_ip: String) -> Result<Vec<WasmModuleInfo>, String> {
|
||||
|
||||
let url = format!("http://{}:{}/wasm/list", node_ip, WASM_PORT);
|
||||
|
||||
let response = client.get(&url).send().await
|
||||
let response = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to connect to node: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("Node returned HTTP {}", response.status()));
|
||||
}
|
||||
|
||||
let modules: Vec<WasmModuleInfo> = response.json().await
|
||||
let modules: Vec<WasmModuleInfo> = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
Ok(modules)
|
||||
@@ -50,8 +55,7 @@ pub async fn wasm_upload(
|
||||
auto_start: Option<bool>,
|
||||
) -> Result<WasmUploadResult, String> {
|
||||
// Read WASM file
|
||||
let mut file = File::open(&wasm_path)
|
||||
.map_err(|e| format!("Cannot read WASM file: {}", e))?;
|
||||
let mut file = File::open(&wasm_path).map_err(|e| format!("Cannot read WASM file: {}", e))?;
|
||||
|
||||
let mut wasm_data = Vec::new();
|
||||
file.read_to_end(&mut wasm_data)
|
||||
@@ -99,7 +103,8 @@ pub async fn wasm_upload(
|
||||
|
||||
// Send request
|
||||
let url = format!("http://{}:{}/wasm/upload", node_ip, WASM_PORT);
|
||||
let response = client.post(&url)
|
||||
let response = client
|
||||
.post(&url)
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
@@ -113,13 +118,18 @@ pub async fn wasm_upload(
|
||||
}
|
||||
|
||||
// Parse response for module ID
|
||||
let upload_response: WasmUploadResponse = response.json().await
|
||||
let upload_response: WasmUploadResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse upload response: {}", e))?;
|
||||
|
||||
Ok(WasmUploadResult {
|
||||
success: true,
|
||||
module_id: upload_response.module_id,
|
||||
message: format!("Module '{}' uploaded successfully ({} bytes)", name, wasm_size),
|
||||
message: format!(
|
||||
"Module '{}' uploaded successfully ({} bytes)",
|
||||
name, wasm_size
|
||||
),
|
||||
sha256: Some(wasm_hash),
|
||||
})
|
||||
}
|
||||
@@ -156,7 +166,10 @@ pub async fn wasm_control(
|
||||
node_ip, WASM_PORT, module_id, action
|
||||
);
|
||||
|
||||
let response = client.post(&url).send().await
|
||||
let response = client
|
||||
.post(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("WASM control failed: {}", e))?;
|
||||
|
||||
let status = response.status();
|
||||
@@ -179,10 +192,7 @@ pub async fn wasm_control(
|
||||
|
||||
/// Get detailed info about a specific WASM module.
|
||||
#[tauri::command]
|
||||
pub async fn wasm_info(
|
||||
node_ip: String,
|
||||
module_id: String,
|
||||
) -> Result<WasmModuleDetail, String> {
|
||||
pub async fn wasm_info(node_ip: String, module_id: String) -> Result<WasmModuleDetail, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(WASM_TIMEOUT_SECS))
|
||||
.build()
|
||||
@@ -190,14 +200,19 @@ pub async fn wasm_info(
|
||||
|
||||
let url = format!("http://{}:{}/wasm/{}", node_ip, WASM_PORT, module_id);
|
||||
|
||||
let response = client.get(&url).send().await
|
||||
let response = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get module info: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("Module not found or HTTP {}", response.status()));
|
||||
}
|
||||
|
||||
let detail: WasmModuleDetail = response.json().await
|
||||
let detail: WasmModuleDetail = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse module info: {}", e))?;
|
||||
|
||||
Ok(detail)
|
||||
@@ -213,14 +228,19 @@ pub async fn wasm_stats(node_ip: String) -> Result<WasmRuntimeStats, String> {
|
||||
|
||||
let url = format!("http://{}:{}/wasm/stats", node_ip, WASM_PORT);
|
||||
|
||||
let response = client.get(&url).send().await
|
||||
let response = client
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get WASM stats: {}", e))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("HTTP {}", response.status()));
|
||||
}
|
||||
|
||||
let stats: WasmRuntimeStats = response.json().await
|
||||
let stats: WasmRuntimeStats = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse stats: {}", e))?;
|
||||
|
||||
Ok(stats)
|
||||
@@ -246,13 +266,16 @@ pub async fn check_wasm_support(node_ip: String) -> Result<WasmSupportInfo, Stri
|
||||
|
||||
Ok(WasmSupportInfo {
|
||||
supported: true,
|
||||
max_modules: info.as_ref()
|
||||
max_modules: info
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("max_modules").and_then(|v| v.as_u64()))
|
||||
.map(|v| v as u8),
|
||||
memory_limit_kb: info.as_ref()
|
||||
memory_limit_kb: info
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("memory_limit_kb").and_then(|v| v.as_u64()))
|
||||
.map(|v| v as u32),
|
||||
verify_signatures: info.as_ref()
|
||||
verify_signatures: info
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("verify_signatures").and_then(|v| v.as_bool()))
|
||||
.unwrap_or(false),
|
||||
})
|
||||
|
||||
@@ -51,10 +51,7 @@ impl ProvisioningConfig {
|
||||
}
|
||||
if let Some(duty) = self.power_duty {
|
||||
if !(10..=100).contains(&duty) {
|
||||
return Err(format!(
|
||||
"power_duty ({}) must be between 10 and 100",
|
||||
duty
|
||||
));
|
||||
return Err(format!("power_duty ({}) must be between 10 and 100", duty));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -12,6 +12,7 @@ pub struct DiscoveryState {
|
||||
}
|
||||
|
||||
/// Sub-state for the managed sensing server process.
|
||||
#[derive(Default)]
|
||||
pub struct ServerState {
|
||||
pub running: bool,
|
||||
pub pid: Option<u32>,
|
||||
@@ -22,20 +23,6 @@ pub struct ServerState {
|
||||
pub start_time: Option<Instant>,
|
||||
}
|
||||
|
||||
impl Default for ServerState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
running: false,
|
||||
pid: None,
|
||||
http_port: None,
|
||||
ws_port: None,
|
||||
udp_port: None,
|
||||
child: None,
|
||||
start_time: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sub-state for flash progress tracking.
|
||||
#[derive(Default)]
|
||||
pub struct FlashState {
|
||||
@@ -73,21 +60,14 @@ impl Default for OtaUpdateTracker {
|
||||
}
|
||||
|
||||
/// Sub-state for application settings cache.
|
||||
#[derive(Default)]
|
||||
pub struct SettingsState {
|
||||
pub loaded: bool,
|
||||
pub dirty: bool,
|
||||
}
|
||||
|
||||
impl Default for SettingsState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
loaded: false,
|
||||
dirty: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Top-level application state managed by Tauri.
|
||||
#[derive(Default)]
|
||||
pub struct AppState {
|
||||
pub discovery: Mutex<DiscoveryState>,
|
||||
pub server: Mutex<ServerState>,
|
||||
@@ -96,18 +76,6 @@ pub struct AppState {
|
||||
pub settings: Mutex<SettingsState>,
|
||||
}
|
||||
|
||||
impl Default for AppState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
discovery: Mutex::new(DiscoveryState::default()),
|
||||
server: Mutex::new(ServerState::default()),
|
||||
flash: Mutex::new(FlashState::default()),
|
||||
ota: Mutex::new(OtaState::default()),
|
||||
settings: Mutex::new(SettingsState::default()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// Create a new AppState instance.
|
||||
pub fn new() -> Self {
|
||||
|
||||
@@ -10,23 +10,44 @@
|
||||
fn test_serial_port_detection_logic() {
|
||||
// Test ESP32 VID/PID detection
|
||||
// CP210x (Silicon Labs)
|
||||
assert!(is_esp32_vid_pid(0x10C4, 0xEA60), "CP2102 should be detected");
|
||||
assert!(is_esp32_vid_pid(0x10C4, 0xEA70), "CP2104 should be detected");
|
||||
assert!(
|
||||
is_esp32_vid_pid(0x10C4, 0xEA60),
|
||||
"CP2102 should be detected"
|
||||
);
|
||||
assert!(
|
||||
is_esp32_vid_pid(0x10C4, 0xEA70),
|
||||
"CP2104 should be detected"
|
||||
);
|
||||
|
||||
// CH340/CH341 (QinHeng)
|
||||
assert!(is_esp32_vid_pid(0x1A86, 0x7523), "CH340 should be detected");
|
||||
assert!(is_esp32_vid_pid(0x1A86, 0x5523), "CH341 should be detected");
|
||||
|
||||
// FTDI
|
||||
assert!(is_esp32_vid_pid(0x0403, 0x6001), "FTDI FT232 should be detected");
|
||||
assert!(is_esp32_vid_pid(0x0403, 0x6010), "FTDI FT2232 should be detected");
|
||||
assert!(
|
||||
is_esp32_vid_pid(0x0403, 0x6001),
|
||||
"FTDI FT232 should be detected"
|
||||
);
|
||||
assert!(
|
||||
is_esp32_vid_pid(0x0403, 0x6010),
|
||||
"FTDI FT2232 should be detected"
|
||||
);
|
||||
|
||||
// ESP32 native USB
|
||||
assert!(is_esp32_vid_pid(0x303A, 0x1001), "ESP32-S2/S3 native should be detected");
|
||||
assert!(
|
||||
is_esp32_vid_pid(0x303A, 0x1001),
|
||||
"ESP32-S2/S3 native should be detected"
|
||||
);
|
||||
|
||||
// Unknown device
|
||||
assert!(!is_esp32_vid_pid(0x0000, 0x0000), "Unknown VID/PID should not be detected");
|
||||
assert!(!is_esp32_vid_pid(0x1234, 0x5678), "Random VID/PID should not be detected");
|
||||
assert!(
|
||||
!is_esp32_vid_pid(0x0000, 0x0000),
|
||||
"Unknown VID/PID should not be detected"
|
||||
);
|
||||
assert!(
|
||||
!is_esp32_vid_pid(0x1234, 0x5678),
|
||||
"Random VID/PID should not be detected"
|
||||
);
|
||||
}
|
||||
|
||||
fn is_esp32_vid_pid(vid: u16, pid: u16) -> bool {
|
||||
@@ -39,7 +60,9 @@ fn is_esp32_vid_pid(vid: u16, pid: u16) -> bool {
|
||||
return true;
|
||||
}
|
||||
// FTDI
|
||||
if vid == 0x0403 && (pid == 0x6001 || pid == 0x6010 || pid == 0x6011 || pid == 0x6014 || pid == 0x6015) {
|
||||
if vid == 0x0403
|
||||
&& (pid == 0x6001 || pid == 0x6010 || pid == 0x6011 || pid == 0x6014 || pid == 0x6015)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
// ESP32-S2/S3 native USB
|
||||
@@ -78,8 +101,14 @@ fn test_settings_structure() {
|
||||
|
||||
// Check default values
|
||||
assert!(!settings.theme.is_empty(), "Theme should have a default");
|
||||
assert!(settings.discover_interval_ms > 0, "Discovery interval should be positive");
|
||||
assert!(settings.auto_discover, "Auto-discover should default to true");
|
||||
assert!(
|
||||
settings.discover_interval_ms > 0,
|
||||
"Discovery interval should be positive"
|
||||
);
|
||||
assert!(
|
||||
settings.auto_discover,
|
||||
"Auto-discover should default to true"
|
||||
);
|
||||
assert_eq!(settings.server_http_port, 8080);
|
||||
}
|
||||
|
||||
@@ -128,7 +157,10 @@ fn test_chip_variants() {
|
||||
|
||||
for chip in chips {
|
||||
let name = format!("{:?}", chip).to_lowercase();
|
||||
assert!(name.starts_with("esp32"), "All chips should be ESP32 variants");
|
||||
assert!(
|
||||
name.starts_with("esp32"),
|
||||
"All chips should be ESP32 variants"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,7 +184,7 @@ fn test_progress_parsing() {
|
||||
|
||||
#[test]
|
||||
fn test_sha256_hash() {
|
||||
use sha2::{Sha256, Digest};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
let data = b"test firmware data";
|
||||
let mut hasher = Sha256::new();
|
||||
@@ -178,7 +210,11 @@ fn test_hmac_signature() {
|
||||
let result = mac.finalize();
|
||||
let signature = hex::encode(result.into_bytes());
|
||||
|
||||
assert_eq!(signature.len(), 64, "HMAC-SHA256 should produce 64 hex characters");
|
||||
assert_eq!(
|
||||
signature.len(),
|
||||
64,
|
||||
"HMAC-SHA256 should produce 64 hex characters"
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -305,11 +341,7 @@ fn test_discovery_method_variants() {
|
||||
fn test_mesh_role_variants() {
|
||||
use wifi_densepose_desktop::domain::node::MeshRole;
|
||||
|
||||
let roles = vec![
|
||||
MeshRole::Coordinator,
|
||||
MeshRole::Aggregator,
|
||||
MeshRole::Node,
|
||||
];
|
||||
let roles = vec![MeshRole::Coordinator, MeshRole::Aggregator, MeshRole::Node];
|
||||
|
||||
for role in roles {
|
||||
let json = serde_json::to_string(&role).expect("Should serialize");
|
||||
@@ -343,14 +375,18 @@ fn test_wifi_config_command_format() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::const_is_empty)]
|
||||
fn test_wifi_credentials_validation() {
|
||||
// SSID: 1-32 characters
|
||||
let valid_ssid = "MyNetwork";
|
||||
let empty_ssid = "";
|
||||
let long_ssid = "A".repeat(33);
|
||||
|
||||
assert!(!valid_ssid.is_empty() && valid_ssid.len() <= 32);
|
||||
assert!(empty_ssid.is_empty());
|
||||
assert!(
|
||||
!valid_ssid.is_empty() && valid_ssid.len() <= 32,
|
||||
"SSID length must be 1-32"
|
||||
);
|
||||
assert!(empty_ssid.is_empty(), "empty_ssid must be empty");
|
||||
assert!(long_ssid.len() > 32);
|
||||
|
||||
// Password: 8-63 characters for WPA2
|
||||
@@ -370,7 +406,7 @@ fn test_wifi_credentials_validation() {
|
||||
#[test]
|
||||
fn test_node_registry() {
|
||||
use wifi_densepose_desktop::domain::node::{
|
||||
DiscoveredNode, MacAddress, NodeRegistry, HealthStatus, Chip, MeshRole, DiscoveryMethod
|
||||
Chip, DiscoveredNode, DiscoveryMethod, HealthStatus, MacAddress, MeshRole, NodeRegistry,
|
||||
};
|
||||
|
||||
let mut registry = NodeRegistry::new();
|
||||
|
||||
@@ -13,24 +13,43 @@ async fn main() -> anyhow::Result<()> {
|
||||
println!(" Location: {:.4}N, {:.4}W", loc.lat, loc.lon);
|
||||
|
||||
let bbox = GeoBBox::from_center(&loc, 300.0);
|
||||
let tiles_list = tiles::fetch_area(&tiles::TileProvider::Sentinel2Cloudless, &bbox, 16, &cache).await?;
|
||||
println!(" Tiles: {} ({:.0}KB)", tiles_list.len(),
|
||||
tiles_list.iter().map(|t| t.data.len()).sum::<usize>() as f64 / 1024.0);
|
||||
let tiles_list =
|
||||
tiles::fetch_area(&tiles::TileProvider::Sentinel2Cloudless, &bbox, 16, &cache).await?;
|
||||
println!(
|
||||
" Tiles: {} ({:.0}KB)",
|
||||
tiles_list.len(),
|
||||
tiles_list.iter().map(|t| t.data.len()).sum::<usize>() as f64 / 1024.0
|
||||
);
|
||||
|
||||
let dem = terrain::fetch_elevation(&loc, &cache).await?;
|
||||
println!(" Elevation: {:.0}m (grid {}x{})", terrain::elevation_at(&dem, &loc), dem.cols, dem.rows);
|
||||
println!(
|
||||
" Elevation: {:.0}m (grid {}x{})",
|
||||
terrain::elevation_at(&dem, &loc),
|
||||
dem.cols,
|
||||
dem.rows
|
||||
);
|
||||
|
||||
let buildings = osm::fetch_buildings(&loc, 300.0).await.unwrap_or_default();
|
||||
let roads = osm::fetch_roads(&loc, 300.0).await.unwrap_or_default();
|
||||
println!(" OSM: {} buildings, {} roads", buildings.len(), roads.len());
|
||||
println!(
|
||||
" OSM: {} buildings, {} roads",
|
||||
buildings.len(),
|
||||
roads.len()
|
||||
);
|
||||
|
||||
let weather = temporal::fetch_weather(&loc).await?;
|
||||
println!(" Weather: {:.0}°C humidity={:.0}% wind={:.1}m/s",
|
||||
weather.temperature_c, weather.humidity_pct, weather.wind_speed_ms);
|
||||
println!(
|
||||
" Weather: {:.0}°C humidity={:.0}% wind={:.1}m/s",
|
||||
weather.temperature_c, weather.humidity_pct, weather.wind_speed_ms
|
||||
);
|
||||
|
||||
let scene = GeoScene {
|
||||
location: loc.clone(), bbox, elevation_m: terrain::elevation_at(&dem, &loc),
|
||||
buildings, roads, tile_count: tiles_list.len(),
|
||||
location: loc.clone(),
|
||||
bbox,
|
||||
elevation_m: terrain::elevation_at(&dem, &loc),
|
||||
buildings,
|
||||
roads,
|
||||
tile_count: tiles_list.len(),
|
||||
registration: register::auto_register(&loc),
|
||||
last_updated: chrono::Utc::now().to_rfc3339(),
|
||||
};
|
||||
@@ -41,7 +60,10 @@ async fn main() -> anyhow::Result<()> {
|
||||
Err(e) => println!(" Brain: {e}"),
|
||||
}
|
||||
|
||||
println!("\n Total: {}ms | Cache: {:.0}KB",
|
||||
t0.elapsed().as_millis(), cache.size_bytes() as f64 / 1024.0);
|
||||
println!(
|
||||
"\n Total: {}ms | Cache: {:.0}KB",
|
||||
t0.elapsed().as_millis(),
|
||||
cache.size_bytes() as f64 / 1024.0
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ const DEFAULT_BRAIN_URL: &str = "http://127.0.0.1:9876";
|
||||
pub(crate) fn brain_url() -> &'static str {
|
||||
static BRAIN_URL: OnceLock<String> = OnceLock::new();
|
||||
BRAIN_URL.get_or_init(|| {
|
||||
let url = std::env::var("RUVIEW_BRAIN_URL")
|
||||
.unwrap_or_else(|_| DEFAULT_BRAIN_URL.to_string());
|
||||
let url =
|
||||
std::env::var("RUVIEW_BRAIN_URL").unwrap_or_else(|_| DEFAULT_BRAIN_URL.to_string());
|
||||
eprintln!(" wifi-densepose-geo: using brain URL {url}");
|
||||
url
|
||||
})
|
||||
@@ -34,7 +34,13 @@ pub async fn store_geo_context(scene: &GeoScene) -> Result<u32> {
|
||||
"category": "spatial-geo",
|
||||
"content": summary,
|
||||
});
|
||||
if client.post(format!("{}/memories", brain_url())).json(&body).send().await.is_ok() {
|
||||
if client
|
||||
.post(format!("{}/memories", brain_url()))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
stored += 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -54,8 +54,11 @@ fn walkdir(path: &Path) -> u64 {
|
||||
.flatten()
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| {
|
||||
if e.path().is_dir() { walkdir(&e.path()) }
|
||||
else { e.metadata().map(|m| m.len()).unwrap_or(0) }
|
||||
if e.path().is_dir() {
|
||||
walkdir(&e.path())
|
||||
} else {
|
||||
e.metadata().map(|m| m.len()).unwrap_or(0)
|
||||
}
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Coordinate transforms — WGS84, UTM, ENU, tile math.
|
||||
|
||||
use crate::types::{GeoPoint, GeoBBox, TileCoord};
|
||||
use crate::types::{GeoBBox, GeoPoint, TileCoord};
|
||||
|
||||
const WGS84_A: f64 = 6_378_137.0;
|
||||
#[allow(dead_code)]
|
||||
@@ -55,9 +55,20 @@ pub fn tile_bounds(coord: &TileCoord) -> GeoBBox {
|
||||
let n = 2f64.powi(coord.z as i32);
|
||||
let west = coord.x as f64 / n * 360.0 - 180.0;
|
||||
let east = (coord.x + 1) as f64 / n * 360.0 - 180.0;
|
||||
let north = (std::f64::consts::PI * (1.0 - 2.0 * coord.y as f64 / n)).sinh().atan().to_degrees();
|
||||
let south = (std::f64::consts::PI * (1.0 - 2.0 * (coord.y + 1) as f64 / n)).sinh().atan().to_degrees();
|
||||
GeoBBox { south, west, north, east }
|
||||
let north = (std::f64::consts::PI * (1.0 - 2.0 * coord.y as f64 / n))
|
||||
.sinh()
|
||||
.atan()
|
||||
.to_degrees();
|
||||
let south = (std::f64::consts::PI * (1.0 - 2.0 * (coord.y + 1) as f64 / n))
|
||||
.sinh()
|
||||
.atan()
|
||||
.to_degrees();
|
||||
GeoBBox {
|
||||
south,
|
||||
west,
|
||||
north,
|
||||
east,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all tile coordinates covering a bounding box at a zoom level.
|
||||
|
||||
@@ -12,11 +12,15 @@ pub async fn build_scene(radius_m: f64) -> Result<GeoScene> {
|
||||
// 1. Locate
|
||||
let cache_path = cache.base_dir.join("location.json");
|
||||
let location = locate::get_location(cache_path.to_str().unwrap_or("")).await?;
|
||||
eprintln!(" Geo: located at {:.4}N, {:.4}W", location.lat, location.lon);
|
||||
eprintln!(
|
||||
" Geo: located at {:.4}N, {:.4}W",
|
||||
location.lat, location.lon
|
||||
);
|
||||
|
||||
// 2. Fetch satellite tiles
|
||||
let bbox = GeoBBox::from_center(&location, radius_m);
|
||||
let tile_list = tiles::fetch_area(&tiles::TileProvider::Sentinel2Cloudless, &bbox, 16, &cache).await?;
|
||||
let tile_list =
|
||||
tiles::fetch_area(&tiles::TileProvider::Sentinel2Cloudless, &bbox, 16, &cache).await?;
|
||||
eprintln!(" Geo: fetched {} satellite tiles", tile_list.len());
|
||||
|
||||
// 3. Fetch elevation
|
||||
@@ -25,9 +29,17 @@ pub async fn build_scene(radius_m: f64) -> Result<GeoScene> {
|
||||
eprintln!(" Geo: elevation {:.0}m ASL", elevation);
|
||||
|
||||
// 4. Fetch OSM buildings + roads
|
||||
let buildings = osm::fetch_buildings(&location, radius_m).await.unwrap_or_default();
|
||||
let roads = osm::fetch_roads(&location, radius_m).await.unwrap_or_default();
|
||||
eprintln!(" Geo: {} buildings, {} roads", buildings.len(), roads.len());
|
||||
let buildings = osm::fetch_buildings(&location, radius_m)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let roads = osm::fetch_roads(&location, radius_m)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
eprintln!(
|
||||
" Geo: {} buildings, {} roads",
|
||||
buildings.len(),
|
||||
roads.len()
|
||||
);
|
||||
|
||||
// 5. Build registration
|
||||
let mut reg_origin = location.clone();
|
||||
@@ -50,7 +62,9 @@ pub async fn build_scene(radius_m: f64) -> Result<GeoScene> {
|
||||
pub fn summarize(scene: &GeoScene) -> String {
|
||||
let building_count = scene.buildings.len();
|
||||
let road_count = scene.roads.len();
|
||||
let road_names: Vec<&str> = scene.roads.iter()
|
||||
let road_names: Vec<&str> = scene
|
||||
.roads
|
||||
.iter()
|
||||
.filter_map(|r| match r {
|
||||
OsmFeature::Road { name, .. } => name.as_deref(),
|
||||
_ => None,
|
||||
@@ -62,10 +76,16 @@ pub fn summarize(scene: &GeoScene) -> String {
|
||||
"Location: {:.4}N, {:.4}W, elevation {:.0}m ASL. \
|
||||
{} buildings within view. {} roads nearby{}. \
|
||||
{} satellite tiles at zoom 16. Updated: {}.",
|
||||
scene.location.lat, scene.location.lon, scene.elevation_m,
|
||||
building_count, road_count,
|
||||
if road_names.is_empty() { String::new() }
|
||||
else { format!(" ({})", road_names.join(", ")) },
|
||||
scene.location.lat,
|
||||
scene.location.lon,
|
||||
scene.elevation_m,
|
||||
building_count,
|
||||
road_count,
|
||||
if road_names.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" ({})", road_names.join(", "))
|
||||
},
|
||||
scene.tile_count,
|
||||
&scene.last_updated[..10],
|
||||
)
|
||||
|
||||
@@ -4,16 +4,16 @@
|
||||
//! SRTM elevation, OSM buildings/roads, coordinate transforms,
|
||||
//! temporal change tracking, and brain memory integration.
|
||||
|
||||
pub mod types;
|
||||
pub mod coord;
|
||||
pub mod locate;
|
||||
pub mod brain;
|
||||
pub mod cache;
|
||||
pub mod tiles;
|
||||
pub mod terrain;
|
||||
pub mod coord;
|
||||
pub mod fuse;
|
||||
pub mod locate;
|
||||
pub mod osm;
|
||||
pub mod register;
|
||||
pub mod fuse;
|
||||
pub mod brain;
|
||||
pub mod temporal;
|
||||
pub mod terrain;
|
||||
pub mod tiles;
|
||||
pub mod types;
|
||||
|
||||
pub use types::*;
|
||||
|
||||
@@ -12,8 +12,10 @@ pub async fn locate_by_ip() -> Result<GeoPoint> {
|
||||
// Primary: ip-api.com (free, 45 req/min)
|
||||
let resp: serde_json::Value = client
|
||||
.get("http://ip-api.com/json/?fields=lat,lon,city,regionName,country")
|
||||
.send().await?
|
||||
.json().await?;
|
||||
.send()
|
||||
.await?
|
||||
.json()
|
||||
.await?;
|
||||
|
||||
let lat = resp.get("lat").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let lon = resp.get("lon").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user