lume

Elgato Key Light Air MK.2

Mutual-TLS WebSocket, JSON-RPC framing, BLE onboarding — and the trap that reboots the lamp.

Reverse-engineered for interoperability from observed device behaviour and the vendor’s own desktop application. The firmware itself was not disassembled, and no vendor code or key material is reproduced here.

Transport

  • TLS on TCP 9123, mutually authenticated (the lamp requires a client certificate it trusts).
  • The endpoint is a WebSocket at /. Plain HTTP requests to /elgato/* return 404 Nothing matches the given URI.
  • Application protocol is JSON-RPC 2.0 over the WebSocket.

Trust model

The MK.2 verifies client certificates against a CA it was given during BLE pairing. The official app installs the vendor CA; this project instead installs your own CA during onboarding, so your own client cert is accepted. A lamp still paired with the vendor app trusts only the vendor chain and cannot be controlled by a third-party certificate — factory-reset and re-onboard it first.

Methods

"<verb>.elgato.<endpoint>", verb ∈ {get, put, post}. Confirmed: get.elgato.api-info, get.elgato.lights, put.elgato.lights, get.elgato.lights.settings, put.elgato.lights.settings, post.elgato.identify, elgato.restart, elgato.factory-reset.

The device pushes notification.elgato.accessory-info, notification.elgato.lights, notification.elgato.lights.settings to every other open connection when state changes.

Requests are answered; commands are not

get.* requests do get a JSON-RPC result back on the same connection:

→ {"jsonrpc":"2.0","id":"1","method":"get.elgato.lights"}
← {"jsonrpc":"2.0","result":{"numberOfLights":1,
     "lights":[{"on":0,"brightness":8,"temperature":333}]},"id":"1"}

put.* and post.* are fire-and-forget and produce no reply at all. This asymmetry is what makes read-back possible: lume sends a put, then a get on the same connection, and the answer to the get is both the confirmation that the write landed and the state to print. Earlier notes on this protocol claimed the lamp never answers — that holds only for the command verbs.

Setting lights

{"jsonrpc":"2.0","id":"1","method":"put.elgato.lights",
 "params":{"numberOfLights":1,"lights":[{"on":1,"brightness":30,"temperature":200}]}}
  • brightness: 0–100
  • temperature: mired, 143 (7000K, cool) … 344 (2900K, warm)

Gotchas that cost real time

  1. id must be a JSON string. A numeric id makes the firmware reboot. The device’s own error replies use "id":"unknown" — a hint that it treats ids as strings.
  2. put.* is fire-and-forget — no reply, no error, nothing. A write that the firmware rejected looks exactly like one that worked, so confirm with a get.* rather than assuming. (get.* does answer; see above.)
  3. The lamp accepts one WebSocket connection at a time and is slow to free a slot after a dropped connection; close cleanly and don’t hammer it.

BLE onboarding (pairing mode)

Service 0d27fa90-f0d4-469d-afd3-605a6ebbdb13. Relevant characteristics (0d27fb9X): state 93, wifi-scan 94, wifi-credential 95, wifi-ip 9e, cert-install 9b, cert-cmd 9c, wifi-connect-option 9d.

Onboarding order: install certs (CA → server → tls-key), then send Wi-Fi credentials. Cert command handshake on cert-cmd (CMD_INIT → segments → …). Each cert block: [type:4][len:4 LE][contentCRC16:2 LE][headerCRC16:2 LE][PEM], segmented at 242 bytes, segment framed [frameLen:1][seq:1 (0x80 on last)]. Wi-Fi credential frame: [secType:2 LE][ssidLen:2 LE][pwLen:2 LE][ssid][pw], then +CRC16 and a leading length byte. All CRCs are CRC-16/KERMIT.