Jordi Wippert
← All writing
02 Sept 2026 · 8 min · #code #hobby

Replacing the crashing XPPen ACK05 driver on macOS by reverse-engineering its Bluetooth protocol

TL;DR: the driver I ended up writing is ack05d (Swift, MIT), and the protocol notes are in PROTOCOL.md.

I got an XP-Pen ACK05 as a present, which is a small Bluetooth macro pad with ten keys and a dial. The first ideas I came up with were switching between my tmux sessions, and connecting or disconnecting my iPad as a Sidecar screen, both without touching the keyboard. Nothing I actually needed, just things that seemed fun to try. The remote itself turned out to be fine. The official macOS driver was where the trouble started.

The problem

It kept crashing, and this is what the report said:

Process:        XPPenTablet [67353]
Version:        4.0.18
OS Version:     macOS 26.5.2 (25F84)
Launch Time:    2026-08-31 15:55:57
Date/Time:      2026-08-31 16:12:50

Exception Type: EXC_BAD_ACCESS (SIGSEGV)
Exception Subtype: KERN_INVALID_ADDRESS at 0x0000000000000000

Thread 0 Crashed:: Dispatch queue: com.apple.main-thread
0   XPPenTablet  MyLrc::paintEvent(QPaintEvent*) + 288
1   QtWidgets    QWidget::event(QEvent*) + 128
...
15  QtGui        QWindowPrivate::emitScreenChangedRecursion(QScreen*) + 80
16  QtGui        QWindowPrivate::setTopLevelScreen(QScreen*, bool) + 712
...
29  AppKit       -[NSWindow _updateSettingsSendingScreenChangeNotificationToScreen:] + 96
30  AppKit       -[NSWindow _setFrameCommon:display:fromServer:] + 1904
...
33  QtGui        QWindow::resize(QSize const&) + 612

The app is built on Qt, and MyLrc is the little on-screen window that tells you what the dial does right now. If you read the stack from the bottom up, the sequence is that the tip window resizes itself, macOS posts a screen-change notification while that resize is still running, Qt delivers the notification synchronously, a paint happens in the middle of it, and then the paint handler dereferences a null pointer. Seventeen minutes after the app launched, with three displays attached.

I could have turned the dial popup off in the settings and probably never seen the crash again. However, that popup was also the only feedback the remote gave me, and there were two other things the driver simply could not do:

  • No media keys. Its key types are keyboard shortcuts, mouse actions and “run application”, but on macOS play/pause and volume are not keystrokes at all, they are system media events. There is nothing you can type into that field.
  • No dial centre button. XP-Pen’s manual calls it the “Roller switch key”, and the firmware handles it internally to cycle the driver’s own dial modes, so there is nothing left for you to bind.

So instead of working around it, I went looking at what the remote actually sends.

Two modes

Out of the box the ACK05 is a plain Bluetooth keyboard, where every key sends a fixed shortcut: Ctrl+Z, Ctrl+S, F5, a bare Shift, and the dial sends Ctrl+NumPad+ and Ctrl+NumPad-.

You can remap all of that with Karabiner-Elements, and that is exactly what I did at first. Three things kept bothering me though:

  • The real keystrokes still fire if the remapper is not running, so Ctrl+Z lands in whatever app happens to be in front.
  • Three of the keys are bare modifiers, which interferes with whatever you are already holding.
  • The dial centre button keeps sending nothing. MartinSadovy’s probe found that it emits two all-zero keyboard reports, which is indistinguishable from the device sitting idle.

The official driver does not use those keystrokes at all. Instead it flips the remote into a vendor mode, where every key becomes one bit in a 10-byte frame:

02 f0 <keys 1-8> <keys 9-10 + dial button> 00 00 00 <wheel CW/CCW> 00 00

Over USB this is all well documented, as the Linux kernel has shipped a HID-BPF driver for it since 6.15, and the switch into vendor mode is a single HID output report: 02 b0 04 00 …. Over Bluetooth that path is closed, because the remote exposes no vendor HID collection there.

The private GATT service

Over Bluetooth, vendor mode lives on a proprietary GATT service called FFE0, sitting right next to the normal HID profile:

CharacteristicUse
0001write, command channel
0002notify, acknowledgements
0003notify, button frames, battery heartbeat, reconnect events

I am not the first to find this, to be clear. RomainGehrig’s Python driver for Linux has Bluetooth as a fallback transport, and it documented that you have to subscribe to 0002 before 0003 streams anything at all. Jayphen’s macOS menubar app advertises USB and Bluetooth with all 11 buttons working.

