mirror of
https://github.com/ruvnet/RuView.git
synced 2026-10-18 21:00:22 -05:00
Merge PR #2100 (aepod) into release integration
This commit is contained in:
commit
de5f7edd16
17 files changed
+853
-29
No files matched your search
@@ -0,0 +1,195 @@
|
||||
# ADR-379: An OTA'd image confirms itself with a bounded health check, or rolls back
|
||||
|
||||
**Status:** Proposed. Hardware-verified on 2026-09-29 on an ESP32-S3 (node 4) and an ESP32-C6 (node 5).
|
||||
**Date:** 2026-09-29
|
||||
**Numbering:** On 2026-10-02 `main` had up to ADR-378 and open PRs claimed
|
||||
nothing higher. If another branch also takes 379, renumber whichever merges
|
||||
second.
|
||||
|
||||
## Context
|
||||
|
||||
Every 16 MB node was flashed with `CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y`
|
||||
(`sdkconfig.defaults.16mb`). With that option the bootloader starts a freshly
|
||||
OTA'd image in `ESP_OTA_IMG_PENDING_VERIFY`. The app must then call
|
||||
`esp_ota_mark_app_valid_cancel_rollback()`. If it doesn't, the bootloader marks
|
||||
the image `ABORTED` at the next reset and boots the previous slot.
|
||||
|
||||
Nothing in `firmware/esp32-csi-node/main` made that call, and `nm` on the ELF
|
||||
confirmed the symbol was not linked. RUNBOOK.md said an
|
||||
`ota_rollback_boot_check()` "sits in the app", but no such function existed
|
||||
anywhere in the tree. The consequences:
|
||||
|
||||
- An image delivered by `POST /ota` ran until the next reset, then reverted
|
||||
silently. OTA updates were not durable.
|
||||
- While the image was pending, `esp_ota_begin()` refused a second OTA with
|
||||
`ESP_ERR_OTA_ROLLBACK_INVALID_STATE`.
|
||||
- Nothing checked health, so a bad image was never rolled back on purpose. It
|
||||
only reverted if it happened to crash or be power-cycled.
|
||||
|
||||
A second defect meant none of this was ever reached: **OTA never succeeded
|
||||
on this firmware before this change.** MEASURED on 2026-09-29, on node 4 (an
|
||||
ESP32-S3 running the v0.8.12 16 MB build, with the OTA key provisioned):
|
||||
|
||||
- A `POST /ota` of a valid 1,228,672 B image logged `OTA update started`, then
|
||||
`stack overflow in task httpd`, then `rst:0xc RTC_SW_CPU_RST`. The node came
|
||||
back on its old slot. Both attempts failed the same way.
|
||||
- The cause: the server used `HTTPD_DEFAULT_CONFIG()`, which gives a 4096 B
|
||||
task stack. The handler held a 1 KB receive buffer on that stack, while
|
||||
calling `esp_ota_*` and formatting a float progress log.
|
||||
|
||||
## Decision
|
||||
|
||||
At boot, `ota_health_start()` (in `main/ota_health.c`, called early in
|
||||
`app_main`) reads the running partition's state:
|
||||
|
||||
| state | meaning here | action |
|
||||
|---|---|---|
|
||||
| `PENDING_VERIFY` | just OTA'd, rollback armed | run the health check |
|
||||
| `VALID` | USB-flashed with `ota_data_initial.bin` (the bootloader writes VALID), or already confirmed | nothing |
|
||||
| `UNDEFINED` | selected by a build without rollback; boots unconditionally | nothing |
|
||||
| `NEW` | bootloader lacks rollback, so it never promoted the image | nothing |
|
||||
| error (`NOT_FOUND`/`NOT_SUPPORTED`) | no otadata entry, or factory partition | nothing |
|
||||
|
||||
For this table I read the ESP-IDF v5.5 source: `bootloader_utility.c`
|
||||
(`set_actual_ota_seq`, the NEW to PENDING_VERIFY to ABORTED transitions) and
|
||||
`esp_ota_ops.c`.
|
||||
|
||||
**The health check.** A low-priority task polls once a second and feeds
|
||||
`ota_health_step()`, which is a pure function in `ota_health.h`:
|
||||
|
||||
- **Signals, both latched:** the STA got an IP (`IP_EVENT_STA_GOT_IP`, via
|
||||
`ota_health_note_got_ip()` in `main.c`'s handler), and the stream sender
|
||||
accepted at least one CSI frame (`csi_collector_get_send_ok_count() > 0`).
|
||||
The second signal covers driver callback, serialization and `sendto()`
|
||||
together.
|
||||
- **Minimum uptime** `CONFIG_OTA_HEALTH_MIN_UPTIME_S` (default 30 s): the image
|
||||
is not confirmed before this point even with both signals present. An image
|
||||
that crashes soon after starting its pipelines still reverts, because a crash
|
||||
while pending is a rollback.
|
||||
- **Deadline** `CONFIG_OTA_HEALTH_TIMEOUT_S` (default 120 s, measured from
|
||||
boot). Both signals are required by then. The minimum uptime is clamped to
|
||||
the deadline.
|
||||
- **Pass:** `esp_ota_mark_app_valid_cancel_rollback()`, which logs
|
||||
`marked valid after <reason> in <ms> ms`.
|
||||
- **Fail:** logs `health check failed: <reason>, rolling back`, then calls
|
||||
`esp_ota_mark_app_invalid_rollback_and_reboot()`. If no other bootable slot
|
||||
exists, that call returns an error. The node then logs and stays up rather
|
||||
than reboot-looping.
|
||||
- **Terminal verdict:** once the check has confirmed or rolled back, it never
|
||||
changes.
|
||||
|
||||
**Negative-test hook.** `CONFIG_OTA_HEALTH_FORCE_FAIL` (default n, test only)
|
||||
makes the check roll back at the point where it would have confirmed. Use it to
|
||||
prove rollback end to end.
|
||||
|
||||
**Remote visibility.** `GET /ota/status` now includes `ota_state` (`valid`,
|
||||
`pending_verify`, `new`, `undefined`, `other`, or `none`). An operator can
|
||||
confirm durability without a serial console.
|
||||
|
||||
**The httpd stack.** The OTA/WASM HTTP server now sets `config.stack_size =
|
||||
CONFIG_OTA_HTTPD_STACK_SIZE`, which defaults to 8192. Three smaller changes go
|
||||
with it:
|
||||
|
||||
- The OTA receive buffer is static rather than on the stack. Handlers run one
|
||||
at a time on the single httpd task, so one buffer is enough.
|
||||
- The progress log prints an integer percent instead of a float.
|
||||
- `GET /wasm/list` keeps its 2 KB JSON buffer on the heap, not the stack.
|
||||
|
||||
`recv_wait_timeout` stays at 30 s. Each `POST /ota` and `POST /wasm/upload` now
|
||||
logs `httpd stack after <handler>: N of M bytes never used`, so the size can
|
||||
be re-tuned from device measurements.
|
||||
|
||||
Sizing: this is an estimate from static data, not a device measurement. It
|
||||
comes from a `-fstack-usage` build of the S3 16 MB configuration with IDF v5.5.
|
||||
|
||||
- **Frame sizes along the OTA path** (bytes):
|
||||
|
||||
| function | bytes |
|
||||
|---|---|
|
||||
| `httpd_thread` | 144 |
|
||||
| `httpd_sess_process` | 32 |
|
||||
| `httpd_req_new` | 176 |
|
||||
| `httpd_uri` | 64 |
|
||||
| `ota_upload_handler` | 192 (1,216 with the old 1 KB buffer) |
|
||||
| `esp_ota_end` | 48 |
|
||||
| `image_validate` | 304 |
|
||||
| `esp_image_verify` | 32 |
|
||||
| `image_load` | 80 |
|
||||
| `process_segments` | 112 |
|
||||
| SHA-256 | about 110 |
|
||||
| mmap/flash read | about 190 |
|
||||
|
||||
- That chain comes to about 1.5 KB now, and about 2.5 KB with the old buffer.
|
||||
- Two things are not in the static data:
|
||||
- libc's `vfprintf` behind every `ESP_LOG`. This build uses full newlib,
|
||||
not nano, and the old progress line formatted a float.
|
||||
- The flash-chip driver, which is reached through function pointers.
|
||||
|
||||
Together they plausibly add 1 to 1.5 KB. That explains an overflow at 4096
|
||||
with the old handler.
|
||||
- The WASM upload path adds `wasm_upload_process` (336) and
|
||||
`rvf_verify_signature` (352), plus a wasm3 module load that wasn't measured.
|
||||
- 8192 leaves roughly 4 to 5 KB above the estimated worst case, and costs
|
||||
4 KB of internal RAM. The high-water-mark log is the check on that estimate.
|
||||
|
||||
When the build has no `CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE` (the 8 MB S3 and
|
||||
4 MB C6 CI lanes), both entry points compile to no-ops.
|
||||
|
||||
## Consequences
|
||||
|
||||
- OTA updates become durable on rollback-enabled nodes once the image passes
|
||||
the check. A second OTA is accepted after confirmation, which can come as
|
||||
early as `MIN_UPTIME_S`.
|
||||
- A bad image now reverts instead of limping, but "bad" only means it failed
|
||||
to reach the network or deliver CSI. An image that passes and then
|
||||
misbehaves later is not caught.
|
||||
- If the AP is down when a node boots an OTA'd image, a good image rolls back.
|
||||
That is the safe direction: push it again later.
|
||||
- Mock/QEMU builds that skip Wi-Fi can never pass. They must not enable
|
||||
bootloader rollback, and none do today.
|
||||
- Bootloader capability is still invisible remotely (RUNBOOK §2). A node
|
||||
whose bootloader lacks rollback reports `ota_state: "new"` after an OTA. That
|
||||
is the first remote hint of which bootloader a board has.
|
||||
|
||||
## Evidence
|
||||
|
||||
- Host test `firmware/esp32-csi-node/test/test_ota_health.c` runs in
|
||||
`make host_tests`, which CI runs. It covers pass, each timeout reason, the
|
||||
terminal verdict, force-fail, the clamp, and zero soak. SYNTHETIC: it
|
||||
exercises the decision function, not a device.
|
||||
- ESP-IDF v5.5 S3 and C6 builds with rollback enabled link
|
||||
`esp_ota_mark_app_valid_cancel_rollback` (verified with `nm`). A build is not
|
||||
hardware evidence.
|
||||
- The httpd overflow is MEASURED on hardware (node 4, above).
|
||||
- Hardware verification, MEASURED on 2026-09-29 on node 4 (ESP32-S3,
|
||||
16 MB, rollback bootloader, OTA key provisioned). The
|
||||
build from this branch was first written over USB. The pushes were made from
|
||||
a Pi 5 with the `sensor-ota-push` cog.
|
||||
1. Positive. An image built from this branch was pushed to `ota_1`: HTTP 200,
|
||||
1,230,848 B in 22 s.
|
||||
- The node logged "httpd stack after POST /ota: 4900 of 8192 bytes never
|
||||
used", so the upload's peak stack use is about 3.3 KB. With the old 1 KB
|
||||
on-stack buffer that is over 4 KB, which is consistent with the
|
||||
overflow.
|
||||
- It rebooted and logged "OTA image pending verify on ota_1".
|
||||
- `/ota/status` then read `ota_state: valid`.
|
||||
- After a hard reset it came back on the same build in `ota_1`, still
|
||||
`valid`, so the update is durable.
|
||||
2. Negative. A `CONFIG_OTA_HEALTH_FORCE_FAIL=y` build was pushed to `ota_0`:
|
||||
HTTP 200.
|
||||
- `/ota/status` showed it running as `pending_verify` for about 30 s.
|
||||
- The node then rebooted and came back on the previous build in `ota_1`,
|
||||
`valid`, so rollback works.
|
||||
3. One push attempt failed mid-upload when a serial capture was opened on the
|
||||
node's USB-Serial-JTAG port at the same moment. The node reset and was
|
||||
unharmed. Don't hold the USB console open while pushing OTA to a
|
||||
USB-attached node.
|
||||
- C6, MEASURED on 2026-09-29 on node 5 (ESP32-C6, 8 MB
|
||||
flash, 4 MB layout, rollback bootloader).
|
||||
- The branch build was first written over USB and the OTA key provisioned.
|
||||
- A second build was pushed from the Pi 5 cog to `ota_1`: HTTP 200 in 11 s.
|
||||
- `/ota/status` showed `pending_verify`, then `valid` about 28 s later.
|
||||
- After a hard reset it stayed on the new build, `valid`.
|
||||
- Not yet verified: the 120 s no-IP timeout path; a real power-cycle (a hard
|
||||
reset via RTS was used instead); the rollback test on the C6 (run on the S3
|
||||
only).
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
Latest proposed decisions:
|
||||
|
||||
- [ADR-379: An OTA'd image confirms itself with a bounded health check, or rolls back](ADR-379-ota-first-boot-health-check.md)
|
||||
- [ADR-378: `ruview-live` showcase views — CSI waterfall, radar fan, animated cells](ADR-378-ruview-live-showcase-views.md)
|
||||
- [ADR-377: `ruview-live` — a Claude Code mod shipped inside `@ruvnet/ruview`](ADR-377-ruview-live-claude-code-mod.md)
|
||||
- [ADR-376: `ruview` — one npm install for every RuView component](ADR-376-ruview-umbrella-npm-package.md)
|
||||
@@ -74,6 +75,7 @@ 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-379](ADR-379-ota-first-boot-health-check.md) | OTA'd image confirms itself with a bounded health check (IP + CSI frame) or rolls back; OTA httpd stack sized from device data | Proposed (host-tested; hardware-verified on one S3 and one C6) |
|
||||
| [ADR-110](ADR-110-esp32-c6-firmware-extension.md) | ESP32-C6 firmware extension — Wi-Fi 6 / 802.15.4 / TWT / LP-core | Accepted, P1-P10 complete, firmware-side substrate closed at **[v0.7.0-esp32](https://github.com/ruvnet/RuView/releases/tag/v0.7.0-esp32)**. Companion docs: [`WITNESS-LOG-110`](../WITNESS-LOG-110.md) (13 §A0.x entries · 99.56 % cross-board RX · **104.1 µs smoothed sync stdev** · ≤100 µs target met), [`ADR-110-REVIEW-GUIDE`](../ADR-110-REVIEW-GUIDE.md) (one-page reviewer tour), [`ADR-110-BRANCH-STATE`](../ADR-110-BRANCH-STATE.md) (coordination map vs `feat/adr-115-ha-mqtt-matter`). Host decoders + tests: Python `SyncPacketParser` (10) + Rust `wifi_densepose_hardware::SyncPacket` (15), cross-language hex pin gates drift. |
|
||||
|
||||
### Signal processing and sensing
|
||||
|
||||
@@ -98,6 +98,12 @@ For an existing provisioned node, back up its current application and inspect
|
||||
`http://DEVICE_IP:8032/ota/status` before choosing an application-only update.
|
||||
Writing only offset `0x20000` is safe only when the status endpoint reports
|
||||
`running_partition` as `ota_0` and the downloaded image matches the board.
|
||||
|
||||
After an OTA, the same endpoint's `ota_state` must read `valid` before you
|
||||
power-cycle the node or push again. `pending_verify` means the image has not
|
||||
passed its first-boot health check yet and would revert on reset. See
|
||||
[RUNBOOK §2.1](RUNBOOK.md) and ADR-379.
|
||||
|
||||
The full bundles do not include NVS, so the documented four-offset install
|
||||
preserves WiFi and node configuration while replacing the boot and application
|
||||
images.
|
||||
@@ -643,6 +649,7 @@ cargo build -p wifi-densepose-wasm-edge --target wasm32-unknown-unknown --releas
|
||||
| `main/nvs_config.c` / `.h` | Runtime configuration: loads Kconfig defaults, overrides from NVS |
|
||||
| `main/edge_processing.c` / `.h` | Tier 0-2 DSP pipeline: SPSC ring buffer, biquad IIR filters, Welford stats, BPM extraction, presence, fall detection |
|
||||
| `main/ota_update.c` / `.h` | HTTP OTA firmware update server on port 8032 |
|
||||
| `main/ota_health.c` / `.h` | First-boot health check that confirms an OTA'd image or rolls it back (ADR-379) |
|
||||
| `main/power_mgmt.c` / `.h` | Battery-aware light sleep duty cycling |
|
||||
| `main/wasm_runtime.c` / `.h` | WASM3 interpreter: module slots, host API bindings, budget guard, per-frame dispatch |
|
||||
| `main/wasm_upload.c` / `.h` | HTTP endpoints for WASM module upload, list, start, stop, delete |
|
||||
|
||||
@@ -31,9 +31,9 @@ It does **not** pick up `sdkconfig.defaults.16mb`, and that file is where
|
||||
`CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y` lives.
|
||||
|
||||
Build without it and you get an image that is correct in every visible respect
|
||||
and **cannot roll back a bad OTA** — while `ota_rollback_boot_check()` sits in
|
||||
the app expecting the capability. Silent, and only discoverable by pushing a
|
||||
deliberately bad image.
|
||||
and **cannot roll back a bad OTA**, while `main/ota_health.c` sits in the app
|
||||
expecting the capability (it compiles to a no-op without it). Silent, and only
|
||||
discoverable by pushing a deliberately bad image. See §2.1.
|
||||
|
||||
That is why the command above concatenates all three explicitly rather than
|
||||
relying on discovery.
|
||||
@@ -104,6 +104,46 @@ Corollary: "every node reports health" proves the **app** is current and proves
|
||||
nothing about the bootloader. Do not answer a bootloader question with app
|
||||
evidence.
|
||||
|
||||
### 2.1 An OTA'd image must confirm itself (ADR-379)
|
||||
|
||||
On a rollback-capable bootloader an OTA'd image boots **`PENDING_VERIFY`**.
|
||||
Until it is confirmed, the **next reset reverts to the previous slot** and
|
||||
`POST /ota` refuses a second update. `main/ota_health.c` does the confirming:
|
||||
|
||||
- It runs only when the running image is `PENDING_VERIFY`. USB-flashed images
|
||||
(the bootloader writes `VALID`), already-confirmed images, `UNDEFINED` and
|
||||
`NEW` are left alone.
|
||||
- It confirms once the STA has an IP **and** the stream sender has accepted at
|
||||
least one CSI frame, but not before `CONFIG_OTA_HEALTH_MIN_UPTIME_S`
|
||||
(default 30 s). Log: `ota_health: marked valid after ... in <ms> ms`.
|
||||
- If both signals have not arrived by `CONFIG_OTA_HEALTH_TIMEOUT_S` (default
|
||||
120 s from boot), it logs `health check failed: <reason>, rolling back` and
|
||||
reboots into the previous slot. A crash or reset before confirmation is also
|
||||
a rollback.
|
||||
- `GET /ota/status` reports `ota_state`. `pending_verify` means the image is
|
||||
not yet durable. `valid` means it survives a power cycle. `new` after an OTA
|
||||
means this board's bootloader has **no** rollback support.
|
||||
|
||||
Consequence for rollouts: wait for `ota_state: "valid"` before pushing again,
|
||||
and before power-cycling. If the AP is down when the node boots, a good image
|
||||
rolls back. That is expected; re-push it.
|
||||
|
||||
**Hardware check, one node.** It needs a node with the OTA PSK provisioned and
|
||||
a rollback bootloader.
|
||||
|
||||
1. Note `version` and `running_partition` from `/ota/status`.
|
||||
2. OTA-push the new image. Watch the log for `OTA image pending verify`, then
|
||||
`marked valid after ... in <ms> ms`. `/ota/status` must show the new
|
||||
version, the other partition, and `ota_state: "valid"`.
|
||||
3. Power-cycle it. It must come back on the same version and partition with
|
||||
`ota_state: "valid"`. Before ADR-379 it came back on the old one.
|
||||
|
||||
**Negative check.** Build with `CONFIG_OTA_HEALTH_FORCE_FAIL=y` and push it.
|
||||
The log must show `health check failed: forced failure ..., rolling back` at
|
||||
about `MIN_UPTIME_S`, and the node must come back on the step-2 version and
|
||||
partition. Then push a normal image to restore it. Never leave a FORCE_FAIL
|
||||
image in `release_bins`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Deploying
|
||||
@@ -113,6 +153,16 @@ evidence.
|
||||
Works end-to-end and is the default for anything that is not a bootloader or
|
||||
partition change. Roll to **one node**, soak it, then the rest.
|
||||
|
||||
**Before ADR-379, `POST /ota` never completed on this firmware.** MEASURED
|
||||
2026-09-29 on node 4 (S3, v0.8.12): it overflowed the 4 KB httpd stack
|
||||
(`stack overflow in task httpd`, `RTC_SW_CPU_RST`), and the node came back on
|
||||
its old slot. The client sees `connection reset`. Any board still on an older
|
||||
build has to be moved to a fixed one **over USB**.
|
||||
|
||||
The server now runs with `CONFIG_OTA_HTTPD_STACK_SIZE` (8192). Each upload
|
||||
logs `httpd stack after POST /ota: N of 8192 bytes never used`. Record that
|
||||
number when you verify a node, because it is the evidence the size rests on.
|
||||
|
||||
### Over USB — bootloader, partition table, or recovery
|
||||
|
||||
```bash
|
||||
|
||||
@@ -2,7 +2,7 @@ set(SRCS
|
||||
"main.c" "csi_collector.c" "node_log.c"
|
||||
"thermal.c" "stream_sender.c" "nvs_config.c"
|
||||
"serial_onboarding.c" "serial_onboarding_protocol.c"
|
||||
"edge_processing.c" "ota_update.c" "power_mgmt.c"
|
||||
"edge_processing.c" "ota_update.c" "ota_health.c" "power_mgmt.c"
|
||||
"wasm_runtime.c" "wasm_upload.c" "rvf_parser.c"
|
||||
"mmwave_sensor.c"
|
||||
"swarm_bridge.c"
|
||||
|
||||
@@ -332,6 +332,25 @@ menu "WASM Programmable Sensing (ADR-040)"
|
||||
|
||||
endmenu
|
||||
|
||||
menu "OTA / WASM HTTP server (ADR-379)"
|
||||
|
||||
config OTA_HTTPD_STACK_SIZE
|
||||
int "Stack size of the OTA / WASM HTTP server task (bytes)"
|
||||
range 4096 16384
|
||||
default 8192
|
||||
help
|
||||
HTTPD_DEFAULT_CONFIG() gives 4096. MEASURED 2026-09-29 on an ESP32-S3
|
||||
(node 4, v0.8.12): every POST /ota overflowed it ("stack overflow in
|
||||
task httpd", RTC_SW_CPU_RST), so no OTA had ever completed on this
|
||||
firmware. The same task runs POST /wasm/upload (signature check plus
|
||||
wasm3 module load) and GET /wasm/list.
|
||||
|
||||
Every OTA and WASM upload logs "httpd stack after <handler>: N of M
|
||||
bytes never used". Lower this only with those numbers in hand, and keep
|
||||
at least 1 KB unused on the worst handler.
|
||||
|
||||
endmenu
|
||||
|
||||
menu "ESP32-C6 capabilities (ADR-110)"
|
||||
depends on IDF_TARGET_ESP32C6
|
||||
|
||||
@@ -674,6 +693,50 @@ config UPLINK_WATCHDOG_TIMEOUT_S
|
||||
|
||||
endmenu
|
||||
|
||||
menu "OTA health check (ADR-379)"
|
||||
|
||||
config OTA_HEALTH_TIMEOUT_S
|
||||
int "Seconds an OTA'd image has to prove itself before rolling back"
|
||||
depends on BOOTLOADER_APP_ROLLBACK_ENABLE
|
||||
range 30 900
|
||||
default 120
|
||||
help
|
||||
Only applies when the running image is ESP_OTA_IMG_PENDING_VERIFY, i.e.
|
||||
it was just delivered by OTA to a node whose bootloader has
|
||||
CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE. USB-flashed and already-confirmed
|
||||
images are never checked.
|
||||
|
||||
Healthy means: the STA got an IP, and the stream sender accepted at least
|
||||
one CSI frame. Both must happen within this many seconds of boot, or the
|
||||
image is marked invalid and the node reboots into the previous slot.
|
||||
|
||||
Measured from boot, and the WiFi boot wait alone can take about two
|
||||
minutes against an absent AP, so an AP outage during a rollout rolls a
|
||||
good image back. That is the safe direction: re-push it later.
|
||||
|
||||
config OTA_HEALTH_MIN_UPTIME_S
|
||||
int "Minimum uptime before an OTA'd image may be confirmed"
|
||||
depends on BOOTLOADER_APP_ROLLBACK_ENABLE
|
||||
range 0 600
|
||||
default 30
|
||||
help
|
||||
Holds confirmation back for this long after boot even once both signals
|
||||
are in, so an image that crashes shortly after starting its pipelines
|
||||
still reverts (a crash while PENDING_VERIFY is a rollback). Clamped to
|
||||
OTA_HEALTH_TIMEOUT_S.
|
||||
|
||||
config OTA_HEALTH_FORCE_FAIL
|
||||
bool "TEST ONLY: fail the OTA health check to exercise rollback"
|
||||
depends on BOOTLOADER_APP_ROLLBACK_ENABLE
|
||||
default n
|
||||
help
|
||||
Builds an image that rolls itself back at the point it would otherwise
|
||||
confirm. OTA-push it to a node to prove rollback works end to end: the
|
||||
node should reboot into the previous image. Has no effect when USB
|
||||
flashed (such an image is not PENDING_VERIFY). Never ship this.
|
||||
|
||||
endmenu
|
||||
|
||||
config CSI_GATE_MESH_ALIGNED
|
||||
bool "Align the CSI rate gate to mesh time (enables cross-node pairing)"
|
||||
default y
|
||||
|
||||
@@ -750,6 +750,11 @@ uint16_t csi_collector_get_send_fail_count(void)
|
||||
return (f > 0xFFFFu) ? 0xFFFFu : (uint16_t)f;
|
||||
}
|
||||
|
||||
uint32_t csi_collector_get_send_ok_count(void)
|
||||
{
|
||||
return s_send_ok;
|
||||
}
|
||||
|
||||
/* 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
|
||||
|
||||
@@ -139,6 +139,18 @@ uint16_t csi_collector_get_pkt_yield_per_sec(void);
|
||||
*/
|
||||
uint16_t csi_collector_get_send_fail_count(void);
|
||||
|
||||
/**
|
||||
* Get the cumulative count of CSI frames the stream sender accepted since boot.
|
||||
*
|
||||
* Nonzero means the whole capture path worked at least once: the driver
|
||||
* delivered a CSI callback, the frame serialized, and sendto() accepted it.
|
||||
* Used by the OTA health check (ota_health.c) as its "operational" signal.
|
||||
*
|
||||
* @return Number of successful stream_sender_send() calls from the CSI
|
||||
* callback path.
|
||||
*/
|
||||
uint32_t csi_collector_get_send_ok_count(void);
|
||||
|
||||
/** Reported by the gate accessors when this build has no selectable gate. */
|
||||
#define CSI_GATE_NOT_CONFIGURABLE 0xFF
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
#include "serial_onboarding.h"
|
||||
#include "edge_processing.h"
|
||||
#include "ota_update.h"
|
||||
#include "ota_health.h"
|
||||
#include "power_mgmt.h"
|
||||
#include "wasm_runtime.h"
|
||||
#include "wasm_upload.h"
|
||||
@@ -142,6 +143,7 @@ static void event_handler(void *arg, esp_event_base_t event_base,
|
||||
ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data;
|
||||
ESP_LOGI(TAG, "Got IP: " IPSTR, IP2STR(&event->ip_info.ip));
|
||||
s_retry_num = 0;
|
||||
ota_health_note_got_ip();
|
||||
xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT);
|
||||
}
|
||||
}
|
||||
@@ -338,6 +340,11 @@ void app_main(void)
|
||||
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);
|
||||
|
||||
/* ADR-379: an OTA'd image boots PENDING_VERIFY and reverts at the next
|
||||
* reset unless it confirms itself. Start the clock before anything that
|
||||
* can block (WiFi waits up to ~2 min for an AP), so the deadline is real. */
|
||||
ota_health_start();
|
||||
|
||||
/* Apply the opt-in XIAO RF path before WiFi starts. Generic C6 boards keep
|
||||
* ownership of GPIO3 and GPIO14 because the default implementation is a
|
||||
* no-op unless CONFIG_C6_XIAO_ANTENNA_SELECT is enabled. */
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/* First-boot health check for an OTA-delivered image. See ota_health.h and
|
||||
* ADR-379 for why it exists; the decision itself is ota_health_step(). */
|
||||
#include "ota_health.h"
|
||||
|
||||
#include "sdkconfig.h"
|
||||
|
||||
#ifdef CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE
|
||||
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "esp_log.h"
|
||||
#include "esp_ota_ops.h"
|
||||
#include "esp_timer.h"
|
||||
|
||||
#include "csi_collector.h"
|
||||
|
||||
static const char *TAG = "ota_health";
|
||||
|
||||
#define OTA_HEALTH_POLL_MS 1000
|
||||
|
||||
/* Written from the event loop task, read from the health task. A single
|
||||
* aligned bool is enough; it only ever goes false -> true. */
|
||||
static volatile bool s_got_ip;
|
||||
|
||||
static const char *state_name(esp_ota_img_states_t st)
|
||||
{
|
||||
switch (st) {
|
||||
case ESP_OTA_IMG_NEW: return "NEW";
|
||||
case ESP_OTA_IMG_PENDING_VERIFY: return "PENDING_VERIFY";
|
||||
case ESP_OTA_IMG_VALID: return "VALID";
|
||||
case ESP_OTA_IMG_INVALID: return "INVALID";
|
||||
case ESP_OTA_IMG_ABORTED: return "ABORTED";
|
||||
case ESP_OTA_IMG_UNDEFINED: return "UNDEFINED";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
static void ota_health_task(void *arg)
|
||||
{
|
||||
(void)arg;
|
||||
const ota_health_cfg_t cfg = {
|
||||
.timeout_ms = (uint32_t)CONFIG_OTA_HEALTH_TIMEOUT_S * 1000u,
|
||||
.min_uptime_ms = (uint32_t)CONFIG_OTA_HEALTH_MIN_UPTIME_S * 1000u,
|
||||
#ifdef CONFIG_OTA_HEALTH_FORCE_FAIL
|
||||
.force_fail = true,
|
||||
#else
|
||||
.force_fail = false,
|
||||
#endif
|
||||
};
|
||||
ota_health_sm_t sm;
|
||||
ota_health_sm_init(&sm);
|
||||
|
||||
for (;;) {
|
||||
uint32_t now_ms = (uint32_t)(esp_timer_get_time() / 1000);
|
||||
ota_health_verdict_t v = ota_health_step(&sm, &cfg, s_got_ip,
|
||||
csi_collector_get_send_ok_count() > 0,
|
||||
now_ms);
|
||||
if (v == OTA_HEALTH_MARK_VALID) {
|
||||
esp_err_t err = esp_ota_mark_app_valid_cancel_rollback();
|
||||
if (err == ESP_OK) {
|
||||
ESP_LOGI(TAG, "marked valid after %s in %lu ms",
|
||||
sm.reason, (unsigned long)now_ms);
|
||||
} else {
|
||||
ESP_LOGE(TAG, "mark valid failed: %s (image stays PENDING_VERIFY "
|
||||
"and reverts on the next reset)", esp_err_to_name(err));
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (v == OTA_HEALTH_ROLLBACK) {
|
||||
ESP_LOGE(TAG, "health check failed: %s, rolling back (%lu ms)",
|
||||
sm.reason, (unsigned long)now_ms);
|
||||
/* Returns only on failure: no other bootable slot. Rebooting then
|
||||
* would just loop, so stay up; the next reset aborts this image. */
|
||||
esp_err_t err = esp_ota_mark_app_invalid_rollback_and_reboot();
|
||||
ESP_LOGE(TAG, "rollback not possible: %s -- staying on this image",
|
||||
esp_err_to_name(err));
|
||||
break;
|
||||
}
|
||||
vTaskDelay(pdMS_TO_TICKS(OTA_HEALTH_POLL_MS));
|
||||
}
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
void ota_health_start(void)
|
||||
{
|
||||
const esp_partition_t *running = esp_ota_get_running_partition();
|
||||
esp_ota_img_states_t st;
|
||||
esp_err_t err = esp_ota_get_state_partition(running, &st);
|
||||
|
||||
/* ESP_ERR_NOT_SUPPORTED: running from factory. ESP_ERR_NOT_FOUND: no
|
||||
* otadata entry for this slot (erased otadata). Neither can be rolled
|
||||
* back, so there is nothing to confirm. */
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGI(TAG, "running %s has no OTA state (%s): no health check",
|
||||
running ? running->label : "?", esp_err_to_name(err));
|
||||
return;
|
||||
}
|
||||
|
||||
/* VALID: USB-flashed with ota_data_initial (the bootloader writes VALID)
|
||||
* or already confirmed. UNDEFINED: selected by a build without rollback;
|
||||
* the bootloader boots it unconditionally. NEW: the bootloader has no
|
||||
* rollback support, so it never promoted the image to PENDING_VERIFY.
|
||||
* None of these revert on reset. */
|
||||
if (st != ESP_OTA_IMG_PENDING_VERIFY) {
|
||||
ESP_LOGI(TAG, "running %s state %s: no health check",
|
||||
running->label, state_name(st));
|
||||
return;
|
||||
}
|
||||
|
||||
ESP_LOGW(TAG, "OTA image pending verify on %s: need IP + CSI frame within %d s "
|
||||
"(min uptime %d s)%s",
|
||||
running->label, CONFIG_OTA_HEALTH_TIMEOUT_S,
|
||||
CONFIG_OTA_HEALTH_MIN_UPTIME_S,
|
||||
#ifdef CONFIG_OTA_HEALTH_FORCE_FAIL
|
||||
" -- FORCE_FAIL test build, will roll back"
|
||||
#else
|
||||
""
|
||||
#endif
|
||||
);
|
||||
|
||||
if (xTaskCreate(ota_health_task, "ota_health", 4096, NULL, 2, NULL) != pdPASS) {
|
||||
/* Without the task nothing confirms the image, which is the same as
|
||||
* failing the check: the next reset reverts it. Say so. */
|
||||
ESP_LOGE(TAG, "health task create failed; image will revert on next reset");
|
||||
}
|
||||
}
|
||||
|
||||
void ota_health_note_got_ip(void)
|
||||
{
|
||||
s_got_ip = true;
|
||||
}
|
||||
|
||||
#else /* !CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE */
|
||||
|
||||
/* Without bootloader rollback no image is ever PENDING_VERIFY. */
|
||||
void ota_health_start(void) {}
|
||||
void ota_health_note_got_ip(void) {}
|
||||
|
||||
#endif /* CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE */
|
||||
@@ -0,0 +1,110 @@
|
||||
/* First-boot health check for an OTA-delivered image (ADR-379).
|
||||
*
|
||||
* With CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE the bootloader starts a freshly
|
||||
* OTA'd image in ESP_OTA_IMG_PENDING_VERIFY. Until the app confirms itself,
|
||||
* the next reset reverts to the previous slot and esp_ota_begin() refuses a
|
||||
* second OTA. Nothing confirmed it before this module existed, so every OTA
|
||||
* on a rollback-enabled node was undone by the next power cycle.
|
||||
*
|
||||
* The decision logic is a pure function kept in this header, like
|
||||
* thermal_next_state(), so the host test exercises the exact code the device
|
||||
* runs. ota_health.c only feeds it events and a clock and acts on the verdict. */
|
||||
#pragma once
|
||||
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
typedef enum {
|
||||
OTA_HEALTH_WAIT = 0, /* keep checking */
|
||||
OTA_HEALTH_MARK_VALID, /* confirm the image: esp_ota_mark_app_valid_cancel_rollback() */
|
||||
OTA_HEALTH_ROLLBACK, /* esp_ota_mark_app_invalid_rollback_and_reboot() */
|
||||
} ota_health_verdict_t;
|
||||
|
||||
typedef struct {
|
||||
uint32_t timeout_ms; /* deadline, measured from boot */
|
||||
uint32_t min_uptime_ms; /* earliest the image may be confirmed; clamped to timeout_ms */
|
||||
bool force_fail; /* test hook: roll back where it would have confirmed */
|
||||
} ota_health_cfg_t;
|
||||
|
||||
typedef struct {
|
||||
bool got_ip; /* STA associated and holds an IPv4 address (latched) */
|
||||
bool csi_delivered; /* stream sender accepted >= 1 CSI frame (latched) */
|
||||
ota_health_verdict_t verdict;
|
||||
const char *reason; /* set once verdict leaves WAIT; static string */
|
||||
} ota_health_sm_t;
|
||||
|
||||
static inline void ota_health_sm_init(ota_health_sm_t *sm)
|
||||
{
|
||||
sm->got_ip = false;
|
||||
sm->csi_delivered = false;
|
||||
sm->verdict = OTA_HEALTH_WAIT;
|
||||
sm->reason = "waiting";
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the health check.
|
||||
*
|
||||
* Pure: no globals, no hardware. Both signals latch, because each proves a
|
||||
* capability the image has, and a later AP outage is not evidence against the
|
||||
* image. Once the verdict leaves WAIT it is terminal: later calls return it
|
||||
* unchanged, so a caller that polls after acting cannot act twice.
|
||||
*
|
||||
* @param sm State, initialised with ota_health_sm_init().
|
||||
* @param cfg Timeout, minimum uptime, force-fail hook.
|
||||
* @param got_ip Event seen since the last step: STA got an IP.
|
||||
* @param csi_delivered Event seen since the last step: a CSI frame was sent.
|
||||
* @param now_ms Milliseconds since boot.
|
||||
* @return The verdict. sm->reason names why, for the log.
|
||||
*/
|
||||
static inline ota_health_verdict_t ota_health_step(ota_health_sm_t *sm,
|
||||
const ota_health_cfg_t *cfg,
|
||||
bool got_ip,
|
||||
bool csi_delivered,
|
||||
uint32_t now_ms)
|
||||
{
|
||||
if (sm->verdict != OTA_HEALTH_WAIT) {
|
||||
return sm->verdict;
|
||||
}
|
||||
|
||||
sm->got_ip = sm->got_ip || got_ip;
|
||||
sm->csi_delivered = sm->csi_delivered || csi_delivered;
|
||||
|
||||
/* A minimum uptime above the timeout could never be satisfied; that would
|
||||
* turn every OTA into a rollback. Clamp rather than trust the config. */
|
||||
uint32_t min_up = cfg->min_uptime_ms < cfg->timeout_ms
|
||||
? cfg->min_uptime_ms : cfg->timeout_ms;
|
||||
|
||||
if (sm->got_ip && sm->csi_delivered && now_ms >= min_up) {
|
||||
if (cfg->force_fail) {
|
||||
sm->verdict = OTA_HEALTH_ROLLBACK;
|
||||
sm->reason = "forced failure (CONFIG_OTA_HEALTH_FORCE_FAIL)";
|
||||
} else {
|
||||
sm->verdict = OTA_HEALTH_MARK_VALID;
|
||||
sm->reason = "Wi-Fi got IP and CSI frame delivered";
|
||||
}
|
||||
return sm->verdict;
|
||||
}
|
||||
|
||||
if (now_ms >= cfg->timeout_ms) {
|
||||
sm->verdict = OTA_HEALTH_ROLLBACK;
|
||||
if (!sm->got_ip && !sm->csi_delivered) {
|
||||
sm->reason = "timeout: no IP and no CSI frame delivered";
|
||||
} else if (!sm->got_ip) {
|
||||
sm->reason = "timeout: no IP";
|
||||
} else {
|
||||
sm->reason = "timeout: no CSI frame delivered";
|
||||
}
|
||||
return sm->verdict;
|
||||
}
|
||||
|
||||
return OTA_HEALTH_WAIT;
|
||||
}
|
||||
|
||||
/* Device side (ota_health.c). No-ops when the bootloader has no rollback. */
|
||||
|
||||
/** Call once, early in app_main. Starts the check only if the running image
|
||||
* is ESP_OTA_IMG_PENDING_VERIFY; otherwise logs the state and returns. */
|
||||
void ota_health_start(void);
|
||||
|
||||
/** Call from the IP_EVENT_STA_GOT_IP handler. Cheap; safe from any task. */
|
||||
void ota_health_note_got_ip(void);
|
||||
@@ -17,6 +17,8 @@
|
||||
#include "esp_app_desc.h"
|
||||
#include "nvs_flash.h"
|
||||
#include "nvs.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
static const char *TAG = "ota_update";
|
||||
|
||||
@@ -88,14 +90,26 @@ static esp_err_t ota_status_handler(httpd_req_t *req)
|
||||
const esp_partition_t *running = esp_ota_get_running_partition();
|
||||
const esp_partition_t *update = esp_ota_get_next_update_partition(NULL);
|
||||
|
||||
/* ADR-379: lets an operator confirm remotely that an OTA'd image left
|
||||
* pending_verify (and so survives a power cycle) without a serial log. */
|
||||
esp_ota_img_states_t st;
|
||||
const char *ota_state = "none";
|
||||
if (running && esp_ota_get_state_partition(running, &st) == ESP_OK) {
|
||||
ota_state = st == ESP_OTA_IMG_VALID ? "valid" :
|
||||
st == ESP_OTA_IMG_PENDING_VERIFY ? "pending_verify" :
|
||||
st == ESP_OTA_IMG_NEW ? "new" :
|
||||
st == ESP_OTA_IMG_UNDEFINED ? "undefined" : "other";
|
||||
}
|
||||
|
||||
char response[512];
|
||||
int len = snprintf(response, sizeof(response),
|
||||
"{\"version\":\"%s\",\"date\":\"%s\",\"time\":\"%s\","
|
||||
"\"running_partition\":\"%s\",\"next_partition\":\"%s\","
|
||||
"\"max_size\":%lu}",
|
||||
"\"ota_state\":\"%s\",\"max_size\":%lu}",
|
||||
app->version, app->date, app->time,
|
||||
running ? running->label : "unknown",
|
||||
update ? update->label : "none",
|
||||
ota_state,
|
||||
(unsigned long)(update ? update->size : 0));
|
||||
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
@@ -103,10 +117,25 @@ static esp_err_t ota_status_handler(httpd_req_t *req)
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* Receive chunk for POST /ota. Static, not on the httpd task stack: handlers
|
||||
* run one at a time on the single httpd task, so one buffer is enough, and
|
||||
* 1 KB was a quarter of the default 4 KB stack (ADR-379). */
|
||||
static char s_ota_rx_buf[1024];
|
||||
|
||||
void ota_update_log_httpd_stack(const char *what)
|
||||
{
|
||||
/* ESP-IDF FreeRTOS counts stack in bytes. */
|
||||
ESP_LOGI(TAG, "httpd stack after %s: %u of %d bytes never used",
|
||||
what, (unsigned)uxTaskGetStackHighWaterMark(NULL),
|
||||
CONFIG_OTA_HTTPD_STACK_SIZE);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /ota — receive and flash firmware binary.
|
||||
* POST /ota — receive and flash firmware binary. Returns ESP_OK only after the
|
||||
* new slot is set as boot partition and the response is sent; the caller
|
||||
* reboots.
|
||||
*/
|
||||
static esp_err_t ota_upload_handler(httpd_req_t *req)
|
||||
static esp_err_t ota_upload_receive(httpd_req_t *req)
|
||||
{
|
||||
/* ADR-050: Authenticate before accepting firmware upload. */
|
||||
if (!ota_check_auth(req)) {
|
||||
@@ -144,12 +173,12 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
|
||||
}
|
||||
|
||||
/* Read firmware in chunks. */
|
||||
char buf[1024];
|
||||
char *buf = s_ota_rx_buf;
|
||||
int received = 0;
|
||||
int total = 0;
|
||||
|
||||
while (total < req->content_len) {
|
||||
received = httpd_req_recv(req, buf, sizeof(buf));
|
||||
received = httpd_req_recv(req, buf, sizeof(s_ota_rx_buf));
|
||||
if (received <= 0) {
|
||||
if (received == HTTPD_SOCK_ERR_TIMEOUT) {
|
||||
continue; /* Retry on timeout. */
|
||||
@@ -173,9 +202,11 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
|
||||
|
||||
total += received;
|
||||
if ((total % (64 * 1024)) == 0) {
|
||||
ESP_LOGI(TAG, "OTA progress: %d / %d bytes (%.0f%%)",
|
||||
/* Integer percent: %f pulls newlib's float formatter onto this
|
||||
* task's stack for no benefit. */
|
||||
ESP_LOGI(TAG, "OTA progress: %d / %d bytes (%d%%)",
|
||||
total, req->content_len,
|
||||
(float)total * 100.0f / (float)req->content_len);
|
||||
(int)((int64_t)total * 100 / req->content_len));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,12 +232,24 @@ static esp_err_t ota_upload_handler(httpd_req_t *req)
|
||||
const char *resp = "{\"status\":\"ok\",\"message\":\"OTA update successful. Rebooting...\"}";
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_send(req, resp, strlen(resp));
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
/* Delay briefly to let the response flush, then reboot. */
|
||||
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||
esp_restart();
|
||||
static esp_err_t ota_upload_handler(httpd_req_t *req)
|
||||
{
|
||||
esp_err_t ret = ota_upload_receive(req);
|
||||
|
||||
return ESP_OK; /* Never reached. */
|
||||
/* The deepest path this server runs is esp_ota_end() -> esp_image_verify().
|
||||
* Log the headroom it left so the stack size is set from evidence, not
|
||||
* guessed a second time. */
|
||||
ota_update_log_httpd_stack("POST /ota");
|
||||
|
||||
if (ret == ESP_OK) {
|
||||
/* Delay briefly to let the response flush, then reboot. */
|
||||
vTaskDelay(pdMS_TO_TICKS(1000));
|
||||
esp_restart();
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
/** Internal: start the HTTP server and register OTA endpoints. */
|
||||
@@ -214,6 +257,11 @@ static esp_err_t ota_start_server(httpd_handle_t *out_handle)
|
||||
{
|
||||
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
|
||||
config.server_port = OTA_PORT;
|
||||
/* HTTPD_DEFAULT_CONFIG gives 4096 B. MEASURED 2026-09-29 on an S3 (node 4,
|
||||
* v0.8.12): POST /ota overflowed it every time ("stack overflow in task
|
||||
* httpd", RTC_SW_CPU_RST), so no OTA had ever completed on this firmware.
|
||||
* See CONFIG_OTA_HTTPD_STACK_SIZE for the sizing. */
|
||||
config.stack_size = CONFIG_OTA_HTTPD_STACK_SIZE;
|
||||
config.max_uri_handlers = 12; /* Extra slots for WASM endpoints (ADR-040). */
|
||||
/* Increase receive timeout for large uploads. */
|
||||
config.recv_wait_timeout = 30;
|
||||
|
||||
@@ -30,4 +30,13 @@ esp_err_t ota_update_init(void);
|
||||
*/
|
||||
esp_err_t ota_update_init_ex(void **out_server);
|
||||
|
||||
/**
|
||||
* Log the httpd task's stack high-water mark (bytes never used). Call from an
|
||||
* HTTP handler on the OTA server after its deepest work, so the configured
|
||||
* CONFIG_OTA_HTTPD_STACK_SIZE is backed by a measured margin.
|
||||
*
|
||||
* @param what Short label for the handler, e.g. "POST /ota".
|
||||
*/
|
||||
void ota_update_log_httpd_stack(const char *what);
|
||||
|
||||
#endif /* OTA_UPDATE_H */
|
||||
@@ -24,7 +24,9 @@
|
||||
#include "wasm_runtime.h"
|
||||
#include "rvf_parser.h"
|
||||
#include "nvs_config.h"
|
||||
#include "ota_update.h"
|
||||
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include "esp_log.h"
|
||||
@@ -82,7 +84,7 @@ static uint8_t *receive_body(httpd_req_t *req, int *out_len)
|
||||
* POST /wasm/upload — Upload RVF or raw .wasm
|
||||
* ====================================================================== */
|
||||
|
||||
static esp_err_t wasm_upload_handler(httpd_req_t *req)
|
||||
static esp_err_t wasm_upload_process(httpd_req_t *req)
|
||||
{
|
||||
int total = 0;
|
||||
uint8_t *buf = receive_body(req, &total);
|
||||
@@ -215,6 +217,15 @@ static esp_err_t wasm_upload_handler(httpd_req_t *req)
|
||||
(void)format;
|
||||
}
|
||||
|
||||
/* Signature check and wasm3 module load run on the shared httpd task; log
|
||||
* the headroom they left (ADR-379, CONFIG_OTA_HTTPD_STACK_SIZE). */
|
||||
static esp_err_t wasm_upload_handler(httpd_req_t *req)
|
||||
{
|
||||
esp_err_t ret = wasm_upload_process(req);
|
||||
ota_update_log_httpd_stack("POST /wasm/upload");
|
||||
return ret;
|
||||
}
|
||||
|
||||
/* ======================================================================
|
||||
* GET /wasm/list — List module slots
|
||||
* ====================================================================== */
|
||||
@@ -237,18 +248,24 @@ static esp_err_t wasm_list_handler(httpd_req_t *req)
|
||||
uint8_t count = 0;
|
||||
wasm_runtime_get_info(info, &count);
|
||||
|
||||
/* Build JSON array (larger buffer for manifest fields). */
|
||||
char response[2048];
|
||||
/* Build JSON array (larger buffer for manifest fields). Heap, not stack:
|
||||
* 2 KB was half the httpd task's former 4 KB stack. */
|
||||
const size_t resp_size = 2048;
|
||||
char *response = malloc(resp_size);
|
||||
if (response == NULL) {
|
||||
httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "Out of memory");
|
||||
return ESP_FAIL;
|
||||
}
|
||||
int pos = 0;
|
||||
pos += snprintf(response + pos, sizeof(response) - pos,
|
||||
pos += snprintf(response + pos, resp_size - pos,
|
||||
"{\"modules\":[");
|
||||
|
||||
for (uint8_t i = 0; i < WASM_MAX_MODULES; i++) {
|
||||
if (i > 0) pos += snprintf(response + pos, sizeof(response) - pos, ",");
|
||||
if (i > 0) pos += snprintf(response + pos, resp_size - pos, ",");
|
||||
uint32_t mean_us = (info[i].frame_count > 0)
|
||||
? (info[i].total_us / info[i].frame_count) : 0;
|
||||
const char *name = info[i].module_name[0] ? info[i].module_name : "";
|
||||
pos += snprintf(response + pos, sizeof(response) - pos,
|
||||
pos += snprintf(response + pos, resp_size - pos,
|
||||
"{\"id\":%u,\"state\":\"%s\",\"name\":\"%s\","
|
||||
"\"binary_size\":%lu,\"caps\":\"0x%04lx\","
|
||||
"\"frame_count\":%lu,\"event_count\":%lu,\"error_count\":%lu,"
|
||||
@@ -266,11 +283,12 @@ static esp_err_t wasm_list_handler(httpd_req_t *req)
|
||||
(unsigned long)info[i].budget_faults);
|
||||
}
|
||||
|
||||
pos += snprintf(response + pos, sizeof(response) - pos,
|
||||
pos += snprintf(response + pos, resp_size - pos,
|
||||
"],\"loaded\":%u,\"max\":%d}", count, WASM_MAX_MODULES);
|
||||
|
||||
httpd_resp_set_type(req, "application/json");
|
||||
httpd_resp_send(req, response, pos);
|
||||
free(response);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@ 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.
|
||||
# itself, or the bootloader reverts on the next boot. main/ota_health.c does
|
||||
# the confirming (IP + one CSI frame within OTA_HEALTH_TIMEOUT_S), ADR-379.
|
||||
CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y
|
||||
|
||||
# Coredump to flash. The 64K `coredump` partition has existed in
|
||||
|
||||
@@ -56,10 +56,11 @@ FUZZ_JOBS ?= 1
|
||||
test_vitals run_vitals test_mmwave_detect run_mmwave_detect host_tests \
|
||||
test_thermal run_thermal test_csi_sanitize run_csi_sanitize \
|
||||
test_c6_antenna run_c6_antenna test_serial_onboarding run_serial_onboarding \
|
||||
run_delivery_contract
|
||||
run_delivery_contract test_ota_health run_ota_health
|
||||
|
||||
all: fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect \
|
||||
test_thermal test_csi_sanitize test_c6_antenna test_serial_onboarding
|
||||
test_thermal test_csi_sanitize test_c6_antenna test_serial_onboarding \
|
||||
test_ota_health
|
||||
|
||||
# --- XIAO ESP32-C6 RF switch selection ---
|
||||
# Host-side truth table for the GPIO14 antenna selector. GPIO3 is always driven
|
||||
@@ -133,12 +134,21 @@ test_serial_onboarding: test_serial_onboarding_protocol.c $(MAIN_DIR)/serial_onb
|
||||
run_serial_onboarding: test_serial_onboarding
|
||||
./test_serial_onboarding
|
||||
|
||||
# --- OTA first-boot health check (ADR-379) ---
|
||||
# Host-side. Exercises ota_health_step() from ../main/ota_health.h directly --
|
||||
# the decision the device runs to confirm or roll back an OTA'd image.
|
||||
test_ota_health: test_ota_health.c $(MAIN_DIR)/ota_health.h
|
||||
cc -std=c99 -Wall -Wextra -Werror -I$(MAIN_DIR) -o $@ $<
|
||||
|
||||
run_ota_health: test_ota_health
|
||||
./test_ota_health
|
||||
|
||||
run_delivery_contract:
|
||||
python3 test_delivery_contract.py
|
||||
|
||||
host_tests: run_adr110 run_vitals run_mmwave_detect run_thermal run_csi_sanitize \
|
||||
run_c6_antenna run_serial_onboarding run_delivery_contract
|
||||
@echo "Host tests passed (ADR-110 + CSI sanitation + vitals + mmwave + thermal + C6 antenna + USB onboarding + delivery contract)"
|
||||
run_c6_antenna run_serial_onboarding run_delivery_contract run_ota_health
|
||||
@echo "Host tests passed (ADR-110 + CSI sanitation + vitals + mmwave + thermal + C6 antenna + USB onboarding + delivery contract + OTA health)"
|
||||
|
||||
# --- Serialize fuzzer ---
|
||||
# Tests csi_serialize_frame() with random wifi_csi_info_t inputs.
|
||||
@@ -175,5 +185,6 @@ run_all: run_serialize run_edge run_nvs
|
||||
|
||||
clean:
|
||||
rm -f fuzz_serialize fuzz_edge fuzz_nvs test_adr110 test_vitals test_mmwave_detect \
|
||||
test_thermal test_csi_sanitize test_c6_antenna test_serial_onboarding
|
||||
test_thermal test_csi_sanitize test_c6_antenna test_serial_onboarding \
|
||||
test_ota_health
|
||||
rm -rf corpus_serialize/ corpus_edge/ corpus_nvs/
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* @file test_ota_health.c
|
||||
* @brief Pins the OTA first-boot health check decision (ADR-379).
|
||||
*
|
||||
* Exercises `ota_health_step()` from ../main/ota_health.h directly -- the same
|
||||
* function the device build runs, not a copy -- so the test and the firmware
|
||||
* cannot drift apart.
|
||||
*
|
||||
* The regression that motivated this: nothing ever called
|
||||
* esp_ota_mark_app_valid_cancel_rollback(), so on rollback-enabled nodes every
|
||||
* OTA'd image reverted at the next reset, and a bad image was never caught.
|
||||
*
|
||||
* Config under test: timeout 120 s, minimum uptime 30 s.
|
||||
*/
|
||||
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "ota_health.h"
|
||||
|
||||
#define TIMEOUT_MS 120000u
|
||||
#define MIN_UP_MS 30000u
|
||||
|
||||
static int g_failures = 0;
|
||||
|
||||
static const char *name(ota_health_verdict_t v)
|
||||
{
|
||||
switch (v) {
|
||||
case OTA_HEALTH_WAIT: return "WAIT";
|
||||
case OTA_HEALTH_MARK_VALID: return "MARK_VALID";
|
||||
case OTA_HEALTH_ROLLBACK: return "ROLLBACK";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
static void expect(ota_health_verdict_t got, ota_health_verdict_t want,
|
||||
const char *why)
|
||||
{
|
||||
if (got != want) {
|
||||
printf(" FAIL: got %s, expected %s\n %s\n", name(got), name(want), why);
|
||||
g_failures++;
|
||||
}
|
||||
}
|
||||
|
||||
static void expect_reason(const ota_health_sm_t *sm, const char *needle,
|
||||
const char *why)
|
||||
{
|
||||
if (strstr(sm->reason, needle) == NULL) {
|
||||
printf(" FAIL: reason \"%s\" lacks \"%s\"\n %s\n",
|
||||
sm->reason, needle, why);
|
||||
g_failures++;
|
||||
}
|
||||
}
|
||||
|
||||
static const ota_health_cfg_t CFG = { TIMEOUT_MS, MIN_UP_MS, false };
|
||||
|
||||
int main(void)
|
||||
{
|
||||
ota_health_sm_t sm;
|
||||
|
||||
printf("ota health check (timeout %u ms, min uptime %u ms)\n",
|
||||
TIMEOUT_MS, MIN_UP_MS);
|
||||
|
||||
/* Healthy boot: IP at 4 s, first CSI frame at 6 s, confirmed at 30 s. */
|
||||
ota_health_sm_init(&sm);
|
||||
expect(ota_health_step(&sm, &CFG, false, false, 1000), OTA_HEALTH_WAIT,
|
||||
"nothing yet, well inside the deadline");
|
||||
expect(ota_health_step(&sm, &CFG, true, false, 4000), OTA_HEALTH_WAIT,
|
||||
"IP alone is not enough");
|
||||
expect(ota_health_step(&sm, &CFG, false, true, 6000), OTA_HEALTH_WAIT,
|
||||
"both signals in, but below the minimum uptime");
|
||||
expect(ota_health_step(&sm, &CFG, false, false, 29999), OTA_HEALTH_WAIT,
|
||||
"one ms short of the minimum uptime");
|
||||
expect(ota_health_step(&sm, &CFG, false, false, 30000), OTA_HEALTH_MARK_VALID,
|
||||
"latched IP + CSI at the minimum uptime must confirm the image");
|
||||
expect_reason(&sm, "IP", "the log names the signals that passed");
|
||||
|
||||
/* Terminal: a verdict never changes, even past the deadline. */
|
||||
expect(ota_health_step(&sm, &CFG, false, false, 500000), OTA_HEALTH_MARK_VALID,
|
||||
"a confirmed image must not later be rolled back by the same check");
|
||||
|
||||
/* Signals arriving after the minimum uptime confirm immediately. */
|
||||
ota_health_sm_init(&sm);
|
||||
expect(ota_health_step(&sm, &CFG, true, true, 45000), OTA_HEALTH_MARK_VALID,
|
||||
"both signals after min uptime -> confirm now");
|
||||
|
||||
/* No IP by the deadline. */
|
||||
ota_health_sm_init(&sm);
|
||||
expect(ota_health_step(&sm, &CFG, false, false, 119999), OTA_HEALTH_WAIT,
|
||||
"one ms before the deadline");
|
||||
expect(ota_health_step(&sm, &CFG, false, false, 120000), OTA_HEALTH_ROLLBACK,
|
||||
"no signals at the deadline -> roll back");
|
||||
expect_reason(&sm, "no IP and no CSI", "both missing");
|
||||
|
||||
/* IP but the CSI pipeline never delivers. */
|
||||
ota_health_sm_init(&sm);
|
||||
ota_health_step(&sm, &CFG, true, false, 5000);
|
||||
expect(ota_health_step(&sm, &CFG, false, false, 120000), OTA_HEALTH_ROLLBACK,
|
||||
"associated but no CSI frame -> roll back");
|
||||
expect_reason(&sm, "no CSI frame", "names the missing CSI signal");
|
||||
|
||||
/* CSI sent (can happen only with IP in practice) but no IP event seen. */
|
||||
ota_health_sm_init(&sm);
|
||||
ota_health_step(&sm, &CFG, false, true, 5000);
|
||||
expect(ota_health_step(&sm, &CFG, false, false, 130000), OTA_HEALTH_ROLLBACK,
|
||||
"no IP -> roll back");
|
||||
expect_reason(&sm, "no IP", "names the missing IP signal");
|
||||
|
||||
/* Rollback is terminal too. */
|
||||
expect(ota_health_step(&sm, &CFG, true, true, 131000), OTA_HEALTH_ROLLBACK,
|
||||
"late signals must not resurrect a rolled-back verdict");
|
||||
|
||||
/* Force-fail hook rolls back exactly where it would have confirmed. */
|
||||
{
|
||||
const ota_health_cfg_t ff = { TIMEOUT_MS, MIN_UP_MS, true };
|
||||
ota_health_sm_init(&sm);
|
||||
expect(ota_health_step(&sm, &ff, true, true, 10000), OTA_HEALTH_WAIT,
|
||||
"force-fail still waits for the minimum uptime");
|
||||
expect(ota_health_step(&sm, &ff, false, false, 30000), OTA_HEALTH_ROLLBACK,
|
||||
"force-fail must roll back instead of confirming");
|
||||
expect_reason(&sm, "FORCE_FAIL", "names the test hook");
|
||||
}
|
||||
|
||||
/* Minimum uptime above the timeout is clamped, not a guaranteed rollback. */
|
||||
{
|
||||
const ota_health_cfg_t bad = { 60000u, 600000u, false };
|
||||
ota_health_sm_init(&sm);
|
||||
expect(ota_health_step(&sm, &bad, true, true, 59999), OTA_HEALTH_WAIT,
|
||||
"clamped min uptime: still waiting just before it");
|
||||
expect(ota_health_step(&sm, &bad, false, false, 60000), OTA_HEALTH_MARK_VALID,
|
||||
"healthy at the deadline must confirm, not roll back");
|
||||
}
|
||||
|
||||
/* Zero minimum uptime confirms on the first step with both signals. */
|
||||
{
|
||||
const ota_health_cfg_t zero = { TIMEOUT_MS, 0u, false };
|
||||
ota_health_sm_init(&sm);
|
||||
expect(ota_health_step(&sm, &zero, true, true, 0), OTA_HEALTH_MARK_VALID,
|
||||
"no soak configured -> confirm immediately");
|
||||
}
|
||||
|
||||
if (g_failures) {
|
||||
printf("FAILED: %d case(s)\n", g_failures);
|
||||
return 1;
|
||||
}
|
||||
printf("OK: all OTA health cases pass\n");
|
||||
return 0;
|
||||
}
|
||||
Reference in new issue
Block a user