Reverse Engineering

Cracking OPOv1:
Reverse Engineering the OnePlus Buds Protocol

A story of frustration, failure, and finally cracking the ANC protocol that OnePlus refused to document.

Part 1

The Frustration

I was working on my MacBook, earbuds in. I decided to change from Transparency to ANC because I needed to focus.

I tried pinching the earbuds to toggle the mode, but it didn't work. The ANC just wouldn't switch over to Transparency (or vice versa) no matter how many times I tried the gesture.

I thought, "No problem, I'll just download the official app on my Mac and change it from there." But when I went to look for it, I realized there is no app. There is absolutely no desktop software to control these things.

That's when I started searching for a solution.

The real problem: I paid for these earbuds. They're connected to my laptop right now via Bluetooth. The hardware link exists — but I have zero software control over it unless I use a phone. That's absurd.

Part 2

The Search

I figured someone must have solved this already. These earbuds aren't brand new. There are developers out there who reverse-engineer everything. Surely one of them had already written a script.

  • "OnePlus Nord Buds 3 Pro macOS" — just Reddit threads of other people asking the same question, with no answers.
  • "Nord Buds ANC script" — nothing. "HeyMelody API" — nothing. "OnePlus BLE protocol" — absolutely nothing.
  • GitHub search for "nord buds" — a few apps for Nord Buds 2, but nothing for the 3 Pro, and nothing in Swift or Python that actually talked to the hardware.

What surprised me wasn't just that OnePlus didn't make a desktop app. It's that they never published anything. No API docs, no developer SDK, no blog post explaining how their protocol works — not even a GitHub issue where someone had figured it out. For a company that sells hundreds of millions of devices, they put zero effort into letting anyone outside their own team do anything with them.

Big companies treat their protocols like trade secrets. Lock you into their ecosystem, limit your control to their apps, and if you use anything other than what they officially support — you're on your own. It's not about security, it's about control.

Part 3

The Decision

Fine. I'll do it myself.

I knew the earbuds were connected via Bluetooth Low Energy — BLE — and I knew that BLE devices always expose their features through a standard interface called GATT. That interface is readable by any device with Bluetooth. My Mac could see it. It was just a question of understanding what to send.

I had three things going for me:

  • 1A Bluetooth HCI snoop log (btsnoop) from my Android phone showing the raw packet exchange between HeyMelody and the buds
  • 2The HeyMelody APK, which I could decompile and read the Java source to understand what it was doing
  • 3CoreBluetooth on macOS — Apple's first-class BLE API that lets you read, write, and subscribe to characteristics from a Swift script
Part 4

First Steps — Understanding BLE

Before diving in: here's how Bluetooth Low Energy actually works under the hood. This context is critical for everything that follows.

The GATT Model

BLE devices advertise their capabilities using a layered structure called GATT (Generic Attribute Profile). Think of it as a file system — with folders (services) containing files (characteristics), each identified by a UUID.

Services

Groups of related functionality. Each has a UUID. The Nord Buds expose 6+ services — one for battery, one for device info, one for the OPO protocol, etc.

Characteristics

Individual data endpoints inside a service. Each has properties: READ, WRITE, NOTIFY, or combinations. ANC control lives inside one of these.

Notifications

Instead of polling, you subscribe to a characteristic and the device pushes updates to you. Responses from the earbuds come back this way.

Write With vs. Without Response

This distinction sounds minor but it bit me for weeks. In CoreBluetooth there are two write types:

  • .withResponse — the peripheral must send an ATT acknowledgement before you can send the next packet. Safe, slow, used for critical writes.
  • .withoutResponse — fire-and-forget. No ATT-level ack. Faster, and required for characteristics that don't support confirmed writes.

The Nord Buds' OPO command characteristic only supports .withoutResponse. Use the wrong one and the write silently fails — no error, no response, nothing. Hours lost.

HCI Snoop Logs (btsnoop)

Android's developer options let you enable Bluetooth HCI snoop logging. Once enabled, every BLE packet going in or out of your phone is written to a .log file. You copy that to your laptop, open it in Wireshark, filter by your earbud's MAC address, and you can see exactly what HeyMelody sends — byte by byte — every time you tap a button in the app.

