Compose Home catalog with accessory preferences
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user