Squirreleye LLC Squirreleye Service Notes Naked Labs Mirror · Revival Series Index Tools About
03
Section 03 · follows 02

Notifications only fire on change, and the mirror was already broken

Written
2026-05-03
Commit
9347e8c
Log
nakedmirror-debug-20260503-185555.txt

The previous post ended with three concrete predictions for the next hardware run. With the stale-timer race fixed in readChar and writeChar, I expected:

  1. The action write would report success cleanly (no more fake timeouts)
  2. scanState notifications would arrive in sequence as the mirror transitioned through its state machine
  3. A clean scanError = .scaleAlignment would surface in the failure detail

The test produced one out of three. The first prediction was right. The other two reveal something about how BLE notifications work that’s worth writing down, and a sequencing bug in my diagnostic that obscured what the mirror was actually doing.

§ 3.1

What I got

The relevant slice of the new log:

00:53:41.909 Writing uploadURL (40 bytes / MTU budget 512) to cloudToken: http://<my-ip>:7777/upload/d5602100:53:42.583 Token written. Writing Command.initDeviceForScan (0x32) to scannerAction00:53:42.675 NOTIFY scanError = 1100:53:43.393 Scan initiated. Monitoring scanState notifications and HTTP POSTs for 90s00:55:14.855 ERR No .complete state or POST within 90s

Both writes resolved cleanly via the BLE delegate. cloudToken at 42.583 (write response received), action write at 43.393. No more bogus URLError(.timedOut). The stale-timer fix from the previous post did exactly what it was supposed to.

The NOTIFY scanError = 11 is the same scaleAlignment error from last time, again about 92 ms after the action byte hit the wire. The action multiplexer accepted 0x32, the mirror tried to start its scan, immediately decided the scale wasn’t where it expected, and emitted a real, decoded, application-level error.

But the failure detail said "Timeout: check scanState notifications above". Two problems hiding in that line.

§ 3.2

Problem one: I missed the scanError notification

The log line at 42.675 is the generic NOTIFY log emitted by the BLE delegate’s catch-all path. It records the byte, but it doesn’t feed into step 15’s result-aggregation logic. That’s a separate callback (scanErrorNotifCallback), which step 15 sets up only after the writes return.

The order in the previous version of step 15:

Fig. 3-1
swift
if let e = await writeChar(cloudTokenChar, ...) { ... }if let e = await writeChar(actionChar, ...) { ... } // ← scanError fires here, ~92 ms inlog("Scan initiated. Monitoring for 90s")setStatus(15, .running, ...) let result = await withCheckedContinuation { cont in scanStateNotifCallback = { ... } // ← only registered now scanErrorNotifCallback = { ... } // ...}

The scaleAlignment notification arrived during the action write, before either callback existed. By the time I registered them, the mirror had already said its piece. The 90-second wait then sat there listening for something that had already happened, and timed out.

The fix is straightforward: register the callbacks first, then issue the writes. Closure bodies of withCheckedContinuation can’t await, so the writes go in their own Task inside the closure, and a small WRITE_FAIL: sentinel string lets the result handler distinguish a write-side failure from a real success or timeout.

§ 3.3

Problem two: scanState never transitioned

The bigger insight came from rereading the log even more carefully. Earlier in the same run, during step 13’s harmless-looking write of a test URL, this happened:

00:53:28.826 Writing fake upload URL to cloudToken: http://debug.local:7777/upload/test00:53:28.903 NOTIFY scanState = 0900:53:28.906 NOTIFY scanError = 0500:53:29.623 Write completed without error

Decoded: scanState 9 = .error, scanError 5 = .processorBusy. The mirror entered .error state during step 13, just from me writing a test URL it couldn’t do anything useful with. (The mirror has wifiConnectionStatus = .notConnected, so it has no internet to validate or contact the URL with, probably the source of processorBusy.)

Now look at step 15. After the action byte:

00:53:42.675 NOTIFY scanError = 11 ← only this ← no NOTIFY scanState anywhere

A new scanError arrived but no scanState notification. The reason isn’t a missing subscription or a delivery glitch. It’s the BLE specification.

setNotifyValue(true) only delivers a notification when the characteristic value actually changes. The mirror was already in scanState = 9 (.error) from step 13. When 0x32 produced a new error condition (.scaleAlignment), the mirror updated its scanError characteristic to byte 11, that’s a change, so I got a notification. But its scanState characteristic was already at byte 9 and stayed there, so no notification fired.

