Designing BLE OTA updates that survive a dropped connection
A firmware update over Bluetooth will be interrupted. The design question is what happens on the next connection, not how to prevent it.
Over a long enough transfer, a BLE connection will drop. Someone walks out of range. The screen locks. iOS decides the radio is needed elsewhere. No OTA implementation prevents any of that, so the thing worth judging is what the device does when the phone comes back.
Resumability is a firmware decision
The app cannot make an update resumable on its own. If the bootloader erases the whole application slot before the first chunk arrives, any interruption leaves a bricked device, and the only honest recovery is to start over. Resumability needs the device to track how far it got and expose that offset for the app to read on reconnect.
Settle this with the firmware team before either side writes code. It changes the app's whole state model:
- A readable 'bytes received' characteristic the app queries on connect.
- A per-chunk acknowledgement, so the app knows what landed and not merely what it sent.
- A whole-image checksum the device verifies before it commits. The app should never be the one checking.
Write without response, carefully
Throughput comes from .withoutResponse writes. Waiting for a response on every chunk cuts an update to a fraction of its possible speed. The cost is that those writes get dropped silently when the transmit queue fills.
// canSendWriteWithoutResponse is the flow-control signal.
// Ignoring it is what turns a fast transfer into a corrupt one.
while offset < image.count, peripheral.canSendWriteWithoutResponse {
let end = min(offset + chunkSize, image.count)
peripheral.writeValue(image[offset..<end],
for: characteristic,
type: .withoutResponse)
offset = end
}
// Resume from peripheralIsReady(toSendWriteWithoutResponse:)
Pair that with a periodic acknowledged write, every 64 chunks or so. A stalled device then surfaces within a second or two, not after the app has cheerfully streamed the rest of the image into nothing.
Negotiate the MTU, don't assume it
Derive chunk size from maximumWriteValueLength(for: .withoutResponse) instead of hardcoding it. iOS negotiates an MTU that varies by device and by iOS version, and a constant that fits the newest hardware in the office will quietly truncate on an older phone. Read it once after connecting and size every chunk from it.
Design the interrupted case first
The happy path is the easy half. Before writing the transfer loop, decide what the user sees when the update is cut off at sixty percent. Is the device still usable? Does reconnecting resume or restart? What does progress show when the app cannot tell what state the device is in? Those answers shape the protocol, and retrofitting them costs far more than designing them.