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.
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.
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:
| Time | iPhone side | Mirror side |
|---|---|---|
| 04:08:37.960 | Writes alignScale (0x35) | Coordinator: “Trying to align scale” |
| 04:08:37.974 | - | DeviceState -> BUSY, LED ring -> align pattern |
| 04:08:38.005 | NOTIFY scanError = NO_ERROR | - |
| 04:08:38.802 | - | “We have enabled our scale weight detection” |
| 04:08:38.815 | NOTIFY scanState = ALIGN_SCALE | scanState set to ALIGN_SCALE |
| 04:08:39.881 | Writes initDeviceForScan (0x32) | “Initiated a new scan”, “waiting for user token!” |
| 04:08:39.925 | Writes scanToken (40 bytes, local listener URL) | Coordinator receives token |
| 04:08:40.934 | - | “We are beginning processing our scan!” |
| 04:08:42.355 | NOTIFY scanState = WARMING_UP | scanState set to WARMING_UP |
| 04:08:43.336 | - | Sets DeviceState -> BUSY for scan |
| 04:08:43.375 | NOTIFY scanState = WAITING_FOR_WEIGHT | LED 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.395 | NOTIFY scanState = ERROR | scanState = ERROR |
| 04:09:17.440 | NOTIFY scanError = NO_CLOUD_CONNECTIVITY (0x12) | scanError = NO_CLOUD_CONNECTIVITY |
The same exchange, as a sequence:
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:
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.
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 1paired 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:
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_urlThe 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:
- 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.
- 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.
- 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.
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 stringmirror-side/01-coord-journal.log,journalctl -u naked-coordinatorfor the full forty secondsmirror-side/02-myeasylog-tail.log, same events from the coordinator’s own logfile, sometimes slightly different formattingmirror-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/scansandhardware_status.jsonwatched every 2 seconds, catches the (would-have-been) moment of scan directory creationmirror-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.
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:
- Mine
05-netstat-poll.logto 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. - 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. - Edit
/etc/hostson the mirror to pointapi.nakedfit.net(and any other destinations I see) at my mock-cloud machine’s LAN address. - 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.
- 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.
Follow along
Procedure 16-AThe story above is mostly a working run, not a dead-end one, so the clean version is close to what happened:
- 1Let the mirror settle after boot before trying a scan. The device-state error I’d been chasing for weeks turned out to be a transient boot-time condition, not a persistent one. Wait for the mirror to be fully up before running your pre-flight checks.
- 2Run the pre-flight reads (
mirrorExternalState,scanState,scanError,wifiConnectionStatus) and confirm every one is clean before writingalignScale. - 3Instrument both sides if you can. The iPhone-side BLE log alone would have told me the scan failed. Pairing it with the mirror’s own coordinator log (if you have shell access) is what told me why: a stalled cloud HTTP call, not a BLE problem.
- 4When the scan stalls at
WAITING_FOR_WEIGHT, check for an outbound HTTP call on the mirror’s side, not just the BLE notifications. The BLE scanToken write is an auth token for a cloud call, not the end of the exchange. - 5Capture a raw BLE packet trace (
btsnoop) from the mirror’s side if you have root. It confirms, byte for byte, what the mirror’s Bluetooth stack actually received, independent of what your client believes it sent. - 6Build your mock cloud around the two-stage model: respond to the “create scan” POST with a parseable body (a scan id and an upload URL) first, then worry about accepting the actual upload payload second.
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.