Fig. 3-2
sequenceDiagram
    participant Phone
    participant Mirror
    Note over Phone,Mirror: Step 13 (minutes earlier)
    Phone->>Mirror: write test URL to cloudToken
    Mirror-->>Phone: notify scanState = ERROR (9)
    Mirror-->>Phone: notify scanError = PROCESSOR_BUSY (5)
    Note over Mirror: scanState stays latched at ERROR (9)
    Note over Phone,Mirror: Step 15 (new run, same mirror session)
    Phone->>Mirror: subscribe to scanState, scanError
    Phone->>Mirror: write 0x32 (initDeviceForScan)
    Mirror-->>Phone: notify scanError = SCALE_ALIGNMENT (11)
    Note over Mirror: scanState unchanged, still ERROR (9): no notify fires
    Note over Phone: 90s wait for a scanState transition never resolves

My 90-second wait was watching for scanState transitions to .complete (8) or to .error (9). The mirror was already at 9 when the wait started. Nothing arrived because nothing changed.

Three implications fall out of this:

  • A diagnostic that waits on a notify-only signal can wait forever if the value’s already the value. This is true of any GATT subscriber, not just mine.
  • You can’t trust a single subscribe-and-listen pass to tell you “current state.” You need to subscribe AND read at least once, and ideally re-read periodically as a fallback for state machines you don’t fully model.
  • Mirror state survives across operations. Step 13’s write put the mirror in a bad mood that step 15 inherited. I need to actively reset before any new attempt, not just hope the mirror went idle on its own.
§ 3.4

The fix, three pieces

Pre-flight scanState read with a cancelScan reset. Step 15 now reads scanState before doing anything else and logs it. If the byte isn’t .invalid (0), it sends Command.cancelScan (0x36) to the action multiplexer and re-reads. The cancel is best-effort (if the mirror is wedged beyond what cancelScan can clear, I’ll find out from the re-read), but it costs me a single round-trip and a small Task.sleep, and unblocks the cleanest case.

