The previous post landed the scan-trigger sequence in the order the official app uses: alignScale (0x35) → initDeviceForScan (0x32) → write URL to cloudToken. The next hardware run did exactly that: three labeled writes in the new order, no immediate processorBusy rejection, the protocol path moving as designed. And then it failed on a different scanError that I almost decoded wrong because I was reading hex and thinking decimal.
My visual confirmation was the cleanest signal I’ve gotten yet: red ring flashing without spinning, sustained until the diagnostic surfaced its error. The mirror was telling me something specific. I just had to read it correctly.
The new failure
Step 15’s writes landed in order:
01:15:43.867 Step 1/3: writing Command.alignScale (0x35) to scannerAction01:15:43.910 NOTIFY scanError = 1101:15:43.911 WARN scanError → .noScannerWifi (0x11)01:15:46.182 Step 2/3: writing Command.initDeviceForScan (0x32) to scannerAction01:15:47.272 Step 3/3: writing uploadURL ... to cloudToken01:15:47.330 WARN scanError → .processorBusy (0x5)01:17:19.580 Post-wait scanState = .error (0x9)The mirror responded to alignScale with a scanError notification 43 milliseconds later. The byte was 11. And here is the trap:
- My generic NOTIFY logger prints raw hex without the
0xprefix:NOTIFY scanError = 11 - My scanError-decoding logger prints with the prefix:
scanError → .noScannerWifi (0x11)
The same number, in the same line, in the same log, in different bases. 11 decimal is 0x0B hex and means .scaleAlignment per the APK’s ScanError enum. 0x11 hex is 17 decimal and means .noScannerWifi. Two completely different errors. The notification was hex 0x11 = decimal 17 = noScannerWifi, the seventeenth case in the enum, not the eleventh. The decoding line had it right; the raw line was easy to misread.
This was a real diagnostic confusion in the moment. I’d been chasing scaleAlignment for two iterations because that was what the previous (decimal-11) errors had meant. The new error looked the same on the raw line but was a totally different problem. Hex versus decimal in the same log file was a footgun pointed at my own foot.
The fix is one log-format change: emit either the hex-prefixed decimal or the named enum on every notification line, never just = 11. I haven’t done that yet; calling it out here so future-me writes the format change before reading the next log.
What .noScannerWifi actually means
The mirror needs WiFi configured and connected before it’ll start any scan-related operation. Not just before the eventual upload, before the very first alignScale action. wifiConnectionStatus had been showing .notConnected (byte 4) in every log I’d produced. I’d been treating that as a downstream concern: “the mirror won’t be able to POST without WiFi, but I should at least see scanState progression first.” I was wrong. The mirror does its WiFi check first, in the alignScale code path, before doing anything mechanical.
The official Android app’s flow doesn’t surface this because the app’s OOBE (out-of-box experience) handles WiFi setup before the user ever reaches the scan flow. By the time ScanInitiationPresenter.alignScale() runs, WiFi is either already connected or the user is in a different code path entirely. I’m hitting it because my diagnostic skips OOBE and goes straight to scan-trigger from a mirror that hasn’t been on WiFi since the cloud died.
What the APK says about WiFi setup
NakedV1ConnectToNetworkOperation.java:
public static UUID CHAR_CONFIG_WIFI_SET_SSID_UUID = UUID.fromString("62fd7453-d5a7-4e30-96b8-bedc1651f02a"); public void execute(WifiNetwork wifiNetwork, String password, BLECallback callback) { this.mBleManager.doWriteOperation( CHAR_CONFIG_WIFI_SET_SSID_UUID, String.format("%s/%s/%s", wifiNetwork.getNetworkSSID(), wifiNetwork.getSecurityType(), password ).getBytes(), callback );}The protocol is straightforward: write a UTF-8 string "<SSID>/<securityType>/<password>" to the wifiSetSSID characteristic at 62FD7453-…. The security type values match the integers I already see in step 14’s network listing: that step prints lines like 0/<my-home-wifi>/4/89 where the 4 is the security type (4 = WPA/WPA2 in the format the mirror uses).
After the write, the mirror tries to associate:
sequenceDiagram
participant Phone
participant Mirror
Phone->>Mirror: write "<SSID>/<securityType>/<password>" to wifiSetSSID (62FD7453-…)
Mirror->>Mirror: attempt network association
loop poll every few seconds
Phone->>Mirror: read wifiConnectionStatus (BDC94304-…)
Mirror-->>Phone: CONNECTING (6) / NOT_CONNECTED (4) / ...
end
Mirror-->>Phone: notify wifiConnectionStatus = CONNECTED (1)wifiConnectionStatus (BDC94304-…) transitions through the byte values defined in the APK’s WifiConnectionStatus enum: INVALID(0), CONNECTED(1), CONNECTED_NO_INTERNET(2), CONFIGURED_BAD_AUTH(3), NOT_CONNECTED(4), NOT_CONFIGURED(5), CONNECTING(6). I already subscribe to that characteristic in step 9, so notifications would arrive automatically.
Wiring up an actual WiFi configuration step in the diagnostic is genuinely useful and probably the right shape for the next iteration. It needs UI (or a hardcoded credential constant I edit before running) for the SSID and password. Worth doing carefully rather than rushing it.
What this build does instead
Two minimal changes to step 15, no new step:
Pre-flight WiFi check. Before any writes, read wifiConnectionStatus. If it’s not .connected, abort cleanly with a clear message naming the protocol needed to configure WiFi. This avoids polluting the mirror’s state with writes that will be rejected.
if let wifiChar = chars[MirrorUUIDs.wifiConnectionStatus] { let (wifiData, _) = await readChar(wifiChar) if let wifiByte = wifiData?.first { let wifiName = WifiConnectionStatus(rawValue: wifiByte)?.name ?? "unknown(\(wifiByte))" log(15, .info, "Pre-flight wifiConnectionStatus = .\(wifiName) (0x\(String(wifiByte, radix: 16)))") if wifiByte != WifiConnectionStatus.connected.rawValue { log(15, .error, "Mirror has no WiFi. Configure WiFi first ...") setStatus(15, .fail, detail: "Mirror wifiConnectionStatus = .\(wifiName); needs .connected. No writes attempted.") return } }}Early-fail on terminal scanErrors. If alignScale or initDeviceForScan produces a scanError that won’t resolve by itself (noScannerWifi, noCloudConnectivity, scaleNotDetected), abort the dry-run wait immediately rather than letting the subsequent writes pile up processorBusy notifications. Done is done; no point doing init and cloudToken writes when I already know the alignment can’t happen.
let terminal: Set<UInt8> = [ ScanError.noScannerWifi.rawValue, // 17 (0x11) ScanError.noCloudConnectivity.rawValue, // 18 (0x12) ScanError.scaleNotDetected.rawValue, // 2]if terminal.contains(errByte), !done { done = true // ... clear callbacks, resolve continuation with nil}The fast-fail keeps log noise down. In the previous run I got two scanError notifications back-to-back (noScannerWifi from alignScale, then processorBusy from cloudToken) plus 90 seconds of dead waiting. The new behavior would emit one labeled error and exit immediately.
What I’m testing in the next build
A more honest set of predictions this time:
-
If WiFi isn’t connected, step 15 should fail in a few seconds with a clear “wifiConnectionStatus = .notConnected” message and never touch the action multiplexer. This is what I expect on the very next run before I add WiFi configuration.
-
Once WiFi is configured (out of band: original Naked Android app, mirror’s own UI, or a future diagnostic step), step 15 should at minimum get past alignScale. Whether the mirror reaches
.completethen depends on the scale being physically positioned correctly (still on USB-C charging at the moment) and any remaining preconditions I haven’t discovered. -
The byte-base-trap fix is not in this build. Adding it as a TODO: the generic NOTIFY logger should print every byte as
(name) (0xHH = NN)so a reader can never mistake hex for decimal again.
Takeaways
- Hex versus decimal in the same log file is a footgun. If your raw-byte logger and your decoded-byte logger use different number bases, you will read the same number two different ways and chase the wrong bug. Pick a format and use it everywhere; if you must show both, make the base obvious every time.
- The official client’s flow assumes its OOBE has run. Any preconditions the OOBE handles (WiFi setup, account pairing, cloud authentication) are implicitly required by the scan flow. If you’ve skipped OOBE (either because you’re reverse-engineering or because the cloud the OOBE relied on is dead), every implicit precondition becomes an explicit bug to track down. Read the OOBE flow too, not just the post-OOBE flow you actually want to run.
- Fail fast on protocol-level “won’t resolve by itself” signals. Some scanErrors are transient (the user moved, the lighting flickered, a battery threshold tripped) and waiting through the state machine makes sense. Others (no WiFi, no scale detected, no cloud) won’t resolve without a config change. Build a list of those into your diagnostic and short-circuit the wait. It’s the difference between a 90-second dry-run that ends in a vague timeout and a 5-second dry-run that names the missing precondition.
- The mirror is communicating clearly now. The visual signal (red ring, flashing, sustained until my app reflected the error) was the mirror trying to tell me “I can’t do this, here’s why, please listen.” I just had to actually decode the byte it sent me. Real protocol work is fundamentally about the device speaking and me learning to hear.
The next post is either “I added WiFi configuration to the diagnostic and the mirror started moving” or “I found another precondition I hadn’t considered.” Either way the failure mode is going to be progressively more specific. I’m running out of preconditions to discover.
Follow along
Procedure 5-AThe story above has a hex/decimal misreading baked in. Here’s the clean version if you’re chasing a scanError on your own mirror:
- 1Always print byte values the same way, in every log line. If your raw notification logger and your decoded logger disagree on hex versus decimal, you will misread the exact same number two different ways. Pick one format (I’d recommend
(name) (0xHH = NN dec)) and use it everywhere. - 2Check
wifiConnectionStatusbefore you do anything else. The mirror requires WiFi configured and connected before it will even attemptalignScale, not just before the final upload. Read it as a pre-flight step and abort cleanly with a clear message if it isn’t.connected. - 3Configure WiFi over BLE by writing
"<SSID>/<securityType>/<password>"to thewifiSetSSIDcharacteristic (62FD7453-…), then poll or subscribe towifiConnectionStatus(BDC94304-…) until it reports.connected(1) or a terminal failure like.configuredBadAuth(3). - 4Maintain a list of “won’t resolve by itself” scanErrors (
noScannerWifi,noCloudConnectivity,scaleNotDetected, and similar) and short-circuit any wait as soon as one of them arrives, instead of letting a 90-second timeout run its course.
Agent prompts Procedure 5-A as prompts for a coding agent · 4 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.