Files

277 lines
8.6 KiB
QML

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()
}
}