1
0
streamdeck-lets-go/daemon.go
Maksim Totmin dfe6544686 Multi-device support, dynamic grid adaptation, keyboard action
- Multi-device: OpenAllDecks(), GET /api/decks, per-deck PageManagers
- Dynamic grid: frontend adapts to connected device layout
- Keyboard action: new action type for sending key combos via wtype/xdotool
- Key recorder UI: click-based modifier toggles + single key capture
- Config: remove hardcoded maxKey validation
- Screensaver and auto-switch sync across all decks
2026-06-16 22:51:39 +07:00

301 lines
6.8 KiB
Go

package main
import (
"context"
"log/slog"
"os"
"os/exec"
"time"
"streamdeck-lets-go/internal/config"
)
type RunOptions struct {
ConfigPath string
HTTPAddr string
HTTPEnabled bool
NoDeck bool
}
func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
web := NewWebServer(cfg, opts.ConfigPath)
if !opts.NoDeck || opts.HTTPEnabled {
checkKeyboardTool()
}
if opts.HTTPEnabled {
go func() {
if err := web.Serve(ctx, opts.HTTPAddr); err != nil {
slog.Error("web server error", "error", err)
}
}()
}
if opts.NoDeck {
<-ctx.Done()
return nil
}
var decks []*Deck
var err error
for {
decks, err = OpenAllDecks()
if err == nil {
break
}
slog.Warn("no stream deck found, retrying in 5s", "error", err)
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(5 * time.Second):
}
}
pageMgrs := make([]*PageManager, len(decks))
for i, d := range decks {
pageMgrs[i] = NewPageManager(d)
pageMgrs[i].LoadPages(cfg.Pages)
}
primaryPM := pageMgrs[0]
primaryDeck := decks[0]
defer func() {
for _, d := range decks {
d.Close()
}
}()
for _, d := range decks {
d.SetBrightness(deviceBrightness(cfg, d.Serial()))
}
web.SetDecks(decks)
web.SetPageManager(primaryPM)
for _, pm := range pageMgrs {
if err := pm.ActivatePage(cfg.DefaultPage); err != nil {
slog.Warn("activate default page", "error", err)
}
pm.startPeriodicKeys()
}
var windowCh <-chan Window
var detector WindowDetector
defer func() {
if detector != nil {
detector.Close()
}
}()
if len(cfg.AutoSwitch) > 0 {
detector = NewWindowDetector()
windowCh, _ = detector.Start(ctx)
}
asm := NewAutoSwitchManager(cfg.AutoSwitch, cfg.DefaultPage)
ssCtrl := NewScreensaver(&cfg.Screensaver)
reconnectTicker := time.NewTicker(5 * time.Second)
defer reconnectTicker.Stop()
ssTicker := time.NewTicker(5 * time.Second)
defer ssTicker.Stop()
configTicker := time.NewTicker(3 * time.Second)
defer configTicker.Stop()
var lastConfigMod time.Time
slog.Info("daemon started", "decks", len(decks))
defer slog.Info("daemon stopped")
for {
select {
case evt, ok := <-primaryDeck.Events():
if !ok {
slog.Warn("deck event channel closed, attempting reconnect...")
primaryDeck.Close()
newDeck := reconnectDeck(ctx, cfg, &primaryPM, asm)
if newDeck == nil {
return ctx.Err()
}
decks[0] = newDeck
primaryDeck = newDeck
web.SetDecks(decks)
newDeck.SetBrightness(deviceBrightness(cfg, newDeck.Serial()))
continue
}
wasSsActive := ssCtrl.IsActive()
ssCtrl.NotifyInput()
if evt.Kind == EventKeyPressed {
if wasSsActive {
ssCtrl.Deactivate(primaryDeck)
savedOutputs := primaryPM.GetDisplayOutputs()
if page := primaryPM.ActivePage(); page != nil {
for _, pm := range pageMgrs {
if err := pm.ActivatePage(primaryPM.ActivePageName()); err != nil {
slog.Warn("screensaver: re-render page", "error", err)
}
}
}
for idx, output := range savedOutputs {
primaryPM.ReRenderDisplayKey(idx, output)
}
}
page := primaryPM.ActivePage()
if page == nil {
continue
}
for _, k := range page.Keys {
if k.Index == evt.Index && k.Action != nil {
slog.Debug("key pressed", "index", evt.Index, "action", k.Action.Type)
if k.Action.Type == "page" {
asm.NotifyManualPage(k.Action.Page)
}
go func(a *config.Action) {
if err := ExecuteAction(a, primaryDeck, primaryPM); err != nil {
slog.Error("execute action", "error", err)
}
if a.Type == "page" {
for _, pm := range pageMgrs[1:] {
pm.ActivatePage(a.Page)
}
}
}(k.Action)
break
}
}
}
case win := <-windowCh:
if page, ok := asm.Evaluate(win, primaryPM.ActivePageName()); ok {
for _, pm := range pageMgrs {
if err := pm.ActivatePage(page); err != nil {
slog.Warn("auto-switch: activate page", "error", err)
}
pm.startPeriodicKeys()
}
}
case <-configTicker.C:
path := config.ConfigPath(opts.ConfigPath)
fi, err := os.Stat(path)
if err != nil {
continue
}
mt := fi.ModTime()
if mt.After(lastConfigMod) && !lastConfigMod.IsZero() {
lastConfigMod = mt
slog.Info("config file changed, reloading")
newCfg, err := config.LoadConfig(opts.ConfigPath)
if err != nil {
slog.Error("reload config", "error", err)
continue
}
cfg = newCfg
web.UpdateConfig(cfg)
ssCtrl = NewScreensaver(&cfg.Screensaver)
for _, d := range decks {
d.SetBrightness(deviceBrightness(cfg, d.Serial()))
}
for _, pm := range pageMgrs {
pm.stopPeriodicKeys()
pm.LoadPages(cfg.Pages)
}
asm.Reload(cfg.AutoSwitch)
if len(cfg.AutoSwitch) > 0 && detector == nil {
detector = NewWindowDetector()
windowCh, _ = detector.Start(ctx)
}
for _, pm := range pageMgrs {
if err := pm.ActivatePage(cfg.DefaultPage); err != nil {
slog.Warn("reload: activate default page", "error", err)
}
pm.startPeriodicKeys()
}
}
if lastConfigMod.IsZero() {
lastConfigMod = mt
}
case <-reconnectTicker.C:
if err := primaryDeck.SetBrightness(primaryDeck.Brightness()); err != nil {
slog.Warn("deck connection lost, reconnecting...", "error", err)
primaryDeck.Close()
newDeck := reconnectDeck(ctx, cfg, &primaryPM, asm)
if newDeck == nil {
return ctx.Err()
}
decks[0] = newDeck
primaryDeck = newDeck
web.SetDecks(decks)
newDeck.SetBrightness(deviceBrightness(cfg, newDeck.Serial()))
}
case <-ssTicker.C:
if ssCtrl.Check() {
for _, d := range decks {
ssCtrl.Activate(d, &cfg.Screensaver)
}
}
case <-ctx.Done():
return nil
}
}
}
func checkKeyboardTool() {
if os.Getenv("WAYLAND_DISPLAY") != "" {
if _, err := exec.LookPath("wtype"); err != nil {
slog.Warn("keyboard actions require wtype on Wayland — install it (e.g. apk add wtype) and restart")
}
} else {
if _, err := exec.LookPath("xdotool"); err != nil {
slog.Warn("keyboard actions require xdotool on X11 — install it (e.g. apk add xdotool) and restart")
}
}
}
func deviceBrightness(cfg *config.Config, serial string) int {
for _, d := range cfg.Devices {
if d.Serial == serial || d.Serial == "" {
if d.Brightness > 0 {
return d.Brightness
}
return 75
}
}
return 75
}
func reconnectDeck(ctx context.Context, cfg *config.Config, pm **PageManager, asm *AutoSwitchManager) *Deck {
for {
newDeck, err := OpenDeck("")
if err == nil {
*pm = NewPageManager(newDeck)
(*pm).LoadPages(cfg.Pages)
(*pm).ActivatePage(cfg.DefaultPage)
(*pm).startPeriodicKeys()
asm.NotifyManualPage(cfg.DefaultPage)
return newDeck
}
select {
case <-ctx.Done():
return nil
case <-time.After(3 * time.Second):
}
}
}