Squirreleye LLC Squirreleye Service Notes Naked Labs Mirror · Revival Series Index Tools About
16
Section 16 · follows 15

Forty seconds of the scan FSM, and where to mock the cloud

Written
2026-05-17
Log
scan-attempts/2026-05-17-0408/

The previous two posts covered getting root and correcting hypotheses. This one is the first empirical scan attempt informed by the corrected understanding, and for forty seconds, the mirror actually ran a scan. I caught both sides of the protocol exchange. The failure happened at exactly the right place to be useful, and now I know what the mock cloud has to do.

§ 16.1

The fourteen steps that had never all passed before

My iPhone diagnostic app has 16 steps. Steps 1-12 cover discovery, connection, GATT, notifications, the local listener. Step 13 is the WiFi connected gate, 14 is fetching the SSID list, 15 is the end-to-end dry-run, 16 is post-scan confirmation. For the past several weeks the project had been stuck on step 15, every run produced mirrorExternalState = .error / mirrorError = .noInternet, the precondition gate slammed shut, and alignScale was rejected at the device-state layer before the scan state machine could even engage.

Tonight, with the corrected understanding that mirror_error is NO_ERROR in steady state (it only transitions to NO_INTERNET for ~3 seconds during boot), and with the mirror up for 2+ hours and well-settled, steps 1-14 all came back green. The pre-flight reads for step 15 were clean:

04:08:37.765 Pre-flight mirrorExternalState = .idle (0x1)04:08:37.825 Pre-flight scanState = .invalid (0x0)04:08:37.885 Pre-flight scanError = .invalid (0x0, sentinel, not an error)04:08:37.945 Pre-flight wifiConnectionStatus = .connected (0x1)

That’s the first time the diagnostic ever got that far without something turning red. The DeviceState=.idle byte was the one I’d been chasing for six weeks, and it just sat there waiting for me as soon as the mirror had time to actually settle.

§ 16.2

The first forty seconds of an actual scan attempt

The dry-run wrote alignScale (0x35) to the scannerAction characteristic, and for the first time in the project’s history the mirror replied with anything other than “go away.” Here’s the synchronized timeline from both sides, the iPhone debug app and the coordinator’s syslog on the mirror itself, lined up by timestamp:

TimeiPhone sideMirror side
04:08:37.960Writes alignScale (0x35)Coordinator: “Trying to align scale”
04:08:37.974-DeviceState -> BUSY, LED ring -> align pattern
04:08:38.005NOTIFY scanError = NO_ERROR-
04:08:38.802-“We have enabled our scale weight detection”
04:08:38.815NOTIFY scanState = ALIGN_SCALEscanState set to ALIGN_SCALE
04:08:39.881Writes initDeviceForScan (0x32)“Initiated a new scan”, “waiting for user token!”
04:08:39.925Writes scanToken (40 bytes, local listener URL)Coordinator receives token
04:08:40.934-“We are beginning processing our scan!”
04:08:42.355NOTIFY scanState = WARMING_UPscanState set to WARMING_UP
04:08:43.336-Sets DeviceState -> BUSY for scan
04:08:43.375NOTIFY scanState = WAITING_FOR_WEIGHTLED ring -> step-up pattern
(32 seconds of nothing)
04:09:17.370-“ERROR: Unable to create scan with error 1”
04:09:17.371-“We lost cloud connectivity and are in the middle of a scan!”
04:09:17.395NOTIFY scanState = ERRORscanState = ERROR
04:09:17.440NOTIFY scanError = NO_CLOUD_CONNECTIVITY (0x12)scanError = NO_CLOUD_CONNECTIVITY

The same exchange, as a sequence:

Fig. 16-1
sequenceDiagram
    participant Phone as iPhone (app)
    participant Mirror
    participant Cloud as api.nakedfit.net (dead)
    Phone->>Mirror: WRITE alignScale (0x35)
    Mirror->>Mirror: DeviceState -> BUSY, LED -> align pattern
    Mirror-->>Phone: NOTIFY scanError = NO_ERROR
    Mirror-->>Phone: NOTIFY scanState = ALIGN_SCALE
    Phone->>Mirror: WRITE initDeviceForScan (0x32)
    Phone->>Mirror: WRITE scanToken (40 bytes, local listener URL)
    Mirror-->>Phone: NOTIFY scanState = WARMING_UP
    Mirror-->>Phone: NOTIFY scanState = WAITING_FOR_WEIGHT
    Mirror->>Cloud: POST create-scan (BLE token as auth)
    Note over Mirror,Cloud: 32 seconds of silence
    Cloud--xMirror: no response (dead)
    Mirror-->>Phone: NOTIFY scanState = ERROR
    Mirror-->>Phone: NOTIFY scanError = NO_CLOUD_CONNECTIVITY (0x12)

And the state machine itself, as far as I’ve now actually observed it move:

