1
0

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
This commit is contained in:
Maksim Totmin 2026-06-16 22:51:39 +07:00
parent f65ff75a43
commit d5998e81cf
8 changed files with 216 additions and 53 deletions

1
.gitignore vendored
View File

@ -3,3 +3,4 @@ streamdeck-lets-go
*.out *.out
*.exe *.exe
/images/ /images/
PLAN.md

106
daemon.go
View File

@ -37,11 +37,11 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
return nil return nil
} }
var deck *Deck var decks []*Deck
var err error var err error
for { for {
deck, err = OpenDeck("") decks, err = OpenAllDecks()
if err == nil { if err == nil {
break break
} }
@ -52,18 +52,34 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
case <-time.After(5 * time.Second): case <-time.After(5 * time.Second):
} }
} }
defer deck.Close()
deck.SetBrightness(deviceBrightness(cfg)) 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]
pm := NewPageManager(deck) defer func() {
pm.LoadPages(cfg.Pages) for _, d := range decks {
web.SetPageManager(pm) 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 { if err := pm.ActivatePage(cfg.DefaultPage); err != nil {
slog.Warn("activate default page", "error", err) slog.Warn("activate default page", "error", err)
} }
pm.startPeriodicKeys() pm.startPeriodicKeys()
}
var windowCh <-chan Window var windowCh <-chan Window
var detector WindowDetector var detector WindowDetector
@ -93,20 +109,23 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
defer configTicker.Stop() defer configTicker.Stop()
var lastConfigMod time.Time var lastConfigMod time.Time
slog.Info("daemon started") slog.Info("daemon started", "decks", len(decks))
defer slog.Info("daemon stopped") defer slog.Info("daemon stopped")
for { for {
select { select {
case evt, ok := <-deck.Events(): case evt, ok := <-primaryDeck.Events():
if !ok { if !ok {
slog.Warn("deck event channel closed, attempting reconnect...") slog.Warn("deck event channel closed, attempting reconnect...")
deck.Close() primaryDeck.Close()
deck = reconnectDeck(ctx, cfg, &pm, asm) newDeck := reconnectDeck(ctx, cfg, &primaryPM, asm)
if deck == nil { if newDeck == nil {
return ctx.Err() return ctx.Err()
} }
deck.SetBrightness(deviceBrightness(cfg)) decks[0] = newDeck
primaryDeck = newDeck
web.SetDecks(decks)
newDeck.SetBrightness(deviceBrightness(cfg, newDeck.Serial()))
continue continue
} }
@ -115,22 +134,24 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
if evt.Kind == EventKeyPressed { if evt.Kind == EventKeyPressed {
if wasSsActive { if wasSsActive {
ssCtrl.Deactivate(deck) ssCtrl.Deactivate(primaryDeck)
savedOutputs := pm.GetDisplayOutputs() savedOutputs := primaryPM.GetDisplayOutputs()
if page := pm.ActivePage(); page != nil { if page := primaryPM.ActivePage(); page != nil {
if err := pm.ActivatePage(pm.ActivePageName()); err != nil { for _, pm := range pageMgrs {
if err := pm.ActivatePage(primaryPM.ActivePageName()); err != nil {
slog.Warn("screensaver: re-render page", "error", err) slog.Warn("screensaver: re-render page", "error", err)
} }
} }
}
for idx, output := range savedOutputs { for idx, output := range savedOutputs {
pm.ReRenderDisplayKey(idx, output) primaryPM.ReRenderDisplayKey(idx, output)
} }
} }
page := pm.ActivePage() page := primaryPM.ActivePage()
if page == nil { if page == nil {
continue continue
} }
@ -143,9 +164,14 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
} }
go func(a *config.Action) { 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) slog.Error("execute action", "error", err)
} }
if a.Type == "page" {
for _, pm := range pageMgrs[1:] {
pm.ActivatePage(a.Page)
}
}
}(k.Action) }(k.Action)
break break
} }
@ -153,12 +179,14 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
} }
case win := <-windowCh: case win := <-windowCh:
if page, ok := asm.Evaluate(win, pm.ActivePageName()); ok { if page, ok := asm.Evaluate(win, primaryPM.ActivePageName()); ok {
for _, pm := range pageMgrs {
if err := pm.ActivatePage(page); err != nil { if err := pm.ActivatePage(page); err != nil {
slog.Warn("auto-switch: activate page", "error", err) slog.Warn("auto-switch: activate page", "error", err)
} }
pm.startPeriodicKeys() pm.startPeriodicKeys()
} }
}
case <-configTicker.C: case <-configTicker.C:
path := config.ConfigPath(opts.ConfigPath) path := config.ConfigPath(opts.ConfigPath)
@ -178,37 +206,48 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
cfg = newCfg cfg = newCfg
web.UpdateConfig(cfg) web.UpdateConfig(cfg)
ssCtrl = NewScreensaver(&cfg.Screensaver) ssCtrl = NewScreensaver(&cfg.Screensaver)
deck.SetBrightness(deviceBrightness(cfg)) for _, d := range decks {
d.SetBrightness(deviceBrightness(cfg, d.Serial()))
}
for _, pm := range pageMgrs {
pm.stopPeriodicKeys() pm.stopPeriodicKeys()
pm.LoadPages(cfg.Pages) pm.LoadPages(cfg.Pages)
}
asm.Reload(cfg.AutoSwitch) asm.Reload(cfg.AutoSwitch)
if len(cfg.AutoSwitch) > 0 && detector == nil { if len(cfg.AutoSwitch) > 0 && detector == nil {
detector = NewWindowDetector() detector = NewWindowDetector()
windowCh, _ = detector.Start(ctx) windowCh, _ = detector.Start(ctx)
} }
for _, pm := range pageMgrs {
if err := pm.ActivatePage(cfg.DefaultPage); err != nil { if err := pm.ActivatePage(cfg.DefaultPage); err != nil {
slog.Warn("reload: activate default page", "error", err) slog.Warn("reload: activate default page", "error", err)
} }
pm.startPeriodicKeys() pm.startPeriodicKeys()
} }
}
if lastConfigMod.IsZero() { if lastConfigMod.IsZero() {
lastConfigMod = mt lastConfigMod = mt
} }
case <-reconnectTicker.C: 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) slog.Warn("deck connection lost, reconnecting...", "error", err)
deck.Close() primaryDeck.Close()
deck = reconnectDeck(ctx, cfg, &pm, asm) newDeck := reconnectDeck(ctx, cfg, &primaryPM, asm)
if deck == nil { if newDeck == nil {
return ctx.Err() return ctx.Err()
} }
deck.SetBrightness(deviceBrightness(cfg)) decks[0] = newDeck
primaryDeck = newDeck
web.SetDecks(decks)
newDeck.SetBrightness(deviceBrightness(cfg, newDeck.Serial()))
} }
case <-ssTicker.C: case <-ssTicker.C:
if ssCtrl.Check() { if ssCtrl.Check() {
ssCtrl.Activate(deck, &cfg.Screensaver) for _, d := range decks {
ssCtrl.Activate(d, &cfg.Screensaver)
}
} }
case <-ctx.Done(): case <-ctx.Done():
@ -229,9 +268,14 @@ func checkKeyboardTool() {
} }
} }
func deviceBrightness(cfg *config.Config) int { func deviceBrightness(cfg *config.Config, serial string) int {
if len(cfg.Devices) > 0 && cfg.Devices[0].Brightness > 0 { for _, d := range cfg.Devices {
return cfg.Devices[0].Brightness if d.Serial == serial || d.Serial == "" {
if d.Brightness > 0 {
return d.Brightness
}
return 75
}
} }
return 75 return 75
} }

