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:
Gabriel Brown
2026-08-23 22:06:18 -04:00
parent 5490fd285d
commit 7578348db1
40 changed files with 2578 additions and 703 deletions
@@ -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 {