Compose Home catalog with accessory preferences

This commit is contained in:
Gabriel Brown
2026-08-17 16:35:41 -04:00
parent 925fc0276f
commit c2b19f7545
3 changed files with 303 additions and 55 deletions
+233 -53
View File
@@ -1,11 +1,12 @@
pragma Singleton pragma Singleton
// Home Assistant state for the Control Center. Credentials and REST details // 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
import Quickshell.Io import Quickshell.Io
import QtQuick import QtQuick
import qs.config
Singleton { Singleton {
id: root id: root
@@ -14,26 +15,42 @@ Singleton {
// "loading" | "ready" | "degraded" | "unavailable" // "loading" | "ready" | "degraded" | "unavailable"
property string phase: "loading" property string phase: "loading"
property var entities: [] property var catalog: []
property var selectedEntities: []
property bool stale: false property bool stale: false
property string busyEntityId: ""
property string lastError: "" property string lastError: ""
property bool fixtureMode: false property bool fixtureMode: false
property var fixtureFavorites: []
readonly property var visibleEntities: root.entities.slice(0, 4) property var actionQueue: []
readonly property int configuredCount: root.entities.length 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 { function refresh(): void {
if (root.fixtureMode || refreshProc.running) if (root.fixtureMode || refreshProc.running)
return; return;
if (root.entities.length === 0) if (root.catalog.length === 0)
root.phase = "loading"; root.phase = "loading";
refreshProc.running = true; refreshProc.running = true;
} }
function consumeSnapshot(text: string): void { function consumeCatalog(text: string): void {
if (root.fixtureMode) if (root.fixtureMode)
return; return;
let result = null; let result = null;
try { try {
result = JSON.parse(text); result = JSON.parse(text);
@@ -42,11 +59,11 @@ Singleton {
} }
if (result.ok === true) { if (result.ok === true) {
root.entities = Array.isArray(result.entities) root.catalog = Array.isArray(result.entities) ? result.entities : [];
? result.entities.map(entity => Object.assign({}, entity, { HomePreferences.initialize(Array.isArray(result.legacyEntityIds)
name: entity.sourceName, domain: "light" ? result.legacyEntityIds
})) : []);
: []; root.rebuildSelection();
root.phase = "ready"; root.phase = "ready";
root.stale = false; root.stale = false;
root.lastError = ""; root.lastError = "";
@@ -54,7 +71,7 @@ Singleton {
} }
root.lastError = String(result.error || "unreachable"); root.lastError = String(result.error || "unreachable");
if (root.entities.length > 0) { if (root.catalog.length > 0) {
root.phase = "degraded"; root.phase = "degraded";
root.stale = true; root.stale = true;
} else { } else {
@@ -63,30 +80,129 @@ Singleton {
} }
} }
function toggleEntity(entityId: string): void { function rebuildSelection(): void {
if (entityId === "" || root.busyEntityId !== "") const favorites = root.fixtureMode
return; ? root.fixtureFavorites
if (root.fixtureMode) { : HomePreferences.favorites;
root.entities = root.entities.map(entity => { const catalogById = {};
if (entity.id !== entityId) for (let index = 0; index < root.catalog.length; index++) {
return entity; const entity = root.catalog[index];
return Object.assign({}, entity, { catalogById[entity.id] = entity;
active: !entity.active, }
state: entity.active ? "off" : "on"
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; 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; 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; actionProc.running = true;
} }
function consumeAction(text: string): void { function consumeAction(text: string): void {
if (root.busyEntityId === "") if (root.activeAction === null)
return; return;
const completedAction = root.activeAction;
let ok = false; let ok = false;
let errorCode = "action-failed"; let errorCode = "action-failed";
try { try {
@@ -96,17 +212,46 @@ Singleton {
} catch (error) { } catch (error) {
errorCode = "invalid-response"; 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) { if (ok) {
root.lastError = ""; delete nextErrors[completedAction.entityId];
refreshDelay.restart(); refreshDelay.restart();
} else { } else {
root.lastError = errorCode; nextErrors[completedAction.entityId] = errorCode;
if (root.entities.length > 0) {
root.phase = "degraded";
root.stale = true;
}
} }
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 { function open(): void {
@@ -115,41 +260,79 @@ Singleton {
function fixtureEntities(): var { function fixtureEntities(): var {
return [ return [
{ id: "light.fixture_all", name: "All lights", domain: "light", state: "on", available: true, active: true }, { id: "light.fixture_all", sourceName: "All lights", state: "on", available: true, active: true, dimmable: true, brightnessPct: 82 },
{ id: "light.fixture_kitchen", name: "Kitchen", domain: "light", state: "on", available: true, active: true }, { id: "light.fixture_kitchen", sourceName: "Kitchen", state: "on", available: true, active: true, dimmable: true, brightnessPct: 71 },
{ id: "light.fixture_living", name: "Living room", domain: "light", state: "off", available: true, active: false }, { id: "light.fixture_living", sourceName: "Living room", state: "off", available: true, active: false, dimmable: true, brightnessPct: 36 },
{ id: "light.fixture_bedroom", name: "Bedroom", domain: "light", state: "on", available: true, active: true }, { id: "light.fixture_bedroom", sourceName: "Bedroom", state: "on", available: true, active: true, dimmable: true, brightnessPct: 48 },
{ id: "light.fixture_hall", name: "Hall", domain: "light", state: "off", available: true, active: false }, { id: "light.fixture_hall", sourceName: "Hall", state: "off", available: true, active: false, dimmable: false, brightnessPct: 0 },
{ id: "light.fixture_desk", name: "Desk", domain: "light", state: "off", available: true, active: false }, { id: "light.fixture_desk", sourceName: "Desk", state: "on", available: true, active: true, dimmable: true, brightnessPct: 24 },
{ id: "light.fixture_corner", name: "Corner lamp", domain: "light", state: "unavailable", available: false, active: false } { 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 { function applyFixture(name: string): void {
if (["ready", "stale", "unavailable"].indexOf(name) < 0) if (["ready", "stale", "unavailable", "missing-selected", "action-error"].indexOf(name) < 0)
return; return;
root.fixtureMode = true; root.fixtureMode = true;
root.busyEntityId = ""; root.resetActionState();
if (name === "unavailable") { if (name === "unavailable") {
root.entities = []; root.catalog = [];
root.fixtureFavorites = [];
root.rebuildSelection();
root.phase = "unavailable"; root.phase = "unavailable";
root.stale = false; root.stale = false;
root.lastError = "not-configured"; root.lastError = "not-configured";
return; 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.phase = name === "stale" ? "degraded" : "ready";
root.stale = name === "stale"; root.stale = name === "stale";
root.lastError = name === "stale" ? "unreachable" : ""; root.lastError = name === "stale" ? "unreachable" : "";
if (name === "action-error") {
root.entityErrors = {
"light.fixture_kitchen": "request-failed"
};
}
} }
function clearFixture(): void { function clearFixture(): void {
root.fixtureMode = false; root.fixtureMode = false;
root.phase = "loading"; root.phase = "loading";
root.entities = []; root.catalog = [];
root.selectedEntities = [];
root.fixtureFavorites = [];
root.stale = false; root.stale = false;
root.busyEntityId = "";
root.lastError = ""; root.lastError = "";
root.resetActionState();
root.refresh(); root.refresh();
} }
@@ -157,19 +340,16 @@ Singleton {
id: refreshProc id: refreshProc
command: [root.helperPath, "catalog"] command: [root.helperPath, "catalog"]
stdout: StdioCollector { stdout: StdioCollector {
onStreamFinished: root.consumeSnapshot(this.text) onStreamFinished: root.consumeCatalog(this.text)
} }
} }
Process { Process {
id: actionProc id: actionProc
stdout: StdioCollector { stdout: StdioCollector {
onStreamFinished: root.consumeAction(this.text) id: actionOutput
}
onExited: (code, status) => {
if (root.busyEntityId !== "")
root.consumeAction("");
} }
onExited: (code, status) => root.consumeAction(actionOutput.text)
} }
Timer { Timer {
+8 -1
View File
@@ -258,14 +258,21 @@ ShellRoot {
function fixture(name: string): void { HomeAssistant.applyFixture(name); } function fixture(name: string): void { HomeAssistant.applyFixture(name); }
function reset(): void { HomeAssistant.clearFixture(); } function reset(): void { HomeAssistant.clearFixture(); }
function refresh(): void { HomeAssistant.refresh(); } 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 { function status(): string {
return JSON.stringify({ return JSON.stringify({
fixture: HomeAssistant.fixtureMode, fixture: HomeAssistant.fixtureMode,
phase: HomeAssistant.phase, phase: HomeAssistant.phase,
discoveredCount: HomeAssistant.discoveredCount,
configuredCount: HomeAssistant.configuredCount, configuredCount: HomeAssistant.configuredCount,
visibleCount: HomeAssistant.visibleEntities.length, visibleCount: HomeAssistant.visibleEntities.length,
selectedIds: HomeAssistant.selectedEntities.map(entity => entity.id),
entities: HomeAssistant.selectedEntities,
stale: HomeAssistant.stale, stale: HomeAssistant.stale,
busy: HomeAssistant.busyEntityId !== "", busyEntityIds: HomeAssistant.busyEntityIds,
pendingBrightness: HomeAssistant.pendingBrightness,
entityErrors: HomeAssistant.entityErrors,
lastError: HomeAssistant.lastError lastError: HomeAssistant.lastError
}); });
} }
@@ -56,20 +56,80 @@ jq -e '.transferActive == false and .ongoingCount == 0' \
|| fail 'phone transfer did not leave Ongoing' || fail 'phone transfer did not leave Ongoing'
qs ipc call home-assistant fixture ready >/dev/null qs ipc call home-assistant fixture ready >/dev/null
home_ready="$(qs ipc call home-assistant status)"
jq -e ' jq -e '
.fixture == true and .fixture == true and
.phase == "ready" and .phase == "ready" and
.discoveredCount == 7 and
.configuredCount == 7 and .configuredCount == 7 and
.visibleCount == 4 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 .stale == false and
.lastError == "" .lastError == ""
' <<<"$(qs ipc call home-assistant status)" >/dev/null \ ' <<<"$home_ready" >/dev/null \
|| fail 'ready Home fixture is malformed' || 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 qs ipc call home-assistant fixture stale >/dev/null
jq -e ' jq -e '
.fixture == true and .fixture == true and
.phase == "degraded" and .phase == "degraded" and
.discoveredCount == 7 and
.configuredCount == 7 and .configuredCount == 7 and
.visibleCount == 4 and .visibleCount == 4 and
.stale == true and .stale == true and
@@ -81,6 +141,7 @@ qs ipc call home-assistant fixture unavailable >/dev/null
jq -e ' jq -e '
.fixture == true and .fixture == true and
.phase == "unavailable" and .phase == "unavailable" and
.discoveredCount == 0 and
.configuredCount == 0 and .configuredCount == 0 and
.visibleCount == 0 and .visibleCount == 0 and
.lastError == "not-configured" .lastError == "not-configured"