Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4c3ffed43 | ||
|
|
5f965daa6a | ||
|
|
5940d6b4bd | ||
|
|
190e3200e0 | ||
|
|
49ec4d8f6a | ||
|
|
173c00fca7 | ||
|
|
3a903934c0 |
@@ -8,3 +8,6 @@
|
||||
/config/dot/nvim/lazy-lock.json
|
||||
# Local visual-companion sessions and generated design mocks.
|
||||
/.superpowers/
|
||||
# Python helper bytecode is local runtime state.
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
@@ -23,7 +23,7 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
|
||||
| Date menu | Two-column Daybook with Agenda, Ongoing activity, Notifications, month calendar, weather, and media | Live |
|
||||
| Ongoing activity | Focus, Caffeine, recording, screen sharing, camera, and microphone state with direct controls | Live |
|
||||
| Calendar agenda | Existing GNOME, Google, iCloud, and Nextcloud sources through Evolution Data Server | Live |
|
||||
| Quick settings | Wi-Fi, Bluetooth, audio input/output, caffeine, night light, focus | Live |
|
||||
| Control Center | Wi-Fi, Bluetooth, audio input/output, Caffeine, Night Light, Focus, Home Assistant favourites, and KDE Connect phone continuity | Live |
|
||||
| Focus sessions | Persistent workspace-bound timer, DND/caffeine coordination, Signal Glass controls | Live |
|
||||
| Brightness | Appears only when a backlight device exists | Live, not applicable on this monitor |
|
||||
| AppIndicator / tray | Native StatusNotifierItem host and themed menus | Live |
|
||||
@@ -35,6 +35,7 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
|
||||
| Portals and screen sharing | xdg-desktop-portal-hyprland plus GTK portal | Live |
|
||||
| Removable media | udiskie plus udisks notifications | Live |
|
||||
| Autostart apps | Nextcloud, Bitwarden, and RustDesk system service/tray | Live |
|
||||
| Printer administration | CUPS with the `system-config-printer` graphical interface | Live |
|
||||
| System settings | Panama Settings for display policy, appearance, desktop, sound, focus, shortcuts, and services; labelled GNOME hardware/account handoffs | Live |
|
||||
|
||||
## GNOME extension migration
|
||||
@@ -52,8 +53,8 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
|
||||
| Blur My Shell / Openbar / User Theme | Replaced by the Prism shell and compositor blur |
|
||||
| Bluetooth Quick Connect | Replaced by the full Bluetooth picker |
|
||||
| Wi-Fi QR | Deliberately omitted; it is not useful enough to justify another credential-reading surface |
|
||||
| GSConnect | No paired phone profile was present; KDE Connect is installed and awaiting pairing |
|
||||
| Home Assistant | Useful integration, but needs a purpose-built panel and secure credential migration |
|
||||
| GSConnect | Replaced by capability-aware KDE Connect phone continuity in Control Center; the paired iPhone exposes file, clipboard, and Ring actions when reachable |
|
||||
| Home Assistant | Replaced by secure favourites in Control Center; explicit private environment values take precedence over the existing GNOME extension and Secret Service setup |
|
||||
| Custom Hot Corners Extended | No action was configured, so there is no behavior to port |
|
||||
| App Hider | Hidden app list is now applied in Vicinae |
|
||||
| Allow Locked Remote Desktop | Mutter-specific patch; RustDesk is independent, but lock-screen access needs a real remote test |
|
||||
@@ -63,20 +64,11 @@ Last live audit: 2026-08-17, Fedora 44, Hyprland 0.56.2, Quickshell 0.3.0.
|
||||
These are not blockers for normal use, but they are the remaining places where
|
||||
a polished general-purpose desktop can go beyond the current shell.
|
||||
|
||||
1. Home Assistant favourites in a dedicated, secure quick panel. The token must
|
||||
stay in the keyring or a private state file, never in this repository.
|
||||
2. Phone integration through KDE Connect, including battery/share status in the
|
||||
tray. The package is installed and ready for a new pairing.
|
||||
3. A printer administration GUI. CUPS is already active; the
|
||||
`system-config-printer` package supplies the missing UI.
|
||||
4. A real external RustDesk test while hyprlock is active. Local process state
|
||||
1. A real external RustDesk test while hyprlock is active. Local process state
|
||||
cannot prove what a remote client sees or whether input is accepted.
|
||||
|
||||
Supporting packages tracked in `setup/packages/hyprland-packages`:
|
||||
|
||||
```sh
|
||||
sudo dnf install kde-connect system-config-printer tesseract zbar
|
||||
```
|
||||
KDE Connect, the printer UI, Tesseract, and ZBar are installed and remain
|
||||
tracked in `setup/packages/hyprland-packages`.
|
||||
|
||||
## Global menu decision
|
||||
|
||||
|
||||
@@ -213,6 +213,38 @@ before a timed event until 5 minutes after it starts. It displays relative time
|
||||
(`12m` or `Now`); the event title is available on hover but otherwise stays out
|
||||
of the bar. All-day events never trigger the capsule.
|
||||
|
||||
## Control Center
|
||||
|
||||
Click the right-side status cluster or press `SUPER + S` for Panama's Control
|
||||
Center. It is attached directly beneath the bar and keeps Wi-Fi, Bluetooth,
|
||||
Caffeine, Night Light, Focus, audio input/output, user, settings, and power in
|
||||
one place. Home and Phone continue the same surface rather than opening extra
|
||||
dashboard windows.
|
||||
|
||||
Home shows the first four configured Home Assistant favourites as direct
|
||||
controls and expands to the complete configured list. Panama reads the current
|
||||
GNOME Home Assistant extension configuration and its Secret Service token as a
|
||||
compatibility fallback, so the existing setup works without copying a secret.
|
||||
The preferred private configuration lives in the gitignored
|
||||
`config/bash/env` file:
|
||||
|
||||
```sh
|
||||
export PANAMA_HOME_ASSISTANT_URL=https://home.example.test
|
||||
export PANAMA_HOME_ASSISTANT_TOKEN=replace-with-a-long-lived-token
|
||||
export PANAMA_HOME_ASSISTANT_ENTITIES=light.kitchen,light.living_room
|
||||
```
|
||||
|
||||
The helper reads that file directly. No shell restart is required after editing
|
||||
it; close and reopen Control Center to refresh. If Home Assistant is offline,
|
||||
the last known values stay visible with a stale-state label and Retry action.
|
||||
|
||||
Phone uses KDE Connect for the capabilities the paired iPhone actually
|
||||
advertises: Send File, Send Clipboard, and Ring. The device remains visible
|
||||
while iOS suspends KDE Connect, but actions stay disabled until it reconnects.
|
||||
No battery percentage is invented when iOS reports none, and BlueBubbles
|
||||
remains the messaging experience. Active file sends appear in Daybook's
|
||||
Ongoing page; successful sends become a quiet recent exchange.
|
||||
|
||||
## Screen Intelligence
|
||||
|
||||
`SUPER + SHIFT + S` opens the capture picker directly in Selection + Read
|
||||
|
||||
@@ -78,6 +78,8 @@ Singleton {
|
||||
readonly property int popoverRadius: 18
|
||||
readonly property int popoverPadding: 14
|
||||
readonly property int popoverWidth: 380
|
||||
readonly property int controlCenterWidth: 430
|
||||
readonly property int controlCenterTopGap: 2
|
||||
|
||||
readonly property int cardRadius: 12
|
||||
readonly property int pillRadius: 999
|
||||
|
||||
@@ -10,6 +10,7 @@ import Quickshell.Networking
|
||||
import Quickshell.Services.Pipewire
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.widgets
|
||||
|
||||
Pill {
|
||||
@@ -106,4 +107,10 @@ Pill {
|
||||
glyph: "\u{F0176}" // md-coffee
|
||||
color: Theme.warn
|
||||
}
|
||||
|
||||
StatusGlyph {
|
||||
visible: KdeConnect.phoneReachable
|
||||
glyph: "\u{F03F2}" // md-cellphone-link
|
||||
color: Theme.cyan
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property string label: ""
|
||||
property string action: ""
|
||||
property bool actionEnabled: true
|
||||
signal actionTriggered
|
||||
|
||||
implicitHeight: 24
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: root.label.toUpperCase()
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
font.letterSpacing: 0.9
|
||||
}
|
||||
|
||||
Text {
|
||||
id: actionLabel
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: root.action !== ""
|
||||
text: root.action
|
||||
color: root.actionEnabled
|
||||
? (actionMouse.containsMouse ? Theme.accentAlt : Theme.accent)
|
||||
: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
|
||||
Behavior on color { ColorAnimation { duration: Theme.durFast } }
|
||||
|
||||
MouseArea {
|
||||
id: actionMouse
|
||||
anchors.fill: parent
|
||||
anchors.margins: -6
|
||||
enabled: root.actionEnabled
|
||||
hoverEnabled: true
|
||||
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: root.actionTriggered()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property bool expanded: false
|
||||
signal toggleExpanded
|
||||
|
||||
implicitHeight: content.implicitHeight
|
||||
|
||||
Column {
|
||||
id: content
|
||||
width: parent.width
|
||||
spacing: 6
|
||||
|
||||
ControlSectionHeader {
|
||||
width: parent.width
|
||||
label: "Home"
|
||||
action: HomeAssistant.entities.length > 0
|
||||
? HomeAssistant.entities.length + " accessories " + (root.expanded ? "⌃" : "›")
|
||||
: "Retry"
|
||||
actionEnabled: HomeAssistant.phase !== "loading"
|
||||
onActionTriggered: {
|
||||
if (HomeAssistant.entities.length > 0)
|
||||
root.toggleExpanded();
|
||||
else
|
||||
HomeAssistant.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: homeCard
|
||||
width: parent.width
|
||||
height: visible ? homeGrid.implicitHeight + 20 : 0
|
||||
visible: HomeAssistant.entities.length > 0
|
||||
radius: Theme.cardRadius + 2
|
||||
color: Theme.alpha(Theme.accent, 0.045)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.accent, 0.12)
|
||||
|
||||
Grid {
|
||||
id: homeGrid
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: 10
|
||||
columns: 4
|
||||
spacing: 7
|
||||
|
||||
Repeater {
|
||||
model: HomeAssistant.visibleEntities
|
||||
|
||||
HomeTile {
|
||||
required property var modelData
|
||||
width: (homeGrid.width - homeGrid.spacing * 3) / 4
|
||||
entity: modelData
|
||||
busy: HomeAssistant.busyEntityId === modelData.id
|
||||
onActivated: HomeAssistant.toggleEntity(modelData.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: visible ? 66 : 0
|
||||
visible: HomeAssistant.entities.length === 0
|
||||
radius: Theme.cardRadius
|
||||
color: Theme.alpha(Theme.fg, 0.05)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.045)
|
||||
|
||||
Text {
|
||||
id: stateIcon
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 12
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: HomeAssistant.phase === "loading" ? "\u{F0772}" : "\u{F02DC}"
|
||||
color: HomeAssistant.phase === "loading" ? Theme.accent : Theme.fgDim
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 16
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.left: stateIcon.right
|
||||
anchors.leftMargin: 11
|
||||
anchors.right: openHome.left
|
||||
anchors.rightMargin: 10
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 3
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.emptyTitle()
|
||||
color: Theme.fg
|
||||
elide: Text.ElideRight
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.emptyDetail()
|
||||
color: Theme.fgDim
|
||||
elide: Text.ElideRight
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: openHome
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 10
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 48
|
||||
height: 28
|
||||
radius: 9
|
||||
visible: HomeAssistant.phase !== "loading"
|
||||
color: openMouse.containsMouse
|
||||
? Theme.alpha(Theme.accent, 0.20)
|
||||
: Theme.alpha(Theme.accent, 0.11)
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "Open"
|
||||
color: Theme.accent
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
MouseArea {
|
||||
id: openMouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: HomeAssistant.open()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: visible ? 30 : 0
|
||||
visible: HomeAssistant.stale
|
||||
radius: 9
|
||||
color: Theme.alpha(Theme.warn, 0.08)
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 10
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Showing the last known state"
|
||||
color: Theme.warn
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
Text {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 10
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Retry"
|
||||
color: Theme.accent
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.Medium
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
anchors.margins: -6
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: HomeAssistant.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
width: parent.width
|
||||
expanded: root.expanded && HomeAssistant.entities.length > 0
|
||||
|
||||
ScrollColumn {
|
||||
width: parent.width
|
||||
maxHeight: 270
|
||||
spacing: 2
|
||||
|
||||
Repeater {
|
||||
model: HomeAssistant.entities
|
||||
|
||||
Rectangle {
|
||||
id: entityRow
|
||||
required property var modelData
|
||||
width: parent.width
|
||||
height: 44
|
||||
radius: 10
|
||||
color: entityMouse.containsMouse
|
||||
? Theme.alpha(Theme.fg, 0.09)
|
||||
: "transparent"
|
||||
|
||||
Text {
|
||||
id: entityGlyph
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 11
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "\u{F0335}"
|
||||
color: entityRow.modelData.active ? Theme.warn : Theme.fgDim
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 14
|
||||
}
|
||||
Column {
|
||||
anchors.left: entityGlyph.right
|
||||
anchors.leftMargin: 10
|
||||
anchors.right: entityState.left
|
||||
anchors.rightMargin: 8
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
Text {
|
||||
width: parent.width
|
||||
text: entityRow.modelData.name
|
||||
color: Theme.fg
|
||||
elide: Text.ElideRight
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: !entityRow.modelData.available ? "Unavailable" : entityRow.modelData.state
|
||||
color: Theme.fgMuted
|
||||
elide: Text.ElideRight
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
Text {
|
||||
id: entityState
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 11
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: HomeAssistant.busyEntityId === entityRow.modelData.id
|
||||
? "Working"
|
||||
: (entityRow.modelData.active ? "On" : "Off")
|
||||
color: entityRow.modelData.active ? Theme.warn : Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
MouseArea {
|
||||
id: entityMouse
|
||||
anchors.fill: parent
|
||||
enabled: entityRow.modelData.available && HomeAssistant.busyEntityId === ""
|
||||
hoverEnabled: true
|
||||
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: HomeAssistant.toggleEntity(entityRow.modelData.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function emptyTitle(): string {
|
||||
if (HomeAssistant.phase === "loading")
|
||||
return "Loading your home";
|
||||
if (HomeAssistant.lastError === "authentication-required")
|
||||
return "Authentication required";
|
||||
if (HomeAssistant.lastError === "not-configured")
|
||||
return "Home Assistant is not configured";
|
||||
return "Home Assistant is unavailable";
|
||||
}
|
||||
|
||||
function emptyDetail(): string {
|
||||
if (HomeAssistant.phase === "loading")
|
||||
return "Reading configured favourites";
|
||||
if (HomeAssistant.lastError === "authentication-required")
|
||||
return "Update the long-lived access token";
|
||||
if (HomeAssistant.lastError === "not-configured")
|
||||
return "Add a URL, token and favourites";
|
||||
return "Check the connection and retry";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Rectangle {
|
||||
id: root
|
||||
|
||||
required property var entity
|
||||
property bool busy: false
|
||||
signal activated
|
||||
|
||||
implicitHeight: 72
|
||||
radius: Theme.cardRadius
|
||||
color: {
|
||||
if (root.entity.active)
|
||||
return Theme.alpha(Theme.warn, tileMouse.containsMouse ? 0.20 : 0.14);
|
||||
return Theme.alpha(Theme.fg, tileMouse.containsMouse ? 0.10 : 0.055);
|
||||
}
|
||||
border.width: 1
|
||||
border.color: root.entity.active
|
||||
? Theme.alpha(Theme.warn, 0.20)
|
||||
: Theme.alpha(Theme.fg, 0.045)
|
||||
|
||||
Behavior on color { ColorAnimation { duration: Theme.durFast } }
|
||||
|
||||
Text {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 10
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 9
|
||||
text: root.busy ? "\u{F0772}" : root.glyphFor(root.entity.domain)
|
||||
color: root.entity.active ? Theme.warn : Theme.fgDim
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 15
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 10
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: 8
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: 8
|
||||
spacing: 1
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.entity.name
|
||||
color: Theme.fg
|
||||
elide: Text.ElideRight
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: !root.entity.available ? "Unavailable" : (root.entity.active ? "On" : "Off")
|
||||
color: root.entity.active ? Theme.warn : Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 9
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
id: tileMouse
|
||||
anchors.fill: parent
|
||||
enabled: root.entity.available && !root.busy
|
||||
hoverEnabled: true
|
||||
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: root.activated()
|
||||
}
|
||||
|
||||
function glyphFor(domain: string): string {
|
||||
if (domain === "light")
|
||||
return "\u{F0335}";
|
||||
if (domain === "switch")
|
||||
return "\u{F0521}";
|
||||
if (domain === "scene")
|
||||
return "\u{F0FCE}";
|
||||
return "\u{F02DC}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import QtQuick
|
||||
import QtQuick.Dialogs
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property bool expanded: false
|
||||
signal toggleExpanded
|
||||
|
||||
readonly property var phone: KdeConnect.preferredPhone
|
||||
readonly property var actionModels: [
|
||||
{ id: "share", glyph: "\u{F0142}", label: "Send file" },
|
||||
{ id: "clipboard", glyph: "\u{F014C}", label: "Clipboard" },
|
||||
{ id: "ring", glyph: "\u{F009A}", label: "Ring" }
|
||||
].filter(item => KdeConnect.supports(item.id))
|
||||
|
||||
implicitHeight: content.implicitHeight
|
||||
|
||||
Column {
|
||||
id: content
|
||||
width: parent.width
|
||||
spacing: 6
|
||||
|
||||
ControlSectionHeader {
|
||||
width: parent.width
|
||||
label: "Phone"
|
||||
action: root.phone ? (root.expanded ? "Details ⌃" : "Details ›") : "Refresh"
|
||||
onActionTriggered: {
|
||||
if (root.phone)
|
||||
root.toggleExpanded();
|
||||
else
|
||||
KdeConnect.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: phoneContent.implicitHeight + 22
|
||||
radius: Theme.cardRadius + 2
|
||||
color: Theme.alpha(Theme.fg, 0.055)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.05)
|
||||
|
||||
Column {
|
||||
id: phoneContent
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: 11
|
||||
spacing: 9
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 46
|
||||
|
||||
Rectangle {
|
||||
id: phoneSilhouette
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 29
|
||||
height: 43
|
||||
radius: 8
|
||||
color: Theme.alpha(Theme.accent, 0.07)
|
||||
border.width: 1
|
||||
border.color: Theme.alpha(Theme.fg, 0.42)
|
||||
|
||||
Rectangle {
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 4
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
width: 10
|
||||
height: 2
|
||||
radius: 1
|
||||
color: Theme.alpha(Theme.fg, 0.35)
|
||||
}
|
||||
Rectangle {
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: -3
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 10
|
||||
width: 6
|
||||
height: 6
|
||||
radius: 3
|
||||
visible: KdeConnect.phoneReachable
|
||||
color: Theme.ok
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.left: phoneSilhouette.right
|
||||
anchors.leftMargin: 11
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 3
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.phone?.name ?? (KdeConnect.available ? "No paired phone" : "KDE Connect unavailable")
|
||||
color: Theme.fg
|
||||
elide: Text.ElideRight
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.phone
|
||||
? (KdeConnect.phoneReachable ? "● Nearby on Wi-Fi" : "Not nearby")
|
||||
: (KdeConnect.available ? "Pair a device to continue" : "Open settings to repair the service")
|
||||
color: KdeConnect.phoneReachable ? Theme.ok : Theme.fgDim
|
||||
elide: Text.ElideRight
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Grid {
|
||||
id: actionsGrid
|
||||
width: parent.width
|
||||
columns: Math.max(1, root.actionModels.length)
|
||||
spacing: 7
|
||||
visible: root.actionModels.length > 0
|
||||
|
||||
Repeater {
|
||||
model: root.actionModels
|
||||
|
||||
Rectangle {
|
||||
id: actionButton
|
||||
required property var modelData
|
||||
width: (actionsGrid.width - actionsGrid.spacing * Math.max(0, root.actionModels.length - 1)) / Math.max(1, root.actionModels.length)
|
||||
height: 48
|
||||
radius: 11
|
||||
color: actionMouse.containsMouse && actionMouse.enabled
|
||||
? Theme.alpha(Theme.accent, 0.15)
|
||||
: Theme.alpha(Theme.accent, 0.075)
|
||||
opacity: actionMouse.enabled ? 1 : 0.48
|
||||
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.top: parent.top
|
||||
anchors.topMargin: 7
|
||||
text: actionButton.modelData.glyph
|
||||
color: Theme.accent
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 13
|
||||
}
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.bottomMargin: 6
|
||||
text: actionButton.modelData.label
|
||||
color: Theme.fg
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 9
|
||||
font.weight: Font.Medium
|
||||
}
|
||||
MouseArea {
|
||||
id: actionMouse
|
||||
anchors.fill: parent
|
||||
enabled: KdeConnect.phoneReachable && !KdeConnect.transferActive
|
||||
hoverEnabled: true
|
||||
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onClicked: root.invoke(actionButton.modelData.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.phone && !KdeConnect.phoneReachable && root.actionModels.length > 0
|
||||
text: "Actions become available when the iPhone reconnects"
|
||||
color: Theme.fgMuted
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: KdeConnect.lastError !== ""
|
||||
text: root.errorText(KdeConnect.lastError)
|
||||
color: Theme.warn
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: parent.width
|
||||
height: 1
|
||||
visible: KdeConnect.recentExchange !== null
|
||||
color: Theme.alpha(Theme.fg, 0.07)
|
||||
}
|
||||
|
||||
RecentExchange {
|
||||
width: parent.width
|
||||
height: visible ? implicitHeight : 0
|
||||
visible: KdeConnect.recentExchange !== null
|
||||
exchange: KdeConnect.recentExchange ?? ({})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
width: parent.width
|
||||
expanded: root.expanded && root.phone !== null
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 2
|
||||
|
||||
RowButton {
|
||||
width: parent.width
|
||||
icon: "network-wireless-symbolic"
|
||||
label: "Connection"
|
||||
sublabel: KdeConnect.phoneReachable ? "Nearby on Wi-Fi" : "Waiting for the phone"
|
||||
selected: KdeConnect.phoneReachable
|
||||
onClicked: KdeConnect.refresh()
|
||||
}
|
||||
RowButton {
|
||||
width: parent.width
|
||||
icon: "preferences-system-symbolic"
|
||||
label: "Device settings"
|
||||
sublabel: "Pairing, plugins and service health"
|
||||
onClicked: {
|
||||
ShellState.close();
|
||||
ShellState.openSettings("connectivity");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FileDialog {
|
||||
id: fileDialog
|
||||
title: root.phone ? "Send a file to " + root.phone.name : "Send a file"
|
||||
fileMode: FileDialog.OpenFile
|
||||
nameFilters: ["All files (*)"]
|
||||
onAccepted: {
|
||||
const path = root.localPath(selectedFile);
|
||||
if (path !== "")
|
||||
KdeConnect.sendFile(path);
|
||||
}
|
||||
}
|
||||
|
||||
function invoke(action: string): void {
|
||||
if (action === "share")
|
||||
fileDialog.open();
|
||||
else if (action === "clipboard")
|
||||
KdeConnect.sendClipboard();
|
||||
else if (action === "ring")
|
||||
KdeConnect.ring();
|
||||
}
|
||||
|
||||
function localPath(selectedUrl: url): string {
|
||||
const value = String(selectedUrl);
|
||||
if (!value.startsWith("file://"))
|
||||
return "";
|
||||
return decodeURIComponent(value.slice(7));
|
||||
}
|
||||
|
||||
function errorText(code: string): string {
|
||||
if (code === "device-offline")
|
||||
return "The iPhone is not nearby";
|
||||
if (code === "unsupported-action")
|
||||
return "That action is not available on this iPhone";
|
||||
return "The phone action did not finish";
|
||||
}
|
||||
}
|
||||
@@ -18,15 +18,21 @@ PanelWindow {
|
||||
visible: ShellState.quickSettingsOpen
|
||||
color: "transparent"
|
||||
|
||||
// Hangs off the top-right corner, clear of the floating bar.
|
||||
// Hangs directly beneath the top-right status cluster. Two logical pixels
|
||||
// preserve the Prism edge without making the surface feel detached.
|
||||
anchors.top: true
|
||||
anchors.right: true
|
||||
margins.top: Theme.barHeight + Theme.barGap * 2
|
||||
margins.top: Theme.barHeight + Theme.controlCenterTopGap
|
||||
margins.right: Theme.barSideMargin
|
||||
exclusiveZone: 0
|
||||
|
||||
implicitWidth: Theme.popoverWidth
|
||||
implicitWidth: Theme.controlCenterWidth
|
||||
implicitHeight: panel.implicitHeight
|
||||
readonly property string expandedSection: panel.expandedSection
|
||||
|
||||
function expand(name: string): void {
|
||||
panel.expand(name);
|
||||
}
|
||||
|
||||
// The `qs-popover` prefix is matched by a layerrule in hypr/rules.lua.
|
||||
WlrLayershell.namespace: "qs-popover-quicksettings"
|
||||
@@ -36,11 +42,14 @@ PanelWindow {
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
|
||||
|
||||
onVisibleChanged: {
|
||||
if (root.visible)
|
||||
if (root.visible) {
|
||||
KdeConnect.refresh();
|
||||
HomeAssistant.refresh();
|
||||
openAnim.restart();
|
||||
else
|
||||
} else {
|
||||
panel.reset();
|
||||
}
|
||||
}
|
||||
|
||||
// Closes the panel when a click lands anywhere else on the desktop.
|
||||
HyprlandFocusGrab {
|
||||
|
||||
@@ -18,7 +18,7 @@ import qs.widgets
|
||||
Item {
|
||||
id: root
|
||||
|
||||
implicitWidth: Theme.popoverWidth
|
||||
implicitWidth: Theme.controlCenterWidth
|
||||
implicitHeight: content.implicitHeight + Theme.popoverPadding * 2
|
||||
|
||||
// "" | "wifi" | "bluetooth" | "sink" | "source". Only one detail list is
|
||||
@@ -233,6 +233,19 @@ Item {
|
||||
width: content.width
|
||||
}
|
||||
|
||||
// ── Connected life ─────────────────────────────────────────────────
|
||||
HomeControls {
|
||||
width: content.width
|
||||
expanded: root.expandedSection === "home"
|
||||
onToggleExpanded: root.expand("home")
|
||||
}
|
||||
|
||||
PhoneControls {
|
||||
width: content.width
|
||||
expanded: root.expandedSection === "phone"
|
||||
onToggleExpanded: root.expand("phone")
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
width: content.width
|
||||
height: 1
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import QtQuick
|
||||
import qs.config
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
required property var exchange
|
||||
|
||||
implicitHeight: 40
|
||||
|
||||
Rectangle {
|
||||
width: 24
|
||||
height: 24
|
||||
radius: 8
|
||||
anchors.left: parent.left
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
color: Theme.alpha(Theme.ok, 0.10)
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: "\u{F012C}"
|
||||
color: Theme.ok
|
||||
font.family: Theme.fontMono
|
||||
font.pixelSize: 12
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: 34
|
||||
anchors.right: timeLabel.left
|
||||
anchors.rightMargin: 8
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 2
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: root.exchange.fileName || "File"
|
||||
color: Theme.fg
|
||||
elide: Text.ElideMiddle
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "Sent to " + (root.exchange.deviceName || "phone")
|
||||
color: Theme.fgMuted
|
||||
elide: Text.ElideRight
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 9
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
id: timeLabel
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Now"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: 9
|
||||
}
|
||||
}
|
||||
+434
@@ -0,0 +1,434 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Private Home Assistant REST boundary for the Panama Control Center."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
ENV_KEYS = (
|
||||
"PANAMA_HOME_ASSISTANT_URL",
|
||||
"PANAMA_HOME_ASSISTANT_TOKEN",
|
||||
"PANAMA_HOME_ASSISTANT_ENTITIES",
|
||||
)
|
||||
ENTITY_ID = re.compile(r"^[a-z_]+\.[a-z0-9_]+$")
|
||||
GNOME_EXTENSION = (
|
||||
pathlib.Path.home()
|
||||
/ ".local/share/gnome-shell/extensions/hass-gshell@geoph9-on-github"
|
||||
)
|
||||
GNOME_SCHEMA_DIR = GNOME_EXTENSION / "schemas"
|
||||
GNOME_SCHEMA = "org.gnome.shell.extensions.hass-data"
|
||||
PANAMA_ENV = pathlib.Path(__file__).resolve().parents[3] / "bash/env"
|
||||
Runner = Callable[..., subprocess.CompletedProcess[str]]
|
||||
|
||||
|
||||
class BridgeError(RuntimeError):
|
||||
"""An error code safe to pass across the shell boundary."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Config:
|
||||
base_url: str
|
||||
token: str
|
||||
entity_ids: tuple[str, ...]
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self.base_url and self.token and self.entity_ids)
|
||||
|
||||
|
||||
def compact_json(value: dict[str, object]) -> str:
|
||||
return json.dumps(value, separators=(",", ":"))
|
||||
|
||||
|
||||
def read_panama_env(path: pathlib.Path = PANAMA_ENV) -> dict[str, str]:
|
||||
values: dict[str, str] = {}
|
||||
try:
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
except OSError:
|
||||
return values
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
if stripped.startswith("export "):
|
||||
stripped = stripped[7:].lstrip()
|
||||
if "=" not in stripped:
|
||||
continue
|
||||
key, raw_value = stripped.split("=", 1)
|
||||
key = key.strip()
|
||||
if key not in ENV_KEYS:
|
||||
continue
|
||||
try:
|
||||
parsed = shlex.split(raw_value, comments=True, posix=True)
|
||||
except ValueError:
|
||||
continue
|
||||
if len(parsed) == 1:
|
||||
values[key] = parsed[0]
|
||||
return values
|
||||
|
||||
|
||||
def normalize_url(value: str) -> str:
|
||||
value = value.strip().rstrip("/")
|
||||
if not value:
|
||||
return ""
|
||||
parsed = urllib.parse.urlsplit(value)
|
||||
if (
|
||||
parsed.scheme not in {"http", "https"}
|
||||
or not parsed.hostname
|
||||
or parsed.username
|
||||
or parsed.password
|
||||
):
|
||||
raise ValueError("invalid-url")
|
||||
return value
|
||||
|
||||
|
||||
def parse_entity_ids(value: str | Sequence[str]) -> tuple[str, ...]:
|
||||
candidates = value.split(",") if isinstance(value, str) else value
|
||||
result: list[str] = []
|
||||
for candidate in candidates:
|
||||
entity_id = str(candidate).strip()
|
||||
if not entity_id:
|
||||
continue
|
||||
if not ENTITY_ID.fullmatch(entity_id):
|
||||
raise ValueError("invalid-entity")
|
||||
if entity_id not in result:
|
||||
result.append(entity_id)
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def parse_gsettings_string(output: str) -> str:
|
||||
try:
|
||||
value = ast.literal_eval(output.strip())
|
||||
except (SyntaxError, ValueError):
|
||||
return ""
|
||||
return value if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def parse_gsettings_array(output: str) -> tuple[str, ...]:
|
||||
stripped = output.strip()
|
||||
if stripped.startswith("@as "):
|
||||
stripped = stripped[4:].strip()
|
||||
try:
|
||||
value = ast.literal_eval(stripped)
|
||||
except (SyntaxError, ValueError):
|
||||
return ()
|
||||
if not isinstance(value, (list, tuple)):
|
||||
return ()
|
||||
return tuple(item for item in value if isinstance(item, str))
|
||||
|
||||
|
||||
def run_command(
|
||||
command: list[str],
|
||||
*,
|
||||
runner: Runner = subprocess.run,
|
||||
timeout: float = 5,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return runner(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def gsettings_value(key: str, runner: Runner = subprocess.run) -> str:
|
||||
if not GNOME_SCHEMA_DIR.is_dir():
|
||||
return ""
|
||||
try:
|
||||
result = run_command(
|
||||
[
|
||||
"gsettings",
|
||||
"--schemadir",
|
||||
str(GNOME_SCHEMA_DIR),
|
||||
"get",
|
||||
GNOME_SCHEMA,
|
||||
key,
|
||||
],
|
||||
runner=runner,
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return ""
|
||||
return result.stdout.strip() if result.returncode == 0 else ""
|
||||
|
||||
|
||||
def secret_service_token(runner: Runner = subprocess.run) -> str:
|
||||
try:
|
||||
result = run_command(
|
||||
["secret-tool", "lookup", "token_string", "user_token"],
|
||||
runner=runner,
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return ""
|
||||
return result.stdout.strip() if result.returncode == 0 else ""
|
||||
|
||||
|
||||
def load_legacy_config(runner: Runner = subprocess.run) -> Config:
|
||||
base_url = parse_gsettings_string(gsettings_value("hass-url", runner))
|
||||
entity_ids = parse_gsettings_array(
|
||||
gsettings_value("hass-enabled-entities", runner)
|
||||
)
|
||||
token = secret_service_token(runner)
|
||||
return Config(base_url=base_url, token=token, entity_ids=entity_ids)
|
||||
|
||||
|
||||
def resolve_config(
|
||||
env: Mapping[str, str] | None = None,
|
||||
legacy: Callable[[], Config] = load_legacy_config,
|
||||
) -> Config:
|
||||
private_env = read_panama_env()
|
||||
private_env.update(dict(os.environ if env is None else env))
|
||||
|
||||
url_value = private_env.get("PANAMA_HOME_ASSISTANT_URL", "").strip()
|
||||
token_value = private_env.get("PANAMA_HOME_ASSISTANT_TOKEN", "").strip()
|
||||
entities_value = private_env.get("PANAMA_HOME_ASSISTANT_ENTITIES", "").strip()
|
||||
|
||||
legacy_config = Config("", "", ())
|
||||
if not (url_value and token_value and entities_value):
|
||||
try:
|
||||
legacy_config = legacy()
|
||||
except (OSError, subprocess.SubprocessError, ValueError):
|
||||
legacy_config = Config("", "", ())
|
||||
|
||||
base_url = normalize_url(url_value or legacy_config.base_url)
|
||||
token = token_value or legacy_config.token
|
||||
entity_ids = (
|
||||
parse_entity_ids(entities_value)
|
||||
if entities_value
|
||||
else parse_entity_ids(legacy_config.entity_ids)
|
||||
)
|
||||
return Config(base_url=base_url, token=token, entity_ids=entity_ids)
|
||||
|
||||
|
||||
def public_http_error(status: int, body: str) -> str:
|
||||
del body
|
||||
return "authentication-required" if status in {401, 403} else "request-failed"
|
||||
|
||||
|
||||
def request_json(
|
||||
config: Config,
|
||||
method: str,
|
||||
path: str,
|
||||
payload: dict[str, str] | None = None,
|
||||
) -> object:
|
||||
if not config.base_url or not config.token:
|
||||
raise BridgeError("not-configured")
|
||||
body = None
|
||||
if payload is not None:
|
||||
body = json.dumps(payload, separators=(",", ":")).encode()
|
||||
request = urllib.request.Request(
|
||||
config.base_url + path,
|
||||
data=body,
|
||||
method=method,
|
||||
headers={
|
||||
"Authorization": f"Bearer {config.token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=5) as response:
|
||||
response_body = response.read()
|
||||
except urllib.error.HTTPError as error:
|
||||
try:
|
||||
error_body = error.read().decode(errors="replace")
|
||||
except OSError:
|
||||
error_body = ""
|
||||
raise BridgeError(public_http_error(error.code, error_body)) from None
|
||||
except (TimeoutError, urllib.error.URLError, OSError):
|
||||
raise BridgeError("unreachable") from None
|
||||
|
||||
if not response_body:
|
||||
return None
|
||||
try:
|
||||
return json.loads(response_body)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
raise BridgeError("invalid-response") from None
|
||||
|
||||
|
||||
def fallback_name(entity_id: str) -> str:
|
||||
return entity_id.split(".", 1)[1].replace("_", " ").title()
|
||||
|
||||
|
||||
def normalize_entities(
|
||||
raw: list[dict[str, Any]],
|
||||
configured: Sequence[str],
|
||||
) -> list[dict[str, object]]:
|
||||
by_id = {
|
||||
str(item.get("entity_id", "")): item
|
||||
for item in raw
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
result: list[dict[str, object]] = []
|
||||
for entity_id in configured:
|
||||
item = by_id.get(entity_id)
|
||||
if not item:
|
||||
continue
|
||||
attributes = item.get("attributes")
|
||||
if not isinstance(attributes, dict):
|
||||
continue
|
||||
state = str(item.get("state", "unavailable"))
|
||||
available = state not in {"unknown", "unavailable"}
|
||||
friendly_name = attributes.get("friendly_name")
|
||||
name = (
|
||||
friendly_name.strip()
|
||||
if isinstance(friendly_name, str) and friendly_name.strip()
|
||||
else fallback_name(entity_id)
|
||||
)
|
||||
result.append(
|
||||
{
|
||||
"id": entity_id,
|
||||
"name": name,
|
||||
"domain": entity_id.split(".", 1)[0],
|
||||
"state": state,
|
||||
"available": available,
|
||||
"active": available
|
||||
and state not in {"off", "closed", "idle", "standby"},
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def ensure_configured(entity_id: str, configured: Sequence[str]) -> str:
|
||||
if entity_id not in configured:
|
||||
raise ValueError("entity-not-configured")
|
||||
return entity_id
|
||||
|
||||
|
||||
def collect_snapshot(config: Config) -> dict[str, object]:
|
||||
if not config.configured:
|
||||
return {
|
||||
"ok": False,
|
||||
"configured": False,
|
||||
"generatedAt": int(time.time()),
|
||||
"entities": [],
|
||||
"error": "not-configured",
|
||||
}
|
||||
try:
|
||||
raw = request_json(config, "GET", "/api/states")
|
||||
if not isinstance(raw, list):
|
||||
raise BridgeError("invalid-response")
|
||||
entities = normalize_entities(raw, config.entity_ids)
|
||||
except BridgeError as error:
|
||||
return {
|
||||
"ok": False,
|
||||
"configured": True,
|
||||
"generatedAt": int(time.time()),
|
||||
"entities": [],
|
||||
"error": str(error),
|
||||
}
|
||||
return {
|
||||
"ok": True,
|
||||
"configured": True,
|
||||
"generatedAt": int(time.time()),
|
||||
"entities": entities,
|
||||
"error": "",
|
||||
}
|
||||
|
||||
|
||||
def probe(config: Config) -> dict[str, object]:
|
||||
if not config.configured:
|
||||
return {
|
||||
"configured": False,
|
||||
"reachable": False,
|
||||
"entityCount": len(config.entity_ids),
|
||||
"error": "not-configured",
|
||||
}
|
||||
try:
|
||||
request_json(config, "GET", "/api/")
|
||||
except BridgeError as error:
|
||||
return {
|
||||
"configured": True,
|
||||
"reachable": False,
|
||||
"entityCount": len(config.entity_ids),
|
||||
"error": str(error),
|
||||
}
|
||||
return {
|
||||
"configured": True,
|
||||
"reachable": True,
|
||||
"entityCount": len(config.entity_ids),
|
||||
"error": "",
|
||||
}
|
||||
|
||||
|
||||
def toggle(config: Config, entity_id: str) -> dict[str, object]:
|
||||
entity_id = ensure_configured(entity_id, config.entity_ids)
|
||||
request_json(
|
||||
config,
|
||||
"POST",
|
||||
"/api/services/homeassistant/toggle",
|
||||
{"entity_id": entity_id},
|
||||
)
|
||||
return {"ok": True, "entityId": entity_id, "error": ""}
|
||||
|
||||
|
||||
def open_home(config: Config) -> dict[str, object]:
|
||||
if not config.base_url:
|
||||
return {"ok": False, "error": "not-configured"}
|
||||
try:
|
||||
subprocess.Popen(
|
||||
["xdg-open", config.base_url],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
except OSError:
|
||||
return {"ok": False, "error": "open-failed"}
|
||||
return {"ok": True, "error": ""}
|
||||
|
||||
|
||||
def print_result(value: dict[str, object]) -> None:
|
||||
sys.stdout.write(compact_json(value) + "\n")
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
try:
|
||||
config = resolve_config()
|
||||
except ValueError as error:
|
||||
print_result({"ok": False, "configured": False, "error": str(error)})
|
||||
return 2
|
||||
|
||||
command = argv[0] if argv else "probe"
|
||||
if command == "probe" and len(argv) <= 1:
|
||||
result = probe(config)
|
||||
success = bool(result["reachable"])
|
||||
elif command == "snapshot" and len(argv) == 1:
|
||||
result = collect_snapshot(config)
|
||||
success = bool(result["ok"])
|
||||
elif command == "toggle" and len(argv) == 2:
|
||||
try:
|
||||
result = toggle(config, argv[1])
|
||||
except ValueError as error:
|
||||
result = {"ok": False, "error": str(error)}
|
||||
except BridgeError as error:
|
||||
result = {"ok": False, "error": str(error)}
|
||||
success = bool(result["ok"])
|
||||
elif command == "open" and len(argv) == 1:
|
||||
result = open_home(config)
|
||||
success = bool(result["ok"])
|
||||
else:
|
||||
result = {"ok": False, "error": "invalid-command"}
|
||||
success = False
|
||||
print_result(result)
|
||||
return 0 if success else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
+306
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Capability-aware KDE Connect boundary for the Panama shell."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
|
||||
|
||||
DEVICE_ID = re.compile(r"^[A-Fa-f0-9]{32,64}$")
|
||||
DEVICE_LINE = re.compile(
|
||||
r"^-\s+(?P<name>.+):\s+(?P<id>[A-Fa-f0-9]{32,64})"
|
||||
r"(?:\s+on\s+.+?)?\s+\((?P<state>[^)]+)\)\s*$"
|
||||
)
|
||||
PLUGIN_ACTIONS = {
|
||||
"kdeconnect_clipboard": "clipboard",
|
||||
"kdeconnect_findmyphone": "ring",
|
||||
"kdeconnect_ping": "ping",
|
||||
"kdeconnect_share": "share",
|
||||
}
|
||||
DEVICE_OBJECT_PREFIX = "/modules/kdeconnect/devices"
|
||||
Runner = Callable[..., subprocess.CompletedProcess[str]]
|
||||
|
||||
|
||||
def compact_json(value: dict[str, object]) -> str:
|
||||
return json.dumps(value, separators=(",", ":"))
|
||||
|
||||
|
||||
def validate_device_id(value: str) -> str:
|
||||
if not DEVICE_ID.fullmatch(value):
|
||||
raise ValueError("invalid-device")
|
||||
return value
|
||||
|
||||
|
||||
def validate_file(value: pathlib.Path) -> pathlib.Path:
|
||||
try:
|
||||
resolved = value.expanduser().resolve(strict=True)
|
||||
except OSError as error:
|
||||
raise ValueError("invalid-file") from error
|
||||
if not resolved.is_file():
|
||||
raise ValueError("invalid-file")
|
||||
return resolved
|
||||
|
||||
|
||||
def inferred_type(name: str) -> str:
|
||||
normalized = name.casefold()
|
||||
if "iphone" in normalized or "phone" in normalized:
|
||||
return "phone"
|
||||
if "tablet" in normalized or "ipad" in normalized:
|
||||
return "tablet"
|
||||
return "device"
|
||||
|
||||
|
||||
def normalize_device_line(
|
||||
line: str,
|
||||
plugins: list[str],
|
||||
device_type: str = "",
|
||||
) -> dict[str, object]:
|
||||
match = DEVICE_LINE.fullmatch(line.strip())
|
||||
if match is None:
|
||||
raise ValueError("invalid-device-line")
|
||||
|
||||
state = match.group("state").casefold()
|
||||
actions = sorted(
|
||||
{
|
||||
action
|
||||
for plugin, action in PLUGIN_ACTIONS.items()
|
||||
if plugin in plugins
|
||||
}
|
||||
)
|
||||
name = match.group("name").strip()
|
||||
return {
|
||||
"id": match.group("id"),
|
||||
"name": name,
|
||||
"type": device_type or inferred_type(name),
|
||||
"paired": "paired" in state,
|
||||
"reachable": "reachable" in state,
|
||||
"actions": actions,
|
||||
}
|
||||
|
||||
|
||||
def parse_loaded_plugins(output: str) -> list[str]:
|
||||
try:
|
||||
parts = shlex.split(output)
|
||||
except ValueError:
|
||||
return []
|
||||
if len(parts) < 2 or parts[0] != "as":
|
||||
return []
|
||||
try:
|
||||
count = int(parts[1])
|
||||
except ValueError:
|
||||
return []
|
||||
return parts[2 : 2 + max(0, count)]
|
||||
|
||||
|
||||
def parse_string_property(output: str) -> str:
|
||||
try:
|
||||
parts = shlex.split(output)
|
||||
except ValueError:
|
||||
return ""
|
||||
return parts[1] if len(parts) == 2 and parts[0] == "s" else ""
|
||||
|
||||
|
||||
def run_command(
|
||||
command: list[str],
|
||||
*,
|
||||
runner: Runner = subprocess.run,
|
||||
timeout: float = 8,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
return runner(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def device_object(device_id: str) -> str:
|
||||
return f"{DEVICE_OBJECT_PREFIX}/{validate_device_id(device_id)}"
|
||||
|
||||
|
||||
def loaded_plugins(device_id: str, runner: Runner = subprocess.run) -> list[str]:
|
||||
result = run_command(
|
||||
[
|
||||
"busctl",
|
||||
"--user",
|
||||
"call",
|
||||
"org.kde.kdeconnect",
|
||||
device_object(device_id),
|
||||
"org.kde.kdeconnect.device",
|
||||
"loadedPlugins",
|
||||
],
|
||||
runner=runner,
|
||||
)
|
||||
return parse_loaded_plugins(result.stdout) if result.returncode == 0 else []
|
||||
|
||||
|
||||
def supported_plugins(device_id: str, runner: Runner = subprocess.run) -> list[str]:
|
||||
result = run_command(
|
||||
[
|
||||
"busctl",
|
||||
"--user",
|
||||
"get-property",
|
||||
"org.kde.kdeconnect",
|
||||
device_object(device_id),
|
||||
"org.kde.kdeconnect.device",
|
||||
"supportedPlugins",
|
||||
],
|
||||
runner=runner,
|
||||
)
|
||||
return parse_loaded_plugins(result.stdout) if result.returncode == 0 else []
|
||||
|
||||
|
||||
def device_plugins(device_id: str, runner: Runner = subprocess.run) -> list[str]:
|
||||
active = loaded_plugins(device_id, runner)
|
||||
return active if active else supported_plugins(device_id, runner)
|
||||
|
||||
|
||||
def reported_type(device_id: str, runner: Runner = subprocess.run) -> str:
|
||||
result = run_command(
|
||||
[
|
||||
"busctl",
|
||||
"--user",
|
||||
"get-property",
|
||||
"org.kde.kdeconnect",
|
||||
device_object(device_id),
|
||||
"org.kde.kdeconnect.device",
|
||||
"type",
|
||||
],
|
||||
runner=runner,
|
||||
)
|
||||
return parse_string_property(result.stdout) if result.returncode == 0 else ""
|
||||
|
||||
|
||||
def collect_status(runner: Runner = subprocess.run) -> dict[str, object]:
|
||||
try:
|
||||
listing = run_command(
|
||||
["kdeconnect-cli", "--list-devices"],
|
||||
runner=runner,
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return {"available": False, "devices": [], "error": "unavailable"}
|
||||
|
||||
if listing.returncode != 0:
|
||||
return {"available": False, "devices": [], "error": "unavailable"}
|
||||
|
||||
devices: list[dict[str, object]] = []
|
||||
for line in listing.stdout.splitlines():
|
||||
if not line.lstrip().startswith("-"):
|
||||
continue
|
||||
match = DEVICE_LINE.fullmatch(line.strip())
|
||||
if match is None:
|
||||
continue
|
||||
device_id = match.group("id")
|
||||
try:
|
||||
device = normalize_device_line(
|
||||
line,
|
||||
device_plugins(device_id, runner),
|
||||
reported_type(device_id, runner),
|
||||
)
|
||||
except ValueError:
|
||||
continue
|
||||
devices.append(device)
|
||||
|
||||
devices.sort(
|
||||
key=lambda device: (
|
||||
not bool(device["reachable"]),
|
||||
not bool(device["paired"]),
|
||||
str(device["name"]).casefold(),
|
||||
)
|
||||
)
|
||||
return {"available": True, "devices": devices, "error": ""}
|
||||
|
||||
|
||||
def action_command(
|
||||
action: str,
|
||||
device_id: str,
|
||||
file_path: pathlib.Path | None = None,
|
||||
) -> list[str]:
|
||||
device_id = validate_device_id(device_id)
|
||||
options = {
|
||||
"ring": ["--ring"],
|
||||
"clipboard": ["--send-clipboard"],
|
||||
}
|
||||
if action == "share" and file_path is not None:
|
||||
return [
|
||||
"kdeconnect-cli",
|
||||
"-d",
|
||||
device_id,
|
||||
"--share",
|
||||
str(validate_file(file_path)),
|
||||
]
|
||||
if action not in options:
|
||||
raise ValueError("unsupported-action")
|
||||
return ["kdeconnect-cli", "-d", device_id, *options[action]]
|
||||
|
||||
|
||||
def invoke_action(
|
||||
action: str,
|
||||
device_id: str,
|
||||
file_path: pathlib.Path | None = None,
|
||||
runner: Runner = subprocess.run,
|
||||
) -> dict[str, object]:
|
||||
device_id = validate_device_id(device_id)
|
||||
status = collect_status(runner)
|
||||
device = next(
|
||||
(
|
||||
item
|
||||
for item in status["devices"]
|
||||
if isinstance(item, dict) and item.get("id") == device_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not device or not device.get("paired") or not device.get("reachable"):
|
||||
return {"ok": False, "action": action, "error": "device-offline"}
|
||||
if action not in device.get("actions", []):
|
||||
return {"ok": False, "action": action, "error": "unsupported-action"}
|
||||
|
||||
command = action_command(action, device_id, file_path)
|
||||
try:
|
||||
result = run_command(command, runner=runner, timeout=3600)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return {"ok": False, "action": action, "error": "action-failed"}
|
||||
if result.returncode != 0:
|
||||
return {"ok": False, "action": action, "error": "action-failed"}
|
||||
|
||||
response: dict[str, object] = {"ok": True, "action": action, "error": ""}
|
||||
if action == "share" and file_path is not None:
|
||||
response["fileName"] = validate_file(file_path).name
|
||||
return response
|
||||
|
||||
|
||||
def print_result(value: dict[str, object]) -> None:
|
||||
sys.stdout.write(compact_json(value) + "\n")
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
if not argv or argv[0] == "status":
|
||||
print_result(collect_status())
|
||||
return 0
|
||||
|
||||
command = argv[0]
|
||||
try:
|
||||
if command == "ring" and len(argv) == 2:
|
||||
result = invoke_action("ring", argv[1])
|
||||
elif command == "send-clipboard" and len(argv) == 2:
|
||||
result = invoke_action("clipboard", argv[1])
|
||||
elif command == "send-file" and len(argv) == 3:
|
||||
result = invoke_action("share", argv[1], pathlib.Path(argv[2]))
|
||||
else:
|
||||
result = {"ok": False, "action": command, "error": "invalid-command"}
|
||||
except ValueError as error:
|
||||
result = {"ok": False, "action": command, "error": str(error)}
|
||||
print_result(result)
|
||||
return 0 if bool(result.get("ok")) else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@@ -16,7 +16,8 @@ Singleton {
|
||||
property bool kdeInitialized: false
|
||||
property string previousOutput: ""
|
||||
property string previousBluetooth: ""
|
||||
property string previousKde: ""
|
||||
property bool previousKdeReachable: false
|
||||
property string previousKdeName: ""
|
||||
|
||||
PwObjectTracker { objects: [Pipewire.defaultAudioSink] }
|
||||
|
||||
@@ -33,6 +34,8 @@ Singleton {
|
||||
.join("\u001f");
|
||||
}
|
||||
|
||||
readonly property string kdeName: KdeConnect.preferredPhone?.name ?? ""
|
||||
|
||||
onOutputNameChanged: {
|
||||
if (!root.outputInitialized) {
|
||||
root.previousOutput = root.outputName;
|
||||
@@ -78,55 +81,41 @@ Singleton {
|
||||
onTriggered: {
|
||||
root.previousOutput = root.outputName;
|
||||
root.previousBluetooth = root.bluetoothNames;
|
||||
root.previousKdeReachable = KdeConnect.phoneReachable;
|
||||
root.previousKdeName = root.kdeName;
|
||||
root.outputInitialized = true;
|
||||
root.bluetoothInitialized = true;
|
||||
root.kdeInitialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 15000
|
||||
repeat: true
|
||||
running: true
|
||||
triggeredOnStart: true
|
||||
onTriggered: {
|
||||
if (!kdeProbe.running)
|
||||
kdeProbe.running = true;
|
||||
}
|
||||
}
|
||||
Connections {
|
||||
target: KdeConnect
|
||||
|
||||
Process {
|
||||
id: kdeProbe
|
||||
command: ["kdeconnect-cli", "-a", "--name-only"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const names = this.text.split("\n")
|
||||
.map(line => line.trim())
|
||||
.filter(line => line && !/^\d+ devices? found$/.test(line))
|
||||
.sort()
|
||||
.join("\u001f");
|
||||
if (root.kdeInitialized && names !== root.previousKde) {
|
||||
const current = names ? names.split("\u001f") : [];
|
||||
const previous = root.previousKde ? root.previousKde.split("\u001f") : [];
|
||||
const connected = current.find(name => !previous.includes(name));
|
||||
const disconnected = previous.find(name => !current.includes(name));
|
||||
function onPhoneReachableChanged(): void {
|
||||
if (!root.kdeInitialized)
|
||||
return;
|
||||
const reachable = KdeConnect.phoneReachable;
|
||||
if (reachable === root.previousKdeReachable)
|
||||
return;
|
||||
StatusEvents.publish({
|
||||
key: "device-kdeconnect",
|
||||
glyph: "\u{F03F2}",
|
||||
title: connected || disconnected || "Phone",
|
||||
detail: connected ? "KDE Connect available" : "KDE Connect disconnected",
|
||||
tone: connected ? "accent" : "warn",
|
||||
title: root.kdeName || root.previousKdeName || "Phone",
|
||||
detail: reachable ? "KDE Connect available" : "KDE Connect disconnected",
|
||||
tone: reachable ? "accent" : "warn",
|
||||
priority: StatusEvents.ambientPriority
|
||||
});
|
||||
}
|
||||
root.previousKde = names;
|
||||
root.kdeInitialized = true;
|
||||
}
|
||||
root.previousKdeReachable = reachable;
|
||||
root.previousKdeName = root.kdeName || root.previousKdeName;
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
root.previousOutput = root.outputName;
|
||||
root.previousBluetooth = root.bluetoothNames;
|
||||
root.previousKdeReachable = KdeConnect.phoneReachable;
|
||||
root.previousKdeName = root.kdeName;
|
||||
discoverySettle.start();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
pragma Singleton
|
||||
|
||||
// Home Assistant state for the Control Center. Credentials and REST details
|
||||
// remain behind the helper; QML receives configured favourites only.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-home-assistant"
|
||||
|
||||
// "loading" | "ready" | "degraded" | "unavailable"
|
||||
property string phase: "loading"
|
||||
property var entities: []
|
||||
property bool stale: false
|
||||
property string busyEntityId: ""
|
||||
property string lastError: ""
|
||||
property bool fixtureMode: false
|
||||
|
||||
readonly property var visibleEntities: root.entities.slice(0, 4)
|
||||
readonly property int configuredCount: root.entities.length
|
||||
|
||||
function refresh(): void {
|
||||
if (root.fixtureMode || refreshProc.running)
|
||||
return;
|
||||
if (root.entities.length === 0)
|
||||
root.phase = "loading";
|
||||
refreshProc.running = true;
|
||||
}
|
||||
|
||||
function consumeSnapshot(text: string): void {
|
||||
if (root.fixtureMode)
|
||||
return;
|
||||
let result = null;
|
||||
try {
|
||||
result = JSON.parse(text);
|
||||
} catch (error) {
|
||||
result = { ok: false, error: "invalid-response" };
|
||||
}
|
||||
|
||||
if (result.ok === true) {
|
||||
root.entities = Array.isArray(result.entities) ? result.entities : [];
|
||||
root.phase = "ready";
|
||||
root.stale = false;
|
||||
root.lastError = "";
|
||||
return;
|
||||
}
|
||||
|
||||
root.lastError = String(result.error || "unreachable");
|
||||
if (root.entities.length > 0) {
|
||||
root.phase = "degraded";
|
||||
root.stale = true;
|
||||
} else {
|
||||
root.phase = "unavailable";
|
||||
root.stale = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleEntity(entityId: string): void {
|
||||
if (entityId === "" || root.busyEntityId !== "")
|
||||
return;
|
||||
if (root.fixtureMode) {
|
||||
root.entities = root.entities.map(entity => {
|
||||
if (entity.id !== entityId)
|
||||
return entity;
|
||||
return Object.assign({}, entity, {
|
||||
active: !entity.active,
|
||||
state: entity.active ? "off" : "on"
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!root.entities.some(entity => entity.id === entityId))
|
||||
return;
|
||||
root.busyEntityId = entityId;
|
||||
actionProc.command = [root.helperPath, "toggle", entityId];
|
||||
actionProc.running = true;
|
||||
}
|
||||
|
||||
function consumeAction(text: string): void {
|
||||
if (root.busyEntityId === "")
|
||||
return;
|
||||
let ok = false;
|
||||
let errorCode = "action-failed";
|
||||
try {
|
||||
const result = JSON.parse(text);
|
||||
ok = result.ok === true;
|
||||
errorCode = String(result.error || errorCode);
|
||||
} catch (error) {
|
||||
errorCode = "invalid-response";
|
||||
}
|
||||
root.busyEntityId = "";
|
||||
if (ok) {
|
||||
root.lastError = "";
|
||||
refreshDelay.restart();
|
||||
} else {
|
||||
root.lastError = errorCode;
|
||||
if (root.entities.length > 0) {
|
||||
root.phase = "degraded";
|
||||
root.stale = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function open(): void {
|
||||
Quickshell.execDetached([root.helperPath, "open"]);
|
||||
}
|
||||
|
||||
function fixtureEntities(): var {
|
||||
return [
|
||||
{ id: "light.fixture_all", name: "All lights", domain: "light", state: "on", available: true, active: true },
|
||||
{ id: "light.fixture_kitchen", name: "Kitchen", domain: "light", state: "on", available: true, active: true },
|
||||
{ id: "light.fixture_living", name: "Living room", domain: "light", state: "off", available: true, active: false },
|
||||
{ id: "light.fixture_bedroom", name: "Bedroom", domain: "light", state: "on", available: true, active: true },
|
||||
{ id: "light.fixture_hall", name: "Hall", domain: "light", state: "off", available: true, active: false },
|
||||
{ id: "light.fixture_desk", name: "Desk", domain: "light", state: "off", available: true, active: false },
|
||||
{ id: "light.fixture_corner", name: "Corner lamp", domain: "light", state: "unavailable", available: false, active: false }
|
||||
];
|
||||
}
|
||||
|
||||
function applyFixture(name: string): void {
|
||||
if (["ready", "stale", "unavailable"].indexOf(name) < 0)
|
||||
return;
|
||||
root.fixtureMode = true;
|
||||
root.busyEntityId = "";
|
||||
if (name === "unavailable") {
|
||||
root.entities = [];
|
||||
root.phase = "unavailable";
|
||||
root.stale = false;
|
||||
root.lastError = "not-configured";
|
||||
return;
|
||||
}
|
||||
root.entities = root.fixtureEntities();
|
||||
root.phase = name === "stale" ? "degraded" : "ready";
|
||||
root.stale = name === "stale";
|
||||
root.lastError = name === "stale" ? "unreachable" : "";
|
||||
}
|
||||
|
||||
function clearFixture(): void {
|
||||
root.fixtureMode = false;
|
||||
root.phase = "loading";
|
||||
root.entities = [];
|
||||
root.stale = false;
|
||||
root.busyEntityId = "";
|
||||
root.lastError = "";
|
||||
root.refresh();
|
||||
}
|
||||
|
||||
Process {
|
||||
id: refreshProc
|
||||
command: [root.helperPath, "snapshot"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.consumeSnapshot(this.text)
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: actionProc
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.consumeAction(this.text)
|
||||
}
|
||||
onExited: (code, status) => {
|
||||
if (root.busyEntityId !== "")
|
||||
root.consumeAction("");
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 60000
|
||||
repeat: true
|
||||
running: ShellState.quickSettingsOpen && !root.fixtureMode
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: refreshDelay
|
||||
interval: 350
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
|
||||
Component.onCompleted: root.refresh()
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
pragma Singleton
|
||||
|
||||
// Shared KDE Connect state. The Python helper owns all CLI and D-Bus parsing;
|
||||
// this singleton exposes only normalized devices and user-initiated actions.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-kdeconnect"
|
||||
|
||||
property bool fixtureMode: false
|
||||
property bool available: false
|
||||
property var devices: []
|
||||
property string lastError: ""
|
||||
|
||||
property bool transferActive: false
|
||||
property string transferFileName: ""
|
||||
property string transferDeviceName: ""
|
||||
property var recentExchange: null
|
||||
|
||||
property string actionKind: ""
|
||||
property string actionPath: ""
|
||||
|
||||
readonly property var preferredPhone: {
|
||||
const phones = root.devices.filter(device => device.type === "phone" && device.paired);
|
||||
return phones.find(device => device.reachable) ?? phones[0] ?? null;
|
||||
}
|
||||
readonly property bool phoneReachable: root.preferredPhone?.reachable === true
|
||||
readonly property int pairedCount: root.devices.filter(device => device.paired).length
|
||||
readonly property var phoneActions: root.preferredPhone?.actions ?? []
|
||||
|
||||
function supports(action: string): bool {
|
||||
return root.phoneActions.indexOf(action) >= 0;
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
if (root.fixtureMode || statusProc.running)
|
||||
return;
|
||||
statusProc.running = true;
|
||||
}
|
||||
|
||||
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.lastError = String(result.error ?? "");
|
||||
} catch (error) {
|
||||
root.available = false;
|
||||
root.devices = [];
|
||||
root.lastError = "invalid-response";
|
||||
}
|
||||
}
|
||||
|
||||
function sendFile(path: string): void {
|
||||
if (!root.phoneReachable || !root.supports("share") || path === "" || actionProc.running)
|
||||
return;
|
||||
root.actionKind = "share";
|
||||
root.actionPath = path;
|
||||
root.transferActive = true;
|
||||
root.transferFileName = path.split("/").pop();
|
||||
root.transferDeviceName = root.preferredPhone.name;
|
||||
actionProc.command = [root.helperPath, "send-file", root.preferredPhone.id, path];
|
||||
actionProc.running = true;
|
||||
}
|
||||
|
||||
function sendClipboard(): void {
|
||||
root.runSimpleAction("clipboard", "send-clipboard");
|
||||
}
|
||||
|
||||
function ring(): void {
|
||||
root.runSimpleAction("ring", "ring");
|
||||
}
|
||||
|
||||
function runSimpleAction(kind: string, command: string): void {
|
||||
if (!root.phoneReachable || !root.supports(kind) || actionProc.running)
|
||||
return;
|
||||
root.actionKind = kind;
|
||||
root.actionPath = "";
|
||||
actionProc.command = [root.helperPath, command, root.preferredPhone.id];
|
||||
actionProc.running = true;
|
||||
}
|
||||
|
||||
function cancelTransfer(): void {
|
||||
if (actionProc.running && root.actionKind === "share")
|
||||
actionProc.signal(2);
|
||||
root.actionKind = "";
|
||||
root.actionPath = "";
|
||||
root.transferActive = false;
|
||||
root.transferFileName = "";
|
||||
root.transferDeviceName = "";
|
||||
}
|
||||
|
||||
function finishAction(text: string): void {
|
||||
if (root.actionKind === "")
|
||||
return;
|
||||
|
||||
const kind = root.actionKind;
|
||||
const localPath = root.actionPath;
|
||||
const fileName = root.transferFileName;
|
||||
const deviceName = root.transferDeviceName || root.preferredPhone?.name || "Phone";
|
||||
let result = null;
|
||||
try {
|
||||
result = JSON.parse(text);
|
||||
} catch (error) {
|
||||
result = { ok: false, error: "invalid-response" };
|
||||
}
|
||||
|
||||
root.actionKind = "";
|
||||
root.actionPath = "";
|
||||
if (kind === "share") {
|
||||
root.transferActive = false;
|
||||
root.transferFileName = "";
|
||||
root.transferDeviceName = "";
|
||||
}
|
||||
|
||||
if (result.ok === true && kind === "share") {
|
||||
root.recentExchange = {
|
||||
direction: "sent",
|
||||
fileName: String(result.fileName || fileName),
|
||||
deviceName,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
StatusEvents.publish({
|
||||
key: "phone-transfer",
|
||||
glyph: "\u{F03F2}",
|
||||
title: "Sent to " + deviceName,
|
||||
detail: String(result.fileName || fileName),
|
||||
tone: "ok",
|
||||
priority: StatusEvents.importantPriority,
|
||||
actionId: localPath !== "" ? "open-path" : "",
|
||||
actionData: localPath
|
||||
});
|
||||
} else if (result.ok !== true) {
|
||||
root.lastError = String(result.error || "action-failed");
|
||||
StatusEvents.publish({
|
||||
key: "phone-action",
|
||||
glyph: "\u{F03F2}",
|
||||
title: kind === "share" ? "File was not sent" : "Phone action failed",
|
||||
detail: "Check that the phone is nearby and try again",
|
||||
tone: "warn",
|
||||
priority: StatusEvents.importantPriority
|
||||
});
|
||||
}
|
||||
refreshDelay.restart();
|
||||
}
|
||||
|
||||
function applyFixture(name: string): void {
|
||||
if (["reachable", "offline", "transfer"].indexOf(name) < 0)
|
||||
return;
|
||||
root.fixtureMode = true;
|
||||
root.available = true;
|
||||
root.lastError = "";
|
||||
root.recentExchange = null;
|
||||
root.devices = [{
|
||||
id: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||
name: "Fixture iPhone",
|
||||
type: "phone",
|
||||
paired: true,
|
||||
reachable: name !== "offline",
|
||||
actions: ["clipboard", "ping", "ring", "share"]
|
||||
}];
|
||||
root.transferActive = name === "transfer";
|
||||
root.transferFileName = name === "transfer" ? "Fixture document.pdf" : "";
|
||||
root.transferDeviceName = name === "transfer" ? "Fixture iPhone" : "";
|
||||
root.actionKind = name === "transfer" ? "share" : "";
|
||||
root.actionPath = "";
|
||||
}
|
||||
|
||||
function clearFixture(): void {
|
||||
root.fixtureMode = false;
|
||||
root.available = false;
|
||||
root.devices = [];
|
||||
root.lastError = "";
|
||||
root.transferActive = false;
|
||||
root.transferFileName = "";
|
||||
root.transferDeviceName = "";
|
||||
root.actionKind = "";
|
||||
root.actionPath = "";
|
||||
root.recentExchange = null;
|
||||
root.refresh();
|
||||
}
|
||||
|
||||
Process {
|
||||
id: statusProc
|
||||
command: [root.helperPath, "status"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.consumeStatus(this.text)
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: actionProc
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.finishAction(this.text)
|
||||
}
|
||||
onExited: (code, status) => {
|
||||
if (root.actionKind !== "")
|
||||
root.finishAction("");
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 30000
|
||||
repeat: true
|
||||
running: !root.fixtureMode
|
||||
triggeredOnStart: true
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: refreshDelay
|
||||
interval: 500
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,18 @@ Singleton {
|
||||
if (PrivacyState.microphoneActive)
|
||||
result.push(root.privacyActivity("microphone", "\u{F036C}", "Microphone", PrivacyState.microphoneApp));
|
||||
|
||||
if (KdeConnect.transferActive) {
|
||||
result.push({
|
||||
kind: "phone-transfer",
|
||||
glyph: "\u{F03F2}",
|
||||
label: "Sending to " + (KdeConnect.transferDeviceName || "Phone"),
|
||||
detail: KdeConnect.transferFileName || "Preparing file",
|
||||
state: "Sending",
|
||||
tone: "accent",
|
||||
action: "Cancel"
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -83,6 +95,8 @@ Singleton {
|
||||
function activate(kind: string): void {
|
||||
if (kind === "focus")
|
||||
FocusSession.activateWorkspace();
|
||||
else if (kind === "phone-transfer")
|
||||
ShellState.open("quicksettings");
|
||||
else if (["recording", "screen", "camera", "microphone"].indexOf(kind) >= 0)
|
||||
ShellState.open("activity");
|
||||
}
|
||||
@@ -94,5 +108,7 @@ Singleton {
|
||||
Caffeine.toggle();
|
||||
else if (kind === "recording")
|
||||
Capture.stopRecording();
|
||||
else if (kind === "phone-transfer")
|
||||
KdeConnect.cancelTransfer();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ ShellRoot {
|
||||
// overview or quick settings is instant rather than paying a load.
|
||||
Overview {}
|
||||
// QuickSettings is the window; QuickSettingsPanel is its content.
|
||||
QuickSettings {}
|
||||
QuickSettings { id: quickSettings }
|
||||
DateMenu { id: dateMenu }
|
||||
ClipboardPanel {}
|
||||
CaptureOverlay {}
|
||||
@@ -221,6 +221,54 @@ ShellRoot {
|
||||
IpcHandler {
|
||||
target: "quicksettings"
|
||||
function toggle(): void { ShellState.toggle("quicksettings"); }
|
||||
function open(): void { ShellState.open("quicksettings"); }
|
||||
function close(): void { ShellState.close(); }
|
||||
function section(name: string): void { quickSettings.expand(name); }
|
||||
function status(): string {
|
||||
return JSON.stringify({
|
||||
open: ShellState.quickSettingsOpen,
|
||||
expandedSection: quickSettings.expandedSection
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "kdeconnect"
|
||||
function fixture(name: string): void { KdeConnect.applyFixture(name); }
|
||||
function reset(): void { KdeConnect.clearFixture(); }
|
||||
function refresh(): void { KdeConnect.refresh(); }
|
||||
function cancel(): void { KdeConnect.cancelTransfer(); }
|
||||
function status(): string {
|
||||
return JSON.stringify({
|
||||
fixture: KdeConnect.fixtureMode,
|
||||
available: KdeConnect.available,
|
||||
reachable: KdeConnect.phoneReachable,
|
||||
pairedCount: KdeConnect.pairedCount,
|
||||
actionCount: KdeConnect.phoneActions.length,
|
||||
transferActive: KdeConnect.transferActive,
|
||||
transferFileName: KdeConnect.transferFileName,
|
||||
ongoingCount: Ongoing.activities.filter(item => item.kind === "phone-transfer").length,
|
||||
lastError: KdeConnect.lastError
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "home-assistant"
|
||||
function fixture(name: string): void { HomeAssistant.applyFixture(name); }
|
||||
function reset(): void { HomeAssistant.clearFixture(); }
|
||||
function refresh(): void { HomeAssistant.refresh(); }
|
||||
function status(): string {
|
||||
return JSON.stringify({
|
||||
fixture: HomeAssistant.fixtureMode,
|
||||
phase: HomeAssistant.phase,
|
||||
configuredCount: HomeAssistant.configuredCount,
|
||||
visibleCount: HomeAssistant.visibleEntities.length,
|
||||
stale: HomeAssistant.stale,
|
||||
busy: HomeAssistant.busyEntityId !== "",
|
||||
lastError: HomeAssistant.lastError
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
|
||||
@@ -88,12 +88,12 @@ Create `tests/quickshell/kdeconnect_bridge_test.py` with import-by-path and thes
|
||||
```python
|
||||
class KdeConnectBridgeTest(unittest.TestCase):
|
||||
def test_status_omits_network_and_exposes_supported_ios_actions(self):
|
||||
listing = "- Gib's iPhone: 90B54E84548E4F109268EC2D0E7D6930 on 192.168.1.7 via LAN (paired and reachable)\n1 device found\n"
|
||||
listing = "- Fixture iPhone: BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB on 192.0.2.7 via LAN (paired and reachable)\n1 device found\n"
|
||||
plugins = ["kdeconnect_clipboard", "kdeconnect_findmyphone", "kdeconnect_ping", "kdeconnect_share"]
|
||||
device = bridge.normalize_device_line(listing.splitlines()[0], plugins)
|
||||
self.assertEqual(device["name"], "Gib's iPhone")
|
||||
self.assertEqual(device["name"], "Fixture iPhone")
|
||||
self.assertEqual(device["actions"], ["clipboard", "ping", "ring", "share"])
|
||||
self.assertNotIn("192.168.1.7", json.dumps(device))
|
||||
self.assertNotIn("192.0.2.7", json.dumps(device))
|
||||
|
||||
def test_invalid_device_id_is_rejected(self):
|
||||
with self.assertRaisesRegex(ValueError, "invalid-device"):
|
||||
@@ -105,8 +105,8 @@ class KdeConnectBridgeTest(unittest.TestCase):
|
||||
bridge.validate_file(pathlib.Path(directory))
|
||||
|
||||
def test_action_command_uses_separate_arguments(self):
|
||||
command = bridge.action_command("ring", "90B54E84548E4F109268EC2D0E7D6930")
|
||||
self.assertEqual(command, ["kdeconnect-cli", "-d", "90B54E84548E4F109268EC2D0E7D6930", "--ring"])
|
||||
command = bridge.action_command("ring", "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB")
|
||||
self.assertEqual(command, ["kdeconnect-cli", "-d", "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", "--ring"])
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test and verify RED**
|
||||
|
||||
@@ -46,7 +46,7 @@ the paired iPhone exposes only a useful subset of KDE Connect.
|
||||
|
||||
## Verified system state
|
||||
|
||||
KDE Connect currently reports one paired and reachable device: `Gib's iPhone`.
|
||||
KDE Connect currently reports one paired iPhone.
|
||||
Its live D-Bus object exposes clipboard, find-my-phone, ping, share, and battery
|
||||
plugins. The battery plugin has no value, and the remote-input object exposes no
|
||||
callable control methods. Panama must derive action visibility from advertised
|
||||
|
||||
Executable
+73
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
fail() {
|
||||
printf 'Control Center contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
|
||||
cleanup() {
|
||||
qs ipc call quicksettings close >/dev/null 2>&1 || true
|
||||
qs ipc call kdeconnect reset >/dev/null 2>&1 || true
|
||||
qs ipc call home-assistant reset >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
rg -Fq 'readonly property int controlCenterWidth: 430' \
|
||||
"$project_root/config/dot/quickshell/config/Theme.qml" \
|
||||
|| fail 'approved Control Center width is missing'
|
||||
rg -Fq 'readonly property int controlCenterTopGap: 2' \
|
||||
"$project_root/config/dot/quickshell/config/Theme.qml" \
|
||||
|| fail 'approved top attachment is missing'
|
||||
rg -Fq 'margins.top: Theme.barHeight + Theme.controlCenterTopGap' \
|
||||
"$project_root/config/dot/quickshell/modules/quicksettings/QuickSettings.qml" \
|
||||
|| fail 'Control Center is not tightly attached to the bar'
|
||||
rg -Fq 'implicitWidth: Theme.controlCenterWidth' \
|
||||
"$project_root/config/dot/quickshell/modules/quicksettings/QuickSettings.qml" \
|
||||
|| fail 'Control Center window does not use its geometry token'
|
||||
rg -Fq 'HomeControls' \
|
||||
"$project_root/config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml" \
|
||||
|| fail 'Home controls are not mounted'
|
||||
rg -Fq 'PhoneControls' \
|
||||
"$project_root/config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml" \
|
||||
|| fail 'Phone controls are not mounted'
|
||||
rg -Fq 'visible: KdeConnect.phoneReachable' \
|
||||
"$project_root/config/dot/quickshell/modules/bar/StatusCluster.qml" \
|
||||
|| fail 'reachable phone state is absent from the bar'
|
||||
|
||||
for component in ControlSectionHeader HomeControls HomeTile PhoneControls RecentExchange; do
|
||||
[[ -f "$project_root/config/dot/quickshell/modules/quicksettings/$component.qml" ]] \
|
||||
|| fail "$component is missing"
|
||||
done
|
||||
|
||||
qs ipc call home-assistant fixture ready >/dev/null
|
||||
qs ipc call kdeconnect fixture reachable >/dev/null
|
||||
qs ipc call quicksettings open >/dev/null
|
||||
|
||||
hyprctl layers | rg -q 'namespace: qs-popover-quicksettings' \
|
||||
|| fail 'Control Center layer did not map'
|
||||
jq -e '.open == true and .expandedSection == ""' \
|
||||
<<<"$(qs ipc call quicksettings status)" >/dev/null \
|
||||
|| fail 'Control Center did not open in its resting state'
|
||||
|
||||
qs ipc call quicksettings section home >/dev/null
|
||||
jq -e '.open == true and .expandedSection == "home"' \
|
||||
<<<"$(qs ipc call quicksettings status)" >/dev/null \
|
||||
|| fail 'Home section did not expand'
|
||||
|
||||
qs ipc call quicksettings section phone >/dev/null
|
||||
jq -e '.open == true and .expandedSection == "phone"' \
|
||||
<<<"$(qs ipc call quicksettings status)" >/dev/null \
|
||||
|| fail 'Phone did not replace Home as the expanded section'
|
||||
|
||||
qs ipc call quicksettings section phone >/dev/null
|
||||
jq -e '.open == true and .expandedSection == ""' \
|
||||
<<<"$(qs ipc call quicksettings status)" >/dev/null \
|
||||
|| fail 'expanded Phone section did not collapse'
|
||||
|
||||
trap - EXIT
|
||||
cleanup
|
||||
printf 'Control Center contract: PASS\n'
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
fail() {
|
||||
printf 'Control Center services contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
qs ipc call kdeconnect reset >/dev/null 2>&1 || true
|
||||
qs ipc call home-assistant reset >/dev/null 2>&1 || true
|
||||
qs ipc call status-events reset >/dev/null 2>&1 || true
|
||||
qs ipc call quicksettings close >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
qs ipc show | rg -q '^target kdeconnect$' \
|
||||
|| fail 'KDE Connect IPC target is missing'
|
||||
qs ipc show | rg -q '^target home-assistant$' \
|
||||
|| fail 'Home Assistant IPC target is missing'
|
||||
|
||||
qs ipc call kdeconnect fixture reachable >/dev/null
|
||||
jq -e '
|
||||
.fixture == true and
|
||||
.available == true and
|
||||
.reachable == true and
|
||||
.actionCount == 4 and
|
||||
.transferActive == false and
|
||||
.ongoingCount == 0
|
||||
' <<<"$(qs ipc call kdeconnect status)" >/dev/null \
|
||||
|| fail 'reachable phone fixture is malformed'
|
||||
|
||||
qs ipc call kdeconnect fixture offline >/dev/null
|
||||
jq -e '
|
||||
.fixture == true and
|
||||
.available == true and
|
||||
.reachable == false and
|
||||
.pairedCount == 1 and
|
||||
.ongoingCount == 0
|
||||
' <<<"$(qs ipc call kdeconnect status)" >/dev/null \
|
||||
|| fail 'offline phone fixture is malformed'
|
||||
|
||||
qs ipc call kdeconnect fixture transfer >/dev/null
|
||||
jq -e '
|
||||
.fixture == true and
|
||||
.transferActive == true and
|
||||
.transferFileName == "Fixture document.pdf" and
|
||||
.ongoingCount == 1
|
||||
' <<<"$(qs ipc call kdeconnect status)" >/dev/null \
|
||||
|| fail 'phone transfer did not enter Ongoing'
|
||||
|
||||
qs ipc call kdeconnect cancel >/dev/null
|
||||
jq -e '.transferActive == false and .ongoingCount == 0' \
|
||||
<<<"$(qs ipc call kdeconnect status)" >/dev/null \
|
||||
|| fail 'phone transfer did not leave Ongoing'
|
||||
|
||||
qs ipc call home-assistant fixture ready >/dev/null
|
||||
jq -e '
|
||||
.fixture == true and
|
||||
.phase == "ready" and
|
||||
.configuredCount == 7 and
|
||||
.visibleCount == 4 and
|
||||
.stale == false and
|
||||
.lastError == ""
|
||||
' <<<"$(qs ipc call home-assistant status)" >/dev/null \
|
||||
|| fail 'ready Home fixture is malformed'
|
||||
|
||||
qs ipc call home-assistant fixture stale >/dev/null
|
||||
jq -e '
|
||||
.fixture == true and
|
||||
.phase == "degraded" and
|
||||
.configuredCount == 7 and
|
||||
.visibleCount == 4 and
|
||||
.stale == true and
|
||||
.lastError == "unreachable"
|
||||
' <<<"$(qs ipc call home-assistant status)" >/dev/null \
|
||||
|| fail 'stale Home fixture did not retain entities'
|
||||
|
||||
qs ipc call home-assistant fixture unavailable >/dev/null
|
||||
jq -e '
|
||||
.fixture == true and
|
||||
.phase == "unavailable" and
|
||||
.configuredCount == 0 and
|
||||
.visibleCount == 0 and
|
||||
.lastError == "not-configured"
|
||||
' <<<"$(qs ipc call home-assistant status)" >/dev/null \
|
||||
|| fail 'unavailable Home fixture is malformed'
|
||||
|
||||
trap - EXIT
|
||||
cleanup
|
||||
printf 'Control Center services contract: PASS\n'
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
fail() {
|
||||
printf 'Home Assistant helper contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
helper="$project_root/config/dot/quickshell/scripts/panama-home-assistant"
|
||||
|
||||
[[ -x "$helper" ]] || fail 'helper is missing or not executable'
|
||||
|
||||
probe="$($helper probe)"
|
||||
jq -e '
|
||||
.configured == true and
|
||||
.reachable == true and
|
||||
(.entityCount | type == "number" and . > 0) and
|
||||
.error == ""
|
||||
' <<<"$probe" >/dev/null || fail 'live API probe failed'
|
||||
|
||||
snapshot="$($helper snapshot)"
|
||||
jq -e --argjson expected "$(jq '.entityCount' <<<"$probe")" '
|
||||
.ok == true and
|
||||
.configured == true and
|
||||
.error == "" and
|
||||
(.generatedAt | type == "number") and
|
||||
(.entities | type == "array" and length == $expected) and
|
||||
([.entities[] | (keys | sort) == (["active", "available", "domain", "id", "name", "state"] | sort)] | all) and
|
||||
([.entities[] | (.active | type == "boolean") and (.available | type == "boolean")] | all)
|
||||
' <<<"$snapshot" >/dev/null || fail 'live snapshot shape is invalid'
|
||||
|
||||
printf 'Home Assistant helper contract: PASS (configured=true, %s favourites; contents redacted)\n' \
|
||||
"$(jq '.entities | length' <<<"$snapshot")"
|
||||
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import pathlib
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||||
HELPER = ROOT / "config/dot/quickshell/scripts/panama-home-assistant"
|
||||
|
||||
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)
|
||||
sys.modules[spec.name] = bridge
|
||||
loader.exec_module(bridge)
|
||||
|
||||
|
||||
class FakeHomeAssistant(BaseHTTPRequestHandler):
|
||||
requests: list[dict[str, object]] = []
|
||||
|
||||
def log_message(self, _format: str, *args: object) -> None:
|
||||
del args
|
||||
|
||||
def _record(self, body: bytes = b"") -> None:
|
||||
self.requests.append(
|
||||
{
|
||||
"method": self.command,
|
||||
"path": self.path,
|
||||
"authorization": self.headers.get("Authorization"),
|
||||
"body": body,
|
||||
}
|
||||
)
|
||||
|
||||
def _json(self, value: object, status: int = 200) -> None:
|
||||
payload = json.dumps(value).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
self._record()
|
||||
if self.path == "/api/":
|
||||
self._json({"message": "API running."})
|
||||
return
|
||||
if self.path == "/api/states":
|
||||
self._json(
|
||||
[
|
||||
{
|
||||
"entity_id": "light.hall",
|
||||
"state": "off",
|
||||
"attributes": {"friendly_name": "Hall"},
|
||||
},
|
||||
{
|
||||
"entity_id": "sensor.private",
|
||||
"state": "1",
|
||||
"attributes": {"friendly_name": "Private"},
|
||||
},
|
||||
{
|
||||
"entity_id": "light.kitchen",
|
||||
"state": "on",
|
||||
"attributes": {"friendly_name": "Kitchen"},
|
||||
},
|
||||
]
|
||||
)
|
||||
return
|
||||
self._json({"message": "not found"}, 404)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
body = self.rfile.read(length)
|
||||
self._record(body)
|
||||
if self.path == "/api/services/homeassistant/toggle":
|
||||
self._json([])
|
||||
return
|
||||
self._json({"message": "not found"}, 404)
|
||||
|
||||
|
||||
class HomeAssistantBridgeTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.server = ThreadingHTTPServer(("127.0.0.1", 0), FakeHomeAssistant)
|
||||
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
|
||||
cls.thread.start()
|
||||
cls.base_url = f"http://127.0.0.1:{cls.server.server_port}"
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
cls.server.shutdown()
|
||||
cls.server.server_close()
|
||||
cls.thread.join(timeout=2)
|
||||
|
||||
def setUp(self) -> None:
|
||||
FakeHomeAssistant.requests.clear()
|
||||
|
||||
def config(self) -> object:
|
||||
return bridge.Config(
|
||||
base_url=self.base_url,
|
||||
token="fixture-token",
|
||||
entity_ids=("light.kitchen", "light.hall"),
|
||||
)
|
||||
|
||||
def test_environment_configuration_wins_over_legacy_sources(self) -> None:
|
||||
env = {
|
||||
"PANAMA_HOME_ASSISTANT_URL": "https://home.example",
|
||||
"PANAMA_HOME_ASSISTANT_TOKEN": "fixture-token",
|
||||
"PANAMA_HOME_ASSISTANT_ENTITIES": "light.kitchen,light.hall",
|
||||
}
|
||||
|
||||
config = bridge.resolve_config(
|
||||
env,
|
||||
legacy=lambda: (_ for _ in ()).throw(AssertionError("legacy called")),
|
||||
)
|
||||
|
||||
self.assertEqual(config.base_url, "https://home.example")
|
||||
self.assertEqual(config.entity_ids, ("light.kitchen", "light.hall"))
|
||||
|
||||
def test_snapshot_filters_and_preserves_configured_order(self) -> None:
|
||||
snapshot = bridge.collect_snapshot(self.config())
|
||||
|
||||
self.assertTrue(snapshot["ok"])
|
||||
self.assertEqual(
|
||||
[item["name"] for item in snapshot["entities"]],
|
||||
["Kitchen", "Hall"],
|
||||
)
|
||||
self.assertNotIn("sensor.private", json.dumps(snapshot))
|
||||
|
||||
def test_snapshot_uses_bearer_authentication(self) -> None:
|
||||
bridge.collect_snapshot(self.config())
|
||||
|
||||
request = FakeHomeAssistant.requests[-1]
|
||||
self.assertEqual(request["method"], "GET")
|
||||
self.assertEqual(request["path"], "/api/states")
|
||||
self.assertEqual(request["authorization"], "Bearer fixture-token")
|
||||
|
||||
def test_toggle_rejects_an_unconfigured_entity(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "entity-not-configured"):
|
||||
bridge.ensure_configured(
|
||||
"light.office",
|
||||
("light.kitchen",),
|
||||
)
|
||||
|
||||
def test_toggle_calls_the_homeassistant_service(self) -> None:
|
||||
result = bridge.toggle(self.config(), "light.kitchen")
|
||||
|
||||
self.assertTrue(result["ok"])
|
||||
request = FakeHomeAssistant.requests[-1]
|
||||
self.assertEqual(request["method"], "POST")
|
||||
self.assertEqual(request["path"], "/api/services/homeassistant/toggle")
|
||||
self.assertEqual(
|
||||
json.loads(request["body"]),
|
||||
{"entity_id": "light.kitchen"},
|
||||
)
|
||||
|
||||
def test_authentication_error_is_redacted(self) -> None:
|
||||
self.assertEqual(
|
||||
bridge.public_http_error(401, "sensitive response"),
|
||||
"authentication-required",
|
||||
)
|
||||
self.assertEqual(
|
||||
bridge.public_http_error(500, "sensitive response"),
|
||||
"request-failed",
|
||||
)
|
||||
|
||||
def test_gsettings_array_parser_handles_empty_and_values(self) -> None:
|
||||
self.assertEqual(bridge.parse_gsettings_array("@as []"), ())
|
||||
self.assertEqual(
|
||||
bridge.parse_gsettings_array("['light.kitchen', 'light.hall']"),
|
||||
("light.kitchen", "light.hall"),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
fail() {
|
||||
printf 'KDE Connect helper contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
helper="$project_root/config/dot/quickshell/scripts/panama-kdeconnect"
|
||||
|
||||
[[ -x "$helper" ]] || fail 'helper is missing or not executable'
|
||||
|
||||
status="$($helper status)"
|
||||
jq -e '
|
||||
.available == true and
|
||||
(.devices | type == "array" and length >= 1) and
|
||||
([.devices[] | (keys | sort) == (["actions", "id", "name", "paired", "reachable", "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) and
|
||||
([.devices[] | select(.paired)] | length >= 1)
|
||||
' <<<"$status" >/dev/null || fail 'live status shape is invalid'
|
||||
|
||||
paired_count="$(jq '[.devices[] | select(.paired)] | length' <<<"$status")"
|
||||
reachable_count="$(jq '[.devices[] | select(.reachable)] | length' <<<"$status")"
|
||||
printf 'KDE Connect helper contract: PASS (%s paired, %s reachable; identities redacted)\n' \
|
||||
"$paired_count" \
|
||||
"$reachable_count"
|
||||
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import pathlib
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||||
HELPER = ROOT / "config/dot/quickshell/scripts/panama-kdeconnect"
|
||||
|
||||
loader = importlib.machinery.SourceFileLoader("panama_kdeconnect", str(HELPER))
|
||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||
bridge = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(bridge)
|
||||
|
||||
|
||||
class KdeConnectBridgeTest(unittest.TestCase):
|
||||
def test_status_omits_network_and_exposes_supported_ios_actions(self) -> None:
|
||||
line = (
|
||||
"- Fixture iPhone: BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB "
|
||||
"on 192.0.2.7 via LAN (paired and reachable)"
|
||||
)
|
||||
plugins = [
|
||||
"kdeconnect_clipboard",
|
||||
"kdeconnect_findmyphone",
|
||||
"kdeconnect_ping",
|
||||
"kdeconnect_share",
|
||||
"kdeconnect_shareinputdevices",
|
||||
]
|
||||
|
||||
device = bridge.normalize_device_line(line, plugins)
|
||||
|
||||
self.assertEqual(device["name"], "Fixture iPhone")
|
||||
self.assertEqual(device["type"], "phone")
|
||||
self.assertTrue(device["paired"])
|
||||
self.assertTrue(device["reachable"])
|
||||
self.assertEqual(device["actions"], ["clipboard", "ping", "ring", "share"])
|
||||
self.assertNotIn("192.0.2.7", json.dumps(device))
|
||||
self.assertNotIn("LAN", json.dumps(device))
|
||||
|
||||
def test_status_parses_an_offline_paired_device(self) -> None:
|
||||
line = "- Pocket: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA (paired)"
|
||||
|
||||
device = bridge.normalize_device_line(line, ["kdeconnect_share"])
|
||||
|
||||
self.assertTrue(device["paired"])
|
||||
self.assertFalse(device["reachable"])
|
||||
self.assertEqual(device["actions"], ["share"])
|
||||
|
||||
def test_invalid_device_id_is_rejected(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "invalid-device"):
|
||||
bridge.validate_device_id("phone; shutdown")
|
||||
|
||||
def test_send_file_requires_a_regular_file(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
with self.assertRaisesRegex(ValueError, "invalid-file"):
|
||||
bridge.validate_file(pathlib.Path(directory))
|
||||
|
||||
def test_send_file_resolves_a_regular_file(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
file_path = pathlib.Path(directory) / "hello world.txt"
|
||||
file_path.write_text("fixture", encoding="utf-8")
|
||||
|
||||
self.assertEqual(bridge.validate_file(file_path), file_path.resolve())
|
||||
|
||||
def test_action_commands_use_separate_arguments(self) -> None:
|
||||
device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
|
||||
self.assertEqual(
|
||||
bridge.action_command("ring", device_id),
|
||||
["kdeconnect-cli", "-d", device_id, "--ring"],
|
||||
)
|
||||
self.assertEqual(
|
||||
bridge.action_command("clipboard", device_id),
|
||||
["kdeconnect-cli", "-d", device_id, "--send-clipboard"],
|
||||
)
|
||||
|
||||
def test_unknown_action_is_rejected(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "unsupported-action"):
|
||||
bridge.action_command(
|
||||
"unlock",
|
||||
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
|
||||
)
|
||||
|
||||
def test_busctl_plugin_output_is_normalized(self) -> None:
|
||||
output = (
|
||||
'as 5 "kdeconnect_ping" "kdeconnect_share" '
|
||||
'"kdeconnect_clipboard" "kdeconnect_findmyphone" "unrelated"\n'
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
bridge.parse_loaded_plugins(output),
|
||||
[
|
||||
"kdeconnect_ping",
|
||||
"kdeconnect_share",
|
||||
"kdeconnect_clipboard",
|
||||
"kdeconnect_findmyphone",
|
||||
"unrelated",
|
||||
],
|
||||
)
|
||||
|
||||
def test_offline_device_falls_back_to_supported_plugins(self) -> None:
|
||||
def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
member = command[-1]
|
||||
if member == "loadedPlugins":
|
||||
return subprocess.CompletedProcess(command, 0, "as 0\n", "")
|
||||
if member == "supportedPlugins":
|
||||
return subprocess.CompletedProcess(
|
||||
command,
|
||||
0,
|
||||
'as 3 "kdeconnect_share" "kdeconnect_clipboard" "kdeconnect_findmyphone"\n',
|
||||
"",
|
||||
)
|
||||
raise AssertionError(command)
|
||||
|
||||
self.assertEqual(
|
||||
bridge.device_plugins(
|
||||
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",
|
||||
runner,
|
||||
),
|
||||
[
|
||||
"kdeconnect_share",
|
||||
"kdeconnect_clipboard",
|
||||
"kdeconnect_findmyphone",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user