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

"The value's length is invalid": when iOS refuses to send your BLE write

Written
2026-05-03
Commit
2755c69
Log
nakedmirror-debug-20260503-183656.txt

I’d just spent the last few sessions reverse-engineering the Naked Mirror’s BLE protocol from a decompiled Android APK. Everything was lined up: scale connected, three GATT services discovered, all 35 characteristics mapped against the APK’s ground truth, and the action multiplexer’s command bytes verified down to 0x32 = initDeviceForScan. I pressed Confirm Scan in my diagnostic app, expecting to finally see the mirror walk through its scan state machine and POST back at the local listener.

Instead, CoreBluetooth threw an error before the packet ever left the iPhone:

ERR cloudToken write failed: The value's length is invalid.

That error string is one of those lovely Apple translations that tells you something is wrong without telling you what to do about it. Here’s what it actually means, and the surprisingly small change that fixed it.

§ 1.1

What I was trying to do

Step 15 of my diagnostic is the end-to-end scan trigger. The protocol, extracted from the APK and verified through earlier steps, is:

  1. Write the upload URL (a UTF-8 string) to the cloudToken characteristic at UUID 884811AA-742D-424D-9513-A570379DC255. The mirror reads this to know where to POST scan data over WiFi.
  2. Write the byte 0x32 to the scannerAction characteristic at 767D14AA-…. That’s the action multiplexer’s command for initDeviceForScan.
  3. Listen on scanState (UUID 04285A29-…) for the mirror to transition through ALIGN_SCALE → WARMING_UP → ... → COMPLETE.

Drawn out, the exchange I was aiming for:

Fig. 1-1
sequenceDiagram
    participant Phone
    participant Mirror
    Phone->>Mirror: write upload URL to cloudToken (884811AA)
    Phone->>Mirror: write 0x32 to scannerAction (initDeviceForScan)
    Mirror-->>Phone: notify scanState (ALIGN_SCALE → WARMING_UP → … → COMPLETE)
    Mirror->>Phone: HTTP POST scan data (over WiFi)

I never made it past step 1. The URL I tried to write was:

http://<my-ip>:7777/upload/drydrun-1777855004

That’s 51 bytes. CoreBluetooth refused.

§ 1.2

What the error actually means

The value's length is invalid is the localized description of CBATTError.invalidAttributeValueLength. It’s not coming from the mirror. It’s iOS telling itself the write payload is too big.

The relevant ceiling is peripheral.maximumWriteValueLength(for: .withResponse), which equals negotiated_MTU - 3 (3 bytes for the ATT write header). The default ATT MTU is 23 bytes, giving you a paltry 20-byte payload budget. Modern iOS typically negotiates the MTU up to 185 (so 182 bytes payload) at connection time, but that’s negotiated: both peers have to agree.

