Skip to content

About

Reverse-engineering how macOS controls AirPods Pro 2 listening modes: packet captures, binary analysis, and a probe that changes the mode over its own L2CAP channel. Apple documents none of it.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

airpods-ctl

How AirPods Pro 2 listening modes (ANC, Transparency, Off, Adaptive) are actually controlled on macOS: documented from live packet captures and binary analysis, with runnable code and the traces behind each claim.

Apple documents none of this. The on-air protocol (AAP) is proprietary, and every framework call that touches a listening mode is private. This repo is the teardown, plus a CLI built on what it found.

Status: mode changing works; the CLI has not caught up yet

As of 2026-08-31, code in this repo changes the listening mode on real hardware, over its own L2CAP channel, speaking AAP. The mode change was heard, the firmware echoed the command back on the channel, and a packet capture shows that channel being opened and carrying traffic. (PacketLogger does not decode AAP payloads, so the capture corroborates the channel, not the individual bytes.) See Findings and docs/aap-protocol-notes.md ("Resolution part 5") for the bytes and the trace.

The shipped CLI does not use that path yet: set and toggle still only update local state, and warn you when they do. Wiring the working transport into airpods-ctl is the next piece of work, and it carries one real design problem: opening the channel currently costs a ~1.7 s Bluetooth reconnection, so a naive toggle interrupts your audio.

Why this exists

The itch was small: switch ANC and Transparency from a keyboard shortcut, silently, while music is playing.

The answer was not small, because there is no public API for it. Reading or setting a listening mode goes through a protocol Apple does not document, reached by framework calls Apple does not support. The available options each stop somewhere:

  • macOS Shortcuts offers no action for selecting a noise control mode at all (checked on Tahoe 26, 2026-09-01), so there is nothing to automate against, let alone a way to read the current mode back and express a toggle.
  • AppleScript and UI scripting against the Sound menu do work, but flash the popover on screen for 300 to 500 ms every time, which defeats the point.
  • NoiseBuddy is abandoned and no longer works on recent macOS. The teardown below shows why: the API it was built on now returns nil without a privileged entitlement.

So the question stopped being "which tool do I install" and became "how does this actually work". That is what this repo answers.

Findings

This started as a macOS port of the AAP-over-L2CAP work from LibrePods (Android and Linux) and turned into a tour of every path macOS offers. Full detail in docs/aap-protocol-notes.md; the short version:

1. macOS blocks your L2CAP connect locally, but only while the AirPods are connected, and the firmware itself is willing. The AAP PSM 0x1001 is real, confirmed via SDP ("AAP Server" record). With the AirPods connected, openL2CAPChannelSync(withPSM: 0x1001) returns kIOReturnError after ~3 s, and a packet capture of the attempt shows no Connection Request ever leaving the Mac: IOBluetooth refuses locally, without asking the device. Run the same call with the AirPods not connected and the picture flips. The request goes out, the AirPods answer Connection Successful, and the channel configures and holds, even though bluetoothd is bringing up its own AAP session on the same link at that moment. So the firmware accepts a second AAP client; macOS is the one saying no, and only in the connected steady state. Separately, Apple negotiates its own channels through a proprietary Fast Connect L2CAP-echo handshake, which is why the AAP connection never appears as a standard PSM connect between two Apple devices.

2. Commands written on that channel do reach the firmware, and the mode changes. Racing the reconnect (closeConnection(), then retry the open while macOS re-pages the device) yields a working channel. On it, an AAP handshake followed by the 11-byte set-mode packet (04 00 04 00 09 00 0D <mode> 00 00 00) switches ANC and Transparency audibly. The AirPods echo the command back, which is how the write is confirmed programmatically. Provenance differs between those two frames: the set-mode bytes are from our own capture, the handshake is from the LibrePods document (see THIRD_PARTY_NOTICES.md). After a mode change the device also emits a settings burst that carries the current mode, which looks like a usable read path, though it arrives unsolicited and we have not yet shown it can be requested on demand. The retry loop is mandatory, since a single open attempt is a coin flip, and each command costs the ~1.7 s reconnect. Bytes, trace and traps in docs/aap-protocol-notes.md ("Resolution part 5").

3. The private listeningMode properties are state mirrors, not command channels. IOBluetoothDevice.setListeningMode:, CBDevice (an XPC data object) and AVOutputDevice.setCurrentBluetoothListeningMode: all look like setters, but in an unprivileged short-lived process they write a local field and nothing reaches the hardware. AVOutputContext.sharedSystemAudioContext, the path NoiseBuddy used, returns nil without a privileged entitlement, which is the likely reason it stopped working.

4. That private setter appears reachable by third-party code, which keeps it alive as a fallback. A shipping third-party app that controls listening modes on the same macOS version ships a resident helper whose Bluetooth service exports the very same setListeningMode: selector, holds no special Apple entitlement (only an app group), and contains no L2CAP or PSM code at all. The likely reading is that the barrier is neither an entitlement we lack nor the choice of API, but whatever runtime setup a persistent process has and a short-lived one does not. Caveat worth keeping in view: this rests on static inspection, which proves the code exists rather than that it is the path taken at runtime. It matters because the direct AAP route above costs an audio interruption, so a route with no reconnect is worth chasing.

How this was investigated

Every claim here came from one of three places, and each one is reproducible.

