178 lines
6.0 KiB
Markdown
178 lines
6.0 KiB
Markdown
# ADR-108 — FW NVS Persistence of Gain-Lock Values
|
||
|
||
**Status**: Accepted
|
||
**Date**: 2026-05-17
|
||
**Scope**: `firmware/esp32-csi-node/main/csi_collector.c`
|
||
(`rv_gain_load_from_nvs`, `rv_gain_save_to_nvs`, NVS hook in
|
||
`rv_gain_lock_process`).
|
||
|
||
## Context
|
||
|
||
ADR-100 introduced the FW-side gain-lock (AGC + FFT scale) but the
|
||
calibration runs on *every* boot:
|
||
|
||
1. Collect 300 packets (~3 s at 100 pps, but realistically 6-12 s
|
||
in production where keepalive drives only 25 pps).
|
||
2. Take the median of AGC and FFT samples.
|
||
3. Call `phy_force_rx_gain` / `phy_fft_scale_force` to freeze.
|
||
|
||
This means after every reboot — OTA, power blip, watchdog — the chip
|
||
goes through 6-12 s where CSI is generated with **unlocked AGC** that
|
||
drifts ±20–30 % (the very artefact gain-lock was meant to suppress).
|
||
The operator's classifier, ADR-101's NBVI selector, and ADR-103's
|
||
baseline comparison all see noisy data during that warm-up.
|
||
|
||
Pace's ESPectre persists everything calibration-related to NVS so
|
||
post-reboot the sensor is back in detect mode in well under a
|
||
second. This ADR ports the gain-lock half of that policy
|
||
(NBVI lives server-side in RuView, doesn't apply).
|
||
|
||
## Decisions
|
||
|
||
### D1 — NVS namespace + keys
|
||
|
||
```c
|
||
#define RV_GAIN_NVS_NS "csi_cfg"
|
||
#define RV_GAIN_NVS_K_AGC "gl_agc" // u8
|
||
#define RV_GAIN_NVS_K_FFT "gl_fft" // i8
|
||
```
|
||
|
||
`csi_cfg` is the same namespace the WiFi creds / collector IP / node_id
|
||
live in (so it's already initialised + checked by `nvs_config_load`).
|
||
Two single-byte values — minimal NVS footprint.
|
||
|
||
### D2 — Two thin helpers
|
||
|
||
```c
|
||
static esp_err_t rv_gain_load_from_nvs(uint8_t *agc, int8_t *fft);
|
||
static void rv_gain_save_to_nvs(uint8_t agc, int8_t fft);
|
||
```
|
||
|
||
Both are local to `csi_collector.c`. Load returns `ESP_ERR_NVS_NOT_FOUND`
|
||
on a fresh chip; save logs a warning but never blocks the boot path
|
||
if NVS write fails.
|
||
|
||
### D3 — One-shot NVS load at top of `rv_gain_lock_process`
|
||
|
||
A static `s_nvs_checked` flag triggers exactly **one** load attempt
|
||
on the first packet after boot:
|
||
|
||
```c
|
||
if (!s_nvs_checked) {
|
||
s_nvs_checked = true;
|
||
uint8_t agc; int8_t fft;
|
||
if (rv_gain_load_from_nvs(&agc, &fft) == ESP_OK
|
||
&& agc >= RV_GAIN_MIN_SAFE_AGC)
|
||
{
|
||
phy_fft_scale_force(true, fft);
|
||
phy_force_rx_gain(1, (int)agc);
|
||
s_gain_locked = true;
|
||
ESP_LOGI(TAG, "gain-lock RESTORED from NVS: AGC=%u FFT=%d", agc, fft);
|
||
return;
|
||
}
|
||
}
|
||
```
|
||
|
||
The `agc >= RV_GAIN_MIN_SAFE_AGC` guard preserves ADR-100's "skip if
|
||
signal too strong" safety: a stale low-AGC value that would freeze
|
||
the RX path is rejected even if it's in NVS.
|
||
|
||
### D4 — Save after every successful lock
|
||
|
||
The existing `phy_*_force` branch in `rv_gain_lock_process` is wrapped
|
||
with a save call:
|
||
|
||
```c
|
||
phy_fft_scale_force(true, s_gain_fft_value);
|
||
phy_force_rx_gain(1, (int)s_gain_agc_value);
|
||
rv_gain_save_to_nvs(s_gain_agc_value, s_gain_fft_value);
|
||
ESP_LOGI(TAG, "gain-lock PERSISTED to NVS (%s/%s, %s)",
|
||
RV_GAIN_NVS_NS, RV_GAIN_NVS_K_AGC, RV_GAIN_NVS_K_FFT);
|
||
```
|
||
|
||
So the first boot ever does the full 300-packet calibration **and**
|
||
saves; every subsequent boot loads instantly from D3.
|
||
|
||
### D5 — Invalidation policy
|
||
|
||
Stored values are tied to: this sensor's physical location + this AP's
|
||
MAC + this channel + this antenna orientation. If any of those change,
|
||
the saved AGC/FFT may be slightly off-optimal — but **not dangerous**.
|
||
The WiFi PHY just receives slightly off-optimal CSI; the host will
|
||
see higher baseline noise until the operator triggers a re-calibration.
|
||
|
||
Today: erase via `idf.py erase-flash` over USB, or `nvs_flash_erase()`
|
||
called from a future REST endpoint. No automatic invalidation — the
|
||
operator decides when a deployment change is significant enough.
|
||
|
||
## Files Touched
|
||
|
||
```
|
||
firmware/esp32-csi-node/main/csi_collector.c
|
||
- #include "nvs.h" / "nvs_flash.h"
|
||
- rv_gain_load_from_nvs / rv_gain_save_to_nvs (D2)
|
||
- s_nvs_checked one-shot in rv_gain_lock_process (D3)
|
||
- save call after lock branch (D4)
|
||
docs/adr/ADR-108-fw-nvs-persist-gain-lock.md (this)
|
||
```
|
||
|
||
Implementation commit: `3779bb76`. Flashed to both sensors via OTA
|
||
(no USB) — `python3 scripts/ota-deploy.sh`.
|
||
|
||
## Verified Acceptance
|
||
|
||
Test sequence:
|
||
|
||
1. OTA flash new FW to both nodes (first boot, NVS empty).
|
||
2. Wait 15 s for FW to complete first calibration + write to NVS.
|
||
3. OTA flash the SAME binary again (forces a reboot; new FW has
|
||
values in NVS from step 2).
|
||
4. Sample WS amplitude rate in the first 3 s after the second boot.
|
||
|
||
Before this ADR: ~5-12 s gap between boot and first amp-bearing WS
|
||
frame (waiting for fresh calibration). After this ADR: WS shows
|
||
**44 Hz raw CSI in the first 3 s** — instant resume.
|
||
|
||
Logs from a chip that has values in NVS:
|
||
|
||
```
|
||
I (335) main: boot: reset_reason=SW running_partition=ota_1
|
||
I (520) csi_collector: gain-lock RESTORED from NVS: AGC=44 FFT=-33
|
||
(0-packet calibration; clear NVS to recalibrate)
|
||
```
|
||
|
||
vs first-boot ever:
|
||
|
||
```
|
||
I (335) main: boot: reset_reason=POWERON running_partition=ota_0
|
||
I (4980) csi_collector: gain-lock APPLIED: AGC=44 FFT=-33
|
||
(median of 300 packets)
|
||
I (4980) csi_collector: gain-lock PERSISTED to NVS (csi_cfg/gl_agc, gl_fft)
|
||
```
|
||
|
||
## Open Items
|
||
|
||
* **Per-channel cache** — `csi_cfg/gl_<chan>_agc`. If the channel hop
|
||
table (ADR-029) is reactivated, each channel needs its own values.
|
||
~1 h FW. Deferred — channel hopping is out of scope for the current
|
||
single-channel deployment.
|
||
|
||
## Closed
|
||
|
||
* **REST endpoint to clear gain-lock NVS** — shipped via
|
||
`POST /ota/recalibrate` in ADR-109.
|
||
* **Track AP MAC alongside AGC/FFT** — shipped via `gl_ap_mac` NVS key
|
||
+ boot-time comparison in ADR-109.
|
||
|
||
## References
|
||
|
||
* ADR-100 — gain-lock implementation that this ADR persists.
|
||
* ADR-101 — classifier that suffers during the 6-12 s warm-up gap
|
||
that this ADR closes.
|
||
* `docs/references/ota-pipeline.md` — the WiFi flash flow used to
|
||
deploy this FW change without USB.
|
||
* Francesco Pace, *How I Turned My Wi-Fi Into a Motion Sensor —
|
||
Part 2*, "Persisted calibration" — the upstream pattern this ADR
|
||
ports (their NVS payload also includes NBVI indices + baseline,
|
||
which RuView keeps server-side).
|