adding OBS support #1

Merged
lwoodard merged 1 commits from obs-function into main 2026-04-16 21:25:18 +00:00
5 changed files with 326 additions and 23 deletions

View File

@@ -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,33 +329,99 @@ func pollKey(ctx context.Context, sd *device.StreamDeck, keyIdx int, keyCfg conf
}
}
imgTrue, err := loadImage(filepath.Join(iconsDir, keyCfg.IconTrue))
// 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 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 {
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)
}
}
}
}
applyState() // set icon immediately on startup
@@ -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()
}

View File

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

View File

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

View File

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

View File

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