It is not a settled thing either. MartinSadovy’s macOS driver, which is Bluetooth-only, lists the dial click under “Not supported”, and the Windows ACK05-Enhanced project calls direct Bluetooth experimental, because a second read-only enumeration on another machine could not reproduce the vendor collection at all.

On my unit the documented write did nothing. I sent 02 b0 04 00 … to 0001, got a friendly acknowledgement back on 0002, and then nothing changed: the battery heartbeats kept ticking and the buttons kept arriving as keystrokes.

What fixed it

In the end it came down to two changes to those same bytes.

  1. Write it without a response. CoreBluetooth’s .withResponse gave me the ack and no effect whatsoever, while .withoutResponse gave me frames.
  2. Send it after the first battery heartbeat, rather than immediately after subscribing.

So the full recipe on my remote is to subscribe 0002, subscribe 0003, wait for the first 02 f2 … heartbeat, and then write 02 b0 04 00 00 00 00 00 00 00 to 0001 without response. After that the 02 f0 frames start flowing, dial centre button included.

For completeness I also pulled the official driver’s Bluetooth handshake out of its binary, which turned out to be seven packets including a USB string-descriptor unlock tunnelled over GATT. ack05d tries the short recipe first, and replays that full sequence if no frame arrives within a few seconds, for units that need it.

About that hardware revision

My remote exposes a Telink OTA service with UUID 00010203-0405-0607-0809-0A0B0C0D1912, and a Nordic chip cannot expose that.

Meanwhile David Revoy’s teardown of an ACK05 shows a Nordic M52833, and a community firmware package for the ACK05 is built for pca10100 with SoftDevice s113, which is an nRF52833 target.

Which means at least two different chips ship under the same product name. So if the published recipe acks and then does nothing on your unit, check the write type before you go looking anywhere else.

One more thing while you are in there: do not write to that Telink OTA service. I did, by accident, while probing, and the remote went quiet until I power-cycled it. ack05d only writes to FFE0 now.

What you get

It is a launch agent that connects over CoreBluetooth, enables vendor mode, decodes the frames, and runs whatever you put in a JSON config:

{
  "overlayCommand": "~/.local/bin/hud",
  "buttons": {
    "BTN_1": { "type": "shell", "command": "open -a Ghostty", "label": "work" },
    "BTN_3": { "type": "battery" },
    "DIAL":  { "type": "wheelModeCycle" }
  },
  "wheelModes": [
    { "name": "volume", "cw": { "type": "mediaKey", "key": "volume_up" }, "ccw": { "type": "mediaKey", "key": "volume_down" } },
    { "name": "zoom",   "cw": { "type": "keystroke", "keystroke": "cmd+=" }, "ccw": { "type": "keystroke", "keystroke": "cmd+-" } }
  ]
}

Media keys go through the real macOS volume and brightness overlay, the dial cycles through wheel modes you define yourself, and an optional overlay shows you which action fired plus the battery level, which macOS does not show for this device anywhere (system_profiler SPBluetoothDataType lists the remote with an address and nothing else). Install is a single ./install.sh, and config edits reload without a restart.

The optional overlay

Things that cost me time

  • Accessibility grants and code signing. Synthesising media keys needs Accessibility, but macOS keys that permission on the app’s code signature, and an ad-hoc signature changes on every build, so the grant disappeared every single time I rebuilt. A self-signed certificate fixes it, yet only once you trust it in the System keychain, because untrusted it falls back to hashing the binary and you are right back where you started.
  • “Off” is not off. The power switch is a soft-off, so the radio still accepts a connection from the bonded Mac and then times out a few seconds later. Without a backoff my driver sat there in a connect-and-drop loop against a remote I had switched off myself.
  • A connected device does not advertise. After a power-cycle the remote picks a new random address and macOS re-bonds it as a keyboard, which means scanning never finds it. You have to ask the system for the already-connected peripherals instead, and ask by the HID service, because macOS has not seen FFE0 on that new address yet.
  • Bluetooth range flaps a lot. Eleven days of my log holds 547 disconnects, 515 of them a plain CoreBluetooth timeout, and every return is a genuine reconnect with a genuine handshake. It just should not be a notification every time, so reconnects inside a three-minute window are silent now, which covers 350 of the 394.

That is Bluetooth only for now. USB and the 2.4 GHz dongle are documented in PROTOCOL.md but not implemented yet, and the credits to everyone whose work this builds on are in there as well.

A series of broken computers →