The previous post was the audit. This post is the patch. v0.5.0 takes the findings from Ref/SCAN_WORKFLOW.md and applies them to stepDryRunScan, the function that’s been failing in the same way for every hardware run since I got to step 13. No new architecture. No new instrumentation. Just doing what the Android client has been doing all along, in the right order.
The shape of the fix
Five concrete corrections, all driven by the audit:
1. DeviceState gate. A new preflightDeviceState() method that reads mirrorExternalState (82B0745A) before any scan write. The byte is decoded as the DeviceState enum (INVALID=0, IDLE=1, BUSY=2, ACTIVE=3, UPDATING=4, STARTING_UP=5, SHUTTING_DOWN=6, NOT_CONFIGURED=7, CRITICAL_BATTERY=8, ERROR=9) and the function bails with a specific failure message for every value that isn’t IDLE or INVALID. On ERROR it also reads mirrorError to surface what kind of error: noWifi, noInternet, levelIncorrect, internalError. This is the gate the Android app’s ScanInitiationPresenter.checkMirrorState runs before alignScale. I hadn’t been running it; the mirror was rejecting alignScale at this layer and I was attributing the failure to the wrong characteristic.
sequenceDiagram
participant Phone
participant Mirror
Phone->>Mirror: READ mirrorExternalState (82B0745A)
Mirror-->>Phone: DeviceState byte
alt IDLE or INVALID
Phone->>Mirror: WRITE alignScale (proceed)
else BUSY / ERROR / NOT_CONFIGURED / ...
Phone->>Mirror: READ mirrorError (if ERROR)
Note over Phone: bail with specific cause, no alignScale write
endThe most informative thing this change buys me isn’t a working scan, it’s finally knowing what byte the mirror’s external state is the moment I try to start a scan. I’d literally never read this char during step 15 before. v0.5.0’s next hardware log will show it on the first line of step 15. Whichever of the ten possible values comes back, I’ll have a specific actionable cause where before I had scanState -> .error with no explanation.
2. Sentinel reclassification. Per APK ScanInitiationPresenter.onScanErrorReceived (line 1019-1040), ScanError.INVALID (0) is the reset sentinel and ScanError.NO_ERROR (1) is the explicit no-error condition. The diagnostic was logging both at WARN and producing detail lines like "Mirror surfaced scanError = .noError", which read as a contradiction. The callback now logs sentinel values at INFO with an explicit , sentinel suffix, and the failure-determination logic distinguishes “scanState reached .error with only a sentinel scanError” from “scanState reached .error with a real scanError code.” The first case is now surfaced as “device-state rejection, re-run pre-flight” rather than misleadingly attributing it to a non-error error.
3. Cancellable writes Task. The Task { ... } that issues the three writes (alignScale -> init -> scanToken) now lives in a captured writesTask: Task<Void, Never>?. Every callback that resumes the continuation early (terminal scanError, .complete, .error, POST received, 90-second timeout) calls writesTask?.cancel(). The Task body checks Task.isCancelled between operations and bails. This means when .error lands 75 ms after alignScale, the subsequent initDeviceForScan and scanToken writes don’t fire, which had been generating the cascade of .processorBusy notifications I kept seeing 1.5 seconds later in every failed log.
4. cloudToken renaming. Not the symbol, the symbol stays cloudToken because that’s how it’s been referred to in the protocol notes for months and a wholesale rename would break a lot of cross-references. But every log line and the friendlyNames entry now read scanToken instead of uploadURL / cloud URL write. The Step 3/3 log line carries an explicit note: “APK semantic: opaque auth token, NOT a redirect URL. Mirror’s upload destination is hardcoded to api.nakedfit.net (see Ref/SCAN_WORKFLOW.md, Scenario C).” Future logs will be self-correcting on this point.
5. Post-wait scanState read removed. The “Post-wait scanState = .scanning (0x6)” line that’s been showing up in failure logs, directly after a .error notification, no less, was a BLE-read race. The read was initiated while the notification was still landing, returning a pre-.error value. The notifications carry the state transitions directly; the post-wait read was redundant and actively misleading. Gone. If I need a post-mortem state read, I re-run the diagnostic on a fresh app launch (now with the auto-started listener, that’s a 5-second turnaround).
Also dropped: a pre-flight scanState .error warning that suggested power-cycling. The DeviceState gate now handles this case directly. If the mirror is in .error at the device level, I surface that specifically, not at the scan-state level.
What I expect to see
On the next hardware run with v0.5.0:
- Most likely: the new pre-flight line
Pre-flight mirrorExternalState = .<something> (0x<byte>)will tell me specifically what’s blocking. If.error, I’ll also see themirrorErrorbyte (.noWifi/.noInternet/.levelIncorrect/.internalError). If.notConfigured, I know to fix WiFi first. If.busy, I wait or power-cycle. If.idleor.invalid, alignScale gets to actually try, and if it still produces a real scanError (.scaleNotDetected,.scaleNotLevel,.lightingConditions), I’ll see that now without the cloudToken-write-cascade noise drowning it out. - Most desired:
DeviceState = .idle,alignScalesucceeds,scanStatewalks through.alignScale -> .warmingUp -> .waitingForWeight, and I discover what the next unknown is. (Scale physical position? User-not-on-scale handling? Something else?) - Most informative: the diagnostic stops doing things I already know to be useless. No more init+scanToken writes after the mirror’s already said no. No more “Post-wait scanState = .scanning” lies. No more “scanError = .noError” treated as a failure.
What’s not in this commit
A few items deliberately deferred:
-
Replacing the 1-second sleep with
scaleConnectionState -> .disconnectedwaiting. The APK transitions between alignScale and initScan on the scale’s BLE link briefly dropping (the scale is physically spinning, its antenna moves). My fixed sleep is fragile. But implementing the event-driven version requires plumbing a new callback through the existing structure, and the DeviceState gate is the priority finding. If the gate makesalignScalesucceed, I’ll see whether the 1-second sleep is actually too short or too long in practice and design the fix from real data instead of speculation. Marked as a TODO in the code. -
A real auth token. The Step 3/3 write still puts a local URL in the scanToken char because I don’t have a valid token from
api.nakedfit.net(the cloud is dead, and even if it weren’t, I don’t have an account that ever provisioned this mirror). The mirror will treat my URL string as a token, hand it to whatever firmware uses it during upload, and the upload will probably fail at the auth layer. But I won’t reach the upload until I get past the DeviceState gate, and even then I’ll need a network intercept setup, which is a separate track. The write is preserved so the byte exchange happens; the log line is honest about what it is. -
DNS override / network intercept setup. Different track. The intercept is downstream of
scanStateactually reaching.complete. Until alignScale works, there’s nothing to intercept.
What I learned
- The right layer to read is sometimes one above the right answer. The protocol byte values I’d nailed (alignScale = 0x35, init = 0x32, scanState enum, scanError enum) were all correct. The thing I missed was the control-flow layer, when to issue each byte. The Android app’s preconditions weren’t in the per-operation BLE classes; they were in the activity-level orchestration that decides when to call the operations. Reading one layer up surfaced the precondition I’d been missing for weeks.
- Logging WARN-level on every notification looks like signal until you read the enum docs. “Mirror surfaced scanError = .noError” was technically true but semantically empty. The APK explicitly comments on which values are sentinels; once you read the source, the WARN-level treatment looks like noise that obscures the actual signal (the real scanError values that would have followed if the precondition had passed).
- Concurrency-cancellation in Swift is cheap and pays off immediately. Three lines (
var writesTask,Task.isCancelledchecks,writesTask?.cancel()in callbacks) eliminate an entire class of state pollution. The previous behavior was writing init and scanToken into a known-rejected mirror because the write Task didn’t know the dry-run had already failed. The fix is mechanical and the resulting log is dramatically cleaner.
What’s next
Whatever the next log tells me. The point of v0.5.0 isn’t to make the scan work, it’s to learn the real reason the scan isn’t working, with a diagnostic that no longer obscures the answer behind misleading log lines and downstream cascade noise. The DeviceState byte from the next hardware run is the next branch point.
Follow along
Procedure 12-AThis post is a mid-stream patch, so the clean version is the five corrections above, applied in this order if you’re carrying the same bug forward on your own mirror:
- 1Read
mirrorExternalState(82B0745A) before any scan write, decoded asDeviceState. Bail with a specific message unless the value isIDLEorINVALID. OnERROR, also readmirrorError. - 2Treat
ScanError.INVALID (0)andScanError.NO_ERROR (1)as sentinels, logged atINFO, not as failures logged atWARN. - 3Make your write sequence cancellable. Hold the
Taskthat issuesalignScale -> init -> scanTokenin a property, and cancel it from every terminal callback (error, complete, timeout) so a rejected mirror doesn’t get three more writes it already said no to. - 4Name the characteristic for what the APK says it is. Log it as
scanToken(an opaque auth token), notuploadURL, even if the underlying symbol name stayscloudTokenfor continuity with earlier notes. - 5Delete any post-notification state read that isn’t driven by the notification itself. If a
.errornotification already told you the state, a follow-up read is a race, not a confirmation.
Agent prompts Procedure 12-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.