From 6f2290bd6c69d7a358802c0be4d5495190ca4920 Mon Sep 17 00:00:00 2001 From: lwoodard <92559124+WoodardDigital@users.noreply.github.com> Date: Thu, 16 Apr 2026 15:23:46 -0600 Subject: [PATCH] adding OBS support --- cmd/streamdeck/main.go | 147 +++++++++++++++++++++---- install.sh | 47 ++++++++ internal/defaults/modules.example.yaml | 73 ++++++++++++ internal/modules/modules.go | 9 ++ modules.example.yaml | 73 ++++++++++++ 5 files changed, 326 insertions(+), 23 deletions(-) diff --git a/cmd/streamdeck/main.go b/cmd/streamdeck/main.go index c571662..306b5d0 100644 --- a/cmd/streamdeck/main.go +++ b/cmd/streamdeck/main.go @@ -47,6 +47,14 @@ func main() { log.Fatalf("setup: %v", err) } + // Load .env file from the config directory so secrets (tokens, passwords) + // are available via {{env "VAR"}} in module templates without requiring + // shell exports or service-level environment configuration. + envPath := filepath.Join(filepath.Dir(*cfgPath), ".env") + if err := loadEnvFile(envPath); err != nil { + log.Printf("warn: %v", err) + } + cfg, err := config.Load(*cfgPath) if err != nil { log.Fatalf("config: %v", err) @@ -321,31 +329,97 @@ func pollKey(ctx context.Context, sd *device.StreamDeck, keyIdx int, keyCfg conf } } - imgTrue, err := loadImage(filepath.Join(iconsDir, keyCfg.IconTrue)) - if err != nil { - log.Printf("key %d: load icon_true %q: %v", keyIdx, keyCfg.IconTrue, err) - return + // Check if icon_true is an animated GIF. + trueIsGIF := strings.ToLower(filepath.Ext(keyCfg.IconTrue)) == ".gif" + falseIsGIF := strings.ToLower(filepath.Ext(keyCfg.IconFalse)) == ".gif" + + // Pre-load GIF frames for whichever icons are GIFs. + var trueFrames [][]byte + var trueDelays []time.Duration + var falseFrames [][]byte + var falseDelays []time.Duration + var imgTrue, imgFalse image.Image + + if trueIsGIF { + var err error + trueFrames, trueDelays, err = loadGIF(sd, filepath.Join(iconsDir, keyCfg.IconTrue)) + if err != nil { + log.Printf("key %d: load icon_true gif %q: %v", keyIdx, keyCfg.IconTrue, err) + return + } + } else { + var err error + imgTrue, err = loadImage(filepath.Join(iconsDir, keyCfg.IconTrue)) + if err != nil { + log.Printf("key %d: load icon_true %q: %v", keyIdx, keyCfg.IconTrue, err) + return + } } - imgFalse, err := loadImage(filepath.Join(iconsDir, keyCfg.IconFalse)) - if err != nil { - log.Printf("key %d: load icon_false %q: %v", keyIdx, keyCfg.IconFalse, err) - return + + if falseIsGIF { + var err error + falseFrames, falseDelays, err = loadGIF(sd, filepath.Join(iconsDir, keyCfg.IconFalse)) + if err != nil { + log.Printf("key %d: load icon_false gif %q: %v", keyIdx, keyCfg.IconFalse, err) + return + } + } else { + var err error + imgFalse, err = loadImage(filepath.Join(iconsDir, keyCfg.IconFalse)) + if err != nil { + log.Printf("key %d: load icon_false %q: %v", keyIdx, keyCfg.IconFalse, err) + return + } } lastState := -1 // unknown, forces initial icon set + var animCancel context.CancelFunc + var animWg sync.WaitGroup + + stopAnim := func() { + if animCancel != nil { + animCancel() + animWg.Wait() + animCancel = nil + } + } applyState := func() { state := queryPollState(keyCfg.Poll) if state == lastState { return } + stopAnim() lastState = state - img := imgFalse + if state == 1 { - img = imgTrue - } - if err := sd.SetKeyImage(keyIdx, img); err != nil { - log.Printf("key %d: set poll icon: %v", keyIdx, err) + if trueIsGIF { + animCtx, cancel := context.WithCancel(ctx) + animCancel = cancel + animWg.Add(1) + go func() { + defer animWg.Done() + animateKey(animCtx, sd, keyIdx, trueFrames, trueDelays) + }() + } else { + if err := sd.SetKeyImage(keyIdx, imgTrue); err != nil { + log.Printf("key %d: set poll icon: %v", keyIdx, err) + } + } + } else { + if falseIsGIF { + animCtx, cancel := context.WithCancel(ctx) + animCancel = cancel + animWg.Add(1) + go func() { + defer animWg.Done() + animateKey(animCtx, sd, keyIdx, falseFrames, falseDelays) + }() + } else { + if err := sd.SetKeyImage(keyIdx, imgFalse); err != nil { + log.Printf("key %d: set poll icon: %v", keyIdx, err) + } + } } } @@ -357,6 +431,7 @@ func pollKey(ctx context.Context, sd *device.StreamDeck, keyIdx int, keyCfg conf for { select { case <-ctx.Done(): + stopAnim() return case <-ticker.C: applyState() @@ -364,6 +439,7 @@ func pollKey(ctx context.Context, sd *device.StreamDeck, keyIdx int, keyCfg conf // Wait briefly for the toggle command to take effect, then re-poll. select { case <-ctx.Done(): + stopAnim() return case <-time.After(400 * time.Millisecond): } @@ -377,7 +453,7 @@ func pollKey(ctx context.Context, sd *device.StreamDeck, keyIdx int, keyCfg conf // If Match is empty, uses exit code: 0 → on, non-zero → off. func queryPollState(poll *config.PollConfig) int { cmd := exec.Command("sh", "-c", poll.Command) - output, err := cmd.Output() + output, err := cmd.CombinedOutput() if poll.Match == "" { if err == nil { return 1 @@ -434,18 +510,10 @@ func animateKey(ctx context.Context, sd *device.StreamDeck, keyIdx int, frames [ } if err := sd.SetKeyFrame(keyIdx, frame); err != nil { writeErrors++ - if writeErrors == 1 { - // Log the first error so the user knows something happened. - log.Printf("key %d: frame write error: %v", keyIdx, err) - } else if writeErrors == 50 { - // Persistent errors — likely a real device issue, not transient. + if writeErrors == 50 { log.Printf("key %d: %d consecutive frame write errors — device may be unhealthy", keyIdx, writeErrors) } } else if writeErrors > 0 { - // Recovered — log a summary if we suppressed errors. - if writeErrors > 1 { - log.Printf("key %d: recovered after %d frame write errors", keyIdx, writeErrors) - } writeErrors = 0 } select { @@ -617,6 +685,39 @@ func loadPrivilegedCommands(path string) (map[string]string, error) { return wl.Commands, nil } +// loadEnvFile reads a .env file and sets each KEY=VALUE pair in the process +// environment. Blank lines and lines starting with # are ignored. Quoted values +// (single or double) are unquoted. A missing file is silently ignored. +func loadEnvFile(path string) error { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("load env %q: %w", path, err) + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, val, ok := strings.Cut(line, "=") + if !ok { + continue + } + key = strings.TrimSpace(key) + val = strings.TrimSpace(val) + // Strip matching quotes around value. + if len(val) >= 2 && + ((val[0] == '"' && val[len(val)-1] == '"') || + (val[0] == '\'' && val[len(val)-1] == '\'')) { + val = val[1 : len(val)-1] + } + os.Setenv(key, val) + } + return nil +} + func defaultConfigPath() string { return config.DefaultConfigPath() } diff --git a/install.sh b/install.sh index f8ff80a..f3a8d35 100755 --- a/install.sh +++ b/install.sh @@ -219,6 +219,46 @@ else exit 1 fi fi + +# obs-cmd (optional — required for OBS module) +if command -v obs-cmd &>/dev/null; then + ok "obs-cmd found" +else + info "obs-cmd not found (required for OBS Studio module)" + if prompt_yn "Install obs-cmd now?" "y"; then + OBS_CMD_VERSION=$(curl -sL "https://api.github.com/repos/grigio/obs-cmd/releases/latest" | grep '"tag_name"' | head -1 | sed 's/.*"v\(.*\)".*/\1/') + if [[ -z "$OBS_CMD_VERSION" ]]; then + warn "Could not fetch obs-cmd release — install manually: cargo install obs-cmd" + elif $IS_MAC; then + # Detect architecture — Apple Silicon vs Intel + ARCH="$(uname -m)" + if [[ "$ARCH" == "arm64" ]]; then + OBS_CMD_ASSET="obs-cmd-arm64-macos.tar.gz" + else + OBS_CMD_ASSET="obs-cmd-x64-macos.tar.gz" + fi + step "Downloading obs-cmd v${OBS_CMD_VERSION} (${ARCH})..." + curl -sL "https://github.com/grigio/obs-cmd/releases/download/v${OBS_CMD_VERSION}/${OBS_CMD_ASSET}" \ + | tar xz -C /tmp + install -m 755 /tmp/obs-cmd /usr/local/bin/obs-cmd + rm -f /tmp/obs-cmd + else + # Linux — x86_64 musl static binary + step "Downloading obs-cmd v${OBS_CMD_VERSION}..." + curl -sL "https://github.com/grigio/obs-cmd/releases/download/v${OBS_CMD_VERSION}/obs-cmd-v${OBS_CMD_VERSION}-x86_64-unknown-linux-musl.tar.gz" \ + | tar xz -C /tmp + sudo install -m 755 /tmp/obs-cmd /usr/local/bin/obs-cmd + rm -f /tmp/obs-cmd + fi + if command -v obs-cmd &>/dev/null; then + ok "obs-cmd installed" + else + warn "obs-cmd install may have failed — OBS module won't work until it's available" + fi + else + info "Skipped — OBS module will not work until obs-cmd is installed" + fi +fi nl # ── 2. Build ─────────────────────────────────────────────────────────────────── @@ -328,6 +368,13 @@ else ok "config.yaml already exists — not overwritten" fi +# Always install/update modules.yaml — this is a registry of available module +# definitions, not user data. User customisations go in config.yaml (params +# overrides per key). Keeping modules.yaml current ensures new modules (OBS, +# Slack, etc.) are available immediately after upgrade. +install -m 644 modules.example.yaml "${CONFIG_DIR}/modules.yaml" +ok "modules.yaml installed (updated to latest)" + # Copy bundled icons (never overwrite existing ones the user may have customised). if [[ -d "icons" ]]; then copied=0 diff --git a/internal/defaults/modules.example.yaml b/internal/defaults/modules.example.yaml index 39df2f8..d8e1157 100644 --- a/internal/defaults/modules.example.yaml +++ b/internal/defaults/modules.example.yaml @@ -56,3 +56,76 @@ modules: exec: | curl -s -X POST https://slack.com/api/dnd.endSnooze \ -H "Authorization: Bearer {{env "SLACK_TOKEN"}}" + + # OBS Studio — media player and streaming control via obs-cmd + # + # Requires: obs-cmd (https://github.com/grigio/obs-cmd) + # macOS: brew install grigio/obs-cmd/obs-cmd + # Linux: cargo install obs-cmd (or download binary from GitHub releases) + # + # OBS WebSocket must be enabled: Tools → WebSocket Server Settings (on by default in OBS 28+) + # + # Add to ~/.config/streamdeck-go/.env: + # OBS_WEBSOCKET_PASSWORD=your-password + # OBS_HOST=localhost (optional, default: localhost) + # OBS_PORT=4455 (optional, default: 4455) + obs: + play: + params: + source: "Media Source" + exec: | + /usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} media-input play "{{.source}}" + + pause: + params: + source: "Media Source" + exec: | + /usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} media-input pause "{{.source}}" + + stop: + params: + source: "Media Source" + exec: | + /usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} media-input stop "{{.source}}" + + restart: + params: + source: "Media Source" + exec: | + /usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} media-input restart "{{.source}}" + + toggle_record: + exec: | + /usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} recording toggle + + toggle_record_pause: + exec: | + /usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} recording toggle-pause + + is_recording_paused: + exec: | + /usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} recording status + + toggle_stream: + exec: | + /usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} streaming toggle + + scene_switch: + params: + scene: "Scene 1" + exec: | + /usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene switch "{{.scene}}" + + toggle_mute: + params: + source: "Mic/Aux" + exec: | + /usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} input toggle-mute "{{.source}}" + + is_recording: + exec: | + /usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} recording status + + is_streaming: + exec: | + /usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} streaming status diff --git a/internal/modules/modules.go b/internal/modules/modules.go index 066e540..18ee6ca 100644 --- a/internal/modules/modules.go +++ b/internal/modules/modules.go @@ -100,6 +100,15 @@ func funcMap() template.FuncMap { // Use this to keep secrets (tokens, passwords) out of modules.yaml. "env": os.Getenv, + // envDefault returns the value of an environment variable, or + // fallback if the variable is empty/unset. + "envDefault": func(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback + }, + // expiry converts a duration string like "+1h" to a Unix epoch timestamp string. // Pass "" or "0" to get "0" (no expiry). Supports Go duration syntax (e.g. "30m", "2h"). // Note: time.ParseDuration does not support days (d) or weeks (w). diff --git a/modules.example.yaml b/modules.example.yaml index 39df2f8..d8e1157 100644 --- a/modules.example.yaml +++ b/modules.example.yaml @@ -56,3 +56,76 @@ modules: exec: | curl -s -X POST https://slack.com/api/dnd.endSnooze \ -H "Authorization: Bearer {{env "SLACK_TOKEN"}}" + + # OBS Studio — media player and streaming control via obs-cmd + # + # Requires: obs-cmd (https://github.com/grigio/obs-cmd) + # macOS: brew install grigio/obs-cmd/obs-cmd + # Linux: cargo install obs-cmd (or download binary from GitHub releases) + # + # OBS WebSocket must be enabled: Tools → WebSocket Server Settings (on by default in OBS 28+) + # + # Add to ~/.config/streamdeck-go/.env: + # OBS_WEBSOCKET_PASSWORD=your-password + # OBS_HOST=localhost (optional, default: localhost) + # OBS_PORT=4455 (optional, default: 4455) + obs: + play: + params: + source: "Media Source" + exec: | + /usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} media-input play "{{.source}}" + + pause: + params: + source: "Media Source" + exec: | + /usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} media-input pause "{{.source}}" + + stop: + params: + source: "Media Source" + exec: | + /usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} media-input stop "{{.source}}" + + restart: + params: + source: "Media Source" + exec: | + /usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} media-input restart "{{.source}}" + + toggle_record: + exec: | + /usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} recording toggle + + toggle_record_pause: + exec: | + /usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} recording toggle-pause + + is_recording_paused: + exec: | + /usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} recording status + + toggle_stream: + exec: | + /usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} streaming toggle + + scene_switch: + params: + scene: "Scene 1" + exec: | + /usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} scene switch "{{.scene}}" + + toggle_mute: + params: + source: "Mic/Aux" + exec: | + /usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} input toggle-mute "{{.source}}" + + is_recording: + exec: | + /usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} recording status + + is_streaming: + exec: | + /usr/local/bin/obs-cmd --websocket obsws://{{envDefault "OBS_HOST" "localhost"}}:{{envDefault "OBS_PORT" "4455"}}/{{env "OBS_WEBSOCKET_PASSWORD"}} streaming status