Compare commits

...

10 Commits

Author SHA1 Message Date
a51fd2beff Fixing watchdog 2026-05-10 13:35:16 -06:00
8b6b4d582d Adding make reinstall 2026-04-26 14:00:43 -06:00
212e957f49 Adding restart watchdog and moving to i0t.app 2026-04-26 13:47:47 -06:00
38893cbb84 adding things and fixing sgv 2026-04-18 16:13:39 -06:00
9b375cfba2 Ignore streamdeck-init build artifact
The init TUI binary was accidentally tracked. Add it to .gitignore
and remove from the index so rebuilds don't show up as modifications.
2026-04-18 12:08:39 -06:00
3a112dfd84 Fix obs-cmd Linux download — upstream renamed asset
The grigio/obs-cmd v1.0.0 release ships Linux builds as
obs-cmd-x64-linux.tar.gz; the old obs-cmd-v-x86_64-unknown-linux-musl.tar.gz
name no longer exists and curl was piping a 404 page into tar.
2026-04-18 12:04:21 -06:00
962ee747fd Text overlay support 2026-04-18 11:55:18 -06:00
lwoodard
44dc22d8ee Adding readme updates 2026-04-16 15:38:46 -06:00
5e85dda038 Merge pull request 'adding OBS support' (#1) from obs-function into main
Reviewed-on: #1
2026-04-16 21:25:17 +00:00
lwoodard
6f2290bd6c adding OBS support 2026-04-16 15:23:46 -06:00
21 changed files with 1458 additions and 74 deletions

1
.gitignore vendored
View File

@@ -2,6 +2,7 @@
/streamdeck /streamdeck
/streamdeck-go /streamdeck-go
/streamdeck-helper /streamdeck-helper
/streamdeck-init
/bin/ /bin/
*.exe *.exe

135
Makefile
View File

@@ -24,7 +24,7 @@ else
UDEV_RULE := /etc/udev/rules.d/99-streamdeck.rules UDEV_RULE := /etc/udev/rules.d/99-streamdeck.rules
endif endif
.PHONY: build build-helper build-init install install-helper uninstall uninstall-helper udev .PHONY: build build-helper build-init install install-helper install-watchdog reinstall uninstall uninstall-helper uninstall-watchdog udev
# ── Build ───────────────────────────────────────────────────────────────────── # ── Build ─────────────────────────────────────────────────────────────────────
@@ -88,6 +88,37 @@ install-helper: build-helper
@echo " or run: newgrp $(GROUP)" @echo " or run: newgrp $(GROUP)"
endif endif
# Install the watchdog timer that detects USB unplug/replug and restarts the
# service when the daemon's in-process reconnect misses an event. Linux only.
ifeq ($(OS),Darwin)
WATCHDOG_PLIST := $(LAUNCHAGENTS)/com.woodarddigital.streamdeck-go-watchdog.plist
WATCHDOG_LOG := $(HOME)/Library/Logs/streamdeck-go-watchdog.log
install-watchdog:
mkdir -p $(BIN_DIR) $(LAUNCHAGENTS) $(HOME)/Library/Logs
install -m 755 systemd/streamdeck-go-watchdog.sh $(BIN_DIR)/streamdeck-go-watchdog
# Substitute the binary and log paths into the plist.
sed -e 's|STREAMDECK_WATCHDOG_PATH|$(BIN_DIR)/streamdeck-go-watchdog|' \
-e 's|STREAMDECK_WATCHDOG_LOG_PATH|$(WATCHDOG_LOG)|g' \
launchd/com.woodarddigital.streamdeck-go-watchdog.plist > $(WATCHDOG_PLIST)
# Reload: bootout (ignore if not loaded) then bootstrap.
launchctl bootout gui/$$(id -u)/com.woodarddigital.streamdeck-go-watchdog 2>/dev/null || true
launchctl bootstrap gui/$$(id -u) $(WATCHDOG_PLIST)
@echo ""
@echo "Watchdog installed. Fires every 30s."
@echo " Logs: $(WATCHDOG_LOG)"
else
install-watchdog:
install -Dm755 systemd/streamdeck-go-watchdog.sh $(BIN_DIR)/streamdeck-go-watchdog
install -Dm644 systemd/streamdeck-go-watchdog.service $(SYSTEMD_USER)/streamdeck-go-watchdog.service
install -Dm644 systemd/streamdeck-go-watchdog.timer $(SYSTEMD_USER)/streamdeck-go-watchdog.timer
systemctl --user daemon-reload
systemctl --user enable --now streamdeck-go-watchdog.timer
@echo ""
@echo "Watchdog timer installed and started."
@echo " Status: systemctl --user status streamdeck-go-watchdog.timer"
@echo " Logs: journalctl --user -u streamdeck-go-watchdog.service"
endif
# udev: Linux-only device permission rule. # udev: Linux-only device permission rule.
udev: udev:
ifeq ($(OS),Darwin) ifeq ($(OS),Darwin)
@@ -104,6 +135,94 @@ else
fi fi
endif endif
# ── Reinstall ─────────────────────────────────────────────────────────────────
# Refresh only the pieces that are already installed: binary, service unit,
# helper, watchdog, and modules.yaml. Skips dependency/dotfile/symlink setup.
# Use this after a code change to redeploy without going through install.sh.
ifeq ($(OS),Darwin)
reinstall: build
@echo " ━━━ streamdeck-go reinstall ━━━"
@if [ -f $(BIN_DIR)/$(BINARY) ]; then \
install -m 755 $(BINARY) $(BIN_DIR)/$(BINARY); \
echo " ✓ binary → $(BIN_DIR)/$(BINARY)"; \
else \
echo " · binary not installed (skipping — run 'make install' first)"; \
fi
@if [ -f $(AGENT_PLIST) ]; then \
LOG_PATH="$$HOME/Library/Logs/streamdeck-go.log"; \
sed -e "s|STREAMDECK_BINARY_PATH|$(BIN_DIR)/$(BINARY)|g" \
-e "s|STREAMDECK_LOG_PATH|$$LOG_PATH|g" \
launchd/com.woodarddigital.streamdeck-go.plist > $(AGENT_PLIST); \
launchctl unload $(AGENT_PLIST) 2>/dev/null || true; \
launchctl load $(AGENT_PLIST); \
echo " ✓ launchd agent reloaded"; \
else \
echo " · launchd agent not installed (skipping)"; \
fi
@if [ -f $(WATCHDOG_PLIST) ]; then \
install -m 755 systemd/streamdeck-go-watchdog.sh $(BIN_DIR)/streamdeck-go-watchdog; \
sed -e 's|STREAMDECK_WATCHDOG_PATH|$(BIN_DIR)/streamdeck-go-watchdog|' \
-e 's|STREAMDECK_WATCHDOG_LOG_PATH|$(WATCHDOG_LOG)|g' \
launchd/com.woodarddigital.streamdeck-go-watchdog.plist > $(WATCHDOG_PLIST); \
launchctl bootout gui/$$(id -u)/com.woodarddigital.streamdeck-go-watchdog 2>/dev/null || true; \
launchctl bootstrap gui/$$(id -u) $(WATCHDOG_PLIST); \
echo " ✓ watchdog refreshed"; \
else \
echo " · watchdog not installed (skipping)"; \
fi
@if [ -f $(CONFIG_DIR)/modules.yaml ]; then \
install -m 644 modules.example.yaml $(CONFIG_DIR)/modules.yaml; \
echo " ✓ modules.yaml updated"; \
else \
echo " · config dir not set up (skipping modules.yaml)"; \
fi
else
reinstall: build
@echo " ━━━ streamdeck-go reinstall ━━━"
@if [ -f $(BIN_DIR)/$(BINARY) ]; then \
install -m 755 $(BINARY) $(BIN_DIR)/$(BINARY); \
echo " ✓ binary → $(BIN_DIR)/$(BINARY)"; \
else \
echo " · binary not installed (skipping — run 'make install' first)"; \
fi
@if [ -f $(SYSTEMD_USER)/streamdeck-go.service ]; then \
install -m 644 systemd/streamdeck-go.service $(SYSTEMD_USER)/streamdeck-go.service; \
systemctl --user daemon-reload; \
systemctl --user restart streamdeck-go.service; \
echo " ✓ systemd unit refreshed and service restarted"; \
else \
echo " · systemd user service not installed (skipping)"; \
fi
@if [ -f $(SYS_BIN)/$(HELPER) ]; then \
$(MAKE) -s build-helper; \
sudo install -m 750 $(HELPER) $(SYS_BIN)/$(HELPER); \
sudo chown root:$(GROUP) $(SYS_BIN)/$(HELPER); \
sudo install -m 644 systemd/streamdeck-go-helper.service $(SYSTEMD_SYS)/streamdeck-go-helper.service; \
sudo systemctl daemon-reload; \
sudo systemctl restart streamdeck-go-helper.service; \
echo " ✓ helper refreshed and restarted"; \
else \
echo " · helper not installed (skipping)"; \
fi
@if [ -f $(SYSTEMD_USER)/streamdeck-go-watchdog.timer ]; then \
install -m 755 systemd/streamdeck-go-watchdog.sh $(BIN_DIR)/streamdeck-go-watchdog; \
install -m 644 systemd/streamdeck-go-watchdog.service $(SYSTEMD_USER)/streamdeck-go-watchdog.service; \
install -m 644 systemd/streamdeck-go-watchdog.timer $(SYSTEMD_USER)/streamdeck-go-watchdog.timer; \
systemctl --user daemon-reload; \
systemctl --user restart streamdeck-go-watchdog.timer; \
echo " ✓ watchdog refreshed"; \
else \
echo " · watchdog not installed (skipping)"; \
fi
@if [ -f $(CONFIG_DIR)/modules.yaml ]; then \
install -m 644 modules.example.yaml $(CONFIG_DIR)/modules.yaml; \
echo " ✓ modules.yaml updated"; \
else \
echo " · config dir not set up (skipping modules.yaml)"; \
fi
endif
# ── Uninstall ───────────────────────────────────────────────────────────────── # ── Uninstall ─────────────────────────────────────────────────────────────────
ifeq ($(OS),Darwin) ifeq ($(OS),Darwin)
@@ -116,6 +235,12 @@ uninstall:
uninstall-helper: uninstall-helper:
@echo "No helper daemon on macOS — nothing to uninstall." @echo "No helper daemon on macOS — nothing to uninstall."
@echo "Whitelist at $(CONFIG_DIR)/privileged.yaml preserved." @echo "Whitelist at $(CONFIG_DIR)/privileged.yaml preserved."
uninstall-watchdog:
launchctl bootout gui/$$(id -u)/com.woodarddigital.streamdeck-go-watchdog 2>/dev/null || true
rm -f $(WATCHDOG_PLIST)
rm -f $(BIN_DIR)/streamdeck-go-watchdog
@echo "Watchdog uninstalled."
else else
uninstall: uninstall:
systemctl --user disable --now streamdeck-go.service || true systemctl --user disable --now streamdeck-go.service || true
@@ -124,6 +249,14 @@ uninstall:
systemctl --user daemon-reload systemctl --user daemon-reload
@echo "Uninstalled. Config at $(CONFIG_DIR) preserved." @echo "Uninstalled. Config at $(CONFIG_DIR) preserved."
uninstall-watchdog:
systemctl --user disable --now streamdeck-go-watchdog.timer || true
rm -f $(BIN_DIR)/streamdeck-go-watchdog
rm -f $(SYSTEMD_USER)/streamdeck-go-watchdog.service
rm -f $(SYSTEMD_USER)/streamdeck-go-watchdog.timer
systemctl --user daemon-reload
@echo "Watchdog uninstalled."
uninstall-helper: uninstall-helper:
sudo systemctl disable --now streamdeck-go-helper.service || true sudo systemctl disable --now streamdeck-go-helper.service || true
sudo rm -f $(SYS_BIN)/$(HELPER) sudo rm -f $(SYS_BIN)/$(HELPER)

207
README.md
View File

@@ -14,17 +14,18 @@ No Elgato software required — communicates directly with the device over USB H
- PNG, JPEG, and SVG icons, automatically scaled to key size - 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 - Animated GIF support — frames pre-encoded at startup, cycled at the GIF's native rate
- Runs any shell command on key press - 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 - **Status/toggle keys** — poll any shell command on an interval, swap icons based on output; icon updates on press. Either icon can be an animated GIF (e.g. a flashing record indicator when recording is active).
- **Live config reload** — save your config and the deck updates instantly, no restart needed - **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 - **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 - 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 - Runs as a systemd user service (Linux) or launchd agent (macOS), starts automatically at login
- No Stream Deck app, no Node.js, no Electron - 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). - **Text overlays** — add a `text` field to any key for auto-sized, word-wrapped labels; white by default, or set `text_color` to black, red, blue, or any `#RRGGBB` hex. Works with icons (overlaid), without icons (white text on black), and on toggle keys. See [Text overlays](#text-overlays).
- **Modules** — define reusable, parameterised commands in `modules.yaml` with Go templates; secrets stay in env vars, config stays in dotfiles. Built-in examples: Slack (status, presence, snooze) and OBS Studio (recording, streaming, scene switching, media control). See [Modules](#modules).
- **Interactive config builder** — TUI tool (`streamdeck-init`) that walks you through key setup: pick a slot, pick a module/function, customize params, choose an icon. No YAML editing required. See [Config builder](#config-builder). - **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) **Planned:** multi-page layouts, AUR package — see [Roadmap](#roadmap)
--- ---
@@ -69,6 +70,8 @@ streamdeck-go/
│ └──▶ registry.Resolve() → rendered shell command │ └──▶ registry.Resolve() → rendered shell command
│ (templates expanded, env vars resolved) │ (templates expanded, env vars resolved)
├── text overlay? ──▶ overlayText() (auto-size font, word wrap, outline)
├── static icon ──▶ device.SetKeyImage() (scale → flip → JPEG → HID) ├── static icon ──▶ device.SetKeyImage() (scale → flip → JPEG → HID)
├── animated GIF ──▶ device.EncodeFrame() (pre-encode all frames once) ├── animated GIF ──▶ device.EncodeFrame() (pre-encode all frames once)
@@ -96,7 +99,7 @@ interleave partial image data across keys.
|---|---| |---|---|
| [`github.com/sstallion/go-hid`](https://github.com/sstallion/go-hid) | Bindings for `libhidapi` — USB HID read/write | | [`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 | | [`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 | | [`golang.org/x/image`](https://pkg.go.dev/golang.org/x/image) | Bi-linear image scaling, font rendering (text overlays use embedded Go Bold font) |
| [`gopkg.in/yaml.v3`](https://pkg.go.dev/gopkg.in/yaml.v3) | YAML config parsing | | [`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 | | [`github.com/srwiley/oksvg`](https://github.com/srwiley/oksvg) | SVG rasterisation |
| Go stdlib `image/gif`, `image/jpeg`, `image/png` | Image decoding and JPEG encoding | | Go stdlib `image/gif`, `image/jpeg`, `image/png` | Image decoding and JPEG encoding |
@@ -123,7 +126,7 @@ interleave partial image data across keys.
# Prerequisites # Prerequisites
brew install go hidapi brew install go hidapi
git clone https://github.com/WoodardDigital/streamdeck-go git clone https://git.i0t.app/WoodardDigital/streamdeck-go
cd streamdeck-go cd streamdeck-go
make install make install
``` ```
@@ -151,7 +154,7 @@ directory integration.
# Prerequisites — Arch example; adjust for your distro (see table above) # Prerequisites — Arch example; adjust for your distro (see table above)
sudo pacman -S go hidapi sudo pacman -S go hidapi
git clone https://github.com/WoodardDigital/streamdeck-go git clone https://git.i0t.app/WoodardDigital/streamdeck-go
cd streamdeck-go cd streamdeck-go
make install make install
``` ```
@@ -215,7 +218,7 @@ sudo udevadm trigger
**2. Build and run:** **2. Build and run:**
```bash ```bash
git clone https://github.com/WoodardDigital/streamdeck-go git clone https://git.i0t.app/WoodardDigital/streamdeck-go
cd streamdeck-go cd streamdeck-go
cp config.example.yaml config.yaml cp config.example.yaml config.yaml
@@ -230,6 +233,33 @@ first (respecting `$XDG_CONFIG_HOME`). The repo's `config.yaml` is gitignored.
--- ---
### Updating — `make reinstall`
After pulling new code or editing a Go source file, refresh whatever's already
deployed without going through the full installer:
```bash
git pull
make reinstall
```
`reinstall` rebuilds the binary and refreshes only the pieces that are already
installed:
| Component | Action when present |
|-------------------|--------------------------------------------------------------------|
| Main binary | rebuilt and copied into `~/.local/bin` (Linux) or `~/go/bin` (macOS) |
| Service unit | systemd unit / launchd plist re-installed; service restarted |
| Helper (Linux) | rebuilt, re-installed under `/usr/local/bin`, helper service restarted (sudo) |
| Watchdog | script + unit/plist refreshed and timer restarted |
| `modules.yaml` | re-copied from `modules.example.yaml` into the active config dir |
Anything not currently installed prints `· skipped` instead of failing.
No dependency installs, no dotfile prompts, no symlink logic — that's still
`make install`'s job.
---
### AUR (Arch Linux) ### AUR (Arch Linux)
> AUR package coming soon. Until then, use `make install` above. > AUR package coming soon. Until then, use `make install` above.
@@ -403,6 +433,68 @@ keys:
--- ---
### Text overlays
Add a `text` field to any key to render a small label at the bottom of the key. The text is rendered at a fixed label size (~15pt at 96×96 key resolution), bottom-aligned so the icon stays visible above it. A contrasting outline is drawn automatically for readability on any background.
Text stays on a single line unless you add explicit line breaks with `\n` or YAML's `|` literal block syntax. If the text is too wide for the key, the font shrinks to fit. Multi-line text (up to 4-5 lines) is supported via explicit newlines.
The source image is scaled to key resolution (96×96 for XL, 72×72 for MK.2) before text is rendered, so label size is consistent regardless of source icon dimensions.
```yaml
keys:
# Label on an icon — single line, bottom-aligned
9:
icon: lock.png
text: "Lock Machine"
command: hyprctl dispatch exec omarchy-lock-screen
# Multi-line (explicit newlines only — no automatic word wrap)
16:
icon: server.png
text: |
Restart
Web Server
command: systemctl restart nginx
# Text-only key (no icon — white text on black background)
17:
text: "Build\nDeploy"
command: make deploy
# Custom text color
18:
icon: alert.png
text: "DANGER"
text_color: red
command: nuke-from-orbit
# Hex color
19:
icon: status.png
text: "Online"
text_color: "#00FF00"
command: ""
```
**`text_color`** is optional and defaults to white. Supported values:
| Value | Color |
|---|---|
| `white` (default) | White |
| `black` | Black |
| `red` | Red |
| `blue` | Blue |
| `#RRGGBB` | Any hex color |
Text overlays work on:
- **Static keys** — label at the bottom of the icon
- **Text-only keys** — no icon needed, renders on a black background
- **Toggle keys** — text appears on both `icon_true` and `icon_false`
- GIF keys do not currently support text overlays
---
### Launching applications ### 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. 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.
@@ -669,6 +761,68 @@ modules:
A full example is included at [`modules.example.yaml`](modules.example.yaml) in the repo root. A full example is included at [`modules.example.yaml`](modules.example.yaml) in the repo root.
#### OBS Studio module
The example `modules.yaml` also includes an `obs` module that drives OBS over WebSocket using [`obs-cmd`](https://github.com/grigio/obs-cmd).
**Setup:**
1. Install `obs-cmd`:
- macOS: `brew install grigio/obs-cmd/obs-cmd`
- Linux: `cargo install obs-cmd` or download a release binary
2. Requires **OBS 30.2 or newer** — earlier versions fail `obs-cmd`'s version check.
3. In OBS, enable **Tools → WebSocket Server Settings** and note the port + password.
4. Export env vars (see [Secrets and tokens](#secrets-and-tokens) for launchd/systemd setup):
```bash
export OBS_WEBSOCKET_PASSWORD="your-password"
export OBS_HOST="localhost" # or remote IP, e.g. 192.168.1.28
export OBS_PORT="4455"
```
**Functions:**
| Function | Purpose | Params |
|---|---|---|
| `play` / `pause` / `stop` / `restart` | Media input controls | `source` (default `"Media Source"`) |
| `toggle_record` | Start/stop recording | — |
| `toggle_record_pause` | Pause/resume recording | — |
| `toggle_stream` | Start/stop streaming | — |
| `scene_switch` | Switch active scene | `scene` (default `"Scene 1"`) |
| `toggle_mute` | Toggle input mute | `source` (default `"Mic/Aux"`) |
| `is_recording` | Status check for poll blocks | — |
| `is_recording_paused` | Status check for poll blocks | — |
| `is_streaming` | Status check for poll blocks | — |
**Example — record button with flashing GIF when active:**
```yaml
keys:
7:
icon_true: recording.gif # animated — flashes while recording
icon_false: camera.png # static — shown when stopped
module: obs
function: toggle_record
poll:
module: obs
function: is_recording
match: "Active: true" # obs-cmd output contains this when recording
interval: 2s
15:
icon_true: pause.svg
icon_false: play.svg
module: obs
function: toggle_record_pause
poll:
module: obs
function: is_recording_paused
match: "Paused: true"
interval: 2s
```
**Note — absolute paths in modules:** The example templates call `/usr/local/bin/obs-cmd` rather than just `obs-cmd`. This is because launchd (macOS) and systemd (Linux) give the service a minimal `PATH` that doesn't include `/usr/local/bin` or Homebrew. Use the absolute path returned by `which obs-cmd` in your own module templates, or set `PATH` in the launchd plist / systemd unit.
#### Using modules in config.yaml #### Using modules in config.yaml
Reference a module function instead of writing inline commands: Reference a module function instead of writing inline commands:
@@ -741,6 +895,36 @@ keys:
match: "snooze_enabled.*true" match: "snooze_enabled.*true"
``` ```
#### Adding your own modules
Modules are purely YAML — no Go code changes required.
1. Open `~/.config/streamdeck-go/modules.yaml`.
2. Add a new top-level key under `modules:` (e.g. `home_assistant`, `spotify`).
3. Define functions, each with an `exec` template and optional default `params`. Use `{{env "VAR"}}` for secrets and `{{.paramName}}` for parameters.
4. Save — the daemon reloads automatically.
5. Reference the new module from `config.yaml` with `module:` / `function:` / `params:`.
**Skeleton:**
```yaml
modules:
my_api:
do_thing:
params:
target: "default-value"
exec: |
curl -s -X POST https://example.com/api/thing \
-H "Authorization: Bearer {{env "MY_API_TOKEN"}}" \
-d '{"target":"{{.target}}"}'
```
**Tips:**
- If the template calls an external binary (like `curl`, `obs-cmd`, `osascript`), use the absolute path — service `PATH` is minimal.
- Chain multiple API calls with `&&` inside a single `exec` (see `go_offline` in the example).
- Status functions for poll blocks should output text matching a substring you set in the key's `poll.match`.
#### Template helpers #### Template helpers
Two helpers are available in `exec` templates: Two helpers are available in `exec` templates:
@@ -913,10 +1097,10 @@ To add a model, edit the `models` map in [internal/device/streamdeck.go](interna
## Roadmap ## Roadmap
### Text / label overlay on icons ### Dynamic text from command output
Render dynamic text directly onto a key image at runtime — useful for showing Extend the text overlay feature to render live output from a shell command —
live state like volume level, a clock, a counter, or the current git branch. useful for showing volume level, a clock, a counter, or the current git branch.
Example config (proposed): Example config (proposed):
@@ -924,8 +1108,7 @@ Example config (proposed):
keys: keys:
4: 4:
icon: volume.png icon: volume.png
label: "$(pactl get-sink-volume @DEFAULT_SINK@ | awk '{print $5}')" text: "$(pactl get-sink-volume @DEFAULT_SINK@ | awk '{print $5}')"
label_position: bottom # top | center | bottom
refresh: 5s refresh: 5s
``` ```

View File

@@ -29,7 +29,7 @@ import (
"fmt" "fmt"
"strings" "strings"
"github.com/WoodardDigital/streamdeck-go/internal/config" "git.i0t.app/lwoodard/streamdeck-go/internal/config"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )

View File

@@ -13,7 +13,7 @@
// 3. Renders an 8x4 key grid showing which slots are free vs occupied. // 3. Renders an 8x4 key grid showing which slots are free vs occupied.
// 4. Walks the user through a series of huh forms: // 4. Walks the user through a series of huh forms:
// - Pick a key slot (0-31) // - Pick a key slot (0-31)
// - Choose action type: module function or raw shell command // - Choose action type: module function, shell command, or toggle key (with poll)
// - If module: pick module → function → customize params (with defaults) // - If module: pick module → function → customize params (with defaults)
// - If command: type a shell command // - If command: type a shell command
// - Pick an icon from icons_dir (or type a custom filename) // - Pick an icon from icons_dir (or type a custom filename)
@@ -22,10 +22,10 @@
// //
// ## Expanding this tool // ## Expanding this tool
// //
// To add new TUI steps (e.g. toggle key setup with poll config, or a // To add new TUI steps (e.g. a "delete key" flow), add a new function
// "delete key" flow), add a new function following the pattern of // following the pattern of configureModuleKey/configureCommandKey/
// configureModuleKey/configureCommandKey: build huh forms, collect values // configureToggleKey: build huh forms, collect values into a keyEntry,
// into a keyEntry, return it for the confirm/append step. // return it for the confirm/append step.
// //
// To support new key types in the YAML output, update keyEntry and // To support new key types in the YAML output, update keyEntry and
// renderKeySnippet() in yaml.go. // renderKeySnippet() in yaml.go.
@@ -51,9 +51,9 @@ import (
"sort" "sort"
"strings" "strings"
"github.com/WoodardDigital/streamdeck-go/internal/config" "git.i0t.app/lwoodard/streamdeck-go/internal/config"
"github.com/WoodardDigital/streamdeck-go/internal/defaults" "git.i0t.app/lwoodard/streamdeck-go/internal/defaults"
"github.com/WoodardDigital/streamdeck-go/internal/modules" "git.i0t.app/lwoodard/streamdeck-go/internal/modules"
"github.com/charmbracelet/huh" "github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
@@ -185,29 +185,51 @@ func runOnce(cfgPath string) error {
var entry keyEntry var entry keyEntry
entry.Index = keyIdx entry.Index = keyIdx
if actionType == "module" { switch actionType {
// Module path: module → function → params (with defaults pre-filled). case "module":
entry, err = configureModuleKey(keyIdx, reg) entry, err = configureModuleKey(keyIdx, reg)
} else { case "toggle":
// Command path: just a shell command string. entry, err = configureToggleKey(keyIdx, reg)
default:
entry, err = configureCommandKey(keyIdx) entry, err = configureCommandKey(keyIdx)
} }
if err != nil { if err != nil {
return err return err
} }
// Step 4: Pick an icon file. // Step 4: Pick icon(s).
if entry.IsToggle {
fmt.Println(lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")).Render(" Icon for ON state:"))
iconTrue, err := pickIcon(cfg.IconsDir)
if err != nil {
return err
}
entry.IconTrue = iconTrue
fmt.Println(lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")).Render(" Icon for OFF state:"))
iconFalse, err := pickIcon(cfg.IconsDir)
if err != nil {
return err
}
entry.IconFalse = iconFalse
} else {
icon, err := pickIcon(cfg.IconsDir) icon, err := pickIcon(cfg.IconsDir)
if err != nil { if err != nil {
return err return err
} }
entry.Icon = icon entry.Icon = icon
}
// Step 5: Show summary and confirm before writing. // Step 5: Show summary and confirm before writing.
fmt.Println() fmt.Println()
fmt.Println(lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")).Render(" Summary:")) fmt.Println(lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")).Render(" Summary:"))
fmt.Printf(" Key: %d\n", entry.Index) fmt.Printf(" Key: %d\n", entry.Index)
if entry.IsToggle {
fmt.Printf(" Icon ON: %s\n", entry.IconTrue)
fmt.Printf(" Icon OFF: %s\n", entry.IconFalse)
} else {
fmt.Printf(" Icon: %s\n", entry.Icon) fmt.Printf(" Icon: %s\n", entry.Icon)
}
if entry.Module != "" { if entry.Module != "" {
fmt.Printf(" Module: %s\n", entry.Module) fmt.Printf(" Module: %s\n", entry.Module)
fmt.Printf(" Function: %s\n", entry.Function) fmt.Printf(" Function: %s\n", entry.Function)
@@ -220,6 +242,27 @@ func runOnce(cfgPath string) error {
} else { } else {
fmt.Printf(" Command: %s\n", entry.Command) fmt.Printf(" Command: %s\n", entry.Command)
} }
if entry.IsToggle {
fmt.Println(" Poll:")
if entry.PollModule != "" {
fmt.Printf(" Module: %s\n", entry.PollModule)
fmt.Printf(" Function: %s\n", entry.PollFunction)
if len(entry.PollParams) > 0 {
fmt.Println(" Params:")
for k, v := range entry.PollParams {
fmt.Printf(" %s: %s\n", k, v)
}
}
} else {
fmt.Printf(" Command: %s\n", entry.PollCommand)
}
if entry.PollMatch != "" {
fmt.Printf(" Match: %s\n", entry.PollMatch)
}
if entry.PollInterval != "" {
fmt.Printf(" Interval: %s\n", entry.PollInterval)
}
}
fmt.Println() fmt.Println()
var confirm bool var confirm bool
@@ -281,10 +324,8 @@ func pickKeySlot(keys map[int]config.KeyConfig) (int, error) {
return keyIdx, nil return keyIdx, nil
} }
// pickActionType asks whether to configure a module function or a raw shell command. // pickActionType asks whether to configure a module function, a raw shell command,
// // or a toggle key with poll-based state checking.
// FUTURE: add "Toggle key (with poll)" as a third option, which would walk
// through icon_true/icon_false and poll config setup.
func pickActionType() (string, error) { func pickActionType() (string, error) {
var actionType string var actionType string
form := huh.NewForm( form := huh.NewForm(
@@ -292,8 +333,9 @@ func pickActionType() (string, error) {
huh.NewSelect[string](). huh.NewSelect[string]().
Title("What should this key do?"). Title("What should this key do?").
Options( Options(
huh.NewOption("Module function (Slack, etc.)", "module"), huh.NewOption("Module function (Slack, OBS, etc.)", "module"),
huh.NewOption("Shell command", "command"), huh.NewOption("Shell command", "command"),
huh.NewOption("Toggle key (with poll)", "toggle"),
). ).
Value(&actionType), Value(&actionType),
), ),
@@ -434,6 +476,123 @@ func configureCommandKey(keyIdx int) (keyEntry, error) {
return entry, nil return entry, nil
} }
// configureToggleKey walks the user through setting up a toggle key:
// press action (module or command) + poll configuration (module or command,
// match string, interval). Icons are handled separately in runOnce().
func configureToggleKey(keyIdx int, reg *modules.Registry) (keyEntry, error) {
entry := keyEntry{Index: keyIdx, IsToggle: true}
// Ask what happens when the key is pressed.
var pressType string
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("What should pressing this key do?").
Options(
huh.NewOption("Module function", "module"),
huh.NewOption("Shell command", "command"),
).
Value(&pressType),
),
)
if err := form.Run(); err != nil {
return entry, err
}
if pressType == "module" {
modEntry, err := configureModuleKey(keyIdx, reg)
if err != nil {
return entry, err
}
entry.Module = modEntry.Module
entry.Function = modEntry.Function
entry.Params = modEntry.Params
} else {
cmdEntry, err := configureCommandKey(keyIdx)
if err != nil {
return entry, err
}
entry.Command = cmdEntry.Command
}
// Configure the poll block.
if err := configurePoll(&entry, reg); err != nil {
return entry, err
}
return entry, nil
}
// configurePoll walks through poll configuration for a toggle key: how to check
// state (module function or shell command), optional match string, and interval.
func configurePoll(entry *keyEntry, reg *modules.Registry) error {
var pollType string
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("How should the key check its state?").
Options(
huh.NewOption("Module function", "module"),
huh.NewOption("Shell command", "command"),
).
Value(&pollType),
),
)
if err := form.Run(); err != nil {
return err
}
if pollType == "module" {
modEntry, err := configureModuleKey(entry.Index, reg)
if err != nil {
return err
}
entry.PollModule = modEntry.Module
entry.PollFunction = modEntry.Function
entry.PollParams = modEntry.Params
} else {
var cmd string
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Poll command").
Description("Shell command to check key state").
Value(&cmd).
Placeholder("e.g. pgrep -x myapp"),
),
)
if err := form.Run(); err != nil {
return err
}
entry.PollCommand = cmd
}
// Match string and interval.
var match, interval string
interval = "2s"
form = huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Match string").
Description("Substring in output that means ON (leave empty to use exit code)").
Value(&match).
Placeholder("optional"),
huh.NewInput().
Title("Poll interval").
Description("How often to check state").
Value(&interval).
Placeholder("2s"),
),
)
if err := form.Run(); err != nil {
return err
}
entry.PollMatch = match
entry.PollInterval = interval
return nil
}
// pickIcon lets the user select an icon file. // pickIcon lets the user select an icon file.
// //
// If icons_dir has image files, shows a select list of them plus a "type custom" // If icons_dir has image files, shows a select list of them plus a "type custom"

View File

@@ -31,8 +31,8 @@
// //
// ## Expanding // ## Expanding
// //
// To support toggle keys (icon_true/icon_false + poll block), add those fields // Toggle keys (icon_true/icon_false + poll block) are supported — see IsToggle
// to keyEntry and extend renderKeySnippet() to emit the extra YAML lines. // and the Poll* fields on keyEntry, and the toggle branch in renderKeySnippet().
// //
// To support deleting or editing existing keys, you'd need a different strategy // To support deleting or editing existing keys, you'd need a different strategy
// (e.g. yaml.v3 node-level manipulation). That's not in scope for the current // (e.g. yaml.v3 node-level manipulation). That's not in scope for the current
@@ -51,9 +51,11 @@ import (
// keyEntry holds the data collected from the TUI forms, ready to be rendered // keyEntry holds the data collected from the TUI forms, ready to be rendered
// as a YAML snippet and appended to config.yaml. // as a YAML snippet and appended to config.yaml.
// //
// Two mutually exclusive modes: // Three mutually exclusive modes:
// - Module key: Module + Function + Params are set, Command is empty // - Module key: Module + Function + Params are set, Command is empty
// - Command key: Command is set, Module/Function/Params are empty // - Command key: Command is set, Module/Function/Params are empty
// - Toggle key: IsToggle is true, IconTrue/IconFalse replace Icon,
// and Poll* fields define state checking. Press action uses Module or Command.
type keyEntry struct { type keyEntry struct {
Index int // key slot (0-31) Index int // key slot (0-31)
Icon string // icon filename relative to icons_dir Icon string // icon filename relative to icons_dir
@@ -61,6 +63,17 @@ type keyEntry struct {
Function string // function name within the module (e.g. "set_status") Function string // function name within the module (e.g. "set_status")
Params map[string]string // param overrides (merged with module defaults at runtime) Params map[string]string // param overrides (merged with module defaults at runtime)
Command string // raw shell command (non-module keys only) Command string // raw shell command (non-module keys only)
// Toggle key fields
IsToggle bool
IconTrue string // icon when poll state is true/on
IconFalse string // icon when poll state is false/off
PollCommand string // shell command to check state
PollModule string // module for poll (alternative to PollCommand)
PollFunction string // function for poll
PollParams map[string]string // poll param overrides
PollMatch string // substring match in output -> true state
PollInterval string // poll frequency (e.g. "2s")
} }
// appendKeyToConfig reads config.yaml, appends a key entry as raw YAML text, // appendKeyToConfig reads config.yaml, appends a key entry as raw YAML text,
@@ -123,7 +136,13 @@ func renderKeySnippet(e keyEntry) string {
var b strings.Builder var b strings.Builder
fmt.Fprintf(&b, " %d:\n", e.Index) fmt.Fprintf(&b, " %d:\n", e.Index)
if e.IsToggle {
fmt.Fprintf(&b, " icon_true: %s\n", e.IconTrue)
fmt.Fprintf(&b, " icon_false: %s\n", e.IconFalse)
} else {
fmt.Fprintf(&b, " icon: %s\n", e.Icon) fmt.Fprintf(&b, " icon: %s\n", e.Icon)
}
if e.Module != "" { if e.Module != "" {
fmt.Fprintf(&b, " module: %s\n", e.Module) fmt.Fprintf(&b, " module: %s\n", e.Module)
@@ -144,5 +163,32 @@ func renderKeySnippet(e keyEntry) string {
fmt.Fprintf(&b, " command: \"%s\"\n", e.Command) fmt.Fprintf(&b, " command: \"%s\"\n", e.Command)
} }
if e.IsToggle {
b.WriteString(" poll:\n")
if e.PollModule != "" {
fmt.Fprintf(&b, " module: %s\n", e.PollModule)
fmt.Fprintf(&b, " function: %s\n", e.PollFunction)
if len(e.PollParams) > 0 {
b.WriteString(" params:\n")
pkeys := make([]string, 0, len(e.PollParams))
for k := range e.PollParams {
pkeys = append(pkeys, k)
}
sort.Strings(pkeys)
for _, k := range pkeys {
fmt.Fprintf(&b, " %s: \"%s\"\n", k, e.PollParams[k])
}
}
} else {
fmt.Fprintf(&b, " command: \"%s\"\n", e.PollCommand)
}
if e.PollMatch != "" {
fmt.Fprintf(&b, " match: \"%s\"\n", e.PollMatch)
}
if e.PollInterval != "" {
fmt.Fprintf(&b, " interval: \"%s\"\n", e.PollInterval)
}
}
return b.String() return b.String()
} }

View File

@@ -3,10 +3,12 @@ package main
import ( import (
"bufio" "bufio"
"context" "context"
"encoding/hex"
"encoding/json" "encoding/json"
"flag" "flag"
"fmt" "fmt"
"image" "image"
"image/color"
"image/draw" "image/draw"
"image/gif" "image/gif"
_ "image/jpeg" _ "image/jpeg"
@@ -21,12 +23,17 @@ import (
"sync" "sync"
"time" "time"
"github.com/WoodardDigital/streamdeck-go/internal/config" "git.i0t.app/lwoodard/streamdeck-go/internal/config"
"github.com/WoodardDigital/streamdeck-go/internal/device" "git.i0t.app/lwoodard/streamdeck-go/internal/device"
"github.com/WoodardDigital/streamdeck-go/internal/modules" "git.i0t.app/lwoodard/streamdeck-go/internal/modules"
"github.com/fsnotify/fsnotify" "github.com/fsnotify/fsnotify"
"github.com/srwiley/oksvg" "github.com/srwiley/oksvg"
"github.com/srwiley/rasterx" "github.com/srwiley/rasterx"
xdraw "golang.org/x/image/draw"
"golang.org/x/image/font"
"golang.org/x/image/font/gofont/gobold"
"golang.org/x/image/font/opentype"
"golang.org/x/image/math/fixed"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
@@ -47,6 +54,14 @@ func main() {
log.Fatalf("setup: %v", err) log.Fatalf("setup: %v", err)
} }
// Load .env file from the config directory so secrets (tokens, passwords)
// are available via {{env "VAR"}} in module templates without requiring
// shell exports or service-level environment configuration.
envPath := filepath.Join(filepath.Dir(*cfgPath), ".env")
if err := loadEnvFile(envPath); err != nil {
log.Printf("warn: %v", err)
}
cfg, err := config.Load(*cfgPath) cfg, err := config.Load(*cfgPath)
if err != nil { if err != nil {
log.Fatalf("config: %v", err) log.Fatalf("config: %v", err)
@@ -215,6 +230,14 @@ func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *mo
// Regular key: load icon once. // Regular key: load icon once.
if keyCfg.Icon == "" { if keyCfg.Icon == "" {
// No icon — render text-only key on a black background.
if keyCfg.Text != "" {
bg := image.NewRGBA(image.Rect(0, 0, sd.ImageWidth(), sd.ImageHeight()))
img := overlayText(bg, keyCfg.Text, keyCfg.TextColor, sd.ImageWidth())
if err := sd.SetKeyImage(keyIdx, img); err != nil {
log.Printf("key %d: set image: %v", keyIdx, err)
}
}
continue continue
} }
iconPath := filepath.Join(cfg.IconsDir, keyCfg.Icon) iconPath := filepath.Join(cfg.IconsDir, keyCfg.Icon)
@@ -241,6 +264,9 @@ func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *mo
log.Printf("key %d: load icon %q: %v", keyIdx, keyCfg.Icon, err) log.Printf("key %d: load icon %q: %v", keyIdx, keyCfg.Icon, err)
continue continue
} }
if keyCfg.Text != "" {
img = overlayText(img, keyCfg.Text, keyCfg.TextColor, sd.ImageWidth())
}
if err := sd.SetKeyImage(keyIdx, img); err != nil { if err := sd.SetKeyImage(keyIdx, img); err != nil {
log.Printf("key %d: set image: %v", keyIdx, err) log.Printf("key %d: set image: %v", keyIdx, err)
} }
@@ -321,33 +347,103 @@ func pollKey(ctx context.Context, sd *device.StreamDeck, keyIdx int, keyCfg conf
} }
} }
imgTrue, err := loadImage(filepath.Join(iconsDir, keyCfg.IconTrue)) // Check if icon_true is an animated GIF.
trueIsGIF := strings.ToLower(filepath.Ext(keyCfg.IconTrue)) == ".gif"
falseIsGIF := strings.ToLower(filepath.Ext(keyCfg.IconFalse)) == ".gif"
// Pre-load GIF frames for whichever icons are GIFs.
var trueFrames [][]byte
var trueDelays []time.Duration
var falseFrames [][]byte
var falseDelays []time.Duration
var imgTrue, imgFalse image.Image
if trueIsGIF {
var err error
trueFrames, trueDelays, err = loadGIF(sd, filepath.Join(iconsDir, keyCfg.IconTrue))
if err != nil {
log.Printf("key %d: load icon_true gif %q: %v", keyIdx, keyCfg.IconTrue, err)
return
}
} else {
var err error
imgTrue, err = loadImage(filepath.Join(iconsDir, keyCfg.IconTrue))
if err != nil { if err != nil {
log.Printf("key %d: load icon_true %q: %v", keyIdx, keyCfg.IconTrue, err) log.Printf("key %d: load icon_true %q: %v", keyIdx, keyCfg.IconTrue, err)
return return
} }
imgFalse, err := loadImage(filepath.Join(iconsDir, keyCfg.IconFalse)) }
if falseIsGIF {
var err error
falseFrames, falseDelays, err = loadGIF(sd, filepath.Join(iconsDir, keyCfg.IconFalse))
if err != nil {
log.Printf("key %d: load icon_false gif %q: %v", keyIdx, keyCfg.IconFalse, err)
return
}
} else {
var err error
imgFalse, err = loadImage(filepath.Join(iconsDir, keyCfg.IconFalse))
if err != nil { if err != nil {
log.Printf("key %d: load icon_false %q: %v", keyIdx, keyCfg.IconFalse, err) log.Printf("key %d: load icon_false %q: %v", keyIdx, keyCfg.IconFalse, err)
return return
} }
}
if keyCfg.Text != "" {
imgTrue = overlayText(imgTrue, keyCfg.Text, keyCfg.TextColor, sd.ImageWidth())
imgFalse = overlayText(imgFalse, keyCfg.Text, keyCfg.TextColor, sd.ImageWidth())
}
lastState := -1 // unknown, forces initial icon set lastState := -1 // unknown, forces initial icon set
var animCancel context.CancelFunc
var animWg sync.WaitGroup
stopAnim := func() {
if animCancel != nil {
animCancel()
animWg.Wait()
animCancel = nil
}
}
applyState := func() { applyState := func() {
state := queryPollState(keyCfg.Poll) state := queryPollState(keyCfg.Poll)
if state == lastState { if state == lastState {
return return
} }
stopAnim()
lastState = state lastState = state
img := imgFalse
if state == 1 { if state == 1 {
img = imgTrue if trueIsGIF {
} animCtx, cancel := context.WithCancel(ctx)
if err := sd.SetKeyImage(keyIdx, img); err != nil { animCancel = cancel
animWg.Add(1)
go func() {
defer animWg.Done()
animateKey(animCtx, sd, keyIdx, trueFrames, trueDelays)
}()
} else {
if err := sd.SetKeyImage(keyIdx, imgTrue); err != nil {
log.Printf("key %d: set poll icon: %v", keyIdx, err) log.Printf("key %d: set poll icon: %v", keyIdx, err)
} }
} }
} else {
if falseIsGIF {
animCtx, cancel := context.WithCancel(ctx)
animCancel = cancel
animWg.Add(1)
go func() {
defer animWg.Done()
animateKey(animCtx, sd, keyIdx, falseFrames, falseDelays)
}()
} else {
if err := sd.SetKeyImage(keyIdx, imgFalse); err != nil {
log.Printf("key %d: set poll icon: %v", keyIdx, err)
}
}
}
}
applyState() // set icon immediately on startup applyState() // set icon immediately on startup
@@ -357,6 +453,7 @@ func pollKey(ctx context.Context, sd *device.StreamDeck, keyIdx int, keyCfg conf
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
stopAnim()
return return
case <-ticker.C: case <-ticker.C:
applyState() applyState()
@@ -364,6 +461,7 @@ func pollKey(ctx context.Context, sd *device.StreamDeck, keyIdx int, keyCfg conf
// Wait briefly for the toggle command to take effect, then re-poll. // Wait briefly for the toggle command to take effect, then re-poll.
select { select {
case <-ctx.Done(): case <-ctx.Done():
stopAnim()
return return
case <-time.After(400 * time.Millisecond): case <-time.After(400 * time.Millisecond):
} }
@@ -377,7 +475,7 @@ func pollKey(ctx context.Context, sd *device.StreamDeck, keyIdx int, keyCfg conf
// If Match is empty, uses exit code: 0 → on, non-zero → off. // If Match is empty, uses exit code: 0 → on, non-zero → off.
func queryPollState(poll *config.PollConfig) int { func queryPollState(poll *config.PollConfig) int {
cmd := exec.Command("sh", "-c", poll.Command) cmd := exec.Command("sh", "-c", poll.Command)
output, err := cmd.Output() output, err := cmd.CombinedOutput()
if poll.Match == "" { if poll.Match == "" {
if err == nil { if err == nil {
return 1 return 1
@@ -434,18 +532,10 @@ func animateKey(ctx context.Context, sd *device.StreamDeck, keyIdx int, frames [
} }
if err := sd.SetKeyFrame(keyIdx, frame); err != nil { if err := sd.SetKeyFrame(keyIdx, frame); err != nil {
writeErrors++ writeErrors++
if writeErrors == 1 { if writeErrors == 50 {
// Log the first error so the user knows something happened.
log.Printf("key %d: frame write error: %v", keyIdx, err)
} else if writeErrors == 50 {
// Persistent errors — likely a real device issue, not transient.
log.Printf("key %d: %d consecutive frame write errors — device may be unhealthy", keyIdx, writeErrors) log.Printf("key %d: %d consecutive frame write errors — device may be unhealthy", keyIdx, writeErrors)
} }
} else if writeErrors > 0 { } else if writeErrors > 0 {
// Recovered — log a summary if we suppressed errors.
if writeErrors > 1 {
log.Printf("key %d: recovered after %d frame write errors", keyIdx, writeErrors)
}
writeErrors = 0 writeErrors = 0
} }
select { select {
@@ -487,6 +577,169 @@ func loadGIF(sd *device.StreamDeck, path string) (frames [][]byte, delays []time
return frames, delays, nil return frames, delays, nil
} }
// --- Text overlay ---
var (
parsedFont *opentype.Font
fontOnce sync.Once
)
func getFont() *opentype.Font {
fontOnce.Do(func() {
f, err := opentype.Parse(gobold.TTF)
if err != nil {
log.Fatalf("parse embedded font: %v", err)
}
parsedFont = f
})
return parsedFont
}
// parseTextColor converts a color name or hex string to a color.Color.
// Supported names: white (default), black, red, blue. Hex: "#RRGGBB".
func parseTextColor(s string) color.Color {
switch strings.ToLower(strings.TrimSpace(s)) {
case "", "white":
return color.White
case "black":
return color.Black
case "red":
return color.RGBA{R: 255, A: 255}
case "blue":
return color.RGBA{B: 255, A: 255}
default:
s = strings.TrimPrefix(s, "#")
if len(s) == 6 {
b, err := hex.DecodeString(s)
if err == nil && len(b) == 3 {
return color.RGBA{R: b[0], G: b[1], B: b[2], A: 255}
}
}
log.Printf("unknown text_color %q, using white", s)
return color.White
}
}
// contrastColor returns black or white depending on which contrasts better
// with the given color. Used for the text outline/shadow.
func contrastColor(c color.Color) color.Color {
r, g, b, _ := c.RGBA()
// Perceived luminance (values are 16-bit, so divide by 257 to get 8-bit).
lum := 0.299*float64(r/257) + 0.587*float64(g/257) + 0.114*float64(b/257)
if lum > 128 {
return color.Black
}
return color.White
}
// splitLines splits text on explicit newlines only. No automatic word wrapping.
func splitLines(text string) []string {
return strings.Split(text, "\n")
}
// overlayText renders text onto img at keySize resolution with auto-sizing.
// The image is first scaled to keySize×keySize so font sizes are consistent
// regardless of source image dimensions.
// textColor is parsed from the config; shadow/outline uses the contrasting color.
func overlayText(img image.Image, text, textColorStr string, keySize int) image.Image {
if text == "" {
return img
}
// Scale to key resolution so font sizing is consistent.
dst := image.NewRGBA(image.Rect(0, 0, keySize, keySize))
xdraw.BiLinear.Scale(dst, dst.Bounds(), img, img.Bounds(), xdraw.Over, nil)
w, h := keySize, keySize
f := getFont()
marginX := int(float64(w) * 0.06)
marginY := int(float64(h) * 0.06)
availW := w - 2*marginX
availH := h - 2*marginY
// Split on explicit newlines only — no automatic word wrapping.
lines := splitLines(text)
// Find the largest font size where all lines fit.
// Cap at h*0.16 (~15pt on 96px key) so text stays label-sized.
var bestFace font.Face
maxSize := float64(h) * 0.16
const minSize = 6.0
for size := maxSize; size >= minSize; size -= 0.5 {
face, err := opentype.NewFace(f, &opentype.FaceOptions{
Size: size,
DPI: 72,
Hinting: font.HintingFull,
})
if err != nil {
continue
}
lineH := face.Metrics().Height.Ceil()
fits := lineH*len(lines) <= availH
if fits {
for _, line := range lines {
if font.MeasureString(face, line).Ceil() > availW {
fits = false
break
}
}
}
if fits {
bestFace = face
break
}
}
if bestFace == nil {
bestFace, _ = opentype.NewFace(f, &opentype.FaceOptions{
Size: minSize, DPI: 72, Hinting: font.HintingFull,
})
}
metrics := bestFace.Metrics()
lineH := metrics.Height.Ceil()
totalH := lineH * len(lines)
// Bottom-align so the icon stays visible above.
startY := h - marginY - totalH + metrics.Ascent.Ceil()
txtColor := parseTextColor(textColorStr)
shadow := contrastColor(txtColor)
// Outline offsets for readability on any background.
offsets := [8]image.Point{
{-1, -1}, {0, -1}, {1, -1},
{-1, 0}, {1, 0},
{-1, 1}, {0, 1}, {1, 1},
}
for i, line := range lines {
adv := font.MeasureString(bestFace, line)
x := (w - adv.Ceil()) / 2
y := startY + i*lineH
// Draw outline.
for _, off := range offsets {
d := &font.Drawer{
Dst: dst,
Src: image.NewUniform(shadow),
Face: bestFace,
Dot: fixed.P(x+off.X, y+off.Y),
}
d.DrawString(line)
}
// Draw text.
d := &font.Drawer{
Dst: dst,
Src: image.NewUniform(txtColor),
Face: bestFace,
Dot: fixed.P(x, y),
}
d.DrawString(line)
}
return dst
}
func loadImage(path string) (image.Image, error) { func loadImage(path string) (image.Image, error) {
if strings.ToLower(filepath.Ext(path)) == ".svg" { if strings.ToLower(filepath.Ext(path)) == ".svg" {
return loadSVG(path) return loadSVG(path)
@@ -501,7 +754,7 @@ func loadImage(path string) (image.Image, error) {
} }
func loadSVG(path string) (image.Image, error) { func loadSVG(path string) (image.Image, error) {
icon, err := oksvg.ReadIcon(path, oksvg.StrictErrorMode) icon, err := oksvg.ReadIcon(path, oksvg.IgnoreErrorMode)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -617,6 +870,39 @@ func loadPrivilegedCommands(path string) (map[string]string, error) {
return wl.Commands, nil return wl.Commands, nil
} }
// loadEnvFile reads a .env file and sets each KEY=VALUE pair in the process
// environment. Blank lines and lines starting with # are ignored. Quoted values
// (single or double) are unquoted. A missing file is silently ignored.
func loadEnvFile(path string) error {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("load env %q: %w", path, err)
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, val, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
val = strings.TrimSpace(val)
// Strip matching quotes around value.
if len(val) >= 2 &&
((val[0] == '"' && val[len(val)-1] == '"') ||
(val[0] == '\'' && val[len(val)-1] == '\'')) {
val = val[1 : len(val)-1]
}
os.Setenv(key, val)
}
return nil
}
func defaultConfigPath() string { func defaultConfigPath() string {
return config.DefaultConfigPath() return config.DefaultConfigPath()
} }
@@ -635,7 +921,7 @@ func ensureConfigDir(cfgPath string) error {
func defaultConfig(iconsDir string) string { func defaultConfig(iconsDir string) string {
return `# streamdeck-go configuration return `# streamdeck-go configuration
# https://github.com/WoodardDigital/streamdeck-go # https://git.i0t.app/lwoodard/streamdeck-go
icons_dir: ` + iconsDir + ` icons_dir: ` + iconsDir + `
brightness: 70 brightness: 70

2
go.mod
View File

@@ -1,4 +1,4 @@
module github.com/WoodardDigital/streamdeck-go module git.i0t.app/lwoodard/streamdeck-go
go 1.25.0 go 1.25.0

View File

@@ -219,6 +219,46 @@ else
exit 1 exit 1
fi fi
fi fi
# obs-cmd (optional — required for OBS module)
if command -v obs-cmd &>/dev/null; then
ok "obs-cmd found"
else
info "obs-cmd not found (required for OBS Studio module)"
if prompt_yn "Install obs-cmd now?" "y"; then
OBS_CMD_VERSION=$(curl -sL "https://api.github.com/repos/grigio/obs-cmd/releases/latest" | grep '"tag_name"' | head -1 | sed 's/.*"v\(.*\)".*/\1/')
if [[ -z "$OBS_CMD_VERSION" ]]; then
warn "Could not fetch obs-cmd release — install manually: cargo install obs-cmd"
elif $IS_MAC; then
# Detect architecture — Apple Silicon vs Intel
ARCH="$(uname -m)"
if [[ "$ARCH" == "arm64" ]]; then
OBS_CMD_ASSET="obs-cmd-arm64-macos.tar.gz"
else
OBS_CMD_ASSET="obs-cmd-x64-macos.tar.gz"
fi
step "Downloading obs-cmd v${OBS_CMD_VERSION} (${ARCH})..."
curl -sL "https://github.com/grigio/obs-cmd/releases/download/v${OBS_CMD_VERSION}/${OBS_CMD_ASSET}" \
| tar xz -C /tmp
install -m 755 /tmp/obs-cmd /usr/local/bin/obs-cmd
rm -f /tmp/obs-cmd
else
# Linux — x86_64 static binary
step "Downloading obs-cmd v${OBS_CMD_VERSION}..."
curl -sL "https://github.com/grigio/obs-cmd/releases/download/v${OBS_CMD_VERSION}/obs-cmd-x64-linux.tar.gz" \
| tar xz -C /tmp
sudo install -m 755 /tmp/obs-cmd /usr/local/bin/obs-cmd
rm -f /tmp/obs-cmd
fi
if command -v obs-cmd &>/dev/null; then
ok "obs-cmd installed"
else
warn "obs-cmd install may have failed — OBS module won't work until it's available"
fi
else
info "Skipped — OBS module will not work until obs-cmd is installed"
fi
fi
nl nl
# ── 2. Build ─────────────────────────────────────────────────────────────────── # ── 2. Build ───────────────────────────────────────────────────────────────────
@@ -328,6 +368,13 @@ else
ok "config.yaml already exists — not overwritten" ok "config.yaml already exists — not overwritten"
fi fi
# Always install/update modules.yaml — this is a registry of available module
# definitions, not user data. User customisations go in config.yaml (params
# overrides per key). Keeping modules.yaml current ensures new modules (OBS,
# Slack, etc.) are available immediately after upgrade.
install -m 644 modules.example.yaml "${CONFIG_DIR}/modules.yaml"
ok "modules.yaml installed (updated to latest)"
# Copy bundled icons (never overwrite existing ones the user may have customised). # Copy bundled icons (never overwrite existing ones the user may have customised).
if [[ -d "icons" ]]; then if [[ -d "icons" ]]; then
copied=0 copied=0
@@ -402,6 +449,33 @@ else
ok "Service enabled and started" ok "Service enabled and started"
fi fi
# ── 9. Watchdog ────────────────────────────────────────────────────────────────
nl
step "Installing watchdog (USB unplug/replug recovery)..."
if $IS_MAC; then
WATCHDOG_BIN="${BIN_DIR}/streamdeck-go-watchdog"
WATCHDOG_PLIST_LABEL="com.woodarddigital.streamdeck-go-watchdog"
WATCHDOG_PLIST="${LAUNCHAGENTS_DIR}/${WATCHDOG_PLIST_LABEL}.plist"
WATCHDOG_LOG="${HOME}/Library/Logs/streamdeck-go-watchdog.log"
install -m 755 systemd/streamdeck-go-watchdog.sh "${WATCHDOG_BIN}"
sed \
-e "s|STREAMDECK_WATCHDOG_PATH|${WATCHDOG_BIN}|g" \
-e "s|STREAMDECK_WATCHDOG_LOG_PATH|${WATCHDOG_LOG}|g" \
launchd/com.woodarddigital.streamdeck-go-watchdog.plist \
> "${WATCHDOG_PLIST}"
launchctl bootout "gui/$(id -u)/${WATCHDOG_PLIST_LABEL}" 2>/dev/null || true
launchctl bootstrap "gui/$(id -u)" "${WATCHDOG_PLIST}"
ok "Watchdog loaded — fires every 30s"
else
install_file 755 systemd/streamdeck-go-watchdog.sh "${BIN_DIR}/streamdeck-go-watchdog"
install_file 644 systemd/streamdeck-go-watchdog.service "${SYSTEMD_USER}/streamdeck-go-watchdog.service"
install_file 644 systemd/streamdeck-go-watchdog.timer "${SYSTEMD_USER}/streamdeck-go-watchdog.timer"
systemctl --user daemon-reload
systemctl --user enable --now streamdeck-go-watchdog.timer
ok "Watchdog timer enabled — fires every 30s"
fi
# ── Done ─────────────────────────────────────────────────────────────────────── # ── Done ───────────────────────────────────────────────────────────────────────
nl nl
echo -e " ${DIM}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" echo -e " ${DIM}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"

View File

@@ -23,6 +23,8 @@ type PollConfig struct {
// KeyConfig defines what a single Stream Deck key does. // KeyConfig defines what a single Stream Deck key does.
type KeyConfig struct { type KeyConfig struct {
Icon string `yaml:"icon"` // filename relative to icons_dir (regular keys) Icon string `yaml:"icon"` // filename relative to icons_dir (regular keys)
Text string `yaml:"text"` // text overlay on the key (auto-sized, supports newlines)
TextColor string `yaml:"text_color"` // text color: "white" (default), "black", "red", "blue", or hex "#RRGGBB"
Command string `yaml:"command"` // shell command to run on press Command string `yaml:"command"` // shell command to run on press
// Toggle/status keys: show different icons based on polled state. // Toggle/status keys: show different icons based on polled state.

View File

@@ -56,3 +56,129 @@ modules:
exec: | exec: |
curl -s -X POST https://slack.com/api/dnd.endSnooze \ curl -s -X POST https://slack.com/api/dnd.endSnooze \
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" -H "Authorization: Bearer {{env "SLACK_TOKEN"}}"
# OBS Studio — media player, streaming, and scene/transition control via obs-cmd
#
# Requires: obs-cmd (https://github.com/grigio/obs-cmd)
# macOS (Intel): brew install grigio/obs-cmd/obs-cmd → /usr/local/bin/obs-cmd
# macOS (Apple Silicon): brew install grigio/obs-cmd/obs-cmd → /opt/homebrew/bin/obs-cmd
# Linux: cargo install obs-cmd → ~/.cargo/bin/obs-cmd
# Linux (binary): download from GitHub releases, e.g. /usr/local/bin/obs-cmd
#
# OBS WebSocket must be enabled: Tools → WebSocket Server Settings (on by default in OBS 28+)
#
# Add to ~/.config/streamdeck-go/.env:
# OBS_WEBSOCKET_PASSWORD=your-password
# OBS_HOST=localhost (optional, default: localhost)
# OBS_PORT=4455 (optional, default: 4455)
# OBS_CMD=/opt/homebrew/bin/obs-cmd (optional, default: /usr/local/bin/obs-cmd)
# set this on Apple Silicon or Linux cargo installs
obs:
play:
params:
source: "Media Source"
exec: |
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} media-input play "{{.source}}"
pause:
params:
source: "Media Source"
exec: |
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} media-input pause "{{.source}}"
stop:
params:
source: "Media Source"
exec: |
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} media-input stop "{{.source}}"
restart:
params:
source: "Media Source"
exec: |
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} media-input restart "{{.source}}"
toggle_record:
exec: |
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} recording toggle
toggle_record_pause:
exec: |
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} recording toggle-pause
is_recording_paused:
exec: |
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} recording status
toggle_stream:
exec: |
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} streaming toggle
scene_switch:
params:
scene: "Scene 1"
exec: |
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene switch "{{.scene}}"
toggle_mute:
params:
source: "Mic/Aux"
exec: |
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} input toggle-mute "{{.source}}"
is_recording:
exec: |
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} recording status
is_streaming:
exec: |
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} streaming status
# Change the active transition type. Common values: Fade, Cut, Slide, Swipe, Stinger.
# Must match a transition that exists in your OBS profile (see Scene Transitions panel).
set_transition:
params:
transition: "Fade"
exec: |
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene transition-set "{{.transition}}"
# Set the active transition duration in milliseconds.
set_transition_duration:
params:
duration: "300"
exec: |
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene transition-duration {{.duration}}
# Toggle Studio Mode (preview + program with a Transition button).
studio_mode_toggle:
exec: |
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene studio-mode-toggle
# In Studio Mode, set the scene shown on the preview side.
preview_scene:
params:
scene: "Scene 1"
exec: |
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene preview-set "{{.scene}}"
# The Studio Mode "Transition" button — push preview to program using the current transition.
take:
exec: |
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene studio-mode-transition
# Fire the current transition without changing scenes (e.g. to re-trigger a stinger).
transition_trigger:
exec: |
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene transition-trigger
# Switch scene using a specific transition, overriding OBS's current transition for this cut.
# Chains set_transition + transition-duration + scene switch in one key press.
scene_switch_with_transition:
params:
scene: "Scene 1"
transition: "Fade"
duration: "300"
exec: |
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene transition-set "{{.transition}}" && \
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene transition-duration {{.duration}} && \
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene switch "{{.scene}}"

View File

@@ -87,6 +87,12 @@ func (sd *StreamDeck) Close() error {
// KeyCount returns the number of keys on this device. // KeyCount returns the number of keys on this device.
func (sd *StreamDeck) KeyCount() int { return sd.model.KeyCount } func (sd *StreamDeck) KeyCount() int { return sd.model.KeyCount }
// ImageWidth returns the pixel width of key images for this device.
func (sd *StreamDeck) ImageWidth() int { return sd.model.ImageWidth }
// ImageHeight returns the pixel height of key images for this device.
func (sd *StreamDeck) ImageHeight() int { return sd.model.ImageHeight }
// Reset clears all key images and returns the device to its default state. // Reset clears all key images and returns the device to its default state.
func (sd *StreamDeck) Reset() error { func (sd *StreamDeck) Reset() error {
report := make([]byte, 32) report := make([]byte, 32)

View File

@@ -100,6 +100,15 @@ func funcMap() template.FuncMap {
// Use this to keep secrets (tokens, passwords) out of modules.yaml. // Use this to keep secrets (tokens, passwords) out of modules.yaml.
"env": os.Getenv, "env": os.Getenv,
// envDefault returns the value of an environment variable, or
// fallback if the variable is empty/unset.
"envDefault": func(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
},
// expiry converts a duration string like "+1h" to a Unix epoch timestamp string. // expiry converts a duration string like "+1h" to a Unix epoch timestamp string.
// Pass "" or "0" to get "0" (no expiry). Supports Go duration syntax (e.g. "30m", "2h"). // Pass "" or "0" to get "0" (no expiry). Supports Go duration syntax (e.g. "30m", "2h").
// Note: time.ParseDuration does not support days (d) or weeks (w). // Note: time.ParseDuration does not support days (d) or weeks (w).

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!--
LaunchAgent for streamdeck-go-watchdog.
Installed to: ~/Library/LaunchAgents/com.woodarddigital.streamdeck-go-watchdog.plist
Fires every 30 seconds. Detects USB unplug/replug events that the daemon's
in-process reconnect missed and restarts the streamdeck-go agent.
The watchdog binary path below is set by the install target at install time.
-->
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.woodarddigital.streamdeck-go-watchdog</string>
<key>ProgramArguments</key>
<array>
<string>STREAMDECK_WATCHDOG_PATH</string>
</array>
<!-- Fire every 30 seconds. -->
<key>StartInterval</key>
<integer>30</integer>
<!-- Don't run at load — let the first interval fire naturally so the
device address has a chance to settle after login. -->
<key>RunAtLoad</key>
<false/>
<key>StandardOutPath</key>
<string>STREAMDECK_WATCHDOG_LOG_PATH</string>
<key>StandardErrorPath</key>
<string>STREAMDECK_WATCHDOG_LOG_PATH</string>
</dict>
</plist>

View File

@@ -56,3 +56,129 @@ modules:
exec: | exec: |
curl -s -X POST https://slack.com/api/dnd.endSnooze \ curl -s -X POST https://slack.com/api/dnd.endSnooze \
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}" -H "Authorization: Bearer {{env "SLACK_TOKEN"}}"
# OBS Studio — media player, streaming, and scene/transition control via obs-cmd
#
# Requires: obs-cmd (https://github.com/grigio/obs-cmd)
# macOS (Intel): brew install grigio/obs-cmd/obs-cmd → /usr/local/bin/obs-cmd
# macOS (Apple Silicon): brew install grigio/obs-cmd/obs-cmd → /opt/homebrew/bin/obs-cmd
# Linux: cargo install obs-cmd → ~/.cargo/bin/obs-cmd
# Linux (binary): download from GitHub releases, e.g. /usr/local/bin/obs-cmd
#
# OBS WebSocket must be enabled: Tools → WebSocket Server Settings (on by default in OBS 28+)
#
# Add to ~/.config/streamdeck-go/.env:
# OBS_WEBSOCKET_PASSWORD=your-password
# OBS_HOST=localhost (optional, default: localhost)
# OBS_PORT=4455 (optional, default: 4455)
# OBS_CMD=/opt/homebrew/bin/obs-cmd (optional, default: /usr/local/bin/obs-cmd)
# set this on Apple Silicon or Linux cargo installs
obs:
play:
params:
source: "Media Source"
exec: |
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} media-input play "{{.source}}"
pause:
params:
source: "Media Source"
exec: |
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} media-input pause "{{.source}}"
stop:
params:
source: "Media Source"
exec: |
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} media-input stop "{{.source}}"
restart:
params:
source: "Media Source"
exec: |
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} media-input restart "{{.source}}"
toggle_record:
exec: |
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} recording toggle
toggle_record_pause:
exec: |
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} recording toggle-pause
is_recording_paused:
exec: |
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} recording status
toggle_stream:
exec: |
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} streaming toggle
scene_switch:
params:
scene: "Scene 1"
exec: |
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene switch "{{.scene}}"
toggle_mute:
params:
source: "Mic/Aux"
exec: |
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} input toggle-mute "{{.source}}"
is_recording:
exec: |
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} recording status
is_streaming:
exec: |
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} streaming status
# Change the active transition type. Common values: Fade, Cut, Slide, Swipe, Stinger.
# Must match a transition that exists in your OBS profile (see Scene Transitions panel).
set_transition:
params:
transition: "Fade"
exec: |
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene transition-set "{{.transition}}"
# Set the active transition duration in milliseconds.
set_transition_duration:
params:
duration: "300"
exec: |
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene transition-duration {{.duration}}
# Toggle Studio Mode (preview + program with a Transition button).
studio_mode_toggle:
exec: |
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene studio-mode-toggle
# In Studio Mode, set the scene shown on the preview side.
preview_scene:
params:
scene: "Scene 1"
exec: |
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene preview-set "{{.scene}}"
# The Studio Mode "Transition" button — push preview to program using the current transition.
take:
exec: |
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene studio-mode-transition
# Fire the current transition without changing scenes (e.g. to re-trigger a stinger).
transition_trigger:
exec: |
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene transition-trigger
# Switch scene using a specific transition, overriding OBS's current transition for this cut.
# Chains set_transition + transition-duration + scene switch in one key press.
scene_switch_with_transition:
params:
scene: "Scene 1"
transition: "Fade"
duration: "300"
exec: |
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene transition-set "{{.transition}}" && \
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene transition-duration {{.duration}} && \
{{envDefault "OBS_CMD" "/usr/local/bin/obs-cmd"}} --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene switch "{{.scene}}"

Binary file not shown.

View File

@@ -1,6 +1,6 @@
[Unit] [Unit]
Description=Stream Deck privileged command helper Description=Stream Deck privileged command helper
Documentation=https://github.com/WoodardDigital/streamdeck-go Documentation=https://git.i0t.app/WoodardDigital/streamdeck-go
# Start before the user session so the socket is ready when streamdeck-go starts. # Start before the user session so the socket is ready when streamdeck-go starts.
Before=graphical.target Before=graphical.target

View File

@@ -0,0 +1,8 @@
[Unit]
Description=Stream Deck watchdog (one-shot)
Documentation=https://git.i0t.app/WoodardDigital/streamdeck-go
After=streamdeck-go.service
[Service]
Type=oneshot
ExecStart=%h/.local/bin/streamdeck-go-watchdog

View File

@@ -0,0 +1,177 @@
#!/usr/bin/env bash
# streamdeck-go watchdog — runs every 30s via systemd timer (Linux) or launchd
# StartInterval (macOS).
#
# Why this exists: when the Stream Deck is unplugged and replugged, the daemon's
# in-process reconnect logic does not always notice. On Linux, hidraw can keep
# returning read timeouts on the now-stale fd instead of surfacing an error, so
# the "3 consecutive errors → reconnect" path never triggers, and the service
# manager still reports the service as active even though the device is
# unreachable.
#
# Strategy: track the device's transient USB address (Linux: bus:device,
# macOS: Location ID). When it changes (unplug/replug) or the service is
# inactive while a device is present, restart the service.
set -euo pipefail
# Stream Deck product IDs we support (see internal/device/streamdeck.go).
PIDS_RE="00ba|006c|006d"
OS="$(uname -s)"
case "$OS" in
Linux)
STATE_DIR="${XDG_RUNTIME_DIR:-/tmp}"
;;
Darwin)
# No XDG_RUNTIME_DIR on macOS; use the user-private temp dir.
STATE_DIR="${TMPDIR:-/tmp}"
;;
*)
echo "watchdog: unsupported OS: $OS" >&2
exit 1
;;
esac
STATE_FILE="$STATE_DIR/streamdeck-go-watchdog.state"
# Print a transient identifier for the first matching Stream Deck on the USB
# bus, or empty if none is present. The identifier must change across
# unplug/replug so we can detect it.
current_addr() {
case "$OS" in
Linux)
# "Bus 003 Device 052: ID 0fd9:00ba ..." → "003:052"
lsusb 2>/dev/null | awk -v pids="$PIDS_RE" '
$0 ~ ("ID 0fd9:(" pids ")") {
gsub(":", "", $4)
print $2 ":" $4
exit
}
'
;;
Darwin)
# system_profiler entry per device:
# Stream Deck XL:
# Product ID: 0x00ba
# Vendor ID: 0x0fd9 (Elgato ...)
# ...
# Location ID: 0x14140000 / 5
# The trailing "/ N" is the bus address — it changes on replug.
system_profiler SPUSBDataType 2>/dev/null | awk -v pids="$PIDS_RE" '
/^[[:space:]]*Product ID:/ { pid = $3 }
/^[[:space:]]*Vendor ID:/ { vid = $3 }
/^[[:space:]]*Location ID:/ {
sub(/^[[:space:]]*Location ID:[[:space:]]*/, "")
if (vid == "0x0fd9" && pid ~ ("^0x(" pids ")$")) {
print
exit
}
}
'
;;
esac
}
# Is the streamdeck-go service currently active?
service_active() {
case "$OS" in
Linux)
systemctl --user is-active --quiet streamdeck-go.service
;;
Darwin)
# launchctl list prints "PID Status Label". A PID of "-" means
# the agent is loaded but not running.
local line
line="$(launchctl list 2>/dev/null | awk '$3 == "com.woodarddigital.streamdeck-go" { print $1 }')"
[[ -n "$line" && "$line" != "-" ]]
;;
esac
}
restart_service() {
case "$OS" in
Linux)
systemctl --user restart streamdeck-go.service
;;
Darwin)
# kickstart -k stops and restarts; works whether or not it's running.
launchctl kickstart -k "gui/$(id -u)/com.woodarddigital.streamdeck-go"
;;
esac
}
prev=""
[[ -f "$STATE_FILE" ]] && prev="$(cat "$STATE_FILE" 2>/dev/null || true)"
curr="$(current_addr)"
# Only update the state file when the device is present. If we overwrote with
# an empty string while the device was absent (e.g. mid-KVM-swap), the very
# next run would see prev="" and miss the address change on return.
if [[ -n "$curr" ]]; then
printf '%s' "$curr" > "$STATE_FILE"
fi
# No device present — nothing to do. Don't touch the service.
if [[ -z "$curr" ]]; then
exit 0
fi
# Linux-only: detect a stale hidraw fd held by the daemon. When the device
# unplugs, hidraw's open fd survives but its /dev node is removed; procfs
# marks the symlink "(deleted)". hid_read_timeout on this fd silently returns
# zero bytes, so the daemon's 3-error reconnect path never trips.
stale_fd_detected() {
[[ "$OS" != "Linux" ]] && return 1
local pid
pid="$(systemctl --user show -p MainPID --value streamdeck-go.service 2>/dev/null || true)"
[[ -z "$pid" || "$pid" == "0" ]] && return 1
[[ ! -d "/proc/$pid/fd" ]] && return 1
ls -la "/proc/$pid/fd/" 2>/dev/null | grep -qE 'hidraw[0-9]+ \(deleted\)'
}
# Linux-only: detect that the system resumed from suspend after the daemon
# started. On resume, the xhci controller may reset the deck's USB device
# in place (same bus address, same hidraw node, fd not deleted). The kernel
# reset leaves the existing fd's input queue dead — buttons no longer reach
# userspace — but no externally visible signal flags the failure. Restarting
# the daemon is cheap and reliably fixes it.
#
# Idempotent by construction: once we restart, the daemon's ActiveEnterTimestamp
# moves past the resume event, so this check stops firing until the next sleep.
resumed_since_start() {
[[ "$OS" != "Linux" ]] && return 1
local started
started="$(systemctl --user show -p ActiveEnterTimestamp --value streamdeck-go.service 2>/dev/null || true)"
[[ -z "$started" || "$started" == "n/a" ]] && return 1
local started_epoch
started_epoch="$(date -d "$started" +%s 2>/dev/null || true)"
[[ -z "$started_epoch" ]] && return 1
journalctl -k --since "@$started_epoch" --no-pager 2>/dev/null \
| grep -qE 'PM: suspend exit|PM: Finishing wakeup'
}
reason=""
if [[ -z "$prev" ]]; then
# First observation (or state file was wiped). Only restart if the service
# is also down — if it's already running, assume it's healthy and just
# record the baseline.
if ! service_active; then
reason="device present at $curr but service is not active"
fi
elif [[ "$curr" != "$prev" ]]; then
reason="device address changed: $prev$curr (likely unplug/replug)"
elif ! service_active; then
reason="device present at $curr but service is not active"
elif stale_fd_detected; then
reason="daemon holds a deleted hidraw fd (post-unplug stale handle)"
elif resumed_since_start; then
reason="system resumed from suspend since daemon started (USB reset may have invalidated input queue)"
fi
if [[ -n "$reason" ]]; then
echo "watchdog: $reason — restarting streamdeck-go"
restart_service
fi

View File

@@ -0,0 +1,12 @@
[Unit]
Description=Stream Deck watchdog timer (every 30s)
Documentation=https://git.i0t.app/WoodardDigital/streamdeck-go
[Timer]
OnBootSec=30s
OnUnitActiveSec=30s
AccuracySec=5s
Unit=streamdeck-go-watchdog.service
[Install]
WantedBy=timers.target

View File

@@ -1,6 +1,6 @@
[Unit] [Unit]
Description=Stream Deck controller Description=Stream Deck controller
Documentation=https://github.com/WoodardDigital/streamdeck-go Documentation=https://git.i0t.app/lwoodard/streamdeck-go
After=graphical-session.target After=graphical-session.target
PartOf=graphical-session.target PartOf=graphical-session.target