Compare commits
7
Commits
dc6da5af34
...
f4c3ffed43
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4c3ffed43 | ||
|
|
5f965daa6a | ||
|
|
5940d6b4bd | ||
|
|
190e3200e0 | ||
|
|
49ec4d8f6a | ||
|
|
173c00fca7 | ||
|
|
3a903934c0 |
@@ -8,3 +8,6 @@
|
|||||||
/config/dot/nvim/lazy-lock.json
|
/config/dot/nvim/lazy-lock.json
|
||||||
# Local visual-companion sessions and generated design mocks.
|
# Local visual-companion sessions and generated design mocks.
|
||||||
/.superpowers/
|
/.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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Brightness | Appears only when a backlight device exists | Live, not applicable on this monitor |
|
||||||
| AppIndicator / tray | Native StatusNotifierItem host and themed menus | Live |
|
| 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 |
|
| Portals and screen sharing | xdg-desktop-portal-hyprland plus GTK portal | Live |
|
||||||
| Removable media | udiskie plus udisks notifications | Live |
|
| Removable media | udiskie plus udisks notifications | Live |
|
||||||
| Autostart apps | Nextcloud, Bitwarden, and RustDesk system service/tray | 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 |
|
| System settings | Panama Settings for display policy, appearance, desktop, sound, focus, shortcuts, and services; labelled GNOME hardware/account handoffs | Live |
|
||||||
|
|
||||||
## GNOME extension migration
|
## 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 |
|
| Blur My Shell / Openbar / User Theme | Replaced by the Prism shell and compositor blur |
|
||||||
| Bluetooth Quick Connect | Replaced by the full Bluetooth picker |
|
| 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 |
|
| 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 |
|
| 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 | Useful integration, but needs a purpose-built panel and secure credential migration |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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
|
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.
|
a polished general-purpose desktop can go beyond the current shell.
|
||||||
|
|
||||||
1. Home Assistant favourites in a dedicated, secure quick panel. The token must
|
1. A real external RustDesk test while hyprlock is active. Local process state
|
||||||
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
|
|
||||||
cannot prove what a remote client sees or whether input is accepted.
|
cannot prove what a remote client sees or whether input is accepted.
|
||||||
|
|
||||||
Supporting packages tracked in `setup/packages/hyprland-packages`:
|
KDE Connect, the printer UI, Tesseract, and ZBar are installed and remain
|
||||||
|
tracked in `setup/packages/hyprland-packages`.
|
||||||
```sh
|
|
||||||
sudo dnf install kde-connect system-config-printer tesseract zbar
|
|
||||||
```
|
|
||||||
|
|
||||||
## Global menu decision
|
## 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
|
(`12m` or `Now`); the event title is available on hover but otherwise stays out
|
||||||
of the bar. All-day events never trigger the capsule.
|
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
|
## Screen Intelligence
|
||||||
|
|
||||||
`SUPER + SHIFT + S` opens the capture picker directly in Selection + Read
|
`SUPER + SHIFT + S` opens the capture picker directly in Selection + Read
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ Singleton {
|
|||||||
readonly property int popoverRadius: 18
|
readonly property int popoverRadius: 18
|
||||||
readonly property int popoverPadding: 14
|
readonly property int popoverPadding: 14
|
||||||
readonly property int popoverWidth: 380
|
readonly property int popoverWidth: 380
|
||||||
|
readonly property int controlCenterWidth: 430
|
||||||
|
readonly property int controlCenterTopGap: 2
|
||||||
|
|
||||||
readonly property int cardRadius: 12
|
readonly property int cardRadius: 12
|
||||||
readonly property int pillRadius: 999
|
readonly property int pillRadius: 999
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import Quickshell.Networking
|
|||||||
import Quickshell.Services.Pipewire
|
import Quickshell.Services.Pipewire
|
||||||
import QtQuick
|
import QtQuick
|
||||||
import qs.config
|
import qs.config
|
||||||
|
import qs.services
|
||||||
import qs.widgets
|
import qs.widgets
|
||||||
|
|
||||||
Pill {
|
Pill {
|
||||||
@@ -106,4 +107,10 @@ Pill {
|
|||||||
glyph: "\u{F0176}" // md-coffee
|
glyph: "\u{F0176}" // md-coffee
|
||||||
color: Theme.warn
|
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
|
visible: ShellState.quickSettingsOpen
|
||||||
color: "transparent"
|
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.top: true
|
||||||
anchors.right: true
|
anchors.right: true
|
||||||
margins.top: Theme.barHeight + Theme.barGap * 2
|
margins.top: Theme.barHeight + Theme.controlCenterTopGap
|
||||||
margins.right: Theme.barSideMargin
|
margins.right: Theme.barSideMargin
|
||||||
exclusiveZone: 0
|
exclusiveZone: 0
|
||||||
|
|
||||||
implicitWidth: Theme.popoverWidth
|
implicitWidth: Theme.controlCenterWidth
|
||||||
implicitHeight: panel.implicitHeight
|
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.
|
// The `qs-popover` prefix is matched by a layerrule in hypr/rules.lua.
|
||||||
WlrLayershell.namespace: "qs-popover-quicksettings"
|
WlrLayershell.namespace: "qs-popover-quicksettings"
|
||||||
@@ -36,11 +42,14 @@ PanelWindow {
|
|||||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
|
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
|
||||||
|
|
||||||
onVisibleChanged: {
|
onVisibleChanged: {
|
||||||
if (root.visible)
|
if (root.visible) {
|
||||||
|
KdeConnect.refresh();
|
||||||
|
HomeAssistant.refresh();
|
||||||
openAnim.restart();
|
openAnim.restart();
|
||||||
else
|
} else {
|
||||||
panel.reset();
|
panel.reset();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Closes the panel when a click lands anywhere else on the desktop.
|
// Closes the panel when a click lands anywhere else on the desktop.
|
||||||
HyprlandFocusGrab {
|
HyprlandFocusGrab {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import qs.widgets
|
|||||||
Item {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
implicitWidth: Theme.popoverWidth
|
implicitWidth: Theme.controlCenterWidth
|
||||||
implicitHeight: content.implicitHeight + Theme.popoverPadding * 2
|
implicitHeight: content.implicitHeight + Theme.popoverPadding * 2
|
||||||
|
|
||||||
// "" | "wifi" | "bluetooth" | "sink" | "source". Only one detail list is
|
// "" | "wifi" | "bluetooth" | "sink" | "source". Only one detail list is
|
||||||
@@ -233,6 +233,19 @@ Item {
|
|||||||
width: content.width
|
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 {
|
Rectangle {
|
||||||
width: content.width
|
width: content.width
|
||||||
height: 1
|
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 bool kdeInitialized: false
|
||||||
property string previousOutput: ""
|
property string previousOutput: ""
|
||||||
property string previousBluetooth: ""
|
property string previousBluetooth: ""
|
||||||
property string previousKde: ""
|
property bool previousKdeReachable: false
|
||||||
|
property string previousKdeName: ""
|
||||||
|
|
||||||
PwObjectTracker { objects: [Pipewire.defaultAudioSink] }
|
PwObjectTracker { objects: [Pipewire.defaultAudioSink] }
|
||||||
|
|
||||||
@@ -33,6 +34,8 @@ Singleton {
|
|||||||
.join("\u001f");
|
.join("\u001f");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
readonly property string kdeName: KdeConnect.preferredPhone?.name ?? ""
|
||||||
|
|
||||||
onOutputNameChanged: {
|
onOutputNameChanged: {
|
||||||
if (!root.outputInitialized) {
|
if (!root.outputInitialized) {
|
||||||
root.previousOutput = root.outputName;
|
root.previousOutput = root.outputName;
|
||||||
@@ -78,55 +81,41 @@ Singleton {
|
|||||||
onTriggered: {
|
onTriggered: {
|
||||||
root.previousOutput = root.outputName;
|
root.previousOutput = root.outputName;
|
||||||
root.previousBluetooth = root.bluetoothNames;
|
root.previousBluetooth = root.bluetoothNames;
|
||||||
|
root.previousKdeReachable = KdeConnect.phoneReachable;
|
||||||
|
root.previousKdeName = root.kdeName;
|
||||||
root.outputInitialized = true;
|
root.outputInitialized = true;
|
||||||
root.bluetoothInitialized = true;
|
root.bluetoothInitialized = true;
|
||||||
|
root.kdeInitialized = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Timer {
|
Connections {
|
||||||
interval: 15000
|
target: KdeConnect
|
||||||
repeat: true
|
|
||||||
running: true
|
|
||||||
triggeredOnStart: true
|
|
||||||
onTriggered: {
|
|
||||||
if (!kdeProbe.running)
|
|
||||||
kdeProbe.running = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Process {
|
function onPhoneReachableChanged(): void {
|
||||||
id: kdeProbe
|
if (!root.kdeInitialized)
|
||||||
command: ["kdeconnect-cli", "-a", "--name-only"]
|
return;
|
||||||
stdout: StdioCollector {
|
const reachable = KdeConnect.phoneReachable;
|
||||||
onStreamFinished: {
|
if (reachable === root.previousKdeReachable)
|
||||||
const names = this.text.split("\n")
|
return;
|
||||||
.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));
|
|
||||||
StatusEvents.publish({
|
StatusEvents.publish({
|
||||||
key: "device-kdeconnect",
|
key: "device-kdeconnect",
|
||||||
glyph: "\u{F03F2}",
|
glyph: "\u{F03F2}",
|
||||||
title: connected || disconnected || "Phone",
|
title: root.kdeName || root.previousKdeName || "Phone",
|
||||||
detail: connected ? "KDE Connect available" : "KDE Connect disconnected",
|
detail: reachable ? "KDE Connect available" : "KDE Connect disconnected",
|
||||||
tone: connected ? "accent" : "warn",
|
tone: reachable ? "accent" : "warn",
|
||||||
priority: StatusEvents.ambientPriority
|
priority: StatusEvents.ambientPriority
|
||||||
});
|
});
|
||||||
}
|
root.previousKdeReachable = reachable;
|
||||||
root.previousKde = names;
|
root.previousKdeName = root.kdeName || root.previousKdeName;
|
||||||
root.kdeInitialized = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Component.onCompleted: {
|
Component.onCompleted: {
|
||||||
root.previousOutput = root.outputName;
|
root.previousOutput = root.outputName;
|
||||||
root.previousBluetooth = root.bluetoothNames;
|
root.previousBluetooth = root.bluetoothNames;
|
||||||
|
root.previousKdeReachable = KdeConnect.phoneReachable;
|
||||||
|
root.previousKdeName = root.kdeName;
|
||||||
discoverySettle.start();
|
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)
|
if (PrivacyState.microphoneActive)
|
||||||
result.push(root.privacyActivity("microphone", "\u{F036C}", "Microphone", PrivacyState.microphoneApp));
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,6 +95,8 @@ Singleton {
|
|||||||
function activate(kind: string): void {
|
function activate(kind: string): void {
|
||||||
if (kind === "focus")
|
if (kind === "focus")
|
||||||
FocusSession.activateWorkspace();
|
FocusSession.activateWorkspace();
|
||||||
|
else if (kind === "phone-transfer")
|
||||||
|
ShellState.open("quicksettings");
|
||||||
else if (["recording", "screen", "camera", "microphone"].indexOf(kind) >= 0)
|
else if (["recording", "screen", "camera", "microphone"].indexOf(kind) >= 0)
|
||||||
ShellState.open("activity");
|
ShellState.open("activity");
|
||||||
}
|
}
|
||||||
@@ -94,5 +108,7 @@ Singleton {
|
|||||||
Caffeine.toggle();
|
Caffeine.toggle();
|
||||||
else if (kind === "recording")
|
else if (kind === "recording")
|
||||||
Capture.stopRecording();
|
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 or quick settings is instant rather than paying a load.
|
||||||
Overview {}
|
Overview {}
|
||||||
// QuickSettings is the window; QuickSettingsPanel is its content.
|
// QuickSettings is the window; QuickSettingsPanel is its content.
|
||||||
QuickSettings {}
|
QuickSettings { id: quickSettings }
|
||||||
DateMenu { id: dateMenu }
|
DateMenu { id: dateMenu }
|
||||||
ClipboardPanel {}
|
ClipboardPanel {}
|
||||||
CaptureOverlay {}
|
CaptureOverlay {}
|
||||||
@@ -221,6 +221,54 @@ ShellRoot {
|
|||||||
IpcHandler {
|
IpcHandler {
|
||||||
target: "quicksettings"
|
target: "quicksettings"
|
||||||
function toggle(): void { ShellState.toggle("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 {
|
IpcHandler {
|
||||||
|
|||||||
@@ -88,12 +88,12 @@ Create `tests/quickshell/kdeconnect_bridge_test.py` with import-by-path and thes
|
|||||||
```python
|
```python
|
||||||
class KdeConnectBridgeTest(unittest.TestCase):
|
class KdeConnectBridgeTest(unittest.TestCase):
|
||||||
def test_status_omits_network_and_exposes_supported_ios_actions(self):
|
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"]
|
plugins = ["kdeconnect_clipboard", "kdeconnect_findmyphone", "kdeconnect_ping", "kdeconnect_share"]
|
||||||
device = bridge.normalize_device_line(listing.splitlines()[0], plugins)
|
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.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):
|
def test_invalid_device_id_is_rejected(self):
|
||||||
with self.assertRaisesRegex(ValueError, "invalid-device"):
|
with self.assertRaisesRegex(ValueError, "invalid-device"):
|
||||||
@@ -105,8 +105,8 @@ class KdeConnectBridgeTest(unittest.TestCase):
|
|||||||
bridge.validate_file(pathlib.Path(directory))
|
bridge.validate_file(pathlib.Path(directory))
|
||||||
|
|
||||||
def test_action_command_uses_separate_arguments(self):
|
def test_action_command_uses_separate_arguments(self):
|
||||||
command = bridge.action_command("ring", "90B54E84548E4F109268EC2D0E7D6930")
|
command = bridge.action_command("ring", "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB")
|
||||||
self.assertEqual(command, ["kdeconnect-cli", "-d", "90B54E84548E4F109268EC2D0E7D6930", "--ring"])
|
self.assertEqual(command, ["kdeconnect-cli", "-d", "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", "--ring"])
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: Run the test and verify RED**
|
- [ ] **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
|
## 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
|
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
|
plugins. The battery plugin has no value, and the remote-input object exposes no
|
||||||
callable control methods. Panama must derive action visibility from advertised
|
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