The gaffer is the crew chief who runs the lights on a film set.
A small Linux daemon that discovers Elgato Key Lights on your network and puts them on the D-Bus session bus, plus a CLI that makes them bindable to a key.
$ gaffer list
NAME STATE BRIGHT TEMP ADDRESS
Elgato Key Light Left on 42% 4200K http://192.0.2.10:9123
Elgato Key Light Right on 42% 4200K http://192.0.2.11:9123
All Lights on 42% 4200K 2 of 2 online
$ gaffer set left 42% 4200k
$ gaffer set all -10%
$ gaffer toggleBecause light state should outlive a window. A GUI that owns discovery loses everything when you close it, and every new client — a hotkey, a panel module, OBS, a future app — has to re-implement mDNS and the protocol from scratch.
gaffer owns the state once. Everything else is a thin client.
sudo dnf copr enable mineiro/rpms
sudo dnf install gafferFedora 43, 44 and rawhide, on x86_64 and aarch64. Packages track releases, and are built in mineiro/rpms from the source archive of each tag.
sudo dnf --refresh upgrade gaffer--refresh because dnf serves cached repository metadata by default, so a new
release can be reported as nothing to do for as long as that cache lives.
Upgrading does not restart the daemon. RPM scriptlets run as root while gafferd runs in your session, so the new binary lands on disk while the old process keeps running:
systemctl --user restart gaffer.servicegaffer warns when it spots this, and gaffer version shows which build is
actually running.
If you enabled mineiro/gaffer before packaging moved out of this repository,
remove it — that project no longer exists:
sudo dnf copr remove mineiro/gafferdisable is not enough. It leaves the repository file in place with
enabled_metadata=1, still pointing at a URL that now returns 404.
{
inputs.gaffer.url = "github:mineiro/gaffer";
# in your configuration:
imports = [ inputs.gaffer.nixosModules.default ];
services.gaffer.enable = true;
}services.gaffer.autoStart = true keeps the daemon resident from login rather
than activating on demand; openFirewall (on by default) permits inbound mDNS.
Needs Rust 1.88+ and a D-Bus session bus. Beyond glibc it links nothing — no GUI toolkit, no Avahi, no OpenSSL.
make && make install-user # → ~/.local/bin, ~/.config/systemd/user, ~/.local/share/dbus-1
make uninstall-user # removes all of it againPackagers want make DESTDIR=… PREFIX=/usr install, which stages into a
buildroot and touches nothing live.
There is nothing to start. gaffer is D-Bus activated, so the first command launches it, and the activation file defers to systemd so the daemon gets a proper cgroup, journal capture and restart policy.
Activation returns as soon as the daemon claims its bus name, which is before mDNS has found anything — so the very first command after a cold start can report no lights. If something is always watching, such as a status-bar module, keep the daemon resident instead and discovery will have settled long before anything asks:
systemctl --user enable --now gaffer.serviceLogs: journalctl --user -u gaffer -f. Raise verbosity with
systemctl --user set-environment GAFFER_LOG=debug.
Value suffixes carry the unit, so order never matters and everything composes:
| Token | Meaning |
|---|---|
42% |
set brightness |
+10% / -10% |
adjust brightness, clamped |
4200k |
set colour temperature (2900–7000K) |
-200k |
warm by 200K |
on off toggle |
power |
A selector is a name substring (left), a hardware id, or all — and it
defaults to all, so gaffer on 60% addresses everything.
gaffer set left 42% 4200k # one light, absolute
gaffer set all -10% # dim everything by 10
gaffer on right 80% # power on and set, in one command
gaffer identify left # blink it, to tell which is which
gaffer list --json # for scripts; carries gang membershipset changes exactly what you name — it never implicitly powers a light on.
Use on when you mean on.
gaffer link left right # they now move as one instrument
gaffer set left +10% # both rise, keeping their difference
gaffer link --mirror a b # snap b onto a instead of keeping the difference
gaffer unlink left # break the ganglink learns the brightness difference the lamps have now, so a key/fill
ratio survives — set your fill 7 points below the key and it stays 7 points
below wherever you take the pair. Moving either lamp moves both. Colour
temperature and power mirror; only brightness carries the offset. A pair that
already matches learns an offset of zero, which behaves exactly like a mirror,
so the friendly default is also the non-destructive one.
Gangs live in the daemon, so a compositor hotkey moves the pair with no panel
running, and they survive a restart — they are stored in
~/.config/gaffer/config.toml, which is meant to be readable.
gaffer scene save "on camera" # remember the whole desk under a name
gaffer scene "on camera" # put it back
gaffer scene # list what you have saved
gaffer scene rm "on camera" # forget oneA scene remembers the desk as gangs plus values, not as a row of brightnesses. Restoring one brings back the instruments: the pair that was ganged is ganged again, with the spacing it had, so the next thing you do to it behaves the way you expect.
Applying a scene only touches the lamps it names. A lamp you added since saving is left where it is — though if it was ganged to one the scene does name, it loses that partner, because the scene is authoritative about the gangs it describes. A lamp that is switched off or unplugged is not an error either: the rest of its gang re-forms without it, and its place is kept, so plugging it back in and re-applying restores the gang whole.
Scenes are stored in the same config.toml as gangs.
bind = SUPER, K, exec, gaffer toggle
bind = SUPER SHIFT, K, exec, gaffer set all +10%
bind = SUPER CTRL, K, exec, gaffer set all -10%Global hotkeys need no portal, because the binding target is a program.
gaffer watch --waybar prints one JSON object per line, forever — which is
exactly Waybar's custom/ module protocol. On Wayland there is no system tray,
so this is the panel integration.
Output carries text, percentage, a class of on/off/offline/empty
for styling, and a per-light tooltip.
Any language that speaks D-Bus is a first-class client — GTK via Gio.DBus, Qt
via QtDBus, Rust via zbus. Flatpak apps need one line:
--talk-name=io.mineiro.gaffer.
io.mineiro.gaffer bus name
/io/mineiro/gaffer Manager1 + org.freedesktop.DBus.ObjectManager
├── /lights/00005E005301 Light1
└── /lights/all Light1 (the group, as a light)
The group implements the same interface as a single light, so controlling
everything at once needs no special case. ObjectManager gives hotplug-aware
enumeration for free, and writable On/Brightness/Kelvin properties emit
PropertiesChanged when the hardware confirms.
Treat ObjectManager as a subscription, not a one-shot query: read
GetManagedObjects and stay on InterfacesAdded/InterfacesRemoved, or a
client started before discovery finishes will show an empty list forever.
The exact contract is committed as crates/gafferd/api/*.xml and pinned by a
test, so a property cannot be renamed or retyped without a failing build and a
visible diff. Note the types: Brightness is y (byte), Kelvin is q
(uint16), OnlineCount is u (uint32).
busctl --user tree io.mineiro.gaffer
busctl --user set-property io.mineiro.gaffer \
/io/mineiro/gaffer/lights/all io.mineiro.gaffer.Light1 Brightness y 42Verified: Elgato Key Light MK.2 (20GAK9902).
Should work, untested: Key Light, Key Light Air, Ring Light, Light Strip —
anything advertising _elg._tcp.local and serving the Key Light HTTP API on
port 9123. They share one protocol, so the odds are good, but nobody has run
gaffer against them. Reports either way are welcome.
Lights are keyed on the MAC from their mDNS TXT record, so renaming one in Elgato's app does not make it reappear as a stranger.
No lights found. Confirm the network sees them at all:
avahi-browse -rtp _elg._tcpIf that comes up empty, the problem is below gaffer. Lights must be on the same
layer-2 network — mDNS does not cross subnets or most guest/IoT VLAN isolation —
and inbound mDNS must be permitted. Fedora Workstation allows it by default;
check with firewall-cmd --list-services | grep mdns and add it with
firewall-cmd --add-service=mdns --permanent if it is missing.
Commands fail with "name not activatable." The D-Bus activation file did not
install. Re-run make install-user and check
~/.local/share/dbus-1/services/io.mineiro.gaffer.service exists.
Changes from an upgrade did not take effect. A package upgrade replaces the
binary but cannot restart a user service, so the old daemon keeps running.
gaffer version shows what is actually running, and the CLI warns when it
detects this. Fix it with systemctl --user restart gaffer.service.
A light shows offline. gaffer discovered it but cannot reach its HTTP API.
gaffer list prints the transport error next to the address; the daemon retries
every 15 s and recovers on its own once the light is reachable.
For anything else, journalctl --user -u gaffer -f with
systemctl --user set-environment GAFFER_LOG=debug.
gaffer controls LAN studio lights from a Linux desktop session, and deliberately stops there. Not planned: Windows or macOS, Elgato's cloud or mobile app, Stream Deck integration, or Bluetooth/Zigbee bulbs. Support for other LAN light protocols is plausible — the discovery, backend and device layers are separate seams — but nothing beyond Elgato is implemented today.
Working today, verified against real hardware: discovery, control, grouping, gangs, scenes, the D-Bus API, the CLI, and the Waybar module.
Not implemented yet — listed so nobody goes looking for them:
- Camera-follow — turning the key lights on when the webcam goes live, via PipeWire. This is the feature that makes a daemon worth having over a script.
- A native panel. The D-Bus API is the interface; a GUI is a client like any other, and none is bundled yet.
GPL-3.0-or-later.