Initial Push
This commit is contained in:
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Compiled binaries
|
||||||
|
streamdeck
|
||||||
|
/bin/
|
||||||
|
*.exe
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Go tool cache
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# Test artifacts
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
coverage.html
|
||||||
|
|
||||||
|
# Icons — keep the folder but not personal icon files
|
||||||
|
icons/*
|
||||||
|
!icons/.gitkeep
|
||||||
|
|
||||||
|
# Personal config (dotfile-style — user maintains their own)
|
||||||
|
config.yaml
|
||||||
|
|
||||||
|
# OS / editor noise
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
|
.claude
|
||||||
99
Makefile
Normal file
99
Makefile
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
BINARY := streamdeck-go
|
||||||
|
HELPER := streamdeck-helper
|
||||||
|
PREFIX ?= $(HOME)/.local
|
||||||
|
BIN_DIR := $(PREFIX)/bin
|
||||||
|
SYS_BIN := /usr/local/bin
|
||||||
|
CONFIG_DIR := $(HOME)/.config/streamdeck-go
|
||||||
|
SYSTEMD_USER := $(HOME)/.config/systemd/user
|
||||||
|
SYSTEMD_SYS := /etc/systemd/system
|
||||||
|
ETC_DIR := /etc/streamdeck-go
|
||||||
|
UDEV_RULE := /etc/udev/rules.d/99-streamdeck.rules
|
||||||
|
GROUP := streamdeck
|
||||||
|
|
||||||
|
.PHONY: build build-helper install install-helper uninstall udev
|
||||||
|
|
||||||
|
# ── Build ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
build:
|
||||||
|
go build -o $(BINARY) ./cmd/streamdeck/
|
||||||
|
|
||||||
|
build-helper:
|
||||||
|
go build -o $(HELPER) ./cmd/streamdeck-helper/
|
||||||
|
|
||||||
|
# ── Install ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
install: build udev _install-user
|
||||||
|
@echo ""
|
||||||
|
@echo "Done. Edit $(CONFIG_DIR)/config.yaml to configure your keys."
|
||||||
|
@echo "To enable privileged commands (suspend, reboot, etc): make install-helper"
|
||||||
|
|
||||||
|
_install-user:
|
||||||
|
install -Dm755 $(BINARY) $(BIN_DIR)/$(BINARY)
|
||||||
|
mkdir -p $(CONFIG_DIR)/icons
|
||||||
|
@if [ ! -f $(CONFIG_DIR)/config.yaml ]; then \
|
||||||
|
install -Dm644 config.example.yaml $(CONFIG_DIR)/config.yaml; \
|
||||||
|
echo "Default config written to $(CONFIG_DIR)/config.yaml"; \
|
||||||
|
else \
|
||||||
|
echo "Config already exists — not overwriting"; \
|
||||||
|
fi
|
||||||
|
install -Dm644 systemd/streamdeck-go.service $(SYSTEMD_USER)/streamdeck-go.service
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user enable --now streamdeck-go.service
|
||||||
|
|
||||||
|
# Install the privileged helper (requires sudo).
|
||||||
|
# Creates a 'streamdeck' group, adds the current user to it, installs the
|
||||||
|
# helper binary as root, and enables it as a system service.
|
||||||
|
install-helper: build-helper
|
||||||
|
# Create group and add current user.
|
||||||
|
@if ! getent group $(GROUP) > /dev/null; then \
|
||||||
|
sudo groupadd $(GROUP); \
|
||||||
|
echo "Created group '$(GROUP)'"; \
|
||||||
|
fi
|
||||||
|
sudo usermod -aG $(GROUP) $(USER)
|
||||||
|
# Install helper binary (owned root, not world-executable by others).
|
||||||
|
sudo install -Dm750 $(HELPER) $(SYS_BIN)/$(HELPER)
|
||||||
|
sudo chown root:$(GROUP) $(SYS_BIN)/$(HELPER)
|
||||||
|
# Install whitelist config (root-owned, not world-writable).
|
||||||
|
sudo mkdir -p $(ETC_DIR)
|
||||||
|
@if [ ! -f $(ETC_DIR)/privileged.yaml ]; then \
|
||||||
|
sudo install -Dm640 config/privileged.example.yaml $(ETC_DIR)/privileged.yaml; \
|
||||||
|
sudo chown root:$(GROUP) $(ETC_DIR)/privileged.yaml; \
|
||||||
|
echo "Whitelist written to $(ETC_DIR)/privileged.yaml"; \
|
||||||
|
else \
|
||||||
|
echo "Whitelist already exists — not overwriting"; \
|
||||||
|
fi
|
||||||
|
# Install and enable system service.
|
||||||
|
sudo install -Dm644 systemd/streamdeck-go-helper.service $(SYSTEMD_SYS)/streamdeck-go-helper.service
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now streamdeck-go-helper.service
|
||||||
|
@echo ""
|
||||||
|
@echo "Helper installed. Edit $(ETC_DIR)/privileged.yaml (as root) to add commands."
|
||||||
|
@echo "NOTE: log out and back in for group membership to take effect,"
|
||||||
|
@echo " or run: newgrp $(GROUP)"
|
||||||
|
|
||||||
|
udev:
|
||||||
|
@if [ ! -f $(UDEV_RULE) ]; then \
|
||||||
|
echo 'KERNEL=="hidraw*", ATTRS{idVendor}=="0fd9", MODE="0666"' \
|
||||||
|
| sudo tee $(UDEV_RULE); \
|
||||||
|
sudo udevadm control --reload; \
|
||||||
|
sudo udevadm trigger; \
|
||||||
|
echo "udev rule installed."; \
|
||||||
|
else \
|
||||||
|
echo "udev rule already exists."; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Uninstall ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
uninstall:
|
||||||
|
systemctl --user disable --now streamdeck-go.service || true
|
||||||
|
rm -f $(BIN_DIR)/$(BINARY)
|
||||||
|
rm -f $(SYSTEMD_USER)/streamdeck-go.service
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
@echo "Uninstalled. Config at $(CONFIG_DIR) preserved."
|
||||||
|
|
||||||
|
uninstall-helper:
|
||||||
|
sudo systemctl disable --now streamdeck-go-helper.service || true
|
||||||
|
sudo rm -f $(SYS_BIN)/$(HELPER)
|
||||||
|
sudo rm -f $(SYSTEMD_SYS)/streamdeck-go-helper.service
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
@echo "Helper uninstalled. Whitelist at $(ETC_DIR)/privileged.yaml preserved."
|
||||||
320
OMARCHY.md
Normal file
320
OMARCHY.md
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
# streamdeck-go — Omarchy Integration
|
||||||
|
|
||||||
|
Omarchy-specific commands and config examples for [streamdeck-go](README.md).
|
||||||
|
All commands run via `sh -c` on key press unless marked `priv:` (requires the
|
||||||
|
privileged helper — see [README § Privileged commands](README.md)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Terminal (Ghostty)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
keys:
|
||||||
|
0:
|
||||||
|
icon: ghostty.png
|
||||||
|
command: ghostty
|
||||||
|
|
||||||
|
# Open a new window with a specific command
|
||||||
|
1:
|
||||||
|
icon: ssh.png
|
||||||
|
command: "ghostty -e ssh user@homeserver"
|
||||||
|
|
||||||
|
# Open in a specific directory
|
||||||
|
2:
|
||||||
|
icon: projects.png
|
||||||
|
command: "ghostty --working-directory=~/projects"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Walker (app launcher)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
keys:
|
||||||
|
3:
|
||||||
|
icon: walker.png
|
||||||
|
command: walker
|
||||||
|
|
||||||
|
# Open walker directly in a specific mode
|
||||||
|
4:
|
||||||
|
icon: calc.png
|
||||||
|
command: "walker --modules calc"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hyprland
|
||||||
|
|
||||||
|
Hyprland exposes everything via `hyprctl dispatch`. No privileges needed.
|
||||||
|
|
||||||
|
### Workspaces
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
keys:
|
||||||
|
0:
|
||||||
|
icon: ws1.png
|
||||||
|
command: "hyprctl dispatch workspace 1"
|
||||||
|
1:
|
||||||
|
icon: ws2.png
|
||||||
|
command: "hyprctl dispatch workspace 2"
|
||||||
|
2:
|
||||||
|
icon: ws3.png
|
||||||
|
command: "hyprctl dispatch workspace 3"
|
||||||
|
|
||||||
|
# Move active window to a workspace
|
||||||
|
8:
|
||||||
|
icon: move.png
|
||||||
|
command: "hyprctl dispatch movetoworkspace 2"
|
||||||
|
|
||||||
|
# Toggle floating on the active window
|
||||||
|
9:
|
||||||
|
icon: float.png
|
||||||
|
command: "hyprctl dispatch togglefloating"
|
||||||
|
|
||||||
|
# Fullscreen
|
||||||
|
10:
|
||||||
|
icon: fullscreen.png
|
||||||
|
command: "hyprctl dispatch fullscreen 0"
|
||||||
|
|
||||||
|
# Kill active window
|
||||||
|
11:
|
||||||
|
icon: close.png
|
||||||
|
command: "hyprctl dispatch killactive"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-monitor
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
keys:
|
||||||
|
16:
|
||||||
|
icon: monitor-left.png
|
||||||
|
command: "hyprctl dispatch focusmonitor l"
|
||||||
|
17:
|
||||||
|
icon: monitor-right.png
|
||||||
|
command: "hyprctl dispatch focusmonitor r"
|
||||||
|
|
||||||
|
# Move window to other monitor
|
||||||
|
18:
|
||||||
|
icon: move-monitor.png
|
||||||
|
command: "hyprctl dispatch movewindow mon:next"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hyprlock (screen lock)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
keys:
|
||||||
|
31:
|
||||||
|
icon: lock.png
|
||||||
|
command: hyprlock
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hypridle (idle inhibitor)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
keys:
|
||||||
|
30:
|
||||||
|
icon: idle-off.png
|
||||||
|
# Toggle idle inhibitor — stops screen from locking during presentations
|
||||||
|
command: "pkill hypridle || hypridle &"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Hyprpaper (wallpaper)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
keys:
|
||||||
|
24:
|
||||||
|
icon: wallpaper.png
|
||||||
|
# Cycle to a specific wallpaper on the active monitor
|
||||||
|
command: "hyprctl hyprpaper wallpaper 'eDP-1,~/wallpapers/mountain.jpg'"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Waybar
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
keys:
|
||||||
|
7:
|
||||||
|
icon: waybar-reload.png
|
||||||
|
# Reload Waybar config and style without restarting
|
||||||
|
command: "pkill -SIGUSR2 waybar"
|
||||||
|
|
||||||
|
15:
|
||||||
|
icon: waybar-toggle.png
|
||||||
|
# Show/hide the bar
|
||||||
|
command: "pkill -SIGUSR1 waybar"
|
||||||
|
|
||||||
|
# Full restart (if SIGUSR2 isn't enough after big config changes)
|
||||||
|
23:
|
||||||
|
icon: waybar-restart.png
|
||||||
|
command: "pkill waybar; waybar &"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mako (notifications)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
keys:
|
||||||
|
5:
|
||||||
|
icon: notif-dismiss.png
|
||||||
|
# Dismiss all notifications
|
||||||
|
command: "makoctl dismiss --all"
|
||||||
|
|
||||||
|
6:
|
||||||
|
icon: notif-invoke.png
|
||||||
|
# Invoke the default action on the last notification
|
||||||
|
command: "makoctl invoke"
|
||||||
|
|
||||||
|
# Toggle do-not-disturb
|
||||||
|
13:
|
||||||
|
icon: dnd.png
|
||||||
|
command: "makoctl set-mode $([ $(makoctl get-mode) = 'do-not-disturb' ] && echo default || echo do-not-disturb)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Audio (PipeWire / wpctl)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
keys:
|
||||||
|
16:
|
||||||
|
icon: vol-up.png
|
||||||
|
command: "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+"
|
||||||
|
17:
|
||||||
|
icon: vol-down.png
|
||||||
|
command: "wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-"
|
||||||
|
18:
|
||||||
|
icon: mute.png
|
||||||
|
command: "wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle"
|
||||||
|
|
||||||
|
# Mic mute (useful for calls)
|
||||||
|
19:
|
||||||
|
icon: mic-mute.png
|
||||||
|
command: "wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Brightness (brightnessctl)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
keys:
|
||||||
|
20:
|
||||||
|
icon: bright-up.png
|
||||||
|
command: "brightnessctl set +10%"
|
||||||
|
21:
|
||||||
|
icon: bright-down.png
|
||||||
|
command: "brightnessctl set 10%-"
|
||||||
|
22:
|
||||||
|
icon: bright-max.png
|
||||||
|
command: "brightnessctl set 100%"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Screenshots (grimblast / hyprshot)
|
||||||
|
|
||||||
|
Omarchy ships with `grimblast`. Output goes to `~/Pictures/Screenshots/` by default.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
keys:
|
||||||
|
8:
|
||||||
|
icon: screenshot.png
|
||||||
|
# Fullscreen screenshot
|
||||||
|
command: "grimblast save screen"
|
||||||
|
|
||||||
|
9:
|
||||||
|
icon: screenshot-area.png
|
||||||
|
# Select area
|
||||||
|
command: "grimblast save area"
|
||||||
|
|
||||||
|
10:
|
||||||
|
icon: screenshot-window.png
|
||||||
|
# Active window
|
||||||
|
command: "grimblast save active"
|
||||||
|
|
||||||
|
# Copy to clipboard instead of saving
|
||||||
|
11:
|
||||||
|
icon: screenshot-clip.png
|
||||||
|
command: "grimblast copy area"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Night light (hyprsunset / wlsunset)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
keys:
|
||||||
|
25:
|
||||||
|
icon: nightlight.png
|
||||||
|
command: "pkill hyprsunset; hyprsunset -t 3500 &"
|
||||||
|
26:
|
||||||
|
icon: nightlight-off.png
|
||||||
|
command: "pkill hyprsunset"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Power (privileged — requires helper)
|
||||||
|
|
||||||
|
These use the `priv:` prefix and must be added to `/etc/streamdeck-go/privileged.yaml`.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# /etc/streamdeck-go/privileged.yaml
|
||||||
|
commands:
|
||||||
|
suspend: "systemctl suspend"
|
||||||
|
hibernate: "systemctl hibernate"
|
||||||
|
reboot: "systemctl reboot"
|
||||||
|
poweroff: "systemctl poweroff"
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# ~/.config/streamdeck-go/config.yaml
|
||||||
|
keys:
|
||||||
|
28:
|
||||||
|
icon: suspend.png
|
||||||
|
command: "priv:suspend"
|
||||||
|
29:
|
||||||
|
icon: reboot.png
|
||||||
|
command: "priv:reboot"
|
||||||
|
30:
|
||||||
|
icon: poweroff.png
|
||||||
|
command: "priv:poweroff"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Media (playerctl)
|
||||||
|
|
||||||
|
Works with Spotify, Firefox, mpv, and anything that exposes MPRIS.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
keys:
|
||||||
|
20:
|
||||||
|
icon: prev.png
|
||||||
|
command: "playerctl previous"
|
||||||
|
21:
|
||||||
|
icon: playpause.png
|
||||||
|
command: "playerctl play-pause"
|
||||||
|
22:
|
||||||
|
icon: next.png
|
||||||
|
command: "playerctl next"
|
||||||
|
23:
|
||||||
|
icon: stop.png
|
||||||
|
command: "playerctl stop"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Useful hyprctl one-liners
|
||||||
|
|
||||||
|
| What | Command |
|
||||||
|
|---|---|
|
||||||
|
| List all windows | `hyprctl clients` |
|
||||||
|
| Active window info | `hyprctl activewindow` |
|
||||||
|
| List monitors | `hyprctl monitors` |
|
||||||
|
| Reload Hyprland config | `hyprctl reload` |
|
||||||
|
| Toggle special workspace | `hyprctl dispatch togglespecialworkspace` |
|
||||||
|
| Focus next window | `hyprctl dispatch cyclenext` |
|
||||||
|
| Rotate layout | `hyprctl dispatch layoutmsg orientationcycle` |
|
||||||
405
README.md
Normal file
405
README.md
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
# 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.
|
||||||
|
No Elgato software required — communicates directly with the device over USB HID.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Configure keys with a single YAML file (Designed for dotfiles compatability)
|
||||||
|
- PNG and JPEG 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
|
||||||
|
- **Live config reload** — save your config and the deck updates instantly, no restart needed
|
||||||
|
- Privileged command helper — run whitelisted root commands via a Unix socket; supports polkit auth dialogs
|
||||||
|
- Automatic reconnect — survives USB unplug, KVM switches, and suspend/resume
|
||||||
|
- Runs as a systemd user service, starts automatically with your desktop session
|
||||||
|
- 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
|
||||||
|
├── 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 unit
|
||||||
|
├── 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)
|
||||||
|
│
|
||||||
|
└── 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 |
|
||||||
|
| Go stdlib `image/gif`, `image/jpeg`, `image/png` | Image decoding and JPEG encoding |
|
||||||
|
|
||||||
|
### System library
|
||||||
|
|
||||||
|
`libhidapi` must be present at runtime:
|
||||||
|
|
||||||
|
| Distro | Command |
|
||||||
|
|---|---|
|
||||||
|
| 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
|
||||||
|
|
||||||
|
### Option A — `make install` (recommended)
|
||||||
|
|
||||||
|
Builds the binary, installs the systemd user service, and writes a default config
|
||||||
|
if one doesn't already exist.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Prerequisites
|
||||||
|
sudo pacman -S go hidapi # Arch; adjust for your distro (see table above)
|
||||||
|
|
||||||
|
git clone https://github.com/WoodardDigital/streamdeck-go
|
||||||
|
cd streamdeck-go
|
||||||
|
make install
|
||||||
|
```
|
||||||
|
|
||||||
|
`make install` does the following automatically:
|
||||||
|
|
||||||
|
1. Installs the udev rule (`/etc/udev/rules.d/99-streamdeck.rules`) so the device is accessible without root
|
||||||
|
2. Builds the binary and copies it to `~/.local/bin/streamdeck-go`
|
||||||
|
3. Creates `~/.config/streamdeck-go/` and `~/.config/streamdeck-go/icons/`
|
||||||
|
4. Writes a starter `~/.config/streamdeck-go/config.yaml` (only if one doesn't exist)
|
||||||
|
5. Installs and enables the systemd user service — starts now and on every login
|
||||||
|
|
||||||
|
After install, edit your config:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$EDITOR ~/.config/streamdeck-go/config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
Changes are picked up automatically — no need to restart anything.
|
||||||
|
|
||||||
|
To remove:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make uninstall # stops the service and removes the binary; config is preserved
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Option B — manual / dev setup
|
||||||
|
|
||||||
|
Use this if you want to run directly from the repo (e.g. while developing).
|
||||||
|
|
||||||
|
**1. udev rule** (one-time, needed once regardless of install method):
|
||||||
|
|
||||||
|
```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
|
||||||
|
|
||||||
|
# Run with the repo's config.yaml (created from the example if absent):
|
||||||
|
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 always checks
|
||||||
|
`~/.config/streamdeck-go/config.yaml` first (respecting `$XDG_CONFIG_HOME`).
|
||||||
|
The repo's `config.yaml` is gitignored — it's your local scratchpad.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Option C — AUR (Arch Linux)
|
||||||
|
|
||||||
|
> AUR package coming soon. Until then, use `make install` above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Systemd service
|
||||||
|
|
||||||
|
The service file lives at `systemd/streamdeck-go.service` in the repo and is
|
||||||
|
installed to `~/.config/systemd/user/streamdeck-go.service` by `make install`.
|
||||||
|
|
||||||
|
Useful commands:
|
||||||
|
|
||||||
|
```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 with the service, edit the unit after install:
|
||||||
|
|
||||||
|
```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
|
||||||
|
# Run: 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, or GIF — relative to icons_dir
|
||||||
|
command: ghostty
|
||||||
|
1:
|
||||||
|
icon: firefox.png
|
||||||
|
command: firefox
|
||||||
|
8:
|
||||||
|
icon: loading.gif # animated — cycles at the GIF's native frame rate
|
||||||
|
command: ""
|
||||||
|
```
|
||||||
|
|
||||||
|
### Terminal & SSH commands
|
||||||
|
|
||||||
|
Any shell command works — including launching terminals and SSH sessions.
|
||||||
|
|
||||||
|
**Open a terminal on key press:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
keys:
|
||||||
|
0:
|
||||||
|
icon: ghostty.png
|
||||||
|
command: ghostty
|
||||||
|
1:
|
||||||
|
icon: terminal.png
|
||||||
|
command: alacritty # or kitty, wezterm, foot, etc.
|
||||||
|
```
|
||||||
|
|
||||||
|
**SSH — open an interactive session in a terminal:**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
keys:
|
||||||
|
5:
|
||||||
|
icon: homeserver.png
|
||||||
|
command: "ghostty -e ssh user@homeserver"
|
||||||
|
6:
|
||||||
|
icon: pi.png
|
||||||
|
command: "alacritty -e ssh pi@raspberrypi.local"
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the recommended pattern for SSH — the terminal handles the TTY,
|
||||||
|
resize events, and any passphrase prompt.
|
||||||
|
|
||||||
|
**SSH — run a remote command silently (no terminal):**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
keys:
|
||||||
|
7:
|
||||||
|
icon: deploy.png
|
||||||
|
command: "ssh user@host 'cd /app && git pull && systemctl restart app'"
|
||||||
|
```
|
||||||
|
|
||||||
|
Works as long as SSH key auth is set up and the key has no passphrase (or the agent is available — see note below).
|
||||||
|
|
||||||
|
> **SSH agent & the systemd service**
|
||||||
|
>
|
||||||
|
> The service starts before your desktop session fully initialises, so
|
||||||
|
> `SSH_AUTH_SOCK` (used for passphrase-protected keys) may not be in its
|
||||||
|
> environment. Fix by importing it from your session startup:
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> # Add to ~/.config/fish/config.fish, ~/.bashrc, or session init:
|
||||||
|
> systemctl --user import-environment SSH_AUTH_SOCK
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> Or hardcode the socket path in the service:
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> systemctl --user edit streamdeck-go
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> ```ini
|
||||||
|
> [Service]
|
||||||
|
> Environment=SSH_AUTH_SOCK=%t/keyring/ssh
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> Run `echo $SSH_AUTH_SOCK` in a terminal to find the correct path for your
|
||||||
|
> desktop environment.
|
||||||
|
|
||||||
|
**Common terminal flags:**
|
||||||
|
|
||||||
|
| Terminal | Flag to run a command |
|
||||||
|
|---|---|
|
||||||
|
| ghostty | `ghostty -e <cmd>` |
|
||||||
|
| alacritty | `alacritty -e <cmd>` |
|
||||||
|
| kitty | `kitty <cmd>` |
|
||||||
|
| foot | `foot <cmd>` |
|
||||||
|
| wezterm | `wezterm start -- <cmd>` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 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 |
|
||||||
|
|
||||||
|
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 | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Stream Deck XL v2 | `0x00ba` | 32 | tested |
|
||||||
|
| Stream Deck XL v1 | `0x006c` | 32 | untested |
|
||||||
|
| Stream Deck MK.2 | `0x006d` | 15 | untested |
|
||||||
|
|
||||||
|
Run `lsusb | grep Elgato` to find your device's product ID.
|
||||||
|
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.
|
||||||
|
Icons would be composited with a text layer before being sent to the device, so
|
||||||
|
no pre-made image is needed for every possible value.
|
||||||
|
|
||||||
|
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 # re-evaluate and redraw every 5 seconds
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-page layouts
|
||||||
|
|
||||||
|
Support more than 32 actions by organising keys into named pages. A designated
|
||||||
|
key (or key combination) switches between pages. The deck reloads instantly with
|
||||||
|
the new page's icons when switching.
|
||||||
|
|
||||||
|
Example config (proposed):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
pages:
|
||||||
|
default:
|
||||||
|
0:
|
||||||
|
icon: apps.png
|
||||||
|
command: "page:apps" # switch to the 'apps' page
|
||||||
|
1:
|
||||||
|
icon: firefox.png
|
||||||
|
command: firefox
|
||||||
|
|
||||||
|
apps:
|
||||||
|
0:
|
||||||
|
icon: back.png
|
||||||
|
command: "page:default"
|
||||||
|
1:
|
||||||
|
icon: ghostty.png
|
||||||
|
command: ghostty
|
||||||
|
2:
|
||||||
|
icon: obsidian.png
|
||||||
|
command: obsidian
|
||||||
|
```
|
||||||
|
|
||||||
|
### AUR package
|
||||||
|
|
||||||
|
A `PKGBUILD` for Arch Linux so the full install (binary, services, udev rule,
|
||||||
|
config skeleton) is handled by `yay` or `paru` like any other package.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yay -S streamdeck-go # coming soon
|
||||||
|
```
|
||||||
161
cmd/streamdeck-helper/main.go
Normal file
161
cmd/streamdeck-helper/main.go
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
// streamdeck-helper is a small privileged daemon that runs as root and executes
|
||||||
|
// whitelisted commands on behalf of the unprivileged streamdeck-go process.
|
||||||
|
//
|
||||||
|
// It communicates over a Unix socket. The main process sends a JSON request
|
||||||
|
// containing only a command name; the helper validates it against a root-owned
|
||||||
|
// whitelist before running anything. Arbitrary shell commands are never accepted.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"os/user"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
socketPath = "/run/streamdeck-go/helper.sock"
|
||||||
|
whitelistPath = "/etc/streamdeck-go/privileged.yaml"
|
||||||
|
socketMode = 0660
|
||||||
|
)
|
||||||
|
|
||||||
|
type whitelist struct {
|
||||||
|
Commands map[string]string `yaml:"commands"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type request struct {
|
||||||
|
Command string `json:"command"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type response struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Must run as root.
|
||||||
|
if os.Getuid() != 0 {
|
||||||
|
log.Fatal("streamdeck-helper must run as root (install as a system service)")
|
||||||
|
}
|
||||||
|
|
||||||
|
wl, err := loadWhitelist(whitelistPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("load whitelist %q: %v", whitelistPath, err)
|
||||||
|
}
|
||||||
|
log.Printf("loaded %d whitelisted commands from %s", len(wl.Commands), whitelistPath)
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(socketPath), 0755); err != nil {
|
||||||
|
log.Fatalf("create socket dir: %v", err)
|
||||||
|
}
|
||||||
|
// Remove stale socket from a previous run.
|
||||||
|
_ = os.Remove(socketPath)
|
||||||
|
|
||||||
|
ln, err := net.Listen("unix", socketPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("listen on %s: %v", socketPath, err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
|
||||||
|
if err := os.Chmod(socketPath, socketMode); err != nil {
|
||||||
|
log.Fatalf("chmod socket: %v", err)
|
||||||
|
}
|
||||||
|
// Chown the socket to root:streamdeck so group members can connect.
|
||||||
|
// Without this the socket is root:root and only root can reach the helper.
|
||||||
|
if grp, err := user.LookupGroup("streamdeck"); err != nil {
|
||||||
|
log.Fatalf("group 'streamdeck' not found — run 'make install-helper' first: %v", err)
|
||||||
|
} else if gid, err := strconv.Atoi(grp.Gid); err != nil {
|
||||||
|
log.Fatalf("invalid gid %q: %v", grp.Gid, err)
|
||||||
|
} else if err := os.Lchown(socketPath, 0, gid); err != nil {
|
||||||
|
log.Fatalf("chown socket: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("listening on %s (group: streamdeck)", socketPath)
|
||||||
|
|
||||||
|
for {
|
||||||
|
conn, err := ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("accept: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
go handle(conn, wl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handle(conn net.Conn, wl *whitelist) {
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(conn)
|
||||||
|
if !scanner.Scan() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req request
|
||||||
|
if err := json.Unmarshal(scanner.Bytes(), &req); err != nil {
|
||||||
|
send(conn, response{Error: "invalid request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Command == "" {
|
||||||
|
send(conn, response{Error: "empty command name"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
shell, ok := wl.Commands[req.Command]
|
||||||
|
if !ok {
|
||||||
|
log.Printf("REJECTED unknown command %q", req.Command)
|
||||||
|
send(conn, response{Error: fmt.Sprintf("unknown command %q — add it to %s", req.Command, whitelistPath)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("running %q → %q", req.Command, shell)
|
||||||
|
out, err := exec.Command("sh", "-c", shell).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
msg := fmt.Sprintf("%v", err)
|
||||||
|
if len(out) > 0 {
|
||||||
|
msg = fmt.Sprintf("%v: %s", err, out)
|
||||||
|
}
|
||||||
|
log.Printf("command %q failed: %s", req.Command, msg)
|
||||||
|
send(conn, response{Error: msg})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
send(conn, response{OK: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func send(conn net.Conn, resp response) {
|
||||||
|
data, _ := json.Marshal(resp)
|
||||||
|
_, _ = conn.Write(append(data, '\n'))
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadWhitelist(path string) (*whitelist, error) {
|
||||||
|
// Verify the file is owned by root and not world-writable.
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if info.Mode().Perm()&0002 != 0 {
|
||||||
|
return nil, fmt.Errorf("%s is world-writable — refusing to load (chmod o-w %s)", path, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var wl whitelist
|
||||||
|
if err := yaml.Unmarshal(data, &wl); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if wl.Commands == nil {
|
||||||
|
wl.Commands = map[string]string{}
|
||||||
|
}
|
||||||
|
return &wl, nil
|
||||||
|
}
|
||||||
30
config.example.yaml
Normal file
30
config.example.yaml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
icons_dir: ./icons
|
||||||
|
brightness: 70
|
||||||
|
|
||||||
|
# Device USB IDs — defaults match Stream Deck XL v2.
|
||||||
|
# Run `lsusb | grep Elgato` to confirm yours.
|
||||||
|
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
|
||||||
|
#
|
||||||
|
# icon: filename inside icons_dir (PNG or JPEG)
|
||||||
|
# command: any shell command
|
||||||
|
|
||||||
|
keys:
|
||||||
|
0:
|
||||||
|
icon: ghostty.png
|
||||||
|
command: ghostty
|
||||||
|
1:
|
||||||
|
icon: terminal.png
|
||||||
|
command: alacritty
|
||||||
|
2:
|
||||||
|
icon: files.png
|
||||||
|
command: nautilus
|
||||||
24
config/privileged.example.yaml
Normal file
24
config/privileged.example.yaml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# /etc/streamdeck-go/privileged.yaml
|
||||||
|
#
|
||||||
|
# Root-owned whitelist of commands that streamdeck-go is allowed to run with
|
||||||
|
# elevated privileges via the helper daemon.
|
||||||
|
#
|
||||||
|
# IMPORTANT:
|
||||||
|
# - This file must be owned by root and not world-writable.
|
||||||
|
# - Only root can add or modify entries.
|
||||||
|
# - The helper will refuse to start if this file is world-writable.
|
||||||
|
#
|
||||||
|
# Usage in ~/.config/streamdeck-go/config.yaml:
|
||||||
|
# keys:
|
||||||
|
# 5:
|
||||||
|
# icon: suspend.png
|
||||||
|
# command: "priv:suspend"
|
||||||
|
#
|
||||||
|
# The value is a shell command run as root. Keep these minimal and specific.
|
||||||
|
|
||||||
|
commands:
|
||||||
|
suspend: "systemctl suspend"
|
||||||
|
reboot: "systemctl reboot"
|
||||||
|
poweroff: "systemctl poweroff"
|
||||||
|
# brightness_max: "brightnessctl set 100%"
|
||||||
|
# wake_on_lan: "etherwake AA:BB:CC:DD:EE:FF"
|
||||||
12
go.mod
Normal file
12
go.mod
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
module github.com/WoodardDigital/streamdeck-go
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0
|
||||||
|
github.com/sstallion/go-hid v0.15.0
|
||||||
|
golang.org/x/image v0.37.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require golang.org/x/sys v0.13.0 // indirect
|
||||||
12
go.sum
Normal file
12
go.sum
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
github.com/sstallion/go-hid v0.15.0 h1:WERW/VW3Us6N73V2qa7HjdqWQvwHd0CoRDOP/N707/w=
|
||||||
|
github.com/sstallion/go-hid v0.15.0/go.mod h1:fPKp4rqx0xuoTV94gwKojsPG++KNKhxuU88goGuGM7I=
|
||||||
|
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
|
||||||
|
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
||||||
|
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
||||||
|
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
0
icons/.gitkeep
Normal file
0
icons/.gitkeep
Normal file
53
internal/config/config.go
Normal file
53
internal/config/config.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// KeyConfig defines what a single Stream Deck key does.
|
||||||
|
type KeyConfig struct {
|
||||||
|
Icon string `yaml:"icon"` // filename relative to icons_dir
|
||||||
|
Command string `yaml:"command"` // shell command to run on press
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config is the top-level structure of the YAML config file.
|
||||||
|
type Config struct {
|
||||||
|
IconsDir string `yaml:"icons_dir"`
|
||||||
|
Brightness int `yaml:"brightness"`
|
||||||
|
Device DeviceConfig `yaml:"device"`
|
||||||
|
Keys map[int]KeyConfig `yaml:"keys"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceConfig allows overriding USB IDs (defaults work for Stream Deck XL v2).
|
||||||
|
type DeviceConfig struct {
|
||||||
|
VendorID uint16 `yaml:"vendor_id"`
|
||||||
|
ProductID uint16 `yaml:"product_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads and parses a YAML config file, applying sane defaults.
|
||||||
|
// icons_dir defaults to an "icons" folder next to the config file.
|
||||||
|
func Load(path string) (*Config, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read config %q: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &Config{
|
||||||
|
IconsDir: filepath.Join(filepath.Dir(path), "icons"),
|
||||||
|
Brightness: 70,
|
||||||
|
Device: DeviceConfig{
|
||||||
|
VendorID: 0x0fd9,
|
||||||
|
ProductID: 0x00ba, // Stream Deck XL v2
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
230
internal/device/streamdeck.go
Normal file
230
internal/device/streamdeck.go
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
package device
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/jpeg"
|
||||||
|
_ "image/png"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/sstallion/go-hid"
|
||||||
|
"golang.org/x/image/draw"
|
||||||
|
)
|
||||||
|
|
||||||
|
const VendorID = 0x0fd9
|
||||||
|
|
||||||
|
// ModelInfo describes hardware-specific constants for a Stream Deck model.
|
||||||
|
type ModelInfo struct {
|
||||||
|
KeyCount int
|
||||||
|
Cols int
|
||||||
|
Rows int
|
||||||
|
ImageWidth int
|
||||||
|
ImageHeight int
|
||||||
|
// FlipX/FlipY: the XL renders images mirrored; we pre-flip before sending.
|
||||||
|
FlipX bool
|
||||||
|
FlipY bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// models maps USB product IDs to their hardware specs.
|
||||||
|
var models = map[uint16]ModelInfo{
|
||||||
|
0x00ba: {KeyCount: 32, Cols: 8, Rows: 4, ImageWidth: 96, ImageHeight: 96, FlipX: true, FlipY: true}, // XL v2
|
||||||
|
0x006c: {KeyCount: 32, Cols: 8, Rows: 4, ImageWidth: 96, ImageHeight: 96, FlipX: true, FlipY: true}, // XL v1
|
||||||
|
0x006d: {KeyCount: 15, Cols: 5, Rows: 3, ImageWidth: 72, ImageHeight: 72, FlipX: true, FlipY: true}, // MK.2
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
reportSize = 1024
|
||||||
|
imageHeaderSize = 8
|
||||||
|
imagePayloadSize = reportSize - imageHeaderSize
|
||||||
|
readReportSize = 512
|
||||||
|
)
|
||||||
|
|
||||||
|
// StreamDeck represents an open Stream Deck device.
|
||||||
|
type StreamDeck struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
dev *hid.Device
|
||||||
|
model ModelInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open finds and opens the first Stream Deck with the given product ID.
|
||||||
|
func Open(vendorID, productID uint16) (*StreamDeck, error) {
|
||||||
|
if err := hid.Init(); err != nil {
|
||||||
|
return nil, fmt.Errorf("hid init: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m, ok := models[productID]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unsupported product ID: 0x%04x (add it to internal/device/streamdeck.go)", productID)
|
||||||
|
}
|
||||||
|
|
||||||
|
dev, err := hid.OpenFirst(vendorID, productID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("open device 0x%04x:0x%04x: %w (try: sudo chmod a+rw /dev/hidraw*)", vendorID, productID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &StreamDeck{dev: dev, model: m}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases the device.
|
||||||
|
func (sd *StreamDeck) Close() error {
|
||||||
|
_ = hid.Exit()
|
||||||
|
return sd.dev.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyCount returns the number of keys on this device.
|
||||||
|
func (sd *StreamDeck) KeyCount() int { return sd.model.KeyCount }
|
||||||
|
|
||||||
|
// Reset clears all key images and returns the device to its default state.
|
||||||
|
func (sd *StreamDeck) Reset() error {
|
||||||
|
report := make([]byte, 32)
|
||||||
|
report[0] = 0x03
|
||||||
|
report[1] = 0x02
|
||||||
|
_, err := sd.dev.SendFeatureReport(report)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetBrightness sets the display brightness (0–100).
|
||||||
|
func (sd *StreamDeck) SetBrightness(pct int) error {
|
||||||
|
if pct < 0 {
|
||||||
|
pct = 0
|
||||||
|
}
|
||||||
|
if pct > 100 {
|
||||||
|
pct = 100
|
||||||
|
}
|
||||||
|
report := make([]byte, 32)
|
||||||
|
report[0] = 0x03
|
||||||
|
report[1] = 0x08
|
||||||
|
report[2] = byte(pct)
|
||||||
|
_, err := sd.dev.SendFeatureReport(report)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncodeFrame scales img to key size and returns the JPEG bytes ready to send.
|
||||||
|
// Use this to pre-encode animation frames once rather than on every tick.
|
||||||
|
func (sd *StreamDeck) EncodeFrame(img image.Image) ([]byte, error) {
|
||||||
|
dst := image.NewRGBA(image.Rect(0, 0, sd.model.ImageWidth, sd.model.ImageHeight))
|
||||||
|
draw.BiLinear.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Over, nil)
|
||||||
|
if sd.model.FlipX || sd.model.FlipY {
|
||||||
|
dst = flipImage(dst, sd.model.FlipX, sd.model.FlipY)
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 95}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetKeyFrame sends pre-encoded JPEG bytes (from EncodeFrame) to a key.
|
||||||
|
func (sd *StreamDeck) SetKeyFrame(keyIndex int, jpegData []byte) error {
|
||||||
|
return sd.sendKeyImageData(keyIndex, jpegData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetKeyImage scales img to the key size and sends it to the given key (0-indexed).
|
||||||
|
func (sd *StreamDeck) SetKeyImage(keyIndex int, img image.Image) error {
|
||||||
|
// Scale to key pixel dimensions.
|
||||||
|
dst := image.NewRGBA(image.Rect(0, 0, sd.model.ImageWidth, sd.model.ImageHeight))
|
||||||
|
draw.BiLinear.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Over, nil)
|
||||||
|
|
||||||
|
// The XL renders images flipped; pre-flip so they appear correct.
|
||||||
|
if sd.model.FlipX || sd.model.FlipY {
|
||||||
|
dst = flipImage(dst, sd.model.FlipX, sd.model.FlipY)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 95}); err != nil {
|
||||||
|
return fmt.Errorf("jpeg encode: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sd.sendKeyImageData(keyIndex, buf.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearKey fills the given key with solid black.
|
||||||
|
func (sd *StreamDeck) ClearKey(keyIndex int) error {
|
||||||
|
blank := image.NewRGBA(image.Rect(0, 0, sd.model.ImageWidth, sd.model.ImageHeight))
|
||||||
|
return sd.SetKeyImage(keyIndex, blank)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadButtons waits up to 250 ms for a button state report.
|
||||||
|
// Returns (nil, nil) on timeout — callers should check context and retry.
|
||||||
|
func (sd *StreamDeck) ReadButtons() ([]bool, error) {
|
||||||
|
data := make([]byte, readReportSize)
|
||||||
|
n, err := sd.dev.ReadWithTimeout(data, 250)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return nil, nil // timeout, no data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input report layout (XL v2): [report_id, 0x00, 0x00, 0x00, key0, key1, ...]
|
||||||
|
const offset = 4
|
||||||
|
if n < offset+sd.model.KeyCount {
|
||||||
|
return nil, fmt.Errorf("short read: got %d bytes, expected at least %d", n, offset+sd.model.KeyCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
buttons := make([]bool, sd.model.KeyCount)
|
||||||
|
for i := 0; i < sd.model.KeyCount; i++ {
|
||||||
|
buttons[i] = data[offset+i] == 0x01
|
||||||
|
}
|
||||||
|
return buttons, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendKeyImageData sends raw JPEG bytes to a key, split across 1024-byte HID reports.
|
||||||
|
func (sd *StreamDeck) sendKeyImageData(keyIndex int, data []byte) error {
|
||||||
|
if keyIndex < 0 || keyIndex >= sd.model.KeyCount {
|
||||||
|
return fmt.Errorf("key index %d out of range (device has %d keys, 0–%d)",
|
||||||
|
keyIndex, sd.model.KeyCount, sd.model.KeyCount-1)
|
||||||
|
}
|
||||||
|
sd.mu.Lock()
|
||||||
|
defer sd.mu.Unlock()
|
||||||
|
pageIndex := 0
|
||||||
|
for len(data) > 0 {
|
||||||
|
chunk := data
|
||||||
|
if len(chunk) > imagePayloadSize {
|
||||||
|
chunk = chunk[:imagePayloadSize]
|
||||||
|
}
|
||||||
|
|
||||||
|
isLast := byte(0)
|
||||||
|
if len(data) == len(chunk) {
|
||||||
|
isLast = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
report := make([]byte, reportSize)
|
||||||
|
report[0] = 0x02
|
||||||
|
report[1] = 0x07
|
||||||
|
report[2] = byte(keyIndex)
|
||||||
|
report[3] = isLast
|
||||||
|
binary.LittleEndian.PutUint16(report[4:6], uint16(len(chunk)))
|
||||||
|
binary.LittleEndian.PutUint16(report[6:8], uint16(pageIndex))
|
||||||
|
copy(report[8:], chunk)
|
||||||
|
|
||||||
|
if _, err := sd.dev.Write(report); err != nil {
|
||||||
|
return fmt.Errorf("write report page %d: %w", pageIndex, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data = data[len(chunk):]
|
||||||
|
pageIndex++
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func flipImage(src *image.RGBA, flipX, flipY bool) *image.RGBA {
|
||||||
|
b := src.Bounds()
|
||||||
|
w, h := b.Max.X, b.Max.Y
|
||||||
|
dst := image.NewRGBA(b)
|
||||||
|
for y := 0; y < h; y++ {
|
||||||
|
for x := 0; x < w; x++ {
|
||||||
|
dx, dy := x, y
|
||||||
|
if flipX {
|
||||||
|
dx = w - 1 - x
|
||||||
|
}
|
||||||
|
if flipY {
|
||||||
|
dy = h - 1 - y
|
||||||
|
}
|
||||||
|
dst.SetRGBA(dx, dy, src.RGBAAt(x, y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
20
systemd/streamdeck-go-helper.service
Normal file
20
systemd/streamdeck-go-helper.service
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Stream Deck privileged command helper
|
||||||
|
Documentation=https://github.com/WoodardDigital/streamdeck-go
|
||||||
|
# Start before the user session so the socket is ready when streamdeck-go starts.
|
||||||
|
Before=graphical.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=/usr/local/bin/streamdeck-helper
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
# Run as root (required to execute privileged commands).
|
||||||
|
# The socket is group-restricted to the 'streamdeck' group — only
|
||||||
|
# members of that group can connect.
|
||||||
|
RuntimeDirectory=streamdeck-go
|
||||||
|
RuntimeDirectoryMode=0755
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
16
systemd/streamdeck-go.service
Normal file
16
systemd/streamdeck-go.service
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Stream Deck controller
|
||||||
|
Documentation=https://github.com/WoodardDigital/streamdeck-go
|
||||||
|
After=graphical-session.target
|
||||||
|
PartOf=graphical-session.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=%h/.local/bin/streamdeck-go
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
# Config is auto-discovered at ~/.config/streamdeck-go/config.yaml
|
||||||
|
# Override with: ExecStart=%h/.local/bin/streamdeck-go -config /path/to/config.yaml
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=graphical-session.target
|
||||||
Reference in New Issue
Block a user