adding OBS support
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user