Skip to content

onurpaca/decd

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

decd

A Linux daemon for Elgato Stream Deck devices. Config-driven, YAML-only, userspace USB — no GUI, no plugins, no kernel module.

Quick Start

# 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 decd

No 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.

Config example

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: back

Architecture

decd 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  │
       └─────────┘

Kernel surface

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

Why not /dev/hidrawN?

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.

Features

  • Stream Deck Plus — 4×2 keys + 4 dials + 800×100 touchscreen
  • YAML config — no GUI, edit directly
  • 28 built-in actionsshell, 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 stackfolder pushes the current page, back pops 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, with ha_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 bindingbind: on any key or dial swaps icon/label as values change. Two source families are wired:
    • entity: against a home_assistant source — initial snapshot is fetched on every (re)connect, then state_changed events drive live updates.
    • path: against a generic websocket source — 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 the states: exact-value map. An optional text: template renders state inline ("{state}°C", "Vol {state}", "ON ({brightness}%)"); HA attributes are exposed as placeholders. Dial state changes trigger a partial 200×100 touchscreen zone re-upload rather than the full 800×100 strip.
  • Custom HA eventson_event(event_type, callback) on the HA source subscribes to anything beyond state_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.
  • Triggerstriggers: block runs action sequences in response to source events. Two flavours:
    • HA path: event: + optional when: filter against event.data.
    • Generic-WS path: match: list of {path, equals|regex|gt|lt|in|...} conditions evaluated against each inbound JSON message (AND).
  • Bind transition actions — a bind: can declare per-state on_enter: / on_exit: plus a top-level on_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) or bar: { attribute: brightness, max: 255, color: "#ffaa00" } to a bind: and decd renders a fill bar at the bottom of the key. The bar reads from a HA state attribute when attribute: is given, otherwise from the state value 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 \n or auto-wrap on width. The DECD_FONT env var overrides the default font (DejaVu Sans).
  • Modal pages (mode / exit_mode) — like folder but doesn't push history; the previous page is the anchor and decd hops back automatically after timeout: of input idle, after running any action on the modal page (exit_on_action: true by default), or on explicit exit_mode. Designed for chord-style temporary submenus.
  • Hot config reloadkill -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 subcommandsdecd 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-devices print registries. Bare decd runs the daemon.
  • Widgetswidget: 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.

Permissions

/dev/bus/usb/* defaults to root:root 0660. Two paths to non-root access:

  1. Recommended — run ./scripts/setup.sh to install the udev rule and add your user to plugdev. Logout/login once.
  2. 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.

Requirements

  • Python 3.14+
  • Linux with usbfs (/dev/bus/usb/...) — every modern distro
  • User in plugdev group (after running setup.sh)
  • wpctl (PipeWire) or amixer / pactl for the volume action, playerctl for media, xdotool / wtype / ydotool for text and hotkey, xdg-open for url / open, systemctl / loginctl for system. Each action falls back gracefully if its tool isn't installed.

Supported devices

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.

Documentation

  • docs/architecture.md — module layout and component responsibilities
  • docs/design.md — design rationale, what's in/out of scope
  • docs/backlog.md — roadmap and known issues
  • docs/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

License

MIT — see LICENSE.

About

A Linux daemon for Elgato Stream Deck devices. Config-driven, YAML-only, userspace USB — no GUI, no plugins, no kernel module.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors