Files
streamdeck-go/README.md
2026-04-13 08:11:19 -06:00

589 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# streamdeck-go
> [!IMPORTANT]
> This is a sloppy utility that's designed to just work. [406.fail](https://406.fail)
A lightweight, dotfile-style controller for the **Elgato Stream Deck XL** on Linux and macOS.
No Elgato software required — communicates directly with the device over USB HID.
---
## Features
- Configure keys with a single YAML file (designed for dotfiles compatibility)
- PNG, JPEG, and SVG icons, automatically scaled to key size
- Animated GIF support — frames pre-encoded at startup, cycled at the GIF's native rate
- Runs any shell command on key press
- **Status/toggle keys** — poll any shell command on an interval, swap icons based on output; icon updates on press
- **Live config reload** — save your config and the deck updates instantly, no restart needed
- **Privileged commands** — run whitelisted root/admin commands; Linux uses a root helper daemon, macOS uses the native admin auth dialog
- Automatic reconnect — survives USB unplug, KVM switches, and suspend/resume
- Runs as a systemd user service (Linux) or launchd agent (macOS), starts automatically at login
- No Stream Deck app, no Node.js, no Electron
**Planned:** text/label overlays on keys, multi-page layouts, AUR package — see [Roadmap](#roadmap)
---
## Architecture
```
streamdeck-go/
├── cmd/
│ ├── streamdeck/
│ │ └── main.go # Entry point, config watcher, event loop
│ └── streamdeck-helper/
│ └── main.go # Privileged helper daemon (Linux only)
├── internal/
│ ├── config/
│ │ └── config.go # YAML parsing with XDG-aware defaults
│ └── device/
│ └── streamdeck.go # USB HID communication, image encoding, button reads
├── systemd/
│ └── streamdeck-go.service # systemd user service (Linux)
├── launchd/
│ └── com.woodarddigital.streamdeck-go.plist # launchd agent (macOS)
├── config.example.yaml # Starter config (copied to ~/.config on install)
├── Makefile # build / install / uninstall
├── go.mod
└── go.sum
```
### How it works
```
~/.config/streamdeck-go/config.yaml
├── fsnotify watcher ──── file saved? ──▶ cancel ctx → reload config → restart run()
└──▶ run(ctx)
├── static icon ──▶ device.SetKeyImage() (scale → flip → JPEG → HID)
├── animated GIF ──▶ device.EncodeFrame() (pre-encode all frames once)
│ └──▶ goroutine/key: loop frames, sleep per-frame delay
│ (cancelled via ctx on reload)
├── poll goroutine/key ──▶ exec poll command every interval
│ └──▶ match in stdout? swap icon_true / icon_false
│ (also triggered immediately on button press)
└── event loop ──▶ device.ReadButtons() (250 ms timeout, checks ctx)
└──▶ key-down: exec.Command("sh", "-c", command)
```
HID output reports are mutex-guarded so concurrent animation goroutines never
interleave partial image data across keys.
---
## Dependencies
### Go packages
| Package | Purpose |
|---|---|
| [`github.com/sstallion/go-hid`](https://github.com/sstallion/go-hid) | Bindings for `libhidapi` — USB HID read/write |
| [`github.com/fsnotify/fsnotify`](https://github.com/fsnotify/fsnotify) | Config file watching for live reload |
| [`golang.org/x/image`](https://pkg.go.dev/golang.org/x/image) | Bi-linear image scaling |
| [`gopkg.in/yaml.v3`](https://pkg.go.dev/gopkg.in/yaml.v3) | YAML config parsing |
| [`github.com/srwiley/oksvg`](https://github.com/srwiley/oksvg) | SVG rasterisation |
| Go stdlib `image/gif`, `image/jpeg`, `image/png` | Image decoding and JPEG encoding |
### System library
`libhidapi` must be present at runtime:
| Platform | Command |
|---|---|
| macOS | `brew install hidapi` |
| Arch / Manjaro | `sudo pacman -S hidapi` |
| Debian / Ubuntu | `sudo apt install libhidapi-hidraw0` |
| Fedora / RHEL | `sudo dnf install hidapi` |
| openSUSE | `sudo zypper install libhidapi-hidraw0` |
---
## Installation
### macOS
```bash
# Prerequisites
brew install go hidapi
git clone https://github.com/WoodardDigital/streamdeck-go
cd streamdeck-go
make install
```
The binary installs to `~/go/bin/streamdeck-go`. Make sure `~/go/bin` is in your `PATH`:
```bash
# Add to ~/.zshrc or ~/.bash_profile if not already present:
export PATH="$HOME/go/bin:$PATH"
```
A launchd agent is installed to `~/Library/LaunchAgents/` and started automatically.
No sudo required for the main binary.
For privileged commands (sleep, reboot, etc.) see [Privileged commands — macOS](#privileged-commands--macos).
---
### Linux — `make install` (recommended)
An interactive installer that handles everything, including optional dotfiles
directory integration.
```bash
# Prerequisites — Arch example; adjust for your distro (see table above)
sudo pacman -S go hidapi
git clone https://github.com/WoodardDigital/streamdeck-go
cd streamdeck-go
make install
```
The installer walks you through the whole setup:
```
Building streamdeck-go...
✓ Build complete
Checking udev rule...
✓ udev rule already installed — skipping
Config location
─────────────────────────────────────────────
· streamdeck-go stores its config and icons in a single directory.
· You can keep that directory inside your dotfiles repo and symlink it
· into ~/.config — the same pattern used by Hyprland, Waybar, etc.
Use a dotfiles directory? [Y/n]
Path to dotfiles repo [~/dotfiles]:
· Will create:
· ~/dotfiles/.config/streamdeck-go/
· ~/dotfiles/.config/streamdeck-go/config.yaml
· ~/dotfiles/.config/streamdeck-go/icons/
· Will symlink:
· ~/.config/streamdeck-go
· └─▶ ~/dotfiles/.config/streamdeck-go
Confirm? [Y/n]
```
The resulting structure inside your dotfiles repo mirrors everything else in `.config`:
```
~/dotfiles/
└── .config/
├── hypr/
├── waybar/
└── streamdeck-go/ ← lives here, symlinked to ~/.config/streamdeck-go
├── config.yaml
└── icons/
```
---
### Linux — manual / dev setup
**1. udev rule** (one-time):
```bash
echo 'KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", MODE="0666"' \
| sudo tee /etc/udev/rules.d/99-streamdeck.rules
sudo udevadm control --reload
sudo udevadm trigger
```
**2. Build and run:**
```bash
git clone https://github.com/WoodardDigital/streamdeck-go
cd streamdeck-go
cp config.example.yaml config.yaml
go run ./cmd/streamdeck/
# Or point at any config file:
go run ./cmd/streamdeck/ -config ~/.config/streamdeck-go/config.yaml
```
When no `-config` flag is given the binary checks `~/.config/streamdeck-go/config.yaml`
first (respecting `$XDG_CONFIG_HOME`). The repo's `config.yaml` is gitignored.
---
### AUR (Arch Linux)
> AUR package coming soon. Until then, use `make install` above.
---
## Service management
### macOS (launchd)
```bash
# Status
launchctl list | grep streamdeck
# Stop / start
launchctl unload ~/Library/LaunchAgents/com.woodarddigital.streamdeck-go.plist
launchctl load ~/Library/LaunchAgents/com.woodarddigital.streamdeck-go.plist
# Logs (persistent across reboots)
tail -f ~/Library/Logs/streamdeck-go.log
```
The agent starts automatically at login and restarts if the process exits.
Sleep/wake is handled by the reconnect loop in the binary — no extra config needed.
To use a custom config path, edit the installed plist at
`~/Library/LaunchAgents/com.woodarddigital.streamdeck-go.plist` and add
`-config /path/to/config.yaml` to the `ProgramArguments` array, then reload.
### Linux (systemd)
```bash
systemctl --user status streamdeck-go # check if running
systemctl --user restart streamdeck-go # restart manually
systemctl --user stop streamdeck-go # stop
journalctl --user -u streamdeck-go -f # follow logs
```
To use a custom config path:
```bash
systemctl --user edit streamdeck-go
```
Add:
```ini
[Service]
ExecStart=
ExecStart=%h/.local/bin/streamdeck-go -config %h/.config/streamdeck-go/config.yaml
```
---
## Configuration
Config lives at `~/.config/streamdeck-go/config.yaml` (or wherever `-config` points).
**Editing and saving the file reloads the deck live** — icons update, animations restart,
no service restart required.
```yaml
icons_dir: ~/.config/streamdeck-go/icons # default; can be any path
brightness: 70 # 0100
# USB IDs — defaults match Stream Deck XL v2
# macOS: system_profiler SPUSBDataType | grep -A5 "Stream Deck"
# Linux: lsusb | grep Elgato
device:
vendor_id: 0x0fd9
product_id: 0x00ba
# Keys are 0-indexed, left-to-right, top-to-bottom.
# Stream Deck XL layout (8 columns × 4 rows):
#
# 0 1 2 3 4 5 6 7
# 8 9 10 11 12 13 14 15
# 16 17 18 19 20 21 22 23
# 24 25 26 27 28 29 30 31
keys:
0:
icon: ghostty.png # PNG, JPEG, GIF, or SVG — relative to icons_dir
command: ghostty
1:
icon: firefox.png
command: open -a Firefox # macOS; use "firefox" on Linux
8:
icon: loading.gif # animated — cycles at the GIF's native frame rate
command: ""
```
### Status / toggle keys
A key can poll any shell command on an interval and show one of two icons based on the result. Pressing the button runs `command` as usual, and the icon re-checks ~400 ms later so it reflects the new state immediately.
```yaml
keys:
3:
command: pactl set-source-mute @DEFAULT_SOURCE@ toggle
icon_true: mic-muted.png # shown when poll output contains match
icon_false: mic-active.png # shown when poll output does not contain match
poll:
command: pactl get-source-mute @DEFAULT_SOURCE@
interval: 2s # how often to check (default: 2s)
match: "yes" # substring to find in stdout → true
```
**How matching works:**
| `match` set? | True condition | False condition |
|---|---|---|
| Yes | stdout contains the string | stdout does not contain it |
| No (omitted) | command exits 0 | command exits non-zero |
**macOS examples:**
```yaml
# Mute microphone (macOS — requires SwitchAudioSource or similar)
3:
command: "osascript -e 'set volume input muted not (input muted of (get volume settings))'"
icon_true: mic-muted.png
icon_false: mic-active.png
poll:
command: "osascript -e 'input muted of (get volume settings)'"
interval: 2s
match: "true"
# VPN toggle (macOS)
4:
command: "osascript -e 'tell application \"Tunnelblick\" to connect \"My VPN\"'"
icon_true: vpn-on.png
icon_false: vpn-off.png
poll:
command: "scutil --nc status \"My VPN\""
interval: 5s
match: "Connected"
```
**Linux examples:**
```yaml
# VPN status (exit-code match — no match string needed)
4:
command: nmcli connection up my-vpn
icon_true: vpn-on.png
icon_false: vpn-off.png
poll:
command: nmcli connection show --active my-vpn
interval: 5s
# Systemd service toggle
5:
command: systemctl --user toggle my-service
icon_true: service-running.png
icon_false: service-stopped.png
poll:
command: systemctl --user is-active my-service
interval: 3s
# Speaker mute
6:
command: pactl set-sink-mute @DEFAULT_SINK@ toggle
icon_true: speaker-muted.png
icon_false: speaker-on.png
poll:
command: pactl get-sink-mute @DEFAULT_SINK@
interval: 2s
match: "yes"
```
---
### Terminal & SSH commands
Any shell command works — including launching terminals and SSH sessions.
**Open a terminal:**
```yaml
keys:
0:
icon: ghostty.png
command: ghostty # Linux
1:
icon: terminal.png
command: open -a Terminal # macOS — or: open -a iTerm
```
**SSH:**
```yaml
keys:
5:
icon: homeserver.png
command: "ghostty -e ssh user@homeserver" # Linux
6:
icon: homeserver.png
command: "open -a Terminal ssh://user@homeserver" # macOS
```
> **SSH agent & the service**
>
> The service starts before your shell environment is fully loaded, so
> `SSH_AUTH_SOCK` may not be set. Fix:
>
> **Linux:**
> ```bash
> systemctl --user import-environment SSH_AUTH_SOCK
> ```
>
> **macOS** — add to `~/Library/LaunchAgents/com.woodarddigital.streamdeck-go.plist`
> inside the `<dict>` block:
> ```xml
> <key>EnvironmentVariables</key>
> <dict>
> <key>SSH_AUTH_SOCK</key>
> <string>/private/tmp/com.apple.launchd.XXXXX/Listeners</string>
> </dict>
> ```
> Run `echo $SSH_AUTH_SOCK` in a terminal to find the correct path.
**Common terminal flags:**
| Terminal | Flag to run a command |
|---|---|
| ghostty | `ghostty -e <cmd>` |
| alacritty | `alacritty -e <cmd>` |
| kitty | `kitty <cmd>` |
| wezterm | `wezterm start -- <cmd>` |
| Terminal.app | `open -a Terminal <script>` |
| iTerm2 | `open -a iTerm <script>` |
---
### Privileged commands
Use the `priv:` prefix to run commands that require elevated privileges.
```yaml
keys:
30:
icon: suspend.png
command: "priv:suspend"
```
#### Privileged commands — macOS
No root daemon required. The main process reads
`~/.config/streamdeck-go/privileged.yaml` and runs the command via `osascript`,
which shows the standard macOS admin authentication dialog.
Setup:
```bash
make install-helper # copies privileged.yaml to ~/.config/streamdeck-go/
```
Edit `~/.config/streamdeck-go/privileged.yaml`:
```yaml
commands:
suspend: "pmset sleepnow"
reboot: "shutdown -r now"
poweroff: "shutdown -h now"
```
#### Privileged commands — Linux
A root helper daemon (`streamdeck-go-helper`) validates commands against a
root-owned whitelist at `/etc/streamdeck-go/privileged.yaml` before running them.
The main daemon communicates with it over a Unix socket.
```bash
make install-helper # creates streamdeck group, installs helper + system service
```
Edit `/etc/streamdeck-go/privileged.yaml` (as root):
```yaml
commands:
suspend: "systemctl suspend"
reboot: "systemctl reboot"
poweroff: "systemctl poweroff"
```
---
### Supported icon formats
| Format | Notes |
|---|---|
| PNG | Recommended for static icons |
| JPEG | Good for photos / complex images |
| GIF | Animated — all frames pre-encoded at startup, cycled in a background goroutine |
| SVG | Rasterised to 96×96 at startup via `oksvg` |
Icons are scaled to 96×96 px using bi-linear filtering. The XL renders images
mirrored, so they are pre-flipped before sending — your icons will appear the right
way round.
---
## Supported Devices
| Model | Product ID | Keys | Linux | macOS |
|---|---|---|---|---|
| Stream Deck XL v2 | `0x00ba` | 32 | tested | tested |
| Stream Deck XL v1 | `0x006c` | 32 | untested | untested |
| Stream Deck MK.2 | `0x006d` | 15 | untested | untested |
To find your device's product ID:
- **macOS:** `system_profiler SPUSBDataType | grep -A5 "Stream Deck"`
- **Linux:** `lsusb | grep Elgato`
To add a model, edit the `models` map in [internal/device/streamdeck.go](internal/device/streamdeck.go).
---
## Roadmap
### Text / label overlay on icons
Render dynamic text directly onto a key image at runtime — useful for showing
live state like volume level, a clock, a counter, or the current git branch.
Example config (proposed):
```yaml
keys:
4:
icon: volume.png
label: "$(pactl get-sink-volume @DEFAULT_SINK@ | awk '{print $5}')"
label_position: bottom # top | center | bottom
refresh: 5s
```
### Multi-page layouts
Support more than 32 actions by organising keys into named pages. A designated
key switches between pages.
Example config (proposed):
```yaml
pages:
default:
0:
icon: apps.png
command: "page:apps"
1:
icon: firefox.png
command: firefox
apps:
0:
icon: back.png
command: "page:default"
1:
icon: ghostty.png
command: ghostty
```
### AUR package
```bash
yay -S streamdeck-go # coming soon
```