Fig. 16-2
stateDiagram-v2
    [*] --> INVALID
    INVALID --> ALIGN_SCALE: alignScale write, DeviceState idle
    ALIGN_SCALE --> WARMING_UP: scale aligned
    WARMING_UP --> WAITING_FOR_WEIGHT: init + scanToken written
    WAITING_FOR_WEIGHT --> ERROR: cloud create-scan POST times out (~32s)
    WAITING_FOR_WEIGHT --> COUNT_DOWN: (not yet observed, needs cloud response)
    COUNT_DOWN --> PRE_CHECK: (not yet observed)
    PRE_CHECK --> SCANNING: (not yet observed)
    SCANNING --> POST_CHECK: (not yet observed)
    POST_CHECK --> COMPLETE: (not yet observed)

The mirror physically did things. The laser fired. The LED ring went to align pattern. The scale’s weight detection enabled. Then the scan transitioned through ALIGN_SCALE -> WARMING_UP -> WAITING_FOR_WEIGHT and sat there for thirty-two seconds waiting on a thing that wasn’t going to come. The thing it was waiting on was a cloud API response.

I was watching the LED ring on the mirror’s face the whole time. It changed patterns. The scan was happening. The mirror was doing exactly what it would have done in 2022 with a working cloud, except now the cloud is dead and my network monitor is blocking all the mirror’s outbound traffic to confirm that fact at the network layer.

§ 16.3

The finding that decides my next several weeks of work

The line that matters most is at 04:09:17 on the mirror side:

ERROR: Unable to create scan with error 1

paired with

We lost cloud connectivity and are in the middle of a scan!

The coordinator makes an HTTP call to the cloud between receiving the BLE scan token and proceeding past WAITING_FOR_WEIGHT. The endpoint is approximately POST api.nakedfit.net/<something> carrying the user token from BLE as authentication. The response, when the cloud was alive, contained the scan’s actual upload destination: an S3 presigned URL or a scan-specific ID that gets used in step two of the upload. Without a response, the coordinator times out after thirty-some seconds, sets the scanError byte to NO_CLOUD_CONNECTIVITY (18 / 0x12), transitions scanState to ERROR, and bails.

This confirms a two-stage upload architecture I’d been hypothesizing but couldn’t confirm without a real scan attempt to look at:

Fig. 16-3
sequenceDiagram
    participant Phone as iPhone
    participant Mirror
    participant Cloud as api.nakedfit.net
    participant S3 as s3.amazonaws.com
    Phone->>Mirror: BLE scanToken (user auth token)
    Note over Mirror,Cloud: Stage 1
    Mirror->>Cloud: POST create-scan (BLE token as auth)
    Cloud-->>Mirror: { scan_id, upload_url (presigned) }
    Note over Mirror: physical scan completes
    Note over Mirror,S3: Stage 2
    Mirror->>S3: PUT capture files to upload_url

The BLE scanToken characteristic carries the user auth token that authorizes stage 1. The response from stage 1 then determines where the actual scan data goes in stage 2. I had been writing a URL to the BLE characteristic; the mirror was ignoring it because that field is for a token, not a redirect. The mirror’s upload destination is always whatever stage 1 returns.

This is the single most actionable finding from tonight. I now know what the mock cloud has to do, in priority order:

  1. Respond to the stage-1 “create scan” POST with something parseable that doesn’t time out. Even a stub response that the coordinator accepts will move me past WAITING_FOR_WEIGHT for the first time. The next failure beyond that will tell me the next thing to build.
  2. Accept the stage-2 PUT (the actual scan payload). Whatever that PUT contains is the answer to the long-open question about the upload format: finished ZIP, raw depth frames, or something else.
  3. Pass auth. Probably the BLE-supplied token is what auths stage 1. I can either accept anything as auth, or echo back whatever was sent. Naked Labs’ real cloud presumably did real validation; mine doesn’t need to.

The two-stage architecture also retroactively explains why so many things I’d attempted didn’t work. Writing a local listener URL to the BLE scanToken characteristic, then expecting the mirror to upload to that URL, was always going to fail because the mirror’s actual upload destination comes from the cloud response, not the BLE token. The BLE token was the authentication input to a cloud call, not the destination output.

§ 16.4

What’s saved from this attempt

Both sides of the protocol exchange are captured at scan-attempts/2026-05-17-0408/ (gitignored, these have unredacted device identifiers in them, the same ones my earlier redaction pass removed from the public-facing recon files). The files:

  • iphone-current.txt, full iPhone-side log, every BLE write/read/notify, every step-by-step status string
  • mirror-side/01-coord-journal.log, journalctl -u naked-coordinator for the full forty seconds
  • mirror-side/02-myeasylog-tail.log, same events from the coordinator’s own logfile, sometimes slightly different formatting
  • mirror-side/03-btmon.btsnoop, the actual BLE packet capture from the mirror’s perspective, loadable in Wireshark. Direct byte-level confirmation of every read/write/notify.
  • mirror-side/04-fs-poll.log, /data/scans and hardware_status.json watched every 2 seconds, catches the (would-have-been) moment of scan directory creation
  • mirror-side/05-netstat-poll.log, every socket the coordinator opened or tried to open during the scan, tomorrow’s analysis target for “what destination did the coordinator try to reach for stage 1?”

