Elgato Key Light Neo USB Protocol

Created On:

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:

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.

magic0x02idxtotalmagic0x03lenu16LEpayloadbytesend0x03zeropad01234..56..6+len..511Fixedheader(6bytes)BodyHIDframelayout(512bytes)

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:

The important commands are:

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.