Reflashing a Macropad for macOS
Reflashing a Macropad for macOS
A student gave me a small macropad at the end of a class last semester — six keys and a rotary encoder, one of those generic Chinese boards you find rebranded on Amazon for fifteen bucks. Thoughtful gift. The only catch: it came with a Windows-only configurator called MINI KeyBoard.exe, and I wanted to actually use it on my Mac. The story of how getting it working on macOS turned into “let’s replace the entire firmware” is, in retrospect, the more interesting result.
This post is the writeup of that journey. I worked through it with Claude, Anthropic’s AI assistant, which did virtually all of the code-writing and reading-of-error-codes; my role was to want the thing in the first place, do the physical work, run every command, test on real hardware, and decide where the project was going at each fork. Throughout this post I’ll try to be careful about which of us did what — when I say “Claude wrote X,” I mean Claude actually generated the code; when I say “I did Y,” I mean I was the one with hands on the hardware or the terminal. There’s also a fuller account of what AI pairing looked like for a project like this further down, after the technical walkthrough.
The device
6 keys arranged 2×3, plus a rotary encoder with click. USB-C. Enumerates as a USB HID device with VID 0x1189 / PID 0x8890. Empty manufacturer and product strings — a tell that it’s white-label hardware. Inside, it’s running a WCH CH552G, a $0.30 8051-based microcontroller with a built-in USB peripheral.
What I wanted
Just a Mac app to remap the keys. The Windows tool worked fine. The initial assumption — neither of us pushed back on it — was that reverse-engineering the wire protocol and rewriting it in Swift would take an afternoon.
It did not.
The first attempt: reverse-engineer the Windows protocol
The Windows configurator was a .NET WinForms app. Claude decompiled its IL bytecode with monodis and walked through the relevant methods to pull out the wire-format code. The protocol turned out to be a fixed 65-byte HID output report — a 1-byte Report ID followed by 64 bytes of command:
byte 0: Report ID (probed: 0, 2, or 3)
byte 1: slot/opcode (1-6 = keys, 0x0D-0x0F = knob)
byte 2: type bits (basic key / multimedia / mouse)
byte 3: keycode count
byte 4: reserved
byte 5: modifier mask
bytes 6+: keycodes (up to 19)
The Windows binary sends this via a HidLibrary call that ultimately becomes a WriteFile to the HID device handle. On Windows, that gets converted to either an interrupt OUT transfer or a SET_REPORT control transfer, depending on the device descriptor.
Claude then wrote a Python CLI to do the same thing on macOS using the hidapi library, and started building a SwiftUI app on top of it using IOHIDDeviceSetReport. I ran each script as it landed, plugged the macropad in and out for every test, and reported back what actually happened.
Both immediately hit a wall.
Why macOS makes this hard
The macropad enumerates as five HID interfaces — multiple keyboards plus a mouse. The trick the Windows tool relies on is that one of those interfaces has a HID descriptor that quietly declares a 64-byte Output report channel under a vendor-specific usage page. The Windows HID stack will happily let you write to it.
macOS HID is stricter in two ways that, together, killed the approach:
-
Output report sizes are enforced from the descriptor. macOS’s
IOHIDDeviceSetReportrefuses writes larger than the descriptor’s declaredkIOHIDMaxOutputReportSizeKeyfor the relevant interface. Most of the macropad’s interfaces declare output reports of 2 bytes (the standard keyboard LED report). 65-byte writes get rejected. -
Keyboard-class HID interfaces require Input Monitoring permission and are exclusively claimed by the kernel. Even with
libusb, you can’t bypass the kernel HID driver to write to a claimed interface — you getEACCES(errno 13).
We spent a long time chasing this. Claude wrote diagnostic Python scripts to dump the device’s HID Report Descriptor from libusb, trace every interface number, and send interrupt-OUT writes directly to endpoint 0x02 on the vendor-class interface. I ran them and pasted the output back. Every write came back OK at the libusb level and did nothing at the firmware level. Somewhere between “macOS sent the bytes” and “the chip acted on them,” reality and our assumptions diverged.
The pivot: just reflash it
Deep down this rabbit hole, I typed the four-word question that ended the reverse-engineering arc: “Can we just reflash it?” The chip is a CH552G. It has a built-in USB bootloader. We could put our own firmware on it instead of fighting this one.
Claude reorganized around that and we never went back.
Identifying the bootloader entry
I opened the case and took a photograph of the PCB so Claude could read the chip markings: clear CH552G etched on the package at U7, with the WCH logo. Claude also pointed out the empty footprint marked SW2 near the chip. On generic CH552 boards, SW2 is the bootloader-entry button footprint — it pulls P3.6 (the USB D+ line) up to 3.3V at power-on through a 10kΩ resistor, which is how the chip’s mask-ROM bootloader gets invoked. The manufacturer leaves the footprint unpopulated because end users aren’t supposed to be reflashing the device.
You can simulate “pressing SW2” by bridging any left pad to any right pad of the footprint with tweezers, a wire, or a blob of solder, while plugging in USB:
- Unplug the macropad.
- Bridge SW2’s left↔right pads.
- Plug in USB while holding the bridge.
- Release.
The chip then enumerates as VID 0x4348 / PID 0x55E0 — the WCH USB ISP bootloader, in DFU mode.
For repeated flashing, I used a small patch cable from my modular synth and some painters tape.
Talking to the bootloader
Claude pointed at two tools for talking to the WCH bootloader. The Rust one, wchisp, turned out to fail on macOS — its rusb backend returns Other error from IOHIDDeviceOpen no matter what permissions you grant. The Python alternative, ch55xtool, worked once we figured out you have to tell pyusb where Homebrew installed libusb:
brew install libusb
pip3 install ch55xtool
DYLD_LIBRARY_PATH=/opt/homebrew/lib sudo -E ch55xtool -l
That last incantation is worth memorizing if you do any CH55x work on Apple Silicon:
DYLD_LIBRARY_PATH=/opt/homebrew/libtells pyusb where to findlibusb-1.0.dylib.sudois needed for raw USB access on macOS.-Epreserves the env var across the sudo boundary, which it would otherwise strip for security.
When this works:
Found CH552 with SubId:17
BTVER:02.50
UID:50-B0-F7-BD-00-00-00-00
Finalize communication. Done.
BTVER:02.50 is the bootloader version. Now we could flash.
Custom firmware, phase 1: discovery
There is no schematic for this macropad. We needed to know which of the CH552G’s 11 GPIO pins corresponded to each physical input.
Claude wrote a tiny ch55xduino sketch that:
- Enumerates the chip as a USB HID keyboard.
- Configures every candidate GPIO (P1.0, P1.1, P1.4–P1.7, P3.0–P3.4) as
INPUT_PULLUP. - When any pin transitions HIGH→LOW (i.e., something pulls it to ground = a button press), types the pin name as keystrokes —
P14,P30, etc.
I flashed it, opened TextEdit, and pressed each input in order. The transcript that ended up in the editor was P11 P17 P16 P15 P14 P32 P33 P30 P31 P31 P30. Claude turned that into the mapping:
| Input | GPIO |
|---|---|
| Key 1 | P1.1 |
| Key 2 | P1.7 |
| Key 3 | P1.6 |
| Key 4 | P1.5 |
| Key 5 | P1.4 |
| Key 6 | P3.2 |
| Encoder click | P3.3 |
| Encoder A | P3.0 |
| Encoder B | P3.1 |
The encoder’s A/B pair tells you direction via quadrature: when A goes low, look at B — if B is still high, the rotation is one direction; if B is already low, it’s the other. One detent of rotation produces one keystroke from a single A-falling-edge trigger. Standard pattern; ch55xduino made it trivial.
Phase 2: runtime configuration via HID Feature reports
The design question for phase 2 was: how does the Mac app talk to the firmware without falling into the same trap as the Windows protocol did?
Claude’s answer: HID Feature reports. They’re a standard part of the HID spec, distinct from Input and Output reports. They travel over the control endpoint (endpoint 0), addressed by SET_REPORT (write) and GET_REPORT (read) class requests. Crucially, macOS treats Feature reports as first-class and lets you send/receive them at any size declared in the descriptor — even on keyboard-class interfaces where it won’t let you mess with Input or Output reports. This was exactly the macOS-friendly channel the original firmware lacked.
Claude extended the HID Report Descriptor inside ch55xduino’s keyboard collection to add a 32-byte vendor-defined Feature report:
// added to the keyboard collection in ReportDescriptor[]
0x06, 0x00, 0xff, // USAGE_PAGE (Vendor 0xFF00)
0x09, 0x01, // USAGE (1)
0x15, 0x00, // LOGICAL_MINIMUM (0)
0x26, 0xff, 0x00, // LOGICAL_MAXIMUM (255)
0x75, 0x08, // REPORT_SIZE (8)
0x95, 0x20, // REPORT_COUNT (32)
0xb1, 0x02, // FEATURE (Data,Var,Abs)
Then Claude wrote a small command protocol in the firmware behind that Feature report. Each 32-byte payload starts with an opcode, followed by command-specific arguments:
#define CMD_GET_VERSION 0x01
#define CMD_GET_BINDING_COUNT 0x02
#define CMD_GET_BINDING 0x10
#define CMD_SET_BINDING 0x11
#define CMD_LOAD_DEFAULTS 0x20
#define CMD_COMMIT 0x21
#define CMD_ERASE_FLASH 0x22
#define CMD_PING 0x30
The host writes a Feature report; the device parses the opcode, mutates an in-RAM keymap (9 entries: 6 keys, encoder click, encoder CW, encoder CCW), and prepares a response. The host then reads back the Feature report to get a result code and any payload (binding data, version, etc.).
Claude also wired up the USB stack’s EP0_OUT and EP0_IN callbacks to handle SET/GET_FEATURE chunked across four 8-byte EP0 packets, since the control endpoint has an 8-byte max packet size and a Feature report is 32 bytes total.
End-to-end maybe 300 lines of C. The first time I ran the host probe script and a PING round-tripped successfully — and then SET_BINDING actually changed which key the macropad typed in real time — that was the moment it stopped feeling like a side quest and started feeling like a project.
Phase 3: persistence
In-RAM bindings die on every unplug. Useless.
CH552 exposes 128 bytes of byte-addressable data flash via ch55xduino’s eeprom_read_byte / eeprom_write_byte. Claude designed the layout:
Byte 0: magic byte (0x4D) — sentinel: present + correct = valid keymap on flash
Byte 1: format version (0x01)
Bytes 2-28: 9 bindings × 3 bytes (type, modifiers, keycode)
Bytes 29-127: unused
The write order matters for crash-safety: the firmware writes the bindings first, then the version byte, then the magic byte last. If power dies mid-write, the magic byte stays unset and the next boot falls back to defaults instead of loading a half-updated keymap. (That ordering was Claude’s call; I would not have thought of it.)
A new CMD_COMMIT opcode triggers the write. The Mac app calls it after every SET_BINDING.
To verify persistence, Claude wrote a small Swift CLI that sets a sentinel binding, commits it, prompts me to physically unplug and replug the macropad, then re-reads the binding to check it survived. I unplugged, waited, replugged. The binding survived. That was the second moment in the project where everything clicked into place.
Phase 4: the Mac app
Now that the firmware spoke a clean protocol, Claude threw away most of what it had written during the reverse-engineering phase and started over. The new architecture is small:
ConfigDevice.swift— wrapsIOHIDManager+IOHIDDeviceSetReport/IOHIDDeviceGetReport. Public methods likesetBinding(slot:action:),commit(),loadDefaults(). Each does one round-trip Feature report.ConfigProtocol.swift— the wire protocol in Swift. Slot enum (0–8),BindingActionenum, command opcodes mirrored from the firmware header.MacropadState.swift— an@MainActor ObservableObjectwith@Published var bindings: [Slot: BindingAction]. Connect, reload, set, commit.- SwiftUI views — six key tiles in a 3×2 grid, three knob tiles in a row, a binding-editor sheet, a toolbar with Reload / Restore Defaults / Apply All.
Total Swift code: about 1500 lines, half of which is keycode tables. Claude wrote all of it.
There’s one subtle gotcha worth mentioning, because it’s the kind of thing that’s easy to miss when generating code at speed. The firmware uses ch55xduino’s keycode space, not USB HID usage codes. ch55xduino’s Keyboard_press(c) takes ASCII for printable characters (so 'a' is 0x61, not the HID usage 0x04) and HID_usage + 0x88 for special keys (so right-arrow is 0xD7, not the HID usage 0x4F). When the Mac app stores or displays a binding, it has to use the same numbering as the firmware. Claude got this wrong on the first cut of the GUI: every key tile showed “A” because the decoder fell back to .a whenever no HIDKey case matched the wire byte. I caught the symptom when I launched the app and saw every tile reading “A”; Claude diagnosed the cause and added a wireCode property to the HIDKey enum that does the conversion.
The TCC chapter
This is the part nobody warns you about.
macOS gates IOHIDDeviceOpen on keyboard-class devices behind the Input Monitoring privacy permission. Reasonable — it stops random apps from logging every keystroke you make. The wrinkle is how TCC remembers your grant.
TCC keys permission to (bundle ID, code signing identity). For ad-hoc signed apps (codesign --sign -), the “identity” is the binary’s CDHash — a content hash of the binary itself. Every time the Swift binary rebuilds, even a one-character change, the CDHash changes, TCC sees “a different app,” and the Input Monitoring grant doesn’t apply. You have to re-grant after every rebuild.
The fix Claude proposed: sign with a real identity instead of ad-hoc. I already had an Apple Development cert in my keychain from a previous Xcode project. Claude’s build script picks it up via:
security find-identity -p codesigning -v
and feeds the name to codesign --sign:
codesign --force --deep --sign "Apple Development: Daniel Dehaan (XXXXXXX)" "$APP"
One pitfall we hit: --options runtime. Adding the hardened runtime flag requires either a notarization-ready entitlements file or a provisioning profile, and without either, macOS refuses to launch the app at all (LSOpenURLs … error -600). For a personal, non-distributed app, plain signing is what you want. Claude included the flag at first, the app refused to launch, and I had to log what was happening so we could figure out why; Claude dropped the flag from the build script after that.
After that, every build is signed with the same Authority=Apple Development: … chain. TCC sees the same code identity even though the CDHash changes on each rebuild, and the Input Monitoring grant survives forever (well, until the cert expires).
If you don’t have an Apple Developer account, the same approach works with a self-signed certificate you create in Keychain Access → Certificate Assistant → Create a Certificate → Type: Code Signing, Self Signed Root, validity 3650 days. The script auto-picks anything matching macropad-tool|Developer ID Application:|Apple Development: from your keychain.
What we ended up with
| Layer | What’s there |
|---|---|
| Hardware | Same $15 macropad, but now running firmware I own end-to-end |
| Firmware | ch55xduino C — HID keyboard + 32-byte vendor Feature report, runtime keymap, flash persistence |
| Wire protocol | 8 opcodes, all round-trip cleanly, survive power cycles |
| macOS app | SwiftUI, signed with a stable identity so TCC permission sticks |
| Recovery | ch55xtool flash via DFU is always available; SW2 short, plug in, reflash |
The original ask was a Mac control tool for a Windows-only macropad. What ended up on my desk is the better outcome: a macropad I own end-to-end, a clean documented protocol, and a native macOS configurator. No more wrestling with an opaque vendor binary. No more fighting macOS HID restrictions. Just a small device that does what I tell it.
How this actually got built
I want to give a real account of how this project came together, because if you only read the technical sections above you might come away thinking I did most of the work. I didn’t. Claude did most of the work. Here’s the actual breakdown.
What Claude did
- Drove the planning. Almost every “what should we try next” decision came from Claude, presented to me as a menu of two-to-four options that I picked from. The architectural calls within each phase — the framework choice, the file structure, the wire-format byte layout, the build-script auto-detection of signing certs — were Claude’s. When you read the conversation log, the moments that look like me deciding things are mostly me agreeing to one of Claude’s proposals.
- Decompiled the Windows .NET binary with
monodis, walked through the IL bytecode ofHidLib.WriteDeviceandKeyBoardVersion_Check, and pulled out the wire protocol byte-by-byte. - Wrote the entirety of the SwiftUI app — HID layer, protocol layer, state model, views, build script — somewhere around 1500 lines of Swift.
- Wrote the entirety of the CH552 custom firmware in plain-C ch55xduino, including the modifications to the USB stack’s EP0 handler to dispatch SET_FEATURE and GET_FEATURE chunked across 8-byte packets.
- Wrote every Python diagnostic script: descriptor dumper, libusb prober, persistence verifier, Intel-HEX-to-binary converter.
- Diagnosed every error code that came up —
IOReturn 0xE00002E2=kIOReturnNotPermitted= TCC,IOReturn 0xE00002ED=kIOReturnNotResponding,errno 5from libusb = device STALL, etc. — and explained what was happening at the layer below. - Made the key architectural suggestions: use Feature reports instead of Output reports (the unlock for the whole macOS HID problem), write the magic byte last in the flash-persistence routine for crash safety, pick the signing cert via name-prefix matching so the build script works whether you have a Developer cert or a self-signed one.
- Held context across an exhausting amount of back-and-forth — easily 80+ rounds of “here’s an error, what now.”
What I did
- Had the macropad — it was a gift from a student — and the desire to make it work on macOS. That’s the only thing about this project that started with me alone.
- Made the scope calls when Claude offered options: build a Mac-native GUI, redesign it for Mac instead of mirroring Windows, try Wine, reflash, use runtime HID config, ship the working result. Claude wrote those menus; I picked from them. So I drove direction at a high level, but the technical decisions inside each phase were Claude’s.
- Did the physical work, when asked. Opened the case, photographed the PCB so Claude could identify the chip, soldered a bridge across the SW2 footprint to put the chip in DFU mode, plugged and unplugged the USB cable hundreds of times.
- Ran every command Claude proposed and pasted the output back faithfully. Took screenshots when Claude needed to see what I was seeing. That ferrying of state between my hands and Claude’s text was the rhythm of the entire session.
- Verified by hand at each step: pressed keys in TextEdit and watched what was typed, granted Input Monitoring permission in System Settings, checked the GUI state visually after every flash.
- Asked “Can we just reflash it?” after watching the reverse-engineering arc die for the fifth or sixth time. That was the one moment where I stepped meaningfully outside the trajectory Claude was on, rather than picking from one of its menus. It turned out to be the project-defining pivot.
- Called it done when we had a working result. Claude would have happily kept extending — per-key LED color, macros, multi-layer support, notarization for distribution. I said we were stopping there.
What this kind of pairing actually feels like
A few honest observations from inside it.
Claude is much faster than I would be at most of the code. The SwiftUI app, the firmware, the IL decompilation analysis — all of those would have taken me considerably longer to do alone, if I could have done them at all. What took us days might have taken me weeks, and some of it I’d have given up on.
Claude is also confidently wrong sometimes. Multiple plans were proposed and pursued that turned out to be dead-ends. The most expensive one was the entire reverse-engineering arc — Claude ground out a working-looking Python implementation and a SwiftUI mock-up of the original protocol, then we spent more hours together probing every USB interface in every way we could think of, before finally realizing that macOS HID enforcement would never allow the protocol to work the way it works on Windows. I’m not sure whether a more skeptical Claude would have flagged the futility earlier, or whether it took the actual hardware refusing to respond before either of us could see what was wrong.
The pairing requires me to be the bridge to reality. Claude could write code instantly, but only I could check whether real hardware did what it was supposed to do. Every claim of “this should work now” had to be tested with a USB cable and a text editor. Claude’s enthusiasm for trying yet another variant of the failed approach was sometimes useful — and sometimes a trap. When we’d been at it for hours and Claude was still confidently proposing things to try, what was actually needed wasn’t another protocol variant. It was for me to step back and ask whether the whole premise was wrong.
I also had to be the one who decided when to stop. There’s an instinct in long collaborative sessions to keep extending — add multi-layer support, add macros, add per-key LED color, add a notarized distribution build. We had a working result and called it done. That call was mine.
What I take away from this is that AI pairing on a project like this is real and substantial, but it doesn’t replace the person with the problem. Decisions still have to be made by someone who cares about the outcome and is willing to test their assumptions against real hardware. The code is faster to write with Claude; the project isn’t necessarily faster to build, because the rate-limiting step often isn’t typing. It’s running the test, watching what actually happens, and being willing to throw away the path you were on when the universe tells you it doesn’t work.
Worth being clear about all of that, because the alternative — quietly presenting the result as if I produced it alone — would have been dishonest about both my own work and what AI collaboration looks like in 2026.
Lessons that generalize
A few things to internalize from this project:
The platform difference matters more than the protocol. Windows HID is permissive — it’ll happily pass arbitrary bytes to a device’s firmware. macOS HID is strict and enforces what the descriptor declares. A wire-protocol reverse-engineering that works on the source platform may not be reproducible on a more locked-down one, no matter how perfectly you mirror the bytes.
Reflashing is usually easier than reverse-engineering. We spent days fighting an opaque protocol that, even if we’d gotten it working, would have produced a tool dependent on undocumented behavior in a closed firmware. Replacing the firmware took comparable time and produces something I control completely. The bar for “cheaper to replace than to integrate” is lower than it looks from the outside.
The CH55x family is shockingly hackable. Built-in USB DFU bootloader, no programmer hardware needed, open toolchain (sdcc + ch55xduino), $0.30 chip. If you see one in a cheap device, you can probably make it do whatever you want.
HID Feature reports are a hidden treasure on macOS. Every problem the original firmware had came down to macOS refusing to let us send oversized Output reports through a HID-claimed interface. Feature reports go through the same interface, same kernel HID driver, no permission fights — and at whatever size the descriptor declares. If you’re designing a firmware that needs a host-side config tool on macOS, use Feature reports.
TCC isn’t just about asking for permission, it’s about being recognizable. Ad-hoc signed apps lose their grants on every rebuild because TCC tracks code identity, not just bundle ID. Either get a real signing certificate (paid Apple Dev, or free self-signed) or accept that you’ll re-grant after every build.
Now that I’ve this cheap pad working at a basic level. It time to start see how usable I can make this and if it will become a staple in my studio/mobile pack.