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 6 (current, v0.3.0). Bundled preset library - deckctl init coding writes a working dev profile (5 pages, Claude + Codex launchers, AUTO key); deckctl init streaming-twitch or streaming-youtube for streamers. Plus everything from v0.2.0: deckctl daemon with full action grammar, OBS integration + live indicators, auto profile switching, systemd service install on Linux, Windows port (Task Scheduler install in Phase 4b).
- 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. Install + a starter config in one shot
pipx install deckctl
deckctl init coding # or `default`, `streaming-twitch`, `streaming-youtube`
deckctl init --list # see all available presets
# 2. Validate (no device required)
deckctl validate ~/.config/deckctl/config.yaml
# 3. Preview as PNG (no device required)
deckctl preview ~/.config/deckctl/config.yaml --out preview.png
# 4. Run the daemon
deckctl daemon --config ~/.config/deckctl/config.yaml -vSee 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}
windows-host:
url: obsws://192.168.x.y:4455/${OBS_windows-host_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.