In the last post I shortened the upload URL I was writing to the mirror’s cloudToken characteristic, hit Run All again, and expected to finally see step 15 trigger a real scan. Step 15 failed. Differently this time, more interestingly, and with a small surprise that turned out to invalidate the previous post’s headline diagnosis.
Three things were going on at once. Working through them one by one is a useful walkthrough of how to read a BLE diagnostic log when nothing is what it claims to be.
The puzzle
The relevant slice of the log:
00:41:42.976 Writing uploadURL (40 bytes / MTU budget 512) to cloudToken: http://<my-ip>:7777/upload/d5530200:41:43.689 Token written. Writing Command.initDeviceForScan (0x32) to scannerAction00:41:43.752 NOTIFY scanError = 1100:41:45.644 ERR initDeviceForScan action write failed: The operation couldn't be completed. (NSURLErrorDomain error -1001.)The error text is the localized form of URLError(.timedOut). The writeChar path in my diagnostic looks like this:
private func writeChar(_ char: CBCharacteristic, data: Data) async -> Error? { return await withCheckedContinuation { (cont: CheckedContinuation<Error?, Never>) in pendingWriteContinuation = cont peripheral?.writeValue(data, for: char, type: .withResponse) Task { try? await Task.sleep(nanoseconds: 10_000_000_000) if let c = self.pendingWriteContinuation { self.pendingWriteContinuation = nil c.resume(returning: URLError(.timedOut)) } } }}So the URLError(.timedOut) is something I made. It only ever fires from inside that 10-second Task.sleep. The action write started at 43.689 and the error landed at 45.644, 1.95 seconds later. The 10-second sleep can’t possibly have elapsed.
What did?
Surprise 1: the MTU theory was wrong
Before I get to the timeout puzzle, look at the first line of the log slice:
Writing uploadURL (40 bytes / MTU budget 512) to cloudTokenThat MTU budget 512 is a new diagnostic line I’d added in step 4, straight from peripheral.maximumWriteValueLength(for: .withResponse). The previous post had assumed the mirror was tied to a tight ATT MTU. With a 512-byte budget, that theory falls apart.
CBATTError.invalidAttributeValueLength doesn’t only come from iOS’ local transport check. The same error code comes back from a peripheral that rejects the write at the application layer, and the mirror’s cloudToken characteristic does exactly that. Try to write more than ~40-50 bytes and the firmware says no, regardless of how much MTU room you have at the radio.
So the previous post had the right fix (use a shorter scan ID) for the wrong reason. Worth knowing for next time you stare at this error: it can come from either side, and “I just have to negotiate a bigger MTU” might not help.
That’s correction one. The other two are bigger.
Surprise 2: the scan trigger actually worked
Look at the line right after the action write:
00:41:43.752 NOTIFY scanError = 11scanError byte 11, decoded against the APK’s ScanError enum:
INVALID(0), NO_ERROR(1), SCALE_NOT_DETECTED(2), SCALE_NOT_LEVEL(3),MIRROR_NOT_LEVEL(4), PROCESSOR_BUSY(5), INTERNAL_ERROR(6), ...SCALE_ALIGNMENT(11), ...SCALE_ALIGNMENT. The mirror is saying “I tried to start scanning. The scale isn’t where I expect it to be.”
That notification fires 63 milliseconds after I wrote 0x32 to the action multiplexer. So the mirror received the action byte, decoded it as initDeviceForScan, started its scan state machine, took a fast look at the scale’s physical position, decided it wasn’t aligned, and emitted the error. That is an entire successful round-trip of the protocol path I’ve been working toward for weeks.
I noticed this independently too: a red flashing ring this time, a different visual indicator from the blue pairing-style flash I’d seen before. Red is the mirror’s “I can’t do what you asked” indicator. The fact that the visual changed in response to my action byte is hardware-level confirmation that the mirror heard me.
So the diagnosis “step 15 failed” is wrong in two ways now. The protocol succeeded, the mirror produced a meaningful, decodable, application-level error, and my diagnostic still claimed the underlying BLE write itself had timed out. Why?
Surprise 3: the bug was in my diagnostic
This is the actual cause of the bogus error.
writeChar (and its sibling readChar) used a buggy timeout pattern: every call kicks off an unstructured Task that sleeps for 10 seconds and then checks if there’s still a pending continuation to resume with URLError(.timedOut). Nothing cancels that sleep. The Task runs to completion regardless of whether the write resolved cleanly via the BLE delegate.
That’s fine if calls are spaced more than 10 seconds apart. They aren’t.
Step 14 (the wifi-list flow) starts with a write of 0x31 to the same scannerAction characteristic, at 00:41:35.200. That call’s timeout is scheduled to fire at roughly 00:41:45.200. Step 14 then reads wifiNumNetworks and loops 17 reads of wifiNetworkSSIDEnum, every one of those scheduling its own 10-second timeout, before completing at 00:41:36.404.
Then I look at the dialog, tap “Confirm Scan” 6 seconds later, and step 15 runs. The cloudToken write at 42.976 succeeds, sets pendingWriteContinuation to a fresh continuation, schedules its own timeout for 52.976, and resolves cleanly via the delegate at 43.689. Now pendingWriteContinuation is briefly nil. Then the action write starts immediately after and sets pendingWriteContinuation to a new fresh continuation, scheduled for 53.689.
Here’s the race, drawn out:
sequenceDiagram
participant S14 as Step 14 (0x31 write)
participant Slot as pendingWriteContinuation
participant S15 as Step 15 (action write)
S14->>Slot: set continuation, schedule timeout for +10s
Note over Slot: step 14 resolves cleanly via delegate
S15->>Slot: set NEW continuation (action write in flight)
Note over S14: step 14's 10s timer finally wakes at ~45.2
S14->>Slot: "still pending?" yes, resume with timedOut
Slot-->>S15: receives bogus URLError(.timedOut)
Note over S15: real delegate callback arrives, finds slot nil, no-opsAt approximately 45.2, give or take Task scheduling drift, the timeout from step 14’s original 0x31 write finally wakes up. It does its check:
if let c = self.pendingWriteContinuation { self.pendingWriteContinuation = nil c.resume(returning: URLError(.timedOut))}pendingWriteContinuation is non-nil. It belongs to step 15’s action write, which is still in flight, perfectly healthy, waiting for its own delegate callback that’s about to come. The stale timer doesn’t know any of that. It picks up the slot, resolves it with URLError(.timedOut), sets the slot to nil, and exits.
Step 15 awaits the result. Sees URLError(.timedOut). Logs "action write failed: timeout". Marks step 15 failed.
The actual delegate callback for the action write probably fired correctly a moment later, found pendingWriteContinuation was nil, and silently no-op’d. I never even saw it land.
A continuation slot reused as a single mutable global, with timers that close over an implicit “is this still mine?” check, is the same bug pattern as a use-after-free in C. Whoever last wrote the slot wins; whoever else is referencing it loses.
The fix
Make the timeout Task something I can cancel, hold onto a reference, and cancel it the moment the delegate actually resolves the operation:
private var pendingWriteContinuation: CheckedContinuation<Error?, Never>?private var pendingWriteTimeoutTask: Task<Void, Never>? private func writeChar(_ char: CBCharacteristic, data: Data) async -> Error? { return await withCheckedContinuation { (cont: ...) in pendingWriteTimeoutTask?.cancel() // defensive: kill any in-flight pendingWriteTimeoutTask = nil // ... .withoutResponse fast path elided ... pendingWriteContinuation = cont peripheral?.writeValue(data, for: char, type: .withResponse) pendingWriteTimeoutTask = Task { try? await Task.sleep(nanoseconds: 10_000_000_000) guard !Task.isCancelled else { return } if let c = self.pendingWriteContinuation { self.pendingWriteContinuation = nil self.pendingWriteTimeoutTask = nil c.resume(returning: URLError(.timedOut)) } } }} nonisolated func peripheral(_ p: CBPeripheral, didWriteValueFor c: CBCharacteristic, error: Error?) { Task { @MainActor in pendingWriteTimeoutTask?.cancel() pendingWriteTimeoutTask = nil if let cont = pendingWriteContinuation { pendingWriteContinuation = nil cont.resume(returning: error) } }}Same change to readChar and didUpdateValueFor. The guard !Task.isCancelled else { return } after the sleep means a cancelled timer becomes a no-op when it eventually wakes up. try? await Task.sleep swallows the cancellation as a thrown error, but the surrounding code still runs, so I have to check explicitly.
The other one-shot continuations on the engine (bleStateContinuation, connectContinuation, servicesContinuation, charsContinuation, notifyStateContinuation) have the same pattern but each is used at most once per Run All sequence, so they can’t poison anything. I left them alone for now.
Bonus: actually decode scanError
Since I now know the mirror will send me decodable scanError bytes when something goes wrong, I wired the 04285A29-… characteristic’s notifications through a new scanErrorNotifCallback in step 15, parallel to the existing scanState callback. Added a ScanError enum to MirrorUUIDs.swift matching the APK’s Java enum byte-for-byte. Step 15’s failure detail now includes the last scanError seen, so the next time the mirror complains about scaleAlignment I get a log line like:
scanError → .scaleAlignment (0xb)scanState → .errorMirror reached scanState=.error with last scanError = .scaleAlignmentinstead of the misleading “action write failed: timeout.”
What I’m testing in the next build
Three things should change in the next log:
-
The action write to
scannerActionshould report success. With the stale timer killed, the delegate’s resolve will be the only thing that closes out the continuation. I expect “Token written. Writing Command.initDeviceForScan (0x32) to scannerAction” followed by nothing, until the mirror actually does something. -
scanStatenotifications should arrive in sequence. The real APK enum’s progression isINVALID(0) → ALIGN_SCALE(1) → WARMING_UP(2) → WAITING_FOR_WEIGHT(3) → COUNT_DOWN(4) → PRE_CHECK(5) → SCANNING(6) → POST_CHECK(7) → COMPLETE(8). I don’t expect to reachCOMPLETE. The scale is currently attached to the mirror via USB-C for charging, so it’s almost certainly still misaligned for scanning purposes. But I should see the mirror at least begin transitioning. Anything beyondINVALIDis progress. -
A
scanError = .scaleAlignmentnotification should land cleanly. Not as a fake timeout. The diagnostic’s failure detail will name the actual error.
If I see those three things, the BLE protocol path is fully verified end-to-end. The only remaining work to actually trigger a successful scan is physical: get the scale off the USB-C cable, put it on the floor in the mirror’s field of view, get on it. That’s not a debugging problem anymore, it’s a user manual problem.
If the mirror reaches COMPLETE and POSTs to my local listener, the next thing I find out is the architectural question I’ve been carrying since day one: does it send a finished result ZIP (a PLY mesh + measurements JSON), or does it dump raw depth data and expect the phone to do the body reconstruction? One observation answers it.
Takeaways
- Cryptic Foundation errors in your own code might be your own fault.
URLError(.timedOut)showing up before your timer should have fired is a hint that someone else’s timer is firing, most often a stale one. Continuation slots used as mutable globals are the classic pattern. - Cancel your timers when the work resolves. Unstructured
Taskis fine for fire-and-forget but if it’s gating something, give it a handle and cancel it on success.Task.cancel()plusguard !Task.isCancelledafter the sleep is a five-line idiom that prevents this entire class of bug. CBATTError.invalidAttributeValueLengthis two errors with the same code. Local rejection (you exceeded MTU) and remote rejection (peripheral rejected the value at the application layer) surface the same way. If yourperipheral.maximumWriteValueLength(.withResponse)is comfortably above your payload, look at the peripheral’s per-characteristic limits.- Read your own log. The line
NOTIFY scanError = 11was sitting in the log file the entire time, telling me the mirror had heard me and tried to scan. I was so focused on the failed write that I almost missed the protocol-level success three lines above.
Follow along
Procedure 2-AThe story above has two false alarms in it. Here’s the clean version if you’re debugging the same symptoms on your own mirror:
- 1Give every BLE read/write timeout a cancelable handle. Keep the
Taskin a property (pendingWriteTimeoutTask), not a fire-and-forget closure. - 2Cancel that timeout inside the delegate (
didWriteValueFor/didUpdateValueFor) the moment the operation resolves, and addguard !Task.isCancelled else { return }after the sleep so a late wake-up is a no-op. - 3Never share one continuation slot across overlapping operations without an owner check. If calls can land less than your timeout apart, a stale timer will resolve the wrong operation.
- 4Subscribe to and decode
scanError(characteristic04285A29-…) against the APK’sScanErrorenum, so an application-level rejection shows up as.scaleAlignmentinstead of a fake transport timeout. - 5Remember
invalidAttributeValueLengthcan be server-side. If your write budget is comfortably large, the peripheral’s per-characteristic limit is the thing to check, not the MTU.
Agent prompts Procedure 2-A as prompts for a coding agent · 5 prompts
Paste into Claude Code or a similar agent, one at a time, from the root of your iOS project. Each prompt maps to the step with the same number above.