'use strict'; // OpenAction / Stream Deck protocol; Node 22+, no npm dependencies. const args = process.argv.slice(2); const arg = name => args[args.indexOf(name) + 1]; const port = Number(arg('-port')); if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Missing OpenDeck port'); const socket = new WebSocket(`ws://127.0.0.1:${port}`); const visible = new Map(), rendered = new Map(); let cached = null, refreshing = false, queue = Promise.resolve(); function send(event, context, payload) { if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({event,context,payload})); } async function state() { const response = await fetch('http://127.0.0.1:8765/api/state',{signal:AbortSignal.timeout(3000)}); if (!response.ok) throw new Error('VoicePult unavailable'); return response.json(); } function itemFor(settings, data) { return (settings.kind === 'target' ? data.targets : data.channels)?.find(c=>c.id === settings.id); } function paint(context, settings, data) { const item = data && itemFor(settings, data); const label = item?.name || settings.name || 'Kanal wählen'; const lines = label.replace(/(.{1,12})(?:\s+|$)/g,'$1\n').trim(); const status = !data ? 'OFFLINE' : !item ? 'FEHLT' : item.muted ? 'AUS' : 'AN'; const selected = !item || item.muted || !data ? 1 : 0; const title = `${lines}\n${status}`; const signature = JSON.stringify([selected,title]); if (rendered.get(context) === signature) return; send('setTitle',context,{title,target:0,state:0}); send('setTitle',context,{title,target:0,state:1}); send('setState',context,{state:selected}); rendered.set(context,signature); } async function refresh() { if (refreshing || !visible.size) return; refreshing = true; try { cached = await state(); } catch { cached = null; } finally { refreshing = false; } for (const [context, settings] of visible) paint(context,settings,cached); } async function toggle(context, settings) { try { // Server toggles atomically; don't infer the next state from a stale key image. const before = await state(); if (!itemFor(settings,before)) throw new Error('Channel removed'); const response = await fetch('http://127.0.0.1:8765/api/action',{ method:'POST', headers:{'Content-Type':'application/json','X-VoicePult-Token':before.token}, body:JSON.stringify({action:settings.kind==='target'?'toggle_target':'toggle_channel',id:settings.id}), signal:AbortSignal.timeout(15000) }); if (!response.ok) throw new Error('VoicePult action failed'); cached = await response.json(); for (const [key,settings] of visible) paint(key,settings,cached); } catch (error) { send('showAlert',context); console.error(error.message); await refresh(); } } socket.addEventListener('open',()=>socket.send(JSON.stringify({event:arg('-registerEvent'),uuid:arg('-pluginUUID')}))); socket.addEventListener('message',async event=>{ try { const msg=JSON.parse(event.data), settings=msg.payload?.settings || visible.get(msg.context) || {}; if (msg.event==='willAppear' || msg.event==='didReceiveSettings') { visible.set(msg.context,settings); rendered.delete(msg.context); await refresh(); } else if (msg.event==='willDisappear') { visible.delete(msg.context); rendered.delete(msg.context); } else if (msg.event==='keyDown') { queue=queue.then(()=>toggle(msg.context,settings)).catch(error=>console.error(error.message)); } else if (msg.event==='sendToPlugin') { const data=await state(); send('sendToPropertyInspector',msg.context,{items:[...data.channels.map(c=>({...c,kind:'channel'})),...data.targets.map(c=>({...c,kind:'target'}))]}); } } catch(error) {console.error(error.message);} }); socket.addEventListener('close',()=>process.exit(0)); socket.addEventListener('error',()=>console.error('OpenDeck connection failed')); setInterval(refresh,1000);