Bonded BLE transport¶
Current firmware uses espressif/ble_conn_mgr 1.3.4 as its only NimBLE host owner.
Normal control and the fallback update protocol retain their GATT UUIDs and
local-control protobuf/property contracts. OTS is the standard Object Transfer
Service (0x1825), with a credit-controlled CoC on PSM 0x25.
Pairing and identity¶
Use LE Secure Connections only, bonding, 128-bit encryption and the Display Yes/No
IO capability. New pairing generates a numeric-comparison value. The phone/watch
shows the OS confirmation UI; firmware prints the comparison number to serial
under the uncaptured pairing log tag. A single click on the headset within 30
seconds accepts that pending comparison and consumes the normal button action.
Other gestures are suppressed while comparison is pending. Timeout/disconnect
invalidates the request; no fixed passkey, password or Just Works fallback exists.
The matrix does not display the number yet.
New pairing is unavailable while the runtime is in update mode. Numeric-comparison requests are rejected, pending comparisons are cancelled even after an earlier click, and button confirmation is disabled until updating ends. The existing secured link can carry OTA and existing bonds may reconnect. Bond write/delete callbacks use the runtime flash gate: update entry rejects outstanding flash holds and new non-OTA flash holds are rejected during an update, closing the check/start race. This applies to normal and recovery firmware; it does not depend on LED progress timing.
The owner explicitly accepts blind headset confirmation for this wearable. Unless the serial number is actually compared with the OS number, this does not provide meaningful human-verified MITM protection. Its purpose is deliberate device selection and preventing accidental control of another nearby pair of ears. A Bluetooth device name alone does not authorise control.
Endpoint names are exposed as authenticated 0x2901 User Description descriptors.
The pinned ble_conn_mgr stores characteristic names internally but does not
register these descriptors. The repository-owned endpoint_descriptors.c hook
attaches them to the local-control service before NimBLE counts/registers its
attributes. Its allocations follow the pinned connection manager's teardown
ownership. Other services are unchanged; managed sources are not patched.
Phone and watch pair independently and retain the existing exclusive connection handoff. OS bond keys replace the application password on current firmware. Existing Security 2 credentials are not converted into bonds. Current apps no longer support that firmware or its password exchange. Update older firmware by USB, then pair independently on each controller; there is no plaintext downgrade.
GATT characteristics and OTS require encrypted/authenticated link access. The
firmware additionally requires a bonded connection before dispatching application
requests or accepting a CoC. Native protocomm Security 0 is used only as message
framing inside this secured link. Version metadata must explicitly identify
local_ctrl.link_security = "lesc-bonded-v1" and sec_ver = 0; clients reject
unmarked Security 0. CoC uses the same secured ACL; Android uses its secure socket
API. This does not change the Wi-Fi AP password/browser trust model.
NimBLE stores bond security records and the local identity key in NVS. Their write/delete callbacks
acquire the render/audio flash pause. CCCD/cache persistence is suppressed, and
clients resubscribe on each connection. Radio startup/teardown already holds its
own flash pause. Advertising uses the stable public identity, with the discovery UUID in ADV and
the full device name in scan response so both fit legacy 31-byte limits.
The pinned IDF adapter hooks ble_store_config_init before the
host task starts and applies secured flags to upstream OTS registration without
editing managed sources. Review these hooks on an IDF/component upgrade.
Bulk objects and updates¶
The upstream ble_services 1.5.0~1 OTS component owns metadata and control-point wire
encodings. ESPurr owns object permissions, bounded buffering and backend calls:
- Object 0x100,
firmware.bin: write-only inactive-image staging. Authenticated GATT OPEN supplies expected length/SHA-256 and enters the latched OTA mode. - Object 0x101,
catalogue.json: immutable read-only firmware catalogue. Its existing INFO hash/schema remains the authority for client validation/cache. Both native clients use OTS on a cache miss when available, with GATT paging fallback if CoC cannot open.
Only sequential full/remaining writes are supported; no create/delete/patch, arbitrary flash access or client-editable metadata. GATT control and fallback transfer remain available. OTS READ/WRITE completion reports transfer outcome; firmware boot selection still requires the common OTA worker's explicit COMMIT, length/digest/image-family checks and native image finalisation. Transport bytes accepted are not proof of a valid installed image.
A bounded 512-byte CoC receive handoff feeds a 4096-byte aggregation buffer. The receiver replenishes credits after consuming each handoff, so the phone can stream without per-packet GATT round trips. The ordinary flash worker remains the sole image writer. Off-GATT operations share a transfer mutex; queued cancellation tickets survive an in-flight bulk write. Native transfer tokens prevent cleanup from an old channel from aborting a new attempt. The client selects GATT if CoC cannot be opened before transfer; a failed/ambiguous transfer is cancelled before an explicit fresh attempt, never silently replayed on another transport.
OTA mode remains latched after failure or cancellation until successful update/ reboot or explicit reset. A new transfer needs no reset; a lost BLE link may need reconnect and bond restoration. GATT completion indications replace polling on current firmware; the status-query operation remains available. Normal lighting/control commands are rejected during OTA mode. Both phones show the mode and disable settings edits while leaving firmware retry reachable, including after reconnect. No A/B model slots or model uploader are introduced by this transport change.
Alert Notification Service is deliberately omitted: its message categories and
unread counts do not represent ESPurr's current warning state. firmware.alerts
and the existing diagnostics UI remain authoritative.
Acceptance¶
See the ASK/client checkpoint and transport checkpoint for completed checks, image/static-memory headroom and remaining physical acceptance.
Host tests and cross-builds cannot establish native pairing UI, bond persistence, phone/watch CoC interoperability, negotiated throughput or physical OTA/rollback. Before deployment, check first pairing/timeout/cancel/button consumption, separate phone/watch bonds and handoff, reconnect/reboot, CoC failure/GATT fallback, mid-write disconnect and retry, lost final acknowledgement, and both ESP32/S3 flash pauses.
Flash write policy¶
Automatic settings persistence waits ten seconds after the last applied change. A BLE control disconnect requests an earlier flush through the render owner; update/flash holds defer that flush until it is safe. Unchanged settings already skip NVS writes. A failed save keeps the existing thirty-second retry backoff, including repeated disconnects. Explicit browser Apply & save, synchronous calibration/save requests and the pre-OTA flush remain intentional save points.
The current normal profiles allow three stored bonds and one active connection. This supports two phones and a watch. NimBLE's round-robin store-full policy can evict an older bond when another is added; that peer then needs pairing again. Restoring encryption does not itself create another bond record. Subscription and GATT-cache records are not persisted. Native pairing/bonding may write more than one security record per pairing; no exact physical flash-write count has been measured.