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:
+2
-1
@@ -1,5 +1,6 @@
|
||||
# Ignore bash environment variables.
|
||||
# Ignore bash environment variables, and the lock its writer takes.
|
||||
/config/bash/env
|
||||
/config/bash/.env.lock
|
||||
# Personal espanso triggers (name, email), seeded per-machine by setup-identity.
|
||||
/config/dot/espanso/match/identity.yml
|
||||
# Ignore backups of old config files
|
||||
|
||||
@@ -136,7 +136,7 @@ docs/ Settings reference, and the design specs behind the work
|
||||
|
||||
## 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
|
||||
panama test # everything
|
||||
|
||||
@@ -2,13 +2,18 @@
|
||||
|
||||
## 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
|
||||
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
|
||||
of Input, and About, Software Update, Storage, Snapshots and this manual are
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -301,6 +301,6 @@ Item {
|
||||
|
||||
function openHomeSettings(): void {
|
||||
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.
|
||||
//
|
||||
// It used to lead with a diagram of the attached monitor: DP-2, 4500 x 3000,
|
||||
// 1.13x scale, XRGB2101010. That is Displays-page data, it was the largest
|
||||
// thing on the screen, and nobody has ever needed it here. Below it sat a
|
||||
// permanently open search field for a weather location that is set about once a
|
||||
// year, and then roughly half a page of nothing.
|
||||
//
|
||||
// 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.
|
||||
// The Overview tab of the Home category. Quick actions sit under the greeting
|
||||
// as tiles; findings follow; then two glance cards into the sibling tabs (My
|
||||
// Home, Phone), the next calendar event, and weather. The old Focus and
|
||||
// containers cards retired into the tiles and the System category; the
|
||||
// reclaim-space prompt is gone on purpose — stopped development containers
|
||||
// are not clutter, and prompting their deletion was a racket.
|
||||
|
||||
import QtQuick
|
||||
import qs.config
|
||||
@@ -60,6 +53,14 @@ SettingsPage {
|
||||
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) {
|
||||
const count = Number(Health.summary?.errors ?? 0) + Number(Health.summary?.warnings ?? 0);
|
||||
found.push({
|
||||
@@ -79,7 +80,7 @@ SettingsPage {
|
||||
if (Health.checks.length > 0)
|
||||
parts.push(Health.checks.length + " health checks pass");
|
||||
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
|
||||
? (Snapshots.configs[0].snapshots ?? [])[0]
|
||||
: null;
|
||||
@@ -106,12 +107,48 @@ SettingsPage {
|
||||
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
|
||||
lede: Weather.available
|
||||
? Math.round(Weather.temperature) + Weather.unitSuffix + " and "
|
||||
+ Weather.description.toLowerCase() + " in " + Settings.weatherLocation
|
||||
: "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: {
|
||||
if (!Firewall.scanned)
|
||||
Firewall.refresh();
|
||||
@@ -123,90 +160,81 @@ SettingsPage {
|
||||
Disks.refresh();
|
||||
}
|
||||
|
||||
// ── What you came here to do ────────────────────────────────────────────
|
||||
// ── Quick actions ───────────────────────────────────────────────────────
|
||||
|
||||
Grid {
|
||||
id: doing
|
||||
id: quick
|
||||
|
||||
width: parent.width
|
||||
columns: width >= 720 ? 2 : 1
|
||||
columnSpacing: 16
|
||||
rowSpacing: 16
|
||||
columns: width >= 640 ? 5 : 2
|
||||
columnSpacing: 10
|
||||
rowSpacing: 10
|
||||
|
||||
SettingsCard {
|
||||
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"))
|
||||
readonly property real tileWidth: (width - (columns - 1) * columnSpacing) / columns
|
||||
|
||||
SettingRow {
|
||||
label: FocusSession.active ? "End the session" : "Start a focus session"
|
||||
detail: FocusSession.active
|
||||
? "Puts Do Not Disturb and Caffeine back as they were"
|
||||
: Settings.focusDurationMinutes + " minutes · Super+Shift+F"
|
||||
controlWidth: 120
|
||||
|
||||
SettingsButton {
|
||||
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()
|
||||
}
|
||||
HomeQuickTile {
|
||||
width: quick.tileWidth
|
||||
glyph: "\u{F051F}"
|
||||
label: FocusSession.active ? "End focus" : "Focus session"
|
||||
caption: FocusSession.active
|
||||
? FocusSession.remainingText + " left · tap to end"
|
||||
: Settings.focusDurationMinutes + " min · Super+Shift+F"
|
||||
onActivated: 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"
|
||||
detail: FocusModes.active
|
||||
? "Held on by the " + FocusModes.activeName + " mode"
|
||||
: "Banners are held; the notification centre still fills"
|
||||
divider: false
|
||||
controlWidth: 48
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
enabled: !FocusModes.active && !FocusSession.active
|
||||
checked: Notifs.doNotDisturb
|
||||
onToggled: value => Notifs.doNotDisturb = value
|
||||
}
|
||||
interactive: !FocusModes.active && !FocusSession.active
|
||||
caption: {
|
||||
if (FocusModes.active)
|
||||
return "Held by the " + FocusModes.activeName + " mode";
|
||||
if (FocusSession.active)
|
||||
return "Held by the focus session";
|
||||
return Notifs.doNotDisturb ? "On — tap to allow banners" : "Off — tap to hold banners";
|
||||
}
|
||||
onActivated: Notifs.doNotDisturb = !Notifs.doNotDisturb
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
width: doing.columns === 2
|
||||
? (doing.width - doing.columnSpacing) / 2
|
||||
: doing.width
|
||||
title: "Right now"
|
||||
subtitle: Containers.running > 0
|
||||
? Containers.running + " of " + Containers.total + " containers running"
|
||||
: "No containers are running"
|
||||
|
||||
TextRow {
|
||||
label: Containers.running > 0
|
||||
? Containers.projects.filter(project => project.running > 0)
|
||||
.map(project => String(project.title)).join(", ")
|
||||
: "Nothing to report"
|
||||
detail: Containers.running > 0
|
||||
? "Started for development, still up"
|
||||
: "Start a stack from the Containers page"
|
||||
value: ""
|
||||
HomeQuickTile {
|
||||
width: quick.tileWidth
|
||||
glyph: "\u{F0493}"
|
||||
glyphColor: Health.status === "error"
|
||||
? Theme.danger : (Health.status === "warning" ? Theme.warn : Theme.ok)
|
||||
label: "Health"
|
||||
caption: {
|
||||
if (Health.checks.length === 0)
|
||||
return "Checking the desktop";
|
||||
const count = Number(Health.summary?.errors ?? 0) + Number(Health.summary?.warnings ?? 0);
|
||||
return count > 0 ? count + " need attention" : Health.checks.length + " checks pass";
|
||||
}
|
||||
onActivated: ShellState.openSettings("services")
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Containers"
|
||||
detail: "Projects, what they expose, and what they cost"
|
||||
action: "Open"
|
||||
divider: false
|
||||
onTriggered: ShellState.settingsPage = "containers"
|
||||
HomeQuickTile {
|
||||
width: quick.tileWidth
|
||||
glyph: "\u{F0954}"
|
||||
label: "Snapshots"
|
||||
caption: {
|
||||
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 {
|
||||
visible: Updates.total > 0 || Containers.reclaimable > 0
|
||||
title: "Do next"
|
||||
subtitle: "Small things this machine is waiting on."
|
||||
Grid {
|
||||
id: glances
|
||||
|
||||
ActionRow {
|
||||
visible: Updates.total > 0
|
||||
label: "Install " + Updates.total + " update" + (Updates.total === 1 ? "" : "s")
|
||||
detail: "Packages, applications and firmware, from wherever each comes from"
|
||||
action: "Open"
|
||||
divider: Containers.reclaimable > 0
|
||||
onTriggered: ShellState.settingsPage = "updates"
|
||||
width: parent.width
|
||||
columns: width >= 720 ? 2 : 1
|
||||
columnSpacing: 16
|
||||
rowSpacing: 16
|
||||
|
||||
readonly property real cardWidth: columns === 2 ? (width - columnSpacing) / 2 : width
|
||||
|
||||
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 {
|
||||
visible: Containers.reclaimable > 0
|
||||
label: "Reclaim " + Containers.formatBytes(Containers.reclaimable)
|
||||
detail: "Container images and volumes nothing references"
|
||||
action: "Review"
|
||||
Column {
|
||||
anchors.left: glanceGlyph.right
|
||||
anchors.leftMargin: 14
|
||||
anchors.right: chevron.left
|
||||
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
|
||||
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
|
||||
}
|
||||
+255
-209
@@ -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 qs.config
|
||||
import qs.services
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
objectName: "home-phone-page"
|
||||
title: "Home & Phone"
|
||||
lede: "Choose what appears in Control Center and keep phone continuity close at hand."
|
||||
objectName: "my-home-page"
|
||||
title: "My Home"
|
||||
lede: root.homeStatus()
|
||||
|
||||
property string lightQuery: searchInput.text.trim().toLowerCase()
|
||||
|
||||
@@ -23,10 +32,20 @@ SettingsPage {
|
||||
: (HomeAssistant.catalog.length === 0
|
||||
? "No lights discovered"
|
||||
: "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: ({
|
||||
availableLightIds: root.availableLights.map(entity => entity.id),
|
||||
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 {
|
||||
@@ -34,8 +53,12 @@ SettingsPage {
|
||||
return "Authentication required";
|
||||
if (HomeAssistant.lastError === "not-configured")
|
||||
return "Home Assistant is not configured";
|
||||
if (HomeAssistant.phase === "ready")
|
||||
return `Connected · ${HomeAssistant.discoveredCount} lights discovered`;
|
||||
if (HomeAssistant.phase === "ready") {
|
||||
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")
|
||||
return "Last update unavailable · showing saved controls";
|
||||
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 {
|
||||
title: "Home Assistant"
|
||||
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
|
||||
|
||||
The sidebar lists fifteen **categories**, not one row per page. A category
|
||||
covering several subjects — Input, Network & Sharing, Applications, Users &
|
||||
Accounts, Privacy & Security, System — draws a tab strip above the page, and
|
||||
each of its **leaf** pages is one tab; a category with a single subject is a
|
||||
leaf itself and shows no strip. The column used to be a flat list of thirty-one
|
||||
pages, which made finding Printers a scan of the whole thing. Grouped, it sits
|
||||
under Network & Sharing, where somebody looking for it already expects it.
|
||||
The sidebar lists fourteen **categories**, not one row per page. A category
|
||||
covering several subjects — Home, Input, Network & Sharing, Applications,
|
||||
Users & Accounts, Privacy & Security, System — draws a tab strip above the
|
||||
page, and each of its **leaf** pages is one tab; a category with a single
|
||||
subject is a leaf itself and shows no strip. The column used to be a flat list
|
||||
of thirty-one pages, which made finding Printers a scan of the whole thing.
|
||||
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.
|
||||
`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
|
||||
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:
|
||||
`SettingsRoutes.pageAvailable()` drops the Containers tab on a machine without
|
||||
podman and Snapshots without a snapper configuration, but only once a scan has
|
||||
|
||||
@@ -6,9 +6,9 @@ Rectangle {
|
||||
id: root
|
||||
|
||||
property var hostWindow: null
|
||||
readonly property var homePhoneDiagnostics: pageLoader.status === Loader.Ready
|
||||
readonly property var myHomeDiagnostics: pageLoader.status === Loader.Ready
|
||||
&& pageLoader.item
|
||||
&& pageLoader.item.objectName === "home-phone-page"
|
||||
&& pageLoader.item.objectName === "my-home-page"
|
||||
? pageLoader.item.pageDiagnostics
|
||||
: ({})
|
||||
readonly property var healthDiagnostics: pageLoader.status === Loader.Ready
|
||||
@@ -134,7 +134,8 @@ Rectangle {
|
||||
case "appearance": return appearancePage;
|
||||
case "displays": return displaysPage;
|
||||
case "connectivity": return connectivityPage;
|
||||
case "home-phone": return homePhonePage;
|
||||
case "my-home": return myHomePage;
|
||||
case "phone": return phonePage;
|
||||
case "desktop": return desktopPage;
|
||||
case "sound": return soundPage;
|
||||
case "gaming": return gamingPage;
|
||||
@@ -213,7 +214,8 @@ Rectangle {
|
||||
Component { id: appearancePage; AppearancePage {} }
|
||||
Component { id: displaysPage; DisplaysPage {} }
|
||||
Component { id: connectivityPage; ConnectivityPage {} }
|
||||
Component { id: homePhonePage; HomePhonePage {} }
|
||||
Component { id: myHomePage; MyHomePage {} }
|
||||
Component { id: phonePage; PhonePage {} }
|
||||
Component { id: desktopPage; DesktopPage {} }
|
||||
Component { id: soundPage; SoundPage {} }
|
||||
Component { id: gamingPage; GamingPage {} }
|
||||
|
||||
@@ -6,7 +6,7 @@ import qs.services
|
||||
FloatingWindow {
|
||||
id: root
|
||||
|
||||
readonly property var homePhoneDiagnostics: settingsShell.homePhoneDiagnostics
|
||||
readonly property var myHomeDiagnostics: settingsShell.myHomeDiagnostics
|
||||
|
||||
title: "Settings"
|
||||
visible: ShellState.settingsOpen
|
||||
|
||||
@@ -11,7 +11,6 @@ AvatarCropper 1.0 AvatarCropper.qml
|
||||
PickerRow 1.0 PickerRow.qml
|
||||
SettingsTabs 1.0 SettingsTabs.qml
|
||||
GamingPage 1.0 GamingPage.qml
|
||||
HomePhonePage 1.0 HomePhonePage.qml
|
||||
HomeFavoriteCard 1.0 HomeFavoriteCard.qml
|
||||
AvailableLightRow 1.0 AvailableLightRow.qml
|
||||
DesktopPage 1.0 DesktopPage.qml
|
||||
@@ -74,6 +73,10 @@ TimeOfDayRow 1.0 TimeOfDayRow.qml
|
||||
LocationPicker 1.0 LocationPicker.qml
|
||||
FontPicker 1.0 FontPicker.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
|
||||
TextEntryRow 1.0 TextEntryRow.qml
|
||||
PrivacyPage 1.0 PrivacyPage.qml
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 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,
|
||||
// macOS or Windows cannot tell which four things matter. This is those four
|
||||
// things, once, on the first start.
|
||||
|
||||
@@ -587,7 +587,7 @@ def check_home_assistant(config: DoctorConfig) -> Check:
|
||||
if not configured:
|
||||
return Check("integration.home-assistant", "integrations", "Home Assistant", "unconfigured", "Home Assistant is not configured.")
|
||||
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.")
|
||||
|
||||
|
||||
|
||||
@@ -26,6 +26,18 @@ ENV_KEYS = (
|
||||
"PANAMA_HOME_ASSISTANT_ENTITIES",
|
||||
)
|
||||
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 = (
|
||||
pathlib.Path.home()
|
||||
/ ".local/share/gnome-shell/extensions/hass-gshell@geoph9-on-github"
|
||||
@@ -258,6 +270,8 @@ def request_json(
|
||||
error_body = error.read().decode(errors="replace")
|
||||
except OSError:
|
||||
error_body = ""
|
||||
finally:
|
||||
error.close()
|
||||
raise BridgeError(public_http_error(error.code, error_body)) from None
|
||||
except (TimeoutError, urllib.error.URLError, OSError):
|
||||
raise BridgeError("unreachable") from None
|
||||
@@ -358,6 +372,53 @@ def collect_snapshot(config: Config) -> dict[str, object]:
|
||||
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]:
|
||||
raw = request_json(config, "GET", "/api/states")
|
||||
if not isinstance(raw, list):
|
||||
@@ -466,6 +527,9 @@ def main(argv: list[str]) -> int:
|
||||
elif command in {"catalog", "snapshot"} and len(argv) == 1:
|
||||
result = collect_catalog(config)
|
||||
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:
|
||||
try:
|
||||
result = toggle(config, argv[1])
|
||||
|
||||
@@ -27,6 +27,18 @@ DEVICE_OBJECT_PREFIX = "/modules/kdeconnect/devices"
|
||||
DEVICE_OBJECT_LINE = re.compile(
|
||||
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]]
|
||||
|
||||
|
||||
@@ -98,14 +110,21 @@ def normalize_device_line(
|
||||
#
|
||||
# The JSON form returns real UTF-8 and needs no unescaping, which is why these
|
||||
# parse a document rather than splitting words.
|
||||
def parse_property(output: str, expected: str) -> object | None:
|
||||
"""The value of a busctl --json=short property read, or None if it is not
|
||||
the type asked for."""
|
||||
def property_payload(output: str | None) -> dict[str, object] | None:
|
||||
"""The decoded document of a busctl --json=short read, or None when the
|
||||
output is missing or is not a property document at all."""
|
||||
try:
|
||||
payload = json.loads(output)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
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 payload.get("data")
|
||||
|
||||
@@ -117,16 +136,31 @@ def parse_loaded_plugins(output: str) -> list[str]:
|
||||
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")
|
||||
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")
|
||||
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(
|
||||
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]:
|
||||
try:
|
||||
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]] = []
|
||||
for device_id in dbus_device_ids(runner):
|
||||
try:
|
||||
@@ -266,8 +413,7 @@ def dbus_devices(runner: Runner = subprocess.run) -> list[dict[str, object]]:
|
||||
if plugin in plugins
|
||||
}
|
||||
)
|
||||
devices.append(
|
||||
{
|
||||
device = {
|
||||
"id": device_id,
|
||||
"name": 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,
|
||||
"actions": actions,
|
||||
}
|
||||
)
|
||||
device.update(device_vitals(device, runner) if vitals else EMPTY_VITALS)
|
||||
devices.append(device)
|
||||
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:
|
||||
listing = run_command(
|
||||
["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)
|
||||
except ValueError:
|
||||
continue
|
||||
device.update(device_vitals(device, runner) if vitals else EMPTY_VITALS)
|
||||
devices.append(device)
|
||||
|
||||
known_ids = {str(device["id"]) for device in devices}
|
||||
devices.extend(
|
||||
device
|
||||
for device in dbus_devices(runner)
|
||||
for device in dbus_devices(runner, vitals=vitals)
|
||||
if str(device["id"]) not in known_ids
|
||||
)
|
||||
|
||||
@@ -358,7 +513,7 @@ def invoke_action(
|
||||
runner: Runner = subprocess.run,
|
||||
) -> dict[str, object]:
|
||||
device_id = validate_device_id(device_id)
|
||||
status = collect_status(runner)
|
||||
status = collect_status(runner, vitals=False)
|
||||
device = next(
|
||||
(
|
||||
item
|
||||
|
||||
@@ -52,7 +52,7 @@ class SchemaError(RuntimeError):
|
||||
def read_titles():
|
||||
"""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,
|
||||
"Displays" for a category that is a page by itself.
|
||||
"""
|
||||
|
||||
@@ -33,7 +33,7 @@ Singleton {
|
||||
readonly property var statuses: ["ok", "warning", "error", "unconfigured"]
|
||||
readonly property var groups: ["desktop-foundation", "input-media", "integrations", "panama-tools"]
|
||||
readonly property var overallStatuses: ["healthy", "warning", "error"]
|
||||
readonly property var settingsTargets: ["home-phone", "datetime"]
|
||||
readonly property var settingsTargets: ["my-home", "datetime"]
|
||||
readonly property var instructionTargets: ["ddc-permissions"]
|
||||
|
||||
Process {
|
||||
|
||||
@@ -16,6 +16,7 @@ Singleton {
|
||||
// "loading" | "ready" | "degraded" | "unavailable"
|
||||
property string phase: "loading"
|
||||
property var catalog: []
|
||||
property var areas: []
|
||||
property var selectedEntities: []
|
||||
property bool stale: false
|
||||
property string lastError: ""
|
||||
@@ -38,6 +39,10 @@ Singleton {
|
||||
property var pendingFixtureTarget: null
|
||||
property int delayedFixtureExitCode: 0
|
||||
|
||||
// Catalog lights grouped by Home Assistant area, recomputed whenever either
|
||||
// half changes. Both halves are read here so the binding tracks them.
|
||||
readonly property var rooms: root.composeRooms(root.catalog, root.areas)
|
||||
|
||||
readonly property var visibleEntities: root.selectedEntities.slice(0, 4)
|
||||
readonly property int discoveredCount: root.catalog.length
|
||||
readonly property int configuredCount: root.selectedEntities.length
|
||||
@@ -56,6 +61,56 @@ Singleton {
|
||||
if (root.catalog.length === 0)
|
||||
root.phase = "loading";
|
||||
refreshProc.running = true;
|
||||
if (!areasProc.running)
|
||||
areasProc.running = true;
|
||||
}
|
||||
|
||||
// Rooms hold the SAME entity objects as the catalog, so a tile bound to a
|
||||
// room light sees exactly what the catalog says. Areas keep the order Home
|
||||
// Assistant reports them in; catalog lights no area claims fall into a
|
||||
// trailing "Other" bucket. With no usable areas the whole catalog becomes a
|
||||
// single unnamed bucket, which the UI renders as a flat list with no header.
|
||||
function composeRooms(catalog: var, areas: var): var {
|
||||
const lights = Array.isArray(catalog) ? catalog : [];
|
||||
const flat = [{ id: "", name: "", lights: lights.slice() }];
|
||||
if (!Array.isArray(areas) || areas.length === 0)
|
||||
return flat;
|
||||
|
||||
const catalogById = {};
|
||||
for (let index = 0; index < lights.length; index++)
|
||||
catalogById[lights[index].id] = lights[index];
|
||||
|
||||
const claimed = {};
|
||||
const grouped = [];
|
||||
for (let index = 0; index < areas.length; index++) {
|
||||
const area = areas[index];
|
||||
const entityIds = area && Array.isArray(area.entities) ? area.entities : [];
|
||||
const roomLights = [];
|
||||
for (let inner = 0; inner < entityIds.length; inner++) {
|
||||
const entity = catalogById[entityIds[inner]];
|
||||
if (!entity || claimed[entity.id] === true)
|
||||
continue;
|
||||
claimed[entity.id] = true;
|
||||
roomLights.push(entity);
|
||||
}
|
||||
if (roomLights.length === 0)
|
||||
continue;
|
||||
grouped.push({
|
||||
id: String(area.id || ""),
|
||||
name: String(area.name || ""),
|
||||
lights: roomLights
|
||||
});
|
||||
}
|
||||
|
||||
// Areas that name nothing in the catalog leave every light an orphan;
|
||||
// one "Other" heading over the whole list is worse than no heading.
|
||||
if (grouped.length === 0)
|
||||
return flat;
|
||||
|
||||
const orphans = lights.filter(entity => claimed[entity.id] !== true);
|
||||
if (orphans.length > 0)
|
||||
grouped.push({ id: "", name: "Other", lights: orphans });
|
||||
return grouped;
|
||||
}
|
||||
|
||||
function consumeCatalog(text: string): void {
|
||||
@@ -91,6 +146,26 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
// Rooms are a nicety layered over the catalog: a Home Assistant whose
|
||||
// template endpoint is unavailable still gets its lights. A failure here
|
||||
// only empties `areas` -- it never touches `phase`, `stale`, or `lastError`.
|
||||
function consumeAreas(text: string): void {
|
||||
if (root.fixtureMode)
|
||||
return;
|
||||
|
||||
let result = null;
|
||||
try {
|
||||
result = JSON.parse(text);
|
||||
} catch (error) {
|
||||
root.areas = [];
|
||||
return;
|
||||
}
|
||||
|
||||
root.areas = result && result.ok === true && Array.isArray(result.areas)
|
||||
? result.areas
|
||||
: [];
|
||||
}
|
||||
|
||||
function rebuildSelection(): void {
|
||||
const favorites = root.fixtureMode
|
||||
? root.fixtureFavorites
|
||||
@@ -364,6 +439,16 @@ Singleton {
|
||||
];
|
||||
}
|
||||
|
||||
// Deliberately leaves light.fixture_all and light.fixture_corner unclaimed
|
||||
// so fixtures exercise the trailing "Other" bucket as well as the rooms.
|
||||
function fixtureAreas(): var {
|
||||
return [
|
||||
{ id: "kitchen", name: "Kitchen", entities: ["light.fixture_kitchen"] },
|
||||
{ id: "living_room", name: "Living room", entities: ["light.fixture_living", "light.fixture_desk"] },
|
||||
{ id: "bedroom", name: "Bedroom", entities: ["light.fixture_bedroom", "light.fixture_hall"] }
|
||||
];
|
||||
}
|
||||
|
||||
function fixturePreferenceRecords(): var {
|
||||
return [
|
||||
{ id: "light.fixture_all", alias: " Whole home " },
|
||||
@@ -479,6 +564,7 @@ Singleton {
|
||||
: (name === "process-slow-actions" ? "slow-success" : "")));
|
||||
if (name === "unavailable") {
|
||||
root.catalog = [];
|
||||
root.areas = [];
|
||||
root.fixtureFavorites = [];
|
||||
root.rebuildSelection();
|
||||
root.phase = "unavailable";
|
||||
@@ -488,6 +574,7 @@ Singleton {
|
||||
}
|
||||
|
||||
root.catalog = root.fixtureEntities();
|
||||
root.areas = root.fixtureAreas();
|
||||
root.fixtureFavorites = root.fixturePreferenceRecords();
|
||||
if (name === "available-extra") {
|
||||
root.catalog = root.catalog.concat([{
|
||||
@@ -530,6 +617,7 @@ Singleton {
|
||||
root.fixtureProcessMode = "";
|
||||
root.phase = "loading";
|
||||
root.catalog = [];
|
||||
root.areas = [];
|
||||
root.selectedEntities = [];
|
||||
root.fixtureFavorites = [];
|
||||
root.stale = false;
|
||||
@@ -545,6 +633,14 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: areasProc
|
||||
command: [root.helperPath, "areas"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.consumeAreas(this.text)
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: actionProc
|
||||
stdout: StdioCollector {
|
||||
|
||||
@@ -43,6 +43,12 @@ Singleton {
|
||||
readonly property int pairedCount: root.devices.filter(device => device.paired).length
|
||||
readonly property var phoneActions: root.preferredPhone?.actions ?? []
|
||||
|
||||
// Vitals are null whenever the phone is away or the plugin is off, which is
|
||||
// the normal state rather than an error -- consumers show nothing, not a
|
||||
// zero. `?? null` keeps `undefined` from leaking out as a distinct case.
|
||||
readonly property var phoneBattery: root.preferredPhone?.battery ?? null
|
||||
readonly property var phoneSignal: root.preferredPhone?.signal ?? null
|
||||
|
||||
function supports(action: string): bool {
|
||||
return root.phoneActions.indexOf(action) >= 0;
|
||||
}
|
||||
@@ -53,13 +59,44 @@ Singleton {
|
||||
statusProc.running = true;
|
||||
}
|
||||
|
||||
// The helper emits `battery` and `signal` as either null or a complete
|
||||
// object. These rebuild them anyway so a partial payload -- an old helper,
|
||||
// a field that arrived as a string -- becomes null rather than a card
|
||||
// rendering "undefined%".
|
||||
function normalizeBattery(value: var): var {
|
||||
if (!value || typeof value.charge !== "number" || value.charge < 0)
|
||||
return null;
|
||||
return {
|
||||
charge: Math.round(value.charge),
|
||||
charging: value.charging === true
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSignal(value: var): var {
|
||||
if (!value || typeof value.strength !== "number" || value.strength < 0)
|
||||
return null;
|
||||
return {
|
||||
networkType: String(value.networkType ?? ""),
|
||||
strength: Math.round(value.strength)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDevice(device: var): var {
|
||||
return Object.assign({}, device, {
|
||||
battery: root.normalizeBattery(device.battery),
|
||||
signal: root.normalizeSignal(device.signal)
|
||||
});
|
||||
}
|
||||
|
||||
function consumeStatus(text: string): void {
|
||||
if (root.fixtureMode)
|
||||
return;
|
||||
try {
|
||||
const result = JSON.parse(text);
|
||||
root.available = result.available === true;
|
||||
root.devices = Array.isArray(result.devices) ? result.devices : [];
|
||||
root.devices = Array.isArray(result.devices)
|
||||
? result.devices.map(device => root.normalizeDevice(device))
|
||||
: [];
|
||||
root.lastError = String(result.error ?? "");
|
||||
} catch (error) {
|
||||
root.available = false;
|
||||
@@ -188,13 +225,19 @@ Singleton {
|
||||
root.available = true;
|
||||
root.lastError = "";
|
||||
root.recentExchange = null;
|
||||
// Vitals track reachability the way the helper's do: the plugin objects
|
||||
// only exist for a phone that is actually there, so the offline fixture
|
||||
// carries nulls and the reachable ones carry readable values.
|
||||
const reachable = name !== "offline";
|
||||
root.devices = [{
|
||||
id: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
name: "Fixture iPhone",
|
||||
type: "phone",
|
||||
paired: true,
|
||||
reachable: name !== "offline",
|
||||
actions: ["clipboard", "ping", "ring", "share"]
|
||||
reachable: reachable,
|
||||
actions: ["clipboard", "ping", "ring", "share"],
|
||||
battery: reachable ? { charge: 82, charging: true } : null,
|
||||
signal: reachable ? { networkType: "LTE", strength: 3 } : null
|
||||
}];
|
||||
root.transferActive = name === "transfer";
|
||||
root.transferFileName = name === "transfer" ? "Fixture document.pdf" : "";
|
||||
|
||||
@@ -27,7 +27,11 @@ Singleton {
|
||||
// alias. Three category ids ("applications", "users", "privacy") double as
|
||||
// the id of their first tab, which resolves to the same place either way.
|
||||
readonly property var categories: [
|
||||
{ page: "home", label: "Home", icon: "\u{F02DC}", tabs: [] },
|
||||
{ page: "home", label: "Home", icon: "\u{F02DC}", tabs: [
|
||||
{ page: "home", label: "Overview" },
|
||||
{ page: "my-home", label: "My Home" },
|
||||
{ page: "phone", label: "Phone" }
|
||||
] },
|
||||
{ page: "appearance", label: "Appearance", icon: "\u{F0E0D}", tabs: [] },
|
||||
{ page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}", tabs: [] },
|
||||
{ page: "displays", label: "Displays", icon: "\u{F0379}", tabs: [] },
|
||||
@@ -44,7 +48,6 @@ Singleton {
|
||||
{ page: "sharing", label: "Sharing" },
|
||||
{ page: "printers", label: "Printers" }
|
||||
] },
|
||||
{ page: "home-phone", label: "Home & Phone", icon: "\u{F02DC}", tabs: [] },
|
||||
{ page: "applications", label: "Applications", icon: "\u{F003B}", tabs: [
|
||||
{ page: "applications", label: "Applications" },
|
||||
{ page: "gaming", label: "Gaming" },
|
||||
@@ -98,10 +101,20 @@ Singleton {
|
||||
return root.categories.find(cat => cat.page === leaf) ?? root.categories[0];
|
||||
}
|
||||
|
||||
// Any id a caller may hold — leaf, category, or garbage — to the leaf that
|
||||
// should render: a leaf resolves to itself, a category to its first
|
||||
// available tab, anything unknown to home.
|
||||
// Retired page ids keep resolving forever: old Vicinae commands, shell
|
||||
// history, and muscle memory all hold them. Each maps to the leaf that
|
||||
// absorbed its content.
|
||||
readonly property var retired: ({ "home-phone": "my-home" })
|
||||
|
||||
// Any id a caller may hold — leaf, category, retired id, or garbage — to
|
||||
// the leaf that should render: a leaf resolves to itself, a category to
|
||||
// its first available tab, anything unknown to home.
|
||||
function resolve(id: string): string {
|
||||
// hasOwnProperty guard: `retired` is a plain JS object, so a bare
|
||||
// index would answer for prototype names like "toString" and leak a
|
||||
// function into settingsPage.
|
||||
if (Object.prototype.hasOwnProperty.call(root.retired, id))
|
||||
return root.retired[id];
|
||||
const asLeaf = root.categories.some(cat => (cat.page === id && cat.tabs.length === 0)
|
||||
|| cat.tabs.some(tab => tab.page === id));
|
||||
if (asLeaf)
|
||||
|
||||
@@ -130,8 +130,14 @@ Singleton {
|
||||
{ label: "Language", detail: "The system language, applied to programs started afterwards", page: "region" },
|
||||
{ label: "Regional formats", detail: "How dates, times, and numbers are written", page: "region" },
|
||||
{ label: "Online accounts", detail: "Sign in to mail, calendar, and contacts", page: "accounts" },
|
||||
{ label: "Home Assistant", detail: "Connect the desktop to a Home Assistant server", page: "home-phone" },
|
||||
{ label: "Phone", detail: "Pair a phone for messages and notifications", page: "home-phone" },
|
||||
{ label: "Home Assistant", detail: "Connect the desktop to a Home Assistant server", page: "my-home" },
|
||||
{ label: "Lights", detail: "Toggle and dim lights, grouped by room", page: "my-home" },
|
||||
{ label: "Control Center lights", detail: "Choose the accessories on your shelf", page: "my-home" },
|
||||
{ label: "Phone", detail: "Battery, signal, and reaching your phone", page: "phone" },
|
||||
{ label: "Ring your phone", detail: "Find it, even when silenced", page: "phone" },
|
||||
{ label: "Send clipboard to phone", detail: "Hand off what you just copied", page: "phone" },
|
||||
{ label: "Send a file to phone", detail: "Lands in the phone's downloads", page: "phone" },
|
||||
{ label: "iMessage", detail: "Opens BlueBubbles", page: "phone" },
|
||||
{ label: "System information", detail: "Kernel, distribution, and hardware", page: "about" },
|
||||
{ label: "Desktop version", detail: "Which Hyprland and Quickshell this session runs", page: "about" },
|
||||
{ label: "Restore defaults", detail: "Return every Panama setting to its shipped value", page: "desktop" },
|
||||
|
||||
@@ -489,7 +489,7 @@ ShellRoot {
|
||||
page: ShellState.settingsPage,
|
||||
discoveredCount: HomeAssistant.discoveredCount,
|
||||
selectedCount: HomeAssistant.configuredCount,
|
||||
homePhone: settingsWindow.homePhoneDiagnostics
|
||||
myHome: settingsWindow.myHomeDiagnostics
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -1,10 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# Generated by scripts/panama-settings-commands -- do not edit by hand.
|
||||
# @vicinae.schemaVersion 1
|
||||
# @vicinae.title Settings: Home & Phone
|
||||
# @vicinae.title Settings: My Home
|
||||
# @vicinae.mode silent
|
||||
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
|
||||
# @vicinae.description Open Home & Phone in Settings.
|
||||
# @vicinae.keywords ["settings", "home assistant", "phone"]
|
||||
# @vicinae.description Open My Home in Settings.
|
||||
# @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
@@ -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
@@ -345,7 +345,7 @@ Found on **Appearance**.
|
||||
|
||||
## weather
|
||||
|
||||
Found on **Home**.
|
||||
Found on **Home › Overview**.
|
||||
|
||||
| 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
|
||||
|
||||
@@ -134,8 +134,8 @@ rg -Fq 'HomeAssistant.pendingFor(' "$quicksettings_path/HomeControls.qml" \
|
||||
|| fail 'Home tiles do not receive per-light pending brightness'
|
||||
rg -Fq 'HomeAssistant.setBrightness(' "$quicksettings_path/HomeControls.qml" \
|
||||
|| fail 'Home brightness commits are not wired to the service'
|
||||
rg -Fq 'ShellState.openSettings("home-phone")' "$quicksettings_path/HomeControls.qml" \
|
||||
|| fail 'Manage in Settings does not open Home & Phone'
|
||||
rg -Fq 'ShellState.openSettings("my-home")' "$quicksettings_path/HomeControls.qml" \
|
||||
|| fail 'Manage in Settings does not open My Home'
|
||||
|
||||
rg -Fq 'signal previewChanged(int value)' "$quicksettings_path/HomeBrightnessSlider.qml" \
|
||||
|| fail 'brightness slider has no preview contract'
|
||||
|
||||
@@ -5,7 +5,7 @@ set -euo pipefail
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
helper="$repo_dir/config/dot/quickshell/scripts/panama-home-assistant-config"
|
||||
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"
|
||||
harness_fixture="$repo_dir/tests/quickshell/HomeAssistantConfigHarness.qml"
|
||||
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.util
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
@@ -16,6 +18,23 @@ ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||||
HELPER = ROOT / "config/dot/quickshell/scripts/panama-home-assistant"
|
||||
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))
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
bridge = importlib.util.module_from_spec(spec)
|
||||
@@ -25,6 +44,8 @@ loader.exec_module(bridge)
|
||||
|
||||
class FakeHomeAssistant(BaseHTTPRequestHandler):
|
||||
requests: list[dict[str, object]] = []
|
||||
# "rendered" | "malformed" | "unauthorized"
|
||||
template_mode: str = "rendered"
|
||||
|
||||
def log_message(self, _format: str, *args: object) -> None:
|
||||
del args
|
||||
@@ -47,6 +68,25 @@ class FakeHomeAssistant(BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
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:
|
||||
self._record()
|
||||
if self.path == "/api/":
|
||||
@@ -95,6 +135,9 @@ class FakeHomeAssistant(BaseHTTPRequestHandler):
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
body = self.rfile.read(length)
|
||||
self._record(body)
|
||||
if self.path == "/api/template":
|
||||
self._template()
|
||||
return
|
||||
if self.path in {
|
||||
"/api/services/homeassistant/toggle",
|
||||
"/api/services/light/turn_on",
|
||||
@@ -121,6 +164,7 @@ class HomeAssistantBridgeTest(unittest.TestCase):
|
||||
|
||||
def setUp(self) -> None:
|
||||
FakeHomeAssistant.requests.clear()
|
||||
FakeHomeAssistant.template_mode = "rendered"
|
||||
|
||||
def config(self) -> object:
|
||||
return bridge.Config(
|
||||
@@ -129,6 +173,15 @@ class HomeAssistantBridgeTest(unittest.TestCase):
|
||||
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:
|
||||
env = {
|
||||
"PANAMA_HOME_ASSISTANT_URL": "https://home.example",
|
||||
@@ -203,6 +256,108 @@ class HomeAssistantBridgeTest(unittest.TestCase):
|
||||
["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:
|
||||
with self.assertRaisesRegex(ValueError, "entity-not-discovered"):
|
||||
bridge.toggle(self.config(), "light.office")
|
||||
@@ -271,6 +426,28 @@ class HomeAssistantBridgeTest(unittest.TestCase):
|
||||
self.assertIn("name: alias || entity.sourceName,", 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:
|
||||
self.assertEqual(
|
||||
bridge.public_http_error(401, "sensitive response"),
|
||||
|
||||
@@ -28,10 +28,19 @@ if [[ "$(jq -r '.devices | length' <<<"$status")" == "0" ]]; then
|
||||
exit 0
|
||||
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 '
|
||||
([.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[].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'
|
||||
|
||||
paired_count="$(jq '[.devices[] | select(.paired)] | length' <<<"$status")"
|
||||
|
||||
@@ -193,6 +193,8 @@ class KdeConnectBridgeTest(unittest.TestCase):
|
||||
"paired": True,
|
||||
"reachable": False,
|
||||
"actions": ["clipboard", "ring", "share"],
|
||||
"battery": None,
|
||||
"signal": None,
|
||||
}
|
||||
],
|
||||
"error": "",
|
||||
@@ -269,7 +271,11 @@ class KdeConnectBridgeTest(unittest.TestCase):
|
||||
if command[:4] == ["busctl", "--user", "--json=short", "call"]:
|
||||
return subprocess.CompletedProcess(command, 0, '{"type":"as","data":[]}\n', "")
|
||||
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 = {
|
||||
"name": '{"type":"s","data":"Fixture iPhone"}\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})
|
||||
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
Executable
+329
@@ -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'
|
||||
@@ -318,8 +318,8 @@ mkdir -p "$config_home/quickshell/scripts"
|
||||
home_assistant_failure="$(run_doctor --json)"
|
||||
check_status "$home_assistant_failure" integration.home-assistant warning
|
||||
jq -e '.checks[] | select(.id == "integration.home-assistant")
|
||||
| .action == {kind:"open", label:"Open Home settings", confirm:false, target:"home-phone"}' \
|
||||
>/dev/null <<<"$home_assistant_failure" || fail 'Home Assistant action was not routed to 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 my-home'
|
||||
|
||||
# Invalid probe text is contained in its own check and never copied to JSON.
|
||||
malformed_calendar="$(PANAMA_DOCTOR_FIXTURE_CALENDAR=malformed run_doctor --json)"
|
||||
|
||||
Executable
+235
@@ -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'
|
||||
@@ -80,6 +80,29 @@ duplicate_categories="$(sort <<<"$category_ids" | uniq -d)"
|
||||
[[ -z "$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 ───────────────────────────────────────────
|
||||
while read -r page; do
|
||||
[[ -n "$page" ]] || continue
|
||||
@@ -130,5 +153,5 @@ while read -r page_file; do
|
||||
|| fail "$type_name.qml exists but nothing in SettingsShell instantiates it"
|
||||
done < <(find "$settings_dir" -maxdepth 1 -name '*Page.qml')
|
||||
|
||||
printf 'settings nav contract: PASS (%d categories, %d leaves)\n' \
|
||||
"$(grep -c . <<<"$category_ids")" "$(grep -c . <<<"$leaves")"
|
||||
printf 'settings nav contract: PASS (%d categories, %d leaves, %d retired ids)\n' \
|
||||
"$(grep -c . <<<"$category_ids")" "$(grep -c . <<<"$leaves")" "$retired_count"
|
||||
|
||||
@@ -9,7 +9,7 @@ fail() {
|
||||
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
|
||||
page_file="$repo_dir/config/dot/quickshell/modules/settings/${page}Page.qml"
|
||||
[[ -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.
|
||||
# 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.
|
||||
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
|
||||
qs_for_test ipc call settings page "$page" >/dev/null
|
||||
for _ in $(seq 1 20); do
|
||||
|
||||
Reference in New Issue
Block a user