Adding restart watchdog and moving to i0t.app

This commit is contained in:
2026-04-26 13:47:47 -06:00
parent 38893cbb84
commit 212e957f49
13 changed files with 279 additions and 15 deletions

View File

@@ -24,7 +24,7 @@ else
UDEV_RULE := /etc/udev/rules.d/99-streamdeck.rules UDEV_RULE := /etc/udev/rules.d/99-streamdeck.rules
endif endif
.PHONY: build build-helper build-init install install-helper uninstall uninstall-helper udev .PHONY: build build-helper build-init install install-helper install-watchdog 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)
@@ -116,6 +147,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 +161,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)

View File

@@ -126,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
``` ```
@@ -154,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
``` ```
@@ -218,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
@@ -791,6 +791,7 @@ keys:
function: is_recording_paused function: is_recording_paused
match: "Paused: true" match: "Paused: true"
interval: 2s 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. **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" "fmt"
"strings" "strings"
"github.com/WoodardDigital/streamdeck-go/internal/config" "git.i0t.app/lwoodard/streamdeck-go/internal/config"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )

View File

@@ -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"
) )

View File

@@ -23,9 +23,9 @@ 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"
@@ -921,7 +921,7 @@ func ensureConfigDir(cfgPath string) error {
func defaultConfig(iconsDir string) string { func defaultConfig(iconsDir string) string {
return `# streamdeck-go configuration return `# streamdeck-go configuration
# https://github.com/WoodardDigital/streamdeck-go # https://git.i0t.app/lwoodard/streamdeck-go
icons_dir: ` + iconsDir + ` icons_dir: ` + iconsDir + `
brightness: 70 brightness: 70

2
go.mod
View File

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

View File

@@ -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}"

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

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

View File

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

View File

@@ -0,0 +1,135 @@
#!/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)"
# Always update the state file so the next run sees a fresh baseline.
printf '%s' "$curr" > "$STATE_FILE"
# No device present — nothing to do. Don't touch the service.
if [[ -z "$curr" ]]; then
exit 0
fi
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"
fi
if [[ -n "$reason" ]]; then
echo "watchdog: $reason — restarting streamdeck-go"
restart_service
fi

View File

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

View File

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