feat: added home assistant widget
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user