Compare commits

...

5 Commits

Author SHA1 Message Date
lwoodard
d1d5d88508 Add text_command + refresh live-text keys
Introduce a per-key `text_command` + `refresh` feature: the service
periodically runs a shell command and renders its stdout as the key's
text overlay, compositing over the base icon (or a black background for
text-only keys) each tick. `text` acts as the static fallback shown
before the first run or when the command errors/outputs nothing. A
refresh goroutine owns the key's image and also handles an optional poll
block (choosing icon_true/icon_false per tick). Adds `$(...)` sugar in
`text` as shorthand for `text_command`.

- internal/config: add TextCommand/Refresh fields to KeyConfig
- cmd/streamdeck: refreshTextKey goroutine, runTextCommand, unwrapCmdSubst
- config.example.yaml: document the feature with a `flow` pomodoro
  live-countdown example (status --short, toggle/skip/stop/gui)
- modules.example.yaml: example config updates

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TEoLyNbtbjbDq7aTWAymUG
2026-08-07 13:23:43 -06:00
lwoodard
a542ad60cd macOS watchdog: detect deck via ioreg sessionID
system_profiler SPUSBDataType was silently omitting the deck on some Macs
(watchdog exited early, never restarted anything) and, when it did report,
the trailing bus number in "Location ID: X / N" flapped without an actual
replug — causing spurious restarts. ioreg sees the device reliably and
sessionID changes only on real re-enumeration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-09 11:25:54 -06:00
Levi Woodard
a51fd2beff Fixing watchdog 2026-05-10 13:35:16 -06:00
Levi Woodard
8b6b4d582d Adding make reinstall 2026-04-26 14:00:43 -06:00
Levi Woodard
212e957f49 Adding restart watchdog and moving to i0t.app 2026-04-26 13:47:47 -06:00
16 changed files with 675 additions and 22 deletions

135
Makefile
View File

@@ -24,7 +24,7 @@ else
UDEV_RULE := /etc/udev/rules.d/99-streamdeck.rules
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 ─────────────────────────────────────────────────────────────────────
@@ -88,6 +88,37 @@ install-helper: build-helper
@echo " or run: newgrp $(GROUP)"
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:
ifeq ($(OS),Darwin)
@@ -104,6 +135,94 @@ else
fi
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 ─────────────────────────────────────────────────────────────────
ifeq ($(OS),Darwin)
@@ -116,6 +235,12 @@ uninstall:
uninstall-helper:
@echo "No helper daemon on macOS — nothing to uninstall."
@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
uninstall:
systemctl --user disable --now streamdeck-go.service || true
@@ -124,6 +249,14 @@ uninstall:
systemctl --user daemon-reload
@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:
sudo systemctl disable --now streamdeck-go-helper.service || true
sudo rm -f $(SYS_BIN)/$(HELPER)

View File

