Files
streamdeck-go/README.md
2026-04-26 14:00:43 -06:00

1146 lines
34 KiB
Markdown
Raw Permalink 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. Either icon can be an animated GIF (e.g. a flashing record indicator when recording is active).
- **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
- **Text overlays** — add a `text` field to any key for auto-sized, word-wrapped labels; white by default, or set `text_color` to black, red, blue, or any `#RRGGBB` hex. Works with icons (overlaid), without icons (white text on black), and on toggle keys. See [Text overlays](#text-overlays).
- **Modules** — define reusable, parameterised commands in `modules.yaml` with Go templates; secrets stay in env vars, config stays in dotfiles. Built-in examples: Slack (status, presence, snooze) and OBS Studio (recording, streaming, scene switching, media control). See [Modules](#modules).
- **Interactive config builder** — TUI tool (`streamdeck-init`) that walks you through key setup: pick a slot, pick a module/function, customize params, choose an icon. No YAML editing required. See [Config builder](#config-builder).
**Planned:** 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
│ └── modules/
│ └── modules.go # Module registry, template resolution, env/expiry helpers
├── 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)
├── modules.example.yaml # Example modules file (Slack integration)
├── Makefile # build / install / uninstall
├── go.mod
└── go.sum
```
### How it works
```
~/.config/streamdeck-go/config.yaml
~/.config/streamdeck-go/modules.yaml (optional)
├── fsnotify watcher ──── file saved? ──▶ cancel ctx → reload both → restart run()
└──▶ run(ctx, registry)
├── module resolution ──▶ key has module/function?
│ └──▶ registry.Resolve() → rendered shell command
│ (templates expanded, env vars resolved)
├── text overlay? ──▶ overlayText() (auto-size font, word wrap, outline)
├── 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, font rendering (text overlays use embedded Go Bold font) |
| [`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://git.i0t.app/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://git.i0t.app/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://git.i0t.app/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.
---
### Updating — `make reinstall`
After pulling new code or editing a Go source file, refresh whatever's already
deployed without going through the full installer:
```bash
git pull
make reinstall
```
`reinstall` rebuilds the binary and refreshes only the pieces that are already
installed:
| Component | Action when present |
|-------------------|--------------------------------------------------------------------|
| Main binary | rebuilt and copied into `~/.local/bin` (Linux) or `~/go/bin` (macOS) |
| Service unit | systemd unit / launchd plist re-installed; service restarted |
| Helper (Linux) | rebuilt, re-installed under `/usr/local/bin`, helper service restarted (sudo) |
| Watchdog | script + unit/plist refreshed and timer restarted |
| `modules.yaml` | re-copied from `modules.example.yaml` into the active config dir |
Anything not currently installed prints `· skipped` instead of failing.
No dependency installs, no dotfile prompts, no symlink logic — that's still
`make install`'s job.
---
### 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"
```
---
### Text overlays
Add a `text` field to any key to render a small label at the bottom of the key. The text is rendered at a fixed label size (~15pt at 96×96 key resolution), bottom-aligned so the icon stays visible above it. A contrasting outline is drawn automatically for readability on any background.
Text stays on a single line unless you add explicit line breaks with `\n` or YAML's `|` literal block syntax. If the text is too wide for the key, the font shrinks to fit. Multi-line text (up to 4-5 lines) is supported via explicit newlines.
The source image is scaled to key resolution (96×96 for XL, 72×72 for MK.2) before text is rendered, so label size is consistent regardless of source icon dimensions.
```yaml
keys:
# Label on an icon — single line, bottom-aligned
9:
icon: lock.png
text: "Lock Machine"
command: hyprctl dispatch exec omarchy-lock-screen
# Multi-line (explicit newlines only — no automatic word wrap)
16:
icon: server.png
text: |
Restart
Web Server
command: systemctl restart nginx
# Text-only key (no icon — white text on black background)
17:
text: "Build\nDeploy"
command: make deploy
# Custom text color
18:
icon: alert.png
text: "DANGER"
text_color: red
command: nuke-from-orbit
# Hex color
19:
icon: status.png
text: "Online"
text_color: "#00FF00"
command: ""
```
**`text_color`** is optional and defaults to white. Supported values:
| Value | Color |
|---|---|
| `white` (default) | White |
| `black` | Black |
| `red` | Red |
| `blue` | Blue |
| `#RRGGBB` | Any hex color |
Text overlays work on:
- **Static keys** — label at the bottom of the icon
- **Text-only keys** — no icon needed, renders on a black background
- **Toggle keys** — text appears on both `icon_true` and `icon_false`
- GIF keys do not currently support text overlays
---
### Launching applications
On Linux, GUI apps are typically launched by their binary name (`firefox`, `ghostty`, `nautilus`). On macOS, apps live in `/Applications/` as `.app` bundles and need to be opened differently.
#### macOS — `open -a`
The `open -a` command launches (or focuses) a macOS application by name:
```yaml
keys:
0:
icon: firefox.png
command: "open -a Firefox"
1:
icon: slack.png
command: "open -a Slack"
2:
icon: ghostty.png
command: "open -a Ghostty"
3:
icon: finder.png
command: "open -a Finder ~/Documents" # open Finder to a specific folder
4:
icon: vscode.png
command: "open -a 'Visual Studio Code'" # quote names with spaces
```
**How `open -a` works:**
- If the app is already running, it brings it to the front (no duplicate launched).
- If the app is not running, it launches it.
- The app name matches what's in `/Applications/` minus the `.app` extension.
- To find the exact name: `ls /Applications/` or `osascript -e 'tell application "System Events" to get name of every process whose background only is false'`
**Opening files and URLs:**
```yaml
keys:
5:
icon: project.png
command: "open -a 'Visual Studio Code' ~/Projects/myproject" # open a folder in VS Code
6:
icon: notes.png
command: "open ~/Documents/notes.txt" # opens in default app for .txt
7:
icon: github.png
command: "open https://github.com" # opens in default browser
```
#### Linux — direct binary
```yaml
keys:
0:
icon: firefox.png
command: firefox
1:
icon: slack.png
command: slack
2:
icon: ghostty.png
command: ghostty
3:
icon: files.png
command: nautilus ~/Documents
```
Most Linux desktop apps can also be launched with their `.desktop` file via `gtk-launch`:
```yaml
keys:
4:
icon: vscode.png
command: "gtk-launch code" # uses the .desktop file name (without .desktop)
```
#### Cross-platform keys
If you use the same config on both platforms, you can use a shell one-liner:
```yaml
keys:
0:
icon: browser.png
command: "if [ \"$(uname)\" = \"Darwin\" ]; then open -a Firefox; else firefox; fi"
```
---
### 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 Ghostty # macOS — or: open -a Terminal, 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"
```
---
### Modules
Modules let you define reusable commands in a separate `modules.yaml` file instead of inlining shell commands in your config. This is especially useful for API-driven integrations like Slack where commands are long `curl` calls with tokens and JSON payloads.
#### How it works
1. Create `~/.config/streamdeck-go/modules.yaml` (next to your `config.yaml`).
2. Define modules with named functions. Each function has an `exec` template and optional default `params`.
3. Reference them in `config.yaml` with `module`, `function`, and optional `params` overrides.
The daemon watches `modules.yaml` for changes alongside `config.yaml` — edits to either file trigger a live reload.
#### Module file format
```yaml
# ~/.config/streamdeck-go/modules.yaml
modules:
slack:
set_status:
params:
emoji: ":speech_balloon:"
text: "In a meeting"
expiry: "1h"
exec: |
curl -s -X POST https://slack.com/api/users.profile.set \
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
-H "Content-Type: application/json" \
-d '{"profile":{"status_emoji":"{{.emoji}}","status_text":"{{.text}}","status_expiration":{{expiry .expiry}}}}'
clear_status:
exec: |
curl -s -X POST https://slack.com/api/users.profile.set \
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
-H "Content-Type: application/json" \
-d '{"profile":{"status_emoji":"","status_text":"","status_expiration":0}}'
set_presence:
params:
presence: "away"
exec: |
curl -s -X POST https://slack.com/api/users.setPresence \
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
-H "Content-Type: application/json" \
-d '{"presence":"{{.presence}}"}'
snooze:
params:
minutes: "60"
exec: |
curl -s -X POST https://slack.com/api/dnd.setSnooze \
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" \
-H "Content-Type: application/json" \
-d '{"num_minutes":{{.minutes}}}'
end_snooze:
exec: |
curl -s -X POST https://slack.com/api/dnd.endSnooze \
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}"
```
A full example is included at [`modules.example.yaml`](modules.example.yaml) in the repo root.
#### OBS Studio module
The example `modules.yaml` also includes an `obs` module that drives OBS over WebSocket using [`obs-cmd`](https://github.com/grigio/obs-cmd).
**Setup:**
1. Install `obs-cmd`:
- macOS: `brew install grigio/obs-cmd/obs-cmd`
- Linux: `cargo install obs-cmd` or download a release binary
2. Requires **OBS 30.2 or newer** — earlier versions fail `obs-cmd`'s version check.
3. In OBS, enable **Tools → WebSocket Server Settings** and note the port + password.
4. Export env vars (see [Secrets and tokens](#secrets-and-tokens) for launchd/systemd setup):
```bash
export OBS_WEBSOCKET_PASSWORD="your-password"
export OBS_HOST="localhost" # or remote IP, e.g. 192.168.1.28
export OBS_PORT="4455"
```
**Functions:**
| Function | Purpose | Params |
|---|---|---|
| `play` / `pause` / `stop` / `restart` | Media input controls | `source` (default `"Media Source"`) |
| `toggle_record` | Start/stop recording | — |
| `toggle_record_pause` | Pause/resume recording | — |
| `toggle_stream` | Start/stop streaming | — |
| `scene_switch` | Switch active scene | `scene` (default `"Scene 1"`) |
| `toggle_mute` | Toggle input mute | `source` (default `"Mic/Aux"`) |
| `is_recording` | Status check for poll blocks | — |
| `is_recording_paused` | Status check for poll blocks | — |
| `is_streaming` | Status check for poll blocks | — |
**Example — record button with flashing GIF when active:**
```yaml
keys:
7:
icon_true: recording.gif # animated — flashes while recording
icon_false: camera.png # static — shown when stopped
module: obs
function: toggle_record
poll:
module: obs
function: is_recording
match: "Active: true" # obs-cmd output contains this when recording
interval: 2s
15:
icon_true: pause.svg
icon_false: play.svg
module: obs
function: toggle_record_pause
poll:
module: obs
function: is_recording_paused
match: "Paused: true"
interval: 2s
```
**Note — absolute paths in modules:** The example templates call `/usr/local/bin/obs-cmd` rather than just `obs-cmd`. This is because launchd (macOS) and systemd (Linux) give the service a minimal `PATH` that doesn't include `/usr/local/bin` or Homebrew. Use the absolute path returned by `which obs-cmd` in your own module templates, or set `PATH` in the launchd plist / systemd unit.
#### Using modules in config.yaml
Reference a module function instead of writing inline commands:
```yaml
keys:
# Set Slack status to "In a meeting" for 1 hour (uses module defaults)
10:
icon: meeting.png
module: slack
function: set_status
# Override default params — different emoji, text, and duration
11:
icon: lunch.png
module: slack
function: set_status
params:
emoji: ":knife_fork_plate:"
text: "Out to lunch"
expiry: "30m"
# Clear Slack status
12:
icon: clear-status.png
module: slack
function: clear_status
# Go away / come back
13:
icon: away.png
module: slack
function: set_presence
params:
presence: "away"
14:
icon: active.png
module: slack
function: set_presence
params:
presence: "auto"
# Snooze notifications for 60 minutes
15:
icon: snooze.png
module: slack
function: snooze
# End snooze
16:
icon: unsnooze.png
module: slack
function: end_snooze
```
Poll commands also support modules — use `module`, `function`, and `params` inside the `poll` block:
```yaml
keys:
17:
icon_true: dnd-on.png
icon_false: dnd-off.png
module: slack
function: snooze
poll:
module: slack
function: check_dnd
interval: 10s
match: "snooze_enabled.*true"
```
#### Adding your own modules
Modules are purely YAML — no Go code changes required.
1. Open `~/.config/streamdeck-go/modules.yaml`.
2. Add a new top-level key under `modules:` (e.g. `home_assistant`, `spotify`).
3. Define functions, each with an `exec` template and optional default `params`. Use `{{env "VAR"}}` for secrets and `{{.paramName}}` for parameters.
4. Save — the daemon reloads automatically.
5. Reference the new module from `config.yaml` with `module:` / `function:` / `params:`.
**Skeleton:**
```yaml
modules:
my_api:
do_thing:
params:
target: "default-value"
exec: |
curl -s -X POST https://example.com/api/thing \
-H "Authorization: Bearer {{env "MY_API_TOKEN"}}" \
-d '{"target":"{{.target}}"}'
```
**Tips:**
- If the template calls an external binary (like `curl`, `obs-cmd`, `osascript`), use the absolute path — service `PATH` is minimal.
- Chain multiple API calls with `&&` inside a single `exec` (see `go_offline` in the example).
- Status functions for poll blocks should output text matching a substring you set in the key's `poll.match`.
#### Template helpers
Two helpers are available in `exec` templates:
| Helper | Usage | Description |
|---|---|---|
| `env` | `{{env "VAR_NAME"}}` | Returns the value of an environment variable |
| `expiry` | `{{expiry .duration}}` | Converts a Go duration string (e.g. `"1h"`, `"30m"`) to a Unix epoch timestamp. `"0"` or `""` returns `"0"` (no expiry) |
#### Secrets and tokens
**Tokens and secrets must never go in `modules.yaml` or `config.yaml`.** Use the `{{env "VAR_NAME"}}` template helper to read them from environment variables at runtime.
**Setting up your Slack token:**
1. Create a Slack app at [api.slack.com/apps](https://api.slack.com/apps) with these OAuth scopes:
- `users.profile:write` — set/clear status
- `users:write` — set presence (away/auto)
- `dnd:write` — snooze/unsnooze notifications
2. Install the app to your workspace and copy the **User OAuth Token** (`xoxp-...`).
3. Export the token in your shell profile (`~/.zshrc`, `~/.bash_profile`, etc.):
```bash
export SLACK_TOKEN="xoxp-your-token-here"
```
4. Make sure the service can see the variable:
**macOS** — add to `~/Library/LaunchAgents/com.woodarddigital.streamdeck-go.plist` inside the `<dict>` block:
```xml
<key>EnvironmentVariables</key>
<dict>
<key>SLACK_TOKEN</key>
<string>xoxp-your-token-here</string>
</dict>
```
**Linux** — import into the systemd user environment:
```bash
systemctl --user import-environment SLACK_TOKEN
```
Or add an `Environment=` line via `systemctl --user edit streamdeck-go`:
```ini
[Service]
Environment=SLACK_TOKEN=xoxp-your-token-here
```
> **Security notes:**
> - The `modules.yaml` and `config.yaml` files can safely live in your dotfiles repo — they contain no secrets.
> - On Linux, the systemd override file (`~/.config/systemd/user/streamdeck-go.service.d/override.conf`) is user-readable only (mode 600). Still, prefer `import-environment` over hardcoding tokens in unit files when possible.
> - On macOS, launchd plist files in `~/Library/LaunchAgents/` are user-readable only by default.
> - Never commit tokens to git. Add `.env` files to `.gitignore` if you use one.
---
### Config builder
Instead of editing YAML by hand, use the interactive TUI to configure keys:
```bash
make build-init
./streamdeck-init
```
Or with a custom config path:
```bash
./streamdeck-init -config ~/dotfiles/.config/streamdeck-go/config.yaml
```
The tool shows your 8x4 key grid, walks you through each step, and appends the result to your config.yaml:
```
Stream Deck XL — 8×4 grid
╭─────────╮╭─────────╮╭─────────╮╭─────────╮╭─────────╮╭─────────╮╭─────────╮╭─────────╮
│Rootshell││ lights ││set_stat ││ WHH ││Dumpster ││ [5] ││ [6] ││ [7] │
╰─────────╯╰─────────╯╰─────────╯╰─────────╯╰─────────╯╰─────────╯╰─────────╯╰─────────╯
...
[n] = free dim = occupied
? Pick a key slot
> 5 (free)
6 (free)
7 (free)
...
? What should this key do?
> Module function (Slack, etc.)
Shell command
? Pick a module
> slack
? Pick a function from slack
> set_status
clear_status
set_presence
go_offline
snooze
end_snooze
? emoji
> :coffee:
? text
> Coffee break
? expiry
> 30m
? Pick an icon from ~/.config/streamdeck-go/icons
> coffee.png
Summary:
Key: 5
Icon: coffee.png
Module: slack
Function: set_status
Params:
emoji: :coffee:
text: Coffee break
expiry: 30m
? Write this key to config? Yes
✓ Key 5 added to config.
? Add another key? No
✓ Done — the daemon will auto-reload your config.
```
On a fresh install with no config files, the tool creates `config.yaml` and `modules.yaml` automatically.
---
### 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
### Dynamic text from command output
Extend the text overlay feature to render live output from a shell command —
useful for showing volume level, a clock, a counter, or the current git branch.
Example config (proposed):
```yaml
keys:
4:
icon: volume.png
text: "$(pactl get-sink-volume @DEFAULT_SINK@ | awk '{print $5}')"
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
```