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 areas: [] 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 // Catalog lights grouped by Home Assistant area, recomputed whenever either // half changes. Both halves are read here so the binding tracks them. readonly property var rooms: root.composeRooms(root.catalog, root.areas) 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; if (!areasProc.running) areasProc.running = true; } // Rooms hold the SAME entity objects as the catalog, so a tile bound to a // room light sees exactly what the catalog says. Areas keep the order Home // Assistant reports them in; catalog lights no area claims fall into a // trailing "Other" bucket. With no usable areas the whole catalog becomes a // single unnamed bucket, which the UI renders as a flat list with no header. function composeRooms(catalog: var, areas: var): var { const lights = Array.isArray(catalog) ? catalog : []; const flat = [{ id: "", name: "", lights: lights.slice() }]; if (!Array.isArray(areas) || areas.length === 0) return flat; const catalogById = {}; for (let index = 0; index < lights.length; index++) catalogById[lights[index].id] = lights[index]; const claimed = {}; const grouped = []; for (let index = 0; index < areas.length; index++) { const area = areas[index]; const entityIds = area && Array.isArray(area.entities) ? area.entities : []; const roomLights = []; for (let inner = 0; inner < entityIds.length; inner++) { const entity = catalogById[entityIds[inner]]; if (!entity || claimed[entity.id] === true) continue; claimed[entity.id] = true; roomLights.push(entity); } if (roomLights.length === 0) continue; grouped.push({ id: String(area.id || ""), name: String(area.name || ""), lights: roomLights }); } // Areas that name nothing in the catalog leave every light an orphan; // one "Other" heading over the whole list is worse than no heading. if (grouped.length === 0) return flat; const orphans = lights.filter(entity => claimed[entity.id] !== true); if (orphans.length > 0) grouped.push({ id: "", name: "Other", lights: orphans }); return grouped; } 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; } } // Rooms are a nicety layered over the catalog: a Home Assistant whose // template endpoint is unavailable still gets its lights. A failure here // only empties `areas` -- it never touches `phase`, `stale`, or `lastError`. function consumeAreas(text: string): void { if (root.fixtureMode) return; let result = null; try { result = JSON.parse(text); } catch (error) { root.areas = []; return; } root.areas = result && result.ok === true && Array.isArray(result.areas) ? result.areas : []; } 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 } ]; } // Deliberately leaves light.fixture_all and light.fixture_corner unclaimed // so fixtures exercise the trailing "Other" bucket as well as the rooms. function fixtureAreas(): var { return [ { id: "kitchen", name: "Kitchen", entities: ["light.fixture_kitchen"] }, { id: "living_room", name: "Living room", entities: ["light.fixture_living", "light.fixture_desk"] }, { id: "bedroom", name: "Bedroom", entities: ["light.fixture_bedroom", "light.fixture_hall"] } ]; } 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.areas = []; root.fixtureFavorites = []; root.rebuildSelection(); root.phase = "unavailable"; root.stale = false; root.lastError = "not-configured"; return; } root.catalog = root.fixtureEntities(); root.areas = root.fixtureAreas(); 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.areas = []; 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: areasProc command: [root.helperPath, "areas"] stdout: StdioCollector { onStreamFinished: root.consumeAreas(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() }