Rollback-enabled nodes (CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y) boot an
OTA'd image as PENDING_VERIFY, but nothing called
esp_ota_mark_app_valid_cancel_rollback(), so every OTA reverted at the next
reset, a second OTA was refused until then, and a bad image was never
deliberately rolled back.
- main/ota_health.{c,h}: at boot, if the running image is PENDING_VERIFY,
poll for STA got-IP and >=1 CSI frame accepted by the stream sender.
Confirm after CONFIG_OTA_HEALTH_MIN_UPTIME_S (30 s) once both are in;
roll back via esp_ota_mark_app_invalid_rollback_and_reboot() if they are
not in by CONFIG_OTA_HEALTH_TIMEOUT_S (120 s from boot). VALID, UNDEFINED,
NEW and no-otadata/factory images are left alone. The decision is a pure
header function, host-tested in test/test_ota_health.c (make host_tests).
- CONFIG_OTA_HEALTH_FORCE_FAIL (test only) rolls back where it would have
confirmed, for proving rollback on hardware.
- GET /ota/status reports ota_state so durability is visible remotely.
- RUNBOOK: replace the nonexistent ota_rollback_boot_check() claim and
document the behaviour and the hardware check (2.1).
Hardware-verified on an ESP32-S3 (push, confirm, hard reset, forced
rollback) and an ESP32-C6 (push, confirm, hard reset); see ADR-379, next in
this series. Host tests: make host_tests passes, including run_ota_health.
Co-Authored-By: claude-flow <ruv@ruv.net>
MEASURED 2026-09-29 on an ESP32-S3 (v0.8.12, 16 MB): every POST /ota of a
valid 1,228,672 B image died with "stack overflow in task httpd" and
RTC_SW_CPU_RST, so no OTA had ever completed on this firmware.
- ota_update.c: set config.stack_size = CONFIG_OTA_HTTPD_STACK_SIZE (new
Kconfig, default 8192; HTTPD_DEFAULT_CONFIG gave 4096). Move the 1 KB
receive buffer off the stack into a static (handlers are serialised on
the one httpd task). Integer progress percent instead of %f.
recv_wait_timeout stays 30 s.
- Log the httpd stack high-water mark after every POST /ota and
POST /wasm/upload so the size rests on device data.
- wasm_upload.c: GET /wasm/list builds its 2 KB JSON on the heap.
- RUNBOOK §3 (over the air) records the failure and the high-water-mark
log line.
Device check after the fix (same S3): "httpd stack after POST /ota: 4900 of
8192 bytes never used", i.e. about 3.3 KB peak. The ADR with the full
hardware evidence follows in this series (ADR-379).
This overlaps PR #1594, which raises the same stack. If #1594 lands first,
drop this commit; the rest of the series does not depend on it.
Co-Authored-By: claude-flow <ruv@ruv.net>
The two options differ in kind. Mesh alignment is an agreement between
receivers and inherits whatever the sync topology does; selecting by rx_seq
needs no agreement, because the 802.11 sequence control field is assigned by
the transmitter and is identical at every receiver. Nodes that have never heard
of each other, sit in different sync domains, or run with no mesh at all still
keep the same subset.
The code shows it: the seq test runs before any sync check and reads only the
frame, while only the mesh branch calls c6_sync_espnow_is_valid(). That
asymmetry is why seq had nothing to degrade in our runs, and it is the thing to
weigh if a fleet's sync is fragile -- seq is not just the better performer
there, it is the option with no dependency that can quietly fail.
Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
The mesh gate uses mesh-time buckets only while c6_sync_espnow_is_valid(); an
unsynced node falls back to the elapsed gate rather than gating on a
meaningless epoch. Correct behaviour, but it contaminates the comparison we
published: three of nine nodes never synchronised, so for those three the
setting under test was silently not in effect. The +0.5 pp should be read as
"mesh where sync held, elapsed where it did not", not as a clean fleet-wide
measurement.
Records the house-dependent cause as well. Time sync is leader-elected and
hub-and-spoke: one node broadcasts and the rest follow, so whether every node
hears the leader is a property of the building. In a larger or more divided
house the fleet fragments into sync domains and any option depending on shared
time degrades quietly for the nodes that fell out -- which is exactly the case
where someone would most want to trust the measurement.
Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
The tunables table says which options depend on the building. This shows what
that means with real numbers, and is explicit that they are ours.
Nine-node C6 fleet, 2x2 factorial, three Latin-square blocks: seq selection
doubled cross-node pairing (0.5605 vs 0.2434, +31.7 pp, ~70x the drift floor)
while mesh-time alignment bought +0.5 pp, inside noise.
The point is the reasoning, not the ranking. It records WHY that happened here
-- these nodes already overheard 72% of transmissions in common, so there was a
large shared pool for seq to select from and little left for alignment to
recover -- and says plainly that a house with fewer shared transmitters or
thicker walls has less for seq to work with and may find alignment matters
more. We did not measure such a house and do not claim to know.
Also keeps the trade (pairing bought with raw frame rate; edge processing
unaffected because separately rate-limited, so the cost lands on the uplink)
and the prediction we got wrong: seq is NOT a workaround for broken time sync,
which means repairing our mesh split would not have closed the gap.
Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
The Kconfig options were discoverable only by opening menuconfig and reading
help text, which requires already knowing they exist. Two of them --
CSI_GATE_MESH_ALIGNED and the thermal throttle pair -- have no universally
right value: they depend on walls, transmitters, enclosure and placement.
Lists all eight with their defaults, marks which are house-dependent, and says
what to measure for each. Notes explicitly that in one measured fleet window
alignment bought +0.5 pp of cross-node pairing while rx_seq selection bought
+31.7 pp, so nobody inherits the assumption that the default is doing the work.
Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo
README.md explains the project; there was nothing that said what to run, in
what order, and what to check. This is that, written from the procedures that
were actually used on a nine-node C6 fleet.
Everything in it was learned by getting it wrong at least once, and the
failures it prevents are the silent kind:
- The build must concatenate THREE defaults files. IDF discovers
sdkconfig.defaults and sdkconfig.defaults.<target> on its own but NOT
sdkconfig.defaults.16mb, which is where CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE
lives. Build without it and the image 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.
- A five-key pre-flash gate, because a stale sdkconfig reading FLASHSIZE=4MB
against a 16MB fleet produces an image that silently is not the configuration
under test.
- What OTA can and cannot reach, per flash region. Bootloader and partition
table are USB-only, and there is no bootloader version on the wire -- so
"every node reports health" proves the app is current and proves nothing
about the bootloader.
- A single-board diagnostic flash with a revert path: parse otadata first, back
up the 8 KB, and reverting is writing it back rather than rebuilding.
- min_heap_kib is a minimum-since-boot watermark, not an instantaneous reading.
Sampling it within a minute of a restart produced a wrong "no effect" verdict
once; the real answer, once settled, was -56 KiB.
- /api/v1/nodes rssi_dbm is not the AP link and swings 20+ dB with no physical
change.
No code changes. One file.
Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BHNNWbmKvxXrVaEDwXEBNo