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
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)
}
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))")
}
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
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)")
}
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)
}
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)
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)
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)