adding OBS support

This commit is contained in:
lwoodard
2026-04-16 15:23:46 -06:00
parent ae1ab88d4a
commit 6f2290bd6c
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,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()
}