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 var actionQueue: [] property var activeAction: null property var busyEntityIds: [] property var pendingBrightness: ({}) property var entityErrors: ({}) readonly property var visibleEntities: root.selectedEntities.slice(0, 4) readonly property int discoveredCount: root.catalog.length readonly property int configuredCount: root.selectedEntities.length // 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 (!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.applyFixtureAction(action); root.finishActionState(action.entityId); return; } root.actionQueue = root.actionQueue.concat([action]); root.startNextAction(); } function startNextAction(): void { if (actionProc.running || root.activeAction !== null || root.actionQueue.length === 0) return; root.activeAction = root.actionQueue[0]; 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 consumeAction(text: string): void { if (root.activeAction === null) return; const completedAction = root.activeAction; let ok = false; let errorCode = "action-failed"; try { const result = JSON.parse(text); ok = result.ok === true; errorCode = String(result.error || errorCode); } catch (error) { errorCode = "invalid-response"; } 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]; refreshDelay.restart(); } else { nextErrors[completedAction.entityId] = errorCode; } root.entityErrors = nextErrors; root.startNextAction(); } 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 { root.actionQueue = []; root.activeAction = null; root.busyEntityIds = []; root.pendingBrightness = {}; root.entityErrors = {}; } function applyFixture(name: string): void { if (["ready", "stale", "unavailable", "missing-selected", "action-error"].indexOf(name) < 0) return; root.fixtureMode = true; root.resetActionState(); 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 === "missing-selected") { root.fixtureFavorites = root.fixtureFavorites.concat([ { id: "light.fixture_missing", alias: "Porch" } ]); } root.rebuildSelection(); root.phase = name === "stale" ? "degraded" : "ready"; root.stale = name === "stale"; root.lastError = name === "stale" ? "unreachable" : ""; if (name === "action-error") { root.entityErrors = { "light.fixture_kitchen": "request-failed" }; } } function clearFixture(): void { root.fixtureMode = false; root.phase = "loading"; root.catalog = []; root.selectedEntities = []; root.fixtureFavorites = []; root.stale = false; root.lastError = ""; root.resetActionState(); root.refresh(); } Process { id: refreshProc command: [root.helperPath, "catalog"] stdout: StdioCollector { onStreamFinished: root.consumeCatalog(this.text) } } Process { id: actionProc stdout: StdioCollector { id: actionOutput } onExited: (code, status) => root.consumeAction(actionOutput.text) } 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() }