Squirreleye LLC Squirreleye Service Notes Naked Labs Mirror · Revival Series Index Tools About
12
Section 12 · follows 11

The precondition gate I wasn't checking

Written
2026-05-16
Log
NakedMirrorDebug/logs/current.txt

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.

§ 12.1

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.

Fig. 12-1
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
    end

The 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.

§ 12.2

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 the mirrorError byte (.noWifi / .noInternet / .levelIncorrect / .internalError). If .notConfigured, I know to fix WiFi first. If .busy, I wait or power-cycle. If .idle or .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, alignScale succeeds, scanState walks 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.
§ 12.3

What’s not in this commit

A few items deliberately deferred:

  • Replacing the 1-second sleep with scaleConnectionState -> .disconnected waiting. 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 makes alignScale succeed, 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 scanState actually reaching .complete. Until alignScale works, there’s nothing to intercept.

§ 12.4

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.isCancelled checks, 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.
§ 12.5

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.

§ 12.6
§ 12.7
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.

1
Add the DeviceState pre-flight gate
Add a preflightDeviceState() method to NakedMirrorDebug/DebugChecklistView.swift that reads mirrorExternalState (MirrorUUIDs.mirrorExternalState, 82B0745A) before stepDryRunScan issues any scan write. Decode the byte with 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 bail with a value-specific failure detail for anything other than IDLE or INVALID. When the value is ERROR, also read mirrorError (25E29F6F) and decode it (noWifi, noInternet, levelIncorrect, internalError) into the failure message.
2
Reclassify scanError sentinels
In stepDryRunScan's scanError notification callback, change the log level for ScanError.INVALID (0) and ScanError.NO_ERROR (1) from WARN to INFO, and append a ", sentinel" suffix to their log lines. Update the failure-determination logic so that scanState reaching .error with only a sentinel scanError is reported as "device-state rejection, re-run pre-flight" rather than being attributed to a misleadingly named non-error scanError value.
3
Make the write sequence cancellable
Refactor stepDryRunScan's alignScale -> initDeviceForScan -> scanToken write sequence to run inside a single Task stored in a captured writesTask: Task<Void, Never>? property. From every terminal callback (a terminal scanError, scanState reaching .complete or .error, the POST-received callback, and the 90-second timeout), call writesTask?.cancel(), and have the Task body check Task.isCancelled between each of the three writes so a mirror that already rejected the sequence doesn't receive the remaining writes.
4
Relabel cloudToken as scanToken in logs
Without renaming the cloudToken symbol itself, update every log line and the MirrorUUIDs.friendlyNames entry for MirrorUUIDs.cloudToken so the displayed name reads scanToken, and add a log note on the write step stating the APK semantic: an opaque per-scan auth token, not a redirect URL, with the mirror's upload destination hardcoded to api.nakedfit.net (cross-reference Ref/SCAN_WORKFLOW.md's Scenario C).
5
Remove the post-wait state race
Find the post-wait scanState read in stepDryRunScan that fires immediately after a scanState notification has already arrived (the one producing the misleading "Post-wait scanState = .scanning" log line right after a .error notification). Delete that read entirely — rely on the notification callback as the sole source of state transitions — and remove the pre-flight warning that previously suggested power-cycling on scanState .error, since the new DeviceState gate (preflightDeviceState) now handles that case specifically.