The btsnoop file is the most interesting. Tonight’s iPhone-side log shows what the diagnostic app wrote; the btsnoop shows what the mirror’s BT stack received, which is a fundamentally different perspective and useful for verifying that what I thought I sent is what was actually delivered. Wireshark opens these natively (File > Open > .btsnoop) and decodes the GATT layer.

§ 16.5

What’s queued for tomorrow

The smallest meaningful next step is half a day of work, and the next failure beyond it tells me most of what I still need:

  1. Mine 05-netstat-poll.log to find the exact destination the coordinator tried to reach during the thirty-second timeout. Almost certainly TCP port 443 to a Naked Labs S3 bucket or an AWS endpoint. My network monitor’s logs will corroborate.
  2. Stand up a minimal HTTPS server on my mock-cloud machine that accepts any POST to /scan (or whichever path the coordinator hits) and responds with a JSON body shaped like { "scan_id": "...", "upload_url": "..." }. Don’t try to get the response shape right yet, even a wrong-shape response that doesn’t time out will produce a more specific next failure.
  3. Edit /etc/hosts on the mirror to point api.nakedfit.net (and any other destinations I see) at my mock-cloud machine’s LAN address.
  4. Run the scan again. Either I get past WAITING_FOR_WEIGHT (and learn what stage 2 looks like), or I get a more specific failure that tells me what the response shape needs to be. Either outcome is progress.
  5. Repeat until the next failure is “the response from stage 1 isn’t parseable”, at which point I know the JSON keys the coordinator expects and can produce them.

Last week the path forward was a tangle of overlapping hypotheses about upload scenarios, the noInternet flag, cert pinning, ngrok dependencies, and whether the mirror was even running Linux. Tonight, after one forty-second scan attempt with proper instrumentation on both sides, the next step is concretely “respond to one POST.” The blocker that’s been the entire project’s central mystery for six weeks compresses to a single empirically-resolvable question: what does the stage-1 response need to look like.

Nothing about the mirror was actually broken. I just didn’t know what the cloud used to say. Tomorrow I’ll find out by replying.

§ 16.6
§ 16.7
Agent prompts Procedure 16-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
Let the mirror settle before scanning
Before running stepDryRunScan in NakedMirrorDebug, ensure the mirror has been powered on and idle for at least a couple of minutes, not immediately after boot. Treat an early mirrorError = .noInternet reading as a transient boot-time condition rather than a persistent fault, and re-run the pre-flight checks after the mirror has settled before concluding the device is actually blocked.
2
Confirm clean pre-flight reads
In stepDryRunScan, before writing alignScale, read and log mirrorExternalState, scanState, scanError, and wifiConnectionStatus (MirrorUUIDs.mirrorExternalState, .scanState, .scanError, .wifiConnectionStatus). Confirm all four decode to a non-blocking value (DeviceState .idle or .invalid, scanState .invalid, scanError .invalid/.noError as sentinels, wifiConnectionStatus .connected) before proceeding with the alignScale write; otherwise bail with the specific value that failed.
3
Instrument both sides of a scan
During a scan attempt, capture the iPhone-side BLE log (NakedMirrorDebug's existing step log) in parallel with the mirror-side coordinator log via SSH — tail journalctl -u naked-coordinator (or the coordinator's own logfile) on the mirror while the dry-run runs. Save both captures with synchronized timestamps so a BLE notification on one side can be correlated against the corresponding coordinator log line on the other, and store them together (e.g. under a gitignored scan-attempts/<timestamp>/ directory) since they contain unredacted device identifiers.
4
Check for a stalled cloud call
If a scan attempt stalls at scanState = WAITING_FOR_WEIGHT for longer than a few seconds, check the mirror's coordinator log and open sockets (e.g. netstat) for an in-flight outbound HTTP call rather than assuming the stall is a BLE issue. Treat the BLE scanToken write as an auth token supplied to that call, not as the end of the protocol exchange, and record the destination host/port the coordinator attempted to reach.
5
Capture a raw BLE packet trace
With root access on the mirror, capture a raw Bluetooth packet trace during a scan attempt (btmon writing to a .btsnoop file). Compare what the mirror's BT stack actually received against what the iPhone-side log claims it sent, to independently verify each write/notify in the protocol exchange at the byte level. Open the resulting file in Wireshark (File > Open > the .btsnoop file) to decode the GATT layer.
6
Stand up a two-stage mock cloud
Build a minimal HTTPS mock-cloud server that first accepts the coordinator's "create scan" POST (whatever path it is observed hitting, approximately api.nakedfit.net's create-scan endpoint) and responds with a parseable JSON body containing a scan_id and an upload_url, without worrying about getting the exact field names right on the first pass. Then add a second handler that accepts the stage-two PUT of the actual scan payload to that upload_url. Point the mirror at this mock server by editing /etc/hosts on the mirror for api.nakedfit.net (and any other relevant hosts found in the traffic capture), and re-run the scan to see whether the coordinator proceeds past WAITING_FOR_WEIGHT or produces a more specific parse failure.