Compare commits
8 Commits
5e85dda038
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a51fd2beff | |||
| 8b6b4d582d | |||
| 212e957f49 | |||
| 38893cbb84 | |||
| 9b375cfba2 | |||
| 3a112dfd84 | |||
| 962ee747fd | |||
|
|
44dc22d8ee |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,6 +2,7 @@
|
|||||||
/streamdeck
|
/streamdeck
|
||||||
/streamdeck-go
|
/streamdeck-go
|
||||||
/streamdeck-helper
|
/streamdeck-helper
|
||||||
|
/streamdeck-init
|
||||||
/bin/
|
/bin/
|
||||||
*.exe
|
*.exe
|
||||||
|
|
||||||
|
|||||||
135
Makefile
135
Makefile
@@ -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
207
README.md
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -223,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)
|
||||||
@@ -249,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)
|
||||||
}
|
}
|
||||||
@@ -371,6 +389,10 @@ func pollKey(ctx context.Context, sd *device.StreamDeck, keyIdx int, keyCfg conf
|
|||||||
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 animCancel context.CancelFunc
|
||||||
@@ -555,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)
|
||||||
@@ -569,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
|
||||||
}
|
}
|
||||||
@@ -736,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
2
go.mod
@@ -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
|
||||||
|
|
||||||
|
|||||||
31
install.sh
31
install.sh
@@ -243,9 +243,9 @@ else
|
|||||||
install -m 755 /tmp/obs-cmd /usr/local/bin/obs-cmd
|
install -m 755 /tmp/obs-cmd /usr/local/bin/obs-cmd
|
||||||
rm -f /tmp/obs-cmd
|
rm -f /tmp/obs-cmd
|
||||||
else
|
else
|
||||||
# Linux — x86_64 musl static binary
|
# Linux — x86_64 static binary
|
||||||
step "Downloading obs-cmd v${OBS_CMD_VERSION}..."
|
step "Downloading obs-cmd v${OBS_CMD_VERSION}..."
|
||||||
curl -sL "https://github.com/grigio/obs-cmd/releases/download/v${OBS_CMD_VERSION}/obs-cmd-v${OBS_CMD_VERSION}-x86_64-unknown-linux-musl.tar.gz" \
|
curl -sL "https://github.com/grigio/obs-cmd/releases/download/v${OBS_CMD_VERSION}/obs-cmd-x64-linux.tar.gz" \
|
||||||
| tar xz -C /tmp
|
| tar xz -C /tmp
|
||||||
sudo install -m 755 /tmp/obs-cmd /usr/local/bin/obs-cmd
|
sudo install -m 755 /tmp/obs-cmd /usr/local/bin/obs-cmd
|
||||||
rm -f /tmp/obs-cmd
|
rm -f /tmp/obs-cmd
|
||||||
@@ -449,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}"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -57,11 +57,13 @@ modules:
|
|||||||
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 and streaming control via obs-cmd
|
# OBS Studio — media player, streaming, and scene/transition control via obs-cmd
|
||||||
#
|
#
|
||||||
# Requires: obs-cmd (https://github.com/grigio/obs-cmd)
|
# Requires: obs-cmd (https://github.com/grigio/obs-cmd)
|
||||||
# macOS: brew install grigio/obs-cmd/obs-cmd
|
# macOS (Intel): brew install grigio/obs-cmd/obs-cmd → /usr/local/bin/obs-cmd
|
||||||
# Linux: cargo install obs-cmd (or download binary from GitHub releases)
|
# 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+)
|
# OBS WebSocket must be enabled: Tools → WebSocket Server Settings (on by default in OBS 28+)
|
||||||
#
|
#
|
||||||
@@ -69,63 +71,114 @@ modules:
|
|||||||
# OBS_WEBSOCKET_PASSWORD=your-password
|
# OBS_WEBSOCKET_PASSWORD=your-password
|
||||||
# OBS_HOST=localhost (optional, default: localhost)
|
# OBS_HOST=localhost (optional, default: localhost)
|
||||||
# OBS_PORT=4455 (optional, default: 4455)
|
# 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:
|
obs:
|
||||||
play:
|
play:
|
||||||
params:
|
params:
|
||||||
source: "Media Source"
|
source: "Media Source"
|
||||||
exec: |
|
exec: |
|
||||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} media-input play "{{.source}}"
|
{{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:
|
pause:
|
||||||
params:
|
params:
|
||||||
source: "Media Source"
|
source: "Media Source"
|
||||||
exec: |
|
exec: |
|
||||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} media-input pause "{{.source}}"
|
{{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:
|
stop:
|
||||||
params:
|
params:
|
||||||
source: "Media Source"
|
source: "Media Source"
|
||||||
exec: |
|
exec: |
|
||||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} media-input stop "{{.source}}"
|
{{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:
|
restart:
|
||||||
params:
|
params:
|
||||||
source: "Media Source"
|
source: "Media Source"
|
||||||
exec: |
|
exec: |
|
||||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} media-input restart "{{.source}}"
|
{{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:
|
toggle_record:
|
||||||
exec: |
|
exec: |
|
||||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} recording toggle
|
{{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:
|
toggle_record_pause:
|
||||||
exec: |
|
exec: |
|
||||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} recording toggle-pause
|
{{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:
|
is_recording_paused:
|
||||||
exec: |
|
exec: |
|
||||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} recording status
|
{{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:
|
toggle_stream:
|
||||||
exec: |
|
exec: |
|
||||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} streaming toggle
|
{{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:
|
scene_switch:
|
||||||
params:
|
params:
|
||||||
scene: "Scene 1"
|
scene: "Scene 1"
|
||||||
exec: |
|
exec: |
|
||||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene switch "{{.scene}}"
|
{{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:
|
toggle_mute:
|
||||||
params:
|
params:
|
||||||
source: "Mic/Aux"
|
source: "Mic/Aux"
|
||||||
exec: |
|
exec: |
|
||||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} input toggle-mute "{{.source}}"
|
{{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:
|
is_recording:
|
||||||
exec: |
|
exec: |
|
||||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} recording status
|
{{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:
|
is_streaming:
|
||||||
exec: |
|
exec: |
|
||||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} streaming status
|
{{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}}"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
36
launchd/com.woodarddigital.streamdeck-go-watchdog.plist
Normal file
36
launchd/com.woodarddigital.streamdeck-go-watchdog.plist
Normal 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>
|
||||||
@@ -57,11 +57,13 @@ modules:
|
|||||||
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 and streaming control via obs-cmd
|
# OBS Studio — media player, streaming, and scene/transition control via obs-cmd
|
||||||
#
|
#
|
||||||
# Requires: obs-cmd (https://github.com/grigio/obs-cmd)
|
# Requires: obs-cmd (https://github.com/grigio/obs-cmd)
|
||||||
# macOS: brew install grigio/obs-cmd/obs-cmd
|
# macOS (Intel): brew install grigio/obs-cmd/obs-cmd → /usr/local/bin/obs-cmd
|
||||||
# Linux: cargo install obs-cmd (or download binary from GitHub releases)
|
# 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+)
|
# OBS WebSocket must be enabled: Tools → WebSocket Server Settings (on by default in OBS 28+)
|
||||||
#
|
#
|
||||||
@@ -69,63 +71,114 @@ modules:
|
|||||||
# OBS_WEBSOCKET_PASSWORD=your-password
|
# OBS_WEBSOCKET_PASSWORD=your-password
|
||||||
# OBS_HOST=localhost (optional, default: localhost)
|
# OBS_HOST=localhost (optional, default: localhost)
|
||||||
# OBS_PORT=4455 (optional, default: 4455)
|
# 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:
|
obs:
|
||||||
play:
|
play:
|
||||||
params:
|
params:
|
||||||
source: "Media Source"
|
source: "Media Source"
|
||||||
exec: |
|
exec: |
|
||||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} media-input play "{{.source}}"
|
{{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:
|
pause:
|
||||||
params:
|
params:
|
||||||
source: "Media Source"
|
source: "Media Source"
|
||||||
exec: |
|
exec: |
|
||||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} media-input pause "{{.source}}"
|
{{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:
|
stop:
|
||||||
params:
|
params:
|
||||||
source: "Media Source"
|
source: "Media Source"
|
||||||
exec: |
|
exec: |
|
||||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} media-input stop "{{.source}}"
|
{{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:
|
restart:
|
||||||
params:
|
params:
|
||||||
source: "Media Source"
|
source: "Media Source"
|
||||||
exec: |
|
exec: |
|
||||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} media-input restart "{{.source}}"
|
{{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:
|
toggle_record:
|
||||||
exec: |
|
exec: |
|
||||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} recording toggle
|
{{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:
|
toggle_record_pause:
|
||||||
exec: |
|
exec: |
|
||||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} recording toggle-pause
|
{{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:
|
is_recording_paused:
|
||||||
exec: |
|
exec: |
|
||||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} recording status
|
{{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:
|
toggle_stream:
|
||||||
exec: |
|
exec: |
|
||||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} streaming toggle
|
{{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:
|
scene_switch:
|
||||||
params:
|
params:
|
||||||
scene: "Scene 1"
|
scene: "Scene 1"
|
||||||
exec: |
|
exec: |
|
||||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene switch "{{.scene}}"
|
{{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:
|
toggle_mute:
|
||||||
params:
|
params:
|
||||||
source: "Mic/Aux"
|
source: "Mic/Aux"
|
||||||
exec: |
|
exec: |
|
||||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} input toggle-mute "{{.source}}"
|
{{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:
|
is_recording:
|
||||||
exec: |
|
exec: |
|
||||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} recording status
|
{{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:
|
is_streaming:
|
||||||
exec: |
|
exec: |
|
||||||
/usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} streaming status
|
{{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}}"
|
||||||
|
|||||||
BIN
streamdeck-init
BIN
streamdeck-init
Binary file not shown.
@@ -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
|
||||||
|
|
||||||
|
|||||||
8
systemd/streamdeck-go-watchdog.service
Normal file
8
systemd/streamdeck-go-watchdog.service
Normal 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
|
||||||
177
systemd/streamdeck-go-watchdog.sh
Normal file
177
systemd/streamdeck-go-watchdog.sh
Normal 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
|
||||||
12
systemd/streamdeck-go-watchdog.timer
Normal file
12
systemd/streamdeck-go-watchdog.timer
Normal 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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user