Skip to content

Phase 7a: dual OTA partitions + signed, self-updating firmware - #2

Merged
RndmCodeGuy20 merged 14 commits into
mainfrom
phase7a-partition-ota
Aug 4, 2026
Merged

Phase 7a: dual OTA partitions + signed, self-updating firmware#2
RndmCodeGuy20 merged 14 commits into
mainfrom
phase7a-partition-ota

Conversation

@RndmCodeGuy20

Copy link
Copy Markdown
Owner

Summary

  • Repartitions flash for dual OTA slots (ota_0/ota_1 + otadata + coredump), replacing the single factory partition — the prerequisite for any update mechanism.
  • Adds main/ota_updater.cpp/.h: a background task that periodically checks GitHub Releases (every 6h, or on demand via POST /api/ota/check), downloads updates over HTTPS via esp_https_ota() with cert-bundle verification, and only cancels the bootloader's rollback after a real health check (uptime + at least one DNS query answered, with a 10-minute unconditional fallback so an idle-but-healthy device isn't stuck forever).
  • Firmware images are signed (CONFIG_SECURE_SIGNED_APPS_NO_SECURE_BOOT) without enabling full Secure Boot v2 or flash encryption — both would burn eFuses irreversibly, an explicit non-goal for this dev board.
  • New pure/host-testable semver comparator (main/ota_version.h/.cpp) with 13 Unity test cases.
  • GET /api/ota (status) and POST /api/ota/check (auth-gated manual trigger) HTTP routes.
  • CI signs release binaries with a repo secret and documents the corrected flash offsets for a fresh install.
  • README/ARCHITECTURE.md updated: OTA moved from non-goals to done, docs describe the actual health-gate and periodic-check behavior.

This branch went through 3 review rounds (per-task + two whole-branch passes) that found and fixed: a signing-key scheme mismatch, a latest_version discard bug, a zero-sentinel cooldown regression, a stale CI flash offset (would have bricked a fresh flash), a missing periodic check despite docs claiming one, an unsynchronized std::string struct copied across FreeRTOS tasks (heap-UAF risk), a missing timeout escape from the health gate, and a narrow cooldown-bypass window.

Test plan

  • host_test/: idf.py --preview set-target linux build && ./build/host_test.elf — 37/37 passing
  • idf.py build (esp32s3) — clean, signed mini_dns.bin, 69% of ota_0 free
  • Hardware: fresh flash (erase-flash flash monitor), confirm health gate passes (~30s + first query), trigger POST /api/ota/check against a real tagged release, confirm OTA round-trip and bootloader rollback on a deliberately broken image

🤖 Generated with Claude Code

fetch_latest_release() can parse tag_name successfully but still fail
(e.g. no mini_dns.bin asset), and run_check_cycle() was discarding that
tag instead of surfacing it alongside last_error, making a partial
failure look identical to "never checked" over /api/ota.

Also add a 30s cooldown after a failed check cycle so a client hammering
POST /api/ota/check can't trigger unbounded rapid retries once the
in-progress guard clears.
s_last_failure_us defaults to 0, indistinguishable from
esp_timer_get_time()'s own near-zero values during the first ~30s
after boot, so the very first legitimate check request after a
fresh flash was spuriously rejected as "within cooldown". Gate the
comparison on s_last_failure_us != 0. Also tightens the
latest_version doc comment to reflect its per-cycle reset behavior.
…health-gate timeout, synchronize status reads
run_check_cycle()'s two failure paths cleared s_check_in_progress before
stamping s_last_failure_us, leaving a two-instruction window where a
request landing between them saw in_progress=false and a stale (pre-
failure) cooldown timestamp — reorder so the cooldown is armed first.

Also brings README/ARCHITECTURE.md and the ota_updater_request_check()
doc comment in line with the actual health-gate behavior (10-minute
uptime fallback, periodic 6h interval, pending-verify rejection), and
gives the 409 response accurate wording instead of always claiming
"check already in progress" regardless of the real rejection reason.
CI's release build failed at the signing step with a cryptic espsecure
traceback ("Could not deserialize key data") — the secret was almost
certainly populated using README's own generate_signing_key command,
which is missing --scheme ecdsa256 and defaults to an RSA key the
project's ECDSA sdkconfig can't sign with. Add an explicit empty-secret
check and an ECDSA-header sanity check with an actionable error message
in both jobs that write the key, and fix the README command.
Provisioned monitoring/ (docker-compose, scrape config, Grafana
datasource + dashboard) for the existing /metrics endpoint, plus a
dashboard screenshot referenced from the README's Monitoring section.
Sanitized the scrape target's hardcoded LAN IP to the placeholder the
setup instructions already document, and moved the screenshot into
docs/ with a space-free filename.
push had no branch/tag filter, so every push to phase7a-partition-ota
triggered both the push and pull_request events for the same commit —
two full firmware+host-test runs per push. Scope push to main and
version tags (pull_request already covers feature-branch commits), and
add a concurrency group so a new push cancels a still-running prior one
instead of queuing behind it.
@RndmCodeGuy20
RndmCodeGuy20 merged commit 0c394c1 into main Aug 4, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant