962 lines
27 KiB
Markdown
962 lines
27 KiB
Markdown
# 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
|
||
|
||
- **Modules** — define reusable, parameterised commands in `modules.yaml` with Go templates; secrets stay in env vars, config stays in dotfiles. First built-in example: Slack (status, presence, snooze). 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:** 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
|
||
│ └── 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)
|
||
│
|
||
├── 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 # 0–100
|
||
|
||
# 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"
|
||
```
|
||
|
||
---
|
||
|
||
### 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.
|
||
|
||
#### 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"
|
||
```
|
||
|
||
#### 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
|
||
|
||
### 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
|
||
```
|