Add Control Center integration services

This commit is contained in:
Gabriel Brown
2026-08-17 13:31:27 -04:00
parent 173c00fca7
commit 49ec4d8f6a
6 changed files with 586 additions and 38 deletions
+27 -38
View File
@@ -16,7 +16,8 @@ Singleton {
property bool kdeInitialized: false
property string previousOutput: ""
property string previousBluetooth: ""
property string previousKde: ""
property bool previousKdeReachable: false
property string previousKdeName: ""
PwObjectTracker { objects: [Pipewire.defaultAudioSink] }
@@ -33,6 +34,8 @@ Singleton {
.join("\u001f");
}
readonly property string kdeName: KdeConnect.preferredPhone?.name ?? ""
onOutputNameChanged: {
if (!root.outputInitialized) {
root.previousOutput = root.outputName;
@@ -78,55 +81,41 @@ Singleton {
onTriggered: {
root.previousOutput = root.outputName;
root.previousBluetooth = root.bluetoothNames;
root.previousKdeReachable = KdeConnect.phoneReachable;
root.previousKdeName = root.kdeName;
root.outputInitialized = true;
root.bluetoothInitialized = true;
root.kdeInitialized = true;
}
}
Timer {
interval: 15000
repeat: true
running: true
triggeredOnStart: true
onTriggered: {
if (!kdeProbe.running)
kdeProbe.running = true;
}
}
Connections {
target: KdeConnect
Process {
id: kdeProbe
command: ["kdeconnect-cli", "-a", "--name-only"]
stdout: StdioCollector {
onStreamFinished: {
const names = this.text.split("\n")
.map(line => line.trim())
.filter(line => line && !/^\d+ devices? found$/.test(line))
.sort()
.join("\u001f");
if (root.kdeInitialized && names !== root.previousKde) {
const current = names ? names.split("\u001f") : [];
const previous = root.previousKde ? root.previousKde.split("\u001f") : [];
const connected = current.find(name => !previous.includes(name));
const disconnected = previous.find(name => !current.includes(name));
StatusEvents.publish({
key: "device-kdeconnect",
glyph: "\u{F03F2}",
title: connected || disconnected || "Phone",
detail: connected ? "KDE Connect available" : "KDE Connect disconnected",
tone: connected ? "accent" : "warn",
priority: StatusEvents.ambientPriority
});
}
root.previousKde = names;
root.kdeInitialized = true;
}
function onPhoneReachableChanged(): void {
if (!root.kdeInitialized)
return;
const reachable = KdeConnect.phoneReachable;
if (reachable === root.previousKdeReachable)
return;
StatusEvents.publish({
key: "device-kdeconnect",
glyph: "\u{F03F2}",
title: root.kdeName || root.previousKdeName || "Phone",
detail: reachable ? "KDE Connect available" : "KDE Connect disconnected",
tone: reachable ? "accent" : "warn",
priority: StatusEvents.ambientPriority
});
root.previousKdeReachable = reachable;
root.previousKdeName = root.kdeName || root.previousKdeName;
}
}
Component.onCompleted: {
root.previousOutput = root.outputName;
root.previousBluetooth = root.bluetoothNames;
root.previousKdeReachable = KdeConnect.phoneReachable;
root.previousKdeName = root.kdeName;
discoverySettle.start();
}
}
@@ -0,0 +1,185 @@
pragma Singleton
// Home Assistant state for the Control Center. Credentials and REST details
// remain behind the helper; QML receives configured favourites only.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-home-assistant"
// "loading" | "ready" | "degraded" | "unavailable"
property string phase: "loading"
property var entities: []
property bool stale: false
property string busyEntityId: ""
property string lastError: ""
property bool fixtureMode: false
readonly property var visibleEntities: root.entities.slice(0, 4)
readonly property int configuredCount: root.entities.length
function refresh(): void {
if (root.fixtureMode || refreshProc.running)
return;
if (root.entities.length === 0)
root.phase = "loading";
refreshProc.running = true;
}
function consumeSnapshot(text: string): void {
if (root.fixtureMode)
return;
let result = null;
try {
result = JSON.parse(text);
} catch (error) {
result = { ok: false, error: "invalid-response" };
}
if (result.ok === true) {
root.entities = Array.isArray(result.entities) ? result.entities : [];
root.phase = "ready";
root.stale = false;
root.lastError = "";
return;
}
root.lastError = String(result.error || "unreachable");
if (root.entities.length > 0) {
root.phase = "degraded";
root.stale = true;
} else {
root.phase = "unavailable";
root.stale = false;
}
}
function toggleEntity(entityId: string): void {
if (entityId === "" || root.busyEntityId !== "")
return;
if (root.fixtureMode) {
root.entities = root.entities.map(entity => {
if (entity.id !== entityId)
return entity;
return Object.assign({}, entity, {
active: !entity.active,
state: entity.active ? "off" : "on"
});
});
return;
}
if (!root.entities.some(entity => entity.id === entityId))
return;
root.busyEntityId = entityId;
actionProc.command = [root.helperPath, "toggle", entityId];
actionProc.running = true;
}
function consumeAction(text: string): void {
if (root.busyEntityId === "")
return;
let ok = false;
let errorCode = "action-failed";
try {
const result = JSON.parse(text);
ok = result.ok === true;
errorCode = String(result.error || errorCode);
} catch (error) {
errorCode = "invalid-response";
}
root.busyEntityId = "";
if (ok) {
root.lastError = "";
refreshDelay.restart();
} else {
root.lastError = errorCode;
if (root.entities.length > 0) {
root.phase = "degraded";
root.stale = true;
}
}
}
function open(): void {
Quickshell.execDetached([root.helperPath, "open"]);
}
function fixtureEntities(): var {
return [
{ id: "light.fixture_all", name: "All lights", domain: "light", state: "on", available: true, active: true },
{ id: "light.fixture_kitchen", name: "Kitchen", domain: "light", state: "on", available: true, active: true },
{ id: "light.fixture_living", name: "Living room", domain: "light", state: "off", available: true, active: false },
{ id: "light.fixture_bedroom", name: "Bedroom", domain: "light", state: "on", available: true, active: true },
{ id: "light.fixture_hall", name: "Hall", domain: "light", state: "off", available: true, active: false },
{ id: "light.fixture_desk", name: "Desk", domain: "light", state: "off", available: true, active: false },
{ id: "light.fixture_corner", name: "Corner lamp", domain: "light", state: "unavailable", available: false, active: false }
];
}
function applyFixture(name: string): void {
if (["ready", "stale", "unavailable"].indexOf(name) < 0)
return;
root.fixtureMode = true;
root.busyEntityId = "";
if (name === "unavailable") {
root.entities = [];
root.phase = "unavailable";
root.stale = false;
root.lastError = "not-configured";
return;
}
root.entities = root.fixtureEntities();
root.phase = name === "stale" ? "degraded" : "ready";
root.stale = name === "stale";
root.lastError = name === "stale" ? "unreachable" : "";
}
function clearFixture(): void {
root.fixtureMode = false;
root.phase = "loading";
root.entities = [];
root.stale = false;
root.busyEntityId = "";
root.lastError = "";
root.refresh();
}
Process {
id: refreshProc
command: [root.helperPath, "snapshot"]
stdout: StdioCollector {
onStreamFinished: root.consumeSnapshot(this.text)
}
}
Process {
id: actionProc
stdout: StdioCollector {
onStreamFinished: root.consumeAction(this.text)
}
onExited: (code, status) => {
if (root.busyEntityId !== "")
root.consumeAction("");
}
}
Timer {
interval: 60000
repeat: true
running: ShellState.quickSettingsOpen && !root.fixtureMode
onTriggered: root.refresh()
}
Timer {
id: refreshDelay
interval: 350
onTriggered: root.refresh()
}
Component.onCompleted: root.refresh()
}
@@ -0,0 +1,222 @@
pragma Singleton
// Shared KDE Connect state. The Python helper owns all CLI and D-Bus parsing;
// this singleton exposes only normalized devices and user-initiated actions.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-kdeconnect"
property bool fixtureMode: false
property bool available: false
property var devices: []
property string lastError: ""
property bool transferActive: false
property string transferFileName: ""
property string transferDeviceName: ""
property var recentExchange: null
property string actionKind: ""
property string actionPath: ""
readonly property var preferredPhone: {
const phones = root.devices.filter(device => device.type === "phone" && device.paired);
return phones.find(device => device.reachable) ?? phones[0] ?? null;
}
readonly property bool phoneReachable: root.preferredPhone?.reachable === true
readonly property int pairedCount: root.devices.filter(device => device.paired).length
readonly property var phoneActions: root.preferredPhone?.actions ?? []
function supports(action: string): bool {
return root.phoneActions.indexOf(action) >= 0;
}
function refresh(): void {
if (root.fixtureMode || statusProc.running)
return;
statusProc.running = true;
}
function consumeStatus(text: string): void {
if (root.fixtureMode)
return;
try {
const result = JSON.parse(text);
root.available = result.available === true;
root.devices = Array.isArray(result.devices) ? result.devices : [];
root.lastError = String(result.error ?? "");
} catch (error) {
root.available = false;
root.devices = [];
root.lastError = "invalid-response";
}
}
function sendFile(path: string): void {
if (!root.phoneReachable || !root.supports("share") || path === "" || actionProc.running)
return;
root.actionKind = "share";
root.actionPath = path;
root.transferActive = true;
root.transferFileName = path.split("/").pop();
root.transferDeviceName = root.preferredPhone.name;
actionProc.command = [root.helperPath, "send-file", root.preferredPhone.id, path];
actionProc.running = true;
}
function sendClipboard(): void {
root.runSimpleAction("clipboard", "send-clipboard");
}
function ring(): void {
root.runSimpleAction("ring", "ring");
}
function runSimpleAction(kind: string, command: string): void {
if (!root.phoneReachable || !root.supports(kind) || actionProc.running)
return;
root.actionKind = kind;
root.actionPath = "";
actionProc.command = [root.helperPath, command, root.preferredPhone.id];
actionProc.running = true;
}
function cancelTransfer(): void {
if (actionProc.running && root.actionKind === "share")
actionProc.signal(2);
root.actionKind = "";
root.actionPath = "";
root.transferActive = false;
root.transferFileName = "";
root.transferDeviceName = "";
}
function finishAction(text: string): void {
if (root.actionKind === "")
return;
const kind = root.actionKind;
const localPath = root.actionPath;
const fileName = root.transferFileName;
const deviceName = root.transferDeviceName || root.preferredPhone?.name || "Phone";
let result = null;
try {
result = JSON.parse(text);
} catch (error) {
result = { ok: false, error: "invalid-response" };
}
root.actionKind = "";
root.actionPath = "";
if (kind === "share") {
root.transferActive = false;
root.transferFileName = "";
root.transferDeviceName = "";
}
if (result.ok === true && kind === "share") {
root.recentExchange = {
direction: "sent",
fileName: String(result.fileName || fileName),
deviceName,
timestamp: Date.now()
};
StatusEvents.publish({
key: "phone-transfer",
glyph: "\u{F03F2}",
title: "Sent to " + deviceName,
detail: String(result.fileName || fileName),
tone: "ok",
priority: StatusEvents.importantPriority,
actionId: localPath !== "" ? "open-path" : "",
actionData: localPath
});
} else if (result.ok !== true) {
root.lastError = String(result.error || "action-failed");
StatusEvents.publish({
key: "phone-action",
glyph: "\u{F03F2}",
title: kind === "share" ? "File was not sent" : "Phone action failed",
detail: "Check that the phone is nearby and try again",
tone: "warn",
priority: StatusEvents.importantPriority
});
}
refreshDelay.restart();
}
function applyFixture(name: string): void {
if (["reachable", "offline", "transfer"].indexOf(name) < 0)
return;
root.fixtureMode = true;
root.available = true;
root.lastError = "";
root.recentExchange = null;
root.devices = [{
id: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
name: "Fixture iPhone",
type: "phone",
paired: true,
reachable: name !== "offline",
actions: ["clipboard", "ping", "ring", "share"]
}];
root.transferActive = name === "transfer";
root.transferFileName = name === "transfer" ? "Fixture document.pdf" : "";
root.transferDeviceName = name === "transfer" ? "Fixture iPhone" : "";
root.actionKind = name === "transfer" ? "share" : "";
root.actionPath = "";
}
function clearFixture(): void {
root.fixtureMode = false;
root.available = false;
root.devices = [];
root.lastError = "";
root.transferActive = false;
root.transferFileName = "";
root.transferDeviceName = "";
root.actionKind = "";
root.actionPath = "";
root.recentExchange = null;
root.refresh();
}
Process {
id: statusProc
command: [root.helperPath, "status"]
stdout: StdioCollector {
onStreamFinished: root.consumeStatus(this.text)
}
}
Process {
id: actionProc
stdout: StdioCollector {
onStreamFinished: root.finishAction(this.text)
}
onExited: (code, status) => {
if (root.actionKind !== "")
root.finishAction("");
}
}
Timer {
interval: 30000
repeat: true
running: !root.fixtureMode
triggeredOnStart: true
onTriggered: root.refresh()
}
Timer {
id: refreshDelay
interval: 500
onTriggered: root.refresh()
}
}
@@ -54,6 +54,18 @@ Singleton {
if (PrivacyState.microphoneActive)
result.push(root.privacyActivity("microphone", "\u{F036C}", "Microphone", PrivacyState.microphoneApp));
if (KdeConnect.transferActive) {
result.push({
kind: "phone-transfer",
glyph: "\u{F03F2}",
label: "Sending to " + (KdeConnect.transferDeviceName || "Phone"),
detail: KdeConnect.transferFileName || "Preparing file",
state: "Sending",
tone: "accent",
action: "Cancel"
});
}
return result;
}
@@ -83,6 +95,8 @@ Singleton {
function activate(kind: string): void {
if (kind === "focus")
FocusSession.activateWorkspace();
else if (kind === "phone-transfer")
ShellState.open("quicksettings");
else if (["recording", "screen", "camera", "microphone"].indexOf(kind) >= 0)
ShellState.open("activity");
}
@@ -94,5 +108,7 @@ Singleton {
Caffeine.toggle();
else if (kind === "recording")
Capture.stopRecording();
else if (kind === "phone-transfer")
KdeConnect.cancelTransfer();
}
}
+44
View File
@@ -221,6 +221,50 @@ ShellRoot {
IpcHandler {
target: "quicksettings"
function toggle(): void { ShellState.toggle("quicksettings"); }
function open(): void { ShellState.open("quicksettings"); }
function close(): void { ShellState.close(); }
function status(): string {
return JSON.stringify({ open: ShellState.quickSettingsOpen });
}
}
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 {
+92
View File
@@ -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'