45
deck.go
View File

@ -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) NumKeys() int { return d.cfg.KeysX * d.cfg.KeysY }
func (d *Deck) KeySize() int { return d.cfg.KeySize } func (d *Deck) KeySize() int { return d.cfg.KeySize }
func (d *Deck) Config() DeckConfig { return d.cfg } 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 { func (d *Deck) SetBrightness(val int) error {
if val < 0 { if val < 0 {

View File

@ -160,14 +160,10 @@ func (c *Config) Validate() error {
pageNames[p.Name] = true pageNames[p.Name] = true
seenKeys := make(map[int]bool) seenKeys := make(map[int]bool)
maxKey := 31
for _, k := range p.Keys { for _, k := range p.Keys {
if k.Index < 0 { if k.Index < 0 {
return fmt.Errorf("page %s: key index must be >= 0", p.Name) 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] { if seenKeys[k.Index] {
return fmt.Errorf("page %s: duplicate key index %d", p.Name, k.Index) return fmt.Errorf("page %s: duplicate key index %d", p.Name, k.Index)
} }

View File

@ -10,6 +10,8 @@ document.addEventListener('alpine:init', () => {
toast: null, toast: null,
displayOutputs: {}, displayOutputs: {},
isCapturingKey: false, isCapturingKey: false,
decks: [],
activeDeckSerial: '',
// ── Edit state ── // ── Edit state ──
editing: null, editing: null,
@ -25,9 +27,22 @@ document.addEventListener('alpine:init', () => {
// ── Init ── // ── Init ──
async init() { async init() {
await this.loadConfig() await this.loadConfig()
await this.loadDecks()
setInterval(() => this.pollDisplayOutputs(), 3000) 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() { async pollDisplayOutputs() {
if (this.view !== 'grid') return if (this.view !== 'grid') return
try { try {
@ -69,12 +84,18 @@ document.addEventListener('alpine:init', () => {
return this.pages.map(p => p.name) 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 ── // ── Grid ──
get gridKeys() { get gridKeys() {
const page = this.currentPage const page = this.currentPage
if (!page) return [] if (!page) return []
const keys = [] 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) const kc = page.keys.find(k => k.index === i)
keys.push({ keys.push({
index: i, index: i,

View File

@ -45,7 +45,15 @@
<!-- ═══ Grid View ═══ --> <!-- ═══ Grid View ═══ -->
<main x-show="view === 'grid' && !subView"> <main x-show="view === 'grid' && !subView">
<div class="deck-grid"> <div class="deck-selector" x-show="decks.length > 1">
<template x-for="d in decks" :key="d.serial">
<button class="deck-btn"
:class="{ active: d.serial === activeDeckSerial }"
@click="activeDeckSerial = d.serial"
x-text="d.model + ' (' + d.keys_x + '\u00d7' + d.keys_y + ')'"></button>
</template>
</div>
<div class="deck-grid" :style="'grid-template-columns: repeat(' + activeDeck.keys_x + ', 1fr)'">
<template x-for="key in gridKeys" :key="key.index"> <template x-for="key in gridKeys" :key="key.index">
<button class="key-btn" <button class="key-btn"
:class="{ empty: !key.configured }" :class="{ empty: !key.configured }"

View File

@ -107,7 +107,6 @@ main {
.deck-grid { .deck-grid {
display: grid; display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px; gap: 8px;
padding: 20px; padding: 20px;
background: var(--surface); background: var(--surface);
@ -115,6 +114,33 @@ main {
border: 1px solid var(--border); border: 1px solid var(--border);
} }
.deck-selector {
display: flex;
gap: 4px;
margin-bottom: 8px;
align-self: stretch;
}
.deck-btn {
flex: 1;
padding: 6px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-xs);
color: var(--text-muted);
font-size: 11px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: all var(--transition);
text-align: center;
}
.deck-btn:hover { color: var(--text); border-color: var(--border-light); }
.deck-btn.active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.key-btn { .key-btn {
aspect-ratio: 1; aspect-ratio: 1;
border: 1px solid var(--border); border: 1px solid var(--border);

22
web.go
View File

@ -37,6 +37,7 @@ type WebServer struct {
cfg *config.Config cfg *config.Config
configPath string configPath string
pm *PageManager pm *PageManager
decks []*Deck
mu sync.RWMutex mu sync.RWMutex
} }
@ -56,6 +57,12 @@ func (s *WebServer) SetPageManager(pm *PageManager) {
s.pm = pm s.pm = pm
} }
func (s *WebServer) SetDecks(decks []*Deck) {
s.mu.Lock()
defer s.mu.Unlock()
s.decks = decks
}
func (s *WebServer) Serve(ctx context.Context, addr string) error { func (s *WebServer) Serve(ctx context.Context, addr string) error {
mux := http.NewServeMux() mux := http.NewServeMux()
@ -75,6 +82,8 @@ func (s *WebServer) Serve(ctx context.Context, addr string) error {
mux.HandleFunc("GET /api/models", s.handleGetModels) mux.HandleFunc("GET /api/models", s.handleGetModels)
mux.HandleFunc("GET /api/decks", s.handleGetDecks)
mux.HandleFunc("GET /api/status", s.handleGetStatus) mux.HandleFunc("GET /api/status", s.handleGetStatus)
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticRoot)))) mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticRoot))))
@ -471,6 +480,19 @@ func (s *WebServer) handleGetModels(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(models) json.NewEncoder(w).Encode(models)
} }
func (s *WebServer) handleGetDecks(w http.ResponseWriter, r *http.Request) {
s.mu.RLock()
defer s.mu.RUnlock()
infos := make([]DeckInfo, 0, len(s.decks))
for _, d := range s.decks {
infos = append(infos, d.DeckInfo())
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(infos)
}
func (s *WebServer) handleGetStatus(w http.ResponseWriter, r *http.Request) { func (s *WebServer) handleGetStatus(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{ json.NewEncoder(w).Encode(map[string]interface{}{