Compare commits

...

3 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
5 changed files with 296 additions and 22 deletions

View File

@@ -207,6 +207,43 @@ func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *mo
cfg.Keys[keyIdx] = keyCfg 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. // Toggle/status key: managed by a polling goroutine.
if keyCfg.Poll != nil { if keyCfg.Poll != nil {
if keyCfg.IconTrue == "" || keyCfg.IconFalse == "" { if keyCfg.IconTrue == "" || keyCfg.IconFalse == "" {
@@ -488,6 +525,119 @@ func queryPollState(poll *config.PollConfig) int {
return 0 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. // mustConnect blocks until the device opens successfully.
// //
// Strategy: try quickly at first (device may just be enumerating), then settle // 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. // Outline offsets for readability on any background.
offsets := [8]image.Point{ offsets := [8]image.Point{
{-1, -1}, {0, -1}, {1, -1}, {-1, -1},
{-1, 0}, {1, 0}, {0, -1},
{-1, 1}, {0, 1}, {1, 1}, {1, -1},
{-1, 0},
{1, 0},
{-1, 1},
{0, 1},
{1, 1},
} }
for i, line := range lines { for i, line := range lines {
@@ -910,11 +1065,11 @@ func defaultConfigPath() string {
func ensureConfigDir(cfgPath string) error { func ensureConfigDir(cfgPath string) error {
dir := filepath.Dir(cfgPath) dir := filepath.Dir(cfgPath)
iconsDir := filepath.Join(dir, "icons") iconsDir := filepath.Join(dir, "icons")
if err := os.MkdirAll(iconsDir, 0755); err != nil { if err := os.MkdirAll(iconsDir, 0o755); err != nil {
return err return err
} }
if _, err := os.Stat(cfgPath); os.IsNotExist(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 return nil
} }
@@ -946,4 +1101,3 @@ device:
keys: {} keys: {}
` `
} }

View File

@@ -61,3 +61,48 @@ keys:
# command: pactl get-source-mute @DEFAULT_SOURCE@ # command: pactl get-source-mute @DEFAULT_SOURCE@
# interval: 2s # interval: 2s
# match: "yes" # 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

View File

@@ -27,6 +27,12 @@ type KeyConfig struct {
TextColor string `yaml:"text_color"` // text color: "white" (default), "black", "red", "blue", or hex "#RRGGBB" 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
// 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. // Toggle/status keys: show different icons based on polled state.
IconTrue string `yaml:"icon_true"` // icon when poll match is true IconTrue string `yaml:"icon_true"` // icon when poll match is true
IconFalse string `yaml:"icon_false"` // icon when poll match is false IconFalse string `yaml:"icon_false"` // icon when poll match is false

View File

@@ -57,6 +57,30 @@ 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"}}"
# 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 # 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)

View File

@@ -51,20 +51,23 @@ current_addr() {
' '
;; ;;
Darwin) Darwin)
# system_profiler entry per device: # ioreg is the reliable source on macOS — system_profiler SPUSBDataType
# Stream Deck XL: # silently omits the deck on some machines, and its "Location ID: X / N"
# Product ID: 0x00ba # trailing bus number flaps spuriously without an actual replug.
# Vendor ID: 0x0fd9 (Elgato ...) #
# ... # sessionID is unique per USB enumeration session: stable while the
# Location ID: 0x14140000 / 5 # device stays plugged in, changes on every replug. Exactly what we want.
# The trailing "/ N" is the bus address — it changes on replug. #
system_profiler SPUSBDataType 2>/dev/null | awk -v pids="$PIDS_RE" ' # Elgato vendor in decimal: 4057 (0x0fd9).
/^[[:space:]]*Product ID:/ { pid = $3 } # Stream Deck product IDs in decimal: 186 (0x00ba), 108 (0x006c), 109 (0x006d).
/^[[:space:]]*Vendor ID:/ { vid = $3 } ioreg -p IOUSB -l -w 0 2>/dev/null | awk '
/^[[:space:]]*Location ID:/ { /<class IOUSBHostDevice/ { vid=""; pid=""; sid="" }
sub(/^[[:space:]]*Location ID:[[:space:]]*/, "") /"idVendor"/ { vid=$NF }
if (vid == "0x0fd9" && pid ~ ("^0x(" pids ")$")) { /"idProduct"/ { pid=$NF }
print /"sessionID"/ { sid=$NF }
/}/ {
if (vid == "4057" && (pid == "186" || pid == "108" || pid == "109") && sid != "") {
print sid
exit exit
} }
} }
@@ -106,14 +109,52 @@ prev=""
curr="$(current_addr)" curr="$(current_addr)"
# Always update the state file so the next run sees a fresh baseline. # 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" printf '%s' "$curr" > "$STATE_FILE"
fi
# No device present — nothing to do. Don't touch the service. # No device present — nothing to do. Don't touch the service.
if [[ -z "$curr" ]]; then if [[ -z "$curr" ]]; then
exit 0 exit 0
fi 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="" reason=""
if [[ -z "$prev" ]]; then if [[ -z "$prev" ]]; then
@@ -127,6 +168,10 @@ elif [[ "$curr" != "$prev" ]]; then
reason="device address changed: $prev$curr (likely unplug/replug)" reason="device address changed: $prev$curr (likely unplug/replug)"
elif ! service_active; then elif ! service_active; then
reason="device present at $curr but service is not active" 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 fi
if [[ -n "$reason" ]]; then if [[ -n "$reason" ]]; then