Squirreleye LLC Squirreleye Service Notes Naked Labs Mirror · Revival Series Index Tools About
00
Section 00

Reviving the Naked Mirror: a BLE reverse-engineering diary

Written
2026-05-03
Commit
6590613

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.

§ 0.1

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:

  1. A BLE GATT client that speaks the mirror’s protocol
  2. A local HTTP listener the mirror can POST to
  3. Both on the same WiFi network as the mirror

At a high level the round-trip looks like this:

Fig. 0-1
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)
§ 0.2

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.

§ 0.3

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:

  1. Bluetooth powered on
  2. Scan finds the mirror
  3. Connect
  4. Discover services
  5. Discover characteristics 6–9. Read friendlyName, deviceBatteryLevel, deviceHash, wifiStatus
  6. Subscribe to notifications
  7. Scale paired and connected (a hard precondition)
  8. Local HTTP listener binds a port
  9. Loopback self-test
  10. Write the scan-token characteristic
  11. Request the wifi network list
  12. 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.

§ 0.4

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 found

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

§ 0.5

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.

§ 0.6

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:

ByteDecimalAction
0x3149get wifi networks
0x3250initDeviceForScan
0x3351reboot
0x3553alignScale
0x3654cancelScan
0x3755requestDisconnect
0x3856factoryReset
0x3957OOBE complete
0x6E110enable OTA now
0x6F111pause 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:

Fig. 0-2
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)
    end

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

§ 0.7

The rewrite

Two file rewrites. MirrorUUIDs.swift got reorganized around the three real services and the action multiplexer:

Fig. 0-3
swift
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.

§ 0.8

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.

§ 0.9

What I learned

  • Decompile early. Grep is fast but lies. I spent a week building on grep-derived assumptions. Fifteen minutes of jadx would 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 scanForPeripherals only 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. NakedMirrorDebug is 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.
§ 0.10

What’s next

The next step is actually triggering a scan. The sequence I’m aiming for:

Fig. 0-4
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 listener

That part has been more interesting than expected. The very next attempt failed in a way I initially misdiagnosed. See the next post.

§ 0.11
§ 0.12
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.

1
Decompile the official APK
Run jadx against com.nakedlabs.android.nakedfit.apk and decompile it to Java source. Read BLEUtils.java and every class under the per-operation package (files like NakedV1InitDeviceForScanOperation.java, NakedV1WifiStatusOperation.java) before writing or modifying any BLE client code. Extract the real service/characteristic UUIDs, command byte values, and enum definitions from this decompiled source rather than from grep-derived string matches, and treat any previously grep-derived UUID map as unverified until confirmed against this source.
2
Diagnostic-first BLE checklist
Before building the production app, create a separate diagnostic app (NakedMirrorDebug-style) implemented as a sequential, numbered checklist view where each step exercises exactly one BLE assumption (Bluetooth powered on, scan finds the mirror, connect, discover services, discover characteristics, read each characteristic, subscribe to notifications, scale-paired precondition, local HTTP listener, scan trigger). Each step must produce a structured, timestamped, machine-readable log line recording PASS/FAIL and a detail string, plus a global chronological log and an export action that writes the full run to a single text file.
3
Unfiltered BLE scan
In the peripheral-discovery step of the BLE client, replace any filtered `scanForPeripherals(withServices: [...])` call with an unfiltered scan (`scanForPeripherals(withServices: nil)`), and log every discovered peripheral's name, identifier, RSSI, and advertised service UUIDs. Then match the target peripheral in software by checking whether its advertised service UUIDs include the scale service UUID `8C43EF91-BD35-4F72-A812-14F8E35395A8`, since that is the only service the mirror puts in its advertisement packet.
4
Model three real services
In MirrorUUIDs.swift, define exactly three CBUUID services: scaleService (8C43EF91-BD35-4F72-A812-14F8E35395A8), mirrorService (D916D540-3F4D-4A50-859D-3576B0D45EB1), and configService (E089084D-A260-4AD1-A7E4-5004DB02F16D). Remove any UUIDs previously modeled as additional "services" and reclassify them as characteristics under one of these three. Update the discover-services diagnostic step to assert that all three services are present rather than two.
5
Single action multiplexer
Replace any per-command characteristic design with a single write characteristic, scannerAction, at UUID 767D14AA-BB8A-4ADD-B294-0F6A5B6608EB. Define a `Command: UInt8` enum on this characteristic with cases for every documented one-byte action: getWifiNetworks=0x31, initDeviceForScan=0x32, reboot=0x33, alignScale=0x35, cancelScan=0x36, requestDisconnect=0x37, factoryReset=0x38, oobeComplete=0x39, enableOTA=0x6E, pauseOOBEOTA=0x6F. Update every call site that previously wrote to a dedicated "command" characteristic to instead write the appropriate byte to scannerAction.
6
Byte-exact enums from APK
Rebuild the ScanState, ScaleConnectionState, and ScanError Swift enums so their raw UInt8 values match the decompiled APK exactly: ScanState = 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); ScaleConnectionState = INVALID(0), CONNECTED(1), DISCONNECTED(2). Do not invent or carry over any case names that aren't present in the decompiled Java enums (drop any .uploading, .processing, or .pairing cases).
7
Wifi list as read loop
Rewrite the wifi-network-listing step so it no longer waits for a notification. Instead: write Command.getWifiNetworks (0x31) to scannerAction, then read the wifiNumNetworks characteristic to get a count N, then loop N times reading the wifiNetworkSSIDEnum characteristic, decoding each read as one SSID record (index / SSID / security / signal). Log each record as it's read and fail the step if any individual read errors out rather than only checking the final count.