A few years ago, Naked Labs sold a $1,500 mirror that took 360° depth scans of your body so you could track shape and posture over time. The mirror has three Intel RealSense depth cameras at fixed angles, runs Android internally on an Intel Celeron, and is paired with a Bluetooth-connected scale that doubles as a turntable. The scale spins you while the cameras shoot, so coverage comes from rotating the user, not the cameras.
Then the company shut down. api.nakedfit.net went dark. The mirror still works locally. Its depth cameras still work, the turntable still spins, the BLE link still pairs. But it tries to upload scans to a server that doesn’t exist anymore, and the companion app refuses to do anything useful without that cloud handshake.
This is the story of bringing one of those mirrors back to life with an iPhone replacing the cloud. It’s still a work in progress. At the time of writing I’ve reverse-engineered the BLE protocol from the official Android APK, verified my understanding against real hardware through 14 of 16 diagnostic steps, and am mid-attempt on triggering a real scan. But I’ve covered enough ground to be worth writing down.
The hardware
Two physical devices. Both are required for any scan:
- Mirror. Stationary. Three Intel RealSense D410 depth cameras, one Intel Celeron G3930T, 64GB SSD, runs Android. Hosts the BLE GATT server.
- Scale. Doubles as a rotating turntable. Spins you 360° while the cameras capture depth. Paired to the mirror over BLE, not directly to the phone.
Without the scale rotating, the cameras only see the user from one angle, which is useless for body modelling. The scale also produces the weight value that ends up in the scan metadata. So “scale paired and connected” is a hard precondition for any scan, not just a UI nicety.
The phone talks to the mirror over BLE for control. When a scan completes, the mirror posts the scan data to a URL that the phone handed it earlier over BLE. That upload travels over WiFi, not BLE. So the phone needs three things:
- A BLE GATT client that speaks the mirror’s protocol
- A local HTTP listener the mirror can POST to
- Both on the same WiFi network as the mirror
At a high level the round-trip looks like this:
sequenceDiagram
participant Phone as iPhone (app)
participant Mirror
participant Scale
Phone->>Mirror: BLE control commands
Phone->>Mirror: BLE: set upload URL
Mirror->>Scale: BLE: pair + spin turntable
Scale-->>Mirror: weight + rotation
Note over Mirror: cameras capture depth
Mirror->>Phone: HTTP POST scan data (over WiFi)Where I started
I had a PLAN.md from a previous session, a handoff document with a complete proposed iOS app. It included a MirrorUUIDs.swift with what looked like the full BLE protocol map: a couple of services, a couple dozen characteristics, an enum for scan state, an enum for scale connection state. The UUIDs all came from grep-style inspection of the official Naked Android APK. Plausible-looking, but never tested against real hardware.
The first decision: don’t trust any of it.
Every UUID, every characteristic flag, every byte enum was an unverified APK-derived assumption. Building the production app on those assumptions and then watching it silently misbehave against a real mirror would be very painful to debug. So before touching the production app, I built a separate diagnostic app.
The diagnostic-first approach
NakedMirrorDebug is a 16-step sequential checklist. Each step exercises one assumption in isolation and produces a structured log line. The list, abridged:
- Bluetooth powered on
- Scan finds the mirror
- Connect
- Discover services
- Discover characteristics 6–9. Read friendlyName, deviceBatteryLevel, deviceHash, wifiStatus
- Subscribe to notifications
- Scale paired and connected (a hard precondition)
- Local HTTP listener binds a port
- Loopback self-test
- Write the scan-token characteristic
- Request the wifi network list
- End-to-end scan dry-run (manual confirmation; physically spins the scale)
Each step has a per-step log, there’s a global chronological log, and a 📤 button writes the whole thing to a structured .txt you AirDrop back to your Mac. The format is deliberately structured and machine-readable, so when I pick a debugging session back up I start from a known state.
The whole point is that you find out exactly which assumption is wrong and at exactly which BLE operation it broke.
First contact
I installed the diagnostic on an iPhone, set the mirror in front of it, hit Run All, AirDropped the log back, and sat down to read it.
--- Step 0: Bluetooth powered on [PASS] ------ Step 1: Scan finds the mirror [FAIL] ---detail: 0 peripherals foundFive 10-second scan retries, zero peripherals. The iPhone’s BLE radio was fine (step 0 passed). The mirror was powered on. I was 6 feet away. And scanForPeripherals(withServices: [primaryService, secondaryService]) returned nothing.
Two possibilities. Either the mirror genuinely wasn’t advertising, or the service UUIDs I was filtering on weren’t in the advertisement packet at all.
Some BLE devices put their service UUIDs in the advertisement packet so a CoreBluetooth scan can filter for them. Others don’t. They save the 31-byte advertisement budget for other things and only expose their services after connection. iOS’ filtered scan only sees what’s in the advertisement.
So I changed step 1 to two-pass: first the filtered scan, and if that returns zero, an unfiltered scan that logs every nearby BLE device with its name, identifier, RSSI, and advertised service UUIDs. That way the next failure log either finds the mirror under a different filter, or lists the mirror as one of the unfiltered peripherals so I can identify it by name.
But before running again, I decided to do something different: actually decompile the APK.
The decompile
Up to this point I’d been working from the strings in the APK, meaning UUID-shaped substrings extracted with a grep and mapped to plausible-sounding names by reading file paths. That was the cheap way. The expensive way was jadx decompile and read the actual Java.
I installed jadx, pointed it at com.nakedlabs.android.nakedfit.apk, and waited for the output. The result was a tree of decompiled Java packages, including a real BLEUtils.java and a per-operation directory full of files like NakedV1InitDeviceForScanOperation.java and NakedV1WifiStatusOperation.java.
Reading those files for fifteen minutes invalidated almost every assumption I had.
How wrong I was
The scan filter was wrong. The Android app calls scanBleDevices(new UUID[0]), an empty array meaning unfiltered, then filters in software for one specific UUID: 8C43EF91-BD35-4F72-A812-14F8E35395A8. I had labeled that UUID renameDevice and assigned it to the rename-device characteristic. It is in fact the scale GATT service UUID, and it’s the only thing the mirror puts in its advertisement packet.
There are three services, not two. My primaryService and secondaryService aren’t services at all, they’re characteristics. The real services are:
8C43EF91-…(scale)D916D540-…(mirror)E089084D-…(config)
There is one “scanner action” characteristic, not many. A single write characteristic at 767D14AA-BB8A-4ADD-B294-0F6A5B6608EB accepts ten different one-byte commands:
| Byte | Decimal | Action |
|---|---|---|
0x31 | 49 | get wifi networks |
0x32 | 50 | initDeviceForScan |
0x33 | 51 | reboot |
0x35 | 53 | alignScale |
0x36 | 54 | cancelScan |
0x37 | 55 | requestDisconnect |
0x38 | 56 | factoryReset |
0x39 | 57 | OOBE complete |
0x6E | 110 | enable OTA now |
0x6F | 111 | pause OOBE OTA |
I had been planning to write 0x01 to ten different “command” characteristics: wrong UUIDs, wrong payload values, wrong protocol model.
The ScanState enum was made up. I had idle, warmingUp, aligning, scanning, processing, uploading, completed, failed, cancelled, timedOut. The real APK enum is:
INVALID(0), ALIGN_SCALE(1), WARMING_UP(2), WAITING_FOR_WEIGHT(3),COUNT_DOWN(4), PRE_CHECK(5), SCANNING(6), POST_CHECK(7),COMPLETE(8), ERROR(9), WAIT_STEP_OFF(10)There’s no .uploading state. There’s no .processing. The mirror just hits COMPLETE and POSTs.
ScaleConnectionState had its bytes reversed. I had disconnected=0, connected=1, pairing=2. The real enum is INVALID=0, CONNECTED=1, DISCONNECTED=2. There is no .pairing state.
The wifi-list flow is read-loop, not write-then-notify. I had assumed: write 0x01 to a wifi-networks characteristic, then wait for a notification with newline-separated SSIDs. The real flow is a write followed by a read loop:
sequenceDiagram
participant Phone
participant Mirror
Phone->>Mirror: write 0x31 to scannerAction (get wifi networks)
Phone->>Mirror: read wifiNumNetworks
Mirror-->>Phone: count = N
loop N times
Phone->>Mirror: read wifiNetworkSSIDEnum
Mirror-->>Phone: one SSID record (index / SSID / security / signal)
endAlmost every characteristic in my map had the wrong human-readable name. The UUID I had labeled setScanToken was actually scaleConnectionState. The UUID I had labeled initDeviceForScan was actually the scale serial number characteristic. And so on, for about thirty more.
If I’d kept building on these assumptions, the production app would have been writing 0x01 to a serial number characteristic to “trigger a scan.” It would have failed silently and I would have spent days chasing a ghost.
The rewrite
Two file rewrites. MirrorUUIDs.swift got reorganized around the three real services and the action multiplexer:
enum MirrorUUIDs { static let scaleService = CBUUID(string: "8C43EF91-BD35-4F72-A812-14F8E35395A8") static let mirrorService = CBUUID(string: "D916D540-3F4D-4A50-859D-3576B0D45EB1") static let configService = CBUUID(string: "E089084D-A260-4AD1-A7E4-5004DB02F16D") static let advertisedServiceUUID = scaleService static let scannerAction = CBUUID(string: "767D14AA-BB8A-4ADD-B294-0F6A5B6608EB") enum Command: UInt8 { case getWifiNetworks = 0x31 case initDeviceForScan = 0x32 case alignScale = 0x35 case cancelScan = 0x36 // ... } // ...}The ScanState, ScaleConnectionState, and WifiConnectionStatus enums got rebuilt with the correct byte values. DebugChecklistView was updated step-by-step: scan filter changed to the advertised service UUID, step 4 expects three services, reads use the corrected UUIDs, writes go to the action multiplexer with the right command bytes, and step 15 (the wifi-list step) became a write-then-read-loop.
Second contact
I re-installed and ran again. This time the log was almost all green.
Step 0: Bluetooth powered on [PASS]Step 1: Scan finds the mirror [PASS] (auto-selected "Squirrel Fit")Step 2: Connect [PASS]Step 3: Discover services [PASS] (scale + mirror + config services all present)Step 4: Discover characteristics [PASS] (35 chars found, all expected present)Step 5: Read friendlyName [PASS] ("Squirrel Fit")Step 6: Read deviceBattery [PASS] (0%)Step 7: Read deviceHash [PASS] (30 30 30 30 …)Step 8: Read wifiConnectionStatus [PASS] .notConnected (0x4)Step 9: Subscribe to notifications [PASS]Step 10: Scale paired and connected [FAIL] (scale was dead)Step 11: Local network listener [PASS]Step 12: Self-test the listener [PASS]Step 13: Write cloudToken (dry run) [PASS]Step 14: Request wifi networks [PASS] (14 SSIDs)The mirror identified itself as “Squirrel Fit”, a custom name a previous owner had given it, which I’d see again later when it changed to “SquirrelFit” with no space. The deviceHash returned 30 30 30 30 …, which is ASCII for "0000…", the null state for a mirror that’s been disconnected from the cloud (no more authenticated user, no more hash). WiFi was disconnected. The 14-SSID read worked perfectly through the new write-then-read-loop flow, returning records like 0/<my-home-wifi>/4/90 (index/SSID/security/signal).
Step 10 failed because the physical scale battery was dead. That was a real-world problem, not a software one. I went to charge the scale.
I also got an unsolicited bonus: while writing to cloudToken the mirror immediately notified me with scanError = 0x05, which is PROCESSOR_BUSY from the APK enum. The notify path was working end-to-end, even before I’d subscribed to it intentionally. The mirror was telling me things, in real time, in a protocol I now understood.
This was the moment I knew the protocol model was right.
What I learned
- Decompile early. Grep is fast but lies. I spent a week building on grep-derived assumptions. Fifteen minutes of
jadxwould have saved the entire first iteration. If you’re reverse-engineering a BLE protocol, decompile the official client first, then design. - Don’t trust your scan filter. iOS’ filtered
scanForPeripheralsonly sees service UUIDs that the peripheral chose to put in its 31-byte advertisement packet. If your scan returns zero, do an unfiltered pass first to see what’s actually nearby. - Build the diagnostic before the production app.
NakedMirrorDebugis the single best decision in this project. Every assumption I got wrong, I got wrong loudly and locally, at a specific step, with a specific UUID, with a specific log line. The fix is always cheap when the failure is precise. - Make logs machine-readable. ISO timestamps, structured per-step sections, friendly UUID labels. The whole point is that the file is useful the moment you open it. If your log format requires human reformatting before it’s useful, you’ll do that work every time.
- Most “the protocol is wrong” surprises are naming surprises. The UUIDs were correct. The bytes were correct. I just had nearly every label assigned to the wrong thing. The model in my head was completely upside-down for a week.
What’s next
The next step is actually triggering a scan. The sequence I’m aiming for:
sequenceDiagram
participant Phone
participant Mirror
participant Scale
Phone->>Mirror: write upload URL to cloudToken (884811AA)
Phone->>Mirror: write 0x32 to scannerAction (initDeviceForScan)
Mirror-->>Phone: notify scanState = ALIGN_SCALE
Mirror->>Scale: spin + align turntable
Mirror-->>Phone: notify scanState = WARMING_UP
Mirror-->>Phone: notify scanState = … (SCANNING, POST_CHECK)
Mirror-->>Phone: notify scanState = COMPLETE
Mirror->>Phone: HTTP POST scan data to local listenerThat part has been more interesting than expected. The very next attempt failed in a way I initially misdiagnosed. See the next post.
Follow along
Procedure 0-AThe story above has the wrong turns left in on purpose. If you just want to reproduce this first contact with your own mirror, here’s the clean sequence:
- 1Decompile the official app first. Pull
com.nakedlabs.android.nakedfit.apkand runjadxon it. Read the real Java (BLEUtils.java, the per-operation classes) before you write a line of client code. Grep-derived guesses will cost you a week. - 2Build a diagnostic checklist, not the production app. One step per assumption, each emitting a structured, machine-readable log line, so a failure tells you exactly which BLE operation broke.
- 3Scan unfiltered. iOS’ filtered
scanForPeripheralsonly sees service UUIDs that made it into the 31-byte advertisement. Scan with no filter, then match the mirror in software on the advertised scale-service UUID8C43EF91-…. - 4Model three services, not two: scale
8C43EF91-…, mirrorD916D540-…, configE089084D-…. Most of the things that look like services are actually characteristics. - 5Use the one action multiplexer. A single write characteristic at
767D14AA-…takes one-byte commands (0x31get wifi,0x32initDeviceForScan,0x36cancel, and so on). Don’t go looking for ten separate command characteristics. - 6Copy the enums byte-for-byte from the APK (
ScanState,ScaleConnectionState,ScanError). Don’t invent state names. - 7Read the wifi list as a loop: write
0x31, read the count fromwifiNumNetworks, then loop reads ofwifiNetworkSSIDEnumto collect each SSID record.
Agent prompts Procedure 0-A as prompts for a coding agent · 7 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.