This is the single most powerful tool I had. The packets don't lie.

Part 5

Service Discovery Hell

First thing I did was enumerate every service and characteristic the buds expose. Here's what came back:

Service UUIDTypeRole
0000079A-D102-11E1-9B23-00025B00A5A5OPO Protocol← THIS is the main command channel
FE2C1234-8366-4814-8EB0-01DE32100BEAOnePlus customTelemetry / firmware update
66666666-6666-6666-6666-666666666666Unknown customPossibly diagnostics
0000079C-...OPO extendedAdditional commands (SCO / A2DP)
180AStandard BLEDevice Information (manufacturer, model)
180FStandard BLEBattery Level (simple, not detailed)

The 0000079A UUID is the OPO (OnePlus/Oppo) proprietary protocol service — shared across OnePlus, Oppo, and Realme devices. It has two key characteristics inside it:

  • 0100079A — Write-only. This is where you send commands.
  • 0200079A — Notify-only. This is where responses come back.

Then there's FE2C — a dense service with many characteristics. Lots of readable values, lots of writable ones. It looked important. My first instinct was to explore it. That was my first mistake.

Part 6

The False Starts

Weeks. I spent weeks going nowhere. Here's a log of every wrong assumption I made:

Mistake #1 — Wrong Service (FE2C)

FE2C had the most characteristics and the most readable data — so naturally I assumed it was the main control channel. Wrong. FE2C handles low-level diagnostics, firmware update status, and device telemetry. Writing to its characteristics either silently no-ops or triggers some internal logging mode. ANC is not in there.

Mistake #2 — Wrong Write Type