This particular mirror caps the MTU somewhere between 38 and 53 bytes. I know that because step 13 had successfully written a 35-byte URL minutes earlier (http://debug.local:7777/upload/test), against the same characteristic, in the same connection. So it’s not a permissions problem, not a protocol problem, not a value-format problem. The 51-byte URL just doesn’t fit.

§ 1.3

The thing iOS won’t let you do

On Android, when you need a bigger MTU, you call BluetoothGatt.requestMtu(int). It’s part of the public API. Negotiate to 247 bytes, send your 240-byte payload, done.

iOS doesn’t expose this. CoreBluetooth negotiates MTU at connection time and gives you no API to influence it. Your only signals are maximumUpdateValueLength(for:) (read-only, post-connect) and maximumWriteValueLength(for:) (also read-only). You take what you get.

There is a fallback for writes longer than the MTU: the GATT spec defines a “Prepare Write + Execute Write” sequence that lets you push up to 512 bytes by chunking. iOS does not auto-chunk for .withResponse writes. You’d implement that ATT protocol yourself, which is real work, and only worth it if you genuinely need to send long payloads.

I don’t. The IP, port, and /upload/ prefix are fixed; everything else is just a scan ID for my local listener to route on. I can make that as short as I want.

§ 1.4

The fix

Two small changes to the diagnostic.

First, log the MTU budget once we have characteristics, so the next person running this never has to guess what the limit is:

Fig. 1-2
swift
let maxWithResp = peripheral?.maximumWriteValueLength(for: .withResponse) ?? 0let maxWithoutResp = peripheral?.maximumWriteValueLength(for: .withoutResponse) ?? 0log(4, .info, "MTU-based write limits: .withResponse=\(maxWithResp) bytes, .withoutResponse=\(maxWithoutResp) bytes")

Second, in step 15, generate a shorter scan ID and validate against the budget before asking CoreBluetooth to send:

Fig. 1-3
swift
let scanId = "d\(Int(Date().timeIntervalSince1970) % 100000)" // e.g. "d12345"let uploadURL = "http://\(deviceIP):\(listenerPort)/upload/\(scanId)"let maxWrite = peripheral?.maximumWriteValueLength(for: .withResponse) ?? 0log(15, .info, "Writing uploadURL (\(uploadURL.utf8.count) bytes / MTU budget \(maxWrite)) to cloudToken: \(uploadURL)")if uploadURL.utf8.count > maxWrite { log(15, .error, "URL \(uploadURL.utf8.count) bytes exceeds MTU budget \(maxWrite)") setStatus(15, .fail, detail: "URL too long for MTU: \(uploadURL.utf8.count) > \(maxWrite) bytes") return}

The shortened URL lands at around 41 bytes, well under any reasonable MTU. And if a future firmware variant has an even tighter cap, the explicit length check catches it with a useful error instead of the cryptic ATT one.

§ 1.5

Bonus discovery: PROCESSOR_BUSY

While I was reading the same log carefully, two notifications jumped out from earlier in the run, emitted during step 13’s earlier (35-byte, successful) cloudToken write:

NOTIFY scanState = 09NOTIFY scanError = 05

Decoding via the enums I’d extracted from the APK:

  • ScanState 9 = ERROR
  • ScanError 5 = PROCESSOR_BUSY

So the mirror is sitting in a busy/error state from the moment I connect. The cloudToken write succeeded at the BLE layer, but the mirror’s application layer immediately complained. This wasn’t blocking on log #3 because step 15 failed earlier (URL length). On the next attempt, with the URL length fixed, I’ll find out whether the busy state also blocks the actual scan trigger.

§ 1.6

What I’m trying next

  1. Re-run with the URL fix. Step 4 will now print the actual MTU budget; step 15 will use a short URL. If the mirror is in an idle state, this should walk all the way through to a real scan trigger or a clear protocol-level error.

  2. If PROCESSOR_BUSY persists, send Command.cancelScan (0x36) to the action multiplexer first, before initDeviceForScan. The APK doesn’t always send a cancel before triggering, but I have evidence the mirror is wedged on something, and a cancel is safe to send unconditionally.

  3. Watch the scanState byte sequence. 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). Seeing any of these in sequence on a NOTIFY confirms the mirror is actually doing something. Hitting 8 or a POST on the local listener confirms the full path works.

  4. Find out what the mirror POSTs. This is the open architectural question I’ve been carrying since the start: does the mirror send back a finished result ZIP (a PLY mesh + a measurements JSON), or does it dump raw depth data and expect me to do the body reconstruction myself? The diagnostic’s local HTTP listener will log the byte count and content type of whatever arrives. That single observation closes a major branch in the project plan.

§ 1.7

Takeaways

  • CBATTError.invalidAttributeValueLength always means MTU. It’s not about the value’s content, it’s about its size relative to the negotiated transport budget.
  • iOS hides MTU. If you’re porting BLE code from Android and your writes start failing, this is the first thing to check.
  • Log peripheral.maximumWriteValueLength(for:) early. It’s two lines of code, runs once per connection, and saves you an iteration cycle the first time something doesn’t fit.
  • Keep diagnostic payloads short by construction. I don’t need pretty URLs in a debug tool. d12345 works as well as dryrun-1777855004 and is much more portable across firmware variants.

The mirror is responding. Step 15 is the last fence. If the next log shows the state machine actually moving, I’m three or four messages away from finding out what shape the data takes when it lands on my doorstep.

§ 1.8
§ 1.9
Agent prompts Procedure 1-A as prompts for a coding agent · 3 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
Log MTU write budget
In the BLE client, right after characteristic discovery completes (the didDiscoverCharacteristicsFor delegate callback), log `peripheral.maximumWriteValueLength(for: .withResponse)` and `peripheral.maximumWriteValueLength(for: .withoutResponse)` as a single info-level log line, e.g. ".withResponse=N bytes, .withoutResponse=M bytes". Do this once per connection so every diagnostic run records the actual negotiated write budget instead of assuming a fixed MTU.
2
Shorten the cloudToken URL
In the step that writes the local-listener upload URL to the cloudToken characteristic (884811AA-742D-424D-9513-A570379DC255), replace any long or timestamp-suffixed scan ID with a short one, e.g. `"d\(Int(Date().timeIntervalSince1970) % 100000)"`, so the full URL (`http://<ip>:<port>/upload/<id>`) stays around 40 bytes instead of 50+.
3
Validate write length before sending
Before calling peripheral.writeValue for the cloudToken upload URL, compute `uploadURL.utf8.count` and compare it against `peripheral.maximumWriteValueLength(for: .withResponse)`. Log both values together on one line. If the URL's byte count exceeds the budget, fail the step immediately with a detail message like "URL too long for MTU: X > Y bytes" instead of issuing the write and letting CoreBluetooth throw CBATTError.invalidAttributeValueLength.