A Linux daemon for Elgato Stream Deck devices. Config-driven, YAML-only, userspace USB — no GUI, no plugins, no kernel module.
# 1. clone
git clone https://github.com/onurpaca/decd
cd decd
# 2. one-time permission setup (udev rule + plugdev)
./scripts/setup.sh
# ^ log out / log back in once so plugdev membership takes effect
# 3. install (pipx is the cleanest — isolated venv, decd on PATH)
pipx install . # or: pip install --user .
# 4. configure
mkdir -p ~/.config/decd
cp decd/assets/config.example.yaml ~/.config/decd/config.yaml
$EDITOR ~/.config/decd/config.yaml
# 5. (optional) Home Assistant token
echo 'HA_TOKEN=your-long-lived-token' > ~/.config/decd/env
chmod 600 ~/.config/decd/env
# 6. run
decd
# or as a systemd user service that starts on login:
install -Dm644 packaging/systemd/decd.service \
~/.config/systemd/user/decd.service
systemctl --user daemon-reload
systemctl --user enable --now decdNo sudo at runtime — the udev rule + plugdev grants device access to the logged-in user. If another Stream Deck app is already running (Elgato's Wine tool, StreamController, etc.), stop it first; only one process can claim the USB interface at a time.
device:
brightness: 75
sources:
- name: ha
type: home_assistant
url: "wss://homeassistant.local/api/websocket"
token_env: HA_TOKEN
pages:
- name: Home
id: 1
keys:
- position: [0, 0]
icon: icons/folders/lights.png
label: Lights
actions:
- type: folder
page: lights
- position: [1, 0]
icon: icons/ghostty.png
actions:
- type: shell
command: ghostty
- position: [2, 0]
icon: icons/loading.gif
label: Build
actions:
- type: shell
command: make
hold:
- type: shell
command: make clean
dials:
- index: 0
actions:
- type: volume
action: mute
rotate_cw:
- type: volume
action: up
rotate_ccw:
- type: volume
action: down
- name: Lights
id: lights
keys:
- position: [0, 0]
icon: icons/ha/bulb.png # fallback until live state arrives
label: Office
bind: # icon flips with HA state
source: ha
entity: light.office
states:
on: { icon: icons/ha/bulb-on.png }
off: { icon: icons/ha/bulb-off.png }
actions:
- type: ha_toggle
entity_id: light.office
- position: [3, 1]
icon: icons/back.png
actions:
- type: backdecd is a userspace USB application. It talks to the device through the
kernel's existing usbfs interface (/dev/bus/usb/NNN/MMM) using only
ctypes and a handful of ioctls — no kernel module, no modprobe, no
out-of-tree compilation.
┌─────────────────────────────────────────────────────┐
│ Userspace │
│ ┌──────────────────┐ │
│ │ decd │ Python + ctypes │
│ └────────┬─────────┘ │
│ │ ioctl(USBDEVFS_SUBMITURB, …) │
│ │ ioctl(USBDEVFS_CONTROL, …) │
│ ┌────────▼─────────┐ │
│ │ /dev/bus/usb/ │ usbfs (kernel-provided │
│ │ 009/010 │ userspace API) │
│ └────────┬─────────┘ │
│ ─ ─ ─ ─ ─ │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│ Kernel │ │
│ ┌────────▼─────────┐ ┌──────────────────┐ │
│ │ drivers/usb/ │ │ hid-generic │ │
│ │ core/devio.c │ │ (detached while │ │
│ │ (usbfs handler) │ │ we hold it) │ │
│ └────────┬─────────┘ └──────────────────┘ │
│ │ URB (USB Request Block) │
│ ┌────────▼─────────┐ │
│ │ USB host driver │ xhci_hcd │
│ └────────┬─────────┘ │
└───────────┼─────────────────────────────────────────┘
│ DMA
┌────▼────┐
│ USB │ ← cable
│ device │
└─────────┘
A small fixed set of ioctls on the usbfs file descriptor:
| ioctl | Purpose |
|---|---|
USBDEVFS_DISCONNECT |
detach the kernel hid-generic driver from this interface |
USBDEVFS_CLAIMINTERFACE |
claim the interface for exclusive use |
USBDEVFS_SUBMITURB (type=INTERRUPT) |
submit one URB to the host controller |
USBDEVFS_REAPURB |
collect a completed URB |
USBDEVFS_CONTROL |
one synchronous control transfer (HID feature reports: brightness, reset) |
RELEASEINTERFACE / CONNECT |
clean handover back to the kernel on shutdown |
The kernel hid-generic driver does not deliver Stream Deck Plus's
1024-byte output reports correctly — every write times out (ETIMEDOUT)
even though the report descriptor and endpoint sizes match. The Elgato
vendor tooling (and the python-elgato-streamdeck library) work around
this by using libusb, which detaches the kernel driver and drives
the URBs itself. decd does the same thing without libusb / hidapi as a
dependency — the same primitives are reachable through the kernel's
usbfs ioctls directly. That keeps the dependency surface to stdlib +
Pillow + PyYAML + websockets.
- Stream Deck Plus — 4×2 keys + 4 dials + 800×100 touchscreen
- YAML config — no GUI, edit directly
- 28 built-in actions —
shell,change_page,folder,back,next_page/previous_page,dbus,hotkey,text,url,open,media,system(lock/sleep/logoff/shutdown/reboot),volume,brightness,mute_mic,screenshot,delay,set_deck_brightness,ha_toggle,ha_service,ws_send,notify,clipboard,http,cooldown,mode/exit_mode - Folders + history stack —
folderpushes the current page,backpops it - Long press — per-key
hold:actions with configurable threshold - Multi-action sequences — chain actions with optional
delay - Animated icons — GIF / APNG / animated WebP, frame-accurate per-key playback
- External sources — config-declared, named live data feeds.
home_assistant(WebSocket, withha_toggle/ha_service)websocket(generic JSON-over-WS, bearer/header/query auth)http_poll(periodically GETs a URL; delivers each body to bind paths and trigger matchers) All three auto-reconnect with exponential backoff; HA fetches a fresh snapshot on every (re)connect so bound keys never show stale state.
- Live state binding —
bind:on any key or dial swaps icon/label as values change. Two source families are wired:entity:against ahome_assistantsource — initial snapshot is fetched on every (re)connect, thenstate_changedevents drive live updates.path:against a genericwebsocketsource — the manager parses each inbound JSON message and walks the dotted path, dispatching when the value differs. Predicate matchers (gt,lt,gte,lte,equals,in,regex) compose alongside thestates:exact-value map. An optionaltext:template renders state inline ("{state}°C","Vol {state}","ON ({brightness}%)"); HAattributesare exposed as placeholders. Dial state changes trigger a partial 200×100 touchscreen zone re-upload rather than the full 800×100 strip.
- Custom HA events —
on_event(event_type, callback)on the HA source subscribes to anything beyondstate_changed(automation_triggered, custom user events). Re-subscriptions survive reconnects automatically. - Hotplug — kernel netlink event-driven via
pyudev(optional dep:pip install decd[hotplug]). Without pyudev, falls back to periodic polling. Either way: unplug → torndown, replug → instant repaint with live state cache; daemon also starts cleanly when no deck is connected. - Triggers —
triggers:block runs action sequences in response to source events. Two flavours:- HA path:
event:+ optionalwhen:filter againstevent.data. - Generic-WS path:
match:list of{path, equals|regex|gt|lt|in|...}conditions evaluated against each inbound JSON message (AND).
- HA path:
- Bind transition actions — a
bind:can declare per-stateon_enter:/on_exit:plus a top-levelon_change:. Fired when the observed value transitions, not on the initial-state snapshot, so reloads and reconnects don't spam re-fire enter actions. - Progress bar overlay — add
bar: true(0–100 input) orbar: { attribute: brightness, max: 255, color: "#ffaa00" }to abind:and decd renders a fill bar at the bottom of the key. The bar reads from a HA state attribute whenattribute:is given, otherwise from thestatevalue itself. - Tint / background / multi-line label — bind override dicts can
carry
tint:(color the icon by multiply blend),bg_color:(key background), and label strings can use\nor auto-wrap on width. TheDECD_FONTenv var overrides the default font (DejaVu Sans). - Modal pages (
mode/exit_mode) — likefolderbut doesn't push history; the previous page is the anchor and decd hops back automatically aftertimeout:of input idle, after running any action on the modal page (exit_on_action: trueby default), or on explicitexit_mode. Designed for chord-style temporary submenus. - Hot config reload —
kill -HUP $(pgrep -f decd)re-reads the config without restart: pages, sources, bindings, triggers all swap. Sources whose name is unchanged keep their existing connection. - CLI subcommands —
decd validate [path]checks config (icons, unknown actions/widgets, undefined source refs, OOB positions; non-zero exit on issues).decd list-actions/list-widgets/list-sources/list-devicesprint registries. Baredecdruns the daemon. - Widgets —
widget:on a key drives a refresh loop. Built-ins:clock,weather,cpu,memory,battery. Each widget owns its tile and re-renders on its own cadence (refresh: 30s/5m/1h). Tasks pause when the page isn't active. - systemd user service — ships a unit file in
packaging/systemd/that starts decd on graphical login.
/dev/bus/usb/* defaults to root:root 0660. Two paths to non-root
access:
- Recommended — run
./scripts/setup.shto install the udev rule and add your user toplugdev. Logout/login once. - Development only — run as root via
sudo.
The shipped udev rule:
SUBSYSTEM=="usb", ATTRS{idVendor}=="0fd9", MODE="0660", GROUP="plugdev"
Note that during operation decd holds a USBDEVFS_DISCONNECT against the
interface — running another Stream Deck program (Elgato's Windows tool
under Wine, StreamController, etc.) at the same time will fight over
ownership. Stop the other one first.
- Python 3.14+
- Linux with usbfs (
/dev/bus/usb/...) — every modern distro - User in
plugdevgroup (after runningsetup.sh) wpctl(PipeWire) oramixer/pactlfor thevolumeaction,playerctlformedia,xdotool/wtype/ydotoolfortextandhotkey,xdg-openforurl/open,systemctl/loginctlforsystem. Each action falls back gracefully if its tool isn't installed.
| Device | VID:PID | Geometry |
|---|---|---|
| Stream Deck Plus | 0fd9:0084 |
4×2 keys, 4 dials, 800×100 touchscreen |
decd list-devices prints the registry at runtime. New hardware is
added by subclassing decd.device.Device and registering it; nothing
in the daemon, renderer, or config layer is hard-coded to a specific
model.
docs/architecture.md— module layout and component responsibilitiesdocs/design.md— design rationale, what's in/out of scopedocs/backlog.md— roadmap and known issuesdocs/examples/desktop-demo.yaml— fully-loaded Stream Deck Plus config for a Linux desktop with Home Assistant: HA folder with state-tinted bound lights, system widgets (cpu/memory/clock/date), Firefox tab dial, scroll dial, seek-bar dial, cooldown'd launchers
MIT — see LICENSE.