Add Control Center integration services
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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'
|
||||
Reference in New Issue
Block a user