A small Go daemon that puts Elgato Key Lights into Apple Home. It discovers every Key Light on the LAN over mDNS, exposes each as a HomeKit Lightbulb — power, brightness, colour temperature — and keeps Home in sync with whatever else is poking the light (the Elgato app, a Stream Deck, a keyboard shortcut). No cloud account, no Elgato Control Center, no Home Assistant. LAN only.
It talks to the light's built-in HTTP API (port 9123) directly and is built
on brutella/hap, shipped as a Nix flake
with a NixOS module.
Why this exists: the Key Light has no native HomeKit support — Elgato expects you to use Control Center or a Stream Deck. This bridges the gap so the light lives in Home alongside everything else, and so a Siri "turn off the key light" actually works.
The light speaks a tiny unauthenticated HTTP API; the daemon is a thin, stateful HomeKit front-end over it:
# The HTTP endpoint the daemon drives (via Go's net/http) — shown with curl
# here only so you can poke it by hand. Each light advertises an mDNS name
# like elgato-key-light-<id>.local (no need to know its IP):
$ curl http://elgato-key-light-c998.local:9123/elgato/lights
{"numberOfLights":1,"lights":[{"on":1,"brightness":20,"temperature":213}]}
# The daemon, once running, logs its pairing PIN:
$ journalctl -u keylight-hap | grep -i pin
... HomeKit PIN pin=1234-5678Add the bridge in the Home app, enter the PIN, done — the light shows up with an on/off toggle, a brightness slider, and a warm↔cool temperature slider, and it tracks changes you make from any other controller within one poll interval.
What's covered:
- Discovery: browses
_elg._tcp.local.at startup and exposes every light it finds, named from each device'sdisplayName. - Control from Home: power, brightness (0–100 %), and colour temperature (2900 K–7000 K). HomeKit Identify flashes the light.
- Two-way sync: polls each light (default every 20 s) so changes from the Elgato app, a Stream Deck, or a keybinding appear in Home.
- Adaptive Lighting: each light supports HomeKit Adaptive Lighting (see below) — enable it on the bulb in the Home app and the daemon tracks the day's natural colour-temperature curve, warming as the light dims. Requires a home hub.
- HomeKit done right: generated + persisted 8-digit PIN, pairing survives restarts, each light carries its real manufacturer / model / serial / firmware in the accessory details.
What's deliberately out (see Known limitations):
- The RGB Light Strip / colour models — this exposes white-temperature Key Lights only.
- Runtime add/remove of lights — the device set is a startup snapshot.
Any Elgato Wi-Fi light that advertises _elg._tcp and exposes the
on / brightness / temperature light object:
| Model | Status |
|---|---|
| Elgato Key Light | Tested (firmware 1.0.3). |
| Elgato Key Light Air | Expected to work — same API surface. |
| Elgato Key Light Mini | Expected to work — same API surface. |
| Elgato Light Strip (RGB) | Not supported (uses hue/saturation, no temperature). |
temperature is reported in mireds: 143 = 7000 K (cool) through 344
= 2900 K (warm), which maps 1:1 onto HomeKit's ColorTemperature
characteristic, so the colour slider needs no conversion — just a clamp to
the device's range.
With Nix, no install:
# Run straight from the flake (slower per-invocation; re-checks the flake):
nix run github:hughobrien/keylight-hap
# Or build the binary into ./result/bin:
nix build github:hughobrien/keylight-hap
./result/bin/keylight-hapFrom source (Go 1.26+):
go build ./cmd/keylight-hap && ./keylight-hapOn first run it browses for lights, builds the bridge, and prints the PIN.
Every flag also reads an environment variable; the NixOS module sets them on the command line. There are no secrets — the Elgato API is unauthenticated — so there is no config file.
| Flag | Env | Default | Meaning |
|---|---|---|---|
--bridge-name |
KEYLIGHT_HAP_BRIDGE_NAME |
keylight-hap |
Name shown during HomeKit pairing. |
--port |
KEYLIGHT_HAP_PORT |
0 |
HAP TCP port; 0 = OS-assigned ephemeral. |
--poll-interval |
KEYLIGHT_HAP_POLL_INTERVAL |
20s |
State-sync poll period. |
--discovery-timeout |
KEYLIGHT_HAP_DISCOVERY_TIMEOUT |
5s |
mDNS browse window per attempt. |
--state-dir |
KEYLIGHT_HAP_STATE_DIR |
/var/lib/keylight-hap |
PIN + pairing storage directory. |
--debug |
KEYLIGHT_HAP_DEBUG |
false |
Verbose HAP debug logging (raw protocol payloads). |
# flake.nix
{
inputs.keylight-hap.url = "github:hughobrien/keylight-hap";
outputs = { self, nixpkgs, keylight-hap, ... }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
modules = [
keylight-hap.nixosModules.default
./configuration.nix
];
};
};
}The simplest form — one knob, ephemeral port, firewall opened for you:
services.keylight-hap = {
enable = true;
openFirewall = true; # opens UDP 5353 (mDNS) + the HAP TCP port if pinned
};openFirewall only opens the HAP TCP port when you pin one (an ephemeral
port = 0 can't be firewalled), so for a fixed hole set a port too:
services.keylight-hap = {
enable = true;
port = 21063;
openFirewall = true;
};Prefer to manage the firewall centrally instead of per-module? Leave
openFirewall = false, pin port, and open that TCP port (plus mDNS
5353/udp) on your LAN interface yourself.
nixos-rebuild switch, then find the PIN and add the bridge in Home:
journalctl -u keylight-hap | grep -i pin- Runs
keylight-hapas a dedicated system user under a hardened systemd unit (ProtectSystem=strict, syscall filtering, etc.).AF_NETLINKis deliberately allowed — without it the mDNS responder silently advertises on zero interfaces and the bridge never appears on iPhones. - Stores the PIN and pairing keys in
StateDirectory=/var/lib/keylight-hap.
| Option | Default | Meaning |
|---|---|---|
services.keylight-hap.enable |
false |
Enable the service. |
keylight-hap.bridgeName |
"keylight-hap" |
Name shown during pairing. |
keylight-hap.port |
0 |
HAP TCP port (0 = ephemeral). |
keylight-hap.pollInterval |
"20s" |
State-sync poll period. |
keylight-hap.discoveryTimeout |
"5s" |
mDNS browse window per attempt. |
keylight-hap.stateDir |
/var/lib/keylight-hap |
PIN + pairing storage. |
keylight-hap.openFirewall |
false |
Open mDNS + the pinned HAP port. |
Impermanence: if your root is wiped on boot, persist
/var/lib/keylight-hap(e.g. add it to your impermanencedirectories). Otherwise the PIN regenerates and HomeKit drops the pairing every reboot.
The 8-digit HomeKit PIN is generated on first run (random, screened against
HomeKit's weak-PIN list), written to pin.txt (mode 0600) in the state
directory, and logged at startup as XXXX-XXXX:
journalctl -u keylight-hap | grep -i pinAdd the bridge in the Home app and enter that PIN. Every discovered light appears together under the one bridge, each as its own tile. To factory-reset pairing, delete the state directory and restart — a fresh PIN is generated.
Each Key Light supports HomeKit Adaptive Lighting — the same feature Apple ships on first-party bulbs. Toggle it on for the bulb in the Home app and a home hub (Apple TV or HomePod) hands the daemon a 24-hour colour-temperature schedule; the daemon then drives the light along that curve, cooler in the daytime and warmer toward night, and shifts warmer as you dim the brightness. The schedule is refreshed by the hub each day.
- Requires a home hub. Adaptive Lighting is set up and renewed by an Apple TV / HomePod; without one the toggle won't appear. This is a HomeKit requirement, not specific to keylight-hap.
- Manual changes turn it off. Setting the colour temperature yourself — from the Home app, the Elgato app, a Stream Deck, or a keybinding (the last three are seen via the poll loop) — disables Adaptive Lighting until you re-enable it in Home, exactly as Apple's own behaviour does.
- Survives restarts. The active schedule is persisted in the state directory alongside the PIN, so Adaptive Lighting resumes immediately after the daemon restarts without waiting for the hub to re-send it.
Implementation note: the three HomeKit transition characteristics and the curve
engine live in the brutella/hap fork
this project builds against, ported from the reverse-engineered formats in
HAP-NodeJS and
HAP-python.
Deliberate choices, not bugs:
- Startup snapshot. The set of lights is fixed when the daemon starts. A
light added, removed, or re-addressed (new DHCP lease) afterwards isn't
picked up until you restart the service. A light that goes offline stays
present-but-unreachable in Home with its last-known values. This keeps the
bridge simple and avoids re-announcing the HomeKit accessory database at
runtime (which
brutella/hapisn't built for). - White temperature only. Key Lights are white-with-adjustable-temperature devices; there is no hue/saturation to expose. The RGB Light Strip uses a different light object and is out of scope.
- 1 % is "on but dark." The device accepts
brightnessdown to 1, but at 1 it is effectively off while still reportingon. Dragging brightness to 0 in Home turns the accessory off cleanly; the 1 % corner is harmless.
nix develop # Go, gopls, gotools, go-tools
go test ./... # unit tests, incl. an httptest fake Key Light
go test -race ./...
go vet ./... && gofmt -l .Project layout:
cmd/keylight-hap/ entrypoint: flags/env, discovery, lifecycle
internal/elgato/ HTTP client for the light + an in-memory fake device
internal/discover/ mDNS browse of _elg._tcp
internal/bridge/ HAP accessory wiring, PIN/FsStore, the poll loop
nix/module.nix the services.keylight-hap NixOS module
The design spec and implementation plan live under docs/superpowers/.
After bumping a dependency, refresh the flake's vendorHash: set it to
pkgs.lib.fakeHash, run nix build .#keylight-hap, and paste the reported
got: hash back in.
The Elgato Key Light's local API is undocumented by the vendor. This project stands on two community reverse-engineering write-ups:
- adamesch/elgato-key-light-api — the resource/field reference for
/elgato/lights,/elgato/accessory-info, and/elgato/identify: https://github.com/adamesch/elgato-key-light-api - apihandyman — "Hacking Elgato Key Light with Postman" — for the endpoint walkthrough and the temperature-range notes: https://apihandyman.io/hacking-elgato-key-light-with-postman/
(The two sources disagree on the colour-temperature direction; the
temperature-is-mireds reading used here was confirmed against a real Key
Light.)
HomeKit support is provided by brutella/hap,
via a fork that adds the Adaptive Lighting
characteristics + curve engine and carries a few upstreamable HAP fixes (pairing
handler robustness; see the fork's branches and
brutella/hap#67). The Adaptive
Lighting TLV8 format was ported from
HAP-NodeJS and
HAP-python. mDNS is handled by
brutella/dnssd (pinned to an upstream
commit ahead of v1.2.14 for an mDNS-browse CPU fix).
Copyright (C) 2026 Hugh O'Brien
This program is free software: you can redistribute it and/or modify it under
the terms of the GNU General Public License as published by the Free Software
Foundation, either version 3 of the License, or (at your option) any later
version (SPDX-License-Identifier: GPL-3.0-or-later). See LICENSE
for the full text.
This project is not affiliated with or endorsed by Elgato / Corsair. "Elgato" and "Key Light" are trademarks of their respective owners.