DEV Community

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

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.

import IOKit
import IOKit.hid
import Foundation
Enter fullscreen mode Exit fullscreen mode

Finding a Device

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

let manager = IOHIDManagerCreate(kCFAllocatorDefault, IOOptionBits(kIOHIDOptionsTypeNone))

IOHIDManagerSetDeviceMatching(manager, [
    kIOHIDVendorIDKey:  0x1532,
    kIOHIDProductIDKey: 0x0084
] as CFDictionary)

guard IOHIDManagerOpen(manager, IOOptionBits(kIOHIDOptionsTypeNone)) == kIOReturnSuccess else {
    print("failed to open manager")
    exit(1)
}

guard let devices = IOHIDManagerCopyDevices(manager) as? Set<IOHIDDevice>,
      !devices.isEmpty else {
    print("device not found")
    exit(1)
}
Enter fullscreen mode Exit fullscreen mode

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

IOHIDManagerSetDeviceMatching(manager, [kIOHIDVendorIDKey: 0x1532] as CFDictionary)
// ... open manager ...
for d in devices {
    let name = IOHIDDeviceGetProperty(d, kIOHIDProductKey as CFString) as? String ?? "?"
    let pid  = IOHIDDeviceGetProperty(d, kIOHIDProductIDKey as CFString) as? Int ?? 0
    print("\(name): 0x\(String(pid, radix: 16))")
}
Enter fullscreen mode Exit 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 mode Exit 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:

for d in devices {
    let usagePage  = IOHIDDeviceGetProperty(d, kIOHIDPrimaryUsagePageKey as CFString) as? Int ?? 0
    let usage      = IOHIDDeviceGetProperty(d, kIOHIDPrimaryUsageKey as CFString) as? Int ?? 0
    let maxFeature = IOHIDDeviceGetProperty(d, kIOHIDMaxFeatureReportSizeKey as CFString) as? Int ?? 0
    print("page: 0x\(String(usagePage, radix: 16)) | usage: 0x\(String(usage, radix: 16)) | maxFeatureReport: \(maxFeature)")
}
Enter fullscreen mode Exit 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:

guard let device = devices.first(where: { d in
    (IOHIDDeviceGetProperty(d, kIOHIDMaxFeatureReportSizeKey as CFString) as? Int ?? 0) == 90
}) else {
    print("control interface not found")
    exit(1)
}
Enter fullscreen mode Exit 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:

guard IOHIDDeviceOpen(device, IOOptionBits(kIOHIDOptionsTypeNone)) == kIOReturnSuccess else {
    print("could not open device")
    exit(1)
}

var report = [UInt8](repeating: 0, count: 90)
// ... fill report bytes ...

let result = IOHIDDeviceSetReport(device, kIOHIDReportTypeFeature, 0, &report, report.count)
Enter fullscreen mode Exit 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)

var response = [UInt8](repeating: 0, count: 90)
var responseLen = CFIndex(90)
IOHIDDeviceGetReport(device, kIOHIDReportTypeFeature, 0, &response, &responseLen)
Enter fullscreen mode Exit 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:

Value Meaning
0x00 No response / read stale data
0x01 Device busy
0x02 Command accepted
0x05 Command 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 (0)