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
+23 -1
View File
@@ -52,6 +52,7 @@ document.addEventListener('alpine:init', () => {
await this.loadConfig()
await this.loadDecks()
setInterval(() => this.pollDisplayOutputs(), 3000)
setInterval(() => this.loadEffectiveKeys(), 10000)
this.connectSSE()
},
@@ -101,6 +102,7 @@ document.addEventListener('alpine:init', () => {
const res = await fetch('/api/config')
this.config = await res.json()
this.pages = this.config.pages
await this.loadEffectiveKeys()
if (this.pages.length > 0) {
const saved = localStorage.getItem('sd_active_page')
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() {
this.settingsForm = {
brightness: this.getDeviceConfig(this.activeDeckSerial)?.brightness ?? 75,
@@ -151,10 +166,13 @@ document.addEventListener('alpine:init', () => {
get gridKeys() {
const page = this.currentPage
if (!page) return []
const sourceKeys = (page.effective_keys && page.effective_keys.length > 0)
? page.effective_keys
: (page.keys || [])
const keys = []
const n = this.activeDeck.num_keys
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]
keys.push({
index: i,
@@ -166,6 +184,7 @@ document.addEventListener('alpine:init', () => {
displayBg: dout?.background || '',
displayText: dout?.text || '',
previewUrl: this.keyPreviewUrl(kc ? kc : {}, dout),
isDynamic: !!(page.dynamic_keys && page.effective_keys?.find(k => k.index === i)),
})
}
return keys
@@ -306,6 +325,9 @@ document.addEventListener('alpine:init', () => {
const page = this.currentPage
if (!page) return
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.showAdvanced = false
+4 -1
View File
@@ -94,8 +94,11 @@
:style="{ borderColor: p.name === activePage ? 'var(--accent)' : '' }"
@click="switchPage(p.name); subView = null">
<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;"
x-text="p.keys.length + ' keys'"></span>
x-text="(p.effective_keys?.length || p.keys.length) + ' keys'"></span>
</button>
</template>
</div>