Your Adapter Isn't Broken: A Swift String Bug That Looked Like Corrupted Hardware

September 12, 2026 · Serg

For weeks, our telemetry pointed at one conclusion: a specific class of cheap Bluetooth OBD adapter was corrupting data mid-scan. The evidence looked damning. The actual cause was three characters long, present in Swift's standard library since day one, and entirely our own doing.

The Symptom

CellPulse reads a VW MEB battery's pack configuration — cell count, chemistry, pack code — over a multi-frame ISO-TP request. On most adapters this comes back clean:

q82K096S3PLG0qHEIZKU

On one specific cheap clone, it came back like this instead:

q{!82K096S{"3PLG0q{#

Real data, interleaved with garbage — {!, {", {# — at what looked like fixed, deliberate intervals. That's a very specific shape. It's the kind of thing a firmware bug produces, not random noise. We wrote it up as exactly that: a clone chip corrupting its own multi-frame responses.

The Investigation

Before shipping a fix based on that theory, we wanted to see the actual bytes on the wire — not the string our own app had already processed. So we built a small, from-scratch BLE console app with zero shared code with the main app: connect, send a raw AT command, log every notification packet exactly as Bluetooth delivers it, no parsing, no reassembly.

Running the identical request through it, on the identical hardware, gave us this:

17 FE 00 7B 10 3C 62 F1 B3 04 71 00
17 FE 00 7B 21 38 32 4B 30 39 36 53
17 FE 00 7B 22 33 50 4C 47 30 71 01
17 FE 00 7B 23 48 45 49 5A 4B 55 45
17 FE 00 7B 24 48 59 59 59 59 71 02

Completely clean. Every frame, correctly sequenced, zero injected bytes. The adapter's actual output was fine — it just stopped early (a real, separate, adapter-side limitation on long reads that no software fix changes). The corruption only existed in our own app's version of the same data. Which meant the bug was ours.

The Discovery

Our response parser split each line of the adapter's output like this:

response.split(separator: "\r")

Reasonable-looking code. Most adapters terminate each line with a bare \r, and this had worked fine against every one of them. This particular clone, though, terminates lines with \r\n instead — and that's where things go wrong, in a way that has nothing to do with Bluetooth at all:

"AAA\r\nBBB\rCCC".count
// 11 — \r\n counts as ONE Character

"AAA\r\nBBB\rCCC".split(separator: "\r")
// ["AAA\r\nBBB", "CCC"] — never splits at the \r\n boundary

Swift's String treats \r\n as a single extended grapheme cluster — one Character, not two. It's the same Unicode rule that keeps emoji with skin-tone modifiers, or a base letter plus a combining accent, from splitting apart when you iterate a string. Carriage-return-plus-linefeed is explicitly one of the sequences Unicode's segmentation algorithm treats as a single user-perceived character. split(separator: "\r") compares by Character equality, so the bare "\r" literal — a different, one-scalar Character — never matches anywhere inside a "\r\n" cluster.

The practical effect: against a \r\n-terminated device, our split never fires at all. A five-line response collapses into a single "line." Our code then walked every space-separated token in that giant blob looking for hex bytes — and dutifully found some: the next four lines' own address headers (17 FE 00 7B) and sequence bytes, sitting right where real payload was expected. 0x7B plus an incrementing sequence number is exactly {!, {", {# in ASCII — the "corruption" we'd been chasing was our own header bytes, misread as data.

The Fix

Normalize every line-ending style to one before splitting, rather than assuming which one a given device uses:

let normalized = response
    .replacingOccurrences(of: "\r\n", with: "\n")
    .replacingOccurrences(of: "\r", with: "\n")
let lines = normalized.split(separator: "\n")

Verified against the real captured bytes from both the broken and working cases: the fix produces the correct five-line split either way, and the field-observed corruption pattern no longer reproduces.

The Lesson

Two, really. The narrow one: if you're parsing any line-oriented byte stream in Swift — serial, BLE, an old network protocol — don't assume a device uses \r or \n specifically. Normalize both before you split, or the input from a real \r\n device will silently do something much stranger than "fail to split" — String.split won't error, it'll just quietly return one giant chunk.

The broader one: "the hardware is corrupting data" is a satisfying story, and this specific clone genuinely does have an unrelated real limitation (it can't reliably finish long reads — no fix changes that). But the interleaved-garbage pattern that looked like proof of a firmware bug was, byte for byte, a parsing bug in our own code. Before shipping a fix for "bad hardware," it was worth twenty minutes to go look at the actual bytes.

Try CellPulse

CellPulse reads real per-cell voltage and temperature data from VW Group MEB EVs (ID.3/4/5, Buzz, Audi Q4 e-tron, Skoda Enyaq, CUPRA Born) over a BLE OBD-II adapter, and produces a transparent health report. See our tested adapter list and get it on the App Store or Google Play.