My Soundcore Space 2 pairs with my MacBook and sounds exactly as it should. But
the moment I want to turn noise cancelling off, or pull the bass back, I have to
pick up my phone and open the Soundcore app — because Anker only ships that app
for Android and iOS.
The headphones are right there. The Mac is right there. The controls are on a
third device.
So over a single weekend, I decided to pair-program and co-engineer a solution with
Claude to see if we could reverse-engineer the protocol and build
SoundcoreBridge: a native macOS menu bar app
and CLI that speaks the same protocol. And it actually worked.
This is the story of how that weekend went, what co-engineering low-level hardware
code with an LLM looks like in practice, and the two occasions I confidently
concluded macOS was broken when the bug was mine.
Finding the door
Bluetooth devices advertise their services over SDP. Dumping the Space 2's
records shows the usual audio profiles — A2DP, AVRCP, handsfree — and then some
more interesting entries:
ch 30 PaulWang-Spp 0cf12d31-fac3-4553-bd80-d6832e7d1402 <- vendor control
ch 17 RFCOMM COM df21fe2c-2515-4fdb-8886-f12c4d67927c
ch 12 TOTA 0x1101 <- firmware OTA
ch 13 BESOTA 66666666-... <- chipset OTA
ch 16 IOSSPP 00000000-deca-fade-deca-deafdecacaff <- Apple iAP2
That first UUID shares its prefix (0cf12d31-fac3-4553-bd80-) with the
documented Soundcore control UUID; the suffix is per-model. Channel 30 is the
door.
Channels 12 and 13 flash firmware. Those are hard-blocked in my code by service
identity, and I'd suggest anyone doing this does the same before writing a
single byte.
The frame format
Prior art from other Soundcore projects gave me the shape:
08 EE 00 00 | 00 | 06 81 | 10 00 | 00 5F 02 00 00 01 | EF
└─ magic ─┘ seq └ cmd ┘ └ len ┘ └─── payload ──┘ checksum
Two-byte command, little-endian total length, and a checksum that's just the sum
of every preceding byte truncated to 8 bits. Device replies swap the magic to
09 FF 00 00.
Before touching hardware I implemented the codec and checked it against six
packets documented for other models. All six reproduced byte-for-byte. That
five-minute investment meant that later, when nothing worked, I could rule out
the encoder completely.
Two days of "macOS blocks this"
Then I tried to open the channel, and nothing happened.
openRFCOMMChannelAsync returned success and the delegate callback never fired.
The synchronous variant returned kIOReturnError immediately. I tried channel
30, then 17, then channel 1 (handsfree — definitely exists), then channel 2
(nothing advertised). Identical failure every time.
So I ran what I thought was a decisive control experiment: I tried opening
RFCOMM to a completely different device — an Android phone, on standard
services like OBEX and phonebook access. Same failure.
Two devices, seven channels, standard services, from both a bare binary and a
signed .app. bluetoothd logs showed no connect attempt ever reaching the air.
I checked TCC (granted), App Sandbox (not sandboxed), the baseband link
(connected). I wrote it up as a platform limitation and started designing a
Raspberry Pi bridge.
I was wrong.
Claude — who I was pair-programming with throughout the weekend — pushed back on my
theory and surfaced SonyBridge, an
open-source project doing exactly this on macOS. It exists. It works. That single
pointer broke my confirmation bias. I read its implementation, reproduced its
approach in a standalone spike with Claude, and the channel opened.
Diffing my spike against my app turned up three things:
1. The first openRFCOMMChannelAsync in a process always fails. It appears
to lazily initialise IOBluetooth's run-loop source, and the delegate never fires
for that attempt. A retry succeeds. My spike had accidentally made two throwaway
attempts against the phone before the one that worked — which is also why my
"control experiment" failed identically on both devices. It was measuring my own
bug twice and I read it as corroboration.
2. IOBluetooth delivers delegate callbacks on the run loop of the thread that
opened the channel — not on a dispatch queue. I was awaiting a Swift
continuation, which never sees them. You have to pump the run loop:
func pump(_ seconds: TimeInterval, until: () -> Bool = { false }) {
let deadline = Date().addingTimeInterval(seconds)
while Date() < deadline {
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
if until() { return }
}
}
This is also why CoreBluetooth worked fine for me all along while IOBluetooth
never did — CoreBluetooth uses a dispatch queue. I had that evidence and read it
backwards.
3. Never close a failed attempt. The close lands asynchronously and tears
down whichever channel succeeds next.
Reads work. Writes vanish.
With the channel open, 01:01 returns 103 bytes, and there's readable ASCII in
there straight away — 01.59, 1402, and the headset's own MAC. Firmware,
model, serial.
Writes were another matter. I sent what I was sure was the noise-cancelling
command and got nothing. No error. No NAK. No state change. The device
acknowledged reads perfectly and acted as though writes had never arrived.
I burned a lot of time guessing command bytes. What actually solved it was
capturing the Android app's own traffic: enable Bluetooth HCI snoop log in
developer options, drive the app, pull a bug report, and scan the capture for
the frame magic.
The capture showed a handshake the app performs on connect:
01:01 device info
05:01 payload 01 capability table
05:81 (no payload) x2
05:81 payload 01
05:81 (no payload)
18:85 payload 01
Until that runs, the device discards every write in silence. That's not
documented anywhere I could find, and I don't think I'd ever have guessed it.
The same capture caught something subtler. The sound-mode write is 06:81 with
payload [mode, level, 02, 00, 00, 01]. I'd been building that payload by
reading the current block back and modifying it — but the read returns FF in
byte 2 where the write requires 02. Echoing back what you read gets you
silently ignored. That single byte was most of a day.
Mapping the rest by diffing
Once writes worked, the remaining fields fell out of a simple loop: hold the
channel open from the Mac, poll the state blob, change one setting on the phone,
and diff the bytes.
CHANGED 01:01
byte[71] (0x47): 02 → 01
That's the ANC mode byte. Same method found the EQ preset at byte 23, the eight
band values at 25–32, and — after an embarrassing detour — the ANC strength in
the high nibble of byte 72, not byte 70 as I first assumed. Byte 70 is a
constant 05: the number of levels supported, not the level selected.
Battery is byte 0, and it reads 0–9 rather than a percentage. That's Apple's
accessory convention (AT+IPHONEACCEV): percent is (level + 1) × 10. I only
worked that out because the raw byte said 5 while both macOS and the phone app
said 60%.
The official app has a bug
While verifying the strength levels I found something I didn't expect.
I set level 4 from the Mac, reconnected the phone, and watched the exchange. The
app asked the headset for its sound mode. The headset replied truthfully:
11:46:10 app → 06:01
11:46:10 dev → 00 4F FF 00 00 01 (level 4)
app displays: 2
It queries, receives the correct answer, and renders its own cached value
instead. So if you change anything from another host, Anker's app lies to you
about the current state. Mine reads it from the device.
Making it work for more than one model
The frame format is identical across Soundcore devices. What differs is where
fields sit in the state blob, which value means which sound mode, and how many
EQ bands there are. So a device is a data structure, not new protocol code:
static let space2 = DeviceProfile(
modelCode: "1402", displayName: "Soundcore Space 2",
batteryOffset: 0, ancModeOffset: 71, ancLevelOffset: 72,
eqPresetOffset: 23, eqBandsStart: 25, eqBandCount: 8,
features: [.battery, .soundMode, .ancLevel, .equaliser, .customEQ]
)
Unknown models resolve to a read-only profile: battery and firmware, no writes.
I own exactly one Soundcore device, so that's the only one whose offsets I've
confirmed. Shipping guessed offsets to someone else's headphones isn't a risk
worth taking for the sake of a longer compatibility table.
What I'd tell myself at the start
Verify by reading back, not by absence of an error. Every real bug in this
project was silent. The device never once told me I was wrong — it just ignored
me. confirmed: byte[71] = 0x00 is worth more than any number of successful
writes.
Check that your control experiment isn't measuring the same bug. Mine tested
a second device and gave me false confidence, because the defect was in the code
common to both paths.
Prior art is the cheapest debugging tool there is. I'd written off the whole
platform. One link to a project that already did it turned two days of wrong
conclusions around in an hour.
Ground your AI pairing in real packet captures. Claude excelled at spotting
byte patterns, generating Swift codecs, and writing offline tests, but only
because every prompt was grounded in real hex dumps and HCI logs. Without ground-truth
captures, pair-programming on hardware quickly turns into mutual guesswork.
SoundcoreBridge is MIT, Swift, macOS 13+. The full protocol map — state blob
layout, handshake, all 22 EQ presets with band values, and the IOBluetooth traps
— is in the repo, so if you'd rather write your own client than use mine, the
hard part is already done.
github.com/mervin008/soundcorebridge
Not affiliated with Anker or Soundcore. Firmware-update channels are blocked in
code; the protocol work is interoperability reverse engineering on hardware I own.

Top comments (1)
The "never close a failed attempt" trap is the one that would have cost me the most time too. Async teardown landing after the next connect has that exact signature: reads keep working while writes go nowhere, no error, no state change, and every command byte you try looks guilty.
The HCI snoop log route was the right call, and the payload detail you found is worth repeating: reads returning FF where writes require 02 means the device never intended the read block to be a write template. I hit the same class of thing on a BLE bike trainer that wanted a handshake before it would accept any control point writes at all. Did you keep a capture-driven test around the handshake, or is it documented-only for now?