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.