import QtQuick import Quickshell import Quickshell.Io Item { id: service property var daemon: null readonly property var pluginData: (daemon && daemon.pluginData) ? daemon.pluginData : ({}) readonly property string baseUrl: String(pluginData.haUrl || "").trim().replace(/\/+$/, "") readonly property string token: String(pluginData.haToken || "").trim() readonly property int pollInterval: Math.max(2, pluginData.haPollInterval || 5) * 1000 readonly property bool configured: baseUrl !== "" && token !== "" // entity_id -> state object, plus a sorted array for pickers. property var entities: ({}) property var entityList: [] property string lastError: "" property bool loading: false property bool everLoaded: false property int lastStatus: 0 // Polling only runs while something is actually showing HA data. property int consumers: 0 signal refreshed signal callFailed(string message) function addConsumer() { service.consumers = service.consumers + 1; if (service.consumers === 1) service.refresh(); } function removeConsumer() { service.consumers = Math.max(0, service.consumers - 1); } function entityState(entityId) { return service.entities[entityId] || null; } function friendlyName(entityId) { const state = service.entityState(entityId); if (state && state.attributes && state.attributes.friendly_name) return state.attributes.friendly_name; return entityId; } function domainOf(entityId) { const parts = String(entityId || "").split("."); return parts.length > 1 ? parts[0] : ""; } // A changed URL or token invalidates whatever the last attempt reported. onTokenChanged: service.lastError = "" onBaseUrlChanged: service.lastError = "" // The token is handed to curl through the environment, so it never shows up // in the process arguments (and there is no stdin race to lose it to). readonly property string curlGetScript: 'exec curl -sS --fail-with-body --connect-timeout 4 --max-time 10 -w "\n%{http_code}" -H "Authorization: Bearer $GAMEBAR_HA_TOKEN" -H "Content-Type: application/json" "$1"' readonly property string curlPostScript: 'exec curl -sS --fail-with-body --connect-timeout 4 --max-time 10 -H "Authorization: Bearer $GAMEBAR_HA_TOKEN" -H "Content-Type: application/json" -X POST -d "$2" "$1"' function refresh() { if (!service.configured) { service.lastError = "Not configured"; return; } if (service.loading) return; service.loading = true; statesProc.environment = { "GAMEBAR_HA_TOKEN": service.token }; statesProc.command = ["sh", "-c", service.curlGetScript, "gameBar", service.baseUrl + "/api/states"]; statesProc.running = true; } function callService(domain, method, data) { if (!service.configured) return; service.callQueue.push({ "url": service.baseUrl + "/api/services/" + domain + "/" + method, "body": JSON.stringify(data || {}) }); service.drainQueue(); } property var callQueue: [] function drainQueue() { if (callProc.running || service.callQueue.length === 0) return; const next = service.callQueue.shift(); callProc.environment = { "GAMEBAR_HA_TOKEN": service.token }; callProc.command = ["sh", "-c", service.curlPostScript, "gameBar", next.url, next.body]; callProc.running = true; } function toggle(entityId) { const domain = service.domainOf(entityId); const toggleDomains = ["light", "switch", "fan", "input_boolean", "siren", "humidifier", "media_player", "automation"]; const target = toggleDomains.indexOf(domain) !== -1 ? domain : "homeassistant"; service.callService(target, "toggle", { "entity_id": entityId }); service.scheduleRefresh(); } function setBrightness(entityId, percent) { service.callService("light", "turn_on", { "entity_id": entityId, "brightness_pct": Math.round(percent) }); service.scheduleRefresh(); } function setVolume(entityId, percent) { service.callService("media_player", "volume_set", { "entity_id": entityId, "volume_level": Math.max(0, Math.min(1, percent / 100)) }); service.scheduleRefresh(); } function setMuted(entityId, muted) { service.callService("media_player", "volume_mute", { "entity_id": entityId, "is_volume_muted": muted === true }); service.scheduleRefresh(); } function setNumber(entityId, value) { service.callService(service.domainOf(entityId), "set_value", { "entity_id": entityId, "value": value }); service.scheduleRefresh(); } function press(entityId) { const domain = service.domainOf(entityId); if (domain === "scene" || domain === "script") service.callService(domain, "turn_on", { "entity_id": entityId }); else service.callService(domain, "press", { "entity_id": entityId }); service.scheduleRefresh(); } function cover(entityId, action) { service.callService("cover", action, { "entity_id": entityId }); service.scheduleRefresh(); } function scheduleRefresh() { followUpTimer.restart(); } Process { id: statesProc running: false stdout: StdioCollector { onStreamFinished: { service.loading = false; let raw = String(text || "").trim(); if (raw === "") return; // curl -w appends the HTTP status on its own line. const split = raw.lastIndexOf("\n"); if (split !== -1) { const tail = raw.substring(split + 1).trim(); if (/^[0-9]{3}$/.test(tail)) { service.lastStatus = parseInt(tail, 10); raw = raw.substring(0, split).trim(); } } if (service.lastStatus === 401) { service.lastError = "401 Unauthorized - check the access token"; return; } try { const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) { service.lastError = parsed.message || "Unexpected response"; return; } const map = {}; for (const item of parsed) map[item.entity_id] = item; service.entities = map; service.entityList = parsed.slice().sort((a, b) => a.entity_id.localeCompare(b.entity_id)); service.lastError = ""; service.everLoaded = true; service.refreshed(); } catch (e) { service.lastError = "Invalid response from Home Assistant"; } } } stderr: StdioCollector { onStreamFinished: { const raw = String(text || "").trim(); if (raw !== "") service.lastError = raw.split("\n")[0]; } } onExited: exitCode => { service.loading = false; if (exitCode !== 0 && service.lastError === "") service.lastError = "Request failed (curl " + exitCode + ")"; } } Process { id: callProc running: false stderr: StdioCollector { onStreamFinished: { const raw = String(text || "").trim(); if (raw !== "") service.callFailed(raw.split("\n")[0]); } } onExited: exitCode => { if (exitCode !== 0) service.callFailed("Service call failed (curl " + exitCode + ")"); service.drainQueue(); } } Timer { id: pollTimer interval: service.pollInterval repeat: true running: service.consumers > 0 && service.configured onTriggered: service.refresh() } // Home Assistant needs a moment to apply a service call before it shows up // in /api/states. Timer { id: followUpTimer interval: 400 repeat: false onTriggered: service.refresh() } }