277 lines
8.6 KiB
QML
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()
|
|
}
|
|
}
|