Live packet capture. PacketLogger, from Additional Tools for Xcode, with Apple's Bluetooth logging profile installed, recording HCI and L2CAP traffic between macOS and the AirPods: connection setup, Apple's Fast Connect echo handshake, and per-PSM signaling. Traces are in docs/captures/, and the notes quote timestamps against them. Two limits worth knowing: PacketLogger reports AAP frames as "No Decoder Specified", so payload bytes have to be searched in the raw .pklg rather than read off the text export, and captures that carry device serial numbers or a lot of ambient Bluetooth traffic from nearby strangers are kept out of the repo, with the notes saying so where they are cited.

Binary and runtime inspection. otool -L, codesign -d --entitlements, strings, dyld_info -exports and Objective-C runtime introspection (property_getAttributes, objc_getClass) against the private frameworks, and against a shipping third-party app as a working existence proof. That is how listeningMode was found to be an ivar-backed unsigned char rather than the Int it appears to be from Swift, and how the entitlement question in finding 4 was settled.

Probes on real hardware. Small single-purpose Swift programs that make one call and report exactly what happened, run against AirPods Pro 2, with results confirmed audibly and by the firmware's own command echo. Tools/aap-set-mode-probe.swift is the published one: it finds the AirPods itself rather than hardcoding an address, and it reproduces the mode change on a Mac with paired AirPods Pro 2. Expect to lose Bluetooth audio for a couple of seconds while it races the reconnect, and expect it to need more than one attempt.

Findings were published as they were reached, including one that turned out to be wrong. This repo previously stated that the AirPods firmware refuses a second AAP client. A capture taken on 2026-08-31 showed the opposite: the refusal comes from the macOS Bluetooth stack, which does not even put the request on the air, while the firmware accepts the connection when it is actually asked. The claim was corrected in place, with the trace that refutes it, in "Resolution part 4". Anything still unverified is labelled as such.

What works today

Command State
airpods-ctl list works, enumerates connected AirPods
airpods-ctl set <mode> runs and persists state, does not change the device yet
airpods-ctl get prints the last mode this tool set, or unknown
airpods-ctl toggle same caveat as set

The working AAP path is not in the CLI yet, but it is in the repo as a runnable reproducer, Tools/aap-set-mode-probe.swift:

swiftc Tools/aap-set-mode-probe.swift -o /tmp/aap-probe -framework IOBluetooth
/tmp/aap-probe        # wear the AirPods: it sets Transparency, then ANC

It finds your AirPods itself, opens the channel, and prints every frame it sends and receives, flagging the firmware's echo. It drops your Bluetooth audio for a couple of seconds while it races the reconnect, which is the unsolved part, and it leaves the AirPods in ANC when it finishes.

Architecture

The transport is abstracted behind a ListeningModeTransport protocol, which keeps the CLI, the per-device mode store and the tests independent of how a mode is actually set. The protocol as written is synchronous and per-call, so the working AAP path does not drop in unchanged: it needs a retry loop, a live RunLoop for the channel delegate, and probably a resident process to amortise the reconnect. Expect the protocol to grow an async, session-shaped variant.

Swift 5.10 and SwiftPM. The .app bundle exists to give the binary a bundle identity that Bluetooth TCC can key on across rebuilds; note the signature is ad hoc (codesign --sign -), so this is the intent rather than a tested guarantee.

Build

make build            # release binary for the host architecture
make build-universal  # universal binary (arm64 + x86_64), needs full Xcode
make app              # assemble AirPodsToggle.app under .build/
make install          # copy to ~/Applications and symlink CLI to /opt/homebrew/bin/
make test             # run unit tests (no hardware needed)
make lint / format    # swiftformat, same file set CI checks
make clean            # remove build artifacts

Exit codes: 0 success, 2 no AirPods connected or Bluetooth unavailable, 3 Bluetooth permission denied, 1 anything else.

Testing locally

Tests use Swift Testing. On a machine with a full Xcode install, make test just works. On a Command Line Tools-only install, the test bundle compiles but dyld may fail to locate Testing.framework at runtime, so install Xcode for local testing. CI uses GitHub's macos-15 runners, which have Xcode pre-installed.

Requirements

  • macOS 15 (Sequoia) or newer. Developed against Tahoe 26.
  • Swift 5.10+ (ships with Xcode 15.3+ or Command Line Tools).
  • AirPods Pro 2 paired to this Mac.

Contributing

Two things would help most. First, the audio interruption: opening the AAP channel currently requires dropping the Bluetooth link, and any way to reach that channel without doing so would turn this into a usable toggle. Second, the fallback route: if you know what runtime setup makes IOBluetoothDevice.setListeningMode: reach the firmware from a resident process, that solves the same problem from the other side, with no reconnect.

Captures and probes that reproduce, or refute, anything documented here are especially welcome. Findings in this repo have been wrong before, and were corrected by exactly that kind of evidence.

License

MIT. See LICENSE.

Acknowledgments

See THIRD_PARTY_NOTICES.md. The AAP protocol layout that seeded this investigation comes from the LibrePods reverse-engineering effort.

About

Reverse-engineering how macOS controls AirPods Pro 2 listening modes: packet captures, binary analysis, and a probe that changes the mode over its own L2CAP channel. Apple documents none of it.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages