The previous post added a pre-flight WiFi check to step 15. The next hardware run did exactly what it predicted: pre-flight reads ran, the diagnostic named the missing precondition, and step 15 failed in 14 seconds with no writes attempted. The fast-fail worked. Battery was at 7% (up from 5% the run before). Forward motion.
But buried in the same log is a line that’s been there for the last six runs and that I’d been ignoring. It’s the reason the mirror was in .error state every time the dry-run ran, and it had nothing to do with the dry-run itself.
The line I’d been ignoring
01:23:38.843 NOTIFY scanState = 0901:23:38.857 NOTIFY scanError = 05Decimal, not hex this time: bytes 9 and 5, which decode to .error and .processorBusy. These notifications fired at 01:23:38. Step 15 didn’t run until 01:23:55, seventeen seconds later. Whatever put the mirror into .error happened well before the dry-run ever started.
Looking at what was happening at 01:23:38:
01:23:38.783 Writing fake upload URL to cloudToken: http://debug.local:7777/upload/test01:23:38.843 NOTIFY scanState = 0901:23:38.857 NOTIFY scanError = 0501:23:39.053 Write completed without errorThat’s step 13. Specifically, it’s the fake URL write step 13 has been doing for the entire history of this diagnostic. The original intent: prove the BLE write path to the cloudToken characteristic actually works, with a harmless test URL. The reality: every single time, in 60 milliseconds, the mirror responds with .error / .processorBusy and stays wedged there for the rest of the run.
I diagnosed this exact behavior in post 04: writing cloudToken with no scan in progress is invalid per the scan flow the official Android app uses. A cloudToken write only makes sense after alignScale → initDeviceForScan has put the mirror in the right state to receive an upload destination. I fixed step 15 to do those in the correct order. I did not fix step 13, which writes cloudToken with no init at all, before any of the action multiplexer commands have been sent.
sequenceDiagram
participant Phone
participant Mirror
Note over Phone,Mirror: Step 13 (seconds into the run)
Phone->>Mirror: write fake test URL to cloudToken
Mirror-->>Phone: notify scanState = ERROR (9)
Mirror-->>Phone: notify scanError = PROCESSOR_BUSY (5)
Note over Mirror: mirror stays wedged in ERROR
Note over Phone,Mirror: Step 15 (seventeen seconds later)
Phone->>Mirror: pre-flight WiFi check (aborts: not connected)
Note over Phone: failure correctly blamed on missing WiFi...
Note over Mirror: ...but the mirror was already broken by step 13So step 13 was producing the exact failure mode post 04 explained. I just hadn’t connected the dots. I’d been treating the .error state as something the mirror does on its own when I connect, not as something I caused fifteen seconds earlier.
I hadn’t been doing anything wrong. The diagnostic was sabotaging itself.
Why it took six runs to notice
Honestly, because the test step was working as designed. Step 13’s purpose was to verify that the BLE-level write to cloudToken succeeded: peripheral.writeValue(...) with .withResponse, get a didWriteValueFor callback, no error. That part worked every time. Write completed without error. Pass. ✓
The application-layer rejection (scanState = .error, scanError = .processorBusy) was happening on a different characteristic via different notifications. It showed up in the global log as two NOTIFY lines that I attributed to “the mirror is in some idle housekeeping state.” It only became obvious when post 03’s “notifications only fire on change” insight pointed out that the mirror was staying in .error across operations, and post 04’s APK re-read showed why writing cloudToken first triggers it.
The pieces for diagnosis had been in the log for sessions. The connection between them required the cumulative context of three earlier posts. Real debugging, especially of state machines you didn’t design, is often this shape: the bug is visible immediately but takes you weeks to actually see.
Replacing step 13
Step 13’s slot is more useful as a WiFi configuration step than as a redundant write test. Step 16 already exercises the cloudToken write path properly, as the third operation in the alignScale → init → cloudToken sequence. So I lose nothing by removing the standalone test, and I gain the WiFi configuration the mirror has been asking for since post 05.
The protocol from NakedV1ConnectToNetworkOperation.java:
this.mBleManager.doWriteOperation( CHAR_CONFIG_WIFI_SET_SSID_UUID, // 62FD7453-… String.format("%s/%s/%s", ssid, securityType, password).getBytes(), callback);A UTF-8 string with three slash-separated fields. securityType is the same integer the mirror uses in its own network listing (step 14’s index/SSID/security/signal format: 4 for WPA/WPA2, 1 for open, 3 for WEP).
The new step 13 has three branches:
- Constants empty (default). Skipped. No write, no state change. Fixes the
.errorpollution problem that was the immediate motivation. - Already on WiFi. Read
wifiConnectionStatus, see byte 1 (.connected), short-circuit with a Pass. - Need to configure. Write the credential payload, then poll
wifiConnectionStatusevery 2 seconds for up to 30 seconds, watching for.connected(byte 1) or.configuredBadAuth(byte 3, surface that explicitly rather than as a generic timeout).
Constants live at the top of DebugChecklistView.swift:
private let TEST_WIFI_SSID: String = "" // e.g. "<my-home-wifi>"private let TEST_WIFI_SECURITY: String = "4" // 4 = WPA/WPA2 by defaultprivate let TEST_WIFI_PASSWORD: String = "" // your wifi passwordYes, hardcoded credentials in source. Yes, the right answer is a SwiftUI sheet that prompts for them at runtime. No, that’s not what shipped today: the immediate goal is to stop poisoning the mirror’s state on every run. The credential UI is the next iteration.
Side fix: the byte-base trap, addressed
Post 05 noted that the generic NOTIFY logger printed bytes as raw hex (no prefix), while the step-15-decoded log used (0x...). Same digit string, two different bases, easy to mistake 0x11 = noScannerWifi for decimal 11 = scaleAlignment. The generic log now formats single-byte payloads as:
NOTIFY scanError = 11 (0x11 = 17 dec)Hex blob plus the explicit double-base annotation. Multi-byte payloads stay as hex-only since their decimal interpretation isn’t meaningful. Future-me will never be able to mistake a base again, at the cost of fourteen extra characters per single-byte notification.
What I’m testing in the next build
Two scenarios depending on what I do between runs:
-
No constants filled in. Step 13 is skipped (cleanly, no write). The
.error/.processorBusyNOTIFY lines from the previous step 13 should no longer appear: the mirror’s scanState should stay at.invalid(byte 0) through step 14. Step 15 still aborts at the WiFi precondition check, but the failure detail will accurately describe the runtime state instead of inheriting pollution from step 13. -
Constants filled in. Step 13 writes the credential payload, polls
wifiConnectionStatusevery 2 seconds, and either reaches.connected(byte 1) or fails with a specific cause (.configuredBadAuthfor wrong password, or 30s timeout for unreachable network). If.connected, step 15’s pre-flight check will pass for the first time, and the alignScale → init → cloudToken sequence will run end-to-end. I’ll find out what the mirror does when its preconditions are actually met.
Either log will be informative.
Takeaways
- A diagnostic that mutates state poisons its own subsequent steps. Step 13’s fake-URL write was useful as a one-shot smoke test of the BLE write path, and harmful as a recurring side effect that landed before every real test. Read-only steps (read characteristic, list services, etc.) are safe to repeat. Write steps need to be either read-back idempotent or removed once the path they prove is exercised by a later real-use step.
- The application-layer error that landed sixty milliseconds after a write is a response to that write. It was easy to read the
NOTIFY scanState = 09 / scanError = 05lines as ambient state because they didn’t say “in response to: cloudToken”, but the timestamps make it unambiguous. Whenever a notification arrives within ~100 ms of a write, your write almost certainly caused it. - Cumulative context is what makes a debugging arc work. Post 03 told me notifications fire on change. Post 04 told me why cloudToken writes fail without prior init. Post 05 told me the alignScale precondition. None of those individually would have caught step 13. Together they made it obvious. The narrative-style commit messages and posts are turning out to actually pay for themselves: every fix I’ve shipped recently has cited a finding from two or three iterations back. If the messages were terse, those connections wouldn’t survive long enough to be seen.
- When a “test step” has been passing for six sessions and the system has been failing for six sessions, look at the test step. The thing reporting success isn’t necessarily the thing actually working.
If WiFi configures cleanly on the next run, the next post is “what the mirror does when I finally meet all its preconditions.” If the credentials don’t work, the next post is “what .configuredBadAuth actually means in practice and how to verify the encoding.” Either is forward progress for the first time in a few sessions.
Follow along
Procedure 6-AThe story above is really a lesson about a self-inflicted wound. Here’s the clean version if you’re setting up your own diagnostic:
- 1Don’t leave a state-mutating “smoke test” step in a sequential checklist once you’ve proven the thing it tests. A write that succeeds at the BLE layer can still trigger an application-layer rejection that poisons every later step in the same run.
- 2Configure WiFi over BLE instead of writing a throwaway test URL. Write
"<SSID>/<securityType>/<password>"to thewifiSetSSIDcharacteristic (62FD7453-…), then pollwifiConnectionStatusfor.connected(1) or a specific failure like.configuredBadAuth(3). - 3Make every byte-logging line unambiguous about its base. Print single-byte notification payloads as
(name) (0xHH = NN dec)everywhere, not hex in one log path and decimal in another. - 4When something that’s been “passing” for many runs sits next to something that’s been failing for the same many runs, suspect the passing thing. A step that reports success at one layer can be quietly breaking a different layer.
- 5Trace any notification within about 100 ms of a write back to that write. At BLE timescales, that’s not a coincidence, it’s a response.
Agent prompts Procedure 6-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.