mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-20 05:40:14 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b370d4fcd4 | ||
|
|
500cfe4787 | ||
|
|
f7c2c6587e | ||
|
|
30391bfbd2 | ||
|
|
00a42d7cda | ||
|
|
bd6f38e2df | ||
|
|
203bd66daa | ||
|
|
2c3a924231 |
No files matched your search
@@ -10,6 +10,35 @@ func main() {
|
||||
exit(1)
|
||||
}
|
||||
|
||||
// One connected-link RSSI sample for the Rust adapter. Keep the legacy
|
||||
// no-argument 10 Hz stream below unchanged for Python callers.
|
||||
if CommandLine.arguments.contains("--scan-once") {
|
||||
guard interface.powerOn(),
|
||||
let channel = interface.wlanChannel(),
|
||||
interface.rssiValue() < 0 else {
|
||||
fputs("WiFi is not connected\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
let sample: [String: Any] = [
|
||||
"ssid": interface.ssid() ?? "",
|
||||
"bssid": interface.bssid() ?? "00:00:00:00:00:00",
|
||||
"channel": channel.channelNumber,
|
||||
"rssi": interface.rssiValue(),
|
||||
"noise": interface.noiseMeasurement(),
|
||||
"timestamp": Date().timeIntervalSince1970,
|
||||
"tx_rate": interface.transmitRate()
|
||||
]
|
||||
do {
|
||||
let data = try JSONSerialization.data(withJSONObject: sample, options: [.sortedKeys])
|
||||
FileHandle.standardOutput.write(data)
|
||||
FileHandle.standardOutput.write(Data([0x0a]))
|
||||
} catch {
|
||||
fputs("Could not encode WiFi sample: \(error)\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Flush stdout automatically to prevent buffering issues with Python subprocess
|
||||
setbuf(stdout, nil)
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
# ADR-361: Point the whole fleet at one transmitter, to remove selection ambiguity
|
||||
|
||||
**Status:** Proposed — built, deployed fleet-wide, A-B-A measured. **Hypothesis CONFIRMED, adoption NOT justified.** The pairing *fraction* rises sharply; paired observations per second fall.
|
||||
**Date:** 2026-09-09
|
||||
**Follows:** ADR-358 (falsified), ADR-359 (mechanism confirmed, not adopted), ADR-360 (falsified).
|
||||
|
||||
## The hypothesis, recorded long before it could be tested
|
||||
|
||||
From `docs/BACKLOG.md`, "Next: the actual pairing constraint":
|
||||
|
||||
> Two nodes can therefore fire in the same aligned window and still pick frames
|
||||
> from *different* transmitters, which can never pair under the
|
||||
> `(tx_mac, rx_seq)` key. Cheapest test and likely fix: **point the whole fleet
|
||||
> at one strong transmitter with the existing ADR-060 MAC filter.**
|
||||
|
||||
It had never been run, and the reason was mechanical rather than scientific:
|
||||
**`filter_mac` was writable only by `provision.py` over USB.** It is a 6-byte
|
||||
blob, absent from the `cfg_key_t` scalar table the config API validates
|
||||
against, so the test would have required nine physical visits. Exposing it over
|
||||
the network (see the firmware change accompanying this ADR) reduced the whole
|
||||
experiment to two config pushes.
|
||||
|
||||
## What was measured
|
||||
|
||||
The associated AP was chosen on measurement, not assumption. Five transmitters
|
||||
reach all nine receivers; only one carries a usable rate:
|
||||
|
||||
```
|
||||
transmitter n_rx total_fps mean_rssi
|
||||
8c:30:66:86:a4:21 9 109.81 -66.6 <- the AP, 26x the next
|
||||
96:30:66:86:a4:21 9 4.24 -65.9
|
||||
08:36:c9:71:ff:52 9 0.18 -75.9
|
||||
64:16:66:cb:17:07 9 0.08 -81.7
|
||||
10:a5:62:24:bb:30 9 0.06 -78.2
|
||||
```
|
||||
|
||||
A-B-A, ten 30 s windows per arm. `/api/v1/fusion` counters are cumulative since
|
||||
sink start, so they are **differenced over each window** and never divided as
|
||||
lifetime totals — the trap already recorded for `early_drop`/`csi_fps_samples`.
|
||||
The filter applied live on all nine nodes with **no restart**, so no reboot
|
||||
confounds any arm.
|
||||
|
||||
```
|
||||
arm n paired_fraction tx/s paired/s
|
||||
A1 control 10 0.5668 +/- 0.0107 73.0 41.4
|
||||
B filtered 10 0.7287 +/- 0.0352 33.9 24.7
|
||||
A2 control 10 0.5836 +/- 0.0120 81.5 47.6
|
||||
```
|
||||
|
||||
**Pairing fraction +0.1535 against the pooled control (+26.7% relative),
|
||||
Welch t = 13.26, df = 10.5, p < 0.0001.** Reversible: A2 returned to control,
|
||||
+0.0168 against A1.
|
||||
|
||||
Per-node confirmation that the filter did what it claims, taken on node 3 while
|
||||
eight nodes were still unfiltered:
|
||||
|
||||
```
|
||||
node 3 (filtered) AP fps 13.56 non-AP fps 0.28 non-AP share 2.0%
|
||||
node 8 (control) AP fps 12.18 non-AP fps 5.66 non-AP share 31.7%
|
||||
```
|
||||
|
||||
The AP rate is untouched; peer traffic falls to residue.
|
||||
|
||||
## The hypothesis is confirmed. The fix is not.
|
||||
|
||||
Transmitter ambiguity **was** suppressing pairing, and removing it lifts the
|
||||
fraction by a quarter. That settles a question that had been open since
|
||||
2026-09-07 and had resisted three other experiments.
|
||||
|
||||
But the backlog called this the "likely fix", and that half does not survive.
|
||||
It predicted yield would fall to ~13 fps and that "the frames that remain are
|
||||
pairable". Both are true, and neither is sufficient:
|
||||
|
||||
- **tx/s fell 56%** (77.3 -> 33.9)
|
||||
- **paired observations per second fell 44%** (44.4 -> 24.7)
|
||||
|
||||
A higher fraction of a much smaller pie. The quantity downstream sensing
|
||||
actually consumes is paired observations per second, and that went **down**.
|
||||
Fraction was the wrong figure of merit, and choosing it is what made this look
|
||||
like a fix rather than a diagnosis.
|
||||
|
||||
## Decision
|
||||
|
||||
**Do not adopt. Keep the branch unmerged.** Production stays unfiltered,
|
||||
`gate_mode=2`, `gate_seq_period=8`.
|
||||
|
||||
`filter_mac` **is** kept in the config API. It costs nothing when unset, it is
|
||||
now the cheapest available probe for "is this node's pairing transmitter-
|
||||
limited?", and the ADR-060 filter had been effectively unreachable for the
|
||||
fleet's whole life.
|
||||
|
||||
## What this opens
|
||||
|
||||
A **partial** filter is the obvious next question and is NOT what was tested
|
||||
here. Restricting to a handful of widely-heard transmitters rather than exactly
|
||||
one would trade less yield for some of the gain, and there is no reason to think
|
||||
the optimum is at either extreme. The five 9-receiver transmitters above are the
|
||||
natural candidate set, though four of them carry almost no rate.
|
||||
|
||||
That is a new experiment with a different independent variable, not a variant
|
||||
of this one. It should be sized on **paired observations per second**, which is
|
||||
the lesson this ADR paid for.
|
||||
|
||||
## Honest scope
|
||||
|
||||
Software-only. One house, one AP, nine nodes, ~15 minutes per arm, at 05:00
|
||||
local with the house quiet. The effect is far larger than the within-arm
|
||||
scatter and it reversed cleanly, but it has not been repeated across times of
|
||||
day, and pairing is known to move with the transmitter population (see
|
||||
"Transmitter population instability", 2026-09-02).
|
||||
@@ -0,0 +1,69 @@
|
||||
# ADR-362: Reading the on-node log is on-demand, never streamed
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-09-09
|
||||
**Follows:** the on-node log engine (persistent 128 B-record ring + coredump-to-flash)
|
||||
|
||||
## Context
|
||||
|
||||
The firmware now keeps a persistent log on the node's `storage` volume and can
|
||||
write a coredump to flash. Nothing reads either one. The log is a new concept
|
||||
in this tree, so there is no existing consumer to extend and the access
|
||||
pattern is genuinely open.
|
||||
|
||||
Two shapes were considered:
|
||||
|
||||
1. **Ingest** — the server polls every node on a schedule, drains new records,
|
||||
and keeps its own copy that the UI reads.
|
||||
2. **On-demand** — the server fetches a node's log only when a human asks for
|
||||
it, and keeps nothing.
|
||||
|
||||
## Decision
|
||||
|
||||
**On-demand only. No streaming, no periodic polling, no server-side copy, no
|
||||
alerting.**
|
||||
|
||||
The log is pulled after a board hangs or crashes, or while troubleshooting a
|
||||
specific node. That is the whole access pattern.
|
||||
|
||||
## Why
|
||||
|
||||
- **The persistence already lives on the node.** The ring holds ~208 days at
|
||||
128 B per 300 s, and surviving a power cycle is the entire reason the module
|
||||
exists — a power cycle is how a wedged node gets recovered. A second copy on
|
||||
the server duplicates a durability property that is already satisfied.
|
||||
- **Airtime is this fleet's binding constraint, and it is the thing being
|
||||
diagnosed.** Two nodes uplinking at 1 Mbps were measured consuming 91% of
|
||||
fleet airtime. A periodic log drain across nine nodes would spend exactly the
|
||||
resource whose exhaustion is the most common thing you would open the log to
|
||||
investigate. An instrument must not perturb what it measures.
|
||||
- **Coredumps are 64 KB.** They are only ever worth moving when something has
|
||||
actually crashed.
|
||||
- **The use is diagnostic, not observational.** Nobody watches nine logs. You
|
||||
open one node's history when that node misbehaves.
|
||||
|
||||
## What this implies gets built
|
||||
|
||||
- A server route that proxies to the node on request — `GET
|
||||
/api/v1/nodes/{id}/log`, and the same for `/coredump` — authenticating with
|
||||
the OTA PSK the firmware endpoints already sit behind.
|
||||
- A page under the existing Tools menu to view one node's records.
|
||||
- **No** scheduler, **no** new storage, **no** retention policy on the server.
|
||||
|
||||
## Open question, deliberately not settled here
|
||||
|
||||
Whether the server decodes the 128 B records into JSON, or proxies raw bytes
|
||||
and lets the decoder at the edge (browser, or `node_log_read.py`) interpret
|
||||
them.
|
||||
|
||||
Decoding server-side means the record layout is defined in two places — C and
|
||||
Rust — and can drift silently. The pilot already paid for a version of this:
|
||||
a field whose sentinel the host had to learn to render as `n/a`, where a
|
||||
plausible wrong value would have been worse than an obvious gap. That argues
|
||||
for the raw proxy and a single definition of the format.
|
||||
|
||||
## Non-goals
|
||||
|
||||
Real-time monitoring, log streaming, fleet-wide log aggregation, and alerting
|
||||
on log contents are all explicitly out of scope. If a future need genuinely
|
||||
requires one of them, it should revisit this ADR rather than grow into it.
|
||||
@@ -81,6 +81,42 @@ v1, with its flat loss, was barely better than random on this same test.
|
||||
params, trained with InfoNCE (temperature 0.1, in-batch + temporal-far negatives), AdamW, 60 epochs.
|
||||
Temporal positives within 2 s; negatives >30 s apart. Time-disjoint 80/20 split.
|
||||
|
||||
**EWC (catastrophic-forgetting protection):** measured forgetting rate on the 4 protected
|
||||
tasks is **32.97%** — real and non-trivial, disclosed here rather than only in the raw
|
||||
`training-metrics.json`. Fisher information is computed and applied (protection strength
|
||||
2000), but this is not "solved forgetting" — a user fine-tuning per-room LoRA adapters
|
||||
should expect some drift on earlier tasks, not none.
|
||||
|
||||
## 🔧 v2.0.1 — fixed the published training-metrics.json itself (2026-09-09)
|
||||
|
||||
Separately from the v1→v2 accuracy retraction above, an unrelated bug was found and fixed
|
||||
in the **export code** (`scripts/train-ruvllm.js`) that produces `config.json` and
|
||||
`training-metrics.json` for this file set (`model.safetensors`, `model-q2/q4/q8.bin`,
|
||||
`presence-head.json`, `node-1/2.json`) — separate from the `csi-embed-v2*` files, which
|
||||
come from a different script.
|
||||
|
||||
The exported `lossHistory` and `contrastive.durationMs` were being read from a disconnected
|
||||
baseline pass (`ContrastiveTrainer.train()`, which computes loss but never updates the
|
||||
encoder) instead of from the loop that actually performs gradient updates on the encoder
|
||||
weights. Concretely: `lossHistory` published a flat, non-decreasing curve around `0.1352`
|
||||
for all 20 epochs, and `durationMs` reported ~12.4s — both measuring the wrong computation.
|
||||
|
||||
**This did not affect `finalLoss`/`improvement`/the 82.3% held-out temporal-triplet
|
||||
accuracy above** — those were already computed from the real training loop and are
|
||||
unchanged by this fix. It only affected the exported diagnostic curve and duration, which
|
||||
anyone auditing convergence from the published JSON would have been misled by. Re-run
|
||||
against the same real capture (`data/recordings/overnight-1775217646.csi.jsonl`) with the
|
||||
fix applied: `lossHistory` now shows a genuine decreasing curve (0.1037 -> 0.0654 across 20
|
||||
epochs) and `durationMs` reports the real ~40s gradient-loop time. `config.json`'s
|
||||
`training.steps` field (`12212300` — epochs × triplets, arithmetically consistent but read
|
||||
like optimizer-step count) is now reported as separate `epochs`/`tripletUpdatesPerEpoch`
|
||||
fields instead.
|
||||
|
||||
Repro: `node scripts/train-ruvllm.js --data data/recordings/overnight-1775217646.csi.jsonl
|
||||
--output <dir> --epochs 20` in [github.com/ruvnet/RuView](https://github.com/ruvnet/RuView)
|
||||
(the `aether-arena/staging/train_csi_embed.py` path cited above is for the separate
|
||||
`csi-embed-v2*` files and was not touched by this fix).
|
||||
|
||||
### v2 files & proof
|
||||
| File | Size | Use |
|
||||
|------|------|-----|
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
ARM_A1_control,30,1242,2162,0.5745,72.07
|
||||
ARM_A1_control,30,1199,2114,0.5672,70.47
|
||||
ARM_A1_control,30,1259,2223,0.5664,74.10
|
||||
ARM_A1_control,30,1221,2206,0.5535,73.53
|
||||
ARM_A1_control,30,1246,2182,0.5710,72.73
|
||||
ARM_A1_control,30,1295,2247,0.5763,74.90
|
||||
ARM_A1_control,30,1307,2242,0.5830,74.73
|
||||
ARM_A1_control,30,1232,2191,0.5623,73.03
|
||||
ARM_A1_control,30,1261,2225,0.5667,74.17
|
||||
ARM_A1_control,30,1152,2107,0.5467,70.23
|
||||
|
@@ -0,0 +1,10 @@
|
||||
ARM_A2_control,30,1352,2326,0.5813,77.53
|
||||
ARM_A2_control,30,1454,2429,0.5986,80.97
|
||||
ARM_A2_control,30,1474,2479,0.5946,82.63
|
||||
ARM_A2_control,30,1412,2456,0.5749,81.87
|
||||
ARM_A2_control,30,1387,2443,0.5677,81.43
|
||||
ARM_A2_control,30,1389,2384,0.5826,79.47
|
||||
ARM_A2_control,30,1399,2482,0.5637,82.73
|
||||
ARM_A2_control,30,1410,2375,0.5937,79.17
|
||||
ARM_A2_control,30,1509,2537,0.5948,84.57
|
||||
ARM_A2_control,30,1483,2541,0.5836,84.70
|
||||
|
@@ -0,0 +1,10 @@
|
||||
ARM_B_filtered,30,734,1062,0.6911,35.40
|
||||
ARM_B_filtered,30,700,982,0.7128,32.73
|
||||
ARM_B_filtered,30,734,1002,0.7325,33.40
|
||||
ARM_B_filtered,30,706,927,0.7616,30.90
|
||||
ARM_B_filtered,30,730,955,0.7644,31.83
|
||||
ARM_B_filtered,30,712,915,0.7781,30.50
|
||||
ARM_B_filtered,30,734,971,0.7559,32.37
|
||||
ARM_B_filtered,30,742,1103,0.6727,36.77
|
||||
ARM_B_filtered,30,812,1139,0.7129,37.97
|
||||
ARM_B_filtered,30,785,1114,0.7047,37.13
|
||||
|
@@ -1,5 +1,5 @@
|
||||
set(SRCS
|
||||
"main.c" "csi_collector.c"
|
||||
"main.c" "csi_collector.c" "node_log.c"
|
||||
"thermal.c" "stream_sender.c" "nvs_config.c"
|
||||
"edge_processing.c" "ota_update.c" "power_mgmt.c"
|
||||
"wasm_runtime.c" "wasm_upload.c" "rvf_parser.c"
|
||||
@@ -32,6 +32,9 @@ set(REQUIRES
|
||||
esp_http_client
|
||||
esp_app_format
|
||||
esp_timer
|
||||
fatfs # node_log.c: the storage FAT volume
|
||||
wear_levelling # node_log.c: wl_mount behind that volume
|
||||
espcoredump # coredump-to-flash readback
|
||||
esp_pm
|
||||
esp_driver_uart
|
||||
esp_driver_gpio
|
||||
|
||||
@@ -750,6 +750,20 @@ uint16_t csi_collector_get_send_fail_count(void)
|
||||
return (f > 0xFFFFu) ? 0xFFFFu : (uint16_t)f;
|
||||
}
|
||||
|
||||
/* The gate here is fixed at compile time (mesh-aligned bucketing, falling
|
||||
* back to elapsed time when unsynced), so there is no mode or period to
|
||||
* report. Return the explicit sentinel rather than a number that reads as
|
||||
* a real setting. */
|
||||
uint8_t csi_collector_get_gate_mode(void)
|
||||
{
|
||||
return CSI_GATE_NOT_CONFIGURABLE;
|
||||
}
|
||||
|
||||
uint8_t csi_collector_get_gate_seq_period(void)
|
||||
{
|
||||
return CSI_GATE_NOT_CONFIGURABLE;
|
||||
}
|
||||
|
||||
/* ---- ADR-029: Channel hopping ---- */
|
||||
|
||||
void csi_collector_set_hop_table(const uint8_t *channels, uint8_t hop_count, uint32_t dwell_ms)
|
||||
|
||||
@@ -139,4 +139,22 @@ uint16_t csi_collector_get_pkt_yield_per_sec(void);
|
||||
*/
|
||||
uint16_t csi_collector_get_send_fail_count(void);
|
||||
|
||||
/** Reported by the gate accessors when this build has no selectable gate. */
|
||||
#define CSI_GATE_NOT_CONFIGURABLE 0xFF
|
||||
|
||||
/**
|
||||
* Report which frame-selection gate is in force, for the on-node log.
|
||||
*
|
||||
* This build has exactly one gate -- mesh-aligned bucketing with an
|
||||
* elapsed-time fallback -- fixed at compile time, so there is no mode or
|
||||
* period to report and both accessors return CSI_GATE_NOT_CONFIGURABLE.
|
||||
*
|
||||
* They exist so the log record keeps a field of fixed width and meaning
|
||||
* whether or not a build can select a gate at runtime, and so an explicit
|
||||
* "not applicable" is stored rather than a plausible number that would
|
||||
* later be reasoned from.
|
||||
*/
|
||||
uint8_t csi_collector_get_gate_mode(void);
|
||||
uint8_t csi_collector_get_gate_seq_period(void);
|
||||
|
||||
#endif /* CSI_COLLECTOR_H */
|
||||
@@ -21,6 +21,7 @@
|
||||
#include "led_strip.h"
|
||||
|
||||
#include "csi_collector.h"
|
||||
#include "node_log.h"
|
||||
#include "thermal.h"
|
||||
#include "stream_sender.h"
|
||||
#include "nvs_config.h"
|
||||
@@ -116,6 +117,10 @@ static void event_handler(void *arg, esp_event_base_t event_base,
|
||||
} else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
|
||||
wifi_event_sta_disconnected_t *disc = (wifi_event_sta_disconnected_t *)event_data;
|
||||
ESP_LOGW(TAG, "WiFi disconnected, reason=%d rssi=%d", disc->reason, disc->rssi);
|
||||
/* The 7.5 h outage was root-caused to the MAX_RETRY latch only because
|
||||
* someone was watching the console at the time. Persist it. */
|
||||
node_log_note_disconnect((uint8_t)disc->reason, (int8_t)disc->rssi);
|
||||
node_log_event(NODE_LOG_EV_WIFI_DISCONNECT, disc->reason, disc->rssi);
|
||||
s_retry_num++;
|
||||
/* Release the boot wait once, so a node that comes up while the AP is
|
||||
* down still starts CSI capture and the mesh instead of blocking in
|
||||
@@ -451,6 +456,24 @@ void app_main(void)
|
||||
} else {
|
||||
ESP_LOGI(TAG, "reset reason: %s (%d)", why, (int)rr);
|
||||
}
|
||||
|
||||
/* And persist it. The console line above is exactly what a power cycle
|
||||
* destroys, which is the whole reason this fleet has no post-mortem for
|
||||
* a remote fault. node_log_init failing is non-fatal by design -- the
|
||||
* node comes up either way, it just has no memory. */
|
||||
if (node_log_init() == ESP_OK) {
|
||||
uint32_t prev_uptime_s = 0;
|
||||
nvs_handle_t nh;
|
||||
if (nvs_open("nodelog", NVS_READWRITE, &nh) == ESP_OK) {
|
||||
nvs_get_u32(nh, "last_up_s", &prev_uptime_s);
|
||||
nvs_set_u32(nh, "last_up_s", 0);
|
||||
nvs_commit(nh);
|
||||
nvs_close(nh);
|
||||
}
|
||||
node_log_boot((uint32_t)rr, prev_uptime_s);
|
||||
ESP_LOGI(TAG, "on-node log active: boot_id=%u, %u records retained",
|
||||
(unsigned)node_log_boot_id(), (unsigned)node_log_count());
|
||||
}
|
||||
}
|
||||
|
||||
/* Bring thermal monitoring up before the radio is loaded, so the first
|
||||
@@ -655,5 +678,24 @@ void app_main(void)
|
||||
#ifdef CONFIG_UPLINK_WATCHDOG
|
||||
uplink_watchdog_tick();
|
||||
#endif
|
||||
/* Offered every 10 s; node_log enforces its own floor, so the cadence
|
||||
* lives in one place rather than being implied by this loop's delay.
|
||||
* Also refresh the uptime NVS cell, which is what lets the NEXT boot
|
||||
* record say how long the previous session actually survived -- the
|
||||
* difference between "it rebooted" and "it wedged after 225 s". */
|
||||
node_log_periodic();
|
||||
{
|
||||
static uint32_t s_last_persist_s;
|
||||
uint32_t up_s = (uint32_t)(esp_timer_get_time() / 1000000);
|
||||
if (up_s - s_last_persist_s >= 60) {
|
||||
s_last_persist_s = up_s;
|
||||
nvs_handle_t nh;
|
||||
if (nvs_open("nodelog", NVS_READWRITE, &nh) == ESP_OK) {
|
||||
nvs_set_u32(nh, "last_up_s", up_s);
|
||||
nvs_commit(nh);
|
||||
nvs_close(nh);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
#include "node_log.h"
|
||||
|
||||
#include <limits.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "esp_log.h"
|
||||
#include "esp_system.h"
|
||||
#include "esp_timer.h"
|
||||
#include "esp_vfs_fat.h"
|
||||
#include "esp_wifi.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/semphr.h"
|
||||
#include "nvs.h"
|
||||
#include "nvs_flash.h"
|
||||
|
||||
#include "c6_sync_espnow.h"
|
||||
#include "csi_collector.h"
|
||||
#include "nvs_config.h"
|
||||
#include "thermal.h"
|
||||
|
||||
static const char *TAG = "node_log";
|
||||
|
||||
/* The FAT volume reserved in partitions_16mb.csv (storage, 8000K) and never
|
||||
* mounted until now. */
|
||||
#define NODE_LOG_PARTITION "storage"
|
||||
#define NODE_LOG_MOUNT "/nlog"
|
||||
#define NODE_LOG_PATH NODE_LOG_MOUNT "/health.bin"
|
||||
|
||||
/* Ring capacity. 60000 * 128 B = 7.68 MB, just inside the 8000K volume with
|
||||
* room for FAT's own metadata. At the 300 s cadence that is ~208 days. */
|
||||
#define NODE_LOG_MAX_RECORDS 60000
|
||||
|
||||
/* Hard floor between periodic samples. The design settled on 300 s; this is
|
||||
* the enforcement, not the schedule. A caller asking more often is DROPPED
|
||||
* rather than served, because flash wear is the one way this module can do
|
||||
* harm and a bug in a caller must not be able to cause it. */
|
||||
#define NODE_LOG_MIN_PERIOD_S 60
|
||||
|
||||
/* Events bypass the periodic floor -- they are rare and they are the whole
|
||||
* point -- but not without limit, or a disconnect storm becomes a write storm. */
|
||||
#define NODE_LOG_MIN_EVENT_MS 1000
|
||||
|
||||
static bool s_active;
|
||||
static FILE *s_fp;
|
||||
static wl_handle_t s_wl = WL_INVALID_HANDLE;
|
||||
static SemaphoreHandle_t s_lock;
|
||||
static uint16_t s_boot_id;
|
||||
static uint32_t s_seq;
|
||||
static size_t s_count; /* records currently in the ring */
|
||||
static size_t s_head; /* next slot to write */
|
||||
static int64_t s_last_periodic_us;
|
||||
static int64_t s_last_event_us;
|
||||
|
||||
/* CRC16/CCITT-FALSE. A record that fails this is skipped by the reader rather
|
||||
* than trusted, which matters because a power cut mid-write leaves a partial
|
||||
* record and the ring has no other way to know. */
|
||||
static uint16_t crc16(const uint8_t *p, size_t n)
|
||||
{
|
||||
uint16_t crc = 0xFFFF;
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
crc ^= (uint16_t)p[i] << 8;
|
||||
for (int b = 0; b < 8; b++) {
|
||||
crc = (crc & 0x8000) ? (uint16_t)((crc << 1) ^ 0x1021) : (uint16_t)(crc << 1);
|
||||
}
|
||||
}
|
||||
return crc;
|
||||
}
|
||||
|
||||
/* 16 B header, then a type-specific payload. The header is identical across
|
||||
* types so a reader can order and validate without dispatching first. */
|
||||
typedef struct __attribute__((packed)) {
|
||||
uint8_t type;
|
||||
uint8_t flags;
|
||||
uint16_t boot_id;
|
||||
uint32_t seq;
|
||||
uint32_t uptime_s;
|
||||
uint16_t payload_len;
|
||||
uint16_t crc16; /* over the record with this field zeroed */
|
||||
} node_log_hdr_t;
|
||||
|
||||
_Static_assert(sizeof(node_log_hdr_t) == 16, "header must be 16 B");
|
||||
|
||||
/* ~72 B used of the 112 B payload budget, leaving ~40 B of deliberate slack so
|
||||
* a field can be added later WITHOUT changing the record size and invalidating
|
||||
* every reader already in the field. */
|
||||
typedef struct __attribute__((packed)) {
|
||||
int8_t die_c;
|
||||
uint8_t thermal_state;
|
||||
int8_t tx_dbm;
|
||||
int8_t ap_rssi_dbm; /* the one field we did not have and needed */
|
||||
uint8_t channel;
|
||||
uint8_t wifi_state;
|
||||
uint8_t gate_mode;
|
||||
uint8_t gate_seq_period;
|
||||
uint16_t free_heap_kib;
|
||||
uint16_t min_heap_kib;
|
||||
uint16_t csi_fps_x100;
|
||||
uint32_t frames_processed;
|
||||
uint32_t frames_rejected;
|
||||
uint32_t seq_drop;
|
||||
uint32_t tx_early_drop;
|
||||
uint16_t tx_rate_skip;
|
||||
uint32_t tx_send_fail;
|
||||
uint16_t disconnect_count;
|
||||
uint8_t last_disc_reason;
|
||||
int8_t last_disc_rssi;
|
||||
uint8_t mesh_flags; /* bit0 valid, bit1 leader */
|
||||
uint8_t leader_id;
|
||||
int32_t mesh_offset_us;
|
||||
uint16_t mesh_staleness_ms;
|
||||
uint32_t mesh_seq;
|
||||
} node_log_periodic_t;
|
||||
|
||||
typedef struct __attribute__((packed)) {
|
||||
uint32_t reset_reason;
|
||||
uint32_t prev_uptime_s;
|
||||
node_log_periodic_t health; /* a boot record carries a snapshot too */
|
||||
} node_log_boot_t;
|
||||
|
||||
typedef struct __attribute__((packed)) {
|
||||
uint8_t subtype;
|
||||
uint8_t _pad[3];
|
||||
int32_t a;
|
||||
int32_t b;
|
||||
} node_log_event_t;
|
||||
|
||||
/* Counters main.c owns; set through the setters below rather than reached for,
|
||||
* so this module has no include cycle with main.c. */
|
||||
static volatile uint16_t s_disconnect_count;
|
||||
static volatile uint8_t s_last_disc_reason;
|
||||
static volatile int8_t s_last_disc_rssi;
|
||||
|
||||
void node_log_note_disconnect(uint8_t reason, int8_t rssi)
|
||||
{
|
||||
s_disconnect_count++;
|
||||
s_last_disc_reason = reason;
|
||||
s_last_disc_rssi = rssi;
|
||||
}
|
||||
|
||||
static void fill_health(node_log_periodic_t *h)
|
||||
{
|
||||
memset(h, 0, sizeof(*h));
|
||||
|
||||
/* thermal.c initialises s_last_c to -273.0f and only replaces it on the
|
||||
* first thermal_tick(), which runs from the adaptive controller -- LATER
|
||||
* than the boot record and later than the first periodic samples. Casting
|
||||
* that sentinel to int8 yields -17, a PLAUSIBLE temperature, which is
|
||||
* exactly the kind of invented-but-believable number that must never reach
|
||||
* a log someone will later reason from. Store INT8_MIN as an explicit
|
||||
* "no reading" instead; node_log_read.py renders it as n/a. */
|
||||
float die = thermal_celsius();
|
||||
h->die_c = (die < -100.0f || die > 125.0f) ? INT8_MIN : (int8_t)die;
|
||||
h->thermal_state = (uint8_t)thermal_state();
|
||||
h->tx_dbm = thermal_tx_dbm();
|
||||
h->gate_mode = csi_collector_get_gate_mode();
|
||||
h->gate_seq_period = csi_collector_get_gate_seq_period();
|
||||
|
||||
/* The association link, NOT the CSI-derived rssi_median_dbm in
|
||||
* adaptive_controller.c. Nodes 5 and 6 show every symptom of a bad uplink
|
||||
* and this is the one call that can confirm or refute it. */
|
||||
wifi_ap_record_t ap;
|
||||
if (esp_wifi_sta_get_ap_info(&ap) == ESP_OK) {
|
||||
h->ap_rssi_dbm = (int8_t)ap.rssi;
|
||||
h->channel = ap.primary;
|
||||
h->wifi_state = 1;
|
||||
}
|
||||
|
||||
h->free_heap_kib = (uint16_t)(esp_get_free_heap_size() / 1024);
|
||||
h->min_heap_kib = (uint16_t)(esp_get_minimum_free_heap_size() / 1024);
|
||||
h->csi_fps_x100 = csi_collector_get_pkt_yield_per_sec() * 100;
|
||||
h->tx_send_fail = csi_collector_get_send_fail_count();
|
||||
|
||||
h->disconnect_count = s_disconnect_count;
|
||||
h->last_disc_reason = s_last_disc_reason;
|
||||
h->last_disc_rssi = s_last_disc_rssi;
|
||||
|
||||
h->mesh_flags = (uint8_t)((c6_sync_espnow_is_valid() ? 1 : 0)
|
||||
| (c6_sync_espnow_is_leader() ? 2 : 0));
|
||||
int64_t off = c6_sync_espnow_get_offset_us();
|
||||
/* Saturate rather than wrap: a wrapped offset reads as a plausible small
|
||||
* number and would be believed. */
|
||||
if (off > INT32_MAX) h->mesh_offset_us = INT32_MAX;
|
||||
else if (off < INT32_MIN) h->mesh_offset_us = INT32_MIN;
|
||||
else h->mesh_offset_us = (int32_t)off;
|
||||
}
|
||||
|
||||
/* Append one record. Callers hold no lock; this takes it. */
|
||||
static void append(uint8_t type, const void *payload, size_t len)
|
||||
{
|
||||
if (!s_active || len > (NODE_LOG_RECORD_SIZE - sizeof(node_log_hdr_t))) {
|
||||
return;
|
||||
}
|
||||
if (xSemaphoreTake(s_lock, pdMS_TO_TICKS(200)) != pdTRUE) {
|
||||
return; /* never block a caller on the log */
|
||||
}
|
||||
|
||||
uint8_t rec[NODE_LOG_RECORD_SIZE];
|
||||
memset(rec, 0, sizeof(rec));
|
||||
node_log_hdr_t *h = (node_log_hdr_t *)rec;
|
||||
h->type = type;
|
||||
h->boot_id = s_boot_id;
|
||||
h->seq = ++s_seq;
|
||||
h->uptime_s = (uint32_t)(esp_timer_get_time() / 1000000);
|
||||
h->payload_len = (uint16_t)len;
|
||||
h->crc16 = 0;
|
||||
memcpy(rec + sizeof(*h), payload, len);
|
||||
h->crc16 = crc16(rec, sizeof(rec));
|
||||
|
||||
if (fseek(s_fp, (long)(s_head * NODE_LOG_RECORD_SIZE), SEEK_SET) == 0
|
||||
&& fwrite(rec, 1, sizeof(rec), s_fp) == sizeof(rec)) {
|
||||
/* fflush only pushes the C stdio buffer into the VFS; FATFS still holds
|
||||
* the directory entry and FAT chain in RAM. Without fsync a reboot
|
||||
* loses the file's size, and on the next open "r+b" sees a zero-length
|
||||
* file -- which is precisely what the node-3 pilot showed: boot 1's
|
||||
* records vanished while the NVS-backed prev_uptime survived. A log
|
||||
* that does not survive a power cycle is the exact thing this module
|
||||
* exists to prevent, so the cost of an fsync per record is accepted:
|
||||
* at one record per 60 s it is nothing, and the bounded write rate is
|
||||
* what keeps it that way. */
|
||||
fflush(s_fp);
|
||||
fsync(fileno(s_fp));
|
||||
s_head = (s_head + 1) % NODE_LOG_MAX_RECORDS;
|
||||
if (s_count < NODE_LOG_MAX_RECORDS) s_count++;
|
||||
} else {
|
||||
ESP_LOGW(TAG, "record write failed; disabling to protect the volume");
|
||||
s_active = false;
|
||||
}
|
||||
xSemaphoreGive(s_lock);
|
||||
}
|
||||
|
||||
esp_err_t node_log_init(void)
|
||||
{
|
||||
if (s_active) return ESP_OK;
|
||||
|
||||
s_lock = xSemaphoreCreateMutex();
|
||||
if (!s_lock) return ESP_ERR_NO_MEM;
|
||||
|
||||
const esp_vfs_fat_mount_config_t cfg = {
|
||||
.format_if_mount_failed = true,
|
||||
.max_files = 2,
|
||||
.allocation_unit_size = CONFIG_WL_SECTOR_SIZE,
|
||||
};
|
||||
esp_err_t err = esp_vfs_fat_spiflash_mount_rw_wl(
|
||||
NODE_LOG_MOUNT, NODE_LOG_PARTITION, &cfg, &s_wl);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "FAT mount failed (%s); logging DISABLED, node unaffected",
|
||||
esp_err_to_name(err));
|
||||
return err;
|
||||
}
|
||||
|
||||
/* boot_id orders records across reboots. Without an RTC it is the only
|
||||
* ordering that survives a power cycle. */
|
||||
nvs_handle_t nh;
|
||||
if (nvs_open("nodelog", NVS_READWRITE, &nh) == ESP_OK) {
|
||||
uint16_t b = 0;
|
||||
nvs_get_u16(nh, "boot_id", &b);
|
||||
s_boot_id = (uint16_t)(b + 1);
|
||||
nvs_set_u16(nh, "boot_id", s_boot_id);
|
||||
nvs_commit(nh);
|
||||
nvs_close(nh);
|
||||
}
|
||||
|
||||
s_fp = fopen(NODE_LOG_PATH, "r+b");
|
||||
if (!s_fp) {
|
||||
/* First boot only. Create it, then fsync so the directory entry is on
|
||||
* flash before anything is written into it. */
|
||||
s_fp = fopen(NODE_LOG_PATH, "w+b");
|
||||
if (s_fp) {
|
||||
fflush(s_fp);
|
||||
fsync(fileno(s_fp));
|
||||
}
|
||||
}
|
||||
if (!s_fp) {
|
||||
ESP_LOGW(TAG, "cannot open %s; logging DISABLED", NODE_LOG_PATH);
|
||||
esp_vfs_fat_spiflash_unmount_rw_wl(NODE_LOG_MOUNT, s_wl);
|
||||
s_wl = WL_INVALID_HANDLE;
|
||||
return ESP_FAIL;
|
||||
}
|
||||
|
||||
/* Recover head/seq by scanning. The ring has no superblock deliberately:
|
||||
* a superblock is one more thing a power cut can leave inconsistent, and
|
||||
* the scan is a one-off cost at boot. */
|
||||
uint8_t rec[NODE_LOG_RECORD_SIZE];
|
||||
uint32_t best_seq = 0;
|
||||
for (size_t i = 0; i < NODE_LOG_MAX_RECORDS; i++) {
|
||||
if (fseek(s_fp, (long)(i * NODE_LOG_RECORD_SIZE), SEEK_SET) != 0) break;
|
||||
if (fread(rec, 1, sizeof(rec), s_fp) != sizeof(rec)) break;
|
||||
node_log_hdr_t *h = (node_log_hdr_t *)rec;
|
||||
if (h->type == 0) continue;
|
||||
uint16_t got = h->crc16;
|
||||
h->crc16 = 0;
|
||||
if (crc16(rec, sizeof(rec)) != got) continue;
|
||||
s_count++;
|
||||
if (h->seq > best_seq) {
|
||||
best_seq = h->seq;
|
||||
s_head = (i + 1) % NODE_LOG_MAX_RECORDS;
|
||||
}
|
||||
}
|
||||
s_seq = best_seq;
|
||||
s_active = true;
|
||||
ESP_LOGI(TAG, "logging active: boot_id=%u recovered=%u records, head=%u",
|
||||
(unsigned)s_boot_id, (unsigned)s_count, (unsigned)s_head);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
bool node_log_is_active(void) { return s_active; }
|
||||
uint16_t node_log_boot_id(void) { return s_boot_id; }
|
||||
size_t node_log_count(void) { return s_count; }
|
||||
|
||||
void node_log_boot(uint32_t reset_reason, uint32_t prev_uptime_s)
|
||||
{
|
||||
node_log_boot_t b;
|
||||
memset(&b, 0, sizeof(b));
|
||||
b.reset_reason = reset_reason;
|
||||
b.prev_uptime_s = prev_uptime_s;
|
||||
fill_health(&b.health);
|
||||
append(NODE_LOG_TYPE_BOOT, &b, sizeof(b));
|
||||
}
|
||||
|
||||
void node_log_periodic(void)
|
||||
{
|
||||
int64_t now = esp_timer_get_time();
|
||||
if (s_last_periodic_us != 0
|
||||
&& (now - s_last_periodic_us) < (int64_t)NODE_LOG_MIN_PERIOD_S * 1000000) {
|
||||
return;
|
||||
}
|
||||
s_last_periodic_us = now;
|
||||
node_log_periodic_t h;
|
||||
fill_health(&h);
|
||||
append(NODE_LOG_TYPE_PERIODIC, &h, sizeof(h));
|
||||
}
|
||||
|
||||
void node_log_event(uint8_t subtype, int32_t a, int32_t b)
|
||||
{
|
||||
int64_t now = esp_timer_get_time();
|
||||
if (s_last_event_us != 0
|
||||
&& (now - s_last_event_us) < (int64_t)NODE_LOG_MIN_EVENT_MS * 1000) {
|
||||
return;
|
||||
}
|
||||
s_last_event_us = now;
|
||||
node_log_event_t e;
|
||||
memset(&e, 0, sizeof(e));
|
||||
e.subtype = subtype;
|
||||
e.a = a;
|
||||
e.b = b;
|
||||
append(NODE_LOG_TYPE_EVENT, &e, sizeof(e));
|
||||
}
|
||||
|
||||
size_t node_log_read(uint8_t *out, size_t max_records, size_t skip)
|
||||
{
|
||||
if (!s_active || !out || max_records == 0) return 0;
|
||||
if (xSemaphoreTake(s_lock, pdMS_TO_TICKS(500)) != pdTRUE) return 0;
|
||||
|
||||
size_t written = 0;
|
||||
for (size_t n = 0; n < s_count && written < max_records; n++) {
|
||||
if (n < skip) continue;
|
||||
/* newest-first: step back from head */
|
||||
size_t idx = (s_head + NODE_LOG_MAX_RECORDS - 1 - n) % NODE_LOG_MAX_RECORDS;
|
||||
if (fseek(s_fp, (long)(idx * NODE_LOG_RECORD_SIZE), SEEK_SET) != 0) break;
|
||||
if (fread(out + written * NODE_LOG_RECORD_SIZE, 1, NODE_LOG_RECORD_SIZE, s_fp)
|
||||
!= NODE_LOG_RECORD_SIZE) break;
|
||||
written++;
|
||||
}
|
||||
xSemaphoreGive(s_lock);
|
||||
return written;
|
||||
}
|
||||
|
||||
esp_err_t node_log_clear(void)
|
||||
{
|
||||
if (!s_active) return ESP_ERR_INVALID_STATE;
|
||||
if (xSemaphoreTake(s_lock, pdMS_TO_TICKS(1000)) != pdTRUE) return ESP_ERR_TIMEOUT;
|
||||
esp_err_t err = ESP_OK;
|
||||
if (s_fp) { fclose(s_fp); s_fp = NULL; }
|
||||
s_fp = fopen(NODE_LOG_PATH, "w+b");
|
||||
if (!s_fp) { s_active = false; err = ESP_FAIL; }
|
||||
s_count = 0;
|
||||
s_head = 0;
|
||||
s_seq = 0;
|
||||
xSemaphoreGive(s_lock);
|
||||
return err;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/* Persistent on-node log -- survives a power cycle, readable without a cable.
|
||||
*
|
||||
* The problem this exists for (Joe, 2026-09-07): "a hung node ... doesnt do us
|
||||
* any good for troubleshooting remotly because the unplug and mount results in
|
||||
* a clearing of the condtion." Today the post-mortem for any remote fault is
|
||||
* nothing: main.c decodes the reset reason at boot and logs it to the CONSOLE,
|
||||
* which is exactly what a power cycle destroys.
|
||||
*
|
||||
* The flash was already partitioned for this and the features were off --
|
||||
* partitions_16mb.csv reserves a 64K coredump partition and an 8000K storage
|
||||
* FAT volume, neither of which had ever been mounted or written.
|
||||
*
|
||||
* # Retention, not wear, is the binding constraint
|
||||
*
|
||||
* 128 B records every 300 s fills 8 MB in ~222 days. Endurance at that rate is
|
||||
* ~61,000 years, so the ring wraps 36 million times before the flash is at
|
||||
* risk. The write rate is nonetheless bounded explicitly below, because flash
|
||||
* wear is the one way this module can do harm.
|
||||
*
|
||||
* # Why uptime and boot_id rather than a timestamp
|
||||
*
|
||||
* These boards have no RTC. Nothing can be timestamped absolutely, so ordering
|
||||
* comes from boot_id (incremented once per boot, persisted in NVS) plus
|
||||
* uptime_s within that boot. That ordering survives a power cycle; a wall clock
|
||||
* would not.
|
||||
*
|
||||
* # Counters are stored RAW
|
||||
*
|
||||
* Every counter here is cumulative and is written as-is. Differencing happens
|
||||
* at read time. Storing rates instead would bake in the sampling interval and
|
||||
* make every record useless the moment the cadence changed.
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/** Every record is exactly this size, so the ring is index-addressable and a
|
||||
* reader never has to parse forward to find a boundary. */
|
||||
#define NODE_LOG_RECORD_SIZE 128
|
||||
|
||||
/** Record types. A reader dispatches on this byte alone. */
|
||||
#define NODE_LOG_TYPE_PERIODIC 1
|
||||
#define NODE_LOG_TYPE_BOOT 2
|
||||
#define NODE_LOG_TYPE_EVENT 3
|
||||
|
||||
/** Event subtypes. Periodic samples give the trend; these give the moment. */
|
||||
#define NODE_LOG_EV_WIFI_DISCONNECT 1
|
||||
#define NODE_LOG_EV_WIFI_RECONNECT 2
|
||||
#define NODE_LOG_EV_THERMAL_STATE 3
|
||||
#define NODE_LOG_EV_SEND_FAIL_RATE 4
|
||||
#define NODE_LOG_EV_WATCHDOG 5
|
||||
|
||||
/** Mount the FAT volume and open the ring. Call once at boot, after NVS is up
|
||||
* (the boot counter lives there).
|
||||
*
|
||||
* Failure is NOT fatal: on any error this module disables itself and every
|
||||
* other entry point becomes a no-op. A logging volume that will not mount must
|
||||
* never take the fleet down with it. */
|
||||
esp_err_t node_log_init(void);
|
||||
|
||||
/** True when the ring mounted and writes are being accepted. */
|
||||
bool node_log_is_active(void);
|
||||
|
||||
/** Append the boot record: reset reason, the previous session's final uptime,
|
||||
* and a health snapshot.
|
||||
*
|
||||
* The highest-value record here. It catches a HANG FOLLOWED BY A WATCHDOG
|
||||
* RESET, which is the failure that bites this fleet and the one a coredump
|
||||
* does not capture. */
|
||||
void node_log_boot(uint32_t reset_reason, uint32_t prev_uptime_s);
|
||||
|
||||
/** Append a periodic health sample. Rate-limited internally, so a caller cannot
|
||||
* wear the flash by calling it in a tight loop. */
|
||||
void node_log_periodic(void);
|
||||
|
||||
/** Append an event record. a/b carry subtype-specific detail -- for a WiFi
|
||||
* disconnect they are the reason code and the RSSI at that moment, the pair
|
||||
* main.c currently logs only to the console. */
|
||||
void node_log_event(uint8_t subtype, int32_t a, int32_t b);
|
||||
|
||||
/** Record a WiFi disconnect for the health snapshot's cumulative counters.
|
||||
* Kept as a setter so this module has no include cycle with main.c, which is
|
||||
* where the reason and RSSI are actually known. */
|
||||
void node_log_note_disconnect(uint8_t reason, int8_t rssi);
|
||||
|
||||
/** Copy raw records into out, newest-first, for the HTTP reader.
|
||||
*
|
||||
* @param out destination, at least max_records * NODE_LOG_RECORD_SIZE
|
||||
* @param max_records maximum to copy
|
||||
* @param skip how many of the newest to skip, so a caller can page
|
||||
* @return number of records written into out */
|
||||
size_t node_log_read(uint8_t *out, size_t max_records, size_t skip);
|
||||
|
||||
/** Total records currently held in the ring. */
|
||||
size_t node_log_count(void);
|
||||
|
||||
/** Boot id of the current session. Records from the same boot share it. */
|
||||
uint16_t node_log_boot_id(void);
|
||||
|
||||
/** Erase every record. Deliberately explicit and never called automatically --
|
||||
* the whole point of the module is that state survives. */
|
||||
esp_err_t node_log_clear(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,25 @@
|
||||
# ESP32-C6 CSI Node - 16MB flash partition table
|
||||
# For boards with 16MB flash (e.g. ESP32-C6 DevKitC-1 N16).
|
||||
#
|
||||
# Two 4MB OTA slots. The app is ~1.04MB, so the slots are far larger than
|
||||
# needed for the binary alone -- the size is chosen so an OTA never has to
|
||||
# care how much the image has grown, and it leaves room for a coredump
|
||||
# partition and an 8MB FAT volume for on-node storage.
|
||||
#
|
||||
# The 4MB and display layouts do not fit this: their slots are 1.875MB and
|
||||
# 2MB, sized for 4MB and 8MB parts respectively.
|
||||
#
|
||||
# Usage: set in sdkconfig, or use the matching defaults file:
|
||||
# CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions_16mb.csv"
|
||||
# idf.py -DSDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.esp32c6;sdkconfig.defaults.16mb" build
|
||||
#
|
||||
# Verify before flashing an already-deployed node: a smaller table written
|
||||
# over a 16MB node relocates NVS and destroys its provisioning.
|
||||
# Name, Type, SubType, Offset, Size, Flags
|
||||
nvs,data,nvs,0x9000,24K,
|
||||
otadata,data,ota,0xf000,8K,
|
||||
phy_init,data,phy,0x11000,4K,
|
||||
ota_0,app,ota_0,0x20000,4M,
|
||||
ota_1,app,ota_1,0x420000,4M,
|
||||
coredump,data,coredump,0x820000,64K,
|
||||
storage,data,fat,0x830000,8000K,
|
||||
|
Can't render this file because it contains an unexpected character in line 13 and column 44.
|
@@ -0,0 +1,41 @@
|
||||
# ESP32-C6 CSI Node - 16MB Flash SDK Configuration
|
||||
# For boards with 16MB flash (e.g. ESP32-C6 DevKitC-1 N16).
|
||||
#
|
||||
# Layered on top of sdkconfig.defaults.esp32c6, which keeps a 4MB layout
|
||||
# because 4MB C6 devkits are the common case and must not be silently
|
||||
# repartitioned by inheriting this.
|
||||
#
|
||||
# Build:
|
||||
# idf.py -DSDKCONFIG_DEFAULTS="sdkconfig.defaults;sdkconfig.defaults.esp32c6;sdkconfig.defaults.16mb" build
|
||||
#
|
||||
# Confirm before flashing a deployed node: /ota/status must report
|
||||
# max_size 4194304. Writing a smaller table over a 16MB node relocates NVS
|
||||
# and destroys its provisioning.
|
||||
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y
|
||||
CONFIG_ESPTOOLPY_FLASHSIZE="16MB"
|
||||
CONFIG_PARTITION_TABLE_CUSTOM=y
|
||||
CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions_16mb.csv"
|
||||
|
||||
# Firmware rollback: an OTA'd image boots PENDING_VERIFY and must confirm
|
||||
# itself, or the bootloader reverts on the next boot. See ota_rollback_* in
|
||||
# ota_update.c for when confirmation happens and why.
|
||||
CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y
|
||||
|
||||
# Coredump to flash. The 64K `coredump` partition has existed in
|
||||
# partitions_16mb.csv since the 16MB layout was written and has never held
|
||||
# anything, because the build defaulted to
|
||||
# CONFIG_ESP_COREDUMP_ENABLE_TO_NONE=y. A panic therefore left no stack trace
|
||||
# on a fleet whose faults are all remote.
|
||||
#
|
||||
# Complementary to the on-node log, not a substitute: a coredump captures a
|
||||
# CRASH, and the log's boot record captures a HANG followed by a watchdog
|
||||
# reset -- which is the failure this fleet actually sees.
|
||||
CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH=y
|
||||
CONFIG_ESP_COREDUMP_DATA_FORMAT_ELF=y
|
||||
CONFIG_ESP_COREDUMP_CHECKSUM_CRC32=y
|
||||
CONFIG_ESP_COREDUMP_LOGS=y
|
||||
|
||||
# The `storage` FAT volume (8000K) that node_log.c mounts. Reserved in the
|
||||
# same partition table and likewise never used until now.
|
||||
CONFIG_WL_SECTOR_SIZE_4096=y
|
||||
+27
-4
@@ -1166,11 +1166,21 @@ async function main() {
|
||||
// We iterate over triplets, compute gradients via computeGradient(), and apply
|
||||
// them to update the encoder's w2 layer (the embedding projection layer).
|
||||
console.log(' Applying gradient updates to encoder weights...');
|
||||
// Same bug class as lossHistory: contrastiveResult.durationMs times the
|
||||
// no-op contrastiveTrainer.train() baseline pass above, not this loop --
|
||||
// the actual gradient-update work. Time it here instead.
|
||||
const contrastiveLoopStart = Date.now();
|
||||
|
||||
const contrastiveLr = CONFIG.learningRate;
|
||||
const contrastiveEpochs = CONFIG.epochs;
|
||||
let initialContrastiveLoss = 0;
|
||||
let finalContrastiveLoss = 0;
|
||||
// Real per-epoch loss from the loop that actually updates encoder.w2 below —
|
||||
// NOT contrastiveResult.history, which comes from contrastiveTrainer.train()
|
||||
// above and never touches the encoder (see its own comment). Exporting that
|
||||
// instead of this produced a flat, meaningless lossHistory in the published
|
||||
// model's training-metrics.json.
|
||||
const realContrastiveLossHistory = [];
|
||||
|
||||
// Compute initial loss
|
||||
for (const triplet of triplets) {
|
||||
@@ -1230,7 +1240,9 @@ async function main() {
|
||||
if (CONFIG.verbose) console.log(` Epoch ${epoch + 1}/${contrastiveEpochs}: loss=${epochLoss.toFixed(6)}`);
|
||||
}
|
||||
finalContrastiveLoss = epochLoss;
|
||||
realContrastiveLossHistory.push({ epoch: epoch + 1, loss: epochLoss });
|
||||
}
|
||||
const realContrastiveDurationMs = Date.now() - contrastiveLoopStart;
|
||||
|
||||
// Re-encode all features with updated encoder
|
||||
console.log(' Re-encoding features with updated encoder...');
|
||||
@@ -1494,10 +1506,15 @@ async function main() {
|
||||
const exportModel = {
|
||||
metadata: {
|
||||
name: 'wifi-densepose-csi-embedding',
|
||||
version: '1.0.0',
|
||||
version: '2.0.1',
|
||||
architecture: 'csi-encoder-8-64-128',
|
||||
training: {
|
||||
steps: contrastiveResult.history.length * contrastiveTrainer.getTripletCount(),
|
||||
// Real epoch count and real per-epoch triplet-update count, reported
|
||||
// separately rather than pre-multiplied into one "steps" figure --
|
||||
// that reads as optimizer steps, but every triplet with loss>0 gets
|
||||
// an immediate direct weight update here, not a batched gradient step.
|
||||
epochs: realContrastiveLossHistory.length,
|
||||
tripletUpdatesPerEpoch: triplets.length,
|
||||
loss: contrastiveResult.finalLoss,
|
||||
learningRate: CONFIG.learningRate,
|
||||
},
|
||||
@@ -1609,8 +1626,14 @@ async function main() {
|
||||
initialLoss: contrastiveResult.initialLoss,
|
||||
finalLoss: contrastiveResult.finalLoss,
|
||||
improvement: contrastiveResult.improvement,
|
||||
durationMs: contrastiveResult.durationMs,
|
||||
lossHistory: contrastiveResult.history,
|
||||
// Real wall-clock of the actual gradient loop -- contrastiveResult.durationMs
|
||||
// timed the no-op baseline pass instead, same bug as lossHistory above.
|
||||
durationMs: realContrastiveDurationMs,
|
||||
// The real per-epoch loss from the loop that actually updates the
|
||||
// encoder (see realContrastiveLossHistory above) -- contrastiveResult.history
|
||||
// comes from a separate, non-weight-updating baseline pass and was flat/
|
||||
// meaningless here.
|
||||
lossHistory: realContrastiveLossHistory,
|
||||
},
|
||||
taskHeads: taskTrainingData.length > 0 ? {
|
||||
samples: labeledCount,
|
||||
|
||||
@@ -234,9 +234,30 @@ impl StreamingEngine {
|
||||
/// WiFi/ESP-NOW-synced multi-node deployments spuriously fail governed
|
||||
/// trust cycles even after widening the guard elsewhere.
|
||||
///
|
||||
/// Rebuilds the fuser, so call before any frames are processed.
|
||||
/// Rebuilds the fuser, so call before any frames are processed. Preserves
|
||||
/// any node positions configured via [`Self::set_node_positions`] across
|
||||
/// the rebuild (issue #1870) — a caller that configures positions once at
|
||||
/// startup and calls this later (e.g. after deriving a TDM schedule) must
|
||||
/// not have that configuration silently discarded.
|
||||
pub fn set_multistatic_config(&mut self, cfg: MultistaticConfig) {
|
||||
let positions = self.fuser.node_positions_by_id().clone();
|
||||
self.fuser = MultistaticFuser::with_config(cfg);
|
||||
self.fuser.set_node_positions_by_id(positions);
|
||||
}
|
||||
|
||||
/// Configure node positions for the governed multistatic fuser, keyed by
|
||||
/// node id (issue #1870). Without this, `StreamingEngine`'s fuser — the
|
||||
/// one behind `fuse_scored_calibrated`, which feeds provenance and the
|
||||
/// witness — always fuses with every node at the origin, regardless of
|
||||
/// deployment configuration, because nothing ever called this on it. This
|
||||
/// is a separate `MultistaticFuser` instance from the one
|
||||
/// `wifi-densepose-sensing-server`'s `AppState` owns (fixed for that
|
||||
/// instance in #1860); each fuser instance needs positions set on it
|
||||
/// independently. Safe to call before or after [`Self::set_multistatic_config`] —
|
||||
/// order does not matter, since that method now preserves whatever
|
||||
/// positions are already set across its rebuild.
|
||||
pub fn set_node_positions(&mut self, positions: std::collections::HashMap<u8, [f32; 3]>) {
|
||||
self.fuser.set_node_positions_by_id(positions);
|
||||
}
|
||||
|
||||
/// Activate a per-room calibration adapter (ADR-150 §3.4). From the next
|
||||
@@ -1359,4 +1380,64 @@ mod tests {
|
||||
"an extra evidence ref must not collide with a model_version that absorbs it"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Issue #1870: StreamingEngine's own fuser never received positions ----
|
||||
|
||||
/// The governed path (`fuse_scored_calibrated`, feeding provenance and the
|
||||
/// witness) must carry each node's configured position -- not the origin
|
||||
/// for every node, which is what happened before `set_node_positions`
|
||||
/// existed on `StreamingEngine` and nothing ever configured this fuser
|
||||
/// instance (distinct from the one `wifi-densepose-sensing-server`'s
|
||||
/// `AppState` owns, fixed separately in #1860).
|
||||
#[test]
|
||||
fn governed_fuse_carries_each_nodes_configured_position() {
|
||||
let (mut e, _room) = engine();
|
||||
let mut positions = std::collections::HashMap::new();
|
||||
positions.insert(0u8, [1.0, 2.0, 3.0]);
|
||||
positions.insert(1u8, [4.0, 5.0, 6.0]);
|
||||
e.set_node_positions(positions.clone());
|
||||
|
||||
let frames = [node_frame(0, 1000, 56), node_frame(1, 1001, 56)];
|
||||
let (fused, _score) = e
|
||||
.fuser
|
||||
.fuse_scored_calibrated(&frames, &[None, None], StreamingEngine::DEFAULT_COHERENCE_ACCEPT)
|
||||
.expect("cohort fuses");
|
||||
|
||||
assert_eq!(fused.node_positions.len(), frames.len());
|
||||
for (frame, position) in fused.node_frames.iter().zip(&fused.node_positions) {
|
||||
let expected = positions
|
||||
.get(&frame.node_id)
|
||||
.copied()
|
||||
.expect("every configured node has a position in this test");
|
||||
assert_eq!(
|
||||
*position, expected,
|
||||
"node {} received the wrong position on the governed path",
|
||||
frame.node_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `set_multistatic_config` used to rebuild the fuser from scratch,
|
||||
/// silently discarding any positions configured before it. A caller that
|
||||
/// sets positions once at startup and derives/applies a TDM-schedule
|
||||
/// config later (a real, documented ordering per #1049/#1057) must not
|
||||
/// lose them.
|
||||
#[test]
|
||||
fn positions_survive_a_later_set_multistatic_config() {
|
||||
let (mut e, _room) = engine();
|
||||
let mut positions = std::collections::HashMap::new();
|
||||
positions.insert(0u8, [7.0, 8.0, 9.0]);
|
||||
e.set_node_positions(positions.clone());
|
||||
|
||||
e.set_multistatic_config(MultistaticConfig {
|
||||
guard_interval_us: 30_000,
|
||||
..MultistaticConfig::default()
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
e.fuser.node_position(0),
|
||||
Some([7.0, 8.0, 9.0]),
|
||||
"set_multistatic_config must not discard previously-configured positions"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,7 @@ use rvf_pipeline::ProgressiveLoader;
|
||||
use vital_signs::{VitalSignDetector, VitalSigns};
|
||||
|
||||
// ADR-022 Phase 3: Multi-BSSID pipeline integration
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
use wifi_densepose_wifiscan::parse_netsh_output as parse_netsh_bssid_output;
|
||||
use wifi_densepose_wifiscan::{BssidRegistry, WindowsWifiPipeline};
|
||||
|
||||
@@ -819,6 +820,51 @@ mod debounce_room_classification_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Upper plausibility ceiling (dBm) for a real 2.4/5 GHz WiFi RSSI reading.
|
||||
///
|
||||
/// A received WiFi signal is always attenuated by free-space path loss and
|
||||
/// receiver noise floor; no deployed node has ever measured better than
|
||||
/// roughly -20 dBm. The ESP32 edge-vitals packet (magic 0xC511_0002,
|
||||
/// ADR-039) carries a raw `i8` RSSI byte with no "valid" flag, and has been
|
||||
/// observed sending near-zero sentinel values (e.g. -1, -2 dBm) when the
|
||||
/// edge pipeline hasn't sampled a real reading yet. Left unguarded, that
|
||||
/// sentinel overwrote the node's `rssi_history` and the room's fused
|
||||
/// `mean_rssi` for the tick it arrived on (found 2026-09-09: node 3
|
||||
/// reporting -2.0 dBm while simultaneously showing a real ~-50 dBm CSI
|
||||
/// reading on the same node).
|
||||
pub(crate) const MAX_PLAUSIBLE_RSSI_DBM: i8 = -10;
|
||||
|
||||
/// Returns `true` if `rssi_dbm` is a physically plausible WiFi RSSI
|
||||
/// reading (see [`MAX_PLAUSIBLE_RSSI_DBM`]).
|
||||
pub(crate) fn is_plausible_rssi(rssi_dbm: i8) -> bool {
|
||||
rssi_dbm <= MAX_PLAUSIBLE_RSSI_DBM
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod rssi_plausibility_tests {
|
||||
use super::{is_plausible_rssi, MAX_PLAUSIBLE_RSSI_DBM};
|
||||
|
||||
#[test]
|
||||
fn realistic_readings_are_plausible() {
|
||||
assert!(is_plausible_rssi(-42));
|
||||
assert!(is_plausible_rssi(-53));
|
||||
assert!(is_plausible_rssi(-90));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn near_zero_sentinel_values_are_rejected() {
|
||||
assert!(!is_plausible_rssi(-1));
|
||||
assert!(!is_plausible_rssi(-2));
|
||||
assert!(!is_plausible_rssi(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_is_inclusive() {
|
||||
assert!(is_plausible_rssi(MAX_PLAUSIBLE_RSSI_DBM));
|
||||
assert!(!is_plausible_rssi(MAX_PLAUSIBLE_RSSI_DBM + 1));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct SignalField {
|
||||
grid_size: [usize; 3],
|
||||
@@ -3797,6 +3843,7 @@ fn trimmed_mean(buf: &VecDeque<f64>) -> f64 {
|
||||
// ── Windows WiFi RSSI collector ──────────────────────────────────────────────
|
||||
|
||||
/// Parse `netsh wlan show interfaces` output for RSSI and signal quality
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn parse_netsh_interfaces_output(output: &str) -> Option<(f64, f64, String)> {
|
||||
let mut rssi = None;
|
||||
let mut signal = None;
|
||||
@@ -3829,7 +3876,7 @@ fn parse_netsh_interfaces_output(output: &str) -> Option<(f64, f64, String)> {
|
||||
}
|
||||
}
|
||||
|
||||
async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
|
||||
async fn wifi_task(state: SharedState, tick_ms: u64) {
|
||||
let mut interval = tokio::time::interval(Duration::from_millis(tick_ms));
|
||||
let mut seq: u32 = 0;
|
||||
|
||||
@@ -3838,7 +3885,8 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
|
||||
let mut pipeline = WindowsWifiPipeline::new();
|
||||
|
||||
info!(
|
||||
"Windows WiFi multi-BSSID pipeline active (tick={}ms, max_bssids=32)",
|
||||
"WiFi RSSI pipeline active (platform={}, tick={}ms, max_bssids=32)",
|
||||
std::env::consts::OS,
|
||||
tick_ms
|
||||
);
|
||||
|
||||
@@ -3847,8 +3895,16 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
|
||||
seq += 1;
|
||||
|
||||
// ── Step 1: Run multi-BSSID scan via spawn_blocking ──────────
|
||||
// NetshBssidScanner is not Send, so we run `netsh` and parse
|
||||
// the output inside a blocking closure.
|
||||
// Keep platform subprocess calls off the async runtime workers.
|
||||
#[cfg(target_os = "macos")]
|
||||
let bssid_scan_result = tokio::task::spawn_blocking(|| {
|
||||
wifi_densepose_wifiscan::adapter::MacosCoreWlanScanner::new()
|
||||
.scan_sync()
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await;
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let bssid_scan_result = tokio::task::spawn_blocking(|| {
|
||||
let output = std::process::Command::new("netsh")
|
||||
.args(["wlan", "show", "networks", "mode=bssid"])
|
||||
@@ -3873,12 +3929,14 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
|
||||
let observations = match bssid_scan_result {
|
||||
Ok(Ok(obs)) if !obs.is_empty() => obs,
|
||||
Ok(Ok(_empty)) => {
|
||||
debug!("Multi-BSSID scan returned 0 observations, falling back");
|
||||
debug!("WiFi scan returned 0 observations");
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
windows_wifi_fallback_tick(&state, seq).await;
|
||||
continue;
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
warn!("Multi-BSSID scan error: {e}, falling back");
|
||||
warn!("WiFi scan error: {e}");
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
windows_wifi_fallback_tick(&state, seq).await;
|
||||
continue;
|
||||
}
|
||||
@@ -4075,6 +4133,7 @@ async fn windows_wifi_task(state: SharedState, tick_ms: u64) {
|
||||
/// Fallback: single-RSSI collection via `netsh wlan show interfaces`.
|
||||
///
|
||||
/// Used when the multi-BSSID scan fails or returns 0 observations.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) {
|
||||
let output = match tokio::process::Command::new("netsh")
|
||||
.args(["wlan", "show", "interfaces"])
|
||||
@@ -4226,8 +4285,21 @@ async fn windows_wifi_fallback_tick(state: &SharedState, seq: u32) {
|
||||
s.latest_update = Some(update);
|
||||
}
|
||||
|
||||
/// Probe if Windows WiFi is connected
|
||||
async fn probe_windows_wifi() -> bool {
|
||||
/// Probe the platform WiFi source using the same helper as capture.
|
||||
#[cfg(target_os = "macos")]
|
||||
async fn probe_wifi() -> bool {
|
||||
matches!(
|
||||
tokio::task::spawn_blocking(|| {
|
||||
wifi_densepose_wifiscan::adapter::MacosCoreWlanScanner::new().scan_sync()
|
||||
})
|
||||
.await,
|
||||
Ok(Ok(observations)) if !observations.is_empty()
|
||||
)
|
||||
}
|
||||
|
||||
/// Probe if Windows WiFi is connected.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
async fn probe_wifi() -> bool {
|
||||
match tokio::process::Command::new("netsh")
|
||||
.args(["wlan", "show", "interfaces"])
|
||||
.output()
|
||||
@@ -4289,7 +4361,7 @@ struct SourcePlan {
|
||||
bind_udp: bool,
|
||||
/// Run the simulated-data generator (serves poses until a real frame arrives).
|
||||
run_simulator: bool,
|
||||
/// Run the Windows WiFi capture task.
|
||||
/// Run the platform WiFi capture task.
|
||||
run_wifi: bool,
|
||||
}
|
||||
|
||||
@@ -8889,10 +8961,18 @@ async fn udp_receiver_task(
|
||||
warn!(name: semconv::EVENT_RUVIEW_FALL_DETECTED, { "ruview.node.id" = node_id }, "fall detected by node {node_id}");
|
||||
}
|
||||
ns.edge_vitals = Some(vitals.clone());
|
||||
ns.rssi_history.push_back(vitals.rssi as f64);
|
||||
if ns.rssi_history.len() > 60 {
|
||||
ns.rssi_history.pop_front();
|
||||
}
|
||||
// An implausible RSSI (e.g. -1/-2 dBm, see
|
||||
// `is_plausible_rssi`) is a firmware sentinel, not a
|
||||
// measurement — don't let it clobber the node's history.
|
||||
let mean_rssi_dbm = if is_plausible_rssi(vitals.rssi) {
|
||||
ns.rssi_history.push_back(vitals.rssi as f64);
|
||||
if ns.rssi_history.len() > 60 {
|
||||
ns.rssi_history.pop_front();
|
||||
}
|
||||
vitals.rssi as f64
|
||||
} else {
|
||||
ns.rssi_history.back().copied().unwrap_or(0.0)
|
||||
};
|
||||
|
||||
// Store per-node person count from edge vitals.
|
||||
let node_est = if vitals.presence {
|
||||
@@ -9010,7 +9090,7 @@ async fn udp_receiver_task(
|
||||
);
|
||||
|
||||
let features = FeatureInfo {
|
||||
mean_rssi: vitals.rssi as f64,
|
||||
mean_rssi: mean_rssi_dbm,
|
||||
variance: vitals.motion_energy as f64,
|
||||
motion_band_power: vitals.motion_energy as f64,
|
||||
breathing_band_power: if vitals.presence { 0.5 } else { 0.0 },
|
||||
@@ -10914,11 +10994,11 @@ async fn main() {
|
||||
let plan = if normalized == "auto" {
|
||||
info!("Auto-detecting data source (UDP :{} bound either way)...", args.udp_port);
|
||||
let esp32 = probe_esp32(args.udp_port).await;
|
||||
let wifi = if esp32 { false } else { probe_windows_wifi().await };
|
||||
let wifi = if esp32 { false } else { probe_wifi().await };
|
||||
if esp32 {
|
||||
info!(" ESP32 CSI detected on UDP :{}", args.udp_port);
|
||||
} else if wifi {
|
||||
info!(" Windows WiFi detected");
|
||||
info!(" WiFi detected ({})", std::env::consts::OS);
|
||||
} else {
|
||||
warn!(
|
||||
"No real CSI source at boot — serving SIMULATED data (tagged as \
|
||||
@@ -11395,7 +11475,7 @@ async fn main() {
|
||||
tokio::spawn(broadcast_tick_task(state.clone(), args.tick_ms));
|
||||
}
|
||||
if plan.run_wifi {
|
||||
tokio::spawn(windows_wifi_task(state.clone(), args.tick_ms));
|
||||
tokio::spawn(wifi_task(state.clone(), args.tick_ms));
|
||||
}
|
||||
if plan.run_simulator {
|
||||
tokio::spawn(simulated_data_task(state.clone(), args.tick_ms));
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
//!
|
||||
//! macOS only. Gated behind `#[cfg(target_os = "macos")]` at the module level.
|
||||
|
||||
use std::process::Command;
|
||||
use std::time::Instant;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use crate::domain::bssid::{BandType, BssidId, BssidObservation, RadioType};
|
||||
use crate::error::WifiScanError;
|
||||
@@ -64,11 +64,14 @@ impl MacosCoreWlanScanner {
|
||||
|
||||
/// Run the Swift helper and parse the output synchronously.
|
||||
///
|
||||
/// Returns one [`BssidObservation`] per BSSID seen in the scan.
|
||||
/// Returns one [`BssidObservation`] for the connected link.
|
||||
/// Helpers that fail to exit within five seconds are killed and reaped.
|
||||
pub fn scan_sync(&self) -> Result<Vec<BssidObservation>, WifiScanError> {
|
||||
let output = Command::new(&self.helper_path)
|
||||
let mut child = Command::new(&self.helper_path)
|
||||
.arg("--scan-once")
|
||||
.output()
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| {
|
||||
WifiScanError::ProcessError(format!(
|
||||
"failed to run mac_wifi helper ({}): {e}",
|
||||
@@ -76,6 +79,29 @@ impl MacosCoreWlanScanner {
|
||||
))
|
||||
})?;
|
||||
|
||||
// Older helpers ignore --scan-once and stream forever. Bound the
|
||||
// wait so an outdated installation cannot hang capture or auto-detect.
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => break,
|
||||
Ok(None) if Instant::now() < deadline => {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
status => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(WifiScanError::ProcessError(match status {
|
||||
Err(e) => format!("failed to wait for mac_wifi: {e}"),
|
||||
_ => "mac_wifi --scan-once timed out; rebuild the Swift helper".into(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
let output = child.wait_with_output().map_err(|e| {
|
||||
WifiScanError::ProcessError(format!("failed to read mac_wifi output: {e}"))
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(WifiScanError::ScanFailed {
|
||||
@@ -160,7 +186,8 @@ fn parse_json_line(line: &str, timestamp: Instant) -> Option<BssidObservation> {
|
||||
/// Resolve a BSSID string to a [`BssidId`].
|
||||
///
|
||||
/// If the MAC is all-zeros (macOS redaction), generate a synthetic
|
||||
/// locally-administered MAC from `SHA-256(ssid:channel)`.
|
||||
/// locally-administered MAC from the SSID and channel. Abstain when the
|
||||
/// real BSSID is unavailable and the SSID is blank.
|
||||
fn resolve_bssid(bssid_str: &str, ssid: &str, channel: u8) -> Option<BssidId> {
|
||||
// Try parsing the real BSSID first.
|
||||
if let Ok(id) = BssidId::parse(bssid_str) {
|
||||
@@ -170,7 +197,13 @@ fn resolve_bssid(bssid_str: &str, ssid: &str, channel: u8) -> Option<BssidId> {
|
||||
}
|
||||
}
|
||||
|
||||
// Generate synthetic BSSID: SHA-256(ssid:channel), take first 6 bytes,
|
||||
// Without either identity, unrelated networks on the same channel would
|
||||
// collapse to one synthetic BSSID. Do not emit an observation in that case.
|
||||
if ssid.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Generate synthetic BSSID from SSID and channel, take first 6 bytes,
|
||||
// set locally-administered + unicast bits (byte 0: bit 1 set, bit 0 clear).
|
||||
Some(synthetic_bssid(ssid, channel))
|
||||
}
|
||||
@@ -276,6 +309,32 @@ mod tests {
|
||||
{"ssid":"Redacted","bssid":"00:00:00:00:00:00","rssi":-65,"noise":-88,"channel":149,"band":"5GHz"}
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn helper_process_errors_are_reported() {
|
||||
assert!(matches!(
|
||||
MacosCoreWlanScanner::with_path("/usr/bin/false").scan_sync(),
|
||||
Err(WifiScanError::ScanFailed { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
MacosCoreWlanScanner::with_path("/dev/null/mac_wifi").scan_sync(),
|
||||
Err(WifiScanError::ProcessError(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_helper_is_timed_out() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let path = std::env::temp_dir().join(format!("mac-wifi-timeout-{}", std::process::id()));
|
||||
// exec preserves the helper PID, so killing it cannot orphan sleep.
|
||||
std::fs::write(&path, "#!/bin/sh\nexec /bin/sleep 30\n").unwrap();
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
let start = Instant::now();
|
||||
let result = MacosCoreWlanScanner::with_path(path.to_string_lossy()).scan_sync();
|
||||
std::fs::remove_file(path).unwrap();
|
||||
assert!(matches!(result, Err(WifiScanError::ProcessError(ref e)) if e.contains("timed out")));
|
||||
assert!(start.elapsed() < Duration::from_secs(15));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_valid_output() {
|
||||
let obs = parse_macos_scan_output(SAMPLE_OUTPUT).unwrap();
|
||||
@@ -304,6 +363,29 @@ mod tests {
|
||||
assert_eq!(obs[2].bssid.0[0] & 0x01, 0x00);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_bssid_without_ssid_abstains() {
|
||||
for bssid in ["00:00:00:00:00:00", "", "invalid"] {
|
||||
for ssid in ["", " "] {
|
||||
let output = format!(
|
||||
r#"{{"ssid":"{ssid}","bssid":"{bssid}","rssi":-65,"noise":-88,"channel":36}}"#
|
||||
);
|
||||
assert!(parse_macos_scan_output(&output).unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_bssid_without_ssid_is_preserved() {
|
||||
let output = r#"{"ssid":"","bssid":"aa:bb:cc:dd:ee:ff","rssi":-65,"noise":-88,"channel":36}"#;
|
||||
let obs = parse_macos_scan_output(output).unwrap();
|
||||
assert_eq!(obs.len(), 1);
|
||||
assert_eq!(obs[0].bssid.to_string(), "aa:bb:cc:dd:ee:ff");
|
||||
assert!(obs[0].ssid.is_empty());
|
||||
assert_eq!(obs[0].rssi_dbm, -65.0);
|
||||
assert_eq!(obs[0].channel, 36);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_bssid_is_deterministic() {
|
||||
let a = synthetic_bssid("TestNet", 36);
|
||||
|
||||
Reference in new issue
Block a user