Cross-platform declarative driver for the Elgato Stream Deck. One YAML config produces identical behavior on Linux and Windows; later phases ship a daemon that talks to the device directly over USB HID with live OBS state integration.
Status: Phase 4 (current). deckctl daemon runs on Linux end-to-end including auto-profile-switching driven by the focused window (X11). Windows daemon code is in place (keys + media + active-window watcher) but the Task Scheduler install verb + Windows volume control are queued for Phase 4b. OBS execution + live indicators work as of Phase 3.
- Validate a YAML config against the full v1 schema (Pydantic 2 discriminated union over 21 action types).
- Resolve
${ENV_VAR}in any string field — keep passwords out of the YAML. - Render every key in a profile/page as a single mosaic PNG (offline preview, no device required).
- Warn (or strict-reject with
--strict-perms) when the config file is world-readable on POSIX. - Run a daemon that owns a real Stream Deck MK.2 over USB and dispatches button presses to handlers.
- Hot-reload the config without restarting the daemon.
- Execute OBS actions over the LAN: scene switch, recording/streaming/replay/virtualcam toggle, audio mute.
- Live state indicators: keys bound to OBS recording/streaming/replay/scene/mute auto-update when OBS state changes.
- Auto profile switching: define
profile_rules:matchingapp_class(Linux) orapp_name(Windows); the daemon switches profiles when the focused window matches. - Install as a systemd user unit with one command (
deckctl install-service). Daemon autostarts at login. deckctl doctorreports on device, deps, service status, config, and OBS reachability — exits non-zero on any FAIL.
Recommended (pipx, isolated):
pipx install deckctlFrom source:
git clone https://github.com/solomonneas/deckctl
cd deckctl
pipx install --editable .Phase 2+ actions shell out to a few system utilities. On Ubuntu / Debian:
sudo apt install -y libhidapi-libusb0 xdotool playerctl
# pactl ships with pulseaudio-utils on PulseAudio or pipewire-pulse on PipeWirelibhidapi-libusb0 is the USB HID library the streamdeck Python package binds to; the daemon will fail to enumerate any device without it. xdotool is required for key.chord and key.text actions, pactl for system.volume.*, playerctl for media.* — those three are only invoked at action dispatch time, so the daemon starts without them, but pressing an action that needs one will fail with FileNotFoundError.
# 1. Write a config
$ cat > ~/.config/deckctl/config.yaml <<'YAML'
version: 1
default_profile: coding
profiles:
coding:
default_page: home
pages:
home:
keys:
0:
icon: {text: "Tests", emoji: "🧪", bg: "#1e88e5"}
action: {type: shell, cmd: "pnpm test"}
YAML
# 2. Validate
$ deckctl validate ~/.config/deckctl/config.yaml
OK: ~/.config/deckctl/config.yaml (1 profile(s), 1 key(s) configured)
# 3. Preview as PNG (no device needed)
$ deckctl preview ~/.config/deckctl/config.yaml --out preview.png
Wrote preview.png (392x232)See docs/schema.md for the full YAML reference.
Run the daemon against a plugged-in Stream Deck MK.2:
deckctl daemon --config ~/.config/deckctl/config.yaml -vOr against an in-memory mock device (no hardware required — useful for testing your config):
deckctl daemon --config ~/.config/deckctl/config.yaml --mock -vThe daemon stays in the foreground. Use Ctrl+C to stop. Phase 2b will add a deckctl install-service command that registers a systemd user unit so it autostarts at login.
Edit the config file while the daemon is running — it'll hot-reload within ~1s. Invalid configs are logged and rejected; the daemon keeps the previous valid config.
Once your config works the way you want via deckctl daemon, register it as a systemd user unit so it autostarts at login:
deckctl install-service --config ~/.config/deckctl/config.yamlThis:
- Writes
~/.config/systemd/user/deckctl.servicepointing at your config. - Installs
/etc/udev/rules.d/60-streamdeck.rulesviasudo(prompts once for your password) so the device is reachable to any logged-in user — needed for the unit to find the Deck at boot. - Reloads udev, daemon-reloads systemd, enables and starts the service.
To stop and remove:
deckctl uninstall-service # removes systemd unit AND udev rule (sudo)
deckctl uninstall-service --keep-udev # leaves the udev rule in placeHealth check at any time:
deckctl doctor # full report
deckctl doctor --config ~/.config/deckctl/config.yaml # also validates the configOutput is a tabular PASS / WARN / FAIL per check (device, libhidapi, python_deps, system_binaries, udev, service, config). Exit code is non-zero if any check fails.
Configure one or more OBS instances under obs_hosts: in your config, then any obs.* action can target them by name:
obs_hosts:
roc:
url: obsws://127.0.0.1:4455/${OBS_ROC_PASS}
gandalf:
url: obsws://192.168.x.y:4455/${OBS_GANDALF_PASS}
profiles:
streaming:
default_page: home
pages:
home:
keys:
0:
icon: {text: "Cam", bg: "#1e88e5"}
action: {type: obs.scene.switch, host: roc, scene: "Camera"}
1:
icon:
text: "REC"
bg_idle: "#424242"
bg_active: "#d32f2f"
indicator: {bind: obs.recording.state, host: roc}
action: {type: obs.recording.toggle, host: roc}Actions execute via obs-cmd on PATH. The daemon also opens a WebSocket connection to each obs_hosts entry on startup to subscribe to state events; the REC key above turns red when OBS is actually recording, and back to gray when it stops. Hosts that aren't reachable at daemon startup are logged and skipped — actions targeting them will simply fail at dispatch time.
Indicators support:
obs.recording.state,obs.streaming.state,obs.replay.state,obs.virtualcam.state— boolean output statesobs.scene.current— match ascene:name; key is active when that scene is the current program sceneobs.input.muted— match aninput_name:; key is active when that audio input is muted
Add profile_rules: to your config to switch profiles automatically when the focused application changes:
profile_rules:
- profile: streaming
when:
app_class: [obs] # Linux WM_CLASS (lowercased)
app_name: [obs64.exe] # Windows process basename (lowercased)
- profile: coding
when:
app_class: [code, jetbrains-idea-ce, ghostty]
app_name: [code.exe, idea64.exe, windowsterminal.exe]
- profile: browsing
when:
app_class: [chromium, firefox]
app_name: [chrome.exe, firefox.exe]
default_profile: codingRules are evaluated top-to-bottom; the first match wins. Linux uses X11's _NET_ACTIVE_WINDOW + WM_CLASS; Windows uses GetForegroundWindow + the process basename. Both poll every 250ms — fast enough to feel instant. Wayland is not supported in Phase 4 (the daemon falls back to "no auto-switch" if it can't open an X display).
| Action | Purpose |
|---|---|
shell |
Run a shell command. |
key.chord |
Send a keystroke (e.g., ctrl+shift+t). |
key.text |
Type literal text. |
open.url / open.app |
Launch a URL / app. |
obs.scene.switch, obs.recording.toggle, obs.streaming.toggle, obs.replay.save, obs.virtualcam.toggle, obs.input.mute.toggle |
OBS WebSocket actions (target any host on the LAN). |
system.volume.up / .down / .mute |
OS volume control. |
media.play / .pause / .next / .prev |
OS media keys. |
page.go |
Navigate within a profile. |
profile.switch |
Switch active profile manually. |
compound |
Sequence of actions. |
Phase 1 validates these in the schema but only deckctl preview executes (rendering icons). Actual key-press dispatch ships in Phase 2.
Phase 1 targets the Elgato Stream Deck MK.2 (15 keys, 72x72 JPEG per key). Architecture is hardware-agnostic; XL/Mini/Plus support is queued for a later phase.
git clone https://github.com/solomonneas/deckctl
cd deckctl
python3.12 -m venv .venv && . .venv/bin/activate
pip install -e ".[dev]"
ruff check src tests
mypy src
pytest -qRegenerate renderer goldens:
DECKCTL_REGEN=1 pytest tests/unit/test_render.py
git status tests/fixtures/goldens/ # inspect before committingMIT. See LICENSE.