Skip to content

Authenticated BLE OTA and image matching

Current firmware uses bonded BLE and OTS/CoC for bulk transfer, with the GATT endpoint below as fallback. GATT completion indications avoid polling on current firmware. Clients reject legacy Security 2 and unmarked plaintext; no fixed device passkey is used.

espurr/ota is a custom ESP-IDF local-control endpoint (UUID FF01) advertised by capability bit 10. Current firmware protects it using the bonded LE Secure Connections link. Endpoint descriptor discovery is required; names/handles alone are not identity. This uses no separate host/profile.

All multibyte values are explicit little-endian. Requests inside the secured link are at most 398 bytes, within the existing 512-byte transport limit. The common header is version u8 = 1, operation u8, session generation u32, request ID u32. Queries require ID 0; mutations require nonzero strictly increasing IDs per connection. These IDs are independent of property command.id. The exact latest request/result is retained: an identical duplicate never writes twice; altered payloads or evicted/old IDs fail. One mutation may be queued/in flight.

Operation Value Body after the 10-byte header
Status 0 none
Begin 1 image length u32 + SHA-256 of complete application image, 32 bytes
Chunk 2 absolute byte offset u32 + 1–384 image bytes
Finish 3 none
Abort 4 none
Reboot acknowledgement 5 none; send only after reading successful Finish

Replies are exactly 32 bytes:

Offset Encoding Meaning
0 u8 version = 1
1 u8 result: empty 0 / queued 1 / applied 2 / failed 3
2 u8 transfer: idle 0 / active 1 / finalised 2 / aborted 3
3 u8 latest mutation operation
4 u32 connection generation
8 u32 latest mutation request ID
12 u32 esp_err_t bits; zero on successful application
16 u32 confirmed received bytes
20 u32 expected image bytes
24 u32 inactive slot capacity (1,740,800 bytes on original Atom; 3,145,728 on S3)
28 u16 maximum chunk bytes (384)
30 u16 firmware chip ID (ESP32 = 0, ESP32-S3 = 9)

GATT success/queued replies mean admission only. Use completion indications on current firmware, or poll Status on older firmware, for exact generation, ID and operation and applied/failed completion. Received bytes advance only after the shared OTA writer completes. Status does not extend the 15-second no-data timeout. Callbacks use a short snapshot lock and nonblocking queue; a small BLE transport task feeds the existing sole OTA flash worker. Strict offsets reject gaps/repeated bytes; errors abort the incomplete upload. Generation closure/disconnection aborts within the transport polling cycle (100 ms) after any current flash operation completes. No partial transfer resumes in a new session. Never resend ambiguous ciphertext.

Begin flushes coalesced settings and pauses normal render output. Every progress frame is requested synchronously after flash completes, and both RMT channels drain before the next write. Each logical outline fills orange over dim violet, following physical reversal/colour order. Main brightness, gamma, RGB trims and the current cap apply; off/zero brightness remain black. No background, gesture or dither runs during update. LED transaction failure aborts before more image writes; after successful slot selection an indicator failure cannot undo the selected slot.

Finish requires exact image length, matching SHA-256, native image finalisation and successful boot-slot selection. Full orange is reserved for this success. Read the finalised result before sending Reboot acknowledgement. The acknowledged reset delay is 1.5 seconds; a lost acknowledgement triggers reset after a 15-second grace period. A lost Finish reply may still mean the new slot was selected: show uncertainty and reconnect to check firmware, never automatically replay Finish. Native rollback health checks and NVS preservation are shared with browser OTA. OTA cannot install a new bootloader or migrate a partition table; use full USB flashing for that.

The app uses Setup → Firmware updates, a Files .bin picker, version/size preflight, confirmed byte progress and cancellation. Keep app foreground/power/range; background closes the connection, and auto-lock is disabled while upload runs. Demo cannot update firmware. Install this endpoint firmware once through browser or USB, and update the app alongside it (the old app requires exactly three endpoints).

Software fixtures exercise admission, offsets, deduplication, cancellation, stale generations, image matching and uncertain completion. Physical throughput, bond/CoC interoperability, flash/LED timing and rollback require hardware checks. Original resource measurements and initial acceptance steps are in the dated endpoint record.

Target matching

Both phone clients recognise application-image families from the native app description: espurr for the existing ESP32 (chip ID 0) and ESP32-S3 (9) builds, espurr-v2 for v2 16 MB and espurr-v2-8mb for v2 8 MB builds. V2 is S3-only. The two v2 layouts are distinct OTA families, regardless of binary size; an app update cannot migrate their partition tables. The optional authenticated, read-only firmware.project property identifies the running family; its absence means legacy espurr. Invalid present metadata fails validation. Unknown families may retain ordinary control but cannot use these clients' known-image OTA path. Phones reject a different family before starting update state and refresh the identity before upload; chip ID and live inactive-slot capacity are also checked before BEGIN. Firmware independently checks its SDK target chip ID and exact running project name before selecting a boot slot. The OTA wire encoding is unchanged. Each v2 executable preset must emit its matching project name; merely using the S3 target is insufficient. Known image ceilings are 4 MiB and 3 MiB respectively; the live device capacity may impose a smaller bound.

The browser refreshes /api/status before uploading and checks the application prefix against firmware_project, chip_id and ota_capacity. It refuses an unavailable status response or wrong-board file before issuing an upload POST. Older responses without the family field retain the legacy-family assumption; the firmware's independent prefix validation remains authoritative.

S3's 8 MB layout reserves a separate recovery test-app partition and gives each normal OTA slot 3072 KiB. Separate recovery presets build a Wi-Fi/BLE updater for this slot. Public S3 USB bootstrap bundles include its native GPIO hold by default; physical installation remains unverified. Recovery images have distinct project names and cannot be installed by normal OTA. Recovery advertises explicit read-only mode and normal upload-family metadata over BLE. Both phone apps route to an updater and validate that family independently of the running recovery project name; see phone recovery. Recovery uploads accept the matching normal image family and select the inactive normal slot relative to the normal boot selection. The module-only recovery runtime starts only the primary button for pairing confirmation, without sensor buses or required LED startup; button failure leaves Wi-Fi available. Optional legacy WS2812 progress errors never reject the upload. V2 recovery remains headless. Normal uploads only target the inactive ota_0/ota_1 slot; they do not write the reserved test partition. Phone preflight limits default ESP32 images to 1700 KiB and legacy S3 images to 3072 KiB. The N16R8 v2 parser permits up to 4096 KiB; N8 v2 permits 3072 KiB, subject to the fresh device-reported capacity before Begin. This parser bound does not establish a deployed v2 partition layout. An older installed layout with smaller slots therefore refuses an oversized upload without mutations. Installing the new partition layout requires full USB flashing. The N16R8 model-data partition is outside app OTA. A successful app update does not provision or version models, and cannot migrate the earlier 6 MiB-slot table.