The previous post ended with a prediction: with no source-side credentials, I would tap the toolbar WiFi button, the sheet would show a list of nearby networks, I’d pick one and enter a password, the sheet would write the credential payload to wifiSetSSID, the diagnostic would re-run, and step 13 would finally read .connected. With WiFi configured, step 15 would walk past alignScale for the first time.
That’s not what happened. I opened the sheet and it showed Discovered networks (0). The exported log had nothing to say about why. The screenshot was timestamped 8:54:35 PM. The log generated thirteen seconds later ended at 8:54:21 PM with the step-13 read of wifiConnectionStatus = .notConnected. There was no record of the sheet opening, no record of a 0x31 write, no record of a wifiNumNetworks read. The diagnostic had gone silent at exactly the moment I needed it to be loud.
This post is about why, and about the larger pivot the silence triggered.
The log gap
NakedMirrorDebug has two layers of logging. The per-step log lines come from log(N, .info, …) calls inside stepConnect, stepReadFriendlyName, and so on. The global log gets entries from those same calls, plus from the generic CoreBluetooth NOTIFY handler, plus from a few delegate-level events like Peripheral disconnected. Inside the BLE primitives (readChar and writeChar) there are no log calls at all. They’re silent helpers. Their callers narrate what’s about to happen and what came back.
That worked fine for the linear runbook. Step 14 (stepRequestWifiNetworks) used to look something like:
log(14, .info, "Writing Command.getWifiNetworks (0x31) to scannerAction")let err = await writeChar(actionChar, ...)log(14, .info, "Reading wifiNumNetworks")let (data, err) = await readChar(numChar)log(14, .info, "wifiNumNetworks = \(count)")for i in 0..<count { ... read SSID ... }Every primitive had a sentence around it. When something went wrong, the global log told me exactly where.
Then commit 2280a9c factored that body out into a public method, discoverWifiNetworks, so the new WifiSetupSheet could reuse it independently of step 14. The factor-out kept the BLE primitives. It didn’t keep the log calls. The new discoverWifiNetworks was twenty-six terse lines that returned a Result<[DiscoveredWifiNetwork], Error>, and emitted nothing to the global log at all.
stepRequestWifiNetworks continued to log around its call site, so step 14 in Run All still produced log lines. But the sheet doesn’t go through that path. The sheet calls engine.discoverWifiNetworks() directly, gets the Result back, displays “0 networks” if the array is empty, and the global log has no idea any of it happened.
sequenceDiagram
participant RunAll as Step 14 (Run All)
participant Sheet as WifiSetupSheet
participant Fn as discoverWifiNetworks()
participant Mirror
RunAll->>Fn: call (through a logged step wrapper)
Fn->>Mirror: write 0x31
Mirror-->>Fn: count + SSID records
Fn-->>RunAll: result (logged by the step wrapper)
Sheet->>Fn: call directly (no wrapper)
Fn->>Mirror: write 0x31
Mirror-->>Fn: count + SSID records
Fn-->>Sheet: result (nothing written to the global log)This is invisible to anyone reading the diff. The factor-out looks clean. It compiled. It ran. The only way to notice the gap is to ship a build, run it on hardware, hit a real failure inside the silent helper, and stare at an export that has nothing to say.
What I don’t know about the 0
I saw Discovered networks (0). With the silent helpers, this is consistent with at least four different failures:
- The
0x31write succeeded, the mirror replied with count = 0 (timing race: the mirror’s WiFi scan hadn’t populated yet). - The
0x31write succeeded, the mirror replied with count = 0 because of some other state precondition I haven’t identified. - The count read returned
nildata, the guard fell through, returned.success([]). - The write itself silently errored (peripheral disconnected mid-call, something), the guard returned the error, the sheet displayed the error message, but if there was no error string, “0” is the same display.
Without log lines for the write attempt, the count-byte value, and the error path, I can’t tell which. The fix has to start with restoring the logging the factor-out lost. A protocol change without that visibility would be a guess.
This is the smaller of the two pivots in this commit.
The larger pivot
I assembled a new reference file, Ref/TechRef.md, synthesized from archived product and spec documentation: BioSpace’s launch piece, Edenspiekermann’s design case study, Design Milk’s hardware breakdown, the Intel RealSense D400 datasheet, the now-frozen nakedlabs.com archive. I collapsed the citations into a single Sources section at the bottom; every paragraph had been carrying a [cite: 1, 2] tag that distinguished nothing.
What’s in it that I didn’t have before isn’t protocol. I already had the BLE protocol, end to end, from the APK decompile. What I had instead now was user-facing context: the LED ring’s eight states (Pulsing Blue for BLE pairing, Solid White for ready, Rotating White for scan in progress, Pulsing Green for upload, plus four red error patterns), the scan protocol’s physical constants (15 seconds for a 360° rotation, 3° tilt threshold for SCALE_NOT_LEVEL, 1.5m side-wall clearance for LIGHTING_CONDITIONS, A-frame pose with arms ~20° from torso for IMPROPER_POSE), the mirror’s storage architecture (64GB SSD as a local cache for ~4GB raw point-cloud data), the cloud component’s name (“Stitching Engine”) and what it did, the mobile UI’s design language (clay avatar, red-shading-for-volumetric-gain heat map, 9-point measurement callouts).
None of that changes the bytes on the wire. All of it changes how I read the bytes on the wire. The LIGHTING_CONDITIONS (10) scanError suddenly has a corresponding firmware-side cause I can check for myself (am I near a window?). The SCALE_ALIGNMENT (11) error has a measurable threshold (the floor under the scale is more than 3° off level). The mirrorExternalState characteristic at 82B0745A… (which I’d discovered at step 4 and never read) is suddenly very interesting, because the LED ring is firmware-controlled but the firmware has to expose its current pattern somewhere, and that’s the only mirror-service characteristic with the right shape I hadn’t decoded.
And the architectural Scenario A vs B question (does the mirror POST a finished avatar ZIP, or raw depth bytes for cloud stitching?) gets a meaningful nudge. “Stitching Engine” cloud plus a ~4GB local raw cache strongly biases toward Scenario B. Not definitively. The mirror clearly does some local processing; it might do partial stitching and ship a compressed result that’s not a finished PLY. But the spec doc shifts the prior, and that prior shapes what I expect to see when step 15 finally produces a POST.
Once I’d absorbed that, the right move stopped being “iterate on the next failing step” and started being “audit everything I haven’t yet read.”
What the audit found
The decompile at /tmp/naked-xapk/decompiled/sources/com/nakedlabs/android/nakedfit/ has roughly 25 operations under ble/domain/operations/NakedV1/. I’d been exercising 10. It defines roughly 8 state enums in ble/domain/model/. I’d documented 5. The mirror advertises 35 characteristics. I’d been exercising 14. The diagnostic’s clean step-4 log says it explicitly: 21 known characteristic(s) discovered but not exercised by this diagnostic. I’d been reading that line for five sessions and treating it as background noise.
Some of what surfaced:
mirrorExternalState(82B0745A…), read+notify, in the mirror service. APK reads it viaGetDeviceExternalStateOperationbut doesn’t define an enum for the bytes. Almost certainly the LED ring state: TechRef enumerates eight patterns, the firmware needs to expose its current pattern somewhere, and this is the only candidate UUID. Subscribe and observe is a one-line addition to step 9, and decoding the byte values by correlating against what my own eyes see is a few minutes of work on real hardware.- The two novel firmware UUIDs.
09F97B9E…(read-only, in the scale service) and4C3EDA85…(read+notify, in the mirror service) appear on real hardware but are not in APK v1.4.0 anywhere. The mirror’s firmware is presumably newer than what shipped in the Naked 1.4.0 / build 2047 Android app. I’ve been logging them as “novel” for several sessions and never reading them. Subscribe and read on the next session and I’ll know what they are. - Scale-to-mirror pairing. STATUS.md has been flagging this as undocumented. The audit confirms the APK has no dedicated operation class for it: the operation directory contains specific Java classes for every other write (
NakedV1AlignScaleOperation,NakedV1InitDeviceForScanOperation,NakedV1RenameDeviceOperation, etc.) but pairing is conspicuously absent. The OOBE flow has anOOBEPairScaleAndMirrorFragmentUI but no operation backing it, which means the pairing handshake is firmware-mediated between the two devices and the phone just observesscaleConnectionStatetransitions. Brick-recovery (replacement scale, lost pairing) needs reverse-engineering against real hardware, not against the APK. - Cloud-token format. What the official app actually wrote to
884811AA…was probably not just a URL. TheScanCredentialsDTOandScanStatusDTOJava classes hint at AWS SigV4 / pre-signed-URL credentials returned by the cloud and presumably forwarded to the mirror via this characteristic. I’ve been writing plain HTTP URLs for myLocalScanServerand the BLE write succeeds, but if I ever wanted to revive cloud integration (I don’t), I’d need to replicate the exact format. Worth knowing, not blocking. - Action-multiplexer commands I hadn’t documented.
0x37(requestScannerDisconnection, a graceful disconnect distinct from the BLE-layer cancel),0x33(reboot),0x38(factoryReset),0x39(OOBEComplete),0x6E/0x6F(OTA enable / pause). All inNakedV1GodModeOperation. None of them dangerous to me right now, but adding a “God Mode” panel to the debug app gives me a clean-slate test path: factory reset to put the mirror into OOBE, then drive each step manually.
The audit also confirmed things I already knew, which is its own kind of useful: every spot the APK agreed with my STATUS.md ground-truth tables increased my confidence in what I’d already verified.
The master plan
What came out of the audit isn’t a list of bugs to fix. It’s a structural map of what’s left to do, organized by what unlocks the next architectural decision:
- Phase A: restore diagnostic visibility (the silent-helpers gap that triggered this whole pivot). Add
globalLog.append(…)lines around every BLE primitive in the public engine methods. Auto-subscribe to every notify-capable characteristic, not just the curated five. One commit, no protocol changes. - Phase B: get to one successful step 15. The three environmental gates (WiFi, scale on floor, clean start) are unchanged. With Phase A in place, whatever happens next will at least be readable.
- Phase C: design the post-scan pipeline, conditional on what Scenario B answers. ZIP transport vs server-side stitching pipeline.
- Phase D: exercise every operation, every characteristic, every enum the APK defines. The 21-untested-characteristics line in the step-4 log goes away.
- Phase E: reverse-engineer the three remaining unknowns: pairing handshake, cloud-token format, novel firmware UUIDs.
- Phase F: port the verified protocol from the debug app to the production app, finally replacing the
PLAN.md-era incorrect map. - Phase G: UI per TechRef. Clay avatar, heat-map, 9-point callouts, gestures.
- Phase H: resilience. Factory-reset round-trip, multi-mirror, OTA, suspend/resume.
The full plan is in MASTER_PLAN.md at the repo root. It’s deliberately one big document: phases will be split into concrete tasks closer to each phase’s start. The intent is not a schedule. The intent is a single source of truth for “what’s left, and in what order, and why.”
What I’m testing in the next build
Phase A first. Add explicit log lines to discoverWifiNetworks and connectToWifi for every BLE write attempt, every read attempt, the raw bytes returned, and any error path. Auto-subscribe to mirrorExternalState and the two novel firmware UUIDs at step 9. Re-export the log format with a “characteristics discovered but not exercised” appendix that’s self-describing. Then back to hardware.
My flow on the next build:
- Run All → step 13 fails at
.notConnected, as before. - Tap the WiFi button → the sheet opens. This time the global log shows the
0x31write attempt, the write completion, thewifiNumNetworksread attempt, and the raw bytes returned. If the count is 0, I’ll know it’s 0 from the wire, not from a silent error. - From there: either a refresh-after-delay confirms a timing race (the mirror needs some time after
0x31for its WiFi scanner to populate), in which case the fix is a polling retry on the count read, or the byte exchange shows something else and I triage from there. - While that’s happening, I’ll watch the LED ring on the physical mirror and note any pattern transitions, cross-referencing them with the bytes that arrive on
mirrorExternalState. Decoding at least two of the eight TechRef-defined LED patterns would be a good outcome. - After WiFi is configured and the scale is on the floor, run all again. The big architectural question, does the POST body start with
PK\x03\x04(Scenario A) or something else (Scenario B), gets answered.
Takeaways
- A factor-out that drops log lines is a regression that doesn’t show up in the diff. Visibility is a feature, not a debug artifact. When extracting a function out of a step body, the per-step log calls are part of the function’s contract: re-add them in the extracted version, even if the call site already has them. The cost is a few lines; the benefit is being able to debug the extracted function in isolation, which is exactly when you’ll need it.
- A spec doc you didn’t write is high signal even when none of it is protocol. TechRef didn’t tell me anything new about the bytes on the wire. It told me what the bytes mean in user-visible terms, what the firmware-side error thresholds are, and what the LED ring is doing while the BLE traffic is happening. Every spec doc I hadn’t read was a multiplier on every log line I’d been collecting blindly.
- Iterate when each step unlocks the next; audit when each step adds to a list of unknowns. Five hardware sessions of “fix the next failing step” produced eight blog posts and five protocol corrections, and got me to 13 of 16. The next step (
step 14 → 15) was clear. But the next-after-next steps were piling up faster than I was resolving them:mirrorExternalState, the novel UUIDs, the pairing handshake, Scenario A vs B, the 21 unexercised characteristics. At some point the right move stops being “fix the next bug” and starts being “stop, read everything you haven’t read, then decide what bug to fix next.” The trigger was the silent-diagnostic moment, but the underlying need had been building for a couple of sessions. - A master plan isn’t a schedule, it’s a decision sequence. The phases in
MASTER_PLAN.mdare ordered by what answers the next architectural question, not by what I’d like to ship by Friday. Phase B answers Scenario A vs B. Phase C designs the pipeline contingent on B. Phase E and F can run in parallel once C is decided. The structure is a graph, presented as a list. The list is for human readability; the graph is what determines what comes next. - Eight blog posts in, the bug stops being the lesson. The first five posts in this series were all “I hit X, here’s why, here’s the fix.” That’s the right shape for the bring-up phase. By post seven the lessons were starting to be more about engineering taste (credentials at runtime, read-vs-write step semantics) than about the specific bug. This post is the inflection where the bug-narrative gives way to a higher-level account of what I now know and how I know it. Future posts may go back to per-bug write-ups when the next hardware session produces one. But the cumulative-knowledge narrative is also worth telling, and there hasn’t been a clean place to tell it before now.
The next post will be either a direct continuation (Phase A’s instrumentation pass plus whatever the next hardware log reveals about that count read) or, if Phase B succeeds on the same session, the answer to the longest-standing open question on this project. Either is a good outcome. The map matters more than which step is next.
Follow along
Procedure 8-AThis post is more about process than a single fix, but the concrete actions are reproducible:
- 1Whenever you factor a BLE (or any I/O) helper out of a logged call site into a reusable method, carry the log calls with it. Don’t assume the old call site’s logging covers the new one.
- 2If a diagnostic step goes silent (wrong result, no explanation), don’t guess: add log lines around every write, every read, and every error path before you touch the protocol logic.
- 3Before continuing to iterate step by step, do a one-time audit: list every operation class, every state enum, and every characteristic the official APK defines, and check off which ones your own code actually exercises. Anything unchecked is a gap worth knowing about even if it isn’t blocking you yet.
- 4Pull in any archived spec or marketing material you can find for the device (launch posts, design case studies, datasheets for the components inside it). It won’t tell you anything about the wire protocol, but it tells you what the bytes are supposed to mean, which reframes what you’re debugging.
- 5Turn the audit’s findings into an ordered list of phases, organized by which answer unlocks which later decision, not by what feels most urgent.
Agent prompts Procedure 8-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.