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