@@ -126,7 +126,7 @@ interleave partial image data across keys.
# Prerequisites
brew install go hidapi
git clone https://github.com/WoodardDigital/streamdeck-go
git clone https://git.i0t.app/WoodardDigital/streamdeck-go
cd streamdeck-go
make install
```
@@ -154,7 +154,7 @@ directory integration.
# Prerequisites — Arch example; adjust for your distro (see table above)
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
make install
```
@@ -218,7 +218,7 @@ sudo udevadm trigger
**2. Build and run:**
```bash
git clone https://github.com/WoodardDigital/streamdeck-go
git clone https://git.i0t.app/WoodardDigital/streamdeck-go
cd streamdeck-go
cp config.example.yaml config.yaml
@@ -233,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 package coming soon. Until then, use `make install` above.
@@ -791,6 +818,7 @@ keys:
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.

View File

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

View File

@@ -51,9 +51,9 @@ import (
"sort"
"strings"
"github.com/WoodardDigital/streamdeck-go/internal/config"
"github.com/WoodardDigital/streamdeck-go/internal/defaults"
"github.com/WoodardDigital/streamdeck-go/internal/modules"
"git.i0t.app/lwoodard/streamdeck-go/internal/config"
"git.i0t.app/lwoodard/streamdeck-go/internal/defaults"
"git.i0t.app/lwoodard/streamdeck-go/internal/modules"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
)

View File

@@ -23,9 +23,9 @@ import (
"sync"
"time"
"github.com/WoodardDigital/streamdeck-go/internal/config"
"github.com/WoodardDigital/streamdeck-go/internal/device"
"github.com/WoodardDigital/streamdeck-go/internal/modules"
"git.i0t.app/lwoodard/streamdeck-go/internal/config"
"git.i0t.app/lwoodard/streamdeck-go/internal/device"
"git.i0t.app/lwoodard/streamdeck-go/internal/modules"
"github.com/fsnotify/fsnotify"
"github.com/srwiley/oksvg"
"github.com/srwiley/rasterx"
@@ -207,6 +207,43 @@ func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *mo
cfg.Keys[keyIdx] = keyCfg
}
// Optional $(...) sugar → text_command.
if keyCfg.TextCommand == "" {
if inner, ok := unwrapCmdSubst(keyCfg.Text); ok {
keyCfg.TextCommand = inner
keyCfg.Text = ""
cfg.Keys[keyIdx] = keyCfg
}
}
// Dynamic text key: a refresh goroutine owns this key's image. It also
// handles an optional poll block (choosing icon_true/icon_false per tick),
// so it must take precedence over the plain toggle path below.
if keyCfg.TextCommand != "" {
baseIcon := keyCfg.Icon
if keyCfg.Poll != nil {
baseIcon = keyCfg.IconTrue
}
if strings.ToLower(filepath.Ext(baseIcon)) == ".gif" ||
strings.ToLower(filepath.Ext(keyCfg.IconFalse)) == ".gif" {
log.Printf("key %d: text_command not supported with GIF icon — skipping refresh", keyIdx)
continue
}
trigger := make(chan struct{}, 1)
triggers[keyIdx] = trigger
wg.Add(1)
go func(idx int, kCfg config.KeyConfig, trig chan struct{}) {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
log.Printf("panic in refreshTextKey %d: %v", idx, r)
}
}()
refreshTextKey(ctx, sd, idx, kCfg, cfg.IconsDir, trig)
}(keyIdx, keyCfg, trigger)
continue
}
// Toggle/status key: managed by a polling goroutine.
if keyCfg.Poll != nil {
if keyCfg.IconTrue == "" || keyCfg.IconFalse == "" {
@@ -488,6 +525,119 @@ func queryPollState(poll *config.PollConfig) int {
return 0
}
// refreshTextKey periodically runs keyCfg.TextCommand and renders its stdout as
// the key's text overlay, compositing over the key's base icon each tick (never
// accumulating — overlayText always renders onto a fresh image from the base).
// If the key also has a Poll block, the base icon is chosen from the polled
// state (icon_true/icon_false) so a single goroutine owns the key's image.
func refreshTextKey(ctx context.Context, sd *device.StreamDeck, keyIdx int, keyCfg config.KeyConfig, iconsDir string, trigger <-chan struct{}) {
// Interval: default 1s, floor 250ms.
interval := time.Second
if keyCfg.Refresh != "" {
if d, err := time.ParseDuration(keyCfg.Refresh); err == nil && d > 0 {
interval = d
} else if err != nil {
log.Printf("key %d: invalid refresh %q, using 1s", keyIdx, keyCfg.Refresh)
}
}
if interval < 250*time.Millisecond {
interval = 250 * time.Millisecond
}
// Preload immutable base image(s). An empty name yields a black background.
load := func(name string) (image.Image, bool) {
if name == "" {
return image.NewRGBA(image.Rect(0, 0, sd.ImageWidth(), sd.ImageHeight())), true
}
img, err := loadImage(filepath.Join(iconsDir, name))
if err != nil {
log.Printf("key %d: load icon %q: %v", keyIdx, name, err)
return nil, false
}
return img, true
}
pollMode := keyCfg.Poll != nil && keyCfg.IconTrue != "" && keyCfg.IconFalse != ""
var baseImg, imgTrue, imgFalse image.Image
if pollMode {
var ok1, ok2 bool
imgTrue, ok1 = load(keyCfg.IconTrue)
imgFalse, ok2 = load(keyCfg.IconFalse)
if !ok1 || !ok2 {
return
}
} else {
var ok bool
baseImg, ok = load(keyCfg.Icon) // "" → black background
if !ok {
return
}
}
render := func() {
text := runTextCommand(keyCfg.TextCommand)
if text == "" {
text = keyCfg.Text // static fallback
}
base := baseImg
if pollMode {
if queryPollState(keyCfg.Poll) == 1 {
base = imgTrue
} else {
base = imgFalse
}
}
img := overlayText(base, text, keyCfg.TextColor, sd.ImageWidth())
if err := sd.SetKeyImage(keyIdx, img); err != nil {
log.Printf("key %d: set dynamic text image: %v", keyIdx, err)
}
}
render() // set immediately on startup
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
render()
case <-trigger:
// Wait briefly for a press-triggered command to take effect, then re-render.
select {
case <-ctx.Done():
return
case <-time.After(300 * time.Millisecond):
}
render()
}
}
}
// runTextCommand runs cmd via sh -c and returns its stdout with trailing
// whitespace/newlines trimmed. Stderr is dropped so it can't pollute the label.
func runTextCommand(cmd string) string {
out, err := exec.Command("sh", "-c", cmd).Output()
if err != nil {
log.Printf("text_command %q: %v", cmd, err)
}
return strings.TrimRight(string(out), " \t\r\n")
}
// unwrapCmdSubst returns the inner command if s is exactly "$( ... )".
func unwrapCmdSubst(s string) (string, bool) {
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "$(") && strings.HasSuffix(s, ")") {
inner := strings.TrimSpace(s[2 : len(s)-1])
if inner != "" {
return inner, true
}
}
return "", false
}
// mustConnect blocks until the device opens successfully.
//
// Strategy: try quickly at first (device may just be enumerating), then settle
@@ -707,9 +857,14 @@ func overlayText(img image.Image, text, textColorStr string, keySize int) image.
// 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},
{-1, -1},
{0, -1},
{1, -1},
{-1, 0},
{1, 0},
{-1, 1},
{0, 1},
{1, 1},
}
for i, line := range lines {
@@ -754,7 +909,7 @@ func loadImage(path string) (image.Image, error) {
}
func loadSVG(path string) (image.Image, error) {
icon, err := oksvg.ReadIcon(path, oksvg.WarnErrorMode)
icon, err := oksvg.ReadIcon(path, oksvg.IgnoreErrorMode)
if err != nil {
return nil, err
}
@@ -910,18 +1065,18 @@ func defaultConfigPath() string {
func ensureConfigDir(cfgPath string) error {
dir := filepath.Dir(cfgPath)
iconsDir := filepath.Join(dir, "icons")
if err := os.MkdirAll(iconsDir, 0755); err != nil {
if err := os.MkdirAll(iconsDir, 0o755); err != nil {
return err
}
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
return os.WriteFile(cfgPath, []byte(defaultConfig(iconsDir)), 0644)
return os.WriteFile(cfgPath, []byte(defaultConfig(iconsDir)), 0o644)
}
return nil
}
func defaultConfig(iconsDir string) string {
return `# streamdeck-go configuration
# https://github.com/WoodardDigital/streamdeck-go
# https://git.i0t.app/lwoodard/streamdeck-go
icons_dir: ` + iconsDir + `
brightness: 70
@@ -946,4 +1101,3 @@ device:
keys: {}
`
}

View File

@@ -61,3 +61,48 @@ keys:
# command: pactl get-source-mute @DEFAULT_SOURCE@
# interval: 2s
# match: "yes"
# --- Dynamic text from command output ---
#
# text_command: a shell command whose stdout is rendered as the key's label,
# re-run every `refresh` interval (default 1s; floored to 250ms). Newlines
# in the output become label line breaks; trailing whitespace is trimmed.
# `text` (if set) is the static fallback shown before the first run or when
# the command errors / outputs nothing. Composited over `icon` each tick.
#
# NOTE: use the ABSOLUTE binary path — the service runs with a minimal PATH.
# Apple Silicon Homebrew installs may live at /opt/homebrew/bin/flow instead.
#
# Pomodoro (`flow`) live countdown — press toggles start/pause:
# 8:
# icon: pomodoro.png # base icon; text on top
# text_command: "/usr/local/bin/flow status --short" # prints e.g. WORK\n24:12 (real newline)
# refresh: 1s
# text_color: "#FFFFFF"
# command: "/usr/local/bin/flow toggle"
#
# Separate icon-swap indicator (running vs paused):
# 9:
# icon_true: running.png
# icon_false: paused.png
# command: "/usr/local/bin/flow toggle"
# poll:
# command: "/usr/local/bin/flow status" # prints: state=running phase=work remaining=24:12
# interval: 1s
# match: "state=running"
#
# Control keys:
# 10:
# icon: skip.png
# command: "/usr/local/bin/flow skip"
# 11:
# icon: stop.png
# command: "/usr/local/bin/flow stop"
# 12:
# icon: gui.png
# command: "/usr/local/bin/flow gui"
#
# A text-only live clock (no icon → white text on black), refreshed each second:
# 13:
# text_command: "date +%H:%M:%S"
# refresh: 1s

2
go.mod
View File

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

View File

@@ -449,6 +449,33 @@ else
ok "Service enabled and started"
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 ───────────────────────────────────────────────────────────────────────
nl
echo -e " ${DIM}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"

View File

@@ -27,6 +27,12 @@ type KeyConfig struct {
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
// Dynamic text: periodically run TextCommand and render its stdout as the
// key's text overlay. Refresh is the interval (default 1s, floor 250ms).
// Text (above) is the static fallback / initial value.
TextCommand string `yaml:"text_command"` // shell command whose stdout becomes the overlay text
Refresh string `yaml:"refresh"` // how often to re-run text_command, e.g. "1s" (default: "1s")
// Toggle/status keys: show different icons based on polled state.
IconTrue string `yaml:"icon_true"` // icon when poll match is true
IconFalse string `yaml:"icon_false"` // icon when poll match is false

View File

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

View File

@@ -57,6 +57,30 @@ modules:
curl -s -X POST https://slack.com/api/dnd.endSnooze \
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}"
# Pomodoro / focus timer — the `flow` CLI.
#
# Absolute path required (minimal service PATH). Apple Silicon Homebrew installs
# live at /opt/homebrew/bin/flow — set that in ~/.config/streamdeck-go/.env:
# FLOW_CMD=/opt/homebrew/bin/flow
#
# Control verbs (start/pause/resume/toggle/skip/stop/reset/gui) run on key press.
# `status` prints one line like `state=running phase=work remaining=24:12` for
# icon-swap poll blocks (match: "state=running").
#
# The LIVE countdown label uses a key's `text_command` field directly (not a
# module), e.g. text_command: "/usr/local/bin/flow status --short" which prints
# a two-line label like WORK\n24:12 (an actual newline between the lines).
flow:
start: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} start' }
pause: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} pause' }
resume: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} resume' }
toggle: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} toggle' }
skip: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} skip' }
stop: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} stop' }
reset: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} reset' }
gui: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} gui' }
status: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} status' }
# OBS Studio — media player, streaming, and scene/transition control via obs-cmd
#
# Requires: obs-cmd (https://github.com/grigio/obs-cmd)

View File

@@ -1,6 +1,6 @@
[Unit]
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.
Before=graphical.target

View File

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

View File

@@ -0,0 +1,180 @@
#!/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)
# ioreg is the reliable source on macOS — system_profiler SPUSBDataType
# silently omits the deck on some machines, and its "Location ID: X / N"
# trailing bus number flaps spuriously without an actual replug.
#
# sessionID is unique per USB enumeration session: stable while the
# device stays plugged in, changes on every replug. Exactly what we want.
#
# Elgato vendor in decimal: 4057 (0x0fd9).
# Stream Deck product IDs in decimal: 186 (0x00ba), 108 (0x006c), 109 (0x006d).
ioreg -p IOUSB -l -w 0 2>/dev/null | awk '
/<class IOUSBHostDevice/ { vid=""; pid=""; sid="" }
/"idVendor"/ { vid=$NF }
/"idProduct"/ { pid=$NF }
/"sessionID"/ { sid=$NF }
/}/ {
if (vid == "4057" && (pid == "186" || pid == "108" || pid == "109") && sid != "") {
print sid
exit
}
}
'
;;
esac
}
# Is the streamdeck-go service currently active?
service_active() {
case "$OS" in
Linux)
systemctl --user is-active --quiet streamdeck-go.service
;;
Darwin)
# launchctl list prints "PID Status Label". A PID of "-" means
# the agent is loaded but not running.
local line
line="$(launchctl list 2>/dev/null | awk '$3 == "com.woodarddigital.streamdeck-go" { print $1 }')"
[[ -n "$line" && "$line" != "-" ]]
;;
esac
}
restart_service() {
case "$OS" in
Linux)
systemctl --user restart streamdeck-go.service
;;
Darwin)
# kickstart -k stops and restarts; works whether or not it's running.
launchctl kickstart -k "gui/$(id -u)/com.woodarddigital.streamdeck-go"
;;
esac
}
prev=""
[[ -f "$STATE_FILE" ]] && prev="$(cat "$STATE_FILE" 2>/dev/null || true)"
curr="$(current_addr)"
# Only update the state file when the device is present. If we overwrote with
# an empty string while the device was absent (e.g. mid-KVM-swap), the very
# next run would see prev="" and miss the address change on return.
if [[ -n "$curr" ]]; then
printf '%s' "$curr" > "$STATE_FILE"
fi
# No device present — nothing to do. Don't touch the service.
if [[ -z "$curr" ]]; then
exit 0
fi
# Linux-only: detect a stale hidraw fd held by the daemon. When the device
# unplugs, hidraw's open fd survives but its /dev node is removed; procfs
# marks the symlink "(deleted)". hid_read_timeout on this fd silently returns
# zero bytes, so the daemon's 3-error reconnect path never trips.
stale_fd_detected() {
[[ "$OS" != "Linux" ]] && return 1
local pid
pid="$(systemctl --user show -p MainPID --value streamdeck-go.service 2>/dev/null || true)"
[[ -z "$pid" || "$pid" == "0" ]] && return 1
[[ ! -d "/proc/$pid/fd" ]] && return 1
ls -la "/proc/$pid/fd/" 2>/dev/null | grep -qE 'hidraw[0-9]+ \(deleted\)'
}
# Linux-only: detect that the system resumed from suspend after the daemon
# started. On resume, the xhci controller may reset the deck's USB device
# in place (same bus address, same hidraw node, fd not deleted). The kernel
# reset leaves the existing fd's input queue dead — buttons no longer reach
# userspace — but no externally visible signal flags the failure. Restarting
# the daemon is cheap and reliably fixes it.
#
# Idempotent by construction: once we restart, the daemon's ActiveEnterTimestamp
# moves past the resume event, so this check stops firing until the next sleep.
resumed_since_start() {
[[ "$OS" != "Linux" ]] && return 1
local started
started="$(systemctl --user show -p ActiveEnterTimestamp --value streamdeck-go.service 2>/dev/null || true)"
[[ -z "$started" || "$started" == "n/a" ]] && return 1
local started_epoch
started_epoch="$(date -d "$started" +%s 2>/dev/null || true)"
[[ -z "$started_epoch" ]] && return 1
journalctl -k --since "@$started_epoch" --no-pager 2>/dev/null \
| grep -qE 'PM: suspend exit|PM: Finishing wakeup'
}
reason=""
if [[ -z "$prev" ]]; then
# First observation (or state file was wiped). Only restart if the service
# is also down — if it's already running, assume it's healthy and just
# record the baseline.
if ! service_active; then
reason="device present at $curr but service is not active"
fi
elif [[ "$curr" != "$prev" ]]; then
reason="device address changed: $prev$curr (likely unplug/replug)"
elif ! service_active; then
reason="device present at $curr but service is not active"
elif stale_fd_detected; then
reason="daemon holds a deleted hidraw fd (post-unplug stale handle)"
elif resumed_since_start; then
reason="system resumed from suspend since daemon started (USB reset may have invalidated input queue)"
fi
if [[ -n "$reason" ]]; then
echo "watchdog: $reason — restarting streamdeck-go"
restart_service
fi

View File

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

View File

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