import streamDeck, { action, type DidReceiveSettingsEvent, type KeyAction, type KeyDownEvent, SingletonAction, type WillAppearEvent, type WillDisappearEvent, } from "@elgato/streamdeck"; import { type GpuMetricKind } from "../lib/metrics-parser"; import { combineSeries } from "../lib/metrics-poller"; import { renderGpuGraph, svgDataUrl } from "../lib/render"; import { runtime } from "../lib/runtime"; import { cfgFromSettings, type CfgSettings } from "../lib/util"; type GpuSettings = CfgSettings & { gpuId?: string; metric?: GpuMetricKind; gpuCombo?: string; }; type GpuState = { settings: GpuSettings; action?: KeyAction; unsubscribe?: () => void; lastSvg?: string; }; @action({ UUID: "com.bryce.llamawatch.gpu" }) export class GpuGraph extends SingletonAction { private states = new Map(); private stateFor(action: KeyAction): GpuState { let state = this.states.get(action.id); if (!state) { state = { settings: {} }; this.states.set(action.id, state); } return state; } override onWillAppear(ev: WillAppearEvent): void { if (!ev.action.isKey()) return; const state = this.stateFor(ev.action); state.settings = ev.payload.settings; state.action = ev.action; runtime.ensureConnections(cfgFromSettings(state.settings)); state.unsubscribe = runtime.subscribe(() => this.render(state)); this.render(state); } override onWillDisappear(ev: WillDisappearEvent): void { const state = this.states.get(ev.action.id); if (!state) return; state.unsubscribe?.(); this.states.delete(ev.action.id); } override onDidReceiveSettings(ev: DidReceiveSettingsEvent): void { if (!ev.action.isKey()) return; const state = this.stateFor(ev.action); state.settings = ev.payload.settings; state.action = ev.action; runtime.ensureConnections(cfgFromSettings(state.settings)); this.render(state); } override onKeyDown(ev: KeyDownEvent): void { void streamDeck.system.openUrl(`${cfgFromSettings(ev.payload.settings).baseUrl}/ui`); } private render(state: GpuState): void { const action = state.action; if (!action) return; const poller = runtime.pollerInstance; const metric = state.settings.metric ?? "util_percent"; const combo = parseCombo(state.settings.gpuCombo); const offline = !poller || poller.isOffline(); let value: number | undefined; let history: number[] = []; let gpuName: string; if (combo) { const known = new Set((poller?.gpus() ?? []).map((g) => g.id)); const ids = combo.filter((id) => known.has(id)); const histories = ids.map((id) => poller?.getHistory(id, metric) ?? []); const combined = combineSeries(histories, metric); value = combined.value; history = combined.history; gpuName = ids.length > 0 ? `GPU ${ids.join("+")}` : "NO GPUS"; } else { const gpuId = state.settings.gpuId ?? "all"; value = poller?.getValue(gpuId, metric); history = poller?.getHistory(gpuId, metric) ?? []; gpuName = gpuId === "all" ? "ALL GPUS" : this.gpuLabel(gpuId); } const svg = renderGpuGraph({ gpuName, metric, value, history, offline }); if (svg === state.lastSvg) return; state.lastSvg = svg; void action.setImage(svgDataUrl(svg)); } private gpuLabel(gpuId: string): string { const gpu = runtime.pollerInstance?.gpus().find((g) => g.id === gpuId); return gpu && gpu.name ? `GPU ${gpuId} ยท ${gpu.name}` : `GPU ${gpuId}`; } } function parseCombo(raw: string | undefined): string[] | undefined { const ids = [...new Set((raw ?? "").split(",").map((s) => s.trim()).filter(Boolean))]; return ids.length > 0 ? ids : undefined; }