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:
Maksim Totmin
2026-06-21 21:05:32 +07:00
parent d171c26755
commit 22caab4b84
9 changed files with 581 additions and 43 deletions
+207
View File
@@ -127,6 +127,213 @@ Server down!
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 1114 for labels) |
| `icon_scale` | number | Icon scale factor (0.01.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 114) ──────────────────────→
```
#### 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
Configured in the web UI (Settings → Gesture Timing) or in `config.json`: