Dynamic key generators: plugin system for auto-generated pages
Add dynamic_keys support to PageConfig — a contract-based plugin
system where any executable outputs a JSON array of KeyConfig to stdout.
The daemon runs the generator on a configurable interval and renders
the keys dynamically on the Stream Deck.
Core changes:
- config: DynamicKeyGen struct (command/script, interval, timeout, max_keys)
- config: full validation for dynamic_keys fields
- action: execDynamicKeys() with safe JSON parsing (stderr logged separately,
env vars STREAMDECK_KEY_COUNT / STREAMDECK_CONFIG_DIR)
- page: activeKeys() merges static + dynamic keys (static wins by index)
- page: startDynamicKeys / stopDynamicKeys lifecycle, per-page results cache
- page: rerenderActivePage() with deadlock-safe lock ordering
- daemon: event handler uses activeKeys() for dynamic key actions
Web UI:
- GET /api/pages returns effective_keys (merged static + cached dynamic)
- app.js: gridKeys uses effective_keys when available
- app.js: guard against editing dynamic keys through the UI
- index.html: "⚡ dynamic" badge + effective key count in page switcher
- polls /api/pages every 10s to keep the UI in sync
Image rendering:
- loadImage: gift.ResizeToFill instead of gift.Resize (preserves aspect
ratio, crops to fill — no more stretched cover art)
Example & docs:
- config.example.json: games page with dynamic_keys + back button
- README.md: comprehensive Dynamic Key Generators section (architecture
diagram, config reference, generator contract, merge behavior,
env vars, error handling, Python + Shell examples, tips)
This commit is contained in:
parent
ac175fb144
commit
8df8b65163
207
README.md
207
README.md
@ -127,6 +127,213 @@ Server down!
|
|||||||
|
|
||||||
If no color is specified, the key uses its configured background or the default black.
|
If no color is specified, the key uses its configured background or the default black.
|
||||||
|
|
||||||
|
### Dynamic Key Generators
|
||||||
|
|
||||||
|
Generate an entire page of keys dynamically by running any executable (shell script, Python, Go binary, etc.). Useful for game launchers, music collections, Docker containers, monitoring dashboards — anything where the set of keys is not known at config time.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────┐
|
||||||
|
│ Generator script (any language) │
|
||||||
|
│ → queries Lutris DB, Steam API, etc. │
|
||||||
|
│ → outputs JSON array of KeyConfig to stdout │
|
||||||
|
│ → receives STREAMDECK_KEY_COUNT env var │
|
||||||
|
└──────────────┬───────────────────────────────────┘
|
||||||
|
│ stdout: [{"index":0,"icon":"...",...}]
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────────────┐
|
||||||
|
│ streamdeck-lets-go daemon │
|
||||||
|
│ → parses JSON │
|
||||||
|
│ → merges with static keys (static wins) │
|
||||||
|
│ → renders keys on the deck │
|
||||||
|
│ → re-runs on interval (default 60s) │
|
||||||
|
└──────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Config
|
||||||
|
|
||||||
|
Add `dynamic_keys` to any page:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Games",
|
||||||
|
"icon": "fa:gamepad",
|
||||||
|
"keys": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"icon": "fa:arrow-left",
|
||||||
|
"actions": [
|
||||||
|
{ "trigger": "tap", "type": "page", "page": "main" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dynamic_keys": {
|
||||||
|
"command": "/home/user/bin/game-list",
|
||||||
|
"interval": "120s",
|
||||||
|
"timeout": "15s",
|
||||||
|
"max_keys": 14
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `command` | string | — | Shell command to execute (mutually exclusive with `script`) |
|
||||||
|
| `script` | string | — | Path to an executable script; relative paths resolve against `~/.config/streamdeck-lets-go/` |
|
||||||
|
| `interval` | string | `"60s"` | How often to re-run the generator (minimum 1s, maximum 24h) |
|
||||||
|
| `timeout` | string | `"15s"` | Maximum execution time before the generator is killed |
|
||||||
|
| `max_keys` | int | `deck.NumKeys()` | Maximum number of keys to accept (e.g. `13` to reserve 2 for navigation) |
|
||||||
|
|
||||||
|
#### Generator contract
|
||||||
|
|
||||||
|
The script **must** print a JSON array of key configs to stdout and exit with code 0. Each element supports the full `KeyConfig` schema:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"icon": "/home/user/.local/share/lutris/coverart/cyberpunk-2077.jpg",
|
||||||
|
"label": "Cyberpunk 2077",
|
||||||
|
"font_size": 12,
|
||||||
|
"icon_scale": 1.0,
|
||||||
|
"background": "#222222",
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"trigger": "tap",
|
||||||
|
"type": "command",
|
||||||
|
"command": "lutris lutris:rungame/cyberpunk-2077",
|
||||||
|
"background": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Every field from a static key is available:
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `index` | int | Key position on the deck (0-based) |
|
||||||
|
| `icon` | string | Icon source: `fa:terminal`, `emoji:fire`, `@firefox`, `/absolute/path.png`, relative path |
|
||||||
|
| `label` | string | Text shown below the icon |
|
||||||
|
| `font_size` | number | Font size in points (default 18, recommended 11–14 for labels) |
|
||||||
|
| `icon_scale` | number | Icon scale factor (0.0–1.0, default 0.55) |
|
||||||
|
| `background` | string | Hex background color `"#222222"` |
|
||||||
|
| `actions` | array | Same action schema as static keys — supports all triggers (`tap`, `long_press`, `double_tap`) and all action types (`command`, `builtin`, `script`, `page`, `keyboard`) |
|
||||||
|
| `display` | object | Periodic display block (same as static key `display`) — command/script runs on interval and text is overlaid on the key |
|
||||||
|
|
||||||
|
#### Merge behavior
|
||||||
|
|
||||||
|
Static `keys` (defined directly on the page) and dynamic keys are **merged** at runtime:
|
||||||
|
|
||||||
|
| Priority | Source |
|
||||||
|
|---|---|
|
||||||
|
| 1 (highest) | Static keys — their indices are reserved |
|
||||||
|
| 2 | Dynamic keys — fill indices not occupied by static keys |
|
||||||
|
|
||||||
|
This lets you keep navigation buttons fixed while the generator fills the rest of the deck:
|
||||||
|
|
||||||
|
```
|
||||||
|
Static: [← back] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ] [ ]
|
||||||
|
index 0 └────────────────────── dynamic (indices 1–14) ──────────────────────→
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Environment variables
|
||||||
|
|
||||||
|
The generator receives these environment variables:
|
||||||
|
|
||||||
|
| Variable | Example | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `STREAMDECK_KEY_COUNT` | `15` | Maximum number of keys to output (derived from deck model or `max_keys` config) |
|
||||||
|
| `STREAMDECK_CONFIG_DIR` | `/home/user/.config/streamdeck-lets-go` | Path to the config directory — useful for finding companion files |
|
||||||
|
|
||||||
|
#### Error handling
|
||||||
|
|
||||||
|
| Situation | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| Script fails (non-zero exit, timeout) | Warning logged; previous keys preserved |
|
||||||
|
| Script returns empty array `[]` | Only static keys rendered |
|
||||||
|
| Script returns invalid JSON | Warning logged; previous keys preserved |
|
||||||
|
| Script outputs debug to stderr | Logged at DEBUG level; stdout JSON parsed cleanly |
|
||||||
|
| More keys than `max_keys` | Excess keys silently dropped |
|
||||||
|
| Generator updates while page is active | Keys re-rendered in place (no flicker) |
|
||||||
|
|
||||||
|
#### Examples
|
||||||
|
|
||||||
|
**Python — Lutris game launcher**
|
||||||
|
|
||||||
|
Place this at `~/.config/streamdeck-lets-go/scripts/lutris-keys`, make executable (`chmod +x`).
|
||||||
|
|
||||||
|
```python
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import json, os, sqlite3, pathlib
|
||||||
|
|
||||||
|
db = pathlib.Path.home() / '.local/share/lutris/pga.db'
|
||||||
|
cover = pathlib.Path.home() / '.local/share/lutris/coverart'
|
||||||
|
max_keys = int(os.environ.get('STREAMDECK_KEY_COUNT', 15))
|
||||||
|
|
||||||
|
if not db.exists():
|
||||||
|
print('[]')
|
||||||
|
exit(0)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
games = conn.execute(
|
||||||
|
"SELECT slug, name FROM games WHERE installed = 1 ORDER BY name"
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
keys = []
|
||||||
|
for idx, (slug, name) in enumerate(games):
|
||||||
|
if idx >= max_keys:
|
||||||
|
break
|
||||||
|
icon = next(cover.glob(f'{slug}.*'), None)
|
||||||
|
key = {
|
||||||
|
"index": idx,
|
||||||
|
"icon_scale": 1.0,
|
||||||
|
"actions": [{
|
||||||
|
"trigger": "tap",
|
||||||
|
"type": "command",
|
||||||
|
"command": f"lutris lutris:rungame/{slug}",
|
||||||
|
"background": True
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
if icon:
|
||||||
|
key["icon"] = str(icon)
|
||||||
|
keys.append(key)
|
||||||
|
|
||||||
|
print(json.dumps(keys))
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference it in config:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Games",
|
||||||
|
"dynamic_keys": {
|
||||||
|
"script": "scripts/lutris-keys",
|
||||||
|
"interval": "120s"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Shell — Simple test generator**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/sh
|
||||||
|
for i in $(seq 0 $((STREAMDECK_KEY_COUNT - 1))); do
|
||||||
|
[ "$i" -gt 0 ] && echo ","
|
||||||
|
printf '{"index":%d,"icon":"fa:hashtag","label":"Item %d"}' "$i" "$i"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
Run with any language you like — the only requirement is a valid JSON array on stdout.
|
||||||
|
|
||||||
|
#### Tips
|
||||||
|
|
||||||
|
- Use `max_keys` to reserve space for static navigation buttons
|
||||||
|
- Enable `show_label_background: true` in the global config if labels on full-bleed icons are hard to read
|
||||||
|
- Test your generator standalone before wiring it up: `STREAMDECK_KEY_COUNT=15 ./my-generator`
|
||||||
|
- For large data sources, pass pagination params through the command string: `"command": "scripts/albums --page 0"`
|
||||||
|
- Dynamic keys support `display` blocks — nest monitoring data inside generated keys
|
||||||
|
|
||||||
### Gesture timing
|
### Gesture timing
|
||||||
|
|
||||||
Configured in the web UI (Settings → Gesture Timing) or in `config.json`:
|
Configured in the web UI (Settings → Gesture Timing) or in `config.json`:
|
||||||
|
|||||||
48
action.go
48
action.go
@ -3,10 +3,12 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -99,6 +101,52 @@ func execDisplayCapture(d *config.DisplayCfg, timeout time.Duration) (string, er
|
|||||||
return output, err
|
return output, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func execDynamicKeys(dg *config.DynamicKeyGen, timeout time.Duration, keyCount int) ([]config.KeyConfig, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
if dg.Command != "" {
|
||||||
|
cmd = exec.CommandContext(ctx, "sh", "-c", dg.Command)
|
||||||
|
} else {
|
||||||
|
script := dg.Script
|
||||||
|
if !filepath.IsAbs(script) {
|
||||||
|
script = filepath.Join(configDir(), script)
|
||||||
|
}
|
||||||
|
cmd = exec.CommandContext(ctx, script)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set environment variables for the script
|
||||||
|
cmd.Env = append(os.Environ(),
|
||||||
|
fmt.Sprintf("STREAMDECK_KEY_COUNT=%d", keyCount),
|
||||||
|
fmt.Sprintf("STREAMDECK_CONFIG_DIR=%s", configDir()),
|
||||||
|
)
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
err := cmd.Run()
|
||||||
|
|
||||||
|
// Log stderr separately (don't merge into stdout for JSON parsing)
|
||||||
|
if stderr.Len() > 0 {
|
||||||
|
slog.Debug("dynamic_keys script stderr", "output", stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("execute dynamic_keys: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JSON from stdout
|
||||||
|
var keys []config.KeyConfig
|
||||||
|
output := stdout.String()
|
||||||
|
if err := json.Unmarshal([]byte(output), &keys); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse dynamic_keys JSON: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys, nil
|
||||||
|
}
|
||||||
|
|
||||||
func execBuiltin(a *config.Action, deck *Deck, pm *PageManager) error {
|
func execBuiltin(a *config.Action, deck *Deck, pm *PageManager) error {
|
||||||
parts := strings.SplitN(a.Builtin, ":", 2)
|
parts := strings.SplitN(a.Builtin, ":", 2)
|
||||||
if len(parts) < 1 {
|
if len(parts) < 1 {
|
||||||
|
|||||||
@ -40,6 +40,19 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"index": 2,
|
||||||
|
"icon": "fa:gamepad",
|
||||||
|
"label": "Games",
|
||||||
|
"font_size": 11,
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"trigger": "tap",
|
||||||
|
"type": "page",
|
||||||
|
"page": "games"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"index": 7,
|
"index": 7,
|
||||||
"icon": "fa:chevron-right",
|
"icon": "fa:chevron-right",
|
||||||
@ -58,6 +71,23 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "games",
|
||||||
|
"icon": "fa:gamepad",
|
||||||
|
"keys": [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"icon": "fa:arrow-left",
|
||||||
|
"actions": [
|
||||||
|
{ "trigger": "tap", "type": "page", "page": "main" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dynamic_keys": {
|
||||||
|
"script": "scripts/lutris-keys",
|
||||||
|
"interval": "120s"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"auto_switch": [],
|
"auto_switch": [],
|
||||||
|
|||||||
@ -184,7 +184,7 @@ func Run(ctx context.Context, cfg *config.Config, opts RunOptions) error {
|
|||||||
if page == nil {
|
if page == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, k := range page.Keys {
|
for _, k := range primaryPM.activeKeys() {
|
||||||
if k.Index == evt.Index {
|
if k.Index == evt.Index {
|
||||||
if len(k.Actions) > 0 {
|
if len(k.Actions) > 0 {
|
||||||
ge.HandleEvent(evt, k.Actions)
|
ge.HandleEvent(evt, k.Actions)
|
||||||
|
|||||||
@ -43,10 +43,19 @@ type PageConfig struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Icon string `json:"icon,omitempty"`
|
Icon string `json:"icon,omitempty"`
|
||||||
Keys []KeyConfig `json:"keys"`
|
Keys []KeyConfig `json:"keys"`
|
||||||
|
DynamicKeys *DynamicKeyGen `json:"dynamic_keys,omitempty"`
|
||||||
Background string `json:"background,omitempty"`
|
Background string `json:"background,omitempty"`
|
||||||
Screensaver *ScreensaverCfg `json:"screensaver,omitempty"`
|
Screensaver *ScreensaverCfg `json:"screensaver,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DynamicKeyGen struct {
|
||||||
|
Command string `json:"command,omitempty"`
|
||||||
|
Script string `json:"script,omitempty"`
|
||||||
|
Interval string `json:"interval,omitempty"`
|
||||||
|
Timeout string `json:"timeout,omitempty"`
|
||||||
|
MaxKeys int `json:"max_keys,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type KeyConfig struct {
|
type KeyConfig struct {
|
||||||
Index int `json:"index"`
|
Index int `json:"index"`
|
||||||
Icon string `json:"icon,omitempty"`
|
Icon string `json:"icon,omitempty"`
|
||||||
@ -293,6 +302,35 @@ func (c *Config) Validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if p.DynamicKeys != nil {
|
||||||
|
if p.DynamicKeys.Command == "" && p.DynamicKeys.Script == "" {
|
||||||
|
return fmt.Errorf("page %s: dynamic_keys requires command or script", p.Name)
|
||||||
|
}
|
||||||
|
if p.DynamicKeys.Command != "" && p.DynamicKeys.Script != "" {
|
||||||
|
return fmt.Errorf("page %s: dynamic_keys command and script are mutually exclusive", p.Name)
|
||||||
|
}
|
||||||
|
if p.DynamicKeys.Interval != "" {
|
||||||
|
interval, err := time.ParseDuration(p.DynamicKeys.Interval)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("page %s: invalid dynamic_keys interval %q: %w", p.Name, p.DynamicKeys.Interval, err)
|
||||||
|
}
|
||||||
|
if interval < time.Second {
|
||||||
|
return fmt.Errorf("page %s: dynamic_keys interval must be at least 1s", p.Name)
|
||||||
|
}
|
||||||
|
if interval > 24*time.Hour {
|
||||||
|
return fmt.Errorf("page %s: dynamic_keys interval must not exceed 24h", p.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.DynamicKeys.Timeout != "" {
|
||||||
|
if _, err := time.ParseDuration(p.DynamicKeys.Timeout); err != nil {
|
||||||
|
return fmt.Errorf("page %s: invalid dynamic_keys timeout %q: %w", p.Name, p.DynamicKeys.Timeout, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.DynamicKeys.MaxKeys < 0 {
|
||||||
|
return fmt.Errorf("page %s: dynamic_keys max_keys must be >= 0", p.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, p := range c.Pages {
|
for _, p := range c.Pages {
|
||||||
|
|||||||
245
page.go
245
page.go
@ -50,6 +50,10 @@ type PageManager struct {
|
|||||||
displayMu sync.RWMutex
|
displayMu sync.RWMutex
|
||||||
defaultFont string
|
defaultFont string
|
||||||
showLabelBackground bool
|
showLabelBackground bool
|
||||||
|
dynamicCancel context.CancelFunc
|
||||||
|
dynamicKeys []config.KeyConfig
|
||||||
|
dynamicKeysMu sync.RWMutex
|
||||||
|
dynamicResults map[string][]config.KeyConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewPageManager(deck *Deck) *PageManager {
|
func NewPageManager(deck *Deck) *PageManager {
|
||||||
@ -59,19 +63,88 @@ func NewPageManager(deck *Deck) *PageManager {
|
|||||||
keyStates: make(map[int]*keyState),
|
keyStates: make(map[int]*keyState),
|
||||||
displayOutputs: make(map[int]*DisplayOutput),
|
displayOutputs: make(map[int]*DisplayOutput),
|
||||||
defaultFont: "medium",
|
defaultFont: "medium",
|
||||||
|
dynamicResults: make(map[string][]config.KeyConfig),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (pm *PageManager) activeKeys() []config.KeyConfig {
|
||||||
|
pm.mu.RLock()
|
||||||
|
page := pm.pages[pm.active]
|
||||||
|
pm.mu.RUnlock()
|
||||||
|
if page == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pm.dynamicKeysMu.RLock()
|
||||||
|
dyn := pm.dynamicKeys
|
||||||
|
pm.dynamicKeysMu.RUnlock()
|
||||||
|
|
||||||
|
if dyn == nil {
|
||||||
|
return page.Keys
|
||||||
|
}
|
||||||
|
|
||||||
|
used := make(map[int]bool, len(page.Keys))
|
||||||
|
result := make([]config.KeyConfig, 0, len(page.Keys)+len(dyn))
|
||||||
|
for _, k := range page.Keys {
|
||||||
|
used[k.Index] = true
|
||||||
|
result = append(result, k)
|
||||||
|
}
|
||||||
|
for _, k := range dyn {
|
||||||
|
if !used[k.Index] {
|
||||||
|
result = append(result, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEffectiveKeys returns merged (static + cached dynamic) keys for a page.
|
||||||
|
// Used by the web UI to preview generated keys.
|
||||||
|
func (pm *PageManager) GetEffectiveKeys(name string) []config.KeyConfig {
|
||||||
|
pm.mu.RLock()
|
||||||
|
page := pm.pages[name]
|
||||||
|
pm.mu.RUnlock()
|
||||||
|
if page == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pm.dynamicKeysMu.RLock()
|
||||||
|
cached := pm.dynamicResults[name]
|
||||||
|
pm.dynamicKeysMu.RUnlock()
|
||||||
|
|
||||||
|
if cached == nil {
|
||||||
|
return page.Keys
|
||||||
|
}
|
||||||
|
|
||||||
|
used := make(map[int]bool, len(page.Keys))
|
||||||
|
result := make([]config.KeyConfig, 0, len(page.Keys)+len(cached))
|
||||||
|
for _, k := range page.Keys {
|
||||||
|
used[k.Index] = true
|
||||||
|
result = append(result, k)
|
||||||
|
}
|
||||||
|
for _, k := range cached {
|
||||||
|
if !used[k.Index] {
|
||||||
|
result = append(result, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func (pm *PageManager) LoadPages(pages []config.PageConfig) {
|
func (pm *PageManager) LoadPages(pages []config.PageConfig) {
|
||||||
pm.mu.Lock()
|
pm.mu.Lock()
|
||||||
defer pm.mu.Unlock()
|
|
||||||
pm.pages = make(map[string]*config.PageConfig, len(pages))
|
pm.pages = make(map[string]*config.PageConfig, len(pages))
|
||||||
for i := range pages {
|
for i := range pages {
|
||||||
pm.pages[pages[i].Name] = &pages[i]
|
pm.pages[pages[i].Name] = &pages[i]
|
||||||
}
|
}
|
||||||
|
pm.mu.Unlock()
|
||||||
|
|
||||||
|
pm.dynamicKeysMu.Lock()
|
||||||
|
pm.dynamicResults = make(map[string][]config.KeyConfig)
|
||||||
|
pm.dynamicKeysMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (pm *PageManager) ActivatePage(name string) error {
|
func (pm *PageManager) ActivatePage(name string) error {
|
||||||
|
pm.stopDynamicKeys()
|
||||||
|
|
||||||
pm.mu.Lock()
|
pm.mu.Lock()
|
||||||
defer pm.mu.Unlock()
|
defer pm.mu.Unlock()
|
||||||
|
|
||||||
@ -117,6 +190,12 @@ func (pm *PageManager) ActivatePage(name string) error {
|
|||||||
pm.renderKey(i, &k)
|
pm.renderKey(i, &k)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if page.DynamicKeys != nil {
|
||||||
|
go func() {
|
||||||
|
pm.startDynamicKeys(page.DynamicKeys)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -149,13 +228,7 @@ func (pm *PageManager) GetPage(name string) *config.PageConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (pm *PageManager) RenderKey(keyIndex int) {
|
func (pm *PageManager) RenderKey(keyIndex int) {
|
||||||
pm.mu.RLock()
|
for _, k := range pm.activeKeys() {
|
||||||
defer pm.mu.RUnlock()
|
|
||||||
page := pm.pages[pm.active]
|
|
||||||
if page == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, k := range page.Keys {
|
|
||||||
if k.Index == keyIndex {
|
if k.Index == keyIndex {
|
||||||
pm.renderKey(keyIndex, &k)
|
pm.renderKey(keyIndex, &k)
|
||||||
return
|
return
|
||||||
@ -164,13 +237,7 @@ func (pm *PageManager) RenderKey(keyIndex int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (pm *PageManager) ReRenderDisplayKey(keyIndex int, output string) {
|
func (pm *PageManager) ReRenderDisplayKey(keyIndex int, output string) {
|
||||||
pm.mu.RLock()
|
for _, k := range pm.activeKeys() {
|
||||||
page := pm.pages[pm.active]
|
|
||||||
pm.mu.RUnlock()
|
|
||||||
if page == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, k := range page.Keys {
|
|
||||||
if k.Index == keyIndex && k.Display != nil {
|
if k.Index == keyIndex && k.Display != nil {
|
||||||
pm.renderKeyOutput(keyIndex, k.Display, output, nil)
|
pm.renderKeyOutput(keyIndex, k.Display, output, nil)
|
||||||
return
|
return
|
||||||
@ -260,6 +327,8 @@ func (pm *PageManager) renderKey(idx int, k *config.KeyConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (pm *PageManager) stopPeriodicKeys() {
|
func (pm *PageManager) stopPeriodicKeys() {
|
||||||
|
pm.stopDynamicKeys()
|
||||||
|
|
||||||
pm.mu.Lock()
|
pm.mu.Lock()
|
||||||
defer pm.mu.Unlock()
|
defer pm.mu.Unlock()
|
||||||
for _, ks := range pm.keyStates {
|
for _, ks := range pm.keyStates {
|
||||||
@ -296,11 +365,136 @@ func (pm *PageManager) startPeriodicKeys() {
|
|||||||
keys = append(keys, displayKey{index: k.Index, display: k.Display, ctx: ctx})
|
keys = append(keys, displayKey{index: k.Index, display: k.Display, ctx: ctx})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
dg := page.DynamicKeys
|
||||||
pm.mu.Unlock()
|
pm.mu.Unlock()
|
||||||
|
|
||||||
for _, dk := range keys {
|
for _, dk := range keys {
|
||||||
go pm.runDisplayKey(dk.index, dk.display, dk.ctx)
|
go pm.runDisplayKey(dk.index, dk.display, dk.ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if dg != nil {
|
||||||
|
pm.startDynamicKeys(dg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *PageManager) stopDynamicKeys() {
|
||||||
|
pm.dynamicKeysMu.Lock()
|
||||||
|
if pm.dynamicCancel != nil {
|
||||||
|
pm.dynamicCancel()
|
||||||
|
pm.dynamicCancel = nil
|
||||||
|
}
|
||||||
|
pm.dynamicKeys = nil
|
||||||
|
pm.dynamicKeysMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *PageManager) startDynamicKeys(dg *config.DynamicKeyGen) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
pm.dynamicKeysMu.Lock()
|
||||||
|
if pm.dynamicCancel != nil {
|
||||||
|
pm.dynamicCancel()
|
||||||
|
}
|
||||||
|
pm.dynamicCancel = cancel
|
||||||
|
pm.dynamicKeysMu.Unlock()
|
||||||
|
|
||||||
|
numKeys := dg.MaxKeys
|
||||||
|
if numKeys <= 0 {
|
||||||
|
numKeys = pm.deck.NumKeys()
|
||||||
|
}
|
||||||
|
|
||||||
|
var interval time.Duration
|
||||||
|
if dg.Interval != "" {
|
||||||
|
interval, _ = time.ParseDuration(dg.Interval)
|
||||||
|
}
|
||||||
|
if interval == 0 {
|
||||||
|
interval = 60 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
go pm.runDynamicKeyGenerator(ctx, dg, numKeys, interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *PageManager) runDynamicKeyGenerator(ctx context.Context, dg *config.DynamicKeyGen, numKeys int, interval time.Duration) {
|
||||||
|
timeout := 15 * time.Second
|
||||||
|
if dg.Timeout != "" {
|
||||||
|
if t, err := time.ParseDuration(dg.Timeout); err == nil && t > 0 {
|
||||||
|
timeout = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pm.updateDynamicKeys(dg, timeout, numKeys)
|
||||||
|
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
pm.updateDynamicKeys(dg, timeout, numKeys)
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *PageManager) updateDynamicKeys(dg *config.DynamicKeyGen, timeout time.Duration, numKeys int) {
|
||||||
|
keys, err := execDynamicKeys(dg, timeout, numKeys)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("dynamic_keys generator failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(keys) > numKeys {
|
||||||
|
keys = keys[:numKeys]
|
||||||
|
}
|
||||||
|
|
||||||
|
pm.mu.RLock()
|
||||||
|
pageName := pm.active
|
||||||
|
pm.mu.RUnlock()
|
||||||
|
|
||||||
|
pm.dynamicKeysMu.Lock()
|
||||||
|
if pm.dynamicCancel == nil {
|
||||||
|
pm.dynamicKeysMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pm.dynamicKeys = keys
|
||||||
|
pm.dynamicResults[pageName] = keys
|
||||||
|
pm.dynamicKeysMu.Unlock()
|
||||||
|
|
||||||
|
slog.Info("dynamic_keys updated", "count", len(keys), "page", pageName)
|
||||||
|
|
||||||
|
pm.rerenderActivePage()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *PageManager) rerenderActivePage() {
|
||||||
|
pm.mu.RLock()
|
||||||
|
bg := ""
|
||||||
|
if page := pm.pages[pm.active]; page != nil {
|
||||||
|
bg = page.Background
|
||||||
|
}
|
||||||
|
pm.mu.RUnlock()
|
||||||
|
|
||||||
|
if bg != "" {
|
||||||
|
if img, err := loadImage(bg, 0, 0); err == nil {
|
||||||
|
pm.deck.FillPanel(img)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := pm.activeKeys()
|
||||||
|
keyByIndex := make(map[int]config.KeyConfig, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
keyByIndex[k.Index] = k
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < pm.deck.NumKeys(); i++ {
|
||||||
|
k, ok := keyByIndex[i]
|
||||||
|
if !ok {
|
||||||
|
if bg == "" {
|
||||||
|
pm.deck.ClearKey(i)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pm.renderKey(i, &k)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (pm *PageManager) runDisplayKey(idx int, d *config.DisplayCfg, ctx context.Context) {
|
func (pm *PageManager) runDisplayKey(idx int, d *config.DisplayCfg, ctx context.Context) {
|
||||||
@ -393,15 +587,8 @@ func parseDisplayOutput(output string) (*DisplayOutput, color.Color, color.Color
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (pm *PageManager) renderKeyOutput(idx int, d *config.DisplayCfg, output string, execErr error) {
|
func (pm *PageManager) renderKeyOutput(idx int, d *config.DisplayCfg, output string, execErr error) {
|
||||||
pm.mu.RLock()
|
|
||||||
page := pm.pages[pm.active]
|
|
||||||
pm.mu.RUnlock()
|
|
||||||
if page == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var kc *config.KeyConfig
|
var kc *config.KeyConfig
|
||||||
for _, k := range page.Keys {
|
for _, k := range pm.activeKeys() {
|
||||||
if k.Index == idx {
|
if k.Index == idx {
|
||||||
kCopy := k
|
kCopy := k
|
||||||
kc = &kCopy
|
kc = &kCopy
|
||||||
@ -499,13 +686,7 @@ func (pm *PageManager) renderKeyOutput(idx int, d *config.DisplayCfg, output str
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (pm *PageManager) RefreshDisplayKey(idx int) {
|
func (pm *PageManager) RefreshDisplayKey(idx int) {
|
||||||
pm.mu.RLock()
|
for _, k := range pm.activeKeys() {
|
||||||
page := pm.pages[pm.active]
|
|
||||||
pm.mu.RUnlock()
|
|
||||||
if page == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, k := range page.Keys {
|
|
||||||
if k.Index == idx && k.Display != nil {
|
if k.Index == idx && k.Display != nil {
|
||||||
timeout := 30 * time.Second
|
timeout := 30 * time.Second
|
||||||
if k.Display.Timeout != "" {
|
if k.Display.Timeout != "" {
|
||||||
@ -892,9 +1073,9 @@ func loadImage(path string, targetSize int, faScale float64) (image.Image, error
|
|||||||
displaySize = 1
|
displaySize = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resize to displaySize
|
// Resize to displaySize preserving aspect ratio (crop to fill)
|
||||||
if img.Bounds().Dx() != displaySize || img.Bounds().Dy() != displaySize {
|
if img.Bounds().Dx() != displaySize || img.Bounds().Dy() != displaySize {
|
||||||
g := gift.New(gift.Resize(displaySize, displaySize, gift.LanczosResampling))
|
g := gift.New(gift.ResizeToFill(displaySize, displaySize, gift.LanczosResampling, gift.CenterAnchor))
|
||||||
scaled := image.NewRGBA(image.Rect(0, 0, displaySize, displaySize))
|
scaled := image.NewRGBA(image.Rect(0, 0, displaySize, displaySize))
|
||||||
g.Draw(scaled, img)
|
g.Draw(scaled, img)
|
||||||
img = scaled
|
img = scaled
|
||||||
|
|||||||
@ -52,6 +52,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
await this.loadConfig()
|
await this.loadConfig()
|
||||||
await this.loadDecks()
|
await this.loadDecks()
|
||||||
setInterval(() => this.pollDisplayOutputs(), 3000)
|
setInterval(() => this.pollDisplayOutputs(), 3000)
|
||||||
|
setInterval(() => this.loadEffectiveKeys(), 10000)
|
||||||
this.connectSSE()
|
this.connectSSE()
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -101,6 +102,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
const res = await fetch('/api/config')
|
const res = await fetch('/api/config')
|
||||||
this.config = await res.json()
|
this.config = await res.json()
|
||||||
this.pages = this.config.pages
|
this.pages = this.config.pages
|
||||||
|
await this.loadEffectiveKeys()
|
||||||
if (this.pages.length > 0) {
|
if (this.pages.length > 0) {
|
||||||
const saved = localStorage.getItem('sd_active_page')
|
const saved = localStorage.getItem('sd_active_page')
|
||||||
if (saved && this.pages.find(p => p.name === saved)) {
|
if (saved && this.pages.find(p => p.name === saved)) {
|
||||||
@ -115,6 +117,19 @@ document.addEventListener('alpine:init', () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async loadEffectiveKeys() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/pages')
|
||||||
|
const pagesData = await res.json()
|
||||||
|
for (const pd of pagesData) {
|
||||||
|
const page = this.pages.find(p => p.name === pd.name)
|
||||||
|
if (page && pd.effective_keys && pd.effective_keys.length > 0) {
|
||||||
|
page.effective_keys = pd.effective_keys
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
},
|
||||||
|
|
||||||
syncSettings() {
|
syncSettings() {
|
||||||
this.settingsForm = {
|
this.settingsForm = {
|
||||||
brightness: this.getDeviceConfig(this.activeDeckSerial)?.brightness ?? 75,
|
brightness: this.getDeviceConfig(this.activeDeckSerial)?.brightness ?? 75,
|
||||||
@ -151,10 +166,13 @@ document.addEventListener('alpine:init', () => {
|
|||||||
get gridKeys() {
|
get gridKeys() {
|
||||||
const page = this.currentPage
|
const page = this.currentPage
|
||||||
if (!page) return []
|
if (!page) return []
|
||||||
|
const sourceKeys = (page.effective_keys && page.effective_keys.length > 0)
|
||||||
|
? page.effective_keys
|
||||||
|
: (page.keys || [])
|
||||||
const keys = []
|
const keys = []
|
||||||
const n = this.activeDeck.num_keys
|
const n = this.activeDeck.num_keys
|
||||||
for (let i = 0; i < n; i++) {
|
for (let i = 0; i < n; i++) {
|
||||||
const kc = page.keys.find(k => k.index === i)
|
const kc = sourceKeys.find(k => k.index === i)
|
||||||
const dout = this.displayOutputs[i]
|
const dout = this.displayOutputs[i]
|
||||||
keys.push({
|
keys.push({
|
||||||
index: i,
|
index: i,
|
||||||
@ -166,6 +184,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
displayBg: dout?.background || '',
|
displayBg: dout?.background || '',
|
||||||
displayText: dout?.text || '',
|
displayText: dout?.text || '',
|
||||||
previewUrl: this.keyPreviewUrl(kc ? kc : {}, dout),
|
previewUrl: this.keyPreviewUrl(kc ? kc : {}, dout),
|
||||||
|
isDynamic: !!(page.dynamic_keys && page.effective_keys?.find(k => k.index === i)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return keys
|
return keys
|
||||||
@ -306,6 +325,9 @@ document.addEventListener('alpine:init', () => {
|
|||||||
const page = this.currentPage
|
const page = this.currentPage
|
||||||
if (!page) return
|
if (!page) return
|
||||||
const kc = page.keys.find(k => k.index === idx)
|
const kc = page.keys.find(k => k.index === idx)
|
||||||
|
if (!kc && page.dynamic_keys && page.effective_keys?.find(k => k.index === idx)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
this.editing = idx
|
this.editing = idx
|
||||||
this.showAdvanced = false
|
this.showAdvanced = false
|
||||||
|
|
||||||
|
|||||||
@ -94,8 +94,11 @@
|
|||||||
:style="{ borderColor: p.name === activePage ? 'var(--accent)' : '' }"
|
:style="{ borderColor: p.name === activePage ? 'var(--accent)' : '' }"
|
||||||
@click="switchPage(p.name); subView = null">
|
@click="switchPage(p.name); subView = null">
|
||||||
<span x-text="p.name"></span>
|
<span x-text="p.name"></span>
|
||||||
|
<span x-show="p.dynamic_keys"
|
||||||
|
title="Dynamic keys generator"
|
||||||
|
style="color:var(--accent);font-size:11px;margin-left:4px;">⚡</span>
|
||||||
<span style="float:right;color:var(--text-muted);font-size:11px;"
|
<span style="float:right;color:var(--text-muted);font-size:11px;"
|
||||||
x-text="p.keys.length + ' keys'"></span>
|
x-text="(p.effective_keys?.length || p.keys.length) + ' keys'"></span>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
13
web.go
13
web.go
@ -342,15 +342,24 @@ func (s *WebServer) handleGetPages(w http.ResponseWriter, r *http.Request) {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Keys []config.KeyConfig `json:"keys"`
|
Keys []config.KeyConfig `json:"keys"`
|
||||||
Icon string `json:"icon,omitempty"`
|
Icon string `json:"icon,omitempty"`
|
||||||
|
DynamicKeys *config.DynamicKeyGen `json:"dynamic_keys,omitempty"`
|
||||||
|
Background string `json:"background,omitempty"`
|
||||||
|
EffectiveKeys []config.KeyConfig `json:"effective_keys,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
pages := make([]pageInfo, 0, len(s.cfg.Pages))
|
pages := make([]pageInfo, 0, len(s.cfg.Pages))
|
||||||
for _, p := range s.cfg.Pages {
|
for _, p := range s.cfg.Pages {
|
||||||
pages = append(pages, pageInfo{
|
pi := pageInfo{
|
||||||
Name: p.Name,
|
Name: p.Name,
|
||||||
Keys: p.Keys,
|
Keys: p.Keys,
|
||||||
Icon: p.Icon,
|
Icon: p.Icon,
|
||||||
})
|
DynamicKeys: p.DynamicKeys,
|
||||||
|
Background: p.Background,
|
||||||
|
}
|
||||||
|
if p.DynamicKeys != nil && s.pm != nil {
|
||||||
|
pi.EffectiveKeys = s.pm.GetEffectiveKeys(p.Name)
|
||||||
|
}
|
||||||
|
pages = append(pages, pi)
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user