Squirreleye LLC Squirreleye Service Notes Naked Labs Mirror · Revival Series Index Tools About
08
Section 08 · follows 07

When the diagnostic goes silent, audit instead of iterate

Written
2026-05-03
Commit
d02bd29
Log
nakedmirror-debug-20260503-205448.txt

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.

§ 8.1

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.

Fig. 8-1
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.

§ 8.2

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 0x31 write succeeded, the mirror replied with count = 0 (timing race: the mirror’s WiFi scan hadn’t populated yet).
  • The 0x31 write succeeded, the mirror replied with count = 0 because of some other state precondition I haven’t identified.
  • The count read returned nil data, 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.

§ 8.3

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

§ 8.4

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 via GetDeviceExternalStateOperation but 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) and 4C3EDA85… (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 an OOBEPairScaleAndMirrorFragment UI but no operation backing it, which means the pairing handshake is firmware-mediated between the two devices and the phone just observes scaleConnectionState transitions. 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. The ScanCredentialsDTO and ScanStatusDTO Java 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 my LocalScanServer and 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 in NakedV1GodModeOperation. 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.

§ 8.5

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

§ 8.6

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:

  1. Run All → step 13 fails at .notConnected, as before.
  2. Tap the WiFi button → the sheet opens. This time the global log shows the 0x31 write attempt, the write completion, the wifiNumNetworks read 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.
  3. From there: either a refresh-after-delay confirms a timing race (the mirror needs some time after 0x31 for 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.
  4. 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.
  5. 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.
§ 8.7

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.md are 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.

§ 8.8
§ 8.9
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.

1
Carry log calls through factor-outs
Review discoverWifiNetworks() (and any other method recently factored out of a logged step body) and add globalLog.append(...) calls around every BLE write, every read, the raw bytes returned, and any error path, matching the granularity the old step-wrapper logging had. Verify by calling it directly from WifiSetupSheet and confirming the global log shows the write attempt and result.
2
Instrument silent diagnostic steps
For any diagnostic step or helper that currently produces no global-log output around its BLE primitives (readChar/writeChar calls with no surrounding log(...) line), add explicit log entries for the write/read attempt, the raw bytes returned, the decoded value, and any error encountered, before changing any protocol-level logic. Do this for discoverWifiNetworks and connectToWifi specifically.
3
Audit APK coverage vs. own implementation
Enumerate every operation class under /tmp/naked-xapk/decompiled/sources/com/nakedlabs/android/nakedfit/ble/domain/operations/NakedV1/, every state enum under ble/domain/model/, and every characteristic UUID the mirror advertises (per the step-4 diagnostic log's discovered-characteristics list). Cross-reference against what NakedMirrorDebug currently exercises and produce a checklist: operation/enum/characteristic name, exercised (yes/no), and if no, which file would need to add it.
4
Pull in archived spec material
Collect archived product/spec documentation for the device (BioSpace launch piece, Edenspiekermann case study, Design Milk hardware breakdown, Intel RealSense D400 datasheet, the frozen nakedlabs.com archive) into a single Ref/TechRef.md with one consolidated Sources section at the bottom rather than per-paragraph citation tags. Focus the content on user-visible meaning (LED ring states, physical error thresholds, storage architecture) rather than protocol bytes, since the protocol is already documented elsewhere.
5
Turn audit gaps into ordered phases
Using the characteristic/operation/enum audit results, write a MASTER_PLAN.md at the repo root that orders remaining work into phases by which phase's answer unlocks the next phase's decision (e.g. Phase A restores diagnostic visibility, Phase B reaches one successful step 15, Phase C designs the post-scan pipeline contingent on Phase B's result), not by subjective urgency. Keep phases high-level; concrete tasks get filed closer to each phase's start.