The previous post ended with three concrete predictions for the next hardware run. With the stale-timer race fixed in readChar and writeChar, I expected:
- The action write would report success cleanly (no more fake timeouts)
scanStatenotifications would arrive in sequence as the mirror transitioned through its state machine- A clean
scanError = .scaleAlignmentwould surface in the failure detail
The test produced one out of three. The first prediction was right. The other two reveal something about how BLE notifications work that’s worth writing down, and a sequencing bug in my diagnostic that obscured what the mirror was actually doing.
What I got
The relevant slice of the new log:
00:53:41.909 Writing uploadURL (40 bytes / MTU budget 512) to cloudToken: http://<my-ip>:7777/upload/d5602100:53:42.583 Token written. Writing Command.initDeviceForScan (0x32) to scannerAction00:53:42.675 NOTIFY scanError = 1100:53:43.393 Scan initiated. Monitoring scanState notifications and HTTP POSTs for 90s00:55:14.855 ERR No .complete state or POST within 90sBoth writes resolved cleanly via the BLE delegate. cloudToken at 42.583 (write response received), action write at 43.393. No more bogus URLError(.timedOut). The stale-timer fix from the previous post did exactly what it was supposed to.
The NOTIFY scanError = 11 is the same scaleAlignment error from last time, again about 92 ms after the action byte hit the wire. The action multiplexer accepted 0x32, the mirror tried to start its scan, immediately decided the scale wasn’t where it expected, and emitted a real, decoded, application-level error.
But the failure detail said "Timeout: check scanState notifications above". Two problems hiding in that line.
Problem one: I missed the scanError notification
The log line at 42.675 is the generic NOTIFY log emitted by the BLE delegate’s catch-all path. It records the byte, but it doesn’t feed into step 15’s result-aggregation logic. That’s a separate callback (scanErrorNotifCallback), which step 15 sets up only after the writes return.
The order in the previous version of step 15:
if let e = await writeChar(cloudTokenChar, ...) { ... }if let e = await writeChar(actionChar, ...) { ... } // ← scanError fires here, ~92 ms inlog("Scan initiated. Monitoring for 90s")setStatus(15, .running, ...) let result = await withCheckedContinuation { cont in scanStateNotifCallback = { ... } // ← only registered now scanErrorNotifCallback = { ... } // ...}The scaleAlignment notification arrived during the action write, before either callback existed. By the time I registered them, the mirror had already said its piece. The 90-second wait then sat there listening for something that had already happened, and timed out.
The fix is straightforward: register the callbacks first, then issue the writes. Closure bodies of withCheckedContinuation can’t await, so the writes go in their own Task inside the closure, and a small WRITE_FAIL: sentinel string lets the result handler distinguish a write-side failure from a real success or timeout.
Problem two: scanState never transitioned
The bigger insight came from rereading the log even more carefully. Earlier in the same run, during step 13’s harmless-looking write of a test URL, this happened:
00:53:28.826 Writing fake upload URL to cloudToken: http://debug.local:7777/upload/test00:53:28.903 NOTIFY scanState = 0900:53:28.906 NOTIFY scanError = 0500:53:29.623 Write completed without errorDecoded: scanState 9 = .error, scanError 5 = .processorBusy. The mirror entered .error state during step 13, just from me writing a test URL it couldn’t do anything useful with. (The mirror has wifiConnectionStatus = .notConnected, so it has no internet to validate or contact the URL with, probably the source of processorBusy.)
Now look at step 15. After the action byte:
00:53:42.675 NOTIFY scanError = 11 ← only this ← no NOTIFY scanState anywhereA new scanError arrived but no scanState notification. The reason isn’t a missing subscription or a delivery glitch. It’s the BLE specification.
setNotifyValue(true) only delivers a notification when the characteristic value actually changes. The mirror was already in scanState = 9 (.error) from step 13. When 0x32 produced a new error condition (.scaleAlignment), the mirror updated its scanError characteristic to byte 11, that’s a change, so I got a notification. But its scanState characteristic was already at byte 9 and stayed there, so no notification fired.
sequenceDiagram
participant Phone
participant Mirror
Note over Phone,Mirror: Step 13 (minutes earlier)
Phone->>Mirror: write test URL to cloudToken
Mirror-->>Phone: notify scanState = ERROR (9)
Mirror-->>Phone: notify scanError = PROCESSOR_BUSY (5)
Note over Mirror: scanState stays latched at ERROR (9)
Note over Phone,Mirror: Step 15 (new run, same mirror session)
Phone->>Mirror: subscribe to scanState, scanError
Phone->>Mirror: write 0x32 (initDeviceForScan)
Mirror-->>Phone: notify scanError = SCALE_ALIGNMENT (11)
Note over Mirror: scanState unchanged, still ERROR (9): no notify fires
Note over Phone: 90s wait for a scanState transition never resolvesMy 90-second wait was watching for scanState transitions to .complete (8) or to .error (9). The mirror was already at 9 when the wait started. Nothing arrived because nothing changed.
Three implications fall out of this:
- A diagnostic that waits on a notify-only signal can wait forever if the value’s already the value. This is true of any GATT subscriber, not just mine.
- You can’t trust a single subscribe-and-listen pass to tell you “current state.” You need to subscribe AND read at least once, and ideally re-read periodically as a fallback for state machines you don’t fully model.
- Mirror state survives across operations. Step 13’s write put the mirror in a bad mood that step 15 inherited. I need to actively reset before any new attempt, not just hope the mirror went idle on its own.
The fix, three pieces
Pre-flight scanState read with a cancelScan reset. Step 15 now reads scanState before doing anything else and logs it. If the byte isn’t .invalid (0), it sends Command.cancelScan (0x36) to the action multiplexer and re-reads. The cancel is best-effort (if the mirror is wedged beyond what cancelScan can clear, I’ll find out from the re-read), but it costs me a single round-trip and a small Task.sleep, and unblocks the cleanest case.
let (preData, _) = await readChar(scanStateChar)if let preByte = preData?.first { log(15, .info, "Pre-flight scanState = .\(ScanState(rawValue: preByte)?.name ?? "?")") if preByte != ScanState.invalid.rawValue { log(15, .info, "Mirror not in .invalid: sending Command.cancelScan (0x36) to reset state") _ = await writeChar(actionChar, data: Data([MirrorUUIDs.Command.cancelScan.rawValue])) try? await Task.sleep(nanoseconds: 500_000_000) let (recheckData, _) = await readChar(scanStateChar) // log re-read result }}Callbacks registered before writes. This is the sequencing bug. The new shape is:
let result = await withCheckedContinuation { (cont: CheckedContinuation<String?, Never>) in var done = false scanStateNotifCallback = { ... } // ← armed first scanErrorNotifCallback = { ... } postReceivedCallback = { ... } Task { // ...writes here, fire under the protection of the callbacks above if let e = await self.writeChar(cloudTokenChar, ...) { // resolve continuation with WRITE_FAIL: sentinel, return } if let e = await self.writeChar(actionChar, ...) { // ditto } // success path: writes returned cleanly, now I wait } Task { try? await Task.sleep(nanoseconds: 90_000_000_000) // resolve with nil if not already done }}The WRITE_FAIL: sentinel is a small ugliness (String? is too narrow a result type to carry both “succeeded with this label” and “failed with this error” cleanly), but it’s localized to the result-handler block and beats refactoring the continuation into a typed enum result.
Post-wait scanState read. Even after both fixes, the notification path only sees state changes. If the mirror lands in a state and stays there, I still want to know what state it ended up in. After the 90-second wait (or whichever exit condition fires first), I read scanState one more time and log it. The fail-detail string now includes both the last scanError and the post-wait scanState, plus a sawAnyScanState flag indicating whether the notify path fired even once.
What I’m testing in the next build
Concrete predictions, again, knowing they may be wrong again:
-
The pre-flight scanState read will tell me where the mirror starts. If it’s
.invalid(0), the mirror was clean when I connected: the previous error states were just within-run accumulation. If it’s.error(9) or anything else, the mirror persists state across BLE connections, which is interesting in its own right. -
The cancelScan re-read will tell me whether
0x36actually resets state. If the mirror went from.errorto.invalid, I have a working reset primitive. If it didn’t budge, I’m going to needCommand.reboot(0x33) or a power cycle between attempts. -
Failure detail will name the actual error. If the mirror still reports
.scaleAlignmentafter0x32, the diagnostic will sayscanError=.scaleAlignment, scanState=.<whatever>; sawAnyScanState=<bool>. That’s three orthogonal data points in one line, more than enough to figure out what’s blocking me.
The remaining problems are physical and configurational, not protocol:
- The mirror needs WiFi.
wifiConnectionStatushas been.notConnectedfor the entire diagnostic series. The mirror has been failing every cloudToken validation because there’s no network for it to use the URL on. The diagnostic could write a wifi config via thewifiSetSSIDcharacteristic, but I haven’t added a step for it yet. - The scale needs to be on the floor in the cameras’ field of view, not USB-C-cabled to the mirror for charging. As long as it’s docked, every attempt is going to surface
scaleAlignment.
Neither is a thing the code can fix from here.
Takeaways
- GATT notifications only fire on value change. If you’re waiting on a notify-only signal and the characteristic is already at the value you’re waiting for, you’ll wait forever. Subscribe AND read. Periodically re-read for state machines you don’t fully model.
- Register your handlers before you trigger the thing they handle. This sounds obvious. It is obvious. I still got it wrong because the natural code shape is “do the work, then wait for results,” and I’d put the result-handler setup inside the wait. The mirror responded faster than I set up to listen.
- Peripheral state survives across your interactions. A failed write earlier in your session can leave the device in a state your next operation can’t escape just by issuing a new command. Build “reset to clean state” into the start of every important operation, not just the end of failed ones.
- The catch-all log path saves you. The generic
NOTIFY <name> = <hex>line in my delegate is what let me go back and find the scaleAlignment error after the fact. Without it, I’d have known that step 15 failed but not what the mirror tried to tell me 92 ms in. If you build a BLE diagnostic, log every single notification by default. You can always filter later.
The next log will tell me whether the mirror’s bad-mood-state is something I can clear from BLE, or whether I need a power cycle between every attempt.
Follow along
Procedure 3-AThe story above has one dead end in it (the timeout detail that pointed the wrong way). Here’s the clean sequence if you’re seeing a BLE operation “time out” against a device that’s actually responding:
- 1Register your notification callbacks before you issue the write that might trigger them, not after. If the response can arrive in under a hundred milliseconds, “do the work, then start listening” is already too late.
- 2Don’t trust a notify-only subscription to tell you the current state.
setNotifyValue(true)only delivers when the value changes. Explicitly read the characteristic in addition to subscribing, especially right before a wait that depends on it. - 3Read the state you care about before you act on it. A pre-flight read of a status characteristic tells you whether the peripheral is already in a state your new operation can’t escape from.
- 4If the pre-flight read shows a bad state, try an explicit reset command and re-read to confirm it worked, rather than assuming your new write will clear old state on its own.
- 5Log every raw notification by default, even the ones you’re not specifically watching for in a given step. It’s the only reason you’ll be able to reconstruct, after the fact, what the device actually told you.
- 6After any timed wait, do one final explicit read of the thing you were waiting on, so you know where it landed even if no notification ever fired.
Agent prompts Procedure 3-A as prompts for a coding agent · 6 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.