diff --git a/config/dot/quickshell/services/HomeAssistant.qml b/config/dot/quickshell/services/HomeAssistant.qml index 8beceab..df2a7cf 100644 --- a/config/dot/quickshell/services/HomeAssistant.qml +++ b/config/dot/quickshell/services/HomeAssistant.qml @@ -1,11 +1,12 @@ pragma Singleton // Home Assistant state for the Control Center. Credentials and REST details -// remain behind the helper; QML receives a normalized light catalog only. +// remain behind the helper; QML composes its catalog with Panama preferences. import Quickshell import Quickshell.Io import QtQuick +import qs.config Singleton { id: root @@ -14,26 +15,42 @@ Singleton { // "loading" | "ready" | "degraded" | "unavailable" property string phase: "loading" - property var entities: [] + property var catalog: [] + property var selectedEntities: [] property bool stale: false - property string busyEntityId: "" property string lastError: "" property bool fixtureMode: false + property var fixtureFavorites: [] - readonly property var visibleEntities: root.entities.slice(0, 4) - readonly property int configuredCount: root.entities.length + 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.entities.length === 0) + if (root.catalog.length === 0) root.phase = "loading"; refreshProc.running = true; } - function consumeSnapshot(text: string): void { + function consumeCatalog(text: string): void { if (root.fixtureMode) return; + let result = null; try { result = JSON.parse(text); @@ -42,11 +59,11 @@ Singleton { } if (result.ok === true) { - root.entities = Array.isArray(result.entities) - ? result.entities.map(entity => Object.assign({}, entity, { - name: entity.sourceName, domain: "light" - })) - : []; + 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 = ""; @@ -54,7 +71,7 @@ Singleton { } root.lastError = String(result.error || "unreachable"); - if (root.entities.length > 0) { + if (root.catalog.length > 0) { root.phase = "degraded"; root.stale = true; } else { @@ -63,30 +80,129 @@ Singleton { } } - function toggleEntity(entityId: string): void { - if (entityId === "" || root.busyEntityId !== "") - return; - if (root.fixtureMode) { - root.entities = root.entities.map(entity => { - if (entity.id !== entityId) - return entity; - return Object.assign({}, entity, { - active: !entity.active, - state: entity.active ? "off" : "on" + 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; } - if (!root.entities.some(entity => entity.id === entityId)) + + root.actionQueue = root.actionQueue.concat([action]); + root.startNextAction(); + } + + function startNextAction(): void { + if (actionProc.running || root.activeAction !== null || root.actionQueue.length === 0) return; - root.busyEntityId = entityId; - actionProc.command = [root.helperPath, "toggle", entityId]; + + 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.busyEntityId === "") + if (root.activeAction === null) return; + + const completedAction = root.activeAction; let ok = false; let errorCode = "action-failed"; try { @@ -96,17 +212,46 @@ Singleton { } catch (error) { errorCode = "invalid-response"; } - root.busyEntityId = ""; + + root.actionQueue = root.actionQueue.slice(1); + root.activeAction = null; + root.finishActionState(completedAction.entityId); + + const nextErrors = Object.assign({}, root.entityErrors); if (ok) { - root.lastError = ""; + delete nextErrors[completedAction.entityId]; refreshDelay.restart(); } else { - root.lastError = errorCode; - if (root.entities.length > 0) { - root.phase = "degraded"; - root.stale = true; - } + 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 { @@ -115,41 +260,79 @@ Singleton { function fixtureEntities(): var { return [ - { id: "light.fixture_all", name: "All lights", domain: "light", state: "on", available: true, active: true }, - { id: "light.fixture_kitchen", name: "Kitchen", domain: "light", state: "on", available: true, active: true }, - { id: "light.fixture_living", name: "Living room", domain: "light", state: "off", available: true, active: false }, - { id: "light.fixture_bedroom", name: "Bedroom", domain: "light", state: "on", available: true, active: true }, - { id: "light.fixture_hall", name: "Hall", domain: "light", state: "off", available: true, active: false }, - { id: "light.fixture_desk", name: "Desk", domain: "light", state: "off", available: true, active: false }, - { id: "light.fixture_corner", name: "Corner lamp", domain: "light", state: "unavailable", available: false, active: false } + { 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"].indexOf(name) < 0) + if (["ready", "stale", "unavailable", "missing-selected", "action-error"].indexOf(name) < 0) return; + root.fixtureMode = true; - root.busyEntityId = ""; + root.resetActionState(); if (name === "unavailable") { - root.entities = []; + root.catalog = []; + root.fixtureFavorites = []; + root.rebuildSelection(); root.phase = "unavailable"; root.stale = false; root.lastError = "not-configured"; return; } - root.entities = root.fixtureEntities(); + + 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.entities = []; + root.catalog = []; + root.selectedEntities = []; + root.fixtureFavorites = []; root.stale = false; - root.busyEntityId = ""; root.lastError = ""; + root.resetActionState(); root.refresh(); } @@ -157,19 +340,16 @@ Singleton { id: refreshProc command: [root.helperPath, "catalog"] stdout: StdioCollector { - onStreamFinished: root.consumeSnapshot(this.text) + onStreamFinished: root.consumeCatalog(this.text) } } Process { id: actionProc stdout: StdioCollector { - onStreamFinished: root.consumeAction(this.text) - } - onExited: (code, status) => { - if (root.busyEntityId !== "") - root.consumeAction(""); + id: actionOutput } + onExited: (code, status) => root.consumeAction(actionOutput.text) } Timer { diff --git a/config/dot/quickshell/shell.qml b/config/dot/quickshell/shell.qml index 09a8396..2e7007b 100644 --- a/config/dot/quickshell/shell.qml +++ b/config/dot/quickshell/shell.qml @@ -258,14 +258,21 @@ ShellRoot { function fixture(name: string): void { HomeAssistant.applyFixture(name); } function reset(): void { HomeAssistant.clearFixture(); } function refresh(): void { HomeAssistant.refresh(); } + function brightness(id: string, percent: int): void { HomeAssistant.setBrightness(id, percent); } + function toggle(id: string): void { HomeAssistant.toggleEntity(id); } function status(): string { return JSON.stringify({ fixture: HomeAssistant.fixtureMode, phase: HomeAssistant.phase, + discoveredCount: HomeAssistant.discoveredCount, configuredCount: HomeAssistant.configuredCount, visibleCount: HomeAssistant.visibleEntities.length, + selectedIds: HomeAssistant.selectedEntities.map(entity => entity.id), + entities: HomeAssistant.selectedEntities, stale: HomeAssistant.stale, - busy: HomeAssistant.busyEntityId !== "", + busyEntityIds: HomeAssistant.busyEntityIds, + pendingBrightness: HomeAssistant.pendingBrightness, + entityErrors: HomeAssistant.entityErrors, lastError: HomeAssistant.lastError }); } diff --git a/tests/quickshell/control-center-services-contract.sh b/tests/quickshell/control-center-services-contract.sh index ecd4946..6614589 100755 --- a/tests/quickshell/control-center-services-contract.sh +++ b/tests/quickshell/control-center-services-contract.sh @@ -56,20 +56,80 @@ jq -e '.transferActive == false and .ongoingCount == 0' \ || fail 'phone transfer did not leave Ongoing' qs ipc call home-assistant fixture ready >/dev/null +home_ready="$(qs ipc call home-assistant status)" jq -e ' .fixture == true and .phase == "ready" and + .discoveredCount == 7 and .configuredCount == 7 and .visibleCount == 4 and + .selectedIds[0:4] == [ + "light.fixture_all", + "light.fixture_kitchen", + "light.fixture_living", + "light.fixture_bedroom" + ] and + (.entities[0:4] | map(.name)) == [ + "Whole home", + "Kitchen island", + "Living room", + "Bedroom" + ] and .stale == false and .lastError == "" -' <<<"$(qs ipc call home-assistant status)" >/dev/null \ +' <<<"$home_ready" >/dev/null \ || fail 'ready Home fixture is malformed' +qs ipc call home-assistant brightness light.fixture_living 64 >/dev/null +jq -e ' + .entities[] | + select(.id == "light.fixture_living") | + .active == true and .state == "on" and .brightnessPct == 64 +' <<<"$(qs ipc call home-assistant status)" >/dev/null \ + || fail 'Home brightness fixture did not update local catalog state' + +qs ipc call home-assistant toggle light.fixture_living >/dev/null +jq -e ' + .entities[] | + select(.id == "light.fixture_living") | + .active == false and .state == "off" +' <<<"$(qs ipc call home-assistant status)" >/dev/null \ + || fail 'Home toggle fixture did not update local catalog state' + +qs ipc call home-assistant fixture missing-selected >/dev/null +jq -e ' + .fixture == true and + .phase == "ready" and + .discoveredCount == 7 and + .configuredCount == 8 and + (.entities[] | + select(.id == "light.fixture_missing") | + .sourceName == "fixture missing" and + .name == "Porch" and + .state == "unavailable" and + .available == false and + .active == false and + .dimmable == false and + .brightnessPct == 0) +' <<<"$(qs ipc call home-assistant status)" >/dev/null \ + || fail 'missing Home selection was not retained as unavailable' + +qs ipc call home-assistant fixture action-error >/dev/null +jq -e ' + .fixture == true and + .phase == "ready" and + .stale == false and + .entityErrors["light.fixture_kitchen"] == "request-failed" and + (.entityErrors["light.fixture_hall"] // "") == "" and + .lastError == "" +' <<<"$(qs ipc call home-assistant status)" >/dev/null \ + || fail 'Home action error was not isolated to one entity' + qs ipc call home-assistant fixture stale >/dev/null jq -e ' .fixture == true and .phase == "degraded" and + .discoveredCount == 7 and .configuredCount == 7 and .visibleCount == 4 and .stale == true and @@ -81,6 +141,7 @@ qs ipc call home-assistant fixture unavailable >/dev/null jq -e ' .fixture == true and .phase == "unavailable" and + .discoveredCount == 0 and .configuredCount == 0 and .visibleCount == 0 and .lastError == "not-configured"