Fig. 3-3
swift
let (preData, _) = await readChar(scanStateChar)if let preByte = preData?.first { log(15, .info, "Pre-flight scanState = .\(ScanState(rawValue: preByte)?.name ?? "?")") if preByte != ScanState.invalid.rawValue { log(15, .info, "Mirror not in .invalid: sending Command.cancelScan (0x36) to reset state") _ = await writeChar(actionChar, data: Data([MirrorUUIDs.Command.cancelScan.rawValue])) try? await Task.sleep(nanoseconds: 500_000_000) let (recheckData, _) = await readChar(scanStateChar) // log re-read result }}

Callbacks registered before writes. This is the sequencing bug. The new shape is:

Fig. 3-4
swift
let result = await withCheckedContinuation { (cont: CheckedContinuation<String?, Never>) in var done = false scanStateNotifCallback = { ... } // ← armed first scanErrorNotifCallback = { ... } postReceivedCallback = { ... } Task { // ...writes here, fire under the protection of the callbacks above if let e = await self.writeChar(cloudTokenChar, ...) { // resolve continuation with WRITE_FAIL: sentinel, return } if let e = await self.writeChar(actionChar, ...) { // ditto } // success path: writes returned cleanly, now I wait } Task { try? await Task.sleep(nanoseconds: 90_000_000_000) // resolve with nil if not already done }}

The WRITE_FAIL: sentinel is a small ugliness (String? is too narrow a result type to carry both “succeeded with this label” and “failed with this error” cleanly), but it’s localized to the result-handler block and beats refactoring the continuation into a typed enum result.

Post-wait scanState read. Even after both fixes, the notification path only sees state changes. If the mirror lands in a state and stays there, I still want to know what state it ended up in. After the 90-second wait (or whichever exit condition fires first), I read scanState one more time and log it. The fail-detail string now includes both the last scanError and the post-wait scanState, plus a sawAnyScanState flag indicating whether the notify path fired even once.

§ 3.5

What I’m testing in the next build

Concrete predictions, again, knowing they may be wrong again:

  1. The pre-flight scanState read will tell me where the mirror starts. If it’s .invalid (0), the mirror was clean when I connected: the previous error states were just within-run accumulation. If it’s .error (9) or anything else, the mirror persists state across BLE connections, which is interesting in its own right.

  2. The cancelScan re-read will tell me whether 0x36 actually resets state. If the mirror went from .error to .invalid, I have a working reset primitive. If it didn’t budge, I’m going to need Command.reboot (0x33) or a power cycle between attempts.

  3. Failure detail will name the actual error. If the mirror still reports .scaleAlignment after 0x32, the diagnostic will say scanError=.scaleAlignment, scanState=.<whatever>; sawAnyScanState=<bool>. That’s three orthogonal data points in one line, more than enough to figure out what’s blocking me.

The remaining problems are physical and configurational, not protocol:

  • The mirror needs WiFi. wifiConnectionStatus has been .notConnected for the entire diagnostic series. The mirror has been failing every cloudToken validation because there’s no network for it to use the URL on. The diagnostic could write a wifi config via the wifiSetSSID characteristic, but I haven’t added a step for it yet.
  • The scale needs to be on the floor in the cameras’ field of view, not USB-C-cabled to the mirror for charging. As long as it’s docked, every attempt is going to surface scaleAlignment.

Neither is a thing the code can fix from here.

§ 3.6

Takeaways

  • GATT notifications only fire on value change. If you’re waiting on a notify-only signal and the characteristic is already at the value you’re waiting for, you’ll wait forever. Subscribe AND read. Periodically re-read for state machines you don’t fully model.
  • Register your handlers before you trigger the thing they handle. This sounds obvious. It is obvious. I still got it wrong because the natural code shape is “do the work, then wait for results,” and I’d put the result-handler setup inside the wait. The mirror responded faster than I set up to listen.
  • Peripheral state survives across your interactions. A failed write earlier in your session can leave the device in a state your next operation can’t escape just by issuing a new command. Build “reset to clean state” into the start of every important operation, not just the end of failed ones.
  • The catch-all log path saves you. The generic NOTIFY <name> = <hex> line in my delegate is what let me go back and find the scaleAlignment error after the fact. Without it, I’d have known that step 15 failed but not what the mirror tried to tell me 92 ms in. If you build a BLE diagnostic, log every single notification by default. You can always filter later.

The next log will tell me whether the mirror’s bad-mood-state is something I can clear from BLE, or whether I need a power cycle between every attempt.

§ 3.7
§ 3.8
Agent prompts Procedure 3-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.

1
Register callbacks before writes
In step 15's scan-trigger logic, restructure the code so that scanStateNotifCallback, scanErrorNotifCallback, and postReceivedCallback are all assigned inside the withCheckedContinuation closure before any writeChar call is issued, not after. Move the actual cloudToken and scannerAction writes into a nested Task inside that same closure so they execute only once the callbacks are armed. Use a sentinel string like "WRITE_FAIL:" to let the continuation's result handler distinguish a write failure from a real notification-driven success or timeout.
2
Read plus subscribe for state
Audit every place the BLE engine subscribes to a notify characteristic (setNotifyValue(true)) expecting to learn the peripheral's current state. For each one, add an explicit readChar call for that same characteristic immediately after subscribing, since CoreBluetooth only delivers a notification when the characteristic's value changes, not on subscribe. Log the read result alongside the subscription so "current state at subscribe time" is always known, not inferred from the absence or presence of a notification.
3
Pre-flight scanState read
At the start of step 15, before writing anything, call readChar on the scanState characteristic and log the result as "Pre-flight scanState = .<name>". Use this value to decide whether the mirror is already in a non-idle state (anything other than .invalid) before proceeding with the alignScale/initDeviceForScan/cloudToken sequence.
4
Reset-and-reread on bad state
If the pre-flight scanState read in step 15 shows anything other than .invalid (0), write Command.cancelScan (0x36) to the scannerAction characteristic, wait roughly 500ms with Task.sleep, then re-read scanState and log the result. Do not assume the reset worked; just record what the re-read shows so later analysis can tell whether cancelScan actually cleared the error state.
5
Log every raw notification
In the BLE delegate's peripheral(_:didUpdateValueFor:error:) implementation, add (or confirm) a catch-all log line that fires for every notification regardless of which characteristic it's on, printing the characteristic's friendly name and the raw byte value, before any characteristic-specific callback logic runs. This generic NOTIFY log must never be gated behind a specific step being active, so it captures notifications that arrive before any step-specific callback is registered.
6
Final read after timed wait
After any timed wait on a notification (for example the 90-second wait in step 15 for scanState to reach .complete), add one explicit readChar call for the characteristic being waited on, after the wait resolves or times out, and include its value in the failure/success detail string. Also track and report a boolean like sawAnyScanState indicating whether the notify callback fired at least once during the wait, so the log distinguishes "no notification occurred" from "notification occurred but didn't reach the target state".