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
+2 -1
View File
@@ -1,5 +1,6 @@
# Ignore bash environment variables. # Ignore bash environment variables, and the lock its writer takes.
/config/bash/env /config/bash/env
/config/bash/.env.lock
# Personal espanso triggers (name, email), seeded per-machine by setup-identity. # Personal espanso triggers (name, email), seeded per-machine by setup-identity.
/config/dot/espanso/match/identity.yml /config/dot/espanso/match/identity.yml
# Ignore backups of old config files # Ignore backups of old config files
+1 -1
View File
@@ -136,7 +136,7 @@ docs/ Settings reference, and the design specs behind the work
## Tests ## Tests
160 of them, under `tests/`. Run the lot, or a subset by pattern: 162 of them, under `tests/`. Run the lot, or a subset by pattern:
```sh ```sh
panama test # everything panama test # everything
@@ -2,13 +2,18 @@
## Settings ## Settings
`Super + I`. Fifteen categories down the side, covering appearance, displays, `Super + I`. Fourteen categories down the side, covering appearance, displays,
sound, input, network, power, accounts and the rest. A category with more than sound, input, network, power, accounts and the rest. A category with more than
one subject in it opens a row of tabs above the page, which is where the one subject in it opens a row of tabs above the page, which is where the
narrower topics live: Printers is a tab of Network & Sharing, Dictation a tab narrower topics live: Printers is a tab of Network & Sharing, Dictation a tab
of Input, and About, Software Update, Storage, Snapshots and this manual are of Input, and About, Software Update, Storage, Snapshots and this manual are
tabs of System. tabs of System.
Home is the first of them, and the page `Super + I` lands on. Its Overview tab
is the one you already know; My Home lists your Home Assistant lights by room
alongside the accessories Control Center shows, and Phone holds the iPhone
handoffs — battery, ring it, send it the clipboard or a file, and Messages.
The search box at the top searches the settings themselves rather than page The search box at the top searches the settings themselves rather than page
names, and each result says where it will land, so if you know what you want names, and each result says where it will land, so if you know what you want
to change you can type it and never touch the sidebar. to change you can type it and never touch the sidebar.
@@ -301,6 +301,6 @@ Item {
function openHomeSettings(): void { function openHomeSettings(): void {
ShellState.close(); ShellState.close();
ShellState.openSettings("home-phone"); ShellState.openSettings("my-home");
} }
} }
@@ -0,0 +1,163 @@
// One light on the My Home page: power square, name, state, and a brightness
// slider when the light dims. The slider is Control Center's — one dimmer
// implementation everywhere, covered by home-brightness-slider-contract.
import QtQuick
import qs.config
import qs.services
import qs.modules.quicksettings
Rectangle {
id: root
// A catalog entity: { id, sourceName, name, state, available, active,
// dimmable, brightnessPct }.
property var entity: ({})
// Whether this light sits in the first four favorites, and therefore in
// Control Center.
property bool featured: false
readonly property string entityId: String(root.entity.id ?? "")
readonly property bool busy: HomeAssistant.isBusy(root.entityId)
readonly property int pending: HomeAssistant.pendingFor(root.entityId)
readonly property string entityError: HomeAssistant.errorFor(root.entityId)
readonly property bool showsSlider: root.entity.dimmable === true && root.entity.available === true
radius: 14
color: Theme.alpha(Theme.fg, 0.05)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.07)
implicitHeight: header.implicitHeight + (showsSlider ? 36 : 0)
+ (entityError !== "" ? errorLine.implicitHeight + 4 : 0) + 22
Row {
id: header
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 11
spacing: 11
Rectangle {
id: power
width: 30
height: 29
radius: 10
anchors.verticalCenter: parent.verticalCenter
color: root.entity.active === true
? Theme.alpha(Theme.warn, 0.16)
: Theme.alpha(Theme.fg, 0.07)
border.width: 1
border.color: root.entity.active === true
? Theme.alpha(Theme.warn, 0.4)
: Theme.alpha(Theme.fg, 0.09)
opacity: root.entity.available === true && !root.busy ? 1 : 0.45
Text {
anchors.centerIn: parent
text: "\u{F0335}"
color: root.entity.active === true ? Theme.warn : Theme.fgDim
font.family: Theme.fontMono
font.pixelSize: 14
}
MouseArea {
anchors.fill: parent
enabled: root.entity.available === true && !root.busy
cursorShape: Qt.PointingHandCursor
onClicked: HomeAssistant.toggleEntity(root.entityId)
}
Accessible.role: Accessible.Button
Accessible.name: (root.entity.active === true ? "Turn off " : "Turn on ")
+ String(root.entity.name ?? root.entity.sourceName ?? "")
}
Column {
width: parent.width - power.width - parent.spacing
anchors.verticalCenter: parent.verticalCenter
spacing: 2
Row {
width: parent.width
spacing: 8
Text {
text: String(root.entity.name ?? root.entity.sourceName ?? "")
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
elide: Text.ElideRight
}
Rectangle {
visible: root.featured
anchors.verticalCenter: parent.verticalCenter
width: featuredCaption.implicitWidth + 16
height: 19
radius: Theme.pillRadius
color: Theme.alpha(Theme.accent, 0.1)
border.width: 1
border.color: Theme.alpha(Theme.accent, 0.2)
Text {
id: featuredCaption
anchors.centerIn: parent
text: "Control Center"
color: Theme.accent
font.family: Theme.fontFamily
font.pixelSize: 9
font.weight: Font.DemiBold
}
}
}
Text {
width: parent.width
text: {
if (root.entity.available !== true)
return "Unavailable";
if (root.entity.active !== true)
return "Off";
const pct = root.pending >= 0 ? root.pending : Number(root.entity.brightnessPct ?? 0);
return root.entity.dimmable === true ? "On · " + pct + "%" : "On";
}
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
}
HomeBrightnessSlider {
visible: root.showsSlider
anchors.left: parent.left
anchors.leftMargin: 11
anchors.right: parent.right
anchors.rightMargin: 11
anchors.top: header.bottom
accessibleName: String(root.entity.name ?? root.entity.sourceName ?? "Light") + " brightness"
value: root.pending >= 0 ? root.pending : Number(root.entity.brightnessPct ?? 0)
onCommitted: value => HomeAssistant.setBrightness(root.entityId, value)
}
Text {
id: errorLine
visible: root.entityError !== ""
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: 11
text: root.entityError === "unreachable"
? "Home Assistant did not respond"
: "That change did not apply"
color: Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
}
}
@@ -1,18 +1,11 @@
// Home — the page you open to do something, not to read a report. // Home — the page you open to do something, not to read a report.
// //
// It used to lead with a diagram of the attached monitor: DP-2, 4500 x 3000, // The Overview tab of the Home category. Quick actions sit under the greeting
// 1.13x scale, XRGB2101010. That is Displays-page data, it was the largest // as tiles; findings follow; then two glance cards into the sibling tabs (My
// thing on the screen, and nobody has ever needed it here. Below it sat a // Home, Phone), the next calendar event, and weather. The old Focus and
// permanently open search field for a weather location that is set about once a // containers cards retired into the tiles and the System category; the
// year, and then roughly half a page of nothing. // reclaim-space prompt is gone on purpose — stopped development containers
// // are not clutter, and prompting their deletion was a racket.
// What Panama already knows is the useful material: whether anything needs
// attention, what is running, and the two or three things worth doing next. A
// finding is shown when there is one and the page is quiet when there is not,
// which is the same shape the Firewall and Containers pages use.
//
// Weather stays. It is the one genuinely ambient thing here, and it belongs
// next to a greeting rather than in a card of its own with a search box open.
import QtQuick import QtQuick
import qs.config import qs.config
@@ -60,6 +53,14 @@ SettingsPage {
action: "Open" action: "Open"
}); });
} }
if (Updates.total > 0) {
found.push({
label: "Install " + Updates.total + " update" + (Updates.total === 1 ? "" : "s"),
detail: "Packages, applications and firmware, from wherever each comes from",
page: "updates",
action: "Open"
});
}
if (Health.summary?.errors > 0 || Health.summary?.warnings > 0) { if (Health.summary?.errors > 0 || Health.summary?.warnings > 0) {
const count = Number(Health.summary?.errors ?? 0) + Number(Health.summary?.warnings ?? 0); const count = Number(Health.summary?.errors ?? 0) + Number(Health.summary?.warnings ?? 0);
found.push({ found.push({
@@ -79,7 +80,7 @@ SettingsPage {
if (Health.checks.length > 0) if (Health.checks.length > 0)
parts.push(Health.checks.length + " health checks pass"); parts.push(Health.checks.length + " health checks pass");
if (Disks.rootFilesystem) if (Disks.rootFilesystem)
parts.push(Disks.formatBytes(Number(Disks.rootFilesystem.available ?? 0)) + " free"); parts.push(Disks.formatBytes(Number(Disks.rootFilesystem.availBytes ?? 0)) + " free");
const newest = Snapshots.configs.length > 0 const newest = Snapshots.configs.length > 0
? (Snapshots.configs[0].snapshots ?? [])[0] ? (Snapshots.configs[0].snapshots ?? [])[0]
: null; : null;
@@ -106,12 +107,48 @@ SettingsPage {
return full.split(" ")[0] || ""; return full.split(" ")[0] || "";
} }
// What the glance cards say about the sibling tabs.
readonly property var lightsOn: HomeAssistant.catalog.filter(entity => entity.active === true)
readonly property string myHomeGlance: {
if (HomeAssistant.lastError === "not-configured")
return "Set up Home Assistant";
if (root.lightsOn.length === 0)
return "No lights are on";
const names = root.lightsOn.slice(0, 2).map(entity => String(entity.name ?? entity.sourceName)).join(", ");
return root.lightsOn.length + (root.lightsOn.length === 1 ? " light on · " : " lights on · ") + names;
}
readonly property string phoneGlance: {
if (!KdeConnect.preferredPhone)
return "No paired phone";
const parts = [String(KdeConnect.preferredPhone.name)];
if (KdeConnect.phoneBattery)
parts.push(KdeConnect.phoneBattery.charge + "%");
parts.push(KdeConnect.phoneReachable ? "nearby" : "away");
return parts.join(" · ");
}
// The next timed calendar event, worded for a card.
readonly property string nextEventWhen: {
const event = CalendarAgenda.nextEvent;
if (!event)
return "";
const start = new Date(Number(event.start) * 1000);
const clock = start.toLocaleTimeString(Qt.locale(), Locale.ShortFormat);
const today = new Date();
const sameDay = start.getFullYear() === today.getFullYear()
&& start.getMonth() === today.getMonth() && start.getDate() === today.getDate();
return sameDay ? clock + " today" : clock + " " + start.toLocaleDateString(Qt.locale(), "dddd");
}
title: root.greetingName !== "" ? `${root.greeting}, ${root.greetingName}` : root.greeting title: root.greetingName !== "" ? `${root.greeting}, ${root.greetingName}` : root.greeting
lede: Weather.available lede: Weather.available
? Math.round(Weather.temperature) + Weather.unitSuffix + " and " ? Math.round(Weather.temperature) + Weather.unitSuffix + " and "
+ Weather.description.toLowerCase() + " in " + Settings.weatherLocation + Weather.description.toLowerCase() + " in " + Settings.weatherLocation
: "Your desktop is configured and ready." : "Your desktop is configured and ready."
// These scans also feed the System category's tab gating (Containers,
// Snapshots), so they fire when Settings opens even though this page no
// longer renders their cards.
Component.onCompleted: { Component.onCompleted: {
if (!Firewall.scanned) if (!Firewall.scanned)
Firewall.refresh(); Firewall.refresh();
@@ -123,90 +160,81 @@ SettingsPage {
Disks.refresh(); Disks.refresh();
} }
// ── What you came here to do ──────────────────────────────────────────── // ── Quick actions ───────────────────────────────────────────────────────
Grid { Grid {
id: doing id: quick
width: parent.width width: parent.width
columns: width >= 720 ? 2 : 1 columns: width >= 640 ? 5 : 2
columnSpacing: 16 columnSpacing: 10
rowSpacing: 16 rowSpacing: 10
SettingsCard { readonly property real tileWidth: (width - (columns - 1) * columnSpacing) / columns
width: doing.columns === 2
? (doing.width - doing.columnSpacing) / 2
: doing.width
title: "Focus"
subtitle: FocusSession.active
? "A session is running · " + FocusSession.remainingText + " left"
: (FocusModes.active
? FocusModes.activeName + " is on because " + FocusModes.activeReason
: (Notifs.doNotDisturb
? "Do Not Disturb is on"
: "Nothing is quieting this machine"))
SettingRow { HomeQuickTile {
label: FocusSession.active ? "End the session" : "Start a focus session" width: quick.tileWidth
detail: FocusSession.active glyph: "\u{F051F}"
? "Puts Do Not Disturb and Caffeine back as they were" label: FocusSession.active ? "End focus" : "Focus session"
: Settings.focusDurationMinutes + " minutes · Super+Shift+F" caption: FocusSession.active
controlWidth: 120 ? FocusSession.remainingText + " left · tap to end"
: Settings.focusDurationMinutes + " min · Super+Shift+F"
SettingsButton { onActivated: FocusSession.active ? FocusSession.end(false) : FocusSession.startDefault()
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: FocusSession.active ? "End" : "Start"
tone: FocusSession.active ? "normal" : "accent"
onClicked: FocusSession.active ? FocusSession.end(false) : FocusSession.startDefault()
}
} }
SettingRow { HomeQuickTile {
width: quick.tileWidth
glyph: "\u{F009A}"
glyphColor: Notifs.doNotDisturb ? Theme.warn : Theme.accent
label: "Do Not Disturb" label: "Do Not Disturb"
detail: FocusModes.active interactive: !FocusModes.active && !FocusSession.active
? "Held on by the " + FocusModes.activeName + " mode" caption: {
: "Banners are held; the notification centre still fills" if (FocusModes.active)
divider: false return "Held by the " + FocusModes.activeName + " mode";
controlWidth: 48 if (FocusSession.active)
return "Held by the focus session";
SettingsToggle { return Notifs.doNotDisturb ? "On — tap to allow banners" : "Off — tap to hold banners";
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
enabled: !FocusModes.active && !FocusSession.active
checked: Notifs.doNotDisturb
onToggled: value => Notifs.doNotDisturb = value
}
} }
onActivated: Notifs.doNotDisturb = !Notifs.doNotDisturb
} }
SettingsCard { HomeQuickTile {
width: doing.columns === 2 width: quick.tileWidth
? (doing.width - doing.columnSpacing) / 2 glyph: "\u{F0493}"
: doing.width glyphColor: Health.status === "error"
title: "Right now" ? Theme.danger : (Health.status === "warning" ? Theme.warn : Theme.ok)
subtitle: Containers.running > 0 label: "Health"
? Containers.running + " of " + Containers.total + " containers running" caption: {
: "No containers are running" if (Health.checks.length === 0)
return "Checking the desktop";
TextRow { const count = Number(Health.summary?.errors ?? 0) + Number(Health.summary?.warnings ?? 0);
label: Containers.running > 0 return count > 0 ? count + " need attention" : Health.checks.length + " checks pass";
? Containers.projects.filter(project => project.running > 0) }
.map(project => String(project.title)).join(", ") onActivated: ShellState.openSettings("services")
: "Nothing to report"
detail: Containers.running > 0
? "Started for development, still up"
: "Start a stack from the Containers page"
value: ""
} }
ActionRow { HomeQuickTile {
label: "Containers" width: quick.tileWidth
detail: "Projects, what they expose, and what they cost" glyph: "\u{F0954}"
action: "Open" label: "Snapshots"
divider: false caption: {
onTriggered: ShellState.settingsPage = "containers" const newest = Snapshots.configs.length > 0
? (Snapshots.configs[0].snapshots ?? [])[0] : null;
if (newest)
return "Ran at " + root.clockOf(String(newest.date ?? ""));
return Snapshots.configs.length > 0 ? "Configured" : "Not protected";
} }
onActivated: ShellState.openSettings("snapshots")
}
HomeQuickTile {
width: quick.tileWidth
glyph: "\u{F02CA}"
label: "Storage"
caption: Disks.rootFilesystem
? Disks.formatBytes(Number(Disks.rootFilesystem.availBytes ?? 0)) + " free"
: "Reading the drive"
onActivated: ShellState.openSettings("storage")
} }
} }
@@ -246,29 +274,129 @@ SettingsPage {
} }
} }
// ── Worth doing, when there is something ──────────────────────────────── // ── Glances into the sibling tabs ───────────────────────────────────────
SettingsCard { Grid {
visible: Updates.total > 0 || Containers.reclaimable > 0 id: glances
title: "Do next"
subtitle: "Small things this machine is waiting on."
ActionRow { width: parent.width
visible: Updates.total > 0 columns: width >= 720 ? 2 : 1
label: "Install " + Updates.total + " update" + (Updates.total === 1 ? "" : "s") columnSpacing: 16
detail: "Packages, applications and firmware, from wherever each comes from" rowSpacing: 16
action: "Open"
divider: Containers.reclaimable > 0 readonly property real cardWidth: columns === 2 ? (width - columnSpacing) / 2 : width
onTriggered: ShellState.settingsPage = "updates"
Repeater {
model: [
{ glyph: "\u{F0335}", title: "My Home", sub: root.myHomeGlance, page: "my-home" },
{ glyph: "\u{F011C}", title: "Phone", sub: root.phoneGlance, page: "phone" }
]
delegate: Rectangle {
id: glance
required property var modelData
width: glances.cardWidth
height: 74
radius: 14
color: glanceArea.containsMouse
? Theme.alpha(Theme.bgPanel, 0.95)
: Theme.alpha(Theme.bgPanel, 0.72)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.07)
Text {
id: glanceGlyph
anchors.left: parent.left
anchors.leftMargin: 16
anchors.verticalCenter: parent.verticalCenter
text: String(glance.modelData.glyph)
color: Theme.accent
font.family: Theme.fontMono
font.pixelSize: 19
} }
ActionRow { Column {
visible: Containers.reclaimable > 0 anchors.left: glanceGlyph.right
label: "Reclaim " + Containers.formatBytes(Containers.reclaimable) anchors.leftMargin: 14
detail: "Container images and volumes nothing references" anchors.right: chevron.left
action: "Review" anchors.rightMargin: 10
anchors.verticalCenter: parent.verticalCenter
spacing: 3
Text {
text: String(glance.modelData.title)
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
font.weight: Font.DemiBold
}
Text {
width: parent.width
text: String(glance.modelData.sub)
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
Text {
id: chevron
anchors.right: parent.right
anchors.rightMargin: 15
anchors.verticalCenter: parent.verticalCenter
text: ""
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: 18
}
MouseArea {
id: glanceArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: ShellState.openSettings(String(glance.modelData.page))
}
Accessible.role: Accessible.Button
Accessible.name: "Open " + String(glance.modelData.title)
}
}
}
// ── Today ───────────────────────────────────────────────────────────────
SettingsCard {
visible: CalendarAgenda.available && CalendarAgenda.nextEvent !== null
title: "Today"
SettingRow {
label: CalendarAgenda.nextEvent
? String(CalendarAgenda.nextEvent.summary) + " — " + root.nextEventWhen
: ""
detail: "Next on your calendar"
divider: false divider: false
onTriggered: ShellState.settingsPage = "containers" controlWidth: 110
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: String(CalendarAgenda.nextEvent?.joinUrl ?? "").startsWith("https://")
? "Join" : "Open"
onClicked: {
const event = CalendarAgenda.nextEvent;
if (!event)
return;
if (String(event.joinUrl ?? "").startsWith("https://"))
CalendarAgenda.join(event);
else
CalendarAgenda.openEvent(event);
}
}
} }
} }
@@ -0,0 +1,83 @@
// A quick action on the Home overview: one glyph, one name, one line of state,
// one tap. The fixed set lives in HomePage; this only draws and reports.
import QtQuick
import qs.config
Rectangle {
id: root
property string glyph
property color glyphColor: Theme.accent
property string label
property string caption
property bool interactive: true
signal activated()
radius: 14
color: tileArea.containsMouse && root.interactive
? Theme.alpha(Theme.fg, 0.09)
: Theme.alpha(Theme.fg, 0.05)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.07)
implicitHeight: body.implicitHeight + 24
Column {
id: body
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 12
spacing: 7
Rectangle {
width: 30
height: 30
radius: 10
color: Theme.alpha(root.glyphColor, 0.13)
Text {
anchors.centerIn: parent
text: root.glyph
color: root.glyphColor
font.family: Theme.fontMono
font.pixelSize: 14
}
}
Text {
width: parent.width
text: root.label
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.DemiBold
elide: Text.ElideRight
}
Text {
width: parent.width
text: root.caption
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.WordWrap
maximumLineCount: 2
elide: Text.ElideRight
}
}
MouseArea {
id: tileArea
anchors.fill: parent
hoverEnabled: true
cursorShape: root.interactive ? Qt.PointingHandCursor : Qt.ArrowCursor
onClicked: if (root.interactive) root.activated()
}
Accessible.role: Accessible.Button
Accessible.name: root.label
Accessible.description: root.caption
}
@@ -1,12 +1,21 @@
// My Home the Home Assistant half of the old Home & Phone page, promoted to
// a tab of Home and taught about rooms.
//
// Lights render grouped by Home Assistant areas when the helper can read them
// (HomeAssistant.rooms); a setup without areas degrades to one flat section
// with no header, which is exactly the old behavior. The favorites editor and
// the connection card move here unchanged the credential boundary they carry
// is contract-tested and was not worth re-inventing.
import QtQuick import QtQuick
import qs.config import qs.config
import qs.services import qs.services
SettingsPage { SettingsPage {
id: root id: root
objectName: "home-phone-page" objectName: "my-home-page"
title: "Home & Phone" title: "My Home"
lede: "Choose what appears in Control Center and keep phone continuity close at hand." lede: root.homeStatus()
property string lightQuery: searchInput.text.trim().toLowerCase() property string lightQuery: searchInput.text.trim().toLowerCase()
@@ -23,10 +32,20 @@ SettingsPage {
: (HomeAssistant.catalog.length === 0 : (HomeAssistant.catalog.length === 0
? "No lights discovered" ? "No lights discovered"
: "All discovered lights are already selected")) : "All discovered lights are already selected"))
// The first four favorites appear in Control Center; the pill on a tile
// says so. Everything else about featuring is HomePreferences' business.
readonly property var featuredIds: HomeAssistant.selectedEntities
.slice(0, 4).map(entity => String(entity.id))
readonly property var pageDiagnostics: ({ readonly property var pageDiagnostics: ({
availableLightIds: root.availableLights.map(entity => entity.id), availableLightIds: root.availableLights.map(entity => entity.id),
availableEmptyText: root.availableEmptyText, availableEmptyText: root.availableEmptyText,
homeStatus: root.homeStatus() homeStatus: root.homeStatus(),
rooms: (HomeAssistant.rooms ?? []).map(room => ({
name: String(room.name ?? ""),
count: (room.lights ?? []).length
}))
}) })
function homeStatus(): string { function homeStatus(): string {
@@ -34,8 +53,12 @@ SettingsPage {
return "Authentication required"; return "Authentication required";
if (HomeAssistant.lastError === "not-configured") if (HomeAssistant.lastError === "not-configured")
return "Home Assistant is not configured"; return "Home Assistant is not configured";
if (HomeAssistant.phase === "ready") if (HomeAssistant.phase === "ready") {
return `Connected · ${HomeAssistant.discoveredCount} lights discovered`; const named = (HomeAssistant.rooms ?? []).filter(room => String(room.name ?? "") !== "").length;
return named > 0
? `Connected · ${HomeAssistant.discoveredCount} lights across ${named} room${named === 1 ? "" : "s"}`
: `Connected · ${HomeAssistant.discoveredCount} lights discovered`;
}
if (HomeAssistant.phase === "degraded") if (HomeAssistant.phase === "degraded")
return "Last update unavailable · showing saved controls"; return "Last update unavailable · showing saved controls";
if (HomeAssistant.phase === "loading") if (HomeAssistant.phase === "loading")
@@ -64,6 +87,232 @@ SettingsPage {
} }
} }
// Rooms
SettingsCard {
visible: HomeAssistant.catalog.length > 0
title: "Lights"
subtitle: (HomeAssistant.rooms ?? []).some(room => String(room.name ?? "") !== "")
? "Grouped by Home Assistant areas. Tap to toggle; drag a track to dim."
: "Tap to toggle; drag a track to dim."
Column {
width: parent.width
spacing: 4
Repeater {
model: HomeAssistant.rooms ?? []
delegate: Column {
id: roomSection
required property var modelData
width: parent.width
spacing: 8
Text {
visible: String(roomSection.modelData.name ?? "") !== ""
text: String(roomSection.modelData.name ?? "").toUpperCase()
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
font.letterSpacing: 0.8
topPadding: 6
}
Grid {
id: roomGrid
width: parent.width
columns: width >= 560 ? 2 : 1
columnSpacing: 10
rowSpacing: 10
bottomPadding: 6
Repeater {
model: roomSection.modelData.lights ?? []
delegate: HomeLightTile {
required property var modelData
width: roomGrid.columns === 2
? (roomGrid.width - roomGrid.columnSpacing) / 2
: roomGrid.width
entity: modelData
featured: root.featuredIds.indexOf(String(modelData.id)) >= 0
}
}
}
}
}
}
}
// Accessories (the Control Center selection)
SettingsCard {
title: "Accessories"
subtitle: HomeAssistant.selectedEntities.length === 0
? "Select the lights that belong on your Control Center shelf."
: `${Math.min(4, HomeAssistant.selectedEntities.length)} in Control Center · ${HomeAssistant.selectedEntities.length} selected`
Text {
width: parent.width
visible: HomeAssistant.selectedEntities.length === 0
text: "Choose lights below to build your Control Center shelf."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
wrapMode: Text.WordWrap
topPadding: 3
bottomPadding: 13
}
GridView {
id: favoritesGrid
width: parent.width
height: Math.ceil(count / 2) * cellHeight
visible: count > 0
interactive: false
clip: false
cellWidth: width / 2
cellHeight: 116
model: HomeAssistant.selectedEntities
delegate: HomeFavoriteCard {
required property var modelData
width: GridView.view.cellWidth - 6
height: 108
favorite: ({
id: modelData.id,
alias: modelData.name === modelData.sourceName ? "" : modelData.name
})
sourceName: modelData.sourceName
featured: index < 4
canMoveEarlier: index > 0
canMoveLater: index < favoritesGrid.count - 1
onAliasCommitted: (id, alias) => HomePreferences.setAlias(id, alias)
onMoveRequested: (id, targetIndex) => HomePreferences.move(id, targetIndex)
onRemoveRequested: id => HomePreferences.remove(id)
}
}
Rectangle {
width: parent.width
height: 46
visible: HomePreferences.saveError !== ""
radius: 9
color: Theme.alpha(Theme.warn, 0.09)
border.width: 1
border.color: Theme.alpha(Theme.warn, 0.24)
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.right: retryButton.left
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: HomePreferences.saveError
color: Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
SettingsButton {
id: retryButton
anchors.right: parent.right
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
text: "Retry"
activeFocusOnTab: true
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.warn : Theme.alpha(Theme.fg, 0.08)
onClicked: HomePreferences.retrySave()
Keys.onReturnPressed: HomePreferences.retrySave()
Keys.onSpacePressed: HomePreferences.retrySave()
}
}
Rectangle {
width: parent.width
height: 38
radius: 10
color: Theme.alpha(Theme.fg, searchInput.activeFocus ? 0.08 : 0.05)
border.width: searchInput.activeFocus ? 2 : 1
border.color: searchInput.activeFocus
? Theme.alpha(Theme.accent, 0.72)
: Theme.alpha(Theme.fg, 0.065)
Text {
anchors.left: parent.left
anchors.leftMargin: 11
anchors.verticalCenter: parent.verticalCenter
text: "\u{F0349}"
color: Theme.fgDim
font.family: Theme.fontMono
font.pixelSize: 14
}
TextInput {
id: searchInput
anchors.left: parent.left
anchors.leftMargin: 36
anchors.right: parent.right
anchors.rightMargin: 11
anchors.verticalCenter: parent.verticalCenter
activeFocusOnTab: true
color: Theme.fg
selectionColor: Theme.accent
selectedTextColor: Theme.bgDark
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
clip: true
Text {
anchors.fill: parent
visible: searchInput.text === "" && !searchInput.activeFocus
text: "Search available lights"
color: Theme.fgMuted
font: searchInput.font
verticalAlignment: Text.AlignVCenter
}
}
}
Column {
width: parent.width
visible: root.availableLights.length > 0
Repeater {
model: root.availableLights
AvailableLightRow {
required property var modelData
width: parent.width
entity: modelData
onAddRequested: id => HomePreferences.add(id)
}
}
}
Text {
width: parent.width
visible: root.availableLights.length === 0
text: root.availableEmptyText
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
topPadding: 18
bottomPadding: 10
}
}
// Connection
SettingsCard { SettingsCard {
title: "Home Assistant" title: "Home Assistant"
subtitle: root.homeStatus() subtitle: root.homeStatus()
@@ -216,207 +465,4 @@ SettingsPage {
} }
} }
} }
SettingsCard {
title: "Control Center lights"
subtitle: HomeAssistant.selectedEntities.length === 0
? "Select the lights that belong on your shelf."
: `${Math.min(4, HomeAssistant.selectedEntities.length)} in Control Center · ${HomeAssistant.selectedEntities.length} selected`
Text {
width: parent.width
visible: HomeAssistant.selectedEntities.length === 0
text: "Choose lights below to build your Control Center shelf."
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
wrapMode: Text.WordWrap
topPadding: 3
bottomPadding: 13
}
GridView {
id: favoritesGrid
width: parent.width
height: Math.ceil(count / 2) * cellHeight
visible: count > 0
interactive: false
clip: false
cellWidth: width / 2
cellHeight: 116
model: HomeAssistant.selectedEntities
delegate: HomeFavoriteCard {
required property var modelData
width: GridView.view.cellWidth - 6
height: 108
favorite: ({
id: modelData.id,
alias: modelData.name === modelData.sourceName ? "" : modelData.name
})
sourceName: modelData.sourceName
featured: index < 4
canMoveEarlier: index > 0
canMoveLater: index < favoritesGrid.count - 1
onAliasCommitted: (id, alias) => HomePreferences.setAlias(id, alias)
onMoveRequested: (id, targetIndex) => HomePreferences.move(id, targetIndex)
onRemoveRequested: id => HomePreferences.remove(id)
}
}
Rectangle {
width: parent.width
height: 46
visible: HomePreferences.saveError !== ""
radius: 9
color: Theme.alpha(Theme.warn, 0.09)
border.width: 1
border.color: Theme.alpha(Theme.warn, 0.24)
Text {
anchors.left: parent.left
anchors.leftMargin: 12
anchors.right: retryButton.left
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: HomePreferences.saveError
color: Theme.warn
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
SettingsButton {
id: retryButton
anchors.right: parent.right
anchors.rightMargin: 8
anchors.verticalCenter: parent.verticalCenter
text: "Retry"
activeFocusOnTab: true
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.warn : Theme.alpha(Theme.fg, 0.08)
onClicked: HomePreferences.retrySave()
Keys.onReturnPressed: HomePreferences.retrySave()
Keys.onSpacePressed: HomePreferences.retrySave()
}
}
}
SettingsCard {
title: "Available lights"
subtitle: "Search the Home Assistant catalog by source name or entity ID."
Rectangle {
width: parent.width
height: 38
radius: 10
color: Theme.alpha(Theme.fg, searchInput.activeFocus ? 0.08 : 0.05)
border.width: searchInput.activeFocus ? 2 : 1
border.color: searchInput.activeFocus
? Theme.alpha(Theme.accent, 0.72)
: Theme.alpha(Theme.fg, 0.065)
Text {
anchors.left: parent.left
anchors.leftMargin: 11
anchors.verticalCenter: parent.verticalCenter
text: "\u{F0349}"
color: Theme.fgDim
font.family: Theme.fontMono
font.pixelSize: 14
}
TextInput {
id: searchInput
anchors.left: parent.left
anchors.leftMargin: 36
anchors.right: parent.right
anchors.rightMargin: 11
anchors.verticalCenter: parent.verticalCenter
activeFocusOnTab: true
color: Theme.fg
selectionColor: Theme.accent
selectedTextColor: Theme.bgDark
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
clip: true
Text {
anchors.fill: parent
visible: searchInput.text === "" && !searchInput.activeFocus
text: "Search available lights"
color: Theme.fgMuted
font: searchInput.font
verticalAlignment: Text.AlignVCenter
}
}
}
Column {
width: parent.width
visible: root.availableLights.length > 0
Repeater {
model: root.availableLights
AvailableLightRow {
required property var modelData
width: parent.width
entity: modelData
onAddRequested: id => HomePreferences.add(id)
}
}
}
Text {
width: parent.width
visible: root.availableLights.length === 0
text: root.availableEmptyText
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
topPadding: 18
bottomPadding: 10
}
}
SettingsCard {
title: "Phone continuity"
subtitle: "Keep the Messages handoff independent from phone connectivity."
SettingRow {
label: "Messages"
detail: "Opens BlueBubbles"
divider: false
controlWidth: 204
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 12
Text {
anchors.verticalCenter: parent.verticalCenter
text: SystemSettings.bluebubblesAvailable ? "Installed" : "Unavailable"
color: SystemSettings.bluebubblesAvailable ? Theme.ok : Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
SettingsButton {
id: openBlueBubblesButton
text: "Open"
enabled: SystemSettings.bluebubblesAvailable
activeFocusOnTab: enabled
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: SystemSettings.openApplication("bluebubbles")
Keys.onReturnPressed: if (enabled) SystemSettings.openApplication("bluebubbles")
Keys.onSpacePressed: if (enabled) SystemSettings.openApplication("bluebubbles")
}
}
}
}
} }
@@ -0,0 +1,258 @@
// Phone — the continuity half of the old Home & Phone page, promoted to a tab
// of Home and given vitals.
//
// The vitals strip reads battery and cell signal from KDE Connect's plugin
// D-Bus objects, which exist only while the phone is paired and reachable — a
// missing datum renders as an em-dash, never an error. Actions mirror Control
// Center's semantics exactly: KDE Connect actions need the phone nearby;
// Messages needs only BlueBubbles.
import QtQuick
import QtQuick.Dialogs
import qs.config
import qs.services
SettingsPage {
id: root
objectName: "phone-page"
title: "Phone"
lede: {
if (!KdeConnect.available)
return "KDE Connect is unavailable — open System Health for the service.";
if (!KdeConnect.preferredPhone)
return "Pair a phone with KDE Connect to reach it from here.";
return String(KdeConnect.preferredPhone.name)
+ (KdeConnect.phoneReachable ? " · nearby on Wi-Fi" : " · not nearby");
}
readonly property bool actionsReady: KdeConnect.phoneReachable && !KdeConnect.transferActive
function localPath(selectedUrl: url): string {
const value = String(selectedUrl);
if (!value.startsWith("file://"))
return "";
return decodeURIComponent(value.slice(7));
}
FileDialog {
id: fileDialog
title: KdeConnect.preferredPhone
? "Send a file to " + KdeConnect.preferredPhone.name : "Send a file"
fileMode: FileDialog.OpenFile
nameFilters: ["All files (*)"]
onAccepted: {
const path = root.localPath(selectedFile);
if (path !== "")
KdeConnect.sendFile(path);
}
}
// ── Vitals ──────────────────────────────────────────────────────────────
Row {
id: vitals
width: parent.width
spacing: 12
Repeater {
model: [
{
value: KdeConnect.phoneBattery
? KdeConnect.phoneBattery.charge + "%" : "—",
caption: KdeConnect.phoneBattery
? (KdeConnect.phoneBattery.charging ? "Battery · charging" : "Battery")
: "Battery"
},
{
value: KdeConnect.phoneSignal
? String(KdeConnect.phoneSignal.networkType) : "—",
caption: KdeConnect.phoneSignal
? "Signal · " + KdeConnect.phoneSignal.strength + " of 4"
: "Signal"
},
{
value: KdeConnect.phoneReachable
? "Nearby"
: (KdeConnect.pairedCount > 0 ? "Away" : "Unpaired"),
caption: KdeConnect.phoneReachable
? "Reachable on Wi-Fi"
: (KdeConnect.pairedCount > 0
? "Actions wait for the phone to reconnect"
: "Pair a device to continue")
}
]
delegate: Rectangle {
id: vital
required property var modelData
width: (vitals.width - vitals.spacing * 2) / 3
height: 66
radius: 12
color: Theme.alpha(Theme.fg, 0.045)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.06)
Column {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: 14
anchors.rightMargin: 14
spacing: 3
Text {
text: String(vital.modelData.value)
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge + 3
font.weight: Font.DemiBold
font.features: Theme.tabularFigures
}
Text {
width: parent.width
text: String(vital.modelData.caption)
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
elide: Text.ElideRight
}
}
}
}
}
// ── Reach it ────────────────────────────────────────────────────────────
SettingsCard {
title: "Reach it"
subtitle: KdeConnect.transferActive
? "Sending " + KdeConnect.transferFileName + "…"
: "Everything you actually do weekly, one card."
SettingRow {
label: "Ring"
detail: "Even when silenced"
controlWidth: 110
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: "Ring"
enabled: root.actionsReady && KdeConnect.supports("ring")
onClicked: KdeConnect.ring()
}
}
SettingRow {
label: "Clipboard"
detail: "Send what you just copied"
controlWidth: 110
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: "Send"
enabled: root.actionsReady && KdeConnect.supports("clipboard")
onClicked: KdeConnect.sendClipboard()
}
}
SettingRow {
label: "Send a file"
detail: KdeConnect.recentExchange
? "Last sent " + KdeConnect.recentExchange.fileName
: "Lands in the phone's downloads"
divider: KdeConnect.transferActive
controlWidth: 110
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: "Choose…"
enabled: root.actionsReady && KdeConnect.supports("share")
onClicked: fileDialog.open()
}
}
SettingRow {
visible: KdeConnect.transferActive
label: "Sending " + KdeConnect.transferFileName
detail: "To " + KdeConnect.transferDeviceName
divider: false
controlWidth: 110
SettingsButton {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: "Cancel"
tone: "danger"
onClicked: KdeConnect.cancelTransfer()
}
}
}
// ── Messages ────────────────────────────────────────────────────────────
SettingsCard {
title: "Messages"
subtitle: "Kept independent from phone connectivity."
SettingRow {
label: "iMessage"
detail: "Opens BlueBubbles"
divider: false
controlWidth: 204
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 12
Text {
anchors.verticalCenter: parent.verticalCenter
text: SystemSettings.bluebubblesAvailable ? "Installed" : "Unavailable"
color: SystemSettings.bluebubblesAvailable ? Theme.ok : Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
SettingsButton {
id: openBlueBubblesButton
text: "Open"
enabled: SystemSettings.bluebubblesAvailable
activeFocusOnTab: enabled
border.width: activeFocus ? 2 : 1
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
onClicked: SystemSettings.openApplication("bluebubbles")
Keys.onReturnPressed: if (enabled) SystemSettings.openApplication("bluebubbles")
Keys.onSpacePressed: if (enabled) SystemSettings.openApplication("bluebubbles")
}
}
}
}
// ── Device ──────────────────────────────────────────────────────────────
SettingsCard {
title: "Device"
ActionRow {
label: "Connection"
detail: "Ask the service to look again"
action: "Refresh"
onTriggered: KdeConnect.refresh()
}
ActionRow {
label: "Device settings"
detail: "Pairing, plugins and service health"
action: "Open"
divider: false
onTriggered: ShellState.openSettings("connectivity")
}
}
}
@@ -6,13 +6,21 @@ such rather than half-reimplemented.
## Navigation ## Navigation
The sidebar lists fifteen **categories**, not one row per page. A category The sidebar lists fourteen **categories**, not one row per page. A category
covering several subjects — Input, Network & Sharing, Applications, Users & covering several subjects — Home, Input, Network & Sharing, Applications,
Accounts, Privacy & Security, System — draws a tab strip above the page, and Users & Accounts, Privacy & Security, System — draws a tab strip above the
each of its **leaf** pages is one tab; a category with a single subject is a page, and each of its **leaf** pages is one tab; a category with a single
leaf itself and shows no strip. The column used to be a flat list of thirty-one subject is a leaf itself and shows no strip. The column used to be a flat list
pages, which made finding Printers a scan of the whole thing. Grouped, it sits of thirty-one pages, which made finding Printers a scan of the whole thing.
under Network & Sharing, where somebody looking for it already expects it. Grouped, it sits under Network & Sharing, where somebody looking for it already
expects it.
**Home** carries three tabs: **Overview** (`home`, the quick-action tiles,
findings, glance cards, Today and Weather that the shell opens by default),
**My Home** (`my-home`, Home Assistant lights grouped by room, accessories, and
the connection card) and **Phone** (`phone`, vitals, ring, clipboard, send file,
and BlueBubbles Messages). It absorbed the former standalone Home & Phone
category, whose `home-phone` id is now retired.
Leaf pages are ordinary `SettingsPage` files and know nothing about this. Leaf pages are ordinary `SettingsPage` files and know nothing about this.
`SettingsShell` draws the strip and hosts the page inside it, so moving a page `SettingsShell` draws the strip and hosts the page inside it, so moving a page
@@ -29,6 +37,15 @@ always used, so every existing deep link, IPC call, and search hit keeps
working and now lands on the exact tab. A category id is accepted too, and working and now lands on the exact tab. A category id is accepted too, and
resolves to that category's first available tab. resolves to that category's first available tab.
**A retired id never stops resolving.** When a page is split, merged, or
renamed, its old id goes into `SettingsRoutes.retired` — a map from the dead id
to the leaf that absorbed its content — rather than being deleted. Old Vicinae
commands, shell history, notification handoffs, and muscle memory all hold
those ids, and `resolve()` checks the map first, so `home-phone` still lands on
**My Home** instead of falling back to Home. Retired ids are aliases only: they
are not leaves, so nothing generates a command, a tab, or a doc heading for
them.
Availability gating belongs to the strip rather than the sidebar: Availability gating belongs to the strip rather than the sidebar:
`SettingsRoutes.pageAvailable()` drops the Containers tab on a machine without `SettingsRoutes.pageAvailable()` drops the Containers tab on a machine without
podman and Snapshots without a snapper configuration, but only once a scan has podman and Snapshots without a snapper configuration, but only once a scan has
@@ -6,9 +6,9 @@ Rectangle {
id: root id: root
property var hostWindow: null property var hostWindow: null
readonly property var homePhoneDiagnostics: pageLoader.status === Loader.Ready readonly property var myHomeDiagnostics: pageLoader.status === Loader.Ready
&& pageLoader.item && pageLoader.item
&& pageLoader.item.objectName === "home-phone-page" && pageLoader.item.objectName === "my-home-page"
? pageLoader.item.pageDiagnostics ? pageLoader.item.pageDiagnostics
: ({}) : ({})
readonly property var healthDiagnostics: pageLoader.status === Loader.Ready readonly property var healthDiagnostics: pageLoader.status === Loader.Ready
@@ -134,7 +134,8 @@ Rectangle {
case "appearance": return appearancePage; case "appearance": return appearancePage;
case "displays": return displaysPage; case "displays": return displaysPage;
case "connectivity": return connectivityPage; case "connectivity": return connectivityPage;
case "home-phone": return homePhonePage; case "my-home": return myHomePage;
case "phone": return phonePage;
case "desktop": return desktopPage; case "desktop": return desktopPage;
case "sound": return soundPage; case "sound": return soundPage;
case "gaming": return gamingPage; case "gaming": return gamingPage;
@@ -213,7 +214,8 @@ Rectangle {
Component { id: appearancePage; AppearancePage {} } Component { id: appearancePage; AppearancePage {} }
Component { id: displaysPage; DisplaysPage {} } Component { id: displaysPage; DisplaysPage {} }
Component { id: connectivityPage; ConnectivityPage {} } Component { id: connectivityPage; ConnectivityPage {} }
Component { id: homePhonePage; HomePhonePage {} } Component { id: myHomePage; MyHomePage {} }
Component { id: phonePage; PhonePage {} }
Component { id: desktopPage; DesktopPage {} } Component { id: desktopPage; DesktopPage {} }
Component { id: soundPage; SoundPage {} } Component { id: soundPage; SoundPage {} }
Component { id: gamingPage; GamingPage {} } Component { id: gamingPage; GamingPage {} }
@@ -6,7 +6,7 @@ import qs.services
FloatingWindow { FloatingWindow {
id: root id: root
readonly property var homePhoneDiagnostics: settingsShell.homePhoneDiagnostics readonly property var myHomeDiagnostics: settingsShell.myHomeDiagnostics
title: "Settings" title: "Settings"
visible: ShellState.settingsOpen visible: ShellState.settingsOpen
@@ -11,7 +11,6 @@ AvatarCropper 1.0 AvatarCropper.qml
PickerRow 1.0 PickerRow.qml PickerRow 1.0 PickerRow.qml
SettingsTabs 1.0 SettingsTabs.qml SettingsTabs 1.0 SettingsTabs.qml
GamingPage 1.0 GamingPage.qml GamingPage 1.0 GamingPage.qml
HomePhonePage 1.0 HomePhonePage.qml
HomeFavoriteCard 1.0 HomeFavoriteCard.qml HomeFavoriteCard 1.0 HomeFavoriteCard.qml
AvailableLightRow 1.0 AvailableLightRow.qml AvailableLightRow 1.0 AvailableLightRow.qml
DesktopPage 1.0 DesktopPage.qml DesktopPage 1.0 DesktopPage.qml
@@ -74,6 +73,10 @@ TimeOfDayRow 1.0 TimeOfDayRow.qml
LocationPicker 1.0 LocationPicker.qml LocationPicker 1.0 LocationPicker.qml
FontPicker 1.0 FontPicker.qml FontPicker 1.0 FontPicker.qml
MousePage 1.0 MousePage.qml MousePage 1.0 MousePage.qml
MyHomePage 1.0 MyHomePage.qml
PhonePage 1.0 PhonePage.qml
HomeQuickTile 1.0 HomeQuickTile.qml
HomeLightTile 1.0 HomeLightTile.qml
DictationPage 1.0 DictationPage.qml DictationPage 1.0 DictationPage.qml
TextEntryRow 1.0 TextEntryRow.qml TextEntryRow 1.0 TextEntryRow.qml
PrivacyPage 1.0 PrivacyPage.qml PrivacyPage 1.0 PrivacyPage.qml
@@ -1,6 +1,6 @@
// The first thing a new machine shows. // The first thing a new machine shows.
// //
// Panama has fifteen settings categories and thirty-odd pages inside them, // Panama has fourteen settings categories and thirty-odd pages inside them,
// which is the opposite of the usual problem: somebody arriving from GNOME, // which is the opposite of the usual problem: somebody arriving from GNOME,
// macOS or Windows cannot tell which four things matter. This is those four // macOS or Windows cannot tell which four things matter. This is those four
// things, once, on the first start. // things, once, on the first start.
+1 -1
View File
@@ -587,7 +587,7 @@ def check_home_assistant(config: DoctorConfig) -> Check:
if not configured: if not configured:
return Check("integration.home-assistant", "integrations", "Home Assistant", "unconfigured", "Home Assistant is not configured.") return Check("integration.home-assistant", "integrations", "Home Assistant", "unconfigured", "Home Assistant is not configured.")
if not helper.is_file(): if not helper.is_file():
return Check("integration.home-assistant", "integrations", "Home Assistant", "warning", "Home Assistant bridge is unavailable.", Action("open", "Open Home settings", target="home-phone")) return Check("integration.home-assistant", "integrations", "Home Assistant", "warning", "Home Assistant bridge is unavailable.", Action("open", "Open Home settings", target="my-home"))
return Check("integration.home-assistant", "integrations", "Home Assistant", "ok", "Home Assistant credentials are configured.") return Check("integration.home-assistant", "integrations", "Home Assistant", "ok", "Home Assistant credentials are configured.")
@@ -26,6 +26,18 @@ ENV_KEYS = (
"PANAMA_HOME_ASSISTANT_ENTITIES", "PANAMA_HOME_ASSISTANT_ENTITIES",
) )
ENTITY_ID = re.compile(r"^[a-z_]+\.[a-z0-9_]+$") ENTITY_ID = re.compile(r"^[a-z_]+\.[a-z0-9_]+$")
# One template renders the whole area list, so a room grouping costs a single
# request. Home Assistant answers /api/template with the rendered text, which
# `tojson` makes a JSON document the caller can parse like any other response.
AREAS_TEMPLATE = (
"{% set ns = namespace(items=[]) %}"
"{% for area in areas() %}"
"{% set ns.items = ns.items + ["
"{'id': area, 'name': area_name(area), 'entities': area_entities(area)}"
"] %}"
"{% endfor %}"
"{{ ns.items | tojson }}"
)
GNOME_EXTENSION = ( GNOME_EXTENSION = (
pathlib.Path.home() pathlib.Path.home()
/ ".local/share/gnome-shell/extensions/hass-gshell@geoph9-on-github" / ".local/share/gnome-shell/extensions/hass-gshell@geoph9-on-github"
@@ -258,6 +270,8 @@ def request_json(
error_body = error.read().decode(errors="replace") error_body = error.read().decode(errors="replace")
except OSError: except OSError:
error_body = "" error_body = ""
finally:
error.close()
raise BridgeError(public_http_error(error.code, error_body)) from None raise BridgeError(public_http_error(error.code, error_body)) from None
except (TimeoutError, urllib.error.URLError, OSError): except (TimeoutError, urllib.error.URLError, OSError):
raise BridgeError("unreachable") from None raise BridgeError("unreachable") from None
@@ -358,6 +372,53 @@ def collect_snapshot(config: Config) -> dict[str, object]:
return collect_catalog(config) return collect_catalog(config)
def normalize_areas(raw: Sequence[object]) -> list[dict[str, object]]:
# A template renders whatever Home Assistant happens to hold, so every entry
# is checked on its own: one malformed area is dropped rather than costing
# the caller the whole grouping. Only lights are kept, because lights are
# all the catalog holds today, and an area left with none is not a room the
# UI can draw.
result: list[dict[str, object]] = []
for item in raw:
if not isinstance(item, dict):
continue
area_id = item.get("id")
name = item.get("name")
entities = item.get("entities")
if not isinstance(area_id, str) or not isinstance(name, str):
continue
if not area_id.strip() or not name.strip() or not isinstance(entities, list):
continue
lights = [
entity_id
for entity_id in entities
if isinstance(entity_id, str)
and entity_id.startswith("light.")
and ENTITY_ID.fullmatch(entity_id)
]
if not lights:
continue
result.append(
{"id": area_id.strip(), "name": name.strip(), "entities": lights}
)
return result
def collect_areas(config: Config) -> dict[str, object]:
if not config.configured:
return {"ok": False, "areas": [], "error": "not-configured"}
try:
raw = request_json(
config, "POST", "/api/template", {"template": AREAS_TEMPLATE}
)
if not isinstance(raw, list):
raise BridgeError("invalid-response")
areas = normalize_areas(raw)
except BridgeError as error:
return {"ok": False, "areas": [], "error": str(error)}
return {"ok": True, "areas": areas, "error": ""}
def discovered_light_ids(config: Config) -> set[str]: def discovered_light_ids(config: Config) -> set[str]:
raw = request_json(config, "GET", "/api/states") raw = request_json(config, "GET", "/api/states")
if not isinstance(raw, list): if not isinstance(raw, list):
@@ -466,6 +527,9 @@ def main(argv: list[str]) -> int:
elif command in {"catalog", "snapshot"} and len(argv) == 1: elif command in {"catalog", "snapshot"} and len(argv) == 1:
result = collect_catalog(config) result = collect_catalog(config)
success = bool(result["ok"]) success = bool(result["ok"])
elif command == "areas" and len(argv) == 1:
result = collect_areas(config)
success = bool(result["ok"])
elif command == "toggle" and len(argv) == 2: elif command == "toggle" and len(argv) == 2:
try: try:
result = toggle(config, argv[1]) result = toggle(config, argv[1])
+168 -13
View File
@@ -27,6 +27,18 @@ DEVICE_OBJECT_PREFIX = "/modules/kdeconnect/devices"
DEVICE_OBJECT_LINE = re.compile( DEVICE_OBJECT_LINE = re.compile(
rf"(?P<path>{re.escape(DEVICE_OBJECT_PREFIX)}/(?P<id>[A-Fa-f0-9]{{32,64}}))$" rf"(?P<path>{re.escape(DEVICE_OBJECT_PREFIX)}/(?P<id>[A-Fa-f0-9]{{32,64}}))$"
) )
BATTERY_INTERFACE = "org.kde.kdeconnect.device.battery"
CONNECTIVITY_INTERFACE = "org.kde.kdeconnect.device.connectivity_report"
# busctl spells every integer width with its own type code; a property that is
# an int32 today may be read back as a uint32 by another kdeconnectd build.
INT_PROPERTY_TYPES = frozenset({"y", "n", "q", "i", "u", "x", "t"})
# Vitals are enrichment, not the answer: a phone that has just gone out of range
# can leave a plugin object that blocks, and the status read must not stall
# behind it. Shorter than the eight seconds the identity reads are allowed.
VITALS_TIMEOUT = 3
# Every device carries both keys so the shell never has to tell "not read" from
# "no such field"; absent vitals are null, not missing.
EMPTY_VITALS: dict[str, object] = {"battery": None, "signal": None}
Runner = Callable[..., subprocess.CompletedProcess[str]] Runner = Callable[..., subprocess.CompletedProcess[str]]
@@ -98,14 +110,21 @@ def normalize_device_line(
# #
# The JSON form returns real UTF-8 and needs no unescaping, which is why these # The JSON form returns real UTF-8 and needs no unescaping, which is why these
# parse a document rather than splitting words. # parse a document rather than splitting words.
def parse_property(output: str, expected: str) -> object | None: def property_payload(output: str | None) -> dict[str, object] | None:
"""The value of a busctl --json=short property read, or None if it is not """The decoded document of a busctl --json=short read, or None when the
the type asked for.""" output is missing or is not a property document at all."""
try: try:
payload = json.loads(output) payload = json.loads(output)
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
return None return None
if not isinstance(payload, dict) or payload.get("type") != expected: return payload if isinstance(payload, dict) else None
def parse_property(output: str | None, expected: str) -> object | None:
"""The value of a busctl --json=short property read, or None if it is not
the type asked for."""
payload = property_payload(output)
if payload is None or payload.get("type") != expected:
return None return None
return payload.get("data") return payload.get("data")
@@ -117,16 +136,31 @@ def parse_loaded_plugins(output: str) -> list[str]:
return [str(item) for item in data] return [str(item) for item in data]
def parse_string_property(output: str) -> str: def parse_string_property(output: str | None) -> str:
data = parse_property(output, "s") data = parse_property(output, "s")
return data if isinstance(data, str) else "" return data if isinstance(data, str) else ""
def parse_bool_property(output: str) -> bool | None: def parse_bool_property(output: str | None) -> bool | None:
data = parse_property(output, "b") data = parse_property(output, "b")
return data if isinstance(data, bool) else None return data if isinstance(data, bool) else None
def parse_int_property(output: str | None) -> int | None:
"""The integer value of a busctl --json=short property read, or None.
JSON has one number type, so booleans are rejected explicitly: `true` is an
int to isinstance and would otherwise read back as a charge of 1.
"""
payload = property_payload(output)
if payload is None or payload.get("type") not in INT_PROPERTY_TYPES:
return None
data = payload.get("data")
if isinstance(data, bool) or not isinstance(data, int):
return None
return data
def run_command( def run_command(
command: list[str], command: list[str],
*, *,
@@ -222,6 +256,115 @@ def device_property(
) )
# Each KDE Connect plugin hangs its own object off the device path, and those
# objects exist only while the device is paired, reachable AND the plugin is
# loaded. A phone with the battery plugin turned off, or one that just walked
# out of range, makes busctl exit non-zero -- that is an absence of data, never
# a fault to report, so every failure mode here collapses to None.
def plugin_property(
device_id: str,
plugin: str,
interface: str,
member: str,
runner: Runner = subprocess.run,
) -> str | None:
"""A plugin object's property read as raw busctl output, or None when the
object, the plugin, or the daemon is not there."""
try:
result = run_command(
[
"busctl",
"--user",
"--json=short",
"get-property",
"org.kde.kdeconnect",
f"{device_object(device_id)}/{plugin}",
interface,
member,
],
runner=runner,
timeout=VITALS_TIMEOUT,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return None
return result.stdout if result.returncode == 0 else None
def device_battery(
device_id: str,
runner: Runner = subprocess.run,
) -> dict[str, object] | None:
"""Charge and charging state, or None when the phone has no battery to
report.
Charge is read first because it is the cheapest proof the plugin object
exists at all: when it is missing the other two reads are skipped, which is
the common case for a device that is merely paired. kdeconnectd reports -1
for "not known yet", and anything outside a percentage is not a charge.
"""
charge = parse_int_property(
plugin_property(device_id, "battery", BATTERY_INTERFACE, "charge", runner)
)
if charge is None or not 0 <= charge <= 100:
return None
has_battery = parse_bool_property(
plugin_property(device_id, "battery", BATTERY_INTERFACE, "hasBattery", runner)
)
if has_battery is False:
return None
charging = parse_bool_property(
plugin_property(device_id, "battery", BATTERY_INTERFACE, "isCharging", runner)
)
return {"charge": charge, "charging": charging is True}
def device_signal(
device_id: str,
runner: Runner = subprocess.run,
) -> dict[str, object] | None:
"""Cell network type and bar count, or None when there is no cellular
report -- strength is -1 on a phone with no modem or no service."""
strength = parse_int_property(
plugin_property(
device_id,
"connectivity_report",
CONNECTIVITY_INTERFACE,
"cellularNetworkStrength",
runner,
)
)
if strength is None or strength < 0:
return None
network_type = parse_string_property(
plugin_property(
device_id,
"connectivity_report",
CONNECTIVITY_INTERFACE,
"cellularNetworkType",
runner,
)
)
return {"networkType": network_type, "strength": strength}
def device_vitals(
device: dict[str, object],
runner: Runner = subprocess.run,
) -> dict[str, object]:
"""The battery and signal fields for a device entry.
Only a paired, reachable device is queried; for anything else the plugin
objects cannot exist, so asking would spend busctl calls to learn nothing.
"""
if not (device.get("paired") and device.get("reachable")):
return dict(EMPTY_VITALS)
device_id = str(device["id"])
return {
"battery": device_battery(device_id, runner),
"signal": device_signal(device_id, runner),
}
def dbus_device_ids(runner: Runner = subprocess.run) -> list[str]: def dbus_device_ids(runner: Runner = subprocess.run) -> list[str]:
try: try:
result = run_command( result = run_command(
@@ -239,7 +382,11 @@ def dbus_device_ids(runner: Runner = subprocess.run) -> list[str]:
] ]
def dbus_devices(runner: Runner = subprocess.run) -> list[dict[str, object]]: def dbus_devices(
runner: Runner = subprocess.run,
*,
vitals: bool = True,
) -> list[dict[str, object]]:
devices: list[dict[str, object]] = [] devices: list[dict[str, object]] = []
for device_id in dbus_device_ids(runner): for device_id in dbus_device_ids(runner):
try: try:
@@ -266,8 +413,7 @@ def dbus_devices(runner: Runner = subprocess.run) -> list[dict[str, object]]:
if plugin in plugins if plugin in plugins
} }
) )
devices.append( device = {
{
"id": device_id, "id": device_id,
"name": name, "name": name,
"type": device_type or inferred_type(name), "type": device_type or inferred_type(name),
@@ -275,11 +421,19 @@ def dbus_devices(runner: Runner = subprocess.run) -> list[dict[str, object]]:
"reachable": reachable, "reachable": reachable,
"actions": actions, "actions": actions,
} }
) device.update(device_vitals(device, runner) if vitals else EMPTY_VITALS)
devices.append(device)
return devices return devices
def collect_status(runner: Runner = subprocess.run) -> dict[str, object]: # `vitals` is off for the read that guards an action: ringing a phone needs its
# identity and its action list, not its charge, and the extra busctl round trips
# would sit between the tap and the ring.
def collect_status(
runner: Runner = subprocess.run,
*,
vitals: bool = True,
) -> dict[str, object]:
try: try:
listing = run_command( listing = run_command(
["kdeconnect-cli", "--list-devices"], ["kdeconnect-cli", "--list-devices"],
@@ -309,12 +463,13 @@ def collect_status(runner: Runner = subprocess.run) -> dict[str, object]:
device = normalize_device_line(line, plugins, device_type) device = normalize_device_line(line, plugins, device_type)
except ValueError: except ValueError:
continue continue
device.update(device_vitals(device, runner) if vitals else EMPTY_VITALS)
devices.append(device) devices.append(device)
known_ids = {str(device["id"]) for device in devices} known_ids = {str(device["id"]) for device in devices}
devices.extend( devices.extend(
device device
for device in dbus_devices(runner) for device in dbus_devices(runner, vitals=vitals)
if str(device["id"]) not in known_ids if str(device["id"]) not in known_ids
) )
@@ -358,7 +513,7 @@ def invoke_action(
runner: Runner = subprocess.run, runner: Runner = subprocess.run,
) -> dict[str, object]: ) -> dict[str, object]:
device_id = validate_device_id(device_id) device_id = validate_device_id(device_id)
status = collect_status(runner) status = collect_status(runner, vitals=False)
device = next( device = next(
( (
item item
@@ -52,7 +52,7 @@ class SchemaError(RuntimeError):
def read_titles(): def read_titles():
"""Leaf page id -> the name a person sees, from SettingsRoutes. """Leaf page id -> the name a person sees, from SettingsRoutes.
The sidebar is fifteen categories of tabs rather than a flat list, so a page The sidebar is fourteen categories of tabs rather than a flat list, so a page
is named the way a hit names it in search: "System Storage" for a tab, is named the way a hit names it in search: "System Storage" for a tab,
"Displays" for a category that is a page by itself. "Displays" for a category that is a page by itself.
""" """
+1 -1
View File
@@ -33,7 +33,7 @@ Singleton {
readonly property var statuses: ["ok", "warning", "error", "unconfigured"] readonly property var statuses: ["ok", "warning", "error", "unconfigured"]
readonly property var groups: ["desktop-foundation", "input-media", "integrations", "panama-tools"] readonly property var groups: ["desktop-foundation", "input-media", "integrations", "panama-tools"]
readonly property var overallStatuses: ["healthy", "warning", "error"] 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"] readonly property var instructionTargets: ["ddc-permissions"]
Process { Process {
@@ -16,6 +16,7 @@ Singleton {
// "loading" | "ready" | "degraded" | "unavailable" // "loading" | "ready" | "degraded" | "unavailable"
property string phase: "loading" property string phase: "loading"
property var catalog: [] property var catalog: []
property var areas: []
property var selectedEntities: [] property var selectedEntities: []
property bool stale: false property bool stale: false
property string lastError: "" property string lastError: ""
@@ -38,6 +39,10 @@ Singleton {
property var pendingFixtureTarget: null property var pendingFixtureTarget: null
property int delayedFixtureExitCode: 0 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 var visibleEntities: root.selectedEntities.slice(0, 4)
readonly property int discoveredCount: root.catalog.length readonly property int discoveredCount: root.catalog.length
readonly property int configuredCount: root.selectedEntities.length readonly property int configuredCount: root.selectedEntities.length
@@ -56,6 +61,56 @@ Singleton {
if (root.catalog.length === 0) if (root.catalog.length === 0)
root.phase = "loading"; root.phase = "loading";
refreshProc.running = true; 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 { 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 { function rebuildSelection(): void {
const favorites = root.fixtureMode const favorites = root.fixtureMode
? root.fixtureFavorites ? 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 { function fixturePreferenceRecords(): var {
return [ return [
{ id: "light.fixture_all", alias: " Whole home " }, { id: "light.fixture_all", alias: " Whole home " },
@@ -479,6 +564,7 @@ Singleton {
: (name === "process-slow-actions" ? "slow-success" : ""))); : (name === "process-slow-actions" ? "slow-success" : "")));
if (name === "unavailable") { if (name === "unavailable") {
root.catalog = []; root.catalog = [];
root.areas = [];
root.fixtureFavorites = []; root.fixtureFavorites = [];
root.rebuildSelection(); root.rebuildSelection();
root.phase = "unavailable"; root.phase = "unavailable";
@@ -488,6 +574,7 @@ Singleton {
} }
root.catalog = root.fixtureEntities(); root.catalog = root.fixtureEntities();
root.areas = root.fixtureAreas();
root.fixtureFavorites = root.fixturePreferenceRecords(); root.fixtureFavorites = root.fixturePreferenceRecords();
if (name === "available-extra") { if (name === "available-extra") {
root.catalog = root.catalog.concat([{ root.catalog = root.catalog.concat([{
@@ -530,6 +617,7 @@ Singleton {
root.fixtureProcessMode = ""; root.fixtureProcessMode = "";
root.phase = "loading"; root.phase = "loading";
root.catalog = []; root.catalog = [];
root.areas = [];
root.selectedEntities = []; root.selectedEntities = [];
root.fixtureFavorites = []; root.fixtureFavorites = [];
root.stale = false; root.stale = false;
@@ -545,6 +633,14 @@ Singleton {
} }
} }
Process {
id: areasProc
command: [root.helperPath, "areas"]
stdout: StdioCollector {
onStreamFinished: root.consumeAreas(this.text)
}
}
Process { Process {
id: actionProc id: actionProc
stdout: StdioCollector { stdout: StdioCollector {
+46 -3
View File
@@ -43,6 +43,12 @@ Singleton {
readonly property int pairedCount: root.devices.filter(device => device.paired).length readonly property int pairedCount: root.devices.filter(device => device.paired).length
readonly property var phoneActions: root.preferredPhone?.actions ?? [] 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 { function supports(action: string): bool {
return root.phoneActions.indexOf(action) >= 0; return root.phoneActions.indexOf(action) >= 0;
} }
@@ -53,13 +59,44 @@ Singleton {
statusProc.running = true; 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 { function consumeStatus(text: string): void {
if (root.fixtureMode) if (root.fixtureMode)
return; return;
try { try {
const result = JSON.parse(text); const result = JSON.parse(text);
root.available = result.available === true; 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 ?? ""); root.lastError = String(result.error ?? "");
} catch (error) { } catch (error) {
root.available = false; root.available = false;
@@ -188,13 +225,19 @@ Singleton {
root.available = true; root.available = true;
root.lastError = ""; root.lastError = "";
root.recentExchange = null; 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 = [{ root.devices = [{
id: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", id: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
name: "Fixture iPhone", name: "Fixture iPhone",
type: "phone", type: "phone",
paired: true, paired: true,
reachable: name !== "offline", reachable: reachable,
actions: ["clipboard", "ping", "ring", "share"] actions: ["clipboard", "ping", "ring", "share"],
battery: reachable ? { charge: 82, charging: true } : null,
signal: reachable ? { networkType: "LTE", strength: 3 } : null
}]; }];
root.transferActive = name === "transfer"; root.transferActive = name === "transfer";
root.transferFileName = name === "transfer" ? "Fixture document.pdf" : ""; root.transferFileName = name === "transfer" ? "Fixture document.pdf" : "";
@@ -27,7 +27,11 @@ Singleton {
// alias. Three category ids ("applications", "users", "privacy") double as // alias. Three category ids ("applications", "users", "privacy") double as
// the id of their first tab, which resolves to the same place either way. // the id of their first tab, which resolves to the same place either way.
readonly property var categories: [ 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: "appearance", label: "Appearance", icon: "\u{F0E0D}", tabs: [] },
{ page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}", tabs: [] }, { page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}", tabs: [] },
{ page: "displays", label: "Displays", icon: "\u{F0379}", tabs: [] }, { page: "displays", label: "Displays", icon: "\u{F0379}", tabs: [] },
@@ -44,7 +48,6 @@ Singleton {
{ page: "sharing", label: "Sharing" }, { page: "sharing", label: "Sharing" },
{ page: "printers", label: "Printers" } { 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", icon: "\u{F003B}", tabs: [
{ page: "applications", label: "Applications" }, { page: "applications", label: "Applications" },
{ page: "gaming", label: "Gaming" }, { page: "gaming", label: "Gaming" },
@@ -98,10 +101,20 @@ Singleton {
return root.categories.find(cat => cat.page === leaf) ?? root.categories[0]; 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 // Retired page ids keep resolving forever: old Vicinae commands, shell
// should render: a leaf resolves to itself, a category to its first // history, and muscle memory all hold them. Each maps to the leaf that
// available tab, anything unknown to home. // 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 { 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) const asLeaf = root.categories.some(cat => (cat.page === id && cat.tabs.length === 0)
|| cat.tabs.some(tab => tab.page === id)); || cat.tabs.some(tab => tab.page === id));
if (asLeaf) if (asLeaf)
@@ -130,8 +130,14 @@ Singleton {
{ label: "Language", detail: "The system language, applied to programs started afterwards", page: "region" }, { 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: "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: "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: "Home Assistant", detail: "Connect the desktop to a Home Assistant server", page: "my-home" },
{ label: "Phone", detail: "Pair a phone for messages and notifications", page: "home-phone" }, { 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: "System information", detail: "Kernel, distribution, and hardware", page: "about" },
{ label: "Desktop version", detail: "Which Hyprland and Quickshell this session runs", 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" }, { label: "Restore defaults", detail: "Return every Panama setting to its shipped value", page: "desktop" },
+1 -1
View File
@@ -489,7 +489,7 @@ ShellRoot {
page: ShellState.settingsPage, page: ShellState.settingsPage,
discoveredCount: HomeAssistant.discoveredCount, discoveredCount: HomeAssistant.discoveredCount,
selectedCount: HomeAssistant.configuredCount, selectedCount: HomeAssistant.configuredCount,
homePhone: settingsWindow.homePhoneDiagnostics myHome: settingsWindow.myHomeDiagnostics
}); });
} }
} }
@@ -1,10 +1,10 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Generated by scripts/panama-settings-commands -- do not edit by hand. # Generated by scripts/panama-settings-commands -- do not edit by hand.
# @vicinae.schemaVersion 1 # @vicinae.schemaVersion 1
# @vicinae.title Settings: Home & Phone # @vicinae.title Settings: My Home
# @vicinae.mode silent # @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg # @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Home & Phone in Settings. # @vicinae.description Open My Home in Settings.
# @vicinae.keywords ["settings", "home assistant", "phone"] # @vicinae.keywords ["settings", "home assistant", "lights", "control center lights"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page home-phone exec "$HOME/.config/quickshell/scripts/panama-action" settings-page my-home
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Generated by scripts/panama-settings-commands -- do not edit by hand.
# @vicinae.schemaVersion 1
# @vicinae.title Settings: Phone
# @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Phone in Settings.
# @vicinae.keywords ["settings", "phone", "ring your phone", "send clipboard to phone", "send a file to phone", "imessage"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page phone
+1 -1
View File
@@ -345,7 +345,7 @@ Found on **Appearance**.
## weather ## weather
Found on **Home**. Found on **Home Overview**.
| Setting | Default | What it does | | Setting | Default | What it does |
|---|---|---| |---|---|---|
@@ -0,0 +1,109 @@
# Home category redesign — Overview | My Home | Phone
Approved 2026-08-23 (mock B, "Rooms & Tabs"). Home absorbs Home & Phone and becomes a
three-tab category. Phase-2 feature picks: rooms grouping, phone vitals, Today card.
The reclaim-space prompt is removed. Deferred with reserved slots: Assist command box,
phone notification inbox, scenes/climate/cameras, now-playing, HA todos.
## Taxonomy
`SettingsRoutes` home category gains tabs; the `home-phone` category is deleted:
```
{ page: "home", label: "Home", tabs: [
{ page: "home", label: "Overview" },
{ page: "my-home", label: "My Home" },
{ page: "phone", label: "Phone" }
] }
```
`home` doubles as category id and first tab (the applications/users/privacy pattern).
New leaf ids `my-home`, `phone`. A new alias map in `SettingsRoutes.resolve()` sends
the retired `home-phone` id to `my-home` so muscle memory, old Vicinae commands, and
`panama-action settings-page home-phone` keep landing correctly. In-repo callers are
updated to the new ids anyway: `HomeControls.qml`, `panama-doctor`'s HA-warning target,
`Health.qml`'s `settingsTargets`.
## Pages
**HomePage.qml (Overview) — rebuilt.** Greeting header and weather lede stay. Then:
- A five-tile quick-action grid (new `HomeQuickTile.qml`): Focus session (start/end),
Do Not Disturb (live toggle state), Health (→ `services`), Snapshots (→ `snapshots`),
Storage (→ `storage`). Fixed set, per the phase decision.
- "This machine": the existing findings array unchanged (firewall exposure, security
advisories, reboot-needed, health attention) plus the "Install N updates" row folded
in as a finding when `Updates.total > 0`. The reclaim-space row is gone entirely.
- Two glance cards linking into the sibling tabs: My Home ("2 lights on · Living Room,
Kitchen") and Phone ("iPhone · 82% · nearby"), each `openSettings("my-home"/"phone")`.
- "Today": `CalendarAgenda.nextEvent` with Join/Open; hidden when nothing upcoming or
no calendar. Zero new plumbing — the service already streams.
- "Weather": the existing card, unchanged.
- The old Focus card and "Right now" containers card are retired (tiles and the System
category cover them).
**MyHomePage.qml (new, `objectName: "my-home-page"`).** Absorbs the Home-Assistant half
of HomePhonePage:
- "Rooms" card: every catalog light grouped by HA area, rendered as interactive tiles
(new `HomeLightTile.qml`: power square, name, state, brightness track for dimmables,
"Control Center" pill on the first four favorites). Toggle → `HomeAssistant.toggleEntity`,
brightness → `setBrightness` (commit-on-release, like `HomeBrightnessSlider`). Falls
back to a single flat "Lights" section when areas are unavailable.
- "Accessories" card: the existing favorites editor (`HomeFavoriteCard` grid — alias,
reorder, remove, Control Center badge) and the existing Available-lights search
(`AvailableLightRow`), moved intact.
- "Home Assistant" card: the existing connection card (URL, token, save/clear, refresh,
open) moved intact.
- Exposes `pageDiagnostics` (same shape as HomePhonePage's plus `rooms` summary);
the IPC key in `settings status` renames `homePhone``myHome`.
**PhonePage.qml (new, `objectName: "phone-page"`).**
- Vitals strip: three stat blocks — Battery (% + charging), Signal (type + strength),
Reachability — from the extended KdeConnect service. A stat whose datum is absent
(unpaired, plugin missing) shows an em-dash, never an error.
- "Reach it" card: Ring, Send clipboard, Send file (FileDialog, the quicksettings
pattern) via existing service functions; transfer progress/cancel as in Control Center.
- "Messages" card: BlueBubbles status + open (moved from HomePhonePage).
- "Device" card: pairing/plugins handoff to `connectivity` (as Control Center does).
`HomePhonePage.qml` is deleted. `HomeFavoriteCard`/`AvailableLightRow` survive under
MyHomePage.
## Service contracts (pages are written against these; helpers implement them)
**`panama-home-assistant areas` (new command).** REST-only: `POST /api/template`
rendering a Jinja template that emits `[{ "id", "name", "entities": [...] }]` via
`areas()` / `area_name()` / `area_entities()` / `tojson`. Same redacted error codes as
the other commands. `HomeAssistant.qml` gains `areas: []` (fetched with refresh) and
`rooms: [{ id, name, lights: [entity] }]` — catalog lights joined to areas, un-areaed
lights in a trailing "Other" bucket; empty `areas``rooms` is one unnamed bucket.
**`panama-kdeconnect status` (extended).** Per reachable paired device, read via
`busctl get-property` from the plugin objects (paths exist only when paired+reachable —
absence is data, not an error): `battery: { charge: int, charging: bool } | null` from
`org.kde.kdeconnect.device.battery`, `signal: { networkType: string, strength: 0-4 } | null`
from `...device.connectivity_report`. `KdeConnect.qml` surfaces `phoneBattery`,
`phoneSignal` (null when absent). Unit tests extend the two bridge test files' fake
busctl/CLI fixtures.
## Blast radius owned by this change
- `SettingsShell.qml`: `my-home`/`phone` cases + components; `homePhoneDiagnostics`
plumbing renamed and repointed at MyHomePage (`SettingsWindow.qml`, `shell.qml`
status JSON key `myHome`).
- `SettingsSearch.qml`: `home-phone` extraEntries re-pointed (`my-home` for Home
Assistant entries, `phone` for phone entries); new entries for rooms, phone battery,
ring, clipboard, Today.
- Generators re-run: Vicinae commands (`settings-home-phone``settings-my-home` +
`settings-phone`; `home` still skipped so Overview adds no command), `docs/settings.md`.
- Contracts: `home-phone-settings-contract` rewritten as `my-home-settings-contract`
(+ phone coverage), `control-center-contract` (Manage target), `health-ui-contract` /
`panama-doctor-contract` (settings target), nav/jump/search/gnome-handoff/
panama-commands re-verified against the new route table. `home-preferences-contract`
and `phone-messages-contract` unchanged.
- Docs: settings README, manual chapter 05 (Home description), Welcome comment
(fourteen categories).
## Non-goals (reserved slots, later phases)
Assist box, notification mirror/reply, scenes, climate, cameras, now-playing, todos,
HA WebSocket `--watch` streaming, customizable quick actions.
@@ -0,0 +1,29 @@
# Settings redesign — deferred test runs
No contract is executed while the settings redesign is in flight: many are live
harnesses that open windows, drive overlays, and run display transactions on
the real desktop. Everything below runs **once, at the end of the redesign,
with Gabriel's go-ahead**, and failures get fixed then.
## The run
- `panama test` — the full suite (162 contracts at last count; the top-level
README's count line must match the final number).
## Known items to verify or investigate at the end
- `quickshell/welcome-contract` — failed once ("the welcome screen did not
open") during a full-suite run storming the live session; passed in
isolation minutes earlier after the Welcome.qml comment edit. Suspected
contention flake, not a regression. Confirm.
- `setup/readme-contract` — counts contracts; keep the README number in sync
as later phases add contracts (phase 2 added `phone-page-contract`).
- Phase 2 additions already written and passing when last run:
`my-home-settings-contract` (renamed from `home-phone-settings-contract`),
`phone-page-contract`, extended `home_assistant_bridge_test.py` and
`kdeconnect_bridge_test.py`.
- Each later phase (Appearance, …) appends its new/changed contracts here
instead of running them.
## Phase 3 (Appearance) — append below
+2 -2
View File
@@ -134,8 +134,8 @@ rg -Fq 'HomeAssistant.pendingFor(' "$quicksettings_path/HomeControls.qml" \
|| fail 'Home tiles do not receive per-light pending brightness' || fail 'Home tiles do not receive per-light pending brightness'
rg -Fq 'HomeAssistant.setBrightness(' "$quicksettings_path/HomeControls.qml" \ rg -Fq 'HomeAssistant.setBrightness(' "$quicksettings_path/HomeControls.qml" \
|| fail 'Home brightness commits are not wired to the service' || fail 'Home brightness commits are not wired to the service'
rg -Fq 'ShellState.openSettings("home-phone")' "$quicksettings_path/HomeControls.qml" \ rg -Fq 'ShellState.openSettings("my-home")' "$quicksettings_path/HomeControls.qml" \
|| fail 'Manage in Settings does not open Home & Phone' || fail 'Manage in Settings does not open My Home'
rg -Fq 'signal previewChanged(int value)' "$quicksettings_path/HomeBrightnessSlider.qml" \ rg -Fq 'signal previewChanged(int value)' "$quicksettings_path/HomeBrightnessSlider.qml" \
|| fail 'brightness slider has no preview contract' || fail 'brightness slider has no preview contract'
@@ -5,7 +5,7 @@ set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-home-assistant-config" helper="$repo_dir/config/dot/quickshell/scripts/panama-home-assistant-config"
service="$repo_dir/config/dot/quickshell/services/HomeAssistantConfig.qml" service="$repo_dir/config/dot/quickshell/services/HomeAssistantConfig.qml"
page="$repo_dir/config/dot/quickshell/modules/settings/HomePhonePage.qml" page="$repo_dir/config/dot/quickshell/modules/settings/MyHomePage.qml"
password_field="$repo_dir/config/dot/quickshell/modules/settings/PasswordField.qml" password_field="$repo_dir/config/dot/quickshell/modules/settings/PasswordField.qml"
harness_fixture="$repo_dir/tests/quickshell/HomeAssistantConfigHarness.qml" harness_fixture="$repo_dir/tests/quickshell/HomeAssistantConfigHarness.qml"
work="$(mktemp -d /tmp/panama-ha-config.XXXXXX)" work="$(mktemp -d /tmp/panama-ha-config.XXXXXX)"
@@ -1,319 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
state_home="$(mktemp -d /tmp/panama-home-phone-settings-state.XXXXXX)"
source_config_path="$repo_dir/config/dot/quickshell"
config_path="$state_home/quickshell"
test_bin="$state_home/bin"
shell_log="$state_home/quickshell.log"
flatpak_log="$state_home/flatpak.log"
cleanup_bootstrap() {
rm -rf "$state_home"
}
trap cleanup_bootstrap EXIT
mkdir -p "$test_bin"
cp -a "$source_config_path" "$config_path"
: >"$flatpak_log"
cat >"$config_path/scripts/panama-home-assistant" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
case "${1:-}" in
catalog)
printf '%s\n' '{"ok":false,"error":"test-helper"}'
;;
toggle|brightness)
printf '%s\n' '{"ok":true}'
;;
esac
EOF
chmod +x "$config_path/scripts/panama-home-assistant"
cat >"$test_bin/hyprctl" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
if [[ "${1:-}" == "-j" && "${2:-}" == "monitors" ]]; then
printf '%s\n' '[{"focused":true,"name":"TEST-1","description":"Home and Phone contract","width":1920,"height":1080,"refreshRate":60,"scale":1,"currentFormat":"XRGB8888","colorManagementPreset":"srgb","vrr":false}]'
exit 0
fi
if [[ "${1:-}" == "keyword" ]]; then
exit 0
fi
exec /usr/sbin/hyprctl "$@"
EOF
chmod +x "$test_bin/hyprctl"
cat >"$test_bin/flatpak" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$*" >>"$PANAMA_FLATPAK_LOG"
if [[ "${1:-}" == "info" && "${2:-}" == "app.bluebubbles.BlueBubbles" ]]; then
exit 0
fi
exit 97
EOF
chmod +x "$test_bin/flatpak"
fail() {
printf 'home phone settings contract: %s\n' "$1" >&2
exit 1
}
assert_contains() {
local needle="$1"
local file="$2"
rg -Fq "$needle" "$file" || fail "$file is missing: $needle"
}
home_page="$repo_dir/config/dot/quickshell/modules/settings/HomePhonePage.qml"
favorite_card="$repo_dir/config/dot/quickshell/modules/settings/HomeFavoriteCard.qml"
available_row="$repo_dir/config/dot/quickshell/modules/settings/AvailableLightRow.qml"
system_settings="$repo_dir/config/dot/quickshell/services/SystemSettings.qml"
[[ -f "$home_page" ]] || fail 'HomePhonePage.qml is missing'
[[ -f "$favorite_card" ]] || fail 'HomeFavoriteCard.qml is missing'
[[ -f "$available_row" ]] || fail 'AvailableLightRow.qml is missing'
assert_contains 'SettingsPage {' "$home_page"
assert_contains 'title: "Home & Phone"' "$home_page"
assert_contains 'lede: "Choose what appears in Control Center and keep phone continuity close at hand."' "$home_page"
if rg -q '^\s*Flickable \{' "$home_page"; then
fail 'HomePhonePage.qml still owns a copied Flickable scaffold'
fi
assert_contains 'Connected · ' "$home_page"
assert_contains 'Last update unavailable · showing saved controls' "$home_page"
assert_contains 'Authentication required' "$home_page"
assert_contains 'Home Assistant is not configured' "$home_page"
assert_contains 'HomePreferences.setAlias' "$home_page"
assert_contains 'HomePreferences.move' "$home_page"
assert_contains 'HomePreferences.remove' "$home_page"
assert_contains 'HomePreferences.add' "$home_page"
assert_contains 'HomePreferences.retrySave' "$home_page"
assert_contains 'Choose lights below to build your Control Center shelf.' "$home_page"
assert_contains 'All discovered lights are already selected' "$home_page"
assert_contains 'No lights discovered' "$home_page"
assert_contains 'No lights match that search' "$home_page"
assert_contains 'Opens BlueBubbles' "$home_page"
[[ "$(rg -Fc 'required property var modelData' "$home_page")" -ge 2 ]] \
|| fail 'HomePhonePage.qml does not bind both reusable delegates to modelData'
if rg -Fq 'index: model.index' "$home_page"; then
fail 'HomePhonePage.qml reads an undefined model.index instead of the delegate index'
fi
if rg -qi 'bearer|api/states' "$home_page"; then
fail 'HomePhonePage.qml crosses the Home Assistant REST boundary'
fi
assert_contains 'signal aliasCommitted(string id, string alias)' "$favorite_card"
assert_contains 'signal removeRequested(string id)' "$favorite_card"
assert_contains 'signal moveRequested(string id, int targetIndex)' "$favorite_card"
assert_contains 'text: "↑"' "$favorite_card"
assert_contains 'text: "↓"' "$favorite_card"
assert_contains 'enabled: root.canMoveEarlier' "$favorite_card"
assert_contains 'enabled: root.canMoveLater' "$favorite_card"
if rg -Fq 'DragHandler {' "$favorite_card"; then
fail 'Home light cards still expose the broken drag affordance'
fi
assert_contains 'canMoveEarlier: index > 0' "$home_page"
assert_contains 'canMoveLater: index < favoritesGrid.count - 1' "$home_page"
assert_contains 'onEditingFinished:' "$favorite_card"
assert_contains 'text: "Control Center"' "$favorite_card"
assert_contains 'activeFocusOnTab: true' "$favorite_card"
assert_contains 'signal addRequested(string id)' "$available_row"
assert_contains 'activeFocusOnTab: true' "$available_row"
assert_contains 'readonly property bool bluebubblesAvailable: root.bluebubblesDetected' "$system_settings"
assert_contains 'command: ["flatpak", "info", "app.bluebubbles.BlueBubbles"]' "$system_settings"
assert_contains '"bluebubbles": ["flatpak", "run", "app.bluebubbles.BlueBubbles"]' "$system_settings"
qs_for_test() {
PATH="$test_bin:$PATH" QS_CONFIG_PATH="$config_path" XDG_STATE_HOME="$state_home" \
PANAMA_FLATPAK_LOG="$flatpak_log" qs -p "$config_path" "$@"
}
stop_test_shell() {
qs_for_test kill >/dev/null 2>&1 || true
for _ in $(seq 1 80); do
if ! qs_for_test list 2>/dev/null | rg '^Instance ' >/dev/null \
&& ! qs_for_test ipc show >/dev/null 2>&1; then
return 0
fi
sleep 0.1
done
return 1
}
cleanup() {
qs_for_test ipc call settings close >/dev/null 2>&1 || true
if stop_test_shell; then
rm -rf "$state_home"
else
printf 'home phone settings contract: branch shell did not stop; retained %s\n' \
"$state_home" >&2
fi
}
trap cleanup EXIT
start_test_shell() {
stop_test_shell || fail 'pre-existing branch shell did not stop cleanly'
for _attempt in 1 2; do
qs_for_test --daemonize >"$shell_log" 2>&1
for _ in $(seq 1 80); do
if qs_for_test ipc show 2>/dev/null | rg '^target settings-system$' >/dev/null; then
return
fi
sleep 0.1
done
stop_test_shell || fail 'failed branch-shell attempt did not stop cleanly'
done
sed -n '1,240p' "$shell_log" >&2
fail 'isolated branch shell did not start'
}
start_test_shell
qs_for_test ipc call home-assistant fixture ready >/dev/null
qs_for_test ipc call settings page home-phone >/dev/null
status='{}'
for _ in $(seq 1 40); do
status="$(qs_for_test ipc call settings status | jq -c .)"
if jq -e '.page == "home-phone" and .discoveredCount == 7 and .selectedCount == 7' \
<<<"$status" >/dev/null; then
break
fi
sleep 0.1
done
jq -e '
.open == true and
.page == "home-phone" and
.discoveredCount == 7 and
.selectedCount == 7 and
.homePhone.availableLightIds == [] and
.homePhone.availableEmptyText == "All discovered lights are already selected"
' \
<<<"$status" >/dev/null || fail "Home & Phone diagnostics are incomplete: $status"
qs_for_test ipc call home-assistant fixture available-extra >/dev/null
available_status='{}'
for _ in $(seq 1 40); do
available_status="$(qs_for_test ipc call settings status | jq -c .)"
if jq -e '
.discoveredCount == 8 and
.selectedCount == 7 and
.homePhone.availableLightIds == ["light.fixture_guest"]
' <<<"$available_status" >/dev/null; then
break
fi
sleep 0.1
done
jq -e '
.discoveredCount == 8 and
.selectedCount == 7 and
.homePhone.availableLightIds == ["light.fixture_guest"]
' <<<"$available_status" >/dev/null \
|| fail "an unselected fixture light was not the only available row: $available_status"
qs_for_test ipc call home-assistant fixture stale-authentication >/dev/null
authentication_status='{}'
for _ in $(seq 1 40); do
authentication_status="$(qs_for_test ipc call settings status | jq -c .)"
if jq -e '
.discoveredCount == 7 and
.selectedCount == 7 and
.homePhone.homeStatus == "Authentication required"
' <<<"$authentication_status" >/dev/null; then
break
fi
sleep 0.1
done
jq -e '
.discoveredCount == 7 and
.selectedCount == 7 and
.homePhone.homeStatus == "Authentication required"
' <<<"$authentication_status" >/dev/null \
|| fail "stale authentication did not retain actionable copy: $authentication_status"
qs_for_test ipc call home-assistant fixture stale-not-configured >/dev/null
not_configured_status='{}'
for _ in $(seq 1 40); do
not_configured_status="$(qs_for_test ipc call settings status | jq -c .)"
if jq -e '
.discoveredCount == 7 and
.selectedCount == 7 and
.homePhone.homeStatus == "Home Assistant is not configured"
' <<<"$not_configured_status" >/dev/null; then
break
fi
sleep 0.1
done
jq -e '
.discoveredCount == 7 and
.selectedCount == 7 and
.homePhone.homeStatus == "Home Assistant is not configured"
' <<<"$not_configured_status" >/dev/null \
|| fail "stale not-configured state did not retain actionable copy: $not_configured_status"
qs_for_test ipc call home-assistant fixture unavailable >/dev/null
empty_status='{}'
for _ in $(seq 1 40); do
empty_status="$(qs_for_test ipc call settings status | jq -c .)"
if jq -e '
.discoveredCount == 0 and
.selectedCount == 0 and
.homePhone.homeStatus == "Home Assistant is not configured" and
.homePhone.availableLightIds == [] and
.homePhone.availableEmptyText == "No lights discovered"
' <<<"$empty_status" >/dev/null; then
break
fi
sleep 0.1
done
jq -e '
.discoveredCount == 0 and
.selectedCount == 0 and
.homePhone.homeStatus == "Home Assistant is not configured" and
.homePhone.availableLightIds == [] and
.homePhone.availableEmptyText == "No lights discovered"
' <<<"$empty_status" >/dev/null \
|| fail "an empty catalog did not render its distinct copy: $empty_status"
system_status="$(qs_for_test ipc call settings-system status | jq -c .)"
jq -e '.bluebubblesAvailable == true' <<<"$system_status" >/dev/null \
|| fail "BlueBubbles availability was not exposed: $system_status"
shell_pid="$(qs_for_test list | awk '/Process ID:/ { print $3; exit }')"
[[ "$shell_pid" =~ ^[0-9]+$ ]] || fail 'could not identify the branch shell process'
for _ in $(seq 1 40); do
if /usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \
'[.[] | select(.pid == $pid and .title == "Settings" and .floating == false)] | length == 1' >/dev/null; then
break
fi
sleep 0.1
done
/usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \
'[.[] | select(.pid == $pid and .title == "Settings" and .floating == false)] | length == 1' >/dev/null \
|| fail 'the branch shell did not own exactly one tiled Panama Settings client'
if [[ -n "${PANAMA_TEST_SCREENSHOT_PATH:-}" ]]; then
geometry="$(/usr/sbin/hyprctl -j clients | jq -r --argjson pid "$shell_pid" \
'.[] | select(.pid == $pid and .title == "Settings") | "\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"')"
[[ -n "$geometry" ]] || fail 'could not resolve the Settings client geometry'
grim -g "$geometry" "$PANAMA_TEST_SCREENSHOT_PATH"
fi
rg -Fxq 'info app.bluebubbles.BlueBubbles' "$flatpak_log" \
|| fail 'the fixed BlueBubbles availability probe did not run'
if rg -q '^run ' "$flatpak_log"; then
fail 'the contract started BlueBubbles'
fi
if find "$state_home" -name panama-home.json -print -quit | rg -q .; then
fail 'the read-only fixture route wrote Home preferences'
fi
trap - EXIT
cleanup
[[ ! -e "$state_home" ]] || fail 'temporary Home & Phone state was not removed after shell exit'
printf 'home phone settings contract: PASS\n'
@@ -5,7 +5,9 @@ from __future__ import annotations
import importlib.machinery import importlib.machinery
import importlib.util import importlib.util
import json import json
import os
import pathlib import pathlib
import subprocess
import sys import sys
import threading import threading
import unittest import unittest
@@ -16,6 +18,23 @@ ROOT = pathlib.Path(__file__).resolve().parents[2]
HELPER = ROOT / "config/dot/quickshell/scripts/panama-home-assistant" HELPER = ROOT / "config/dot/quickshell/scripts/panama-home-assistant"
QML_SERVICE = ROOT / "config/dot/quickshell/services/HomeAssistant.qml" QML_SERVICE = ROOT / "config/dot/quickshell/services/HomeAssistant.qml"
# What a Home Assistant would render for the areas template: every area it
# holds, including ones whose entities are not lights, plus entries no template
# should produce but a bridge still has to survive.
TEMPLATE_AREAS = [
{
"id": "kitchen",
"name": "Kitchen",
"entities": ["light.kitchen", "switch.kettle", "sensor.private"],
},
{"id": "hallway", "name": "Hall", "entities": ["light.hall", "light.corner"]},
{"id": "garage", "name": "Garage", "entities": ["sensor.garage_door"]},
{"id": 12, "name": "Numeric", "entities": ["light.kitchen"]},
{"id": "unnamed", "name": "", "entities": ["light.kitchen"]},
{"id": "not_a_list", "name": "Loose", "entities": "light.kitchen"},
"not-an-area",
]
loader = importlib.machinery.SourceFileLoader("panama_home_assistant", str(HELPER)) loader = importlib.machinery.SourceFileLoader("panama_home_assistant", str(HELPER))
spec = importlib.util.spec_from_loader(loader.name, loader) spec = importlib.util.spec_from_loader(loader.name, loader)
bridge = importlib.util.module_from_spec(spec) bridge = importlib.util.module_from_spec(spec)
@@ -25,6 +44,8 @@ loader.exec_module(bridge)
class FakeHomeAssistant(BaseHTTPRequestHandler): class FakeHomeAssistant(BaseHTTPRequestHandler):
requests: list[dict[str, object]] = [] requests: list[dict[str, object]] = []
# "rendered" | "malformed" | "unauthorized"
template_mode: str = "rendered"
def log_message(self, _format: str, *args: object) -> None: def log_message(self, _format: str, *args: object) -> None:
del args del args
@@ -47,6 +68,25 @@ class FakeHomeAssistant(BaseHTTPRequestHandler):
self.end_headers() self.end_headers()
self.wfile.write(payload) self.wfile.write(payload)
# Home Assistant answers /api/template with the rendered text, not with a
# JSON document, so the fixture replies in kind.
def _text(self, body: str, status: int = 200) -> None:
payload = body.encode()
self.send_response(status)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def _template(self) -> None:
if self.template_mode == "unauthorized":
self._json({"message": "Unauthorized: token abc123"}, 401)
return
if self.template_mode == "malformed":
self._text("Error rendering template: UndefinedError")
return
self._text(json.dumps(TEMPLATE_AREAS))
def do_GET(self) -> None: def do_GET(self) -> None:
self._record() self._record()
if self.path == "/api/": if self.path == "/api/":
@@ -95,6 +135,9 @@ class FakeHomeAssistant(BaseHTTPRequestHandler):
length = int(self.headers.get("Content-Length", "0")) length = int(self.headers.get("Content-Length", "0"))
body = self.rfile.read(length) body = self.rfile.read(length)
self._record(body) self._record(body)
if self.path == "/api/template":
self._template()
return
if self.path in { if self.path in {
"/api/services/homeassistant/toggle", "/api/services/homeassistant/toggle",
"/api/services/light/turn_on", "/api/services/light/turn_on",
@@ -121,6 +164,7 @@ class HomeAssistantBridgeTest(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
FakeHomeAssistant.requests.clear() FakeHomeAssistant.requests.clear()
FakeHomeAssistant.template_mode = "rendered"
def config(self) -> object: def config(self) -> object:
return bridge.Config( return bridge.Config(
@@ -129,6 +173,15 @@ class HomeAssistantBridgeTest(unittest.TestCase):
entity_ids=("light.kitchen", "light.hall"), entity_ids=("light.kitchen", "light.hall"),
) )
def helper_env(self) -> dict[str, str]:
return {
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
"HOME": os.environ.get("HOME", "/tmp"),
"PANAMA_HOME_ASSISTANT_URL": self.base_url,
"PANAMA_HOME_ASSISTANT_TOKEN": "fixture-token",
"PANAMA_HOME_ASSISTANT_ENTITIES": "light.kitchen",
}
def test_environment_configuration_wins_over_legacy_sources(self) -> None: def test_environment_configuration_wins_over_legacy_sources(self) -> None:
env = { env = {
"PANAMA_HOME_ASSISTANT_URL": "https://home.example", "PANAMA_HOME_ASSISTANT_URL": "https://home.example",
@@ -203,6 +256,108 @@ class HomeAssistantBridgeTest(unittest.TestCase):
["Kitchen", "Hall", "Corner"], ["Kitchen", "Hall", "Corner"],
) )
def test_areas_group_lights_by_home_assistant_area(self) -> None:
result = bridge.collect_areas(self.config())
self.assertTrue(result["ok"])
self.assertEqual(result["error"], "")
self.assertEqual(
result["areas"],
[
{"id": "kitchen", "name": "Kitchen", "entities": ["light.kitchen"]},
{
"id": "hallway",
"name": "Hall",
"entities": ["light.hall", "light.corner"],
},
],
)
def test_areas_render_the_whole_list_from_one_template_request(self) -> None:
bridge.collect_areas(self.config())
request = FakeHomeAssistant.requests[-1]
self.assertEqual(len(FakeHomeAssistant.requests), 1)
self.assertEqual(request["method"], "POST")
self.assertEqual(request["path"], "/api/template")
self.assertEqual(request["authorization"], "Bearer fixture-token")
template = json.loads(request["body"])["template"]
for fragment in ("areas()", "area_name(area)", "area_entities(area)", "tojson"):
self.assertIn(fragment, template)
def test_areas_keep_only_lights_and_drop_areas_left_with_none(self) -> None:
rendered = json.dumps(bridge.collect_areas(self.config()))
self.assertNotIn("switch.kettle", rendered)
self.assertNotIn("sensor.private", rendered)
self.assertNotIn("Garage", rendered)
def test_areas_discard_malformed_entries_without_losing_the_rest(self) -> None:
rendered = json.dumps(bridge.collect_areas(self.config()))
self.assertNotIn("Numeric", rendered)
self.assertNotIn("unnamed", rendered)
self.assertNotIn("Loose", rendered)
self.assertIn("Kitchen", rendered)
def test_areas_reject_a_template_response_that_is_not_json(self) -> None:
FakeHomeAssistant.template_mode = "malformed"
result = bridge.collect_areas(self.config())
self.assertFalse(result["ok"])
self.assertEqual(result["error"], "invalid-response")
self.assertEqual(result["areas"], [])
def test_areas_redact_an_authentication_failure(self) -> None:
FakeHomeAssistant.template_mode = "unauthorized"
result = bridge.collect_areas(self.config())
self.assertFalse(result["ok"])
self.assertEqual(result["error"], "authentication-required")
self.assertNotIn("abc123", json.dumps(result))
def test_areas_report_not_configured_before_any_request(self) -> None:
result = bridge.collect_areas(
bridge.Config(base_url=self.base_url, token="", entity_ids=())
)
self.assertEqual(
result, {"ok": False, "areas": [], "error": "not-configured"}
)
self.assertEqual(FakeHomeAssistant.requests, [])
def test_cli_areas_prints_one_json_object(self) -> None:
completed = subprocess.run(
[sys.executable, str(HELPER), "areas"],
capture_output=True,
text=True,
env=self.helper_env(),
timeout=15,
)
self.assertEqual(completed.returncode, 0)
self.assertEqual(len(completed.stdout.strip().splitlines()), 1)
self.assertEqual(
[area["name"] for area in json.loads(completed.stdout)["areas"]],
["Kitchen", "Hall"],
)
def test_cli_still_rejects_unknown_arguments(self) -> None:
completed = subprocess.run(
[sys.executable, str(HELPER), "areas", "kitchen"],
capture_output=True,
text=True,
env=self.helper_env(),
timeout=15,
)
self.assertEqual(completed.returncode, 2)
self.assertEqual(
json.loads(completed.stdout), {"ok": False, "error": "invalid-command"}
)
def test_action_rejects_an_entity_not_in_the_live_catalog(self) -> None: def test_action_rejects_an_entity_not_in_the_live_catalog(self) -> None:
with self.assertRaisesRegex(ValueError, "entity-not-discovered"): with self.assertRaisesRegex(ValueError, "entity-not-discovered"):
bridge.toggle(self.config(), "light.office") bridge.toggle(self.config(), "light.office")
@@ -271,6 +426,28 @@ class HomeAssistantBridgeTest(unittest.TestCase):
self.assertIn("name: alias || entity.sourceName,", source) self.assertIn("name: alias || entity.sourceName,", source)
self.assertIn("root.selectedEntities = nextSelection;", source) self.assertIn("root.selectedEntities = nextSelection;", source)
def test_qml_fetches_areas_alongside_the_catalog(self) -> None:
source = QML_SERVICE.read_text()
self.assertIn('command: [root.helperPath, "areas"]', source)
self.assertIn("if (!areasProc.running)\n areasProc.running = true;", source)
def test_qml_composes_rooms_and_tolerates_missing_areas(self) -> None:
source = QML_SERVICE.read_text()
self.assertIn("property var areas: []", source)
self.assertIn(
"readonly property var rooms: root.composeRooms(root.catalog, root.areas)",
source,
)
self.assertIn('grouped.push({ id: "", name: "Other", lights: orphans });', source)
# A failed areas fetch empties the grouping and nothing else: no phase,
# no stale flag, no lastError, so lights keep working without it.
consume = source.split("function consumeAreas(text: string): void {", 1)[1]
consume = consume.split("\n }\n", 1)[0]
for untouched in ("root.phase", "root.stale", "root.lastError"):
self.assertNotIn(untouched, consume)
def test_authentication_error_is_redacted(self) -> None: def test_authentication_error_is_redacted(self) -> None:
self.assertEqual( self.assertEqual(
bridge.public_http_error(401, "sensitive response"), bridge.public_http_error(401, "sensitive response"),
+11 -2
View File
@@ -28,10 +28,19 @@ if [[ "$(jq -r '.devices | length' <<<"$status")" == "0" ]]; then
exit 0 exit 0
fi fi
# battery and signal are always present and are null far more often than not:
# the plugin objects exist only while a device is paired, reachable and has the
# plugin loaded. Null is the answer, so the check is on the shape when a value
# is there, never on a value being there.
jq -e ' jq -e '
([.devices[] | (keys | sort) == (["actions", "id", "name", "paired", "reachable", "type"] | sort)] | all) and ([.devices[] | (keys | sort) == (["actions", "battery", "id", "name", "paired", "reachable", "signal", "type"] | sort)] | all) and
([.devices[] | (.id | test("^[A-Fa-f0-9]{32,64}$"))] | all) and ([.devices[] | (.id | test("^[A-Fa-f0-9]{32,64}$"))] | all) and
([.devices[].actions[] | . == "clipboard" or . == "ping" or . == "ring" or . == "share"] | all) ([.devices[].actions[] | . == "clipboard" or . == "ping" or . == "ring" or . == "share"] | all) and
([.devices[] | select(.battery != null) | (.battery | keys | sort) == ["charge", "charging"]] | all) and
([.devices[] | select(.battery != null) | .battery.charge >= 0 and .battery.charge <= 100 and (.battery.charging | type == "boolean")] | all) and
([.devices[] | select(.signal != null) | (.signal | keys | sort) == ["networkType", "strength"]] | all) and
([.devices[] | select(.signal != null) | .signal.strength >= 0 and (.signal.networkType | type == "string")] | all) and
([.devices[] | select(.paired and .reachable | not) | .battery == null and .signal == null] | all)
' <<<"$status" >/dev/null || fail 'live status shape is invalid' ' <<<"$status" >/dev/null || fail 'live status shape is invalid'
paired_count="$(jq '[.devices[] | select(.paired)] | length' <<<"$status")" paired_count="$(jq '[.devices[] | select(.paired)] | length' <<<"$status")"
+191 -1
View File
@@ -193,6 +193,8 @@ class KdeConnectBridgeTest(unittest.TestCase):
"paired": True, "paired": True,
"reachable": False, "reachable": False,
"actions": ["clipboard", "ring", "share"], "actions": ["clipboard", "ring", "share"],
"battery": None,
"signal": None,
} }
], ],
"error": "", "error": "",
@@ -269,7 +271,11 @@ class KdeConnectBridgeTest(unittest.TestCase):
if command[:4] == ["busctl", "--user", "--json=short", "call"]: if command[:4] == ["busctl", "--user", "--json=short", "call"]:
return subprocess.CompletedProcess(command, 0, '{"type":"as","data":[]}\n', "") return subprocess.CompletedProcess(command, 0, '{"type":"as","data":[]}\n', "")
if command[:4] == ["busctl", "--user", "--json=short", "get-property"]: if command[:4] == ["busctl", "--user", "--json=short", "get-property"]:
is_dbus_device = dbus_id in command[4] # The reachable CLI device gets its vitals read; nothing answers
# for the plugin objects here.
if not command[5].endswith(cli_id) and not command[5].endswith(dbus_id):
return subprocess.CompletedProcess(command, 1, "", "No such object")
is_dbus_device = command[5].endswith(dbus_id)
values = { values = {
"name": '{"type":"s","data":"Fixture iPhone"}\n', "name": '{"type":"s","data":"Fixture iPhone"}\n',
"type": '{"type":"s","data":"phone"}\n' if is_dbus_device else '{"type":"s","data":"desktop"}\n', "type": '{"type":"s","data":"phone"}\n' if is_dbus_device else '{"type":"s","data":"desktop"}\n',
@@ -284,6 +290,190 @@ class KdeConnectBridgeTest(unittest.TestCase):
self.assertEqual({device["id"] for device in status["devices"]}, {cli_id, dbus_id}) self.assertEqual({device["id"] for device in status["devices"]}, {cli_id, dbus_id})
def test_vitals_are_read_for_a_reachable_phone(self) -> None:
device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
runner = vitals_runner(
device_id,
f"- Fixture iPhone: {device_id} (paired and reachable)\n",
{
"charge": '{"type":"i","data":82}\n',
"isCharging": '{"type":"b","data":true}\n',
"hasBattery": '{"type":"b","data":true}\n',
"cellularNetworkType": '{"type":"s","data":"LTE"}\n',
"cellularNetworkStrength": '{"type":"i","data":3}\n',
},
)
device = bridge.collect_status(runner)["devices"][0]
self.assertEqual(device["battery"], {"charge": 82, "charging": True})
self.assertEqual(device["signal"], {"networkType": "LTE", "strength": 3})
def test_absent_plugin_objects_are_no_data_not_an_error(self) -> None:
"""A phone with the battery plugin off is not a broken status read.
kdeconnectd only publishes a plugin's object while the device is paired,
reachable and the plugin is loaded, so busctl exiting non-zero here is
the ordinary answer "nothing to report" -- the device still appears,
with null vitals and no error on the envelope.
"""
device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
runner = vitals_runner(
device_id,
f"- Fixture iPhone: {device_id} (paired and reachable)\n",
{},
)
status = bridge.collect_status(runner)
self.assertEqual(status["available"], True)
self.assertEqual(status["error"], "")
self.assertIsNone(status["devices"][0]["battery"])
self.assertIsNone(status["devices"][0]["signal"])
def test_negative_charge_reports_no_battery(self) -> None:
device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
runner = vitals_runner(
device_id,
f"- Fixture iPhone: {device_id} (paired and reachable)\n",
{
"charge": '{"type":"i","data":-1}\n',
"isCharging": '{"type":"b","data":false}\n',
"hasBattery": '{"type":"b","data":true}\n',
"cellularNetworkType": '{"type":"s","data":"LTE"}\n',
"cellularNetworkStrength": '{"type":"i","data":3}\n',
},
)
device = bridge.collect_status(runner)["devices"][0]
self.assertIsNone(device["battery"])
self.assertEqual(device["signal"], {"networkType": "LTE", "strength": 3})
def test_a_phone_without_a_battery_reports_none(self) -> None:
device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
runner = vitals_runner(
device_id,
f"- Fixture iPhone: {device_id} (paired and reachable)\n",
{
"charge": '{"type":"i","data":0}\n',
"isCharging": '{"type":"b","data":false}\n',
"hasBattery": '{"type":"b","data":false}\n',
},
)
self.assertIsNone(bridge.collect_status(runner)["devices"][0]["battery"])
def test_strength_of_minus_one_reports_no_signal(self) -> None:
device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
runner = vitals_runner(
device_id,
f"- Fixture iPhone: {device_id} (paired and reachable)\n",
{
"charge": '{"type":"i","data":82}\n',
"isCharging": '{"type":"b","data":false}\n',
"hasBattery": '{"type":"b","data":true}\n',
"cellularNetworkType": '{"type":"s","data":"Unknown"}\n',
"cellularNetworkStrength": '{"type":"i","data":-1}\n',
},
)
device = bridge.collect_status(runner)["devices"][0]
self.assertEqual(device["battery"], {"charge": 82, "charging": False})
self.assertIsNone(device["signal"])
def test_an_unreachable_device_is_never_asked_for_vitals(self) -> None:
"""Plugin objects cannot exist for an absent phone, so asking is waste.
The runner fails the test outright if a plugin path is touched, which is
what keeps a busctl call per device off the path taken every 30 seconds
by the phone that is simply not home.
"""
device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
runner = vitals_runner(
device_id,
f"- Fixture iPhone: {device_id} (paired)\n",
{},
forbid_plugin_reads=True,
)
device = bridge.collect_status(runner)["devices"][0]
self.assertIsNone(device["battery"])
self.assertIsNone(device["signal"])
def test_an_action_does_not_wait_on_vitals(self) -> None:
"""Ringing a phone reads identity only -- no charge between tap and ring."""
device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
listing = f"- Fixture iPhone: {device_id} (paired and reachable)\n"
def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]:
if command == ["kdeconnect-cli", "--list-devices"]:
return subprocess.CompletedProcess(command, 0, listing, "")
if command == ["kdeconnect-cli", "-d", device_id, "--ring"]:
return subprocess.CompletedProcess(command, 0, "", "")
if command[:4] == ["busctl", "--user", "--json=short", "call"]:
return subprocess.CompletedProcess(
command,
0,
'{"type":"as","data":["kdeconnect_findmyphone"]}\n',
"",
)
if command[:4] == ["busctl", "--user", "--json=short", "get-property"]:
if not command[5].endswith(device_id):
raise AssertionError(f"vitals read on the action path: {command}")
return subprocess.CompletedProcess(command, 0, '{"type":"s","data":"phone"}\n', "")
if command[:3] == ["busctl", "--user", "tree"]:
return subprocess.CompletedProcess(command, 0, "", "")
raise AssertionError(command)
self.assertEqual(
bridge.invoke_action("ring", device_id, runner=runner),
{"ok": True, "action": "ring", "error": ""},
)
def vitals_runner(
device_id: str,
listing: str,
plugin_values: dict[str, str],
*,
forbid_plugin_reads: bool = False,
) -> bridge.Runner:
"""A fake busctl/kdeconnect-cli for one CLI-listed device.
Reads of the device object itself always answer; reads of a plugin object
answer only from `plugin_values`, and exit non-zero for anything missing --
which is exactly how busctl behaves when the object is not published.
"""
device_path = f"/modules/kdeconnect/devices/{device_id}"
def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]:
if command == ["kdeconnect-cli", "--list-devices"]:
return subprocess.CompletedProcess(command, 0, listing, "")
if command[:3] == ["busctl", "--user", "tree"]:
return subprocess.CompletedProcess(command, 0, "", "")
if command[:4] == ["busctl", "--user", "--json=short", "call"]:
return subprocess.CompletedProcess(
command,
0,
'{"type":"as","data":["kdeconnect_findmyphone"]}\n',
"",
)
if command[:4] == ["busctl", "--user", "--json=short", "get-property"]:
if command[5] == device_path:
return subprocess.CompletedProcess(command, 0, '{"type":"s","data":"phone"}\n', "")
if forbid_plugin_reads:
raise AssertionError(f"unexpected plugin read: {command}")
member = command[-1]
if member not in plugin_values:
return subprocess.CompletedProcess(command, 1, "", "No such object")
return subprocess.CompletedProcess(command, 0, plugin_values[member], "")
raise AssertionError(command)
return runner
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+329
View File
@@ -0,0 +1,329 @@
#!/usr/bin/env bash
# My Home is the Home Assistant tab of the Home category, and the page that
# owns a long-lived access token. Two things have to stay true of it:
#
# the page never reads Home Assistant directly. Every light, every toggle and
# every dim goes through the helper, so a bearer token or an /api/states URL
# appearing in the QML is the boundary breaking, not a style question
#
# the read-only route stays read-only. Driving fixtures through the page must
# never write panama-home.json, or a contract run would rewrite the favorites
# of whoever ran it
#
# Everything else here is the page structure the old Home & Phone page proved
# and this one inherited -- the favorites editor, the connection card, the
# empty-state copy -- plus what is new: lights grouped into rooms.
#
# The phone half of that old page now lives on its own tab; phone-page-contract
# covers it.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
state_home="$(mktemp -d /tmp/panama-my-home-settings-state.XXXXXX)"
source_config_path="$repo_dir/config/dot/quickshell"
config_path="$state_home/quickshell"
test_bin="$state_home/bin"
shell_log="$state_home/quickshell.log"
cleanup_bootstrap() {
rm -rf "$state_home"
}
trap cleanup_bootstrap EXIT
mkdir -p "$test_bin"
cp -a "$source_config_path" "$config_path"
cat >"$config_path/scripts/panama-home-assistant" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
case "${1:-}" in
catalog|areas)
printf '%s\n' '{"ok":false,"error":"test-helper"}'
;;
toggle|brightness)
printf '%s\n' '{"ok":true}'
;;
esac
EOF
chmod +x "$config_path/scripts/panama-home-assistant"
cat >"$test_bin/hyprctl" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
if [[ "${1:-}" == "-j" && "${2:-}" == "monitors" ]]; then
printf '%s\n' '[{"focused":true,"name":"TEST-1","description":"My Home contract","width":1920,"height":1080,"refreshRate":60,"scale":1,"currentFormat":"XRGB8888","colorManagementPreset":"srgb","vrr":false}]'
exit 0
fi
if [[ "${1:-}" == "keyword" ]]; then
exit 0
fi
exec /usr/sbin/hyprctl "$@"
EOF
chmod +x "$test_bin/hyprctl"
# Nothing on this page consults Flatpak; the stub only keeps the isolated shell
# from reaching the real one while it probes at startup.
cat >"$test_bin/flatpak" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
exit 97
EOF
chmod +x "$test_bin/flatpak"
fail() {
printf 'my home settings contract: %s\n' "$1" >&2
exit 1
}
assert_contains() {
local needle="$1"
local file="$2"
rg -Fq "$needle" "$file" || fail "$file is missing: $needle"
}
settings_dir="$repo_dir/config/dot/quickshell/modules/settings"
my_home_page="$settings_dir/MyHomePage.qml"
light_tile="$settings_dir/HomeLightTile.qml"
favorite_card="$settings_dir/HomeFavoriteCard.qml"
available_row="$settings_dir/AvailableLightRow.qml"
settings_qmldir="$settings_dir/qmldir"
for required in "$my_home_page" "$light_tile" "$favorite_card" "$available_row"; do
[[ -f "$required" ]] || fail "$(basename "$required") is missing"
done
if [[ -e "$settings_dir/HomePhonePage.qml" ]]; then
fail 'the retired Home & Phone page is still on disk'
fi
# ── The page ─────────────────────────────────────────────────────────────────
assert_contains 'SettingsPage {' "$my_home_page"
assert_contains 'objectName: "my-home-page"' "$my_home_page"
assert_contains 'title: "My Home"' "$my_home_page"
assert_contains 'lede: root.homeStatus()' "$my_home_page"
if rg -q '^\s*Flickable \{' "$my_home_page"; then
fail 'MyHomePage.qml still owns a copied Flickable scaffold'
fi
# The status line is the lede, so each state has to say something a person can
# act on rather than collapsing to one "unavailable".
assert_contains 'Connected · ' "$my_home_page"
assert_contains 'Last update unavailable · showing saved controls' "$my_home_page"
assert_contains 'Authentication required' "$my_home_page"
assert_contains 'Home Assistant is not configured' "$my_home_page"
# ── Rooms ────────────────────────────────────────────────────────────────────
# Lights render through the shared tile, grouped by Home Assistant area. A
# setup with no areas is one unnamed bucket, and an unnamed bucket must not
# draw a heading -- an empty grey label above the only list reads as a bug.
assert_contains 'HomeLightTile 1.0 HomeLightTile.qml' "$settings_qmldir"
assert_contains 'HomeLightTile {' "$my_home_page"
assert_contains 'model: HomeAssistant.rooms ?? []' "$my_home_page"
assert_contains 'model: roomSection.modelData.lights ?? []' "$my_home_page"
rg -Fq 'visible: String(roomSection.modelData.name ?? "") !== ""' "$my_home_page" \
|| fail 'room headings render for unnamed rooms, which is what a setup without areas produces'
# One dimmer implementation everywhere: the tile reuses Control Center's
# slider rather than growing a second one that drifts from it.
assert_contains 'import qs.modules.quicksettings' "$light_tile"
assert_contains 'HomeBrightnessSlider {' "$light_tile"
assert_contains 'HomeAssistant.toggleEntity(root.entityId)' "$light_tile"
assert_contains 'HomeAssistant.setBrightness(root.entityId, value)' "$light_tile"
assert_contains 'Accessible.role: Accessible.Button' "$light_tile"
# ── The favorites editor ─────────────────────────────────────────────────────
assert_contains 'HomePreferences.setAlias' "$my_home_page"
assert_contains 'HomePreferences.move' "$my_home_page"
assert_contains 'HomePreferences.remove' "$my_home_page"
assert_contains 'HomePreferences.add' "$my_home_page"
assert_contains 'HomePreferences.retrySave' "$my_home_page"
assert_contains 'Choose lights below to build your Control Center shelf.' "$my_home_page"
assert_contains 'All discovered lights are already selected' "$my_home_page"
assert_contains 'No lights discovered' "$my_home_page"
assert_contains 'No lights match that search' "$my_home_page"
[[ "$(rg -Fc 'required property var modelData' "$my_home_page")" -ge 2 ]] \
|| fail 'MyHomePage.qml does not bind its reusable delegates to modelData'
if rg -Fq 'index: model.index' "$my_home_page"; then
fail 'MyHomePage.qml reads an undefined model.index instead of the delegate index'
fi
assert_contains 'signal aliasCommitted(string id, string alias)' "$favorite_card"
assert_contains 'signal removeRequested(string id)' "$favorite_card"
assert_contains 'signal moveRequested(string id, int targetIndex)' "$favorite_card"
assert_contains 'text: "↑"' "$favorite_card"
assert_contains 'text: "↓"' "$favorite_card"
assert_contains 'enabled: root.canMoveEarlier' "$favorite_card"
assert_contains 'enabled: root.canMoveLater' "$favorite_card"
if rg -Fq 'DragHandler {' "$favorite_card"; then
fail 'Home light cards still expose the broken drag affordance'
fi
assert_contains 'canMoveEarlier: index > 0' "$my_home_page"
assert_contains 'canMoveLater: index < favoritesGrid.count - 1' "$my_home_page"
assert_contains 'onEditingFinished:' "$favorite_card"
assert_contains 'text: "Control Center"' "$favorite_card"
assert_contains 'activeFocusOnTab: true' "$favorite_card"
assert_contains 'signal addRequested(string id)' "$available_row"
assert_contains 'activeFocusOnTab: true' "$available_row"
# ── The credential boundary ──────────────────────────────────────────────────
for boundary_file in "$my_home_page" "$light_tile"; do
if rg -qi 'bearer|api/states' "$boundary_file"; then
fail "$(basename "$boundary_file") crosses the Home Assistant REST boundary"
fi
done
qs_for_test() {
PATH="$test_bin:$PATH" QS_CONFIG_PATH="$config_path" XDG_STATE_HOME="$state_home" \
qs -p "$config_path" "$@"
}
stop_test_shell() {
qs_for_test kill >/dev/null 2>&1 || true
for _ in $(seq 1 80); do
if ! qs_for_test list 2>/dev/null | rg '^Instance ' >/dev/null \
&& ! qs_for_test ipc show >/dev/null 2>&1; then
return 0
fi
sleep 0.1
done
return 1
}
cleanup() {
qs_for_test ipc call settings close >/dev/null 2>&1 || true
if stop_test_shell; then
rm -rf "$state_home"
else
printf 'my home settings contract: branch shell did not stop; retained %s\n' \
"$state_home" >&2
fi
}
trap cleanup EXIT
start_test_shell() {
stop_test_shell || fail 'pre-existing branch shell did not stop cleanly'
for _attempt in 1 2; do
qs_for_test --daemonize >"$shell_log" 2>&1
for _ in $(seq 1 80); do
if qs_for_test ipc show 2>/dev/null | rg '^target settings-system$' >/dev/null; then
return
fi
sleep 0.1
done
stop_test_shell || fail 'failed branch-shell attempt did not stop cleanly'
done
sed -n '1,240p' "$shell_log" >&2
fail 'isolated branch shell did not start'
}
# Reads `settings status` until the diagnostics settle, then holds the caller to
# the same expression. The page recomputes on a binding, so an early read is a
# stale read rather than a wrong one.
await_status() {
local expression="$1"
local label="$2"
local status='{}'
for _ in $(seq 1 40); do
status="$(qs_for_test ipc call settings status | jq -c .)"
if jq -e "$expression" <<<"$status" >/dev/null; then
printf '%s\n' "$status"
return 0
fi
sleep 0.1
done
fail "$label: $status"
}
start_test_shell
qs_for_test ipc call home-assistant fixture ready >/dev/null
qs_for_test ipc call settings page my-home >/dev/null
# The fixture areas claim five of the seven lights; the two nobody claims fall
# into the trailing "Other" bucket, which is the case a flat catalog would hide.
await_status '
.open == true and
.page == "my-home" and
.discoveredCount == 7 and
.selectedCount == 7 and
.myHome.availableLightIds == [] and
.myHome.availableEmptyText == "All discovered lights are already selected" and
.myHome.homeStatus == "Connected · 7 lights across 4 rooms" and
.myHome.rooms == [
{ "name": "Kitchen", "count": 1 },
{ "name": "Living room", "count": 2 },
{ "name": "Bedroom", "count": 2 },
{ "name": "Other", "count": 2 }
]
' 'My Home diagnostics are incomplete' >/dev/null
qs_for_test ipc call home-assistant fixture available-extra >/dev/null
await_status '
.discoveredCount == 8 and
.selectedCount == 7 and
.myHome.availableLightIds == ["light.fixture_guest"] and
(.myHome.rooms[] | select(.name == "Other") | .count) == 3
' 'an unselected fixture light was not the only available row' >/dev/null
qs_for_test ipc call home-assistant fixture stale-authentication >/dev/null
await_status '
.discoveredCount == 7 and
.selectedCount == 7 and
.myHome.homeStatus == "Authentication required"
' 'stale authentication did not retain actionable copy' >/dev/null
qs_for_test ipc call home-assistant fixture stale-not-configured >/dev/null
await_status '
.discoveredCount == 7 and
.selectedCount == 7 and
.myHome.homeStatus == "Home Assistant is not configured"
' 'stale not-configured state did not retain actionable copy' >/dev/null
# No catalog means no areas either, and the room composition degrades to the
# single unnamed bucket the page renders without a heading.
qs_for_test ipc call home-assistant fixture unavailable >/dev/null
await_status '
.discoveredCount == 0 and
.selectedCount == 0 and
.myHome.homeStatus == "Home Assistant is not configured" and
.myHome.availableLightIds == [] and
.myHome.availableEmptyText == "No lights discovered" and
.myHome.rooms == [{ "name": "", "count": 0 }]
' 'an empty catalog did not render its distinct copy' >/dev/null
shell_pid="$(qs_for_test list | awk '/Process ID:/ { print $3; exit }')"
[[ "$shell_pid" =~ ^[0-9]+$ ]] || fail 'could not identify the branch shell process'
for _ in $(seq 1 40); do
if /usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \
'[.[] | select(.pid == $pid and .title == "Settings" and .floating == false)] | length == 1' >/dev/null; then
break
fi
sleep 0.1
done
/usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \
'[.[] | select(.pid == $pid and .title == "Settings" and .floating == false)] | length == 1' >/dev/null \
|| fail 'the branch shell did not own exactly one tiled Panama Settings client'
if [[ -n "${PANAMA_TEST_SCREENSHOT_PATH:-}" ]]; then
qs_for_test ipc call home-assistant fixture ready >/dev/null
sleep 0.5
geometry="$(/usr/sbin/hyprctl -j clients | jq -r --argjson pid "$shell_pid" \
'.[] | select(.pid == $pid and .title == "Settings") | "\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"')"
[[ -n "$geometry" ]] || fail 'could not resolve the Settings client geometry'
grim -g "$geometry" "$PANAMA_TEST_SCREENSHOT_PATH"
fi
if find "$state_home" -name panama-home.json -print -quit | rg -q .; then
fail 'the read-only fixture route wrote Home preferences'
fi
trap - EXIT
cleanup
[[ ! -e "$state_home" ]] || fail 'temporary My Home state was not removed after shell exit'
printf 'my home settings contract: PASS\n'
+2 -2
View File
@@ -318,8 +318,8 @@ mkdir -p "$config_home/quickshell/scripts"
home_assistant_failure="$(run_doctor --json)" home_assistant_failure="$(run_doctor --json)"
check_status "$home_assistant_failure" integration.home-assistant warning check_status "$home_assistant_failure" integration.home-assistant warning
jq -e '.checks[] | select(.id == "integration.home-assistant") jq -e '.checks[] | select(.id == "integration.home-assistant")
| .action == {kind:"open", label:"Open Home settings", confirm:false, target:"home-phone"}' \ | .action == {kind:"open", label:"Open Home settings", confirm:false, target:"my-home"}' \
>/dev/null <<<"$home_assistant_failure" || fail 'Home Assistant action was not routed to home-phone' >/dev/null <<<"$home_assistant_failure" || fail 'Home Assistant action was not routed to my-home'
# Invalid probe text is contained in its own check and never copied to JSON. # Invalid probe text is contained in its own check and never copied to JSON.
malformed_calendar="$(PANAMA_DOCTOR_FIXTURE_CALENDAR=malformed run_doctor --json)" malformed_calendar="$(PANAMA_DOCTOR_FIXTURE_CALENDAR=malformed run_doctor --json)"
+235
View File
@@ -0,0 +1,235 @@
#!/usr/bin/env bash
# Phone is the continuity tab of the Home category -- the half of the old Home
# & Phone page that did not go to My Home. Three things carried over or arrived
# with it, and each has already been got wrong once:
#
# the vitals strip reads plugin data that only exists while a phone is paired
# and nearby. A missing battery or signal reading is the ordinary case, not an
# error, so it renders as an em-dash
#
# the Reach it buttons mirror Control Center exactly: nearby, not mid-transfer,
# and the plugin present. Dropping any one of those three offers an action
# that silently does nothing
#
# Messages is not a KDE Connect action. It needs BlueBubbles installed and
# nothing else, and it must stay that way -- coupling it to phone reachability
# is the regression phone-messages-contract was written for, and this page
# inherited the same row
#
# The live half boots an isolated shell with a fake Flatpak to prove the
# installed-state probe runs and that nothing here launches the app.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
settings_dir="$repo_dir/config/dot/quickshell/modules/settings"
phone_page="$settings_dir/PhonePage.qml"
settings_qmldir="$settings_dir/qmldir"
system_settings="$repo_dir/config/dot/quickshell/services/SystemSettings.qml"
fail() {
printf 'phone page contract: %s\n' "$1" >&2
exit 1
}
assert_contains() {
local needle="$1"
local file="$2"
rg -Fq "$needle" "$file" || fail "$file is missing: $needle"
}
[[ -f "$phone_page" ]] || fail 'PhonePage.qml is missing'
assert_contains 'SettingsPage {' "$phone_page"
assert_contains 'objectName: "phone-page"' "$phone_page"
assert_contains 'title: "Phone"' "$phone_page"
assert_contains 'PhonePage 1.0 PhonePage.qml' "$settings_qmldir"
if rg -q '^\s*Flickable \{' "$phone_page"; then
fail 'PhonePage.qml still owns a copied Flickable scaffold'
fi
# ── Vitals ───────────────────────────────────────────────────────────────────
# KdeConnect.phoneBattery and phoneSignal are null whenever the plugin objects
# are absent, which is most of the time on an unpaired or away phone.
vitals_block="$(sed -n '/id: vitals/,/── Reach it/p' "$phone_page")"
[[ -n "$vitals_block" ]] || fail 'PhonePage.qml has no vitals strip'
rg -Fq 'KdeConnect.phoneBattery' <<<"$vitals_block" \
|| fail 'the vitals strip does not read the phone battery'
rg -Fq 'KdeConnect.phoneSignal' <<<"$vitals_block" \
|| fail 'the vitals strip does not read the phone signal'
[[ "$(rg -Fc -- '"—"' <<<"$vitals_block")" -ge 2 ]] \
|| fail 'a null battery or signal does not render as an em-dash'
# ── Reach it ─────────────────────────────────────────────────────────────────
assert_contains 'readonly property bool actionsReady: KdeConnect.phoneReachable && !KdeConnect.transferActive' \
"$phone_page"
for action in ring clipboard share; do
rg -Fq "enabled: root.actionsReady && KdeConnect.supports(\"$action\")" "$phone_page" \
|| fail "the $action action does not gate on nearby, idle, and plugin support together"
done
assert_contains 'KdeConnect.ring()' "$phone_page"
assert_contains 'KdeConnect.sendClipboard()' "$phone_page"
assert_contains 'KdeConnect.sendFile(path)' "$phone_page"
assert_contains 'KdeConnect.cancelTransfer()' "$phone_page"
# ── Messages ─────────────────────────────────────────────────────────────────
assert_contains 'Opens BlueBubbles' "$phone_page"
assert_contains 'SystemSettings.openApplication("bluebubbles")' "$phone_page"
messages_card="$(sed -n '/title: "Messages"/,/title: "Device"/p' "$phone_page")"
[[ -n "$messages_card" ]] || fail 'PhonePage.qml has no Messages card'
rg -Fq 'SystemSettings.bluebubblesAvailable' <<<"$messages_card" \
|| fail 'Messages enablement does not read BlueBubbles availability'
if rg -Fq 'KdeConnect.' <<<"$messages_card"; then
fail 'Messages is coupled to KDE Connect'
fi
assert_contains 'readonly property bool bluebubblesAvailable: root.bluebubblesDetected' "$system_settings"
assert_contains 'command: ["flatpak", "info", "app.bluebubbles.BlueBubbles"]' "$system_settings"
assert_contains '"bluebubbles": ["flatpak", "run", "app.bluebubbles.BlueBubbles"]' "$system_settings"
# ── The live half ────────────────────────────────────────────────────────────
state_home="$(mktemp -d /tmp/panama-phone-page-state.XXXXXX)"
config_path="$state_home/quickshell"
test_bin="$state_home/bin"
shell_log="$state_home/quickshell.log"
flatpak_log="$state_home/flatpak.log"
cleanup_bootstrap() {
rm -rf "$state_home"
}
trap cleanup_bootstrap EXIT
mkdir -p "$test_bin"
cp -a "$repo_dir/config/dot/quickshell" "$config_path"
: >"$flatpak_log"
cat >"$config_path/scripts/panama-home-assistant" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
case "${1:-}" in
catalog|areas)
printf '%s\n' '{"ok":false,"error":"test-helper"}'
;;
esac
EOF
chmod +x "$config_path/scripts/panama-home-assistant"
cat >"$test_bin/hyprctl" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
if [[ "${1:-}" == "-j" && "${2:-}" == "monitors" ]]; then
printf '%s\n' '[{"focused":true,"name":"TEST-1","description":"Phone contract","width":1920,"height":1080,"refreshRate":60,"scale":1,"currentFormat":"XRGB8888","colorManagementPreset":"srgb","vrr":false}]'
exit 0
fi
if [[ "${1:-}" == "keyword" ]]; then
exit 0
fi
exec /usr/sbin/hyprctl "$@"
EOF
chmod +x "$test_bin/hyprctl"
cat >"$test_bin/flatpak" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
printf '%s\n' "$*" >>"$PANAMA_FLATPAK_LOG"
if [[ "${1:-}" == "info" && "${2:-}" == "app.bluebubbles.BlueBubbles" ]]; then
exit 0
fi
exit 97
EOF
chmod +x "$test_bin/flatpak"
qs_for_test() {
PATH="$test_bin:$PATH" QS_CONFIG_PATH="$config_path" XDG_STATE_HOME="$state_home" \
PANAMA_FLATPAK_LOG="$flatpak_log" qs -p "$config_path" "$@"
}
stop_test_shell() {
qs_for_test kill >/dev/null 2>&1 || true
for _ in $(seq 1 80); do
if ! qs_for_test list 2>/dev/null | rg '^Instance ' >/dev/null \
&& ! qs_for_test ipc show >/dev/null 2>&1; then
return 0
fi
sleep 0.1
done
return 1
}
cleanup() {
qs_for_test ipc call settings close >/dev/null 2>&1 || true
if stop_test_shell; then
rm -rf "$state_home"
else
printf 'phone page contract: branch shell did not stop; retained %s\n' "$state_home" >&2
fi
}
trap cleanup EXIT
start_test_shell() {
stop_test_shell || fail 'pre-existing branch shell did not stop cleanly'
for _attempt in 1 2; do
qs_for_test --daemonize >"$shell_log" 2>&1
for _ in $(seq 1 80); do
if qs_for_test ipc show 2>/dev/null | rg '^target settings-system$' >/dev/null; then
return
fi
sleep 0.1
done
stop_test_shell || fail 'failed branch-shell attempt did not stop cleanly'
done
sed -n '1,240p' "$shell_log" >&2
fail 'isolated branch shell did not start'
}
start_test_shell
qs_for_test ipc call settings page phone >/dev/null
for _ in $(seq 1 40); do
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "phone" ]] && break
sleep 0.1
done
status="$(qs_for_test ipc call settings status | jq -c .)"
jq -e '.open == true and .page == "phone"' <<<"$status" >/dev/null \
|| fail "the Phone tab did not render: $status"
system_status="$(qs_for_test ipc call settings-system status | jq -c .)"
jq -e '.bluebubblesAvailable == true' <<<"$system_status" >/dev/null \
|| fail "BlueBubbles availability was not exposed: $system_status"
shell_pid="$(qs_for_test list | awk '/Process ID:/ { print $3; exit }')"
[[ "$shell_pid" =~ ^[0-9]+$ ]] || fail 'could not identify the branch shell process'
for _ in $(seq 1 40); do
if /usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \
'[.[] | select(.pid == $pid and .title == "Settings" and .floating == false)] | length == 1' >/dev/null; then
break
fi
sleep 0.1
done
/usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \
'[.[] | select(.pid == $pid and .title == "Settings" and .floating == false)] | length == 1' >/dev/null \
|| fail 'the branch shell did not own exactly one tiled Panama Settings client'
if [[ -n "${PANAMA_TEST_SCREENSHOT_PATH:-}" ]]; then
geometry="$(/usr/sbin/hyprctl -j clients | jq -r --argjson pid "$shell_pid" \
'.[] | select(.pid == $pid and .title == "Settings") | "\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"')"
[[ -n "$geometry" ]] || fail 'could not resolve the Settings client geometry'
grim -g "$geometry" "$PANAMA_TEST_SCREENSHOT_PATH"
fi
rg -Fxq 'info app.bluebubbles.BlueBubbles' "$flatpak_log" \
|| fail 'the fixed BlueBubbles availability probe did not run'
if rg -q '^run ' "$flatpak_log"; then
fail 'the contract started BlueBubbles'
fi
trap - EXIT
cleanup
[[ ! -e "$state_home" ]] || fail 'temporary Phone state was not removed after shell exit'
printf 'phone page contract: PASS\n'
+25 -2
View File
@@ -80,6 +80,29 @@ duplicate_categories="$(sort <<<"$category_ids" | uniq -d)"
[[ -z "$duplicate_categories" ]] \ [[ -z "$duplicate_categories" ]] \
|| fail "these category ids are declared twice: $(tr '\n' ' ' <<<"$duplicate_categories")" || fail "these category ids are declared twice: $(tr '\n' ' ' <<<"$duplicate_categories")"
# ── Retired ids still land on a real page ────────────────────────────────────
# Old Vicinae commands, shell history and muscle memory keep handing over page
# ids that no longer exist, and resolve() consults the retired map before
# anything else. An entry aimed at a leaf that has itself since been renamed
# sends every one of those callers to Home without saying so, and an entry that
# names a live leaf shadows the real page.
retired_block="$(sed -n '/property var retired:/,/})/p' "$routes")"
retired_pairs="$(grep -oE '"[a-z-]+"[[:space:]]*:[[:space:]]*"[a-z-]+"' <<<"$retired_block" || true)"
retired_count=0
while read -r pair; do
[[ -n "$pair" ]] || continue
retired_id="$(sed -E 's/"([a-z-]+)".*/\1/' <<<"$pair")"
retired_target="$(sed -E 's/.*"([a-z-]+)"$/\1/' <<<"$pair")"
retired_count=$((retired_count + 1))
if ! grep -qx "$retired_target" <<<"$leaves"; then
fail "the retired id \"$retired_id\" resolves to \"$retired_target\", which is not a leaf, so everyone still holding it silently lands on Home"
fi
if grep -qx "$retired_id" <<<"$leaves"; then
fail "\"$retired_id\" is listed as retired and is also a live leaf, so resolve() answers with the retired target instead of the page itself"
fi
done <<<"$retired_pairs"
# ── Every leaf resolves everywhere ─────────────────────────────────────────── # ── Every leaf resolves everywhere ───────────────────────────────────────────
while read -r page; do while read -r page; do
[[ -n "$page" ]] || continue [[ -n "$page" ]] || continue
@@ -130,5 +153,5 @@ while read -r page_file; do
|| fail "$type_name.qml exists but nothing in SettingsShell instantiates it" || fail "$type_name.qml exists but nothing in SettingsShell instantiates it"
done < <(find "$settings_dir" -maxdepth 1 -name '*Page.qml') done < <(find "$settings_dir" -maxdepth 1 -name '*Page.qml')
printf 'settings nav contract: PASS (%d categories, %d leaves)\n' \ printf 'settings nav contract: PASS (%d categories, %d leaves, %d retired ids)\n' \
"$(grep -c . <<<"$category_ids")" "$(grep -c . <<<"$leaves")" "$(grep -c . <<<"$category_ids")" "$(grep -c . <<<"$leaves")" "$retired_count"
+2 -2
View File
@@ -9,7 +9,7 @@ fail() {
exit 1 exit 1
} }
pages=(Home Displays Connectivity Sound Dictation Notifications ScreenIntelligence Health About) pages=(Home MyHome Phone Displays Connectivity Sound Dictation Notifications ScreenIntelligence Health About)
for page in "${pages[@]}"; do for page in "${pages[@]}"; do
page_file="$repo_dir/config/dot/quickshell/modules/settings/${page}Page.qml" page_file="$repo_dir/config/dot/quickshell/modules/settings/${page}Page.qml"
[[ -f "$page_file" ]] || fail "${page}Page.qml is missing" [[ -f "$page_file" ]] || fail "${page}Page.qml is missing"
@@ -307,7 +307,7 @@ shell_pid="$harness_pid"
# four different categories, and the page the tab strip was introduced for. # four different categories, and the page the tab strip was introduced for.
# Routing to a tab must land on that tab, not on whatever its category opens # Routing to a tab must land on that tab, not on whatever its category opens
# first, which is the failure the SettingsRoutes resolution could introduce. # first, which is the failure the SettingsRoutes resolution could introduce.
pages=(home appearance displays connectivity home-phone desktop sound dictation notifications screen-intelligence shortcuts services manual about) pages=(home appearance displays connectivity my-home phone desktop sound dictation notifications screen-intelligence shortcuts services manual about)
for page in "${pages[@]}"; do for page in "${pages[@]}"; do
qs_for_test ipc call settings page "$page" >/dev/null qs_for_test ipc call settings page "$page" >/dev/null
for _ in $(seq 1 20); do for _ in $(seq 1 20); do