Elgato Key Light Neo USB Protocol
I have been using an Elgato Key Light Neo as an easy way to illuminate my face on video calls. Unlike other products from Elgato, this one is controllable over WiFi and over USB. The protocol over WiFi is HTTP and has been reverse engineered already, and it is the same as other Elgato Key Light products.
This blog post outlines the protocol used to control the light over USB. As far as I can see, there is no documentation on this at all.
Device Overview
On macOS, it is easy to see low-level USB device information with the ioreg command.
$ ioreg -r -d2 -n "Elgato Key Light Neo" -l -w0 -y -f -x
+-o Elgato Key Light Neo@05330000 <class IOUSBHostDevice, id 0x102b1ebc7, registered, matched, active, busy 0 (38 ms), retain 39>
| {
| "sessionID" = 0x57d067c24ca1
| "USBSpeed" = 0x3
| "idProduct" = 0xa0
| "iManufacturer" = 0x1
| "bDeviceClass" = 0x0
| "IOPowerManagement" = {"PowerOverrideOn"=Yes,"DevicePowerState"=0x2,"CurrentPowerState"=0x2,"CapabilityFlags"=0x8000,"MaxPowerState"=0x2,"DriverPowerState"=0x0}
| "bcdDevice" = 0x200
| "bMaxPacketSize0" = 0x40
| "iProduct" = 0x2
| "iSerialNumber" = 0x3
| "bNumConfigurations" = 0x1
| "UsbDeviceSignature" =
| 00000000: D9 0F A0 00 00 02 41 37 42 54 42 34 31 35 31 30 51 4F 47 35 00 00 00 03 00 00 ......A7BTB41510QOG5......
| "USB Product Name" = "Elgato Key Light Neo"
| "locationID" = 0x5330000
| "bDeviceSubClass" = 0x0
| "bcdUSB" = 0x200
| "kUSBSerialNumberString" = "A7BTB41510QOG5"
| "USB Address" = 0x6
| "IOCFPlugInTypes" = {"9dc7b780-9ec0-11d4-a54f-000a27052861"="IOUSBHostFamily.kext/Contents/PlugIns/IOUSBLib.bundle"}
| "kUSBCurrentConfiguration" = 0x1
| "bDeviceProtocol" = 0x0
| "USBPortType" = 0x0
| "IOServiceDEXTEntitlements" = (("com.apple.developer.driverkit.transport.usb"))
| "USB Vendor Name" = "Elgato"
| "Device Speed" = 0x2
| "idVendor" = 0xfd9
| "kUSBProductString" = "Elgato Key Light Neo"
| "USB Serial Number" = "A7BTB41510QOG5"
| "IOGeneralInterest" = "IOCommand is not serializable"
| "kUSBAddress" = 0x6
| "kUSBVendorString" = "Elgato"
| }
|
+-o AppleUSBHostCompositeDevice <class AppleUSBHostCompositeDevice, id 0x102b1ec2d, !registered, !matched, active, busy 0, retain 4>
| {
| "IOProbeScore" = 0xc350
| "CFBundleIdentifier" = "com.apple.driver.usb.AppleUSBHostCompositeDevice"
| "IOProviderClass" = "IOUSBHostDevice"
| "IOClass" = "AppleUSBHostCompositeDevice"
| "IOPersonalityPublisher" = "com.apple.driver.usb.AppleUSBHostCompositeDevice"
| "bDeviceSubClass" = 0x0
| "CFBundleIdentifierKernel" = "com.apple.driver.usb.AppleUSBHostCompositeDevice"
| "IOMatchedAtBoot" = Yes
| "IOMatchCategory" = "IODefaultMatchCategory"
| "IOPrimaryDriverTerminateOptions" = Yes
| "bDeviceClass" = 0x0
| }
|
+-o IOUSBHostInterface@0 <class IOUSBHostInterface, id 0x102b1ec2e, registered, matched, active, busy 0 (32 ms), retain 12>
| {
| "USBSpeed" = 0x3
| "iInterface" = 0x0
| "bInterfaceProtocol" = 0x0
| "bAlternateSetting" = 0x0
| "idProduct" = 0xa0
| "bcdDevice" = 0x200
| "USB Product Name" = "Elgato Key Light Neo"
| "locationID" = 0x5330000
| "bInterfaceClass" = 0x3
| "bInterfaceSubClass" = 0x0
| "IOCFPlugInTypes" = {"2d9786c6-9ef3-11d4-ad51-000a27052861"="IOUSBHostFamily.kext/Contents/PlugIns/IOUSBLib.bundle"}
| "UsbExclusiveOwner" = "AppleUserUSBHostHIDDevice"
| "USBPortType" = 0x0
| "bConfigurationValue" = 0x1
| "bInterfaceNumber" = 0x0
| "USB Vendor Name" = "Elgato"
| "IOServiceDEXTEntitlements" = (("com.apple.developer.driverkit.transport.usb"))
| "idVendor" = 0xfd9
| "IODEXTMatchCount" = 0x1
| "bNumEndpoints" = 0x2
| "USB Serial Number" = "A7BTB41510QOG5"
| }
Output from ioreg for my Elgato Key Light Neo
This command displays three important pieces of information:
- The vendor ID is
0xfd9which is the widely used ID for Elgato. - The product ID is
0xa0. I have not seen it in any USB device databases, but it’s safe to assume this is exclusively for the Key Light Neo. - The device appears to be a USB Human Input Device (HID) even though it isn’t a keyboard or mouse.
Since the device is an HID, it’s possible to use the hidapi library to communicate with the device.
Low-level HID transport
USB HIDs communicate by sending ‘reports’ between device and host, which are effectively fixed-size packets. The Key Light Neo has a maximum report size of 512 bytes in both directions. The transport for messages involves chunking bytes into 512-byte frames. This protocol allows for arbitrary bytes to be sent to and from the device.
The byte-level envelope for each frame is below.
Byte-level layout of one 512-byte frame.
Each frame starts with a magic byte 0x02. The idx field is the frame index and total is the number of frames in the current message. Then another magic byte 0x03 is needed. I suspect this is a “message type” field but I have only seen 0x03 in practice. The len field is a little endian u16 of the number of bytes in the body. The body is terminated with another magic 0x03 and the remainder of the 512-byte frame is padded with zeros.
Using cython-hidapi, it is straightforward to send these frames to the device and receive frames from it.
#!/usr/bin/env python3
import sys
import hid
def build_frames(payload: bytes) -> list[bytes]:
chunks = [payload[i : i + 505] for i in range(0, len(payload), 505)] or [b""]
total = len(chunks)
return [
(bytes([0x02, idx, total, 0x03, *len(chunk).to_bytes(2, "little")]) + chunk + b"\x03").ljust(512, b"\x00")
for idx, chunk in enumerate(chunks)
]
def request(payload: bytes) -> bytes:
dev = hid.device()
dev.open(0x0FD9, 0x00A0)
try:
for frame in build_frames(payload):
# Need a null byte for hidapi
dev.write(b"\x00" + frame)
chunks: dict[int, bytes] = {}
total = 0
while True:
raw = dev.read(512, 100)
if not raw:
break
idx = raw[1]
total = total or raw[2]
n = int.from_bytes(raw[4:6], "little")
chunks.setdefault(idx, bytes(raw[6 : 6 + n]))
if total and len(chunks) >= total:
break
return b"".join(chunks[i] for i in sorted(chunks))
finally:
dev.close()
tx = sys.argv[1].encode("utf-8")
rx = request(tx)
print(rx.decode("utf-8"))
elgato.py which implements the framed protocol
Sending an empty string to the Key Light Neo should work.
$ ./elgato.py ""
Application level protocol
The application protocol is similar to the HTTP JSON API used over WiFi, but is transported over USB instead. At a high level, it involves sending the following formatted strings:
- Read commands are
GET <path>and return a JSON string. - Write commands are
PUT <path> <json>and return a JSON string.
The important commands are:
GET /elgato/accessory-infowhich returns the serial numberGET /elgato/lightswhich returns the status of the lightPUT /elgato/lights <json>which allows for turning the light on/off, adjusting brightness and color temperature
For example, “GET /elgato/accessory-info” returns details of my Key Light.
$ ./elgato.py "GET /elgato/accessory-info" | jq .
{
"productName": "Elgato Key Light Neo",
"hardwareBoardType": 210,
"hardwareRevision": "11.01",
"macAddress": "3C:6A:9D:26:2B:24",
"firmwareBuildNumber": 216,
"firmwareVersion": "1.0.4",
"serialNumber": "A7BTB41510QOG5",
"displayName": "",
"features": [
"lights",
"bt",
"hid"
],
"power-info": {
"operationMode": 1,
"maximumBrightness": 40
},
"bt-info": {
"broadcastMode": 1,
"pairing": false,
"paired": false
}
}
I can also get the status of the light by running:
$ ./elgato.py "GET /elgato/lights" | jq .
{
"numberOfLights": 1,
"lights": [
{
"on": 0,
"brightness": 3,
"temperature": 344
}
]
}
I can toggle the light on by running:
$ ./elgato.py 'PUT /elgato/lights {"lights":[{"on":1}]}' | jq .
{
"numberOfLights": 1,
"lights": [
{
"on": 1,
"brightness": 3,
"temperature": 344
}
]
}
Conclusion
The Elgato Key Light Neo USB protocol is very straight forward. Messages are framed in to 512-byte chunks and messages mirror the HTTP API. It should now be possible to control the Key Light Neo on Linux or without the Elgato app at all.