From dfe65446865842d94367ba87d896a60d9e660041 Mon Sep 17 00:00:00 2001 From: Maksim Totmin Date: Tue, 16 Jun 2026 22:51:39 +0700 Subject: [PATCH] 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 --- .gitignore | 1 + daemon.go | 136 +++++++++++++++++++++++++------------- deck.go | 45 +++++++++++++ internal/config/config.go | 4 -- static/app.js | 23 ++++++- static/index.html | 10 ++- static/styles.css | 28 +++++++- web.go | 22 ++++++ 8 files changed, 216 insertions(+), 53 deletions(-) diff --git a/.gitignore b/.gitignore index 1e98506..f806f1b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ streamdeck-lets-go *.out *.exe /images/ +PLAN.md diff --git a/daemon.go b/daemon.go index 8c39785..e87e781 100644 --- a/daemon.go +++ b/daemon.go @@ -37,11 +37,11 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error { return nil } - var deck *Deck + var decks []*Deck var err error for { - deck, err = OpenDeck("") + decks, err = OpenAllDecks() if err == nil { break } @@ -52,18 +52,34 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error { case <-time.After(5 * time.Second): } } - defer deck.Close() - deck.SetBrightness(deviceBrightness(cfg)) - - pm := NewPageManager(deck) - pm.LoadPages(cfg.Pages) - web.SetPageManager(pm) - - if err := pm.ActivatePage(cfg.DefaultPage); err != nil { - slog.Warn("activate default page", "error", err) + 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() } - pm.startPeriodicKeys() var windowCh <-chan Window var detector WindowDetector @@ -93,20 +109,23 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error { defer configTicker.Stop() var lastConfigMod time.Time - slog.Info("daemon started") + slog.Info("daemon started", "decks", len(decks)) defer slog.Info("daemon stopped") for { select { - case evt, ok := <-deck.Events(): + case evt, ok := <-primaryDeck.Events(): if !ok { slog.Warn("deck event channel closed, attempting reconnect...") - deck.Close() - deck = reconnectDeck(ctx, cfg, &pm, asm) - if deck == nil { + primaryDeck.Close() + newDeck := reconnectDeck(ctx, cfg, &primaryPM, asm) + if newDeck == nil { return ctx.Err() } - deck.SetBrightness(deviceBrightness(cfg)) + decks[0] = newDeck + primaryDeck = newDeck + web.SetDecks(decks) + newDeck.SetBrightness(deviceBrightness(cfg, newDeck.Serial())) continue } @@ -115,22 +134,24 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error { if evt.Kind == EventKeyPressed { if wasSsActive { - ssCtrl.Deactivate(deck) + ssCtrl.Deactivate(primaryDeck) - savedOutputs := pm.GetDisplayOutputs() + savedOutputs := primaryPM.GetDisplayOutputs() - if page := pm.ActivePage(); page != nil { - if err := pm.ActivatePage(pm.ActivePageName()); err != nil { - slog.Warn("screensaver: re-render page", "error", err) + 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 { - pm.ReRenderDisplayKey(idx, output) + primaryPM.ReRenderDisplayKey(idx, output) } } - page := pm.ActivePage() + page := primaryPM.ActivePage() if page == nil { continue } @@ -143,9 +164,14 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error { } go func(a *config.Action) { - if err := ExecuteAction(a, deck, pm); err != nil { + 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 } @@ -153,15 +179,17 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error { } case win := <-windowCh: - if page, ok := asm.Evaluate(win, pm.ActivePageName()); ok { - if err := pm.ActivatePage(page); err != nil { - slog.Warn("auto-switch: activate page", "error", err) + 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() } - pm.startPeriodicKeys() } case <-configTicker.C: - path := config.ConfigPath(opts.ConfigPath) + path := config.ConfigPath(opts.ConfigPath) fi, err := os.Stat(path) if err != nil { continue @@ -178,37 +206,48 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error { cfg = newCfg web.UpdateConfig(cfg) ssCtrl = NewScreensaver(&cfg.Screensaver) - deck.SetBrightness(deviceBrightness(cfg)) - pm.stopPeriodicKeys() - pm.LoadPages(cfg.Pages) + 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) } - if err := pm.ActivatePage(cfg.DefaultPage); err != nil { - slog.Warn("reload: activate default page", "error", err) + for _, pm := range pageMgrs { + if err := pm.ActivatePage(cfg.DefaultPage); err != nil { + slog.Warn("reload: activate default page", "error", err) + } + pm.startPeriodicKeys() } - pm.startPeriodicKeys() } if lastConfigMod.IsZero() { lastConfigMod = mt } case <-reconnectTicker.C: - if err := deck.SetBrightness(deck.Brightness()); err != nil { + if err := primaryDeck.SetBrightness(primaryDeck.Brightness()); err != nil { slog.Warn("deck connection lost, reconnecting...", "error", err) - deck.Close() - deck = reconnectDeck(ctx, cfg, &pm, asm) - if deck == nil { + primaryDeck.Close() + newDeck := reconnectDeck(ctx, cfg, &primaryPM, asm) + if newDeck == nil { return ctx.Err() } - deck.SetBrightness(deviceBrightness(cfg)) + decks[0] = newDeck + primaryDeck = newDeck + web.SetDecks(decks) + newDeck.SetBrightness(deviceBrightness(cfg, newDeck.Serial())) } case <-ssTicker.C: if ssCtrl.Check() { - ssCtrl.Activate(deck, &cfg.Screensaver) + for _, d := range decks { + ssCtrl.Activate(d, &cfg.Screensaver) + } } case <-ctx.Done(): @@ -229,9 +268,14 @@ func checkKeyboardTool() { } } -func deviceBrightness(cfg *config.Config) int { - if len(cfg.Devices) > 0 && cfg.Devices[0].Brightness > 0 { - return cfg.Devices[0].Brightness +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 } diff --git a/deck.go b/deck.go index 58d6c4d..27d3490 100644 --- a/deck.go +++ b/deck.go @@ -163,6 +163,51 @@ func (d *Deck) Events() <-chan Event { return d.events } func (d *Deck) NumKeys() int { return d.cfg.KeysX * d.cfg.KeysY } func (d *Deck) KeySize() int { return d.cfg.KeySize } func (d *Deck) Config() DeckConfig { return d.cfg } +func (d *Deck) Model() string { return d.cfg.Name } + +type DeckInfo struct { + Serial string `json:"serial"` + Model string `json:"model"` + KeysX int `json:"keys_x"` + KeysY int `json:"keys_y"` + NumKeys int `json:"num_keys"` + KeySize int `json:"key_size"` +} + +func (d *Deck) DeckInfo() DeckInfo { + return DeckInfo{ + Serial: d.serial, + Model: d.cfg.Name, + KeysX: d.cfg.KeysX, + KeysY: d.cfg.KeysY, + NumKeys: d.cfg.KeysX * d.cfg.KeysY, + KeySize: d.cfg.KeySize, + } +} + +func OpenAllDecks() ([]*Deck, error) { + devices := hid.Enumerate(streamdeck.VendorID, 0) + if len(devices) == 0 { + return nil, fmt.Errorf("no stream deck devices found") + } + var decks []*Deck + for _, d := range devices { + if _, ok := findConfig(d.ProductID); !ok { + slog.Warn("skipping unsupported device", "pid", fmt.Sprintf("0x%04x", d.ProductID)) + continue + } + deck, err := OpenDeck(d.Serial) + if err != nil { + slog.Warn("failed to open deck", "serial", d.Serial, "error", err) + continue + } + decks = append(decks, deck) + } + if len(decks) == 0 { + return nil, fmt.Errorf("no supported stream deck devices could be opened") + } + return decks, nil +} func (d *Deck) SetBrightness(val int) error { if val < 0 { diff --git a/internal/config/config.go b/internal/config/config.go index 953b865..f44961c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -160,14 +160,10 @@ func (c *Config) Validate() error { pageNames[p.Name] = true seenKeys := make(map[int]bool) - maxKey := 31 for _, k := range p.Keys { if k.Index < 0 { return fmt.Errorf("page %s: key index must be >= 0", p.Name) } - if k.Index > maxKey { - return fmt.Errorf("page %s: key index %d exceeds max %d", p.Name, k.Index, maxKey) - } if seenKeys[k.Index] { return fmt.Errorf("page %s: duplicate key index %d", p.Name, k.Index) } diff --git a/static/app.js b/static/app.js index c114d40..04c4d2d 100644 --- a/static/app.js +++ b/static/app.js @@ -10,6 +10,8 @@ document.addEventListener('alpine:init', () => { toast: null, displayOutputs: {}, isCapturingKey: false, + decks: [], + activeDeckSerial: '', // ── Edit state ── editing: null, @@ -25,9 +27,22 @@ document.addEventListener('alpine:init', () => { // ── Init ── async init() { await this.loadConfig() + await this.loadDecks() setInterval(() => this.pollDisplayOutputs(), 3000) }, + async loadDecks() { + try { + const res = await fetch('/api/decks') + if (res.ok) { + this.decks = await res.json() + if (this.decks.length > 0 && !this.activeDeckSerial) { + this.activeDeckSerial = this.decks[0].serial + } + } + } catch (_) {} + }, + async pollDisplayOutputs() { if (this.view !== 'grid') return try { @@ -69,12 +84,18 @@ document.addEventListener('alpine:init', () => { return this.pages.map(p => p.name) }, + get activeDeck() { + return this.decks.find(d => d.serial === this.activeDeckSerial) + || { keys_x: 5, keys_y: 3, num_keys: 15 } + }, + // ── Grid ── get gridKeys() { const page = this.currentPage if (!page) return [] const keys = [] - for (let i = 0; i < 15; i++) { + const n = this.activeDeck.num_keys + for (let i = 0; i < n; i++) { const kc = page.keys.find(k => k.index === i) keys.push({ index: i, diff --git a/static/index.html b/static/index.html index 81efeb6..f5ce54e 100644 --- a/static/index.html +++ b/static/index.html @@ -45,7 +45,15 @@
-
+
+ +
+