Files
Panama/config/dot/quickshell/services/HomeAssistant.qml

603 lines
21 KiB
QML

pragma Singleton
// Home Assistant state for the Control Center. Credentials and REST details
// remain behind the helper; QML composes its catalog with Panama preferences.
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-home-assistant"
// "loading" | "ready" | "degraded" | "unavailable"
property string phase: "loading"
property var catalog: []
property var selectedEntities: []
property bool stale: false
property string lastError: ""
property bool fixtureMode: false
property var fixtureFavorites: []
property string fixtureProcessMode: ""
property var actionQueue: []
property var activeAction: null
property var busyEntityIds: []
property var pendingBrightness: ({})
property var entityErrors: ({})
property string actionResponseText: ""
property bool actionStreamFinished: false
property bool actionExited: false
property int actionExitCode: 0
property bool fixtureTransitionDraining: false
property bool fixtureTransitionStreamFinished: false
property bool fixtureTransitionExited: false
property var pendingFixtureTarget: null
property int delayedFixtureExitCode: 0
readonly property var visibleEntities: root.selectedEntities.slice(0, 4)
readonly property int discoveredCount: root.catalog.length
readonly property int configuredCount: root.selectedEntities.length
readonly property bool actionProcessRunning: actionProc.running
// Temporary aliases keep the current Home controls usable until the shelf
// switches to the selected-entity and per-entity action interfaces.
readonly property var entities: root.selectedEntities
readonly property string busyEntityId: root.busyEntityIds.length > 0
? root.busyEntityIds[0]
: ""
function refresh(): void {
if (root.fixtureMode || refreshProc.running)
return;
if (root.catalog.length === 0)
root.phase = "loading";
refreshProc.running = true;
}
function consumeCatalog(text: string): void {
if (root.fixtureMode)
return;
let result = null;
try {
result = JSON.parse(text);
} catch (error) {
result = { ok: false, error: "invalid-response" };
}
if (result.ok === true) {
root.catalog = Array.isArray(result.entities) ? result.entities : [];
HomePreferences.initialize(Array.isArray(result.legacyEntityIds)
? result.legacyEntityIds
: []);
root.rebuildSelection();
root.phase = "ready";
root.stale = false;
root.lastError = "";
return;
}
root.lastError = String(result.error || "unreachable");
if (root.catalog.length > 0) {
root.phase = "degraded";
root.stale = true;
} else {
root.phase = "unavailable";
root.stale = false;
}
}
function rebuildSelection(): void {
const favorites = root.fixtureMode
? root.fixtureFavorites
: HomePreferences.favorites;
const catalogById = {};
for (let index = 0; index < root.catalog.length; index++) {
const entity = root.catalog[index];
catalogById[entity.id] = entity;
}
const nextSelection = [];
for (let index = 0; index < favorites.length; index++) {
const favorite = favorites[index];
const entity = catalogById[favorite.id];
const alias = String(favorite.alias || "").trim();
if (entity) {
nextSelection.push({
id: entity.id,
sourceName: entity.sourceName,
name: alias || entity.sourceName,
state: entity.state,
available: entity.available,
active: entity.active,
dimmable: entity.dimmable,
brightnessPct: entity.brightnessPct
});
continue;
}
const sourceName = favorite.id.split(".")[1].split("_").join(" ");
nextSelection.push({
id: favorite.id,
sourceName,
name: alias || sourceName,
state: "unavailable",
available: false,
active: false,
dimmable: false,
brightnessPct: 0
});
}
root.selectedEntities = nextSelection;
}
Connections {
target: HomePreferences
function onFavoritesChanged(): void {
if (!root.fixtureMode)
root.rebuildSelection();
}
}
function isBusy(entityId: string): bool {
return root.busyEntityIds.indexOf(entityId) >= 0;
}
function pendingFor(entityId: string): int {
return Object.prototype.hasOwnProperty.call(root.pendingBrightness, entityId)
? root.pendingBrightness[entityId]
: -1;
}
function errorFor(entityId: string): string {
return String(root.entityErrors[entityId] || "");
}
function toggleEntity(entityId: string): void {
root.enqueueAction({ kind: "toggle", entityId });
}
function setBrightness(entityId: string, percent: int): void {
if (!Number.isInteger(percent) || percent < 0 || percent > 100)
return;
root.enqueueAction({ kind: "brightness", entityId, percent });
}
function enqueueAction(action: var): void {
if (root.fixtureTransitionDraining)
return;
if (!action || (action.kind !== "toggle" && action.kind !== "brightness"))
return;
if (typeof action.entityId !== "string" || action.entityId === "" || root.isBusy(action.entityId))
return;
const entity = root.catalog.find(candidate => candidate.id === action.entityId);
if (!entity || !entity.available || (action.kind === "brightness" && !entity.dimmable))
return;
const nextErrors = Object.assign({}, root.entityErrors);
delete nextErrors[action.entityId];
root.entityErrors = nextErrors;
root.busyEntityIds = root.busyEntityIds.concat([action.entityId]);
if (action.kind === "brightness") {
const nextPending = Object.assign({}, root.pendingBrightness);
nextPending[action.entityId] = action.percent;
root.pendingBrightness = nextPending;
}
if (root.fixtureMode && root.fixtureProcessMode === "") {
root.applyFixtureAction(action);
root.finishActionState(action.entityId);
return;
}
root.actionQueue = root.actionQueue.concat([action]);
root.startNextAction();
}
function startNextAction(): void {
if (root.fixtureTransitionDraining
|| actionProc.running
|| root.activeAction !== null
|| root.actionQueue.length === 0) {
return;
}
root.activeAction = root.actionQueue[0];
root.actionResponseText = "";
root.actionStreamFinished = false;
root.actionExited = false;
root.actionExitCode = 0;
if (root.fixtureProcessMode !== "") {
actionProc.command = root.fixtureActionCommand(root.activeAction);
} else {
actionProc.command = root.activeAction.kind === "brightness"
? [root.helperPath, "brightness", root.activeAction.entityId, String(root.activeAction.percent)]
: [root.helperPath, "toggle", root.activeAction.entityId];
}
actionProc.running = true;
}
function fixtureActionCommand(action: var): var {
if (root.fixtureProcessMode === "delayed-exit")
return ["/usr/bin/sh", "-c", "printf '%s' '{\"ok\":true}'; exit 7"];
if (root.fixtureProcessMode === "no-output"
&& action.entityId === "light.fixture_kitchen") {
return ["/usr/bin/sh", "-c", "sleep 0.5; exit 7"];
}
if (root.fixtureProcessMode === "slow-success")
return ["/usr/bin/sh", "-c", "sleep 2; printf '%s' '{\"ok\":true}'"];
return ["/usr/bin/sh", "-c", "sleep 0.5; printf '%s' '{\"ok\":true}'"];
}
function handleActionStreamFinished(text: string): void {
if (root.fixtureTransitionDraining) {
root.fixtureTransitionStreamFinished = true;
fixtureTransitionTimer.restart();
return;
}
if (root.activeAction === null || root.actionStreamFinished)
return;
root.actionResponseText = text;
root.actionStreamFinished = true;
actionCompletionTimer.restart();
}
function handleActionExited(exitCode: int): void {
// This fixture-only delay gives the contract a deterministic window
// where the process stopped but its exit bookkeeping is still pending.
if (root.fixtureMode
&& root.fixtureProcessMode === "delayed-exit"
&& root.activeAction !== null
&& !root.fixtureTransitionDraining) {
root.delayedFixtureExitCode = exitCode;
delayedFixtureExitTimer.restart();
return;
}
root.recordActionExited(exitCode);
}
function recordActionExited(exitCode: int): void {
if (root.fixtureTransitionDraining) {
root.fixtureTransitionExited = true;
fixtureTransitionTimer.restart();
return;
}
if (root.activeAction === null || root.actionExited)
return;
root.actionExited = true;
root.actionExitCode = exitCode;
actionCompletionTimer.restart();
}
function tryCompleteAction(): void {
if (root.activeAction === null)
return;
if (actionProc.running) {
actionCompletionTimer.restart();
return;
}
if (!root.actionExited || !root.actionStreamFinished)
return;
root.consumeAction(root.actionResponseText, root.actionExitCode);
}
function consumeAction(text: string, exitCode: int): void {
if (root.activeAction === null)
return;
const completedAction = root.activeAction;
let ok = false;
let errorCode = text === "" ? "action-failed" : "invalid-response";
try {
const result = JSON.parse(text);
ok = exitCode === 0 && result.ok === true;
errorCode = String(result.error || "action-failed");
} catch (error) {
// Empty output from a failed process is an action failure, while
// malformed non-empty output remains an invalid response.
}
actionCompletionTimer.stop();
root.actionQueue = root.actionQueue.slice(1);
root.activeAction = null;
root.finishActionState(completedAction.entityId);
const nextErrors = Object.assign({}, root.entityErrors);
if (ok) {
delete nextErrors[completedAction.entityId];
if (root.fixtureMode && root.fixtureProcessMode !== "")
root.applyFixtureAction(completedAction);
else
refreshDelay.restart();
} else {
nextErrors[completedAction.entityId] = errorCode;
}
root.entityErrors = nextErrors;
queueAdvanceTimer.restart();
}
function finishActionState(entityId: string): void {
root.busyEntityIds = root.busyEntityIds.filter(id => id !== entityId);
const nextPending = Object.assign({}, root.pendingBrightness);
delete nextPending[entityId];
root.pendingBrightness = nextPending;
}
function applyFixtureAction(action: var): void {
root.catalog = root.catalog.map(entity => {
if (entity.id !== action.entityId)
return entity;
if (action.kind === "brightness") {
return Object.assign({}, entity, {
state: action.percent === 0 ? "off" : "on",
active: action.percent > 0,
brightnessPct: action.percent
});
}
return Object.assign({}, entity, {
active: !entity.active,
state: entity.active ? "off" : "on"
});
});
root.rebuildSelection();
}
function open(): void {
Quickshell.execDetached([root.helperPath, "open"]);
}
function fixtureEntities(): var {
return [
{ id: "light.fixture_all", sourceName: "All lights", state: "on", available: true, active: true, dimmable: true, brightnessPct: 82 },
{ id: "light.fixture_kitchen", sourceName: "Kitchen", state: "on", available: true, active: true, dimmable: true, brightnessPct: 71 },
{ id: "light.fixture_living", sourceName: "Living room", state: "off", available: true, active: false, dimmable: true, brightnessPct: 36 },
{ id: "light.fixture_bedroom", sourceName: "Bedroom", state: "on", available: true, active: true, dimmable: true, brightnessPct: 48 },
{ id: "light.fixture_hall", sourceName: "Hall", state: "off", available: true, active: false, dimmable: false, brightnessPct: 0 },
{ id: "light.fixture_desk", sourceName: "Desk", state: "on", available: true, active: true, dimmable: true, brightnessPct: 24 },
{ id: "light.fixture_corner", sourceName: "Corner lamp", state: "unavailable", available: false, active: false, dimmable: false, brightnessPct: 0 }
];
}
function fixturePreferenceRecords(): var {
return [
{ id: "light.fixture_all", alias: " Whole home " },
{ id: "light.fixture_kitchen", alias: "Kitchen island" },
{ id: "light.fixture_living", alias: "" },
{ id: "light.fixture_bedroom", alias: "" },
{ id: "light.fixture_hall", alias: "" },
{ id: "light.fixture_desk", alias: "Office" },
{ id: "light.fixture_corner", alias: "" }
];
}
function resetActionState(): void {
actionCompletionTimer.stop();
queueAdvanceTimer.stop();
root.actionQueue = [];
root.activeAction = null;
root.busyEntityIds = [];
root.pendingBrightness = {};
root.entityErrors = {};
root.actionResponseText = "";
root.actionStreamFinished = false;
root.actionExited = false;
root.actionExitCode = 0;
}
function beginFixtureTransition(): void {
if (root.fixtureTransitionDraining)
return;
const shouldDrain = root.fixtureMode
&& root.fixtureProcessMode !== ""
&& (actionProc.running || root.activeAction !== null);
const streamAlreadyFinished = root.actionStreamFinished;
const exitAlreadyObserved = root.actionExited;
fixtureTransitionTimer.stop();
root.fixtureTransitionDraining = shouldDrain;
root.fixtureTransitionStreamFinished = shouldDrain && streamAlreadyFinished;
root.fixtureTransitionExited = shouldDrain && exitAlreadyObserved;
root.resetActionState();
if (!shouldDrain)
return;
if (actionProc.running)
actionProc.running = false;
fixtureTransitionTimer.restart();
}
function tryFinishFixtureTransition(): void {
if (!root.fixtureTransitionDraining)
return;
if (actionProc.running) {
fixtureTransitionTimer.restart();
return;
}
if (!root.fixtureTransitionStreamFinished || !root.fixtureTransitionExited)
return;
const target = root.pendingFixtureTarget;
root.pendingFixtureTarget = null;
root.fixtureTransitionDraining = false;
root.fixtureTransitionStreamFinished = false;
root.fixtureTransitionExited = false;
if (target !== null)
root.installFixtureTarget(target);
queueAdvanceTimer.restart();
}
function applyFixture(name: string): void {
if (["ready", "available-extra", "stale", "stale-authentication", "stale-not-configured",
"unavailable", "missing-selected", "action-error",
"process-actions", "process-no-output", "process-delayed-exit",
"process-slow-actions"].indexOf(name) < 0)
return;
root.requestFixtureTarget({ fixture: true, name });
}
function requestFixtureTarget(target: var): void {
if (root.fixtureTransitionDraining) {
// Preserve the old process's callback guard and keep only the
// latest replacement requested during that drain.
root.pendingFixtureTarget = target;
root.resetActionState();
return;
}
root.pendingFixtureTarget = target;
root.beginFixtureTransition();
if (root.fixtureTransitionDraining)
return;
root.pendingFixtureTarget = null;
root.installFixtureTarget(target);
}
function installFixtureTarget(target: var): void {
if (target.fixture)
root.installFixture(target.name);
else
root.installLiveState();
}
function installFixture(name: string): void {
root.fixtureMode = true;
root.fixtureProcessMode = name === "process-actions"
? "success"
: (name === "process-no-output"
? "no-output"
: (name === "process-delayed-exit"
? "delayed-exit"
: (name === "process-slow-actions" ? "slow-success" : "")));
if (name === "unavailable") {
root.catalog = [];
root.fixtureFavorites = [];
root.rebuildSelection();
root.phase = "unavailable";
root.stale = false;
root.lastError = "not-configured";
return;
}
root.catalog = root.fixtureEntities();
root.fixtureFavorites = root.fixturePreferenceRecords();
if (name === "available-extra") {
root.catalog = root.catalog.concat([{
id: "light.fixture_guest",
sourceName: "Guest lamp",
state: "off",
available: true,
active: false,
dimmable: true,
brightnessPct: 0
}]);
}
if (name === "missing-selected") {
root.fixtureFavorites = root.fixtureFavorites.concat([
{ id: "light.fixture_missing", alias: "Porch" }
]);
}
root.rebuildSelection();
const isStale = ["stale", "stale-authentication", "stale-not-configured"].indexOf(name) >= 0;
root.phase = isStale ? "degraded" : "ready";
root.stale = isStale;
root.lastError = name === "stale-authentication"
? "authentication-required"
: (name === "stale-not-configured"
? "not-configured"
: (name === "stale" ? "unreachable" : ""));
if (name === "action-error") {
root.entityErrors = {
"light.fixture_kitchen": "request-failed"
};
}
}
function clearFixture(): void {
root.requestFixtureTarget({ fixture: false, name: "" });
}
function installLiveState(): void {
root.fixtureMode = false;
root.fixtureProcessMode = "";
root.phase = "loading";
root.catalog = [];
root.selectedEntities = [];
root.fixtureFavorites = [];
root.stale = false;
root.lastError = "";
root.refresh();
}
Process {
id: refreshProc
command: [root.helperPath, "catalog"]
stdout: StdioCollector {
onStreamFinished: root.consumeCatalog(this.text)
}
}
Process {
id: actionProc
stdout: StdioCollector {
onStreamFinished: root.handleActionStreamFinished(this.text)
}
onExited: (code, status) => root.handleActionExited(code)
}
Timer {
id: actionCompletionTimer
interval: 0
onTriggered: root.tryCompleteAction()
}
Timer {
id: queueAdvanceTimer
interval: 0
onTriggered: {
if (root.fixtureTransitionDraining)
return;
if (actionProc.running) {
queueAdvanceTimer.restart();
return;
}
root.startNextAction();
}
}
Timer {
id: fixtureTransitionTimer
interval: 0
onTriggered: root.tryFinishFixtureTransition()
}
Timer {
id: delayedFixtureExitTimer
interval: 1000
onTriggered: root.recordActionExited(root.delayedFixtureExitCode)
}
Timer {
interval: 60000
repeat: true
running: ShellState.quickSettingsOpen && !root.fixtureMode
onTriggered: root.refresh()
}
Timer {
id: refreshDelay
interval: 350
onTriggered: root.refresh()
}
Component.onCompleted: root.refresh()
}