diff --git a/config/dot/quickshell/services/DeviceEvents.qml b/config/dot/quickshell/services/DeviceEvents.qml index e36beb8..75ed3bf 100644 --- a/config/dot/quickshell/services/DeviceEvents.qml +++ b/config/dot/quickshell/services/DeviceEvents.qml @@ -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(); } } diff --git a/config/dot/quickshell/services/HomeAssistant.qml b/config/dot/quickshell/services/HomeAssistant.qml new file mode 100644 index 0000000..123fe4b --- /dev/null +++ b/config/dot/quickshell/services/HomeAssistant.qml @@ -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() +} diff --git a/config/dot/quickshell/services/KdeConnect.qml b/config/dot/quickshell/services/KdeConnect.qml new file mode 100644 index 0000000..fdb66be --- /dev/null +++ b/config/dot/quickshell/services/KdeConnect.qml @@ -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() + } +} diff --git a/config/dot/quickshell/services/Ongoing.qml b/config/dot/quickshell/services/Ongoing.qml index 1ae43f2..67c2f77 100644 --- a/config/dot/quickshell/services/Ongoing.qml +++ b/config/dot/quickshell/services/Ongoing.qml @@ -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(); } } diff --git a/config/dot/quickshell/shell.qml b/config/dot/quickshell/shell.qml index c8bc237..3401581 100644 --- a/config/dot/quickshell/shell.qml +++ b/config/dot/quickshell/shell.qml @@ -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 { diff --git a/tests/quickshell/control-center-services-contract.sh b/tests/quickshell/control-center-services-contract.sh new file mode 100755 index 0000000..ecd4946 --- /dev/null +++ b/tests/quickshell/control-center-services-contract.sh @@ -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'