I was using .withResponse on the 0100079A characteristic. The write would "succeed" (CoreBluetooth didn't throw an error) but nothing happened on the earbuds. This is because the characteristic only accepts .withoutResponse writes — and if you use the wrong type, the peripheral silently ignores you. Zero indication anything went wrong.

Mistake #3 — Phantom CRC

Looking at packets in Wireshark I kept seeing a trailing byte that looked like a checksum. I spent days trying to reverse-engineer a CRC8 or CRC16 algorithm. There is no CRC. There is no checksum at all. The length byte accounts for everything. The "extra" byte at the end was just part of the data payload — I was miscounting from the wrong offset.

Mistake #4 — No Authentication

My first tests skipped the Hello + Register sequence and jumped straight to sending an ANC command. The earbuds just... ignored it. There's no error response — the device won't tell you "you're not authenticated." It simply doesn't act on unauthenticated commands. Without the handshake, nothing works.

Part 7

Decompiling the App

I pulled the HeyMelody APK from my phone and ran it through jadx — a Java decompiler that reconstructs readable source from Android bytecode. The output is messy, obfuscated, class names like C0878a.java, but the logic is there.

Inside the BtOperate class I found the ANC command builder:

java
1public final void E(String str, CurrentNoiseModeInfo currentNoiseModeInfo) {
2    // str = device MAC address
3    // currentNoiseModeInfo.getData() = the mode byte (01, 02, or 04)
4    ((HeadsetCoreService) dVar.f14272b).l(str, 
5        ((o5.b) dVar.f14271a).a(str, 1028, currentNoiseModeInfo.getData()));
6}

The number 1028 is the key. In hex: 0x0404. Split into two bytes:

  • High byte 0x04 = Category: ANC subsystem
  • Low byte 0x04 = Sub-command: Set (write new value)

And from the UUID constants file, confirming the service:

java
1// The OPO BLE GATT service UUIDs - this is where commands go
2public static final List<String> f2524q = 
3    l.c("0000079A-D102-11E1-9B23-00025B00A5A5",   // ← BLE GATT (what we need)
4        "00001107-D102-11E1-9B23-00025B00A5A5");   // ← SPP (classic BT, not relevant)

That confirmed it: 0000079A is the right service. Not FE2C. Two weeks of going in the wrong direction — one line in a decompiled APK set me straight.

Part 8

Packet Analysis

With the correct service confirmed, I went back to the btsnoop capture and filtered for packets on 0000079A with Category byte = 0x04. Three packets jumped out immediately — one for each ANC mode:

ModeFull PacketLast Byte
ANC OFFAA 0A 00 00 04 04 40 03 00 01 01 040x04
ANC ONAA 0A 00 00 04 04 44 03 00 01 01 010x01
TransparencyAA 0A 00 00 04 04 42 03 00 01 01 020x02

Notice: 11 bytes are identical across all three packets. The only thing that changes is the very last byte. That's the mode selector. Everything else — the framing, the length, the category, the sequence number — is boilerplate.

The sequence number (byte 6) also increments: 0x40, 0x42, 0x44. This lets the device match responses to requests. You don't strictly need to increment it correctly for a one-shot command, but the device uses it for ordering.

Then for the authentication packets:

hex
1# Step 1: HELLO — opens the session, no auth yet
2# Byte 5 = 0x01 (sub-command: Hello), Byte 6 = 0x23 (sequence)
3AA 07 00 00 00 01 23 00 00 12
4
5# Step 2: REGISTER — sends the device token
6# Byte 5 = 0x85 (sub-command: Register), token starts at byte 10
7AA 0C 00 00 00 85 41 05 00 00 B5 50 A0 69

The token B5 50 A0 69 is a device-specific identifier — probably derived from the MAC address or baked into the firmware. It's the same every time for a given pair of buds. Send it after the Hello and the device opens a trusted session.

Part 9

The Dark Night

I had everything I needed. I wrote the Swift code. I implemented the sequence exactly:

  1. Connect to the 0000079A service
  2. Subscribe to the notify characteristic
  3. Send Hello
  4. Wait 2 seconds
  5. Send Register with token
  6. Wait 1.5 seconds
  7. Send ANC Query
  8. Wait 1.5 seconds
  9. Send ANC Set

I ran the script. And...

Nothing. The earbuds just sat there. No response on the notify characteristic. No mode change sound. No output. The packets went out — I could see them being written — but nothing came back.

I checked the write type — correct. Checked the UUIDs — correct. Checked the packet bytes against the btsnoop — identical. There was no logical reason it shouldn't work.

I was genuinely close to giving up. The official app exists. My phone is right there. Maybe I just accept that this is how it is. Maybe some things aren't worth cracking open.

Dramatic visualization
Part 10

The Breakthrough

One last thing to try. I'd been subscribing to notifications only on the 0200079A characteristic — the notify char inside the OPO service. What if the device was sending responses back on the other service? What if FE2C was the response channel, even though 0000079A was the command channel?

I modified the code to subscribe to notifications on every characteristic across both services simultaneously. Sent the same command sequence.

That little tri-tone that plays when ANC switches on. The earbuds changed mode. It actually worked.

The device had been processing my commands the entire time. I just wasn't receiving its responses because I was listening on the wrong channel. Subscribing to both made the round-trip complete.

Part 11

Deep Protocol Analysis

Now that it works, here's the full picture — every byte, every timing constraint, every category code.

OPO Protocol — Packet Structure

Every packet starts with 0xAA and follows this exact layout. The very last byte is the only thing that changes between ANC ON / OFF / Transparency.

byte 0SOFAAStartbyte 1LEN0ALengthbyte 2PAD00Padbyte 3PAD00Padbyte 4CAT04Catebyte 5SUB04Subbyte 6SEQ42Seqbyte 7FLAG03Flagbyte 8D000byte 9D101byte 10D201byte 11MODE01ANC

Field-by-field Breakdown

ByteFieldSizeWhat it meansValue in example
0SOF1 BStart-of-Frame marker — every valid packet begins with 0xAA. If you don't see AA at byte 0, the packet is garbage or you're reading the wrong characteristic.AA
1LEN1 BLength of everything from byte 4 (CAT) to the end. 0x0A = 10 means 10 more bytes follow after byte 3. Use this to know when a packet ends.0A (= 10)
2–3PAD2 BAlways 0x00 0x00. Padding / reserved. Probably for future use or alignment. Always zero — don't touch them.00 00
4CAT1 BCategory — which subsystem are we talking to? 0x00 = System (hello/register), 0x03 = Device Info, 0x04 = ANC, 0x05 = Equalizer, 0x06 = Battery.04 (ANC)
5SUB1 BSub-command within the category. 0x04 = Set (write a new value), 0x82 = Query (ask what the current value is), 0x01 = Basic command, 0x85 = Registration.04 (Set)
6SEQ1 BSequence number — a simple counter so both sides can match requests to responses. Increments by 2: 0x40 → 0x42 → 0x44. The device echoes this back in its ACK.42
7FLAG1 BMode flags — always 0x03 for ANC set commands. Likely a bitmask: bit 0 = persist to flash, bit 1 = notify both buds simultaneously.03
8–10DATA3 BFixed payload for ANC commands. Always 0x00 0x01 0x01. Possibly: reserved byte + left-bud flag + right-bud flag (both enabled).00 01 01
11MODE1 BTHE important byte. 0x01 = ANC on (noise cancellation active), 0x02 = Transparency (lets ambient sound through), 0x04 = ANC off (no processing).01 / 02 / 04

Known OPO Category Codes (CAT byte)

CATSystem nameWhat you can doSub-commands
0x00SystemSay hello, register, keep-alive0x01 Hello · 0x85 Register
0x03Device InfoFirmware version, model, serial0x01 Query → 0x81 Response
0x04ANCRead or set noise-cancellation mode0x82 Query · 0x04 Set · 0x81 Response
0x05EqualizerRead or set EQ preset0x01 Query · 0x02 Response
0x06BatteryRead left, right, and case charge levels0x01 Query → 0x81 Response

The Complete Command Sequence — Annotated

Here is the exact sequence of packets you send and the device's response to each one:

Complete Working Sequence

Every packet sent between your Mac and the Nord Buds — in order, with timing.

Your MacNord Buds 3 Prowait ~2 swait ~1.5 swait ~1.5 sHELLOAA 07 00 00 00 01 23 00 00 12"Hi, I want to talk to you"ACK01 00 81 ...Device acknowledges the greetingREGISTER + tokenAA 0C 00 00 00 85 41 05 B5 50 A0 69"Here's my identity — trust me"ACK01 00 81 ...Token accepted — session openQUERY ANC statusAA 09 00 00 04 82 44 02 00 00 F2"What mode are you in right now?"Current mode responseAA .. 04 82 .. [mode byte]Returns 01 / 02 / 04SET ANC modeAA 0A 00 00 04 04 42 03 00 01 01 [01|02|04]"Switch to this mode please"✓ Confirmedmode change appliedYou hear the ANC clickdone → exit
System / query command (Host → Buds)
Authentication (Host → Buds)
ANC set command + confirmation
Device acknowledgement (Buds → Host)
Data response (Buds → Host)

Why the Timing Matters

The earbuds run on a low-power microcontroller. They don't have a fast OS or a large buffer. If you fire packets too quickly, they pile up in the GATT queue and the device processes them out of order — or drops them entirely. If you wait too long between Register and the first command, the session token expires and you're back to unauthenticated.

TransitionMinimum delayRecommendedWhy
HELLO → REGISTER~1.5 s2.0 sDevice needs to initialise the session and prep its token validator
REGISTER → QUERY~1.0 s1.5 sToken validation and state sync between L + R buds via internal RF
QUERY → SET~1.0 s1.5 sQuery must complete and response must be acknowledged before set

The Mode Byte Values

swift
1// ANC mode values — byte 11 of the SET command
20x01ANC ON         (active noise cancellation, microphones working)
30x02Transparency   (lets ambient sound through, mic open)
40x04ANC OFF        (no processing, passive isolation only)
5
6// Why these specific values?
7// Likely a bitmask: bit 0 = ANC active, bit 1 = ambient mic, bit 2 = bypass
8// 0x01 = 0b00000001 → ANC on
9// 0x02 = 0b00000010 → ambient mic on
10// 0x04 = 0b00000100 → bypass mode (no processing)

The Raw Packets — All Four Steps

swift
1// Step 1: HELLO (always the same)
2let hello: [UInt8] = [
3    0xAA,  // SOF — start of frame
4    0x07,  // LEN — 7 bytes follow (from CAT to end)
5    0x00,  // PAD
6    0x00,  // PAD
7    0x00,  // CAT — 0x00 = System
8    0x01,  // SUB — 0x01 = Hello
9    0x23,  // SEQ — sequence number
10    0x00,  // data
11    0x00,  // data
12    0x12,  // data
13]
14
15// Step 2: REGISTER (send auth token B5 50 A0 69)
16let register: [UInt8] = [
17    0xAA,  // SOF
18    0x0C,  // LEN — 12 bytes follow
19    0x00,  // PAD
20    0x00,  // PAD
21    0x00,  // CAT — 0x00 = System
22    0x85,  // SUB — 0x85 = Register
23    0x41,  // SEQ
24    0x05,  // data
25    0x00,  // data
26    0x00,  // data
27    0xB5,  // ← token byte 1
28    0x50,  // ← token byte 2
29    0xA0,  // ← token byte 3
30    0x69,  // ← token byte 4
31]
32
33// Step 3: QUERY current ANC status
34let query: [UInt8] = [
35    0xAA,  // SOF
36    0x09,  // LEN — 9 bytes follow
37    0x00,  // PAD
38    0x00,  // PAD
39    0x04,  // CAT — 0x04 = ANC
40    0x82,  // SUB — 0x82 = Query
41    0x44,  // SEQ
42    0x02,  // data
43    0x00,  // data
44    0x00,  // data
45    0xF2,  // data
46]
47
48// Step 4: SET ANC mode  ← the whole point
49let ancSet: [UInt8] = [
50    0xAA,  // SOF
51    0x0A,  // LEN — 10 bytes follow
52    0x00,  // PAD
53    0x00,  // PAD
54    0x04,  // CAT — 0x04 = ANC
55    0x04,  // SUB — 0x04 = Set
56    0x42,  // SEQ
57    0x03,  // FLAG — 0x03 = persist + sync both buds
58    0x00,  // DATA
59    0x01,  // DATA — 0x01 = both buds targeted
60    0x01,  // DATA
61    mode,  // MODE — 0x01 ANC / 0x02 Transparency / 0x04 Off
62]
Part 12

Full Implementation

The complete Swift script. Drop it anywhere, chmod +x it, run it with swift nordbud.swift on. Requires macOS with Bluetooth powered on and the buds already paired.

swift
1#!/usr/bin/env swift
2
3import Foundation
4import CoreBluetooth
5
6let VERSION = "1.0.0"
7
8enum Command {
9    case ancOn
10    case ancOff
11    case transparency
12    case battery
13    case info
14    case eq
15    case help
16}
17
18class NordBudsCLI: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
19    
20    var centralManager: CBCentralManager!
21    var peripheral: CBPeripheral?
22    var cmdChar079A: CBCharacteristic?
23    var notifyChar079A: CBCharacteristic?
24    var cmdCharFE2C: CBCharacteristic?
25    var command: Command = .help
26    var done = false
27    
28    // MARK: - Initialization
29    
30    override init() {
31        super.init()
32        centralManager = CBCentralManager(delegate: self, queue: .main)
33    }
34    
35    // MARK: - Bluetooth State
36    
37    func centralManagerDidUpdateState(_ central: CBCentralManager) {
38        guard central.state == .poweredOn else {
39            print("[ERROR] Bluetooth is not powered on")
40            exit(1)
41        }
42        
43        // First, check if already connected
44        let retrieve = central.retrieveConnectedPeripherals(withServices: [
45            CBUUID(string: "0000079A-D102-11E1-9B23-00025B00A5A5")
46        ])
47        
48        if !retrieve.isEmpty {
49            self.peripheral = retrieve[0]
50            retrieve[0].delegate = self
51            central.connect(retrieve[0], options: nil)
52            return
53        }
54        
55        print("[*] Scanning for Nord Buds...")
56        centralManager.scanForPeripherals(withServices: nil, options: nil)
57    }
58    
59    func centralManager(_ central: CBCentralManager, 
60                       didDiscover peripheral: CBPeripheral, 
61                       advertisementData: [String : Any], 
62                       rssi RSSI: NSNumber) {
63        
64        if let name = peripheral.name, 
65           (name.contains("Nord Buds") || name.contains("OnePlus")) {
66            print("[FOUND] \(name)")
67            self.peripheral = peripheral
68            peripheral.delegate = self
69            centralManager.stopScan()
70            centralManager.connect(peripheral, options: nil)
71        }
72    }
73    
74    // MARK: - Connection
75    
76    func centralManager(_ central: CBCentralManager, 
77                       didConnect peripheral: CBPeripheral) {
78        print("[OK] Connected to \(peripheral.name ?? "Nord Buds")")
79        peripheral.discoverServices(nil)
80    }
81    
82    func peripheral(_ peripheral: CBPeripheral, 
83                   didDiscoverServices error: Error?) {
84        for service in peripheral.services ?? [] {
85            peripheral.discoverCharacteristics(nil, for: service)
86        }
87    }
88    
89    // MARK: - Characteristic Discovery
90    
91    func peripheral(_ peripheral: CBPeripheral, 
92                   didDiscoverCharacteristicsFor service: CBService, 
93                   error: Error?) {
94        
95        for char in service.characteristics ?? [] {
96            let props = char.properties.rawValue
97            
98            // OPO Service (0000079A)
99            if char.uuid.uuidString == "0100079A-D102-11E1-9B23-00025B00A5A5" {
100                cmdChar079A = char
101                print("[+] Found Write Char: 0100079A")
102            }
103            
104            if char.uuid.uuidString == "0200079A-D102-11E1-9B23-00025B00A5A5" {
105                notifyChar079A = char
106                peripheral.setNotifyValue(true, for: char)
107                print("[+] Found Notify Char: 0200079A")
108            }
109            
110            // FE2C Service
111            if char.uuid.uuidString == "FE2C123A-8366-4814-8EB0-01DE32100BEA" {
112                cmdCharFE2C = char
113                peripheral.setNotifyValue(true, for: char)
114                print("[+] Found FE2C Command Char")
115            }
116            
117            // Enable notifications for all characteristics that support it
118            if props & 16 != 0 || props & 32 != 0 {
119                peripheral.setNotifyValue(true, for: char)
120            }
121            
122            // Read all readable characteristics
123            if props & 2 != 0 {
124                peripheral.readValue(for: char)
125            }
126        }
127        
128        // Once we have the command characteristic, run the command
129        if cmdChar079A != nil {
130            DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { 
131                self.runCommand() 
132            }
133        }
134    }
135    
136    // MARK: - Response Handling
137    
138    func peripheral(_ peripheral: CBPeripheral, 
139                   didUpdateValueFor characteristic: CBCharacteristic, 
140                   error: Error?) {
141        
142        guard let data = characteristic.value else { return }
143        let bytes = [UInt8](data)
144        let hex = bytes.map { String(format: "%02X", $0) }.joined(separator: " ")
145        
146        print("[RX] \(characteristic.uuid): \(hex)")
147        
148        // Battery response (CAT=0x06, SUB=0x81)
149        if bytes.count >= 10 && bytes[4] == 0x06 && bytes[5] == 0x81 {
150            parseBattery(bytes)
151        }
152        
153        // Device info response (CAT=0x03, SUB=0x81)
154        else if bytes.count >= 6 && bytes[4] == 0x03 && bytes[5] == 0x81 {
155            parseDeviceInfo(bytes)
156        }
157        
158        // EQ response (CAT=0x05, SUB=0x02)
159        else if bytes.count >= 6 && bytes[4] == 0x05 && bytes[5] == 0x02 {
160            parseEQ(bytes)
161        }
162        
163        // Registration ACK
164        else if bytes.count >= 3 && bytes[2] == 0x81 {
165            print("[RX] Registration acknowledged")
166        }
167    }
168    
169    // MARK: - Response Parsing
170    
171    func parseBattery(_ bytes: [UInt8]) {
172        print("\n========== BATTERY INFO ==========")
173        if bytes.count >= 16 {
174            let leftBat = bytes[12]
175            let rightBat = bytes[14]
176            let caseBat = bytes[15]
177            print("Left Bud:  \(leftBat)%")
178            print("Right Bud: \(rightBat)%")
179            print("Case:     \(caseBat)%")
180        }
181        print("==================================\n")
182    }
183    
184    func parseDeviceInfo(_ bytes: [UInt8]) {
185        print("\n========== DEVICE INFO ==========")
186        if bytes.count >= 8 {
187            let status = bytes[7]
188            print("Status: \(status)")
189        }
190        print("================================\n")
191    }
192    
193    func parseEQ(_ bytes: [UInt8]) {
194        print("\n========== EQ INFO ==========")
195        if bytes.count >= 7 {
196            let eqMode = bytes[6]
197            print("EQ Mode: \(eqMode)")
198        }
199        print("=============================\n")
200    }
201    
202    // MARK: - Command Execution
203    
204    func runCommand() {
205        if done { return }
206        done = true
207        
208        switch command {
209        case .ancOn:
210            sendAncMode(0x01, name: "ANC ON")
211        case .ancOff:
212            sendAncMode(0x04, name: "ANC OFF")
213        case .transparency:
214            sendAncMode(0x02, name: "TRANSPARENCY")
215        case .battery:
216            sendBatteryQuery()
217        case .info:
218            sendDeviceInfoQuery()
219        case .eq:
220            sendEQQuery()
221        case .help:
222            printHelp()
223            exit(0)
224        }
225    }
226    
227    // MARK: - Packet Sending
228    
229    func sendPacket(_ data: [UInt8], name: String) {
230        let hex = data.map { String(format: "%02X", $0) }.joined(separator: " ")
231        print("[TX] \(name): \(hex)")
232        peripheral?.writeValue(Data(data), for: cmdChar079A!, type: .withoutResponse)
233    }
234    
235    // MARK: - ANC Control
236    
237    func sendAncMode(_ mode: UInt8, name: String) {
238        
239        // Step 1: Send Hello
240        sendPacket([0xAA, 0x07, 0x00, 0x00, 0x00, 0x01, 0x23, 0x00, 0x00, 0x12], 
241                   name: "HELLO")
242        
243        // Step 2: Register with device token
244        DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
245            self.sendPacket([0xAA, 0x0C, 0x00, 0x00, 0x00, 0x85, 0x41, 0x05, 
246                           0x00, 0x00, 0xB5, 0x50, 0xA0, 0x69], 
247                           name: "REGISTER")
248        }
249        
250        // Step 3: Query current ANC status
251        DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) {
252            self.sendPacket([0xAA, 0x09, 0x00, 0x00, 0x04, 0x82, 0x44, 0x02, 
253                           0x00, 0x00, 0xF2], 
254                           name: "QUERY")
255        }
256        
257        // Step 4: Set the ANC mode
258        DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
259            let seq: UInt8 = 0x42
260            let anc: [UInt8] = [0xAA, 0x0A, 0x00, 0x00, 0x04, 0x04, seq, 0x03, 
261                               0x00, 0x01, 0x01, mode]
262            self.sendPacket(anc, name: "ANC SET: \(name)")
263        }
264        
265        DispatchQueue.main.asyncAfter(deadline: .now() + 10.0) {
266            print("\n[DONE] \(name) command sent")
267            exit(0)
268        }
269    }
270    
271    // MARK: - Battery Query
272    
273    func sendBatteryQuery() {
274        sendPacket([0xAA, 0x07, 0x00, 0x00, 0x00, 0x01, 0x23, 0x00, 0x00, 0x12], 
275                   name: "HELLO")
276        
277        DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
278            self.sendPacket([0xAA, 0x0C, 0x00, 0x00, 0x00, 0x85, 0x41, 0x05, 
279                           0x00, 0x00, 0xB5, 0x50, 0xA0, 0x69], 
280                           name: "REGISTER")
281        }
282        
283        DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
284            self.sendPacket([0xAA, 0x07, 0x00, 0x00, 0x06, 0x01, 0x25, 0x00, 0x00], 
285                           name: "BATTERY QUERY")
286        }
287        
288        DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
289            print("\n[DONE]")
290            exit(0)
291        }
292    }
293    
294    // MARK: - Device Info Query
295    
296    func sendDeviceInfoQuery() {
297        sendPacket([0xAA, 0x07, 0x00, 0x00, 0x00, 0x01, 0x23, 0x00, 0x00, 0x12], 
298                   name: "HELLO")
299        
300        DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
301            self.sendPacket([0xAA, 0x0C, 0x00, 0x00, 0x00, 0x85, 0x41, 0x05, 
302                           0x00, 0x00, 0xB5, 0x50, 0xA0, 0x69], 
303                           name: "REGISTER")
304        }
305        
306        DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
307            self.sendPacket([0xAA, 0x07, 0x00, 0x00, 0x03, 0x01, 0x28, 0x00, 0x00], 
308                           name: "DEVICE INFO QUERY")
309        }
310        
311        DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
312            print("\n[DONE]")
313            exit(0)
314        }
315    }
316    
317    // MARK: - EQ Query
318    
319    func sendEQQuery() {
320        sendPacket([0xAA, 0x07, 0x00, 0x00, 0x00, 0x01, 0x23, 0x00, 0x00, 0x12], 
321                   name: "HELLO")
322        
323        DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
324            self.sendPacket([0xAA, 0x0C, 0x00, 0x00, 0x00, 0x85, 0x41, 0x05, 
325                           0x00, 0x00, 0xB5, 0x50, 0xA0, 0x69], 
326                           name: "REGISTER")
327        }
328        
329        DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) {
330            self.sendPacket([0xAA, 0x07, 0x00, 0x00, 0x05, 0x01, 0x2B, 0x00, 0x00], 
331                           name: "EQ QUERY")
332        }
333        
334        DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) {
335            print("\n[DONE]")
336            exit(0)
337        }
338    }
339    
340    // MARK: - Help
341    
342    func printHelp() {
343        print("""
344        NordBuds ANC Controller v\(VERSION)
345        
346        Usage: \(CommandLine.arguments[0]) <command>
347        
348        Commands:
349          on         - Enable ANC (Active Noise Cancellation)
350          off        - Disable ANC
351          trans      - Enable Transparency mode
352          battery    - Query battery levels
353          info       - Query device information
354          eq         - Query equalizer settings
355          help       - Show this help message
356        
357        Examples:
358          \(CommandLine.arguments[0]) on
359          \(CommandLine.arguments[0]) off
360          \(CommandLine.arguments[0]) trans
361          \(CommandLine.arguments[0]) battery
362          \(CommandLine.arguments[0]) info
363        
364        Notes:
365          - Earbuds must be connected to this Mac via Bluetooth
366          - First time? Run 'on' to enable ANC
367        """)
368    }
369}
370
371// MARK: - Main
372
373func main() {
374    let args = CommandLine.arguments
375    
376    if args.count < 2 {
377        printHelp()
378        exit(0)
379    }
380    
381    let cmd = args[1].lowercased()
382    var command: Command = .help
383    
384    switch cmd {
385    case "on", "anc":
386        command = .ancOn
387        print("[*] Target: ANC ON")
388    case "off":
389        command = .ancOff
390        print("[*] Target: ANC OFF")
391    case "trans", "transparency":
392        command = .transparency
393        print("[*] Target: TRANSPARENCY")
394    case "battery", "bat":
395        command = .battery
396        print("[*] Query: BATTERY")
397    case "info", "device":
398        command = .info
399        print("[*] Query: DEVICE INFO")
400    case "eq", "equalizer":
401        command = .eq
402        print("[*] Query: EQUALIZER")
403    case "help", "--help", "-h":
404        printHelp()
405        exit(0)
406    default:
407        print("[ERROR] Unknown command: \(cmd)")
408        print("Run '\(args[0]) help' for usage")
409        exit(1)
410    }
411    
412    print("[*] Looking for Nord Buds...")
413    
414    let cli = NordBudsCLI()
415    cli.command = command
416    
417    RunLoop.main.run(until: Date(timeIntervalSinceNow: 30.0))
418    
419    print("[ERROR] Timeout - earbuds not responding")
420    exit(1)
421}
422
423main()

What I Learned

Big companies don't document their protocols on purpose. It's not an oversight — it's how they keep you in their ecosystem. Your only leverage is the fact that BLE is a public standard and the packets are always readable.
The btsnoop capture is the ground truth. Every hour I spent guessing, I could have spent reading packets. Read the traffic first, guess second.
Small details kill you. Wrong write type, wrong service, wrong notify channel — any one of them kills the whole thing silently with no error message. BLE is not verbose when things go wrong.
The solution was simple. Subscribe to both services. That's it. Weeks of work, and the fix was one extra setNotifyValue(true) call.

Now I can control my earbuds from my laptop.
OnePlus couldn't give me that — I took it myself.

Success!
Round Trip Complete