i work on pocket. it’s a small hardware device, and the phone app updates its firmware over bluetooth. the update has been through three versions. the first two overcorrected each other. classic.
the basic shape: the app chops the firmware image into small frames (244 bytes these days), writes them to the device one by one, then sends APP&OT&OVER to say “that’s everything”. the whole story is about one question: how does the phone know the device is keeping up.
era 1: ack every frame
every frame was written with response. the phone sends a frame, the device acks it, the phone sends the next one.
for (final frame in frames) {
await writer.write(frame, withoutResponse: false); // wait for the ack. every. single. time.
}
super reliable. also an update took 4-5 minutes. a full round trip per frame adds up fast. nobody wants to stare at a progress bar for 5 minutes, so the acks had to go.
era 2: ack nothing, sleep a bit
next version: write everything without response and put a small sleep after each frame so the device doesn’t drown.
for (final frame in frames) {
await writer.write(frame, withoutResponse: true);
await delay(target == wifi ? 5.ms : 6.ms); // trust me bro
}
await writer.write('APP&OT&OVER');
5ms for the wifi chip, 6ms for the main mcu. no comment, no explanation. magic numbers doing load-bearing work.
this is where it got flaky. i benched it from our cli tool on the desk, same image every run (868,864 bytes), fixed 5ms between frames.
3 runs, 3 failures. every one died right at the end: the device answers APP&OT&OVER with an error because the byte count it received doesn’t match the size we announced. so frames went missing somewhere along the way, and nobody notices until the very last step.
a fixed sleep is just a guess about how fast the other side is. the other side is writing to an sd card, and sd cards don’t care about your guess.
era 3: ack once in a while
so era 1 was too careful and era 2 wasn’t careful enough. the fix is in between.
the useful bit is on the firmware side. the device writes each ota frame to the sd card inside the gatt write handler, before it returns. so when a frame is sent with response, the ack only comes back once that frame is on the card. writes arrive in order, so it also means every frame before it is on the card.
so you don’t need to ack every frame like era 1. send a bunch without response and no sleep, then one with response, and wait for it. the device sets the pace instead of my magic number.
static const int ackEveryFrames = 32;
for (var i = 0; i < frames.length; i++) {
final acked = (i + 1) % ackEveryFrames == 0 || i == frames.length - 1;
if (acked) {
await writer
.write(frames[i], withoutResponse: false)
.timeout(const Duration(seconds: 10)); // fail loudly, with the frame number
} else {
await writer.write(frames[i], withoutResponse: true); // no sleep
}
}
await writer.write('APP&OT&OVER');
the last frame is always acked too, so APP&OT&OVER only goes out once everything is actually on the card.
why 32
same bench, same image:
| pacing | result |
|---|---|
| fixed 5ms sleep (era 2) | 3/3 failed, frames lost |
| ack every 16 | ok, 27.8 s, 30.5 KB/s |
| ack every 32 | ok, 22.3 s, 38.0 KB/s on the mcu; 21.8 s, 40.0 KB/s for the wifi chip image relayed through the mcu |
| ack every 64 | failed |
64 falls over, so it looks like the device starts dropping frames when too many unacked ones are in flight. 32 works and it’s the fastest of the ones that worked. it’s a named constant now with a comment saying exactly this, so the next person doesn’t bump it to 64 “for speed”.
about 22 seconds for the whole image. era 1 was 4-5 minutes. turns out most of those round trips were just ceremony.
the timeout
if an acked write doesn’t come back in 10 seconds, the update fails with something like:
Timed out waiting for the device to acknowledge frame 32 of 40 (bytes 7564-7808 of ...)
in era 2, a stall looked like a byte-count error at the very end. now it says where it stopped, which is the difference between debugging and guessing.
misc
- same change covers the mcu and the wifi-chip-over-ble path. the wifi socket update path didn’t change.
- the bench numbers above are from the cli tool, not the app. the app does the same thing, but i don’t have app-side timings to brag about yet.
tl;dr ack everything is slow, ack nothing is a gamble. ack every 32nd frame and let the device tell you when it’s ready.