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.
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:
- Write the upload URL (a UTF-8 string) to the
cloudTokencharacteristic at UUID884811AA-742D-424D-9513-A570379DC255. The mirror reads this to know where to POST scan data over WiFi. - Write the byte
0x32to thescannerActioncharacteristic at767D14AA-…. That’s the action multiplexer’s command forinitDeviceForScan. - Listen on
scanState(UUID04285A29-…) for the mirror to transition throughALIGN_SCALE → WARMING_UP → ... → COMPLETE.
Drawn out, the exchange I was aiming for:
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-1777855004That’s 51 bytes. CoreBluetooth refused.
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.
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.
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:
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:
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.
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 = 05Decoding via the enums I’d extracted from the APK:
ScanState 9=ERRORScanError 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.
What I’m trying next
-
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.
-
If
PROCESSOR_BUSYpersists, sendCommand.cancelScan(0x36) to the action multiplexer first, beforeinitDeviceForScan. 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. -
Watch the
scanStatebyte sequence. The real APK enum isINVALID(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. Hitting8or a POST on the local listener confirms the full path works. -
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.
Takeaways
CBATTError.invalidAttributeValueLengthalways 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.
d12345works as well asdryrun-1777855004and 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.
Follow along
Procedure 1-AIf you hit The value's length is invalid (CBATTError.invalidAttributeValueLength) writing to the mirror, here’s the clean fix:
- 1Log the write budget as soon as characteristics are discovered:
peripheral.maximumWriteValueLength(for: .withResponse). Two lines, runs once per connection, and you never have to guess the ceiling again. - 2Keep the cloudToken upload URL short. The IP, port, and
/upload/prefix are fixed; the only free part is the scan id. Use a tiny one liked12345, which keeps the whole URL around 40 bytes. - 3Validate the URL’s byte length against the budget before you write it, and fail with a clear message if it’s over. That turns the cryptic ATT error into a log line that tells you exactly what happened.
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.