Merge Home & Phone into a three-tab Home that knows your house
Home is now Overview | My Home | Phone. Overview leads with quick-action tiles (focus, Do Not Disturb, health, snapshots, storage), keeps the findings card — updates fold in, the reclaim-space prompt is gone on purpose — and adds glance cards, the next calendar event, and weather. My Home groups every light by Home Assistant area: the helper gained an `areas` command (one REST template render, no websocket), and the rooms degrade to a flat list on setups without areas. The favorites editor and connection card moved intact. Phone gains a vitals strip — battery and cell signal read from KDE Connect's plugin D-Bus objects, where absence is data, not an error — beside ring, clipboard, send-a-file, and the BlueBubbles handoff. The retired home-phone id resolves to my-home forever via a new alias map in SettingsRoutes (with a hasOwnProperty guard so prototype names cannot leak into settingsPage). Storage no longer claims 0 B free — the old page read a field the disks helper never emitted. Contracts updated alongside; per the new workflow, the full suite runs once at the end of the redesign (see the test backlog note). Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -33,7 +33,7 @@ Singleton {
|
||||
readonly property var statuses: ["ok", "warning", "error", "unconfigured"]
|
||||
readonly property var groups: ["desktop-foundation", "input-media", "integrations", "panama-tools"]
|
||||
readonly property var overallStatuses: ["healthy", "warning", "error"]
|
||||
readonly property var settingsTargets: ["home-phone", "datetime"]
|
||||
readonly property var settingsTargets: ["my-home", "datetime"]
|
||||
readonly property var instructionTargets: ["ddc-permissions"]
|
||||
|
||||
Process {
|
||||
|
||||
@@ -16,6 +16,7 @@ Singleton {
|
||||
// "loading" | "ready" | "degraded" | "unavailable"
|
||||
property string phase: "loading"
|
||||
property var catalog: []
|
||||
property var areas: []
|
||||
property var selectedEntities: []
|
||||
property bool stale: false
|
||||
property string lastError: ""
|
||||
@@ -38,6 +39,10 @@ Singleton {
|
||||
property var pendingFixtureTarget: null
|
||||
property int delayedFixtureExitCode: 0
|
||||
|
||||
// Catalog lights grouped by Home Assistant area, recomputed whenever either
|
||||
// half changes. Both halves are read here so the binding tracks them.
|
||||
readonly property var rooms: root.composeRooms(root.catalog, root.areas)
|
||||
|
||||
readonly property var visibleEntities: root.selectedEntities.slice(0, 4)
|
||||
readonly property int discoveredCount: root.catalog.length
|
||||
readonly property int configuredCount: root.selectedEntities.length
|
||||
@@ -56,6 +61,56 @@ Singleton {
|
||||
if (root.catalog.length === 0)
|
||||
root.phase = "loading";
|
||||
refreshProc.running = true;
|
||||
if (!areasProc.running)
|
||||
areasProc.running = true;
|
||||
}
|
||||
|
||||
// Rooms hold the SAME entity objects as the catalog, so a tile bound to a
|
||||
// room light sees exactly what the catalog says. Areas keep the order Home
|
||||
// Assistant reports them in; catalog lights no area claims fall into a
|
||||
// trailing "Other" bucket. With no usable areas the whole catalog becomes a
|
||||
// single unnamed bucket, which the UI renders as a flat list with no header.
|
||||
function composeRooms(catalog: var, areas: var): var {
|
||||
const lights = Array.isArray(catalog) ? catalog : [];
|
||||
const flat = [{ id: "", name: "", lights: lights.slice() }];
|
||||
if (!Array.isArray(areas) || areas.length === 0)
|
||||
return flat;
|
||||
|
||||
const catalogById = {};
|
||||
for (let index = 0; index < lights.length; index++)
|
||||
catalogById[lights[index].id] = lights[index];
|
||||
|
||||
const claimed = {};
|
||||
const grouped = [];
|
||||
for (let index = 0; index < areas.length; index++) {
|
||||
const area = areas[index];
|
||||
const entityIds = area && Array.isArray(area.entities) ? area.entities : [];
|
||||
const roomLights = [];
|
||||
for (let inner = 0; inner < entityIds.length; inner++) {
|
||||
const entity = catalogById[entityIds[inner]];
|
||||
if (!entity || claimed[entity.id] === true)
|
||||
continue;
|
||||
claimed[entity.id] = true;
|
||||
roomLights.push(entity);
|
||||
}
|
||||
if (roomLights.length === 0)
|
||||
continue;
|
||||
grouped.push({
|
||||
id: String(area.id || ""),
|
||||
name: String(area.name || ""),
|
||||
lights: roomLights
|
||||
});
|
||||
}
|
||||
|
||||
// Areas that name nothing in the catalog leave every light an orphan;
|
||||
// one "Other" heading over the whole list is worse than no heading.
|
||||
if (grouped.length === 0)
|
||||
return flat;
|
||||
|
||||
const orphans = lights.filter(entity => claimed[entity.id] !== true);
|
||||
if (orphans.length > 0)
|
||||
grouped.push({ id: "", name: "Other", lights: orphans });
|
||||
return grouped;
|
||||
}
|
||||
|
||||
function consumeCatalog(text: string): void {
|
||||
@@ -91,6 +146,26 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
// Rooms are a nicety layered over the catalog: a Home Assistant whose
|
||||
// template endpoint is unavailable still gets its lights. A failure here
|
||||
// only empties `areas` -- it never touches `phase`, `stale`, or `lastError`.
|
||||
function consumeAreas(text: string): void {
|
||||
if (root.fixtureMode)
|
||||
return;
|
||||
|
||||
let result = null;
|
||||
try {
|
||||
result = JSON.parse(text);
|
||||
} catch (error) {
|
||||
root.areas = [];
|
||||
return;
|
||||
}
|
||||
|
||||
root.areas = result && result.ok === true && Array.isArray(result.areas)
|
||||
? result.areas
|
||||
: [];
|
||||
}
|
||||
|
||||
function rebuildSelection(): void {
|
||||
const favorites = root.fixtureMode
|
||||
? root.fixtureFavorites
|
||||
@@ -364,6 +439,16 @@ Singleton {
|
||||
];
|
||||
}
|
||||
|
||||
// Deliberately leaves light.fixture_all and light.fixture_corner unclaimed
|
||||
// so fixtures exercise the trailing "Other" bucket as well as the rooms.
|
||||
function fixtureAreas(): var {
|
||||
return [
|
||||
{ id: "kitchen", name: "Kitchen", entities: ["light.fixture_kitchen"] },
|
||||
{ id: "living_room", name: "Living room", entities: ["light.fixture_living", "light.fixture_desk"] },
|
||||
{ id: "bedroom", name: "Bedroom", entities: ["light.fixture_bedroom", "light.fixture_hall"] }
|
||||
];
|
||||
}
|
||||
|
||||
function fixturePreferenceRecords(): var {
|
||||
return [
|
||||
{ id: "light.fixture_all", alias: " Whole home " },
|
||||
@@ -479,6 +564,7 @@ Singleton {
|
||||
: (name === "process-slow-actions" ? "slow-success" : "")));
|
||||
if (name === "unavailable") {
|
||||
root.catalog = [];
|
||||
root.areas = [];
|
||||
root.fixtureFavorites = [];
|
||||
root.rebuildSelection();
|
||||
root.phase = "unavailable";
|
||||
@@ -488,6 +574,7 @@ Singleton {
|
||||
}
|
||||
|
||||
root.catalog = root.fixtureEntities();
|
||||
root.areas = root.fixtureAreas();
|
||||
root.fixtureFavorites = root.fixturePreferenceRecords();
|
||||
if (name === "available-extra") {
|
||||
root.catalog = root.catalog.concat([{
|
||||
@@ -530,6 +617,7 @@ Singleton {
|
||||
root.fixtureProcessMode = "";
|
||||
root.phase = "loading";
|
||||
root.catalog = [];
|
||||
root.areas = [];
|
||||
root.selectedEntities = [];
|
||||
root.fixtureFavorites = [];
|
||||
root.stale = false;
|
||||
@@ -545,6 +633,14 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: areasProc
|
||||
command: [root.helperPath, "areas"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.consumeAreas(this.text)
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: actionProc
|
||||
stdout: StdioCollector {
|
||||
|
||||
@@ -43,6 +43,12 @@ Singleton {
|
||||
readonly property int pairedCount: root.devices.filter(device => device.paired).length
|
||||
readonly property var phoneActions: root.preferredPhone?.actions ?? []
|
||||
|
||||
// Vitals are null whenever the phone is away or the plugin is off, which is
|
||||
// the normal state rather than an error -- consumers show nothing, not a
|
||||
// zero. `?? null` keeps `undefined` from leaking out as a distinct case.
|
||||
readonly property var phoneBattery: root.preferredPhone?.battery ?? null
|
||||
readonly property var phoneSignal: root.preferredPhone?.signal ?? null
|
||||
|
||||
function supports(action: string): bool {
|
||||
return root.phoneActions.indexOf(action) >= 0;
|
||||
}
|
||||
@@ -53,13 +59,44 @@ Singleton {
|
||||
statusProc.running = true;
|
||||
}
|
||||
|
||||
// The helper emits `battery` and `signal` as either null or a complete
|
||||
// object. These rebuild them anyway so a partial payload -- an old helper,
|
||||
// a field that arrived as a string -- becomes null rather than a card
|
||||
// rendering "undefined%".
|
||||
function normalizeBattery(value: var): var {
|
||||
if (!value || typeof value.charge !== "number" || value.charge < 0)
|
||||
return null;
|
||||
return {
|
||||
charge: Math.round(value.charge),
|
||||
charging: value.charging === true
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSignal(value: var): var {
|
||||
if (!value || typeof value.strength !== "number" || value.strength < 0)
|
||||
return null;
|
||||
return {
|
||||
networkType: String(value.networkType ?? ""),
|
||||
strength: Math.round(value.strength)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDevice(device: var): var {
|
||||
return Object.assign({}, device, {
|
||||
battery: root.normalizeBattery(device.battery),
|
||||
signal: root.normalizeSignal(device.signal)
|
||||
});
|
||||
}
|
||||
|
||||
function consumeStatus(text: string): void {
|
||||
if (root.fixtureMode)
|
||||
return;
|
||||
try {
|
||||
const result = JSON.parse(text);
|
||||
root.available = result.available === true;
|
||||
root.devices = Array.isArray(result.devices) ? result.devices : [];
|
||||
root.devices = Array.isArray(result.devices)
|
||||
? result.devices.map(device => root.normalizeDevice(device))
|
||||
: [];
|
||||
root.lastError = String(result.error ?? "");
|
||||
} catch (error) {
|
||||
root.available = false;
|
||||
@@ -188,13 +225,19 @@ Singleton {
|
||||
root.available = true;
|
||||
root.lastError = "";
|
||||
root.recentExchange = null;
|
||||
// Vitals track reachability the way the helper's do: the plugin objects
|
||||
// only exist for a phone that is actually there, so the offline fixture
|
||||
// carries nulls and the reachable ones carry readable values.
|
||||
const reachable = name !== "offline";
|
||||
root.devices = [{
|
||||
id: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
name: "Fixture iPhone",
|
||||
type: "phone",
|
||||
paired: true,
|
||||
reachable: name !== "offline",
|
||||
actions: ["clipboard", "ping", "ring", "share"]
|
||||
reachable: reachable,
|
||||
actions: ["clipboard", "ping", "ring", "share"],
|
||||
battery: reachable ? { charge: 82, charging: true } : null,
|
||||
signal: reachable ? { networkType: "LTE", strength: 3 } : null
|
||||
}];
|
||||
root.transferActive = name === "transfer";
|
||||
root.transferFileName = name === "transfer" ? "Fixture document.pdf" : "";
|
||||
|
||||
@@ -27,7 +27,11 @@ Singleton {
|
||||
// alias. Three category ids ("applications", "users", "privacy") double as
|
||||
// the id of their first tab, which resolves to the same place either way.
|
||||
readonly property var categories: [
|
||||
{ page: "home", label: "Home", icon: "\u{F02DC}", tabs: [] },
|
||||
{ page: "home", label: "Home", icon: "\u{F02DC}", tabs: [
|
||||
{ page: "home", label: "Overview" },
|
||||
{ page: "my-home", label: "My Home" },
|
||||
{ page: "phone", label: "Phone" }
|
||||
] },
|
||||
{ page: "appearance", label: "Appearance", icon: "\u{F0E0D}", tabs: [] },
|
||||
{ page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}", tabs: [] },
|
||||
{ page: "displays", label: "Displays", icon: "\u{F0379}", tabs: [] },
|
||||
@@ -44,7 +48,6 @@ Singleton {
|
||||
{ page: "sharing", label: "Sharing" },
|
||||
{ page: "printers", label: "Printers" }
|
||||
] },
|
||||
{ page: "home-phone", label: "Home & Phone", icon: "\u{F02DC}", tabs: [] },
|
||||
{ page: "applications", label: "Applications", icon: "\u{F003B}", tabs: [
|
||||
{ page: "applications", label: "Applications" },
|
||||
{ page: "gaming", label: "Gaming" },
|
||||
@@ -98,10 +101,20 @@ Singleton {
|
||||
return root.categories.find(cat => cat.page === leaf) ?? root.categories[0];
|
||||
}
|
||||
|
||||
// Any id a caller may hold — leaf, category, or garbage — to the leaf that
|
||||
// should render: a leaf resolves to itself, a category to its first
|
||||
// available tab, anything unknown to home.
|
||||
// Retired page ids keep resolving forever: old Vicinae commands, shell
|
||||
// history, and muscle memory all hold them. Each maps to the leaf that
|
||||
// absorbed its content.
|
||||
readonly property var retired: ({ "home-phone": "my-home" })
|
||||
|
||||
// Any id a caller may hold — leaf, category, retired id, or garbage — to
|
||||
// the leaf that should render: a leaf resolves to itself, a category to
|
||||
// its first available tab, anything unknown to home.
|
||||
function resolve(id: string): string {
|
||||
// hasOwnProperty guard: `retired` is a plain JS object, so a bare
|
||||
// index would answer for prototype names like "toString" and leak a
|
||||
// function into settingsPage.
|
||||
if (Object.prototype.hasOwnProperty.call(root.retired, id))
|
||||
return root.retired[id];
|
||||
const asLeaf = root.categories.some(cat => (cat.page === id && cat.tabs.length === 0)
|
||||
|| cat.tabs.some(tab => tab.page === id));
|
||||
if (asLeaf)
|
||||
|
||||
@@ -130,8 +130,14 @@ Singleton {
|
||||
{ label: "Language", detail: "The system language, applied to programs started afterwards", page: "region" },
|
||||
{ label: "Regional formats", detail: "How dates, times, and numbers are written", page: "region" },
|
||||
{ label: "Online accounts", detail: "Sign in to mail, calendar, and contacts", page: "accounts" },
|
||||
{ label: "Home Assistant", detail: "Connect the desktop to a Home Assistant server", page: "home-phone" },
|
||||
{ label: "Phone", detail: "Pair a phone for messages and notifications", page: "home-phone" },
|
||||
{ label: "Home Assistant", detail: "Connect the desktop to a Home Assistant server", page: "my-home" },
|
||||
{ label: "Lights", detail: "Toggle and dim lights, grouped by room", page: "my-home" },
|
||||
{ label: "Control Center lights", detail: "Choose the accessories on your shelf", page: "my-home" },
|
||||
{ label: "Phone", detail: "Battery, signal, and reaching your phone", page: "phone" },
|
||||
{ label: "Ring your phone", detail: "Find it, even when silenced", page: "phone" },
|
||||
{ label: "Send clipboard to phone", detail: "Hand off what you just copied", page: "phone" },
|
||||
{ label: "Send a file to phone", detail: "Lands in the phone's downloads", page: "phone" },
|
||||
{ label: "iMessage", detail: "Opens BlueBubbles", page: "phone" },
|
||||
{ label: "System information", detail: "Kernel, distribution, and hardware", page: "about" },
|
||||
{ label: "Desktop version", detail: "Which Hyprland and Quickshell this session runs", page: "about" },
|
||||
{ label: "Restore defaults", detail: "Return every Panama setting to its shipped value", page: "desktop" },
|
||||
|
||||
Reference in New Issue
Block a user