The previous post added three things to step 15: callbacks register before writes, an auto-cancelScan to clear wedged .error state, and a post-wait scanState read for diagnostic visibility. The auto-cancelScan turned out to be the wrong call. And while diagnosing why, going back to the decompiled APK revealed that I’d had the entire scan-trigger sequence in the wrong order from the start.
The cancelScan surprise
The relevant slice of the new log:
01:03:30.678 Pre-flight scanState = .error (0x9)01:03:30.678 Mirror not in .invalid: sending Command.cancelScan (0x36) to reset state01:03:32.542 WARN Peripheral disconnected (error: The specified device has disconnected from us.)01:03:43.542 Writing uploadURL ... to cloudToken01:04:04.383 ERR Pre-wait write failure: cloudToken: ...timedOutThe mirror processed Command.cancelScan (0x36) by dropping the BLE link entirely about 1.86 seconds later. I watched the matching visual: red ring spun for a couple of seconds and then turned off. That’s not a scan-cancel-and-go-idle behavior. That’s a “soft reset that takes the BLE stack down with it.”
So cancelScan is more drastic than the name suggests, at least when fired from .error state. I don’t know yet whether cancelScan always disconnects or only does so from certain states; I just know that “send it as a defensive reset before any other action” is the wrong heuristic. The diagnostic now reads pre-flight scanState and logs it but doesn’t try to clear it. If the mirror is wedged, the fix is simple: power-cycle the mirror, then re-run.
Fast-fail for the disconnected case
The other ugly part of the log was the 21-second gap between the disconnect and the eventual cloudToken error. The breakdown:
01:03:32.542: peripheral disconnects01:03:43.542: recheckreadChar(scanStateChar)finally times out (~10 s after it was issued, against a peripheral that’s been disconnected the whole time)01:04:04.383: the post-waitreadCharadds another 10 s, surfaces in the result handler
iOS’ peripheral.readValue and peripheral.writeValue on a disconnected CBPeripheral are silent no-ops. The call returns immediately, but didUpdateValueFor and didWriteValueFor never fire. The system has nothing to deliver from. Without explicit checks, every subsequent BLE op pays the full 10 s of my own diagnostic-side timeout, and they stack.
The fix is one early guard:
private func readChar(_ char: CBCharacteristic) async -> (Data?, Error?) { guard peripheral?.state == .connected else { return (nil, NSError(domain: "DebugChecklistView", code: 1, userInfo: [NSLocalizedDescriptionKey: "Peripheral not connected (state=\(peripheral?.state.rawValue ?? -1))"])) } // ... existing read logic}Same pattern in writeChar. Net effect: a step-15 attempt against a disconnected peripheral now fails in milliseconds instead of 30+ seconds. Whatever the mirror is doing, the diagnostic stops bleeding wall-clock time on its absence.
The bigger question
Both fixes were reactive: I found a bug, I patched the symptom. But there was still a question I hadn’t answered. Why does the mirror enter .error state in the first place?
Look back at the very first line where it appeared, in step 13’s harmless-looking write of the test URL:
01:03:21.699 Writing fake upload URL to cloudToken: http://debug.local:7777/upload/test01:03:21.736 NOTIFY scanState = 09 (= .error)01:03:21.739 NOTIFY scanError = 05 (= .processorBusy)01:03:22.097 Write completed without errorThe mirror entered .error from .processorBusy within 37 milliseconds of the cloudToken write hitting the wire. The BLE write itself succeeded. .error is the application-layer response, not a transport failure. The mirror is saying “you wrote to cloudToken when I’m not in a state where that makes sense, so I’m refusing.”
This had been happening every single hardware run. I’d been treating it as a quirk to work around (adding cancelScan calls, watching for the error state, avoiding double writes). Nobody asked why the mirror was rejecting the write in the first place.
Time to actually read what the official app does.
Back to the APK
Inside ScanInitiationPresenter.java, the user-facing scan trigger flows like this:
// User taps "Start Scan" → initScan() runspublic void initScan() { if (mNakedBleSDK.isConnected()) { mNakedBleSDK.initDevice(new NakedBleSDKCallback<Object, BLESdkError>() { public void onSuccess(Object obj) { getUserToken(); // ← only AFTER initDevice succeeds } // ... }); }} private void getUserToken() { // fetch token from cloud, then: sendTokenToDevice(userToken);} private void sendTokenToDevice(String str) { if (mNakedBleSDK.isConnected()) { mNakedBleSDK.sendScanTokenToDevice(str, ...); // ← cloudToken write }}And earlier, before initScan ever gets called, the activity dispatches to ScaleAlignFragment with this:
public void initScaleAlignFragment() { // ... ((ScanInitiationPresenter) mPresenter).alignScale(); // ← writes 0x35 startFragmentFromRight(ScaleAlignFragment.newInstance());}So the actual real-world sequence the official app uses is:
alignScale(): writes0x35to the scannerAction multiplexer. The mirror physically aligns the scale.- (UI shows the user a “put your phone down” prompt and waits for them to comply)
initDevice(): writes0x32(initDeviceForScan).- On init success, the app fetches a per-user token from the cloud.
sendTokenToDevice(): writes the URL string to thecloudTokencharacteristic (884811AA-…).- The mirror walks
scanStatefromALIGN_SCALE → WARMING_UP → … → COMPLETEand POSTs to the URL.
sequenceDiagram
participant Phone
participant Mirror
participant Scale
Phone->>Mirror: write 0x35 to scannerAction (alignScale)
Mirror->>Scale: attempt physical alignment
Note over Phone: UI prompts user, waits for compliance
Phone->>Mirror: write 0x32 to scannerAction (initDeviceForScan)
Note over Phone: fetch per-user token from cloud
Phone->>Mirror: write upload URL to cloudToken (884811AA)
Mirror-->>Phone: notify scanState (ALIGN_SCALE → WARMING_UP → … → COMPLETE)
Mirror->>Phone: HTTP POST scan data (over WiFi)I had been doing step 5 first, then step 3, and skipping steps 1, 2, and 4 entirely. The mirror’s processorBusy rejection was the protocol-level equivalent of “you can’t set an upload destination for a scan you haven’t started.” Of course it was rejecting me. The surprise is that I ever expected anything else.
This also explains the scaleAlignment scanError I saw on the 0x32 writes in earlier runs. The mirror genuinely couldn’t align the scale, partly because the scale was physically docked on USB-C charging instead of on the floor, but also because I never sent the alignScale command to ask it to try. The mirror was doing pre-scan validation: scale exists? Yes. Scale aligned? No, because nobody asked me to align it. Reject scan with scaleAlignment.
What I’m testing in the next build
Reorder step 15 to follow the official sequence:
- Write
Command.alignScale(0x35) first. The mirror should respond by attempting physical alignment of the scale (visible: the turntable rotates). I don’t have a clean signal for “alignment complete” beyond watching the scanState progression, so I’ll wait briefly and proceed. - Write
Command.initDeviceForScan(0x32) second. With alignment done, the mirror should accept the init and start its scan state machine. I expect ascanState = ALIGN_SCALE (1)notification or onward. - Write the upload URL to
cloudTokenthird. This is the equivalent ofsendTokenToDevicein the official flow. The mirror now has an active scan to associate the URL with, so the write should be accepted without surfacingprocessorBusy. - Continue watching
scanStateforCOMPLETE(8), and the local listener for the POST.
Whether this gets me to .complete depends on the same physical/configurational blockers I’ve been carrying: the scale needs to be on the floor in the cameras’ field of view, and the mirror needs WiFi for the eventual POST. But it should at least get the mirror to start the state machine instead of rejecting my first write outright. If I see scanState walk past WARMING_UP, I’ll know the sequence is correct and I’m just waiting to physically set things up.
Takeaways
- Read the official client’s flow before designing your own. I had every byte and every UUID right. I just sequenced them wrong because I built the diagnostic intuitively (set the destination, then trigger the action) instead of looking at how the official app actually does it (trigger the action, then set the destination). One file, one method, ten minutes of reading would have prevented every one of the
processorBusyerrors I’d been chasing. - A consistent application-layer rejection is the protocol telling you something. The mirror entered
.errorimmediately on every cloudToken write. I treated that as state-management noise to work around for four sessions. It was a clear signal that the operation was invalid in the current state. When a peripheral keeps rejecting an operation in exactly the same way, the operation is wrong, not the timing. - “Cancel” and “reset to clean state” are not the same operation.
cancelScanreads like a soft reset because that’s what “cancel” means in most APIs. On this device, from.errorstate, it means “I’ll drop the link entirely while I figure things out.” Reading the API name and assuming the semantics is exactly the kind of shortcut that wastes a session and a hardware run. - Fast-fail when the precondition is already broken. Twenty seconds of dead time per failed step adds up across a debugging session.
peripheral?.state == .connectedis one early guard that saves four read/write timeouts per attempt. Worth it everywhere, not just at the top of high-level flows.
Next post will be either “I successfully started a scan” or “the alignScale-first sequence revealed a new failure mode.” Either way the next log will be the most informative one yet.
Follow along
Procedure 4-AThe story above has one wrong assumption baked into it (the auto-reset) that the next section fixes. Here’s the clean sequence if you’re triggering a scan on your own mirror:
- 1Don’t write the upload URL first. Writing
cloudTokenbefore the mirror has an active scan gets rejected at the application layer withscanState = .error,scanError = .processorBusy, even though the BLE write itself succeeds. - 2Send
Command.alignScale(0x35) first. This matches the official app’s real sequence and gives the mirror a reason to attempt scale alignment before anything else happens. - 3Send
Command.initDeviceForScan(0x32) second, once alignment has had a moment to settle. - 4Write the upload URL to
cloudTokenthird. With an active scan in progress, the mirror has somewhere to put it. - 5Don’t use
cancelScan(0x36) as a general-purpose reset. From.errorstate it drops the BLE connection entirely rather than clearing the error. If the mirror is wedged, power-cycle it instead. - 6Guard every BLE read/write on
peripheral?.state == .connected. A disconnected peripheral silently no-opsreadValue/writeValue, so without the guard you pay a full timeout per call instead of failing in milliseconds.
Agent prompts Procedure 4-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.