DEV Community

Cover image for USB HID on macOS: Talking to Devices with IOKit
Paul Contreras
Paul ContrerasSubscriber

Posted on

USB HID on macOS: Talking to Devices with IOKit

Part 1 of a series on building a native Razer mouse controller for macOS.


USB HID (Human Interface Device) is the protocol your mouse, keyboard, and gamepad use to talk to the OS. Most of the time it's invisible; The OS handles it and your app gets abstracted events.

But HID also has feature reports: raw byte arrays you can send directly to a device outside of normal input events. Manufacturers use these for configuration. That's how Razer Synapse sets DPI and RGB — and it's what we need to replace it.

On macOS, the API for this is IOHIDManager inside IOKit. No kernel extension required.


Setup

Command Line Tool target in Xcode, macOS, Swift. No sandbox add App Sandbox to entitlements and immediately remove it, or just confirm it's not there. IOKit feature reports fail silently with sandbox on.

Link IOKit.framework under target → General → Frameworks and Libraries.

importIOKitimportIOKit.hidimportFoundation
Enter fullscreen modeExit fullscreen mode

Finding a Device

IOHIDManager takes a matching dictionary. At minimum, vendor ID and product ID.

letmanager=IOHIDManagerCreate(kCFAllocatorDefault,IOOptionBits(kIOHIDOptionsTypeNone))IOHIDManagerSetDeviceMatching(manager,[kIOHIDVendorIDKey:0x1532,kIOHIDProductIDKey:0x0084]asCFDictionary)guardIOHIDManagerOpen(manager,IOOptionBits(kIOHIDOptionsTypeNone))==kIOReturnSuccesselse{print("failed to open manager")exit(1)}guardletdevices=IOHIDManagerCopyDevices(manager)as?Set<IOHIDDevice>,!devices.isEmptyelse{print("device not found")exit(1)}
Enter fullscreen modeExit fullscreen mode

If you don't know your device's PID yet, filter by vendor ID only and print everything:

IOHIDManagerSetDeviceMatching(manager,[kIOHIDVendorIDKey:0x1532]asCFDictionary)// ... open manager ...fordindevices{letname=IOHIDDeviceGetProperty(d,kIOHIDProductKeyasCFString)as?String??"?"letpid=IOHIDDeviceGetProperty(d,kIOHIDProductIDKeyasCFString)as?Int??0print("\(name): 0x\(String(pid,radix:16))")}
Enter fullscreen modeExit fullscreen mode

The Multiple Interfaces Problem

One physical USB device often enumerates as multiple HID interfaces. A Razer DeathAdder V2 shows up as four:

page: 0x1 | usage: 0x6 | maxFeatureReport: 1
page: 0x1 | usage: 0x6 | maxFeatureReport: 0
page: 0x59 | usage: 0x1 | maxFeatureReport: 64
page: 0x1 | usage: 0x2 | maxFeatureReport: 90
Enter fullscreen modeExit fullscreen mode

IOHIDManagerCopyDevices returns a Set. devices.first is non-deterministic you have a 1-in-4 chance of hitting the right interface. The wrong interfaces accept your data and do nothing with it. The send call returns 0 (success) and the device response is all zeros.

Print all interfaces first:

fordindevices{letusagePage=IOHIDDeviceGetProperty(d,kIOHIDPrimaryUsagePageKeyasCFString)as?Int??0letusage=IOHIDDeviceGetProperty(d,kIOHIDPrimaryUsageKeyasCFString)as?Int??0letmaxFeature=IOHIDDeviceGetProperty(d,kIOHIDMaxFeatureReportSizeKeyasCFString)as?Int??0print("page: 0x\(String(usagePage,radix:16)) | usage: 0x\(String(usage,radix:16)) | maxFeatureReport: \(maxFeature)")}
Enter fullscreen modeExit fullscreen mode

The interface you want for Razer configuration commands is the one with maxFeatureReport: 90 — that matches the 90-byte packet format Razer uses. Filter on it explicitly:

guardletdevice=devices.first(where:{din(IOHIDDeviceGetProperty(d,kIOHIDMaxFeatureReportSizeKeyasCFString)as?Int??0)==90})else{print("control interface not found")exit(1)}
Enter fullscreen modeExit fullscreen mode

For other devices, the right interface depends on the protocol. maxFeatureReport size is usually the clearest signal.


Opening and Sending

After filtering, open the specific interface and send a feature report:

guardIOHIDDeviceOpen(device,IOOptionBits(kIOHIDOptionsTypeNone))==kIOReturnSuccesselse{print("could not open device")exit(1)}varreport=[UInt8](repeating:0,count:90)// ... fill report bytes ...letresult=IOHIDDeviceSetReport(device,kIOHIDReportTypeFeature,0,&report,report.count)
Enter fullscreen modeExit fullscreen mode

Two things that caused me problems here:

Buffer size.IOHIDDeviceSetReport takes the report ID as a separate parameter (the 0 above). Do not prepend it to the buffer. If maxFeatureReport is 90, your buffer should be exactly 90 bytes. I was sending 91 and getting error -536850432 (0xE0005000) back from IOKit with no further explanation.

Reading the response. The response comes back in a separate IOHIDDeviceGetReport call. The device needs a small delay between send and read — the amount varies, but 200–300ms works reliably:

Thread.sleep(forTimeInterval:0.3)varresponse=[UInt8](repeating:0,count:90)varresponseLen=CFIndex(90)IOHIDDeviceGetReport(device,kIOHIDReportTypeFeature,0,&response,&responseLen)
Enter fullscreen modeExit fullscreen mode

Use a fresh buffer for the response so you can tell whether the device wrote anything into it.


Interpreting the Response

For Razer devices, byte [0] of the response is a status code:

ValueMeaning
0x00No response / read stale data
0x01Device busy
0x02Command accepted
0x05Command not understood

0x02 means the full stack is working. 0x05 usually means wrong command bytes or wrong transaction ID for the device family.

If you get all zeros back, you either read too fast, hit the wrong interface, or the buffer size was wrong.


What's Next

Part 2 covers the actual Razer packet format — what goes in those 90 bytes, how the CRC is computed, and what the transaction ID is and why it matters.

Top comments (1)

Collapse
 
3nginuity profile image
3nginuity

cool!

Some comments may only be visible to logged-in visitors. Sign in to view all comments.