Merge branch 'feat/home-accessories-customization'

# Conflicts:
#	config/dot/quickshell/config/qmldir
#	config/dot/quickshell/services/SystemSettings.qml
#	tests/quickshell/settings-pages-contract.sh
This commit is contained in:
Gabriel Brown
2026-08-17 23:31:33 -04:00
36 changed files with 3539 additions and 452 deletions
+476 -59
View File
@@ -1,11 +1,12 @@
pragma Singleton
// Home Assistant state for the Control Center. Credentials and REST details
// remain behind the helper; QML receives configured favourites 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,53 @@ 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: []
property string fixtureProcessMode: ""
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: ({})
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
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.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,7 +70,11 @@ Singleton {
}
if (result.ok === true) {
root.entities = Array.isArray(result.entities) ? result.entities : [];
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 = "";
@@ -50,7 +82,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 {
@@ -59,50 +91,261 @@ 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 (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;
}
if (!root.entities.some(entity => entity.id === entityId))
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.busyEntityId = entityId;
actionProc.command = [root.helperPath, "toggle", entityId];
}
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 consumeAction(text: string): void {
if (root.busyEntityId === "")
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 = "action-failed";
let errorCode = text === "" ? "action-failed" : "invalid-response";
try {
const result = JSON.parse(text);
ok = result.ok === true;
errorCode = String(result.error || errorCode);
ok = exitCode === 0 && result.ok === true;
errorCode = String(result.error || "action-failed");
} catch (error) {
errorCode = "invalid-response";
// Empty output from a failed process is an action failure, while
// malformed non-empty output remains an invalid response.
}
root.busyEntityId = "";
actionCompletionTimer.stop();
root.actionQueue = root.actionQueue.slice(1);
root.activeAction = null;
root.finishActionState(completedAction.entityId);
const nextErrors = Object.assign({}, root.entityErrors);
if (ok) {
root.lastError = "";
refreshDelay.restart();
delete nextErrors[completedAction.entityId];
if (root.fixtureMode && root.fixtureProcessMode !== "")
root.applyFixtureAction(completedAction);
else
refreshDelay.restart();
} else {
root.lastError = errorCode;
if (root.entities.length > 0) {
root.phase = "degraded";
root.stale = true;
}
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 {
@@ -111,63 +354,237 @@ 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 applyFixture(name: string): void {
if (["ready", "stale", "unavailable"].indexOf(name) < 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 {
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.busyEntityId = "";
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.entities = [];
root.catalog = [];
root.fixtureFavorites = [];
root.rebuildSelection();
root.phase = "unavailable";
root.stale = false;
root.lastError = "not-configured";
return;
}
root.entities = root.fixtureEntities();
root.phase = name === "stale" ? "degraded" : "ready";
root.stale = name === "stale";
root.lastError = name === "stale" ? "unreachable" : "";
root.catalog = root.fixtureEntities();
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.entities = [];
root.catalog = [];
root.selectedEntities = [];
root.fixtureFavorites = [];
root.stale = false;
root.busyEntityId = "";
root.lastError = "";
root.refresh();
}
Process {
id: refreshProc
command: [root.helperPath, "snapshot"]
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)
onStreamFinished: root.handleActionStreamFinished(this.text)
}
onExited: (code, status) => {
if (root.busyEntityId !== "")
root.consumeAction("");
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
@@ -92,7 +92,7 @@ Singleton {
}
function openSettings(page: string): void {
const allowed = ["home", "appearance", "displays", "connectivity", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "services", "about"];
const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "services", "about"];
root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home";
DesktopPreferences.set("lastPage", root.settingsPage);
root.settingsOpen = true;
@@ -27,13 +27,16 @@ Singleton {
property bool hyprpaperActive: false
property bool hypridleActive: false
property bool vicinaeActive: false
property bool bluebubblesDetected: false
property string hyprlandVersion: ""
property string quickshellVersion: "0.3.0"
property string lastError: ""
readonly property bool busy: monitorQuery.running || serviceQuery.running || versionQuery.running
|| configWrite.running || configVerify.running
|| configWrite.running || configVerify.running || bluebubblesQuery.running
readonly property bool bluebubblesAvailable: root.bluebubblesDetected
readonly property bool autoHdr: DesktopPreferences.get("autoHdr")
readonly property int vrrPolicy: DesktopPreferences.get("vrrPolicy")
@@ -120,6 +123,12 @@ Singleton {
}
}
Process {
id: bluebubblesQuery
command: ["flatpak", "info", "app.bluebubbles.BlueBubbles"]
onExited: (exitCode, exitStatus) => root.bluebubblesDetected = exitCode === 0
}
// Reads the written options back out of the compositor. This is the only
// thing that decides whether a write succeeded.
Process {
@@ -148,6 +157,8 @@ Singleton {
serviceQuery.running = true;
if (!versionQuery.running && !root.hyprlandVersion)
versionQuery.running = true;
if (!bluebubblesQuery.running)
bluebubblesQuery.running = true;
}
function parseMonitors(text: string): void {
@@ -371,7 +382,8 @@ Singleton {
"nextcloud": ["nextcloud"],
"rustdesk": ["rustdesk"],
"kdeconnect": ["kdeconnect-app"],
"mission-center": ["flatpak", "run", "io.missioncenter.MissionCenter"]
"mission-center": ["flatpak", "run", "io.missioncenter.MissionCenter"],
"bluebubbles": ["flatpak", "run", "app.bluebubbles.BlueBubbles"]
};
const command = commands[id];
if (!command) {