# OTA Deployment — Status ## Current state (2026-05-14) **End-to-end OTA verified working on `dc-0002`.** Device polled `engagement-api-1`, received a signed manifest, downloaded and verified firmware 1.0.1, set the alternate boot partition, rebooted, and came up reporting `fw=1.0.1`. ## What's deployed - **Branch `feat/pull-ota-code-signing`** merged to `main` (13 commits, 17 new files, 936 LOC). - **Signing toolchain**: `tools/gen_signing_key.py`, `tools/sign_firmware.py`, `tools/deploy_firmware.py`. - **Firmware OTA library**: `firmware/lib/ota_updater/`. - **Signing key**: `secrets/firmware_signing_key.pem` (gitignored). Public key committed at `firmware/lib/ota_updater/ota_pubkey.h`. - **Live OTA handler**: served by `engagement-api-1` Docker service (source not in this repo). The stub at `server/ota_endpoint.py` is unwired and not the one responding to devices. - **Configurable poll interval** via NVS key `ota_interval`. Provision with `flash_device.py --ota-interval-seconds N`. Min 10 s, default 21600 (6 h). ## Issues resolved ### 1. HMAC format mismatch (resolved 2026-05-13) Firmware OTA updater was using `X-HMAC-Signature` header + `millis()`-derived timestamp; the reporter component used `X-Signature` + `time(nullptr)`. Server expected the reporter format. Fixed by aligning the OTA updater to the same canonical scheme as the reporter (`firmware/lib/ota_updater/ota_updater.cpp` `add_hmac_headers`). ### 2. `/ota/check` JSON schema mismatch (resolved 2026-05-14) Server was emitting `{update_available, sha256, url}` but firmware reads `{update, size, sig_b64}`. Device silently decided "up to date" every poll because `doc["update"]` defaulted to `false`. Fixed server-side: the `/ota/check` response now also includes the fields the firmware needs. Firmware schema remains the source of truth. ### 3. Signed firmware artifact pipeline (resolved 2026-05-14) Deploy flow now bumps `FW_VERSION` → builds → copies `.pio/build/timercam/firmware.bin` to `firmware-.bin` → signs with `tools/sign_firmware.py` → SCPs both `.bin` and `.bin.sig` to `root@nginx:/root/engagement-api/firmware/`. Server team updates `firmware_releases.sha256` to match the new binary. **Gotcha:** the `.bin` and `.sig` must always be deployed together. The signature is over the bytes; replacing one without the other puts the server in an inconsistent state and devices will reject the update with `SIGNATURE INVALID`. ## Hardening added this session ### Firmware logging (`firmware/lib/ota_updater/ota_updater.cpp`, `firmware/src/main.cpp`) The previous `log_i/w/e` macros were silenced by the default `CORE_DEBUG_LEVEL`. Replaced with `Serial.printf` so output appears regardless of log level. Now logs at every step: - `[OTA] task started, interval=N ms` - Per-tick WiFi status - Full check URL + HMAC header preview (device id, ts, sig prefix) - HTTP response code + error body on non-200 - JSON parse errors - "Up to date" decision - Partition labels and offsets (running + target) - Per-128 KB download progress - Total bytes + elapsed ms - Computed sha256 of the downloaded image (compare against server `X-SHA256`) - Signature verify result - `esp_ota_end` / `esp_ota_set_boot_partition` errors by name - 500 ms `Serial.flush()` + `delay()` before `esp_restart()` so the final log line escapes the UART ### Boot-time partition state (`firmware/src/main.cpp`) Logs `running partition '