Merge branch 'feat/home-accessories-customization'
# Conflicts: # config/dot/quickshell/config/qmldir # config/dot/quickshell/services/SystemSettings.qml # tests/quickshell/settings-pages-contract.sh
This commit is contained in:
+16
-19
@@ -262,29 +262,26 @@ 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
|
one place. Home and Phone continue the same surface rather than opening extra
|
||||||
dashboard windows.
|
dashboard windows.
|
||||||
|
|
||||||
Home shows the first four configured Home Assistant favourites as direct
|
Home shows the first four selected favourites at rest and every selected light
|
||||||
controls and expands to the complete configured list. Panama reads the current
|
when expanded. Use **Panama Settings → Home & Phone** to choose favourites,
|
||||||
GNOME Home Assistant extension configuration and its Secret Service token as a
|
set Panama-only aliases, and arrange their order. Dragging a brightness control
|
||||||
compatibility fallback, so the existing setup works without copying a secret.
|
only previews the value; releasing it sends one brightness request. A normal
|
||||||
The preferred private configuration lives in the gitignored
|
power toggle leaves Home Assistant responsible for restoring its previous
|
||||||
`config/bash/env` file:
|
level.
|
||||||
|
|
||||||
```sh
|
Credentials stay private in the gitignored `config/bash/env` file, with the
|
||||||
export PANAMA_HOME_ASSISTANT_URL=https://home.example.test
|
existing GNOME extension and Secret Service setup retained as a compatibility
|
||||||
export PANAMA_HOME_ASSISTANT_TOKEN=replace-with-a-long-lived-token
|
fallback. Favourites, aliases, and order live in Quickshell state. No shell
|
||||||
export PANAMA_HOME_ASSISTANT_ENTITIES=light.kitchen,light.living_room
|
restart is required after changing credentials; 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.
|
||||||
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
|
Phone uses KDE Connect for the capabilities the paired iPhone actually
|
||||||
advertises: Send File, Send Clipboard, and Ring. The device remains visible
|
advertises: Send File, Send Clipboard, and Ring. The device remains visible
|
||||||
while iOS suspends KDE Connect, but actions stay disabled until it reconnects.
|
while iOS suspends KDE Connect, but those actions stay disabled until it
|
||||||
No battery percentage is invented when iOS reports none, and BlueBubbles
|
reconnects. Messages opens BlueBubbles independently of KDE Connect. No battery
|
||||||
remains the messaging experience. Active file sends appear in Daybook's
|
percentage is invented when iOS reports none. Active file sends appear in
|
||||||
Ongoing page; successful sends become a quiet recent exchange.
|
Daybook's Ongoing page; successful sends become a quiet recent exchange.
|
||||||
|
|
||||||
## Screen Intelligence
|
## Screen Intelligence
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
Singleton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property alias initialized: values.initialized
|
||||||
|
property alias favorites: values.favorites
|
||||||
|
property string saveError: ""
|
||||||
|
|
||||||
|
FileView {
|
||||||
|
id: preferencesFile
|
||||||
|
|
||||||
|
path: Quickshell.stateDir + "/panama-home.json"
|
||||||
|
blockLoading: true
|
||||||
|
printErrors: false
|
||||||
|
atomicWrites: true
|
||||||
|
onSaved: root.saveError = ""
|
||||||
|
onSaveFailed: error => root.saveError = "Could not save Home favourites."
|
||||||
|
|
||||||
|
JsonAdapter {
|
||||||
|
id: values
|
||||||
|
|
||||||
|
property bool initialized: false
|
||||||
|
property var favorites: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: values
|
||||||
|
function onInitializedChanged(): void { persistTimer.restart(); }
|
||||||
|
function onFavoritesChanged(): void { persistTimer.restart(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: preferencesFile.reload()
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: persistTimer
|
||||||
|
interval: 180
|
||||||
|
repeat: false
|
||||||
|
onTriggered: preferencesFile.writeAdapter()
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneFavorites(): var {
|
||||||
|
var clone = [];
|
||||||
|
for (var index = 0; index < values.favorites.length; index++) {
|
||||||
|
var favorite = values.favorites[index];
|
||||||
|
clone.push({
|
||||||
|
id: favorite.id,
|
||||||
|
alias: favorite.alias
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidEntityId(entityId: var): bool {
|
||||||
|
return typeof entityId === "string" && /^light\.[a-z0-9_]+$/.test(entityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialize(legacyIds: var): void {
|
||||||
|
if (values.initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var seededFavorites = [];
|
||||||
|
var seen = {};
|
||||||
|
if (legacyIds && typeof legacyIds.length === "number") {
|
||||||
|
for (var index = 0; index < legacyIds.length; index++) {
|
||||||
|
var entityId = legacyIds[index];
|
||||||
|
if (!isValidEntityId(entityId) || seen[entityId]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen[entityId] = true;
|
||||||
|
seededFavorites.push({ id: entityId, alias: "" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
values.favorites = seededFavorites;
|
||||||
|
values.initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSelected(entityId: string): bool {
|
||||||
|
for (var index = 0; index < values.favorites.length; index++) {
|
||||||
|
if (values.favorites[index].id === entityId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function aliasFor(entityId: string, sourceName: string): string {
|
||||||
|
for (var index = 0; index < values.favorites.length; index++) {
|
||||||
|
var favorite = values.favorites[index];
|
||||||
|
if (favorite.id === entityId && favorite.alias !== "") {
|
||||||
|
return favorite.alias;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sourceName;
|
||||||
|
}
|
||||||
|
|
||||||
|
function add(entityId: string): void {
|
||||||
|
if (!isValidEntityId(entityId) || isSelected(entityId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var nextFavorites = cloneFavorites();
|
||||||
|
nextFavorites.push({ id: entityId, alias: "" });
|
||||||
|
values.favorites = nextFavorites;
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(entityId: string): void {
|
||||||
|
var nextFavorites = [];
|
||||||
|
var removed = false;
|
||||||
|
for (var index = 0; index < values.favorites.length; index++) {
|
||||||
|
var favorite = values.favorites[index];
|
||||||
|
if (favorite.id === entityId) {
|
||||||
|
removed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
nextFavorites.push({ id: favorite.id, alias: favorite.alias });
|
||||||
|
}
|
||||||
|
if (removed) {
|
||||||
|
values.favorites = nextFavorites;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAlias(entityId: string, alias: string): void {
|
||||||
|
var nextFavorites = cloneFavorites();
|
||||||
|
var updated = false;
|
||||||
|
var trimmedAlias = String(alias).trim();
|
||||||
|
for (var index = 0; index < nextFavorites.length; index++) {
|
||||||
|
if (nextFavorites[index].id === entityId) {
|
||||||
|
nextFavorites[index] = { id: entityId, alias: trimmedAlias };
|
||||||
|
updated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (updated) {
|
||||||
|
values.favorites = nextFavorites;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(entityId: string, targetIndex: int): void {
|
||||||
|
var nextFavorites = cloneFavorites();
|
||||||
|
var currentIndex = -1;
|
||||||
|
for (var index = 0; index < nextFavorites.length; index++) {
|
||||||
|
if (nextFavorites[index].id === entityId) {
|
||||||
|
currentIndex = index;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currentIndex === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var favorite = nextFavorites.splice(currentIndex, 1)[0];
|
||||||
|
var clampedIndex = Math.max(0, Math.min(targetIndex, nextFavorites.length));
|
||||||
|
nextFavorites.splice(clampedIndex, 0, favorite);
|
||||||
|
values.favorites = nextFavorites;
|
||||||
|
}
|
||||||
|
|
||||||
|
function retrySave(): void {
|
||||||
|
preferencesFile.writeAdapter();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
module qs.config
|
module qs.config
|
||||||
singleton DesktopPreferences 1.0 DesktopPreferences.qml
|
singleton DesktopPreferences 1.0 DesktopPreferences.qml
|
||||||
singleton PreferenceSchema 1.0 PreferenceSchema.qml
|
singleton PreferenceSchema 1.0 PreferenceSchema.qml
|
||||||
|
singleton HomePreferences 1.0 HomePreferences.qml
|
||||||
singleton Settings 1.0 Settings.qml
|
singleton Settings 1.0 Settings.qml
|
||||||
singleton Theme 1.0 Theme.qml
|
singleton Theme 1.0 Theme.qml
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
import qs.modules.quicksettings
|
||||||
|
|
||||||
|
ShellRoot {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property int confirmedValue: 30
|
||||||
|
property int commitCount: 0
|
||||||
|
property int lastCommit: -1
|
||||||
|
|
||||||
|
HomeBrightnessSlider {
|
||||||
|
id: slider
|
||||||
|
width: 200
|
||||||
|
value: root.confirmedValue
|
||||||
|
accessibleName: "Desk lamp brightness"
|
||||||
|
onCommitted: value => {
|
||||||
|
root.commitCount += 1;
|
||||||
|
root.lastCommit = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IpcHandler {
|
||||||
|
target: "home-brightness-slider-test"
|
||||||
|
|
||||||
|
function reset(value: int): void {
|
||||||
|
root.confirmedValue = value;
|
||||||
|
root.commitCount = 0;
|
||||||
|
root.lastCommit = -1;
|
||||||
|
slider.cancelPointerInteraction();
|
||||||
|
}
|
||||||
|
function external(value: int): void { root.confirmedValue = value; }
|
||||||
|
function press(position: int): void { slider.beginPointerInteraction(position); }
|
||||||
|
function move(position: int): void { slider.movePointerInteraction(position); }
|
||||||
|
function release(): void { slider.releasePointerInteraction(); }
|
||||||
|
function cancel(): void { slider.cancelPointerInteraction(); }
|
||||||
|
function wheel(delta: int): void { slider.commitWheel(delta); }
|
||||||
|
function status(): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
confirmedValue: root.confirmedValue,
|
||||||
|
previewValue: slider.previewValue,
|
||||||
|
interactionActive: slider.interactionActive,
|
||||||
|
commitCount: root.commitCount,
|
||||||
|
lastCommit: root.lastCommit,
|
||||||
|
accessibleRoleIsSlider: slider.Accessible.role === Accessible.Slider,
|
||||||
|
accessibleName: slider.Accessible.name,
|
||||||
|
accessibleDescription: slider.Accessible.description,
|
||||||
|
accessibleFocusable: slider.Accessible.focusable
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
import qs.config
|
||||||
|
|
||||||
|
ShellRoot {
|
||||||
|
IpcHandler {
|
||||||
|
target: "home-pref-test"
|
||||||
|
|
||||||
|
function initialize(idsJson: string): void { HomePreferences.initialize(JSON.parse(idsJson)); }
|
||||||
|
function add(id: string): void { HomePreferences.add(id); }
|
||||||
|
function alias(id: string, value: string): void { HomePreferences.setAlias(id, value); }
|
||||||
|
function move(id: string, index: int): void { HomePreferences.move(id, index); }
|
||||||
|
function remove(id: string): void { HomePreferences.remove(id); }
|
||||||
|
function status(): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
initialized: HomePreferences.initialized,
|
||||||
|
favorites: HomePreferences.favorites,
|
||||||
|
saveError: HomePreferences.saveError,
|
||||||
|
stateDir: Quickshell.stateDir
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
// A light dimmer that previews locally and sends one value when interaction
|
||||||
|
// ends. Home Assistant never sees the intermediate pointer positions.
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property int value: 0
|
||||||
|
property int previewValue: 0
|
||||||
|
property bool interactionActive: false
|
||||||
|
property string accessibleName: "Brightness"
|
||||||
|
|
||||||
|
readonly property bool pressed: root.interactionActive
|
||||||
|
|
||||||
|
signal previewChanged(int value)
|
||||||
|
signal committed(int value)
|
||||||
|
|
||||||
|
implicitWidth: 160
|
||||||
|
implicitHeight: 32
|
||||||
|
activeFocusOnTab: root.enabled
|
||||||
|
opacity: root.enabled ? 1 : 0.42
|
||||||
|
|
||||||
|
Accessible.role: Accessible.Slider
|
||||||
|
Accessible.name: root.accessibleName
|
||||||
|
// Qt 6.11's installed Accessible attached type has no structured value or
|
||||||
|
// range properties, so expose both in the supported live description.
|
||||||
|
Accessible.description: root.previewValue + " percent, range 0 to 100"
|
||||||
|
Accessible.focusable: root.enabled
|
||||||
|
Accessible.focused: root.activeFocus
|
||||||
|
Accessible.onIncreaseAction: root.commitStep(5)
|
||||||
|
Accessible.onDecreaseAction: root.commitStep(-5)
|
||||||
|
|
||||||
|
onValueChanged: {
|
||||||
|
if (!root.interactionActive)
|
||||||
|
root.updatePreview(root.value, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: root.updatePreview(root.value, false)
|
||||||
|
|
||||||
|
Keys.onLeftPressed: root.commitStep(-5)
|
||||||
|
Keys.onDownPressed: root.commitStep(-5)
|
||||||
|
Keys.onRightPressed: root.commitStep(5)
|
||||||
|
Keys.onUpPressed: root.commitStep(5)
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: track
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
height: 10
|
||||||
|
radius: 5
|
||||||
|
color: Theme.alpha(Theme.fg, 0.105)
|
||||||
|
border.width: root.activeFocus ? 1 : 0
|
||||||
|
border.color: Theme.alpha(Theme.warn, 0.72)
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: fill
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
width: track.width * root.previewValue / 100
|
||||||
|
radius: track.radius
|
||||||
|
color: Theme.warn
|
||||||
|
|
||||||
|
Behavior on width {
|
||||||
|
enabled: !root.interactionActive
|
||||||
|
NumberAnimation {
|
||||||
|
duration: Theme.durFast
|
||||||
|
easing.type: Easing.OutQuad
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: knob
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
x: Math.max(0, Math.min(track.width - width,
|
||||||
|
track.width * root.previewValue / 100 - width / 2))
|
||||||
|
width: 16
|
||||||
|
height: 16
|
||||||
|
radius: 8
|
||||||
|
color: Theme.fg
|
||||||
|
border.width: 1
|
||||||
|
border.color: Theme.alpha(Theme.bgDark, 0.38)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The 32 px interaction surface is intentionally much taller than the
|
||||||
|
// 10 px track, while remaining inside this component's layout bounds.
|
||||||
|
MouseArea {
|
||||||
|
id: drag
|
||||||
|
anchors.fill: parent
|
||||||
|
enabled: root.enabled
|
||||||
|
hoverEnabled: true
|
||||||
|
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||||
|
|
||||||
|
onPressed: event => {
|
||||||
|
root.forceActiveFocus();
|
||||||
|
root.beginPointerInteraction(event.x);
|
||||||
|
event.accepted = true;
|
||||||
|
}
|
||||||
|
onPositionChanged: event => root.movePointerInteraction(event.x)
|
||||||
|
onReleased: event => {
|
||||||
|
root.releasePointerInteraction();
|
||||||
|
event.accepted = true;
|
||||||
|
}
|
||||||
|
onCanceled: root.cancelPointerInteraction()
|
||||||
|
onWheel: event => {
|
||||||
|
root.commitWheel(event.angleDelta.y);
|
||||||
|
event.accepted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(candidate: real): int {
|
||||||
|
return Math.max(0, Math.min(100, Math.round(candidate)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePreview(candidate: real, announce: bool): void {
|
||||||
|
const nextValue = root.clamp(candidate);
|
||||||
|
if (root.previewValue === nextValue)
|
||||||
|
return;
|
||||||
|
root.previewValue = nextValue;
|
||||||
|
if (announce)
|
||||||
|
root.previewChanged(nextValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewAt(pointerX: real): void {
|
||||||
|
root.updatePreview(pointerX / Math.max(1, drag.width) * 100, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginPointerInteraction(pointerX: real): void {
|
||||||
|
if (!root.enabled)
|
||||||
|
return;
|
||||||
|
root.interactionActive = true;
|
||||||
|
root.previewAt(pointerX);
|
||||||
|
}
|
||||||
|
|
||||||
|
function movePointerInteraction(pointerX: real): void {
|
||||||
|
if (root.interactionActive)
|
||||||
|
root.previewAt(pointerX);
|
||||||
|
}
|
||||||
|
|
||||||
|
function releasePointerInteraction(): void {
|
||||||
|
if (!root.interactionActive)
|
||||||
|
return;
|
||||||
|
root.interactionActive = false;
|
||||||
|
root.committed(root.previewValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelPointerInteraction(): void {
|
||||||
|
root.interactionActive = false;
|
||||||
|
root.updatePreview(root.value, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitWheel(delta: int): void {
|
||||||
|
if (delta === 0)
|
||||||
|
return;
|
||||||
|
root.commitStep(delta > 0 ? 5 : -5);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitStep(delta: int): void {
|
||||||
|
if (!root.enabled)
|
||||||
|
return;
|
||||||
|
root.updatePreview(root.previewValue + delta, true);
|
||||||
|
root.committed(root.previewValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,9 @@ Item {
|
|||||||
property bool expanded: false
|
property bool expanded: false
|
||||||
signal toggleExpanded
|
signal toggleExpanded
|
||||||
|
|
||||||
|
readonly property bool hasSelection: HomeAssistant.selectedEntities.length > 0
|
||||||
|
readonly property bool showSetup: HomeAssistant.phase === "ready" && !root.hasSelection
|
||||||
|
|
||||||
implicitHeight: content.implicitHeight
|
implicitHeight: content.implicitHeight
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
@@ -18,46 +21,50 @@ Item {
|
|||||||
ControlSectionHeader {
|
ControlSectionHeader {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
label: "Home"
|
label: "Home"
|
||||||
action: HomeAssistant.entities.length > 0
|
action: root.headerAction()
|
||||||
? HomeAssistant.entities.length + " accessories " + (root.expanded ? "⌃" : "›")
|
|
||||||
: "Retry"
|
|
||||||
actionEnabled: HomeAssistant.phase !== "loading"
|
actionEnabled: HomeAssistant.phase !== "loading"
|
||||||
onActionTriggered: {
|
onActionTriggered: {
|
||||||
if (HomeAssistant.entities.length > 0)
|
if (root.hasSelection) {
|
||||||
root.toggleExpanded();
|
root.toggleExpanded();
|
||||||
else
|
} else if (root.showSetup) {
|
||||||
|
root.openHomeSettings();
|
||||||
|
} else {
|
||||||
HomeAssistant.refresh();
|
HomeAssistant.refresh();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
id: homeCard
|
id: restingCard
|
||||||
width: parent.width
|
width: parent.width
|
||||||
height: visible ? homeGrid.implicitHeight + 20 : 0
|
height: visible ? restingGrid.implicitHeight + 20 : 0
|
||||||
visible: HomeAssistant.entities.length > 0
|
visible: root.hasSelection && !root.expanded
|
||||||
radius: Theme.cardRadius + 2
|
radius: Theme.cardRadius + 2
|
||||||
color: Theme.alpha(Theme.accent, 0.045)
|
color: Theme.alpha(Theme.warn, 0.028)
|
||||||
border.width: 1
|
border.width: 1
|
||||||
border.color: Theme.alpha(Theme.accent, 0.12)
|
border.color: Theme.alpha(Theme.warn, 0.095)
|
||||||
|
|
||||||
Grid {
|
Grid {
|
||||||
id: homeGrid
|
id: restingGrid
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.top: parent.top
|
anchors.top: parent.top
|
||||||
anchors.margins: 10
|
anchors.margins: 10
|
||||||
columns: 4
|
columns: 2
|
||||||
spacing: 7
|
spacing: 8
|
||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
model: HomeAssistant.visibleEntities
|
model: HomeAssistant.visibleEntities
|
||||||
|
|
||||||
HomeTile {
|
HomeTile {
|
||||||
required property var modelData
|
required property var modelData
|
||||||
width: (homeGrid.width - homeGrid.spacing * 3) / 4
|
width: (restingGrid.width - restingGrid.spacing) / 2
|
||||||
entity: modelData
|
entity: modelData
|
||||||
busy: HomeAssistant.busyEntityId === modelData.id
|
busy: HomeAssistant.isBusy(modelData.id)
|
||||||
onActivated: HomeAssistant.toggleEntity(modelData.id)
|
pendingBrightness: HomeAssistant.pendingFor(modelData.id)
|
||||||
|
actionError: HomeAssistant.errorFor(modelData.id)
|
||||||
|
onPowerRequested: HomeAssistant.toggleEntity(modelData.id)
|
||||||
|
onBrightnessRequested: value => HomeAssistant.setBrightness(modelData.id, value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,28 +72,32 @@ Item {
|
|||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
height: visible ? 66 : 0
|
height: visible ? 72 : 0
|
||||||
visible: HomeAssistant.entities.length === 0
|
visible: !root.hasSelection
|
||||||
radius: Theme.cardRadius
|
radius: Theme.cardRadius
|
||||||
color: Theme.alpha(Theme.fg, 0.05)
|
color: root.showSetup
|
||||||
|
? Theme.alpha(Theme.accent, 0.055)
|
||||||
|
: Theme.alpha(Theme.fg, 0.045)
|
||||||
border.width: 1
|
border.width: 1
|
||||||
border.color: Theme.alpha(Theme.fg, 0.045)
|
border.color: root.showSetup
|
||||||
|
? Theme.alpha(Theme.accent, 0.12)
|
||||||
|
: Theme.alpha(Theme.fg, 0.055)
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
id: stateIcon
|
id: stateIcon
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.leftMargin: 12
|
anchors.leftMargin: 13
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: HomeAssistant.phase === "loading" ? "\u{F0772}" : "\u{F02DC}"
|
text: root.showSetup ? "\u{F0335}" : "\u{F02DC}"
|
||||||
color: HomeAssistant.phase === "loading" ? Theme.accent : Theme.fgDim
|
color: root.showSetup ? Theme.accent : Theme.fgDim
|
||||||
font.family: Theme.fontMono
|
font.family: Theme.fontMono
|
||||||
font.pixelSize: 16
|
font.pixelSize: 17
|
||||||
}
|
}
|
||||||
|
|
||||||
Column {
|
Column {
|
||||||
anchors.left: stateIcon.right
|
anchors.left: stateIcon.right
|
||||||
anchors.leftMargin: 11
|
anchors.leftMargin: 11
|
||||||
anchors.right: openHome.left
|
anchors.right: emptyAction.left
|
||||||
anchors.rightMargin: 10
|
anchors.rightMargin: 10
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
spacing: 3
|
spacing: 3
|
||||||
@@ -111,61 +122,66 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
id: openHome
|
id: emptyAction
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.rightMargin: 10
|
anchors.rightMargin: 10
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
width: 48
|
width: root.showSetup ? 68 : 52
|
||||||
height: 28
|
height: 30
|
||||||
radius: 9
|
radius: 10
|
||||||
visible: HomeAssistant.phase !== "loading"
|
visible: HomeAssistant.phase !== "loading"
|
||||||
color: openMouse.containsMouse
|
color: emptyActionMouse.containsMouse
|
||||||
? Theme.alpha(Theme.accent, 0.20)
|
? Theme.alpha(Theme.accent, 0.20)
|
||||||
: Theme.alpha(Theme.accent, 0.11)
|
: Theme.alpha(Theme.accent, 0.105)
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
anchors.centerIn: parent
|
anchors.centerIn: parent
|
||||||
text: "Open"
|
text: root.showSetup ? "Manage" : "Open"
|
||||||
color: Theme.accent
|
color: Theme.accent
|
||||||
font.family: Theme.fontFamily
|
font.family: Theme.fontFamily
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
font.weight: Font.Medium
|
font.weight: Font.DemiBold
|
||||||
}
|
}
|
||||||
MouseArea {
|
MouseArea {
|
||||||
id: openMouse
|
id: emptyActionMouse
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
onClicked: HomeAssistant.open()
|
onClicked: {
|
||||||
|
if (root.showSetup)
|
||||||
|
root.openHomeSettings();
|
||||||
|
else
|
||||||
|
HomeAssistant.open();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
height: visible ? 30 : 0
|
height: visible ? 34 : 0
|
||||||
visible: HomeAssistant.stale
|
visible: HomeAssistant.stale
|
||||||
radius: 9
|
radius: 10
|
||||||
color: Theme.alpha(Theme.warn, 0.08)
|
color: Theme.alpha(Theme.warn, 0.075)
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.leftMargin: 10
|
anchors.leftMargin: 11
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: "Showing the last known state"
|
text: "Last known state · " + root.countSummary()
|
||||||
color: Theme.warn
|
color: Theme.warn
|
||||||
font.family: Theme.fontFamily
|
font.family: Theme.fontFamily
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
}
|
}
|
||||||
Text {
|
Text {
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
anchors.rightMargin: 10
|
anchors.rightMargin: 11
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
text: "Retry"
|
text: "Retry"
|
||||||
color: Theme.accent
|
color: Theme.accent
|
||||||
font.family: Theme.fontFamily
|
font.family: Theme.fontFamily
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
font.weight: Font.Medium
|
font.weight: Font.DemiBold
|
||||||
MouseArea {
|
MouseArea {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
anchors.margins: -6
|
anchors.margins: -6
|
||||||
@@ -177,88 +193,66 @@ Item {
|
|||||||
|
|
||||||
Section {
|
Section {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
expanded: root.expanded && HomeAssistant.entities.length > 0
|
expanded: root.expanded && root.hasSelection
|
||||||
|
|
||||||
ScrollColumn {
|
ScrollColumn {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
maxHeight: 270
|
maxHeight: 324
|
||||||
spacing: 2
|
spacing: 8
|
||||||
|
|
||||||
|
Grid {
|
||||||
|
id: expandedGrid
|
||||||
|
width: parent.width
|
||||||
|
columns: 2
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
model: HomeAssistant.entities
|
model: HomeAssistant.selectedEntities
|
||||||
|
|
||||||
Rectangle {
|
HomeTile {
|
||||||
id: entityRow
|
|
||||||
required property var modelData
|
required property var modelData
|
||||||
width: parent.width
|
width: (expandedGrid.width - expandedGrid.spacing) / 2
|
||||||
height: 44
|
entity: modelData
|
||||||
radius: 10
|
busy: HomeAssistant.isBusy(modelData.id)
|
||||||
color: entityMouse.containsMouse
|
pendingBrightness: HomeAssistant.pendingFor(modelData.id)
|
||||||
? Theme.alpha(Theme.fg, 0.09)
|
actionError: HomeAssistant.errorFor(modelData.id)
|
||||||
: "transparent"
|
onPowerRequested: HomeAssistant.toggleEntity(modelData.id)
|
||||||
|
onBrightnessRequested: value => HomeAssistant.setBrightness(modelData.id, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Text {
|
RowButton {
|
||||||
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
|
width: parent.width
|
||||||
text: entityRow.modelData.name
|
icon: "preferences-system-symbolic"
|
||||||
color: Theme.fg
|
label: "Manage in Settings"
|
||||||
elide: Text.ElideRight
|
sublabel: root.countSummary()
|
||||||
font.family: Theme.fontFamily
|
onClicked: root.openHomeSettings()
|
||||||
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 headerAction(): string {
|
||||||
|
if (root.hasSelection) {
|
||||||
|
const noun = HomeAssistant.configuredCount === 1 ? "accessory" : "accessories";
|
||||||
|
return HomeAssistant.configuredCount + " " + noun + (root.expanded ? " ⌃" : " ›");
|
||||||
}
|
}
|
||||||
|
if (HomeAssistant.phase === "loading")
|
||||||
|
return "Loading";
|
||||||
|
if (root.showSetup)
|
||||||
|
return "Manage";
|
||||||
|
return "Retry";
|
||||||
|
}
|
||||||
|
|
||||||
|
function countSummary(): string {
|
||||||
|
return HomeAssistant.configuredCount + " selected · "
|
||||||
|
+ HomeAssistant.discoveredCount + " discovered";
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyTitle(): string {
|
function emptyTitle(): string {
|
||||||
|
if (root.showSetup)
|
||||||
|
return "Choose your accessories";
|
||||||
if (HomeAssistant.phase === "loading")
|
if (HomeAssistant.phase === "loading")
|
||||||
return "Loading your home";
|
return "Loading your home";
|
||||||
if (HomeAssistant.lastError === "authentication-required")
|
if (HomeAssistant.lastError === "authentication-required")
|
||||||
@@ -269,12 +263,23 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function emptyDetail(): string {
|
function emptyDetail(): string {
|
||||||
|
if (root.showSetup) {
|
||||||
|
if (HomeAssistant.discoveredCount === 0)
|
||||||
|
return "No lights discovered yet";
|
||||||
|
const noun = HomeAssistant.discoveredCount === 1 ? "light" : "lights";
|
||||||
|
return HomeAssistant.discoveredCount + " " + noun + " ready to add";
|
||||||
|
}
|
||||||
if (HomeAssistant.phase === "loading")
|
if (HomeAssistant.phase === "loading")
|
||||||
return "Reading configured favourites";
|
return "Finding your selected lights";
|
||||||
if (HomeAssistant.lastError === "authentication-required")
|
if (HomeAssistant.lastError === "authentication-required")
|
||||||
return "Update the long-lived access token";
|
return "Update the long-lived access token";
|
||||||
if (HomeAssistant.lastError === "not-configured")
|
if (HomeAssistant.lastError === "not-configured")
|
||||||
return "Add a URL, token and favourites";
|
return "Connect Home Assistant in Settings";
|
||||||
return "Check the connection and retry";
|
return "Check the connection, then retry";
|
||||||
|
}
|
||||||
|
|
||||||
|
function openHomeSettings(): void {
|
||||||
|
ShellState.close();
|
||||||
|
ShellState.openSettings("home-phone");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,76 +6,165 @@ Rectangle {
|
|||||||
|
|
||||||
required property var entity
|
required property var entity
|
||||||
property bool busy: false
|
property bool busy: false
|
||||||
signal activated
|
property int pendingBrightness: -1
|
||||||
|
property string actionError: ""
|
||||||
|
|
||||||
implicitHeight: 72
|
signal powerRequested
|
||||||
radius: Theme.cardRadius
|
signal brightnessRequested(int value)
|
||||||
|
|
||||||
|
readonly property int confirmedBrightness: root.clamp(root.entity.brightnessPct ?? 0)
|
||||||
|
readonly property int displayedBrightness: brightnessSlider.previewValue
|
||||||
|
readonly property bool powerEnabled: root.entity.available && !root.busy
|
||||||
|
readonly property bool dimmerEnabled: root.entity.available
|
||||||
|
&& root.entity.dimmable
|
||||||
|
&& !root.busy
|
||||||
|
|
||||||
|
implicitHeight: 124
|
||||||
|
radius: 14
|
||||||
color: {
|
color: {
|
||||||
if (root.entity.active)
|
if (root.entity.active)
|
||||||
return Theme.alpha(Theme.warn, tileMouse.containsMouse ? 0.20 : 0.14);
|
return Theme.alpha(Theme.warn, powerArea.containsMouse ? 0.145 : 0.095);
|
||||||
return Theme.alpha(Theme.fg, tileMouse.containsMouse ? 0.10 : 0.055);
|
return Theme.alpha(Theme.fg, powerArea.containsMouse ? 0.078 : 0.045);
|
||||||
}
|
}
|
||||||
border.width: 1
|
border.width: 1
|
||||||
border.color: root.entity.active
|
border.color: {
|
||||||
? Theme.alpha(Theme.warn, 0.20)
|
if (root.activeFocus)
|
||||||
: Theme.alpha(Theme.fg, 0.045)
|
return Theme.alpha(Theme.accent, 0.72);
|
||||||
|
if (root.entity.active)
|
||||||
|
return Theme.alpha(Theme.warn, 0.17);
|
||||||
|
return Theme.alpha(Theme.fg, 0.065);
|
||||||
|
}
|
||||||
|
activeFocusOnTab: root.powerEnabled
|
||||||
|
|
||||||
Behavior on color { ColorAnimation { duration: Theme.durFast } }
|
Behavior on color { ColorAnimation { duration: Theme.durFast } }
|
||||||
|
Behavior on border.color { ColorAnimation { duration: Theme.durFast } }
|
||||||
|
|
||||||
|
Keys.onSpacePressed: {
|
||||||
|
if (root.powerEnabled)
|
||||||
|
root.powerRequested();
|
||||||
|
}
|
||||||
|
Keys.onReturnPressed: {
|
||||||
|
if (root.powerEnabled)
|
||||||
|
root.powerRequested();
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: bulb
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 13
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.topMargin: 13
|
||||||
|
width: 30
|
||||||
|
height: 30
|
||||||
|
radius: 10
|
||||||
|
color: root.entity.active
|
||||||
|
? Theme.alpha(Theme.warn, 0.16)
|
||||||
|
: Theme.alpha(Theme.fg, 0.065)
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
anchors.left: parent.left
|
anchors.centerIn: parent
|
||||||
anchors.leftMargin: 10
|
text: root.busy ? "\u{F0772}" : "\u{F0335}"
|
||||||
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
|
color: root.entity.active ? Theme.warn : Theme.fgDim
|
||||||
font.family: Theme.fontMono
|
font.family: Theme.fontMono
|
||||||
font.pixelSize: 15
|
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 {
|
Text {
|
||||||
width: parent.width
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 13
|
||||||
|
anchors.verticalCenter: bulb.verticalCenter
|
||||||
|
text: root.entity.dimmable ? root.displayedBrightness + "%" : "—"
|
||||||
|
color: root.entity.active || root.pendingBrightness >= 0
|
||||||
|
? Theme.warn
|
||||||
|
: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
font.features: Theme.tabularFigures
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: aliasLabel
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 13
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 13
|
||||||
|
anchors.top: bulb.bottom
|
||||||
|
anchors.topMargin: 8
|
||||||
text: root.entity.name
|
text: root.entity.name
|
||||||
color: Theme.fg
|
color: Theme.fg
|
||||||
elide: Text.ElideRight
|
elide: Text.ElideRight
|
||||||
font.family: Theme.fontFamily
|
font.family: Theme.fontFamily
|
||||||
font.pixelSize: Theme.fontSizeSmall
|
font.pixelSize: Theme.fontSize
|
||||||
font.weight: Font.DemiBold
|
font.weight: Font.DemiBold
|
||||||
}
|
}
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
width: parent.width
|
anchors.left: aliasLabel.left
|
||||||
text: !root.entity.available ? "Unavailable" : (root.entity.active ? "On" : "Off")
|
anchors.right: aliasLabel.right
|
||||||
color: root.entity.active ? Theme.warn : Theme.fgMuted
|
anchors.top: aliasLabel.bottom
|
||||||
|
anchors.topMargin: 2
|
||||||
|
text: root.secondaryText()
|
||||||
|
color: root.actionError !== ""
|
||||||
|
? Theme.warn
|
||||||
|
: (root.entity.active ? Theme.warn : Theme.fgMuted)
|
||||||
|
elide: Text.ElideRight
|
||||||
font.family: Theme.fontFamily
|
font.family: Theme.fontFamily
|
||||||
font.pixelSize: 9
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
MouseArea {
|
MouseArea {
|
||||||
id: tileMouse
|
id: powerArea
|
||||||
anchors.fill: parent
|
anchors.left: parent.left
|
||||||
enabled: root.entity.available && !root.busy
|
anchors.right: parent.right
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.bottom: brightnessSlider.top
|
||||||
|
anchors.bottomMargin: 1
|
||||||
|
enabled: root.powerEnabled
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||||
onClicked: root.activated()
|
onPressed: root.forceActiveFocus()
|
||||||
|
onClicked: root.powerRequested()
|
||||||
}
|
}
|
||||||
|
|
||||||
function glyphFor(domain: string): string {
|
HomeBrightnessSlider {
|
||||||
if (domain === "light")
|
id: brightnessSlider
|
||||||
return "\u{F0335}";
|
anchors.left: parent.left
|
||||||
if (domain === "switch")
|
anchors.leftMargin: 13
|
||||||
return "\u{F0521}";
|
anchors.right: parent.right
|
||||||
if (domain === "scene")
|
anchors.rightMargin: 13
|
||||||
return "\u{F0FCE}";
|
anchors.bottom: parent.bottom
|
||||||
return "\u{F02DC}";
|
anchors.bottomMargin: 5
|
||||||
|
value: root.pendingBrightness >= 0
|
||||||
|
? root.pendingBrightness
|
||||||
|
: root.confirmedBrightness
|
||||||
|
enabled: root.dimmerEnabled
|
||||||
|
accessibleName: root.entity.name + " brightness"
|
||||||
|
onCommitted: value => root.brightnessRequested(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(candidate: real): int {
|
||||||
|
return Math.max(0, Math.min(100, Math.round(candidate)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function secondaryText(): string {
|
||||||
|
if (root.actionError !== "")
|
||||||
|
return root.errorText(root.actionError);
|
||||||
|
if (!root.entity.available)
|
||||||
|
return "Unavailable";
|
||||||
|
if (root.busy && root.pendingBrightness >= 0)
|
||||||
|
return "Setting " + root.pendingBrightness + "%";
|
||||||
|
if (root.busy)
|
||||||
|
return "Updating";
|
||||||
|
return root.entity.active ? "On" : "Off";
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorText(code: string): string {
|
||||||
|
if (code === "authentication-required")
|
||||||
|
return "Authentication required";
|
||||||
|
if (code === "entity-not-discovered")
|
||||||
|
return "Light is unavailable";
|
||||||
|
return "Couldn’t update light";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
// Nonvisual owner of Phone Controls' stable action model and enablement rules.
|
||||||
|
// Keeping this logic free of delegates and icons makes it safe to exercise in
|
||||||
|
// disposable diagnostic engines.
|
||||||
|
QtObject {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
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" },
|
||||||
|
{ id: "messages", glyph: "\u{F0365}", label: "Messages" }
|
||||||
|
]
|
||||||
|
|
||||||
|
function actionEnabled(action: string): bool {
|
||||||
|
if (action === "messages")
|
||||||
|
return SystemSettings.bluebubblesAvailable;
|
||||||
|
return KdeConnect.phoneReachable
|
||||||
|
&& !KdeConnect.transferActive
|
||||||
|
&& KdeConnect.supports(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,11 +10,11 @@ Item {
|
|||||||
signal toggleExpanded
|
signal toggleExpanded
|
||||||
|
|
||||||
readonly property var phone: KdeConnect.preferredPhone
|
readonly property var phone: KdeConnect.preferredPhone
|
||||||
readonly property var actionModels: [
|
readonly property var actionModels: phoneActions.actionModels
|
||||||
{ id: "share", glyph: "\u{F0142}", label: "Send file" },
|
|
||||||
{ id: "clipboard", glyph: "\u{F014C}", label: "Clipboard" },
|
PhoneActions {
|
||||||
{ id: "ring", glyph: "\u{F009A}", label: "Ring" }
|
id: phoneActions
|
||||||
].filter(item => KdeConnect.supports(item.id))
|
}
|
||||||
|
|
||||||
implicitHeight: content.implicitHeight
|
implicitHeight: content.implicitHeight
|
||||||
|
|
||||||
@@ -120,9 +120,8 @@ Item {
|
|||||||
Grid {
|
Grid {
|
||||||
id: actionsGrid
|
id: actionsGrid
|
||||||
width: parent.width
|
width: parent.width
|
||||||
columns: Math.max(1, root.actionModels.length)
|
columns: 4
|
||||||
spacing: 7
|
spacing: 7
|
||||||
visible: root.actionModels.length > 0
|
|
||||||
|
|
||||||
Repeater {
|
Repeater {
|
||||||
model: root.actionModels
|
model: root.actionModels
|
||||||
@@ -136,8 +135,20 @@ Item {
|
|||||||
color: actionMouse.containsMouse && actionMouse.enabled
|
color: actionMouse.containsMouse && actionMouse.enabled
|
||||||
? Theme.alpha(Theme.accent, 0.15)
|
? Theme.alpha(Theme.accent, 0.15)
|
||||||
: Theme.alpha(Theme.accent, 0.075)
|
: Theme.alpha(Theme.accent, 0.075)
|
||||||
|
border.width: actionMouse.activeFocus ? 2 : 0
|
||||||
|
border.color: Theme.accent
|
||||||
opacity: actionMouse.enabled ? 1 : 0.48
|
opacity: actionMouse.enabled ? 1 : 0.48
|
||||||
|
|
||||||
|
Accessible.role: Accessible.Button
|
||||||
|
Accessible.name: actionButton.modelData.label
|
||||||
|
Accessible.description: root.actionAccessibleDescription(actionButton.modelData.id)
|
||||||
|
Accessible.focusable: actionMouse.enabled
|
||||||
|
Accessible.focused: actionMouse.activeFocus
|
||||||
|
Accessible.onPressAction: {
|
||||||
|
if (actionMouse.enabled)
|
||||||
|
root.invoke(actionButton.modelData.id);
|
||||||
|
}
|
||||||
|
|
||||||
Text {
|
Text {
|
||||||
anchors.horizontalCenter: parent.horizontalCenter
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
anchors.top: parent.top
|
anchors.top: parent.top
|
||||||
@@ -160,10 +171,13 @@ Item {
|
|||||||
MouseArea {
|
MouseArea {
|
||||||
id: actionMouse
|
id: actionMouse
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
enabled: KdeConnect.phoneReachable && !KdeConnect.transferActive
|
enabled: root.actionEnabled(actionButton.modelData.id)
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
|
activeFocusOnTab: enabled
|
||||||
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||||
onClicked: root.invoke(actionButton.modelData.id)
|
onClicked: root.invoke(actionButton.modelData.id)
|
||||||
|
Keys.onReturnPressed: root.invoke(actionButton.modelData.id)
|
||||||
|
Keys.onSpacePressed: root.invoke(actionButton.modelData.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,7 +186,17 @@ Item {
|
|||||||
Text {
|
Text {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
visible: root.phone && !KdeConnect.phoneReachable && root.actionModels.length > 0
|
visible: root.phone && !KdeConnect.phoneReachable && root.actionModels.length > 0
|
||||||
text: "Actions become available when the iPhone reconnects"
|
text: "KDE Connect actions are unavailable until the iPhone reconnects"
|
||||||
|
color: Theme.fgMuted
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: !SystemSettings.bluebubblesAvailable
|
||||||
|
text: "BlueBubbles is not installed"
|
||||||
color: Theme.fgMuted
|
color: Theme.fgMuted
|
||||||
horizontalAlignment: Text.AlignHCenter
|
horizontalAlignment: Text.AlignHCenter
|
||||||
font.family: Theme.fontFamily
|
font.family: Theme.fontFamily
|
||||||
@@ -254,6 +278,20 @@ Item {
|
|||||||
KdeConnect.sendClipboard();
|
KdeConnect.sendClipboard();
|
||||||
else if (action === "ring")
|
else if (action === "ring")
|
||||||
KdeConnect.ring();
|
KdeConnect.ring();
|
||||||
|
else if (action === "messages") {
|
||||||
|
if (SystemSettings.bluebubblesAvailable && SystemSettings.openApplication("bluebubbles"))
|
||||||
|
ShellState.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionEnabled(action: string): bool {
|
||||||
|
return phoneActions.actionEnabled(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionAccessibleDescription(action: string): string {
|
||||||
|
if (action === "messages")
|
||||||
|
return SystemSettings.bluebubblesAvailable ? "Opens BlueBubbles" : "BlueBubbles is not installed";
|
||||||
|
return root.actionEnabled(action) ? "Available through KDE Connect" : "Unavailable through KDE Connect";
|
||||||
}
|
}
|
||||||
|
|
||||||
function localPath(selectedUrl: url): string {
|
function localPath(selectedUrl: url): string {
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
module qs.modules.quicksettings
|
||||||
|
AudioDeviceList 1.0 AudioDeviceList.qml
|
||||||
|
AudioSlider 1.0 AudioSlider.qml
|
||||||
|
BluetoothList 1.0 BluetoothList.qml
|
||||||
|
BrightnessControl 1.0 BrightnessControl.qml
|
||||||
|
ControlSectionHeader 1.0 ControlSectionHeader.qml
|
||||||
|
HomeBrightnessSlider 1.0 HomeBrightnessSlider.qml
|
||||||
|
HomeControls 1.0 HomeControls.qml
|
||||||
|
HomeTile 1.0 HomeTile.qml
|
||||||
|
IconButton 1.0 IconButton.qml
|
||||||
|
PhoneActions 1.0 PhoneActions.qml
|
||||||
|
PhoneControls 1.0 PhoneControls.qml
|
||||||
|
QuickSettings 1.0 QuickSettings.qml
|
||||||
|
QuickSettingsPanel 1.0 QuickSettingsPanel.qml
|
||||||
|
RecentExchange 1.0 RecentExchange.qml
|
||||||
|
RowButton 1.0 RowButton.qml
|
||||||
|
ScrollColumn 1.0 ScrollColumn.qml
|
||||||
|
Section 1.0 Section.qml
|
||||||
|
WifiList 1.0 WifiList.qml
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
required property var entity
|
||||||
|
signal addRequested(string id)
|
||||||
|
|
||||||
|
width: parent ? parent.width : 620
|
||||||
|
implicitHeight: 62
|
||||||
|
|
||||||
|
Column {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: stateCopy.left
|
||||||
|
anchors.rightMargin: 16
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 3
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: root.entity.sourceName
|
||||||
|
color: Theme.fg
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
font.weight: Font.Medium
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
text: root.entity.id
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
elide: Text.ElideMiddle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: stateCopy
|
||||||
|
anchors.right: addButton.left
|
||||||
|
anchors.rightMargin: 14
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 92
|
||||||
|
text: root.entity.available === false ? "Unavailable" : String(root.entity.state || "Unknown")
|
||||||
|
color: root.entity.available === false ? Theme.fgMuted : Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
horizontalAlignment: Text.AlignRight
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
id: addButton
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "Add"
|
||||||
|
tone: "accent"
|
||||||
|
activeFocusOnTab: true
|
||||||
|
border.width: activeFocus ? 2 : 0
|
||||||
|
border.color: activeFocus ? Theme.fg : Theme.alpha(Theme.fg, 0)
|
||||||
|
onClicked: root.addRequested(root.entity.id)
|
||||||
|
Keys.onReturnPressed: root.addRequested(root.entity.id)
|
||||||
|
Keys.onSpacePressed: root.addRequested(root.entity.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
height: 1
|
||||||
|
color: Theme.alpha(Theme.fg, 0.06)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
import qs.widgets
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
required property var favorite
|
||||||
|
required property string sourceName
|
||||||
|
required property int index
|
||||||
|
required property bool featured
|
||||||
|
|
||||||
|
signal aliasCommitted(string id, string alias)
|
||||||
|
signal removeRequested(string id)
|
||||||
|
signal moveRequested(string id, int targetIndex)
|
||||||
|
|
||||||
|
readonly property bool dragging: dragHandler.active
|
||||||
|
|
||||||
|
implicitHeight: 108
|
||||||
|
radius: Theme.cardRadius
|
||||||
|
color: root.dragging
|
||||||
|
? Theme.mix(Theme.bgDark, Theme.accent, 0.09)
|
||||||
|
: Theme.alpha(Theme.bgDark, 0.7)
|
||||||
|
border.width: root.dragging ? 2 : 1
|
||||||
|
border.color: root.dragging
|
||||||
|
? Theme.alpha(Theme.accent, 0.82)
|
||||||
|
: Theme.alpha(Theme.fg, 0.07)
|
||||||
|
z: root.dragging ? 10 : 0
|
||||||
|
|
||||||
|
transform: Translate {
|
||||||
|
x: root.dragging ? dragHandler.translation.x : 0
|
||||||
|
y: root.dragging ? dragHandler.translation.y : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
PrismEdge {
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
inset: root.radius
|
||||||
|
opacity: root.dragging ? 0.82 : 0.2
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: dragHandle
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 11
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
width: 30
|
||||||
|
height: 42
|
||||||
|
radius: 9
|
||||||
|
activeFocusOnTab: true
|
||||||
|
color: root.dragging || activeFocus
|
||||||
|
? Theme.alpha(Theme.accent, 0.14)
|
||||||
|
: (handleMouse.containsMouse ? Theme.alpha(Theme.fg, 0.09) : Theme.alpha(Theme.fg, 0.045))
|
||||||
|
border.width: activeFocus ? 2 : 1
|
||||||
|
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.06)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "⠿"
|
||||||
|
color: root.dragging ? Theme.accent : Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: 16
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: handleMouse
|
||||||
|
anchors.fill: parent
|
||||||
|
hoverEnabled: true
|
||||||
|
acceptedButtons: Qt.NoButton
|
||||||
|
cursorShape: Qt.SizeAllCursor
|
||||||
|
}
|
||||||
|
|
||||||
|
DragHandler {
|
||||||
|
id: dragHandler
|
||||||
|
target: null
|
||||||
|
onActiveChanged: {
|
||||||
|
if (!active)
|
||||||
|
root.commitDrag();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Keys.onPressed: event => {
|
||||||
|
if (event.key === Qt.Key_Left || event.key === Qt.Key_Up) {
|
||||||
|
root.moveRequested(root.favorite.id, Math.max(0, root.index - 1));
|
||||||
|
event.accepted = true;
|
||||||
|
} else if (event.key === Qt.Key_Right || event.key === Qt.Key_Down) {
|
||||||
|
const grid = root.GridView.view;
|
||||||
|
const lastIndex = grid ? grid.count - 1 : root.index;
|
||||||
|
root.moveRequested(root.favorite.id, Math.min(lastIndex, root.index + 1));
|
||||||
|
event.accepted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: aliasFrame
|
||||||
|
anchors.left: dragHandle.right
|
||||||
|
anchors.leftMargin: 10
|
||||||
|
anchors.right: removeButton.left
|
||||||
|
anchors.rightMargin: 12
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.topMargin: 12
|
||||||
|
height: 34
|
||||||
|
radius: 8
|
||||||
|
color: Theme.alpha(Theme.fg, aliasInput.activeFocus ? 0.075 : 0.045)
|
||||||
|
border.width: aliasInput.activeFocus ? 2 : 1
|
||||||
|
border.color: aliasInput.activeFocus
|
||||||
|
? Theme.alpha(Theme.accent, 0.78)
|
||||||
|
: Theme.alpha(Theme.fg, 0.065)
|
||||||
|
|
||||||
|
TextInput {
|
||||||
|
id: aliasInput
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.leftMargin: 10
|
||||||
|
anchors.rightMargin: 10
|
||||||
|
activeFocusOnTab: true
|
||||||
|
text: String(root.favorite.alias || "")
|
||||||
|
color: Theme.fg
|
||||||
|
selectionColor: Theme.accent
|
||||||
|
selectedTextColor: Theme.bgDark
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
font.weight: Font.Medium
|
||||||
|
verticalAlignment: TextInput.AlignVCenter
|
||||||
|
selectByMouse: true
|
||||||
|
clip: true
|
||||||
|
onEditingFinished: root.aliasCommitted(root.favorite.id, text)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.fill: parent
|
||||||
|
visible: aliasInput.text === "" && !aliasInput.activeFocus
|
||||||
|
text: root.sourceName
|
||||||
|
color: Theme.fgDim
|
||||||
|
font: aliasInput.font
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.left: aliasFrame.left
|
||||||
|
anchors.right: removeButton.left
|
||||||
|
anchors.rightMargin: 12
|
||||||
|
anchors.top: aliasFrame.bottom
|
||||||
|
anchors.topMargin: 7
|
||||||
|
text: root.sourceName
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.left: aliasFrame.left
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
anchors.bottomMargin: 10
|
||||||
|
width: badgeCopy.implicitWidth + 14
|
||||||
|
height: 21
|
||||||
|
radius: Theme.pillRadius
|
||||||
|
visible: root.featured
|
||||||
|
color: Theme.alpha(Theme.accent, 0.1)
|
||||||
|
border.width: 1
|
||||||
|
border.color: Theme.alpha(Theme.accent, 0.2)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: badgeCopy
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "Control Center"
|
||||||
|
color: Theme.accent
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: 9
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
id: removeButton
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 11
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "Remove"
|
||||||
|
activeFocusOnTab: true
|
||||||
|
border.width: activeFocus ? 2 : 1
|
||||||
|
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
||||||
|
onClicked: root.removeRequested(root.favorite.id)
|
||||||
|
Keys.onReturnPressed: root.removeRequested(root.favorite.id)
|
||||||
|
Keys.onSpacePressed: root.removeRequested(root.favorite.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitDrag(): void {
|
||||||
|
const grid = root.GridView.view;
|
||||||
|
if (!grid || grid.count <= 0)
|
||||||
|
return;
|
||||||
|
const centerX = root.x + dragHandler.translation.x + root.width / 2;
|
||||||
|
const centerY = root.y + dragHandler.translation.y + root.height / 2;
|
||||||
|
const modelCount = grid.count;
|
||||||
|
const column = Math.max(0, Math.min(1, Math.floor(centerX / grid.cellWidth)));
|
||||||
|
const row = Math.max(0, Math.floor(centerY / grid.cellHeight));
|
||||||
|
root.moveRequested(root.favorite.id, Math.min(modelCount - 1, row * 2 + column));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
objectName: "home-phone-page"
|
||||||
|
|
||||||
|
property string lightQuery: searchInput.text.trim().toLowerCase()
|
||||||
|
|
||||||
|
readonly property var availableLights: HomeAssistant.catalog.filter(entity => {
|
||||||
|
if (HomeAssistant.selectedEntities.some(selected => selected.id === entity.id))
|
||||||
|
return false;
|
||||||
|
const haystack = (entity.sourceName + " " + entity.id).toLowerCase();
|
||||||
|
return root.lightQuery === "" || haystack.includes(root.lightQuery);
|
||||||
|
})
|
||||||
|
readonly property string availableEmptyText: root.availableLights.length > 0
|
||||||
|
? ""
|
||||||
|
: (root.lightQuery !== ""
|
||||||
|
? "No lights match that search"
|
||||||
|
: (HomeAssistant.catalog.length === 0
|
||||||
|
? "No lights discovered"
|
||||||
|
: "All discovered lights are already selected"))
|
||||||
|
readonly property var pageDiagnostics: ({
|
||||||
|
availableLightIds: root.availableLights.map(entity => entity.id),
|
||||||
|
availableEmptyText: root.availableEmptyText,
|
||||||
|
homeStatus: root.homeStatus()
|
||||||
|
})
|
||||||
|
|
||||||
|
function homeStatus(): string {
|
||||||
|
if (HomeAssistant.lastError === "authentication-required")
|
||||||
|
return "Authentication required";
|
||||||
|
if (HomeAssistant.lastError === "not-configured")
|
||||||
|
return "Home Assistant is not configured";
|
||||||
|
if (HomeAssistant.phase === "ready")
|
||||||
|
return `Connected · ${HomeAssistant.discoveredCount} lights discovered`;
|
||||||
|
if (HomeAssistant.phase === "degraded")
|
||||||
|
return "Last update unavailable · showing saved controls";
|
||||||
|
if (HomeAssistant.phase === "loading")
|
||||||
|
return "Connecting to Home Assistant";
|
||||||
|
return "Home Assistant is unavailable";
|
||||||
|
}
|
||||||
|
|
||||||
|
Flickable {
|
||||||
|
anchors.fill: parent
|
||||||
|
clip: true
|
||||||
|
contentWidth: width
|
||||||
|
contentHeight: content.implicitHeight + 64
|
||||||
|
boundsBehavior: Flickable.StopAtBounds
|
||||||
|
|
||||||
|
Column {
|
||||||
|
id: content
|
||||||
|
width: parent.width - 68
|
||||||
|
x: 34
|
||||||
|
y: 30
|
||||||
|
spacing: 16
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "Home & Phone"
|
||||||
|
color: Theme.fg
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: 27
|
||||||
|
font.weight: Font.DemiBold
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "Choose what appears in Control Center and keep phone continuity close at hand."
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
bottomPadding: 6
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Home Assistant"
|
||||||
|
subtitle: root.homeStatus()
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
label: "Light catalog"
|
||||||
|
detail: "Panama reads light state through the Home Assistant helper."
|
||||||
|
divider: false
|
||||||
|
controlWidth: 176
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
id: refreshButton
|
||||||
|
text: "Refresh"
|
||||||
|
activeFocusOnTab: true
|
||||||
|
border.width: activeFocus ? 2 : 1
|
||||||
|
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
||||||
|
onClicked: HomeAssistant.refresh()
|
||||||
|
Keys.onReturnPressed: HomeAssistant.refresh()
|
||||||
|
Keys.onSpacePressed: HomeAssistant.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
id: openHomeButton
|
||||||
|
text: "Open"
|
||||||
|
activeFocusOnTab: true
|
||||||
|
border.width: activeFocus ? 2 : 1
|
||||||
|
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
||||||
|
onClicked: HomeAssistant.open()
|
||||||
|
Keys.onReturnPressed: HomeAssistant.open()
|
||||||
|
Keys.onSpacePressed: HomeAssistant.open()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Control Center lights"
|
||||||
|
subtitle: HomeAssistant.selectedEntities.length === 0
|
||||||
|
? "Select the lights that belong on your shelf."
|
||||||
|
: `${Math.min(4, HomeAssistant.selectedEntities.length)} in Control Center · ${HomeAssistant.selectedEntities.length} selected`
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: HomeAssistant.selectedEntities.length === 0
|
||||||
|
text: "Choose lights below to build your Control Center shelf."
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
topPadding: 3
|
||||||
|
bottomPadding: 13
|
||||||
|
}
|
||||||
|
|
||||||
|
GridView {
|
||||||
|
id: favoritesGrid
|
||||||
|
width: parent.width
|
||||||
|
height: Math.ceil(count / 2) * cellHeight
|
||||||
|
visible: count > 0
|
||||||
|
interactive: false
|
||||||
|
clip: false
|
||||||
|
cellWidth: width / 2
|
||||||
|
cellHeight: 116
|
||||||
|
model: HomeAssistant.selectedEntities
|
||||||
|
|
||||||
|
delegate: HomeFavoriteCard {
|
||||||
|
required property var modelData
|
||||||
|
width: GridView.view.cellWidth - 6
|
||||||
|
height: 108
|
||||||
|
favorite: ({
|
||||||
|
id: modelData.id,
|
||||||
|
alias: modelData.name === modelData.sourceName ? "" : modelData.name
|
||||||
|
})
|
||||||
|
sourceName: modelData.sourceName
|
||||||
|
featured: index < 4
|
||||||
|
onAliasCommitted: (id, alias) => HomePreferences.setAlias(id, alias)
|
||||||
|
onMoveRequested: (id, targetIndex) => HomePreferences.move(id, targetIndex)
|
||||||
|
onRemoveRequested: id => HomePreferences.remove(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
height: 46
|
||||||
|
visible: HomePreferences.saveError !== ""
|
||||||
|
radius: 9
|
||||||
|
color: Theme.alpha(Theme.warn, 0.09)
|
||||||
|
border.width: 1
|
||||||
|
border.color: Theme.alpha(Theme.warn, 0.24)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 12
|
||||||
|
anchors.right: retryButton.left
|
||||||
|
anchors.rightMargin: 12
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: HomePreferences.saveError
|
||||||
|
color: Theme.warn
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
id: retryButton
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 8
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "Retry"
|
||||||
|
activeFocusOnTab: true
|
||||||
|
border.width: activeFocus ? 2 : 1
|
||||||
|
border.color: activeFocus ? Theme.warn : Theme.alpha(Theme.fg, 0.08)
|
||||||
|
onClicked: HomePreferences.retrySave()
|
||||||
|
Keys.onReturnPressed: HomePreferences.retrySave()
|
||||||
|
Keys.onSpacePressed: HomePreferences.retrySave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Available lights"
|
||||||
|
subtitle: "Search the Home Assistant catalog by source name or entity ID."
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: parent.width
|
||||||
|
height: 38
|
||||||
|
radius: 10
|
||||||
|
color: Theme.alpha(Theme.fg, searchInput.activeFocus ? 0.08 : 0.05)
|
||||||
|
border.width: searchInput.activeFocus ? 2 : 1
|
||||||
|
border.color: searchInput.activeFocus
|
||||||
|
? Theme.alpha(Theme.accent, 0.72)
|
||||||
|
: Theme.alpha(Theme.fg, 0.065)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 11
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: "\u{F0349}"
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontMono
|
||||||
|
font.pixelSize: 14
|
||||||
|
}
|
||||||
|
|
||||||
|
TextInput {
|
||||||
|
id: searchInput
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.leftMargin: 36
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.rightMargin: 11
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
activeFocusOnTab: true
|
||||||
|
color: Theme.fg
|
||||||
|
selectionColor: Theme.accent
|
||||||
|
selectedTextColor: Theme.bgDark
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.fill: parent
|
||||||
|
visible: searchInput.text === "" && !searchInput.activeFocus
|
||||||
|
text: "Search available lights"
|
||||||
|
color: Theme.fgMuted
|
||||||
|
font: searchInput.font
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.availableLights.length > 0
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: root.availableLights
|
||||||
|
|
||||||
|
AvailableLightRow {
|
||||||
|
required property var modelData
|
||||||
|
width: parent.width
|
||||||
|
entity: modelData
|
||||||
|
onAddRequested: id => HomePreferences.add(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
width: parent.width
|
||||||
|
visible: root.availableLights.length === 0
|
||||||
|
text: root.availableEmptyText
|
||||||
|
color: Theme.fgDim
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSize
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
topPadding: 18
|
||||||
|
bottomPadding: 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsCard {
|
||||||
|
title: "Phone continuity"
|
||||||
|
subtitle: "Keep the Messages handoff independent from phone connectivity."
|
||||||
|
|
||||||
|
SettingRow {
|
||||||
|
label: "Messages"
|
||||||
|
detail: "Opens BlueBubbles"
|
||||||
|
divider: false
|
||||||
|
controlWidth: 204
|
||||||
|
|
||||||
|
Row {
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
text: SystemSettings.bluebubblesAvailable ? "Installed" : "Unavailable"
|
||||||
|
color: SystemSettings.bluebubblesAvailable ? Theme.ok : Theme.fgMuted
|
||||||
|
font.family: Theme.fontFamily
|
||||||
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsButton {
|
||||||
|
id: openBlueBubblesButton
|
||||||
|
text: "Open"
|
||||||
|
enabled: SystemSettings.bluebubblesAvailable
|
||||||
|
activeFocusOnTab: enabled
|
||||||
|
border.width: activeFocus ? 2 : 1
|
||||||
|
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
||||||
|
onClicked: SystemSettings.openApplication("bluebubbles")
|
||||||
|
Keys.onReturnPressed: if (enabled) SystemSettings.openApplication("bluebubbles")
|
||||||
|
Keys.onSpacePressed: if (enabled) SystemSettings.openApplication("bluebubbles")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,11 @@ Rectangle {
|
|||||||
id: root
|
id: root
|
||||||
|
|
||||||
property var hostWindow: null
|
property var hostWindow: null
|
||||||
|
readonly property var homePhoneDiagnostics: pageLoader.status === Loader.Ready
|
||||||
|
&& pageLoader.item
|
||||||
|
&& pageLoader.item.objectName === "home-phone-page"
|
||||||
|
? pageLoader.item.pageDiagnostics
|
||||||
|
: ({})
|
||||||
|
|
||||||
color: Theme.bg
|
color: Theme.bg
|
||||||
radius: 18
|
radius: 18
|
||||||
@@ -88,6 +93,7 @@ Rectangle {
|
|||||||
case "appearance": return appearancePage;
|
case "appearance": return appearancePage;
|
||||||
case "displays": return displaysPage;
|
case "displays": return displaysPage;
|
||||||
case "connectivity": return connectivityPage;
|
case "connectivity": return connectivityPage;
|
||||||
|
case "home-phone": return homePhonePage;
|
||||||
case "desktop": return desktopPage;
|
case "desktop": return desktopPage;
|
||||||
case "sound": return soundPage;
|
case "sound": return soundPage;
|
||||||
case "notifications": return notificationsPage;
|
case "notifications": return notificationsPage;
|
||||||
@@ -133,6 +139,7 @@ Rectangle {
|
|||||||
Component { id: appearancePage; AppearancePage {} }
|
Component { id: appearancePage; AppearancePage {} }
|
||||||
Component { id: displaysPage; DisplaysPage {} }
|
Component { id: displaysPage; DisplaysPage {} }
|
||||||
Component { id: connectivityPage; ConnectivityPage {} }
|
Component { id: connectivityPage; ConnectivityPage {} }
|
||||||
|
Component { id: homePhonePage; HomePhonePage {} }
|
||||||
Component { id: desktopPage; DesktopPage {} }
|
Component { id: desktopPage; DesktopPage {} }
|
||||||
Component { id: soundPage; SoundPage {} }
|
Component { id: soundPage; SoundPage {} }
|
||||||
Component { id: notificationsPage; NotificationsPage {} }
|
Component { id: notificationsPage; NotificationsPage {} }
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ Rectangle {
|
|||||||
{ page: "appearance", label: "Appearance", icon: "\u{F0E0D}" },
|
{ page: "appearance", label: "Appearance", icon: "\u{F0E0D}" },
|
||||||
{ page: "displays", label: "Displays", icon: "\u{F0379}" },
|
{ page: "displays", label: "Displays", icon: "\u{F0379}" },
|
||||||
{ page: "connectivity", label: "Network & Devices", icon: "\u{F08D4}" },
|
{ page: "connectivity", label: "Network & Devices", icon: "\u{F08D4}" },
|
||||||
|
{ page: "home-phone", label: "Home & Phone", icon: "\u{F02DC}" },
|
||||||
{ page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}" },
|
{ page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}" },
|
||||||
{ page: "sound", label: "Sound", icon: "\u{F057E}" },
|
{ page: "sound", label: "Sound", icon: "\u{F057E}" },
|
||||||
{ page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}" },
|
{ page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}" },
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import qs.services
|
|||||||
FloatingWindow {
|
FloatingWindow {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
|
readonly property var homePhoneDiagnostics: settingsShell.homePhoneDiagnostics
|
||||||
|
|
||||||
title: "Panama Settings"
|
title: "Panama Settings"
|
||||||
visible: ShellState.settingsOpen
|
visible: ShellState.settingsOpen
|
||||||
implicitWidth: 1120
|
implicitWidth: 1120
|
||||||
@@ -23,6 +25,7 @@ FloatingWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SettingsShell {
|
SettingsShell {
|
||||||
|
id: settingsShell
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
hostWindow: root
|
hostWindow: root
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ module qs.modules.settings
|
|||||||
AboutPage 1.0 AboutPage.qml
|
AboutPage 1.0 AboutPage.qml
|
||||||
AppearancePage 1.0 AppearancePage.qml
|
AppearancePage 1.0 AppearancePage.qml
|
||||||
ConnectivityPage 1.0 ConnectivityPage.qml
|
ConnectivityPage 1.0 ConnectivityPage.qml
|
||||||
|
HomePhonePage 1.0 HomePhonePage.qml
|
||||||
|
HomeFavoriteCard 1.0 HomeFavoriteCard.qml
|
||||||
|
AvailableLightRow 1.0 AvailableLightRow.qml
|
||||||
DesktopPage 1.0 DesktopPage.qml
|
DesktopPage 1.0 DesktopPage.qml
|
||||||
DisplaysPage 1.0 DisplaysPage.qml
|
DisplaysPage 1.0 DisplaysPage.qml
|
||||||
HomePage 1.0 HomePage.qml
|
HomePage 1.0 HomePage.qml
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
import qs.modules.quicksettings
|
||||||
|
import qs.services
|
||||||
|
|
||||||
|
// Component-only diagnostic surface. The contract replaces the two services in
|
||||||
|
// its copied configuration with fixture singletons before this file is run.
|
||||||
|
ShellRoot {
|
||||||
|
PhoneActions {
|
||||||
|
id: phoneActions
|
||||||
|
}
|
||||||
|
|
||||||
|
IpcHandler {
|
||||||
|
target: "phone-controls-test"
|
||||||
|
|
||||||
|
function fixture(name: string): void {
|
||||||
|
KdeConnect.available = false;
|
||||||
|
KdeConnect.transferActive = false;
|
||||||
|
KdeConnect.lastError = "";
|
||||||
|
|
||||||
|
if (name === "offline") {
|
||||||
|
KdeConnect.devices = [{
|
||||||
|
id: "fixture-phone",
|
||||||
|
name: "Fixture iPhone",
|
||||||
|
type: "phone",
|
||||||
|
paired: true,
|
||||||
|
reachable: false,
|
||||||
|
actions: []
|
||||||
|
}];
|
||||||
|
} else if (name === "unsupported") {
|
||||||
|
KdeConnect.available = true;
|
||||||
|
KdeConnect.devices = [{
|
||||||
|
id: "fixture-phone",
|
||||||
|
name: "Fixture iPhone",
|
||||||
|
type: "phone",
|
||||||
|
paired: true,
|
||||||
|
reachable: true,
|
||||||
|
actions: ["share"]
|
||||||
|
}];
|
||||||
|
} else if (name === "transfer") {
|
||||||
|
KdeConnect.available = true;
|
||||||
|
KdeConnect.transferActive = true;
|
||||||
|
KdeConnect.devices = [{
|
||||||
|
id: "fixture-phone",
|
||||||
|
name: "Fixture iPhone",
|
||||||
|
type: "phone",
|
||||||
|
paired: true,
|
||||||
|
reachable: true,
|
||||||
|
actions: ["share", "clipboard", "ring"]
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function status(): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
actions: phoneActions.actionModels.map(action => ({
|
||||||
|
id: action.id,
|
||||||
|
enabled: phoneActions.actionEnabled(action.id)
|
||||||
|
})),
|
||||||
|
bluebubblesAvailable: SystemSettings.bluebubblesAvailable,
|
||||||
|
appLaunches: SystemSettings.launchCount,
|
||||||
|
phoneActions: KdeConnect.actionCount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,6 @@ import urllib.parse
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
from collections.abc import Callable, Mapping, Sequence
|
from collections.abc import Callable, Mapping, Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
ENV_KEYS = (
|
ENV_KEYS = (
|
||||||
@@ -49,7 +48,7 @@ class Config:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def configured(self) -> bool:
|
def configured(self) -> bool:
|
||||||
return bool(self.base_url and self.token and self.entity_ids)
|
return bool(self.base_url and self.token)
|
||||||
|
|
||||||
|
|
||||||
def compact_json(value: dict[str, object]) -> str:
|
def compact_json(value: dict[str, object]) -> str:
|
||||||
@@ -226,7 +225,7 @@ def request_json(
|
|||||||
config: Config,
|
config: Config,
|
||||||
method: str,
|
method: str,
|
||||||
path: str,
|
path: str,
|
||||||
payload: dict[str, str] | None = None,
|
payload: Mapping[str, object] | None = None,
|
||||||
) -> object:
|
) -> object:
|
||||||
if not config.base_url or not config.token:
|
if not config.base_url or not config.token:
|
||||||
raise BridgeError("not-configured")
|
raise BridgeError("not-configured")
|
||||||
@@ -266,71 +265,74 @@ def fallback_name(entity_id: str) -> str:
|
|||||||
return entity_id.split(".", 1)[1].replace("_", " ").title()
|
return entity_id.split(".", 1)[1].replace("_", " ").title()
|
||||||
|
|
||||||
|
|
||||||
def normalize_entities(
|
def normalize_catalog(raw: Sequence[object]) -> list[dict[str, object]]:
|
||||||
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]] = []
|
result: list[dict[str, object]] = []
|
||||||
for entity_id in configured:
|
for item in raw:
|
||||||
item = by_id.get(entity_id)
|
if not isinstance(item, dict):
|
||||||
if not item:
|
|
||||||
continue
|
continue
|
||||||
|
entity_id = item.get("entity_id")
|
||||||
attributes = item.get("attributes")
|
attributes = item.get("attributes")
|
||||||
if not isinstance(attributes, dict):
|
if not isinstance(entity_id, str) or not entity_id.startswith("light."):
|
||||||
|
continue
|
||||||
|
if not ENTITY_ID.fullmatch(entity_id) or not isinstance(attributes, dict):
|
||||||
continue
|
continue
|
||||||
state = str(item.get("state", "unavailable"))
|
state = str(item.get("state", "unavailable"))
|
||||||
available = state not in {"unknown", "unavailable"}
|
available = state not in {"unknown", "unavailable"}
|
||||||
friendly_name = attributes.get("friendly_name")
|
active = available and state == "on"
|
||||||
name = (
|
raw_brightness = attributes.get("brightness")
|
||||||
friendly_name.strip()
|
brightness_pct = (
|
||||||
if isinstance(friendly_name, str) and friendly_name.strip()
|
round(max(0, min(255, raw_brightness)) * 100 / 255)
|
||||||
else fallback_name(entity_id)
|
if active
|
||||||
|
and isinstance(raw_brightness, (int, float))
|
||||||
|
and not isinstance(raw_brightness, bool)
|
||||||
|
else 0
|
||||||
)
|
)
|
||||||
|
modes = attributes.get("supported_color_modes", [])
|
||||||
|
dimmable = (
|
||||||
|
isinstance(modes, list) and any(mode != "onoff" for mode in modes)
|
||||||
|
) or isinstance(raw_brightness, (int, float))
|
||||||
|
source_name = attributes.get("friendly_name")
|
||||||
result.append(
|
result.append(
|
||||||
{
|
{
|
||||||
"id": entity_id,
|
"id": entity_id,
|
||||||
"name": name,
|
"sourceName": (
|
||||||
"domain": entity_id.split(".", 1)[0],
|
source_name.strip()
|
||||||
|
if isinstance(source_name, str) and source_name.strip()
|
||||||
|
else fallback_name(entity_id)
|
||||||
|
),
|
||||||
"state": state,
|
"state": state,
|
||||||
"available": available,
|
"available": available,
|
||||||
"active": available
|
"active": active,
|
||||||
and state not in {"off", "closed", "idle", "standby"},
|
"dimmable": dimmable,
|
||||||
|
"brightnessPct": brightness_pct,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def ensure_configured(entity_id: str, configured: Sequence[str]) -> str:
|
def collect_catalog(config: Config) -> dict[str, object]:
|
||||||
if entity_id not in configured:
|
legacy_entity_ids = list(config.entity_ids)
|
||||||
raise ValueError("entity-not-configured")
|
|
||||||
return entity_id
|
|
||||||
|
|
||||||
|
|
||||||
def collect_snapshot(config: Config) -> dict[str, object]:
|
|
||||||
if not config.configured:
|
if not config.configured:
|
||||||
return {
|
return {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"configured": False,
|
"configured": False,
|
||||||
"generatedAt": int(time.time()),
|
"generatedAt": int(time.time()),
|
||||||
"entities": [],
|
"entities": [],
|
||||||
|
"legacyEntityIds": legacy_entity_ids,
|
||||||
"error": "not-configured",
|
"error": "not-configured",
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
raw = request_json(config, "GET", "/api/states")
|
raw = request_json(config, "GET", "/api/states")
|
||||||
if not isinstance(raw, list):
|
if not isinstance(raw, list):
|
||||||
raise BridgeError("invalid-response")
|
raise BridgeError("invalid-response")
|
||||||
entities = normalize_entities(raw, config.entity_ids)
|
entities = normalize_catalog(raw)
|
||||||
except BridgeError as error:
|
except BridgeError as error:
|
||||||
return {
|
return {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"configured": True,
|
"configured": True,
|
||||||
"generatedAt": int(time.time()),
|
"generatedAt": int(time.time()),
|
||||||
"entities": [],
|
"entities": [],
|
||||||
|
"legacyEntityIds": legacy_entity_ids,
|
||||||
"error": str(error),
|
"error": str(error),
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -338,10 +340,28 @@ def collect_snapshot(config: Config) -> dict[str, object]:
|
|||||||
"configured": True,
|
"configured": True,
|
||||||
"generatedAt": int(time.time()),
|
"generatedAt": int(time.time()),
|
||||||
"entities": entities,
|
"entities": entities,
|
||||||
|
"legacyEntityIds": legacy_entity_ids,
|
||||||
"error": "",
|
"error": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def collect_snapshot(config: Config) -> dict[str, object]:
|
||||||
|
return collect_catalog(config)
|
||||||
|
|
||||||
|
|
||||||
|
def discovered_light_ids(config: Config) -> set[str]:
|
||||||
|
raw = request_json(config, "GET", "/api/states")
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
raise BridgeError("invalid-response")
|
||||||
|
return {item["id"] for item in normalize_catalog(raw) if isinstance(item["id"], str)}
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_discovered(config: Config, entity_id: str) -> str:
|
||||||
|
if entity_id not in discovered_light_ids(config):
|
||||||
|
raise ValueError("entity-not-discovered")
|
||||||
|
return entity_id
|
||||||
|
|
||||||
|
|
||||||
def probe(config: Config) -> dict[str, object]:
|
def probe(config: Config) -> dict[str, object]:
|
||||||
if not config.configured:
|
if not config.configured:
|
||||||
return {
|
return {
|
||||||
@@ -368,7 +388,7 @@ def probe(config: Config) -> dict[str, object]:
|
|||||||
|
|
||||||
|
|
||||||
def toggle(config: Config, entity_id: str) -> dict[str, object]:
|
def toggle(config: Config, entity_id: str) -> dict[str, object]:
|
||||||
entity_id = ensure_configured(entity_id, config.entity_ids)
|
entity_id = ensure_discovered(config, entity_id)
|
||||||
request_json(
|
request_json(
|
||||||
config,
|
config,
|
||||||
"POST",
|
"POST",
|
||||||
@@ -378,6 +398,31 @@ def toggle(config: Config, entity_id: str) -> dict[str, object]:
|
|||||||
return {"ok": True, "entityId": entity_id, "error": ""}
|
return {"ok": True, "entityId": entity_id, "error": ""}
|
||||||
|
|
||||||
|
|
||||||
|
def set_brightness(
|
||||||
|
config: Config, entity_id: str, percent: int
|
||||||
|
) -> dict[str, object]:
|
||||||
|
if isinstance(percent, bool) or not isinstance(percent, int) or not 0 <= percent <= 100:
|
||||||
|
raise ValueError("invalid-brightness")
|
||||||
|
ensure_discovered(config, entity_id)
|
||||||
|
if percent == 0:
|
||||||
|
path = "/api/services/light/turn_off"
|
||||||
|
payload = {"entity_id": entity_id}
|
||||||
|
else:
|
||||||
|
path = "/api/services/light/turn_on"
|
||||||
|
payload = {"entity_id": entity_id, "brightness_pct": percent}
|
||||||
|
request_json(config, "POST", path, payload)
|
||||||
|
return {"ok": True, "entityId": entity_id, "brightnessPct": percent, "error": ""}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_brightness(value: str) -> int:
|
||||||
|
if not re.fullmatch(r"(?:0|[1-9][0-9]{0,2})", value):
|
||||||
|
raise ValueError("invalid-brightness")
|
||||||
|
percent = int(value)
|
||||||
|
if percent > 100:
|
||||||
|
raise ValueError("invalid-brightness")
|
||||||
|
return percent
|
||||||
|
|
||||||
|
|
||||||
def open_home(config: Config) -> dict[str, object]:
|
def open_home(config: Config) -> dict[str, object]:
|
||||||
if not config.base_url:
|
if not config.base_url:
|
||||||
return {"ok": False, "error": "not-configured"}
|
return {"ok": False, "error": "not-configured"}
|
||||||
@@ -409,8 +454,8 @@ def main(argv: list[str]) -> int:
|
|||||||
if command == "probe" and len(argv) <= 1:
|
if command == "probe" and len(argv) <= 1:
|
||||||
result = probe(config)
|
result = probe(config)
|
||||||
success = bool(result["reachable"])
|
success = bool(result["reachable"])
|
||||||
elif command == "snapshot" and len(argv) == 1:
|
elif command in {"catalog", "snapshot"} and len(argv) == 1:
|
||||||
result = collect_snapshot(config)
|
result = collect_catalog(config)
|
||||||
success = bool(result["ok"])
|
success = bool(result["ok"])
|
||||||
elif command == "toggle" and len(argv) == 2:
|
elif command == "toggle" and len(argv) == 2:
|
||||||
try:
|
try:
|
||||||
@@ -420,6 +465,14 @@ def main(argv: list[str]) -> int:
|
|||||||
except BridgeError as error:
|
except BridgeError as error:
|
||||||
result = {"ok": False, "error": str(error)}
|
result = {"ok": False, "error": str(error)}
|
||||||
success = bool(result["ok"])
|
success = bool(result["ok"])
|
||||||
|
elif command == "brightness" and len(argv) == 3:
|
||||||
|
try:
|
||||||
|
result = set_brightness(config, argv[1], parse_brightness(argv[2]))
|
||||||
|
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:
|
elif command == "open" and len(argv) == 1:
|
||||||
result = open_home(config)
|
result = open_home(config)
|
||||||
success = bool(result["ok"])
|
success = bool(result["ok"])
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
pragma Singleton
|
pragma Singleton
|
||||||
|
|
||||||
// Home Assistant state for the Control Center. Credentials and REST details
|
// Home Assistant state for the Control Center. Credentials and REST details
|
||||||
// remain behind the helper; QML receives configured favourites only.
|
// remain behind the helper; QML composes its catalog with Panama preferences.
|
||||||
|
|
||||||
import Quickshell
|
import Quickshell
|
||||||
import Quickshell.Io
|
import Quickshell.Io
|
||||||
import QtQuick
|
import QtQuick
|
||||||
|
import qs.config
|
||||||
|
|
||||||
Singleton {
|
Singleton {
|
||||||
id: root
|
id: root
|
||||||
@@ -14,26 +15,53 @@ Singleton {
|
|||||||
|
|
||||||
// "loading" | "ready" | "degraded" | "unavailable"
|
// "loading" | "ready" | "degraded" | "unavailable"
|
||||||
property string phase: "loading"
|
property string phase: "loading"
|
||||||
property var entities: []
|
property var catalog: []
|
||||||
|
property var selectedEntities: []
|
||||||
property bool stale: false
|
property bool stale: false
|
||||||
property string busyEntityId: ""
|
|
||||||
property string lastError: ""
|
property string lastError: ""
|
||||||
property bool fixtureMode: false
|
property bool fixtureMode: false
|
||||||
|
property var fixtureFavorites: []
|
||||||
|
property string fixtureProcessMode: ""
|
||||||
|
|
||||||
readonly property var visibleEntities: root.entities.slice(0, 4)
|
property var actionQueue: []
|
||||||
readonly property int configuredCount: root.entities.length
|
property var activeAction: null
|
||||||
|
property var busyEntityIds: []
|
||||||
|
property var pendingBrightness: ({})
|
||||||
|
property var entityErrors: ({})
|
||||||
|
property string actionResponseText: ""
|
||||||
|
property bool actionStreamFinished: false
|
||||||
|
property bool actionExited: false
|
||||||
|
property int actionExitCode: 0
|
||||||
|
property bool fixtureTransitionDraining: false
|
||||||
|
property bool fixtureTransitionStreamFinished: false
|
||||||
|
property bool fixtureTransitionExited: false
|
||||||
|
property var pendingFixtureTarget: null
|
||||||
|
property int delayedFixtureExitCode: 0
|
||||||
|
|
||||||
|
readonly property var visibleEntities: root.selectedEntities.slice(0, 4)
|
||||||
|
readonly property int discoveredCount: root.catalog.length
|
||||||
|
readonly property int configuredCount: root.selectedEntities.length
|
||||||
|
readonly property bool actionProcessRunning: actionProc.running
|
||||||
|
|
||||||
|
// Temporary aliases keep the current Home controls usable until the shelf
|
||||||
|
// switches to the selected-entity and per-entity action interfaces.
|
||||||
|
readonly property var entities: root.selectedEntities
|
||||||
|
readonly property string busyEntityId: root.busyEntityIds.length > 0
|
||||||
|
? root.busyEntityIds[0]
|
||||||
|
: ""
|
||||||
|
|
||||||
function refresh(): void {
|
function refresh(): void {
|
||||||
if (root.fixtureMode || refreshProc.running)
|
if (root.fixtureMode || refreshProc.running)
|
||||||
return;
|
return;
|
||||||
if (root.entities.length === 0)
|
if (root.catalog.length === 0)
|
||||||
root.phase = "loading";
|
root.phase = "loading";
|
||||||
refreshProc.running = true;
|
refreshProc.running = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function consumeSnapshot(text: string): void {
|
function consumeCatalog(text: string): void {
|
||||||
if (root.fixtureMode)
|
if (root.fixtureMode)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
let result = null;
|
let result = null;
|
||||||
try {
|
try {
|
||||||
result = JSON.parse(text);
|
result = JSON.parse(text);
|
||||||
@@ -42,7 +70,11 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (result.ok === true) {
|
if (result.ok === true) {
|
||||||
root.entities = Array.isArray(result.entities) ? result.entities : [];
|
root.catalog = Array.isArray(result.entities) ? result.entities : [];
|
||||||
|
HomePreferences.initialize(Array.isArray(result.legacyEntityIds)
|
||||||
|
? result.legacyEntityIds
|
||||||
|
: []);
|
||||||
|
root.rebuildSelection();
|
||||||
root.phase = "ready";
|
root.phase = "ready";
|
||||||
root.stale = false;
|
root.stale = false;
|
||||||
root.lastError = "";
|
root.lastError = "";
|
||||||
@@ -50,7 +82,7 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
root.lastError = String(result.error || "unreachable");
|
root.lastError = String(result.error || "unreachable");
|
||||||
if (root.entities.length > 0) {
|
if (root.catalog.length > 0) {
|
||||||
root.phase = "degraded";
|
root.phase = "degraded";
|
||||||
root.stale = true;
|
root.stale = true;
|
||||||
} else {
|
} else {
|
||||||
@@ -59,50 +91,261 @@ Singleton {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rebuildSelection(): void {
|
||||||
|
const favorites = root.fixtureMode
|
||||||
|
? root.fixtureFavorites
|
||||||
|
: HomePreferences.favorites;
|
||||||
|
const catalogById = {};
|
||||||
|
for (let index = 0; index < root.catalog.length; index++) {
|
||||||
|
const entity = root.catalog[index];
|
||||||
|
catalogById[entity.id] = entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextSelection = [];
|
||||||
|
for (let index = 0; index < favorites.length; index++) {
|
||||||
|
const favorite = favorites[index];
|
||||||
|
const entity = catalogById[favorite.id];
|
||||||
|
const alias = String(favorite.alias || "").trim();
|
||||||
|
if (entity) {
|
||||||
|
nextSelection.push({
|
||||||
|
id: entity.id,
|
||||||
|
sourceName: entity.sourceName,
|
||||||
|
name: alias || entity.sourceName,
|
||||||
|
state: entity.state,
|
||||||
|
available: entity.available,
|
||||||
|
active: entity.active,
|
||||||
|
dimmable: entity.dimmable,
|
||||||
|
brightnessPct: entity.brightnessPct
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceName = favorite.id.split(".")[1].split("_").join(" ");
|
||||||
|
nextSelection.push({
|
||||||
|
id: favorite.id,
|
||||||
|
sourceName,
|
||||||
|
name: alias || sourceName,
|
||||||
|
state: "unavailable",
|
||||||
|
available: false,
|
||||||
|
active: false,
|
||||||
|
dimmable: false,
|
||||||
|
brightnessPct: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
root.selectedEntities = nextSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: HomePreferences
|
||||||
|
function onFavoritesChanged(): void {
|
||||||
|
if (!root.fixtureMode)
|
||||||
|
root.rebuildSelection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBusy(entityId: string): bool {
|
||||||
|
return root.busyEntityIds.indexOf(entityId) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pendingFor(entityId: string): int {
|
||||||
|
return Object.prototype.hasOwnProperty.call(root.pendingBrightness, entityId)
|
||||||
|
? root.pendingBrightness[entityId]
|
||||||
|
: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorFor(entityId: string): string {
|
||||||
|
return String(root.entityErrors[entityId] || "");
|
||||||
|
}
|
||||||
|
|
||||||
function toggleEntity(entityId: string): void {
|
function toggleEntity(entityId: string): void {
|
||||||
if (entityId === "" || root.busyEntityId !== "")
|
root.enqueueAction({ kind: "toggle", entityId });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBrightness(entityId: string, percent: int): void {
|
||||||
|
if (!Number.isInteger(percent) || percent < 0 || percent > 100)
|
||||||
return;
|
return;
|
||||||
if (root.fixtureMode) {
|
root.enqueueAction({ kind: "brightness", entityId, percent });
|
||||||
root.entities = root.entities.map(entity => {
|
}
|
||||||
if (entity.id !== entityId)
|
|
||||||
|
function enqueueAction(action: var): void {
|
||||||
|
if (root.fixtureTransitionDraining)
|
||||||
|
return;
|
||||||
|
if (!action || (action.kind !== "toggle" && action.kind !== "brightness"))
|
||||||
|
return;
|
||||||
|
if (typeof action.entityId !== "string" || action.entityId === "" || root.isBusy(action.entityId))
|
||||||
|
return;
|
||||||
|
|
||||||
|
const entity = root.catalog.find(candidate => candidate.id === action.entityId);
|
||||||
|
if (!entity || !entity.available || (action.kind === "brightness" && !entity.dimmable))
|
||||||
|
return;
|
||||||
|
|
||||||
|
const nextErrors = Object.assign({}, root.entityErrors);
|
||||||
|
delete nextErrors[action.entityId];
|
||||||
|
root.entityErrors = nextErrors;
|
||||||
|
root.busyEntityIds = root.busyEntityIds.concat([action.entityId]);
|
||||||
|
|
||||||
|
if (action.kind === "brightness") {
|
||||||
|
const nextPending = Object.assign({}, root.pendingBrightness);
|
||||||
|
nextPending[action.entityId] = action.percent;
|
||||||
|
root.pendingBrightness = nextPending;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root.fixtureMode && root.fixtureProcessMode === "") {
|
||||||
|
root.applyFixtureAction(action);
|
||||||
|
root.finishActionState(action.entityId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.actionQueue = root.actionQueue.concat([action]);
|
||||||
|
root.startNextAction();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startNextAction(): void {
|
||||||
|
if (root.fixtureTransitionDraining
|
||||||
|
|| actionProc.running
|
||||||
|
|| root.activeAction !== null
|
||||||
|
|| root.actionQueue.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.activeAction = root.actionQueue[0];
|
||||||
|
root.actionResponseText = "";
|
||||||
|
root.actionStreamFinished = false;
|
||||||
|
root.actionExited = false;
|
||||||
|
root.actionExitCode = 0;
|
||||||
|
if (root.fixtureProcessMode !== "") {
|
||||||
|
actionProc.command = root.fixtureActionCommand(root.activeAction);
|
||||||
|
} else {
|
||||||
|
actionProc.command = root.activeAction.kind === "brightness"
|
||||||
|
? [root.helperPath, "brightness", root.activeAction.entityId, String(root.activeAction.percent)]
|
||||||
|
: [root.helperPath, "toggle", root.activeAction.entityId];
|
||||||
|
}
|
||||||
|
actionProc.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixtureActionCommand(action: var): var {
|
||||||
|
if (root.fixtureProcessMode === "delayed-exit")
|
||||||
|
return ["/usr/bin/sh", "-c", "printf '%s' '{\"ok\":true}'; exit 7"];
|
||||||
|
if (root.fixtureProcessMode === "no-output"
|
||||||
|
&& action.entityId === "light.fixture_kitchen") {
|
||||||
|
return ["/usr/bin/sh", "-c", "sleep 0.5; exit 7"];
|
||||||
|
}
|
||||||
|
if (root.fixtureProcessMode === "slow-success")
|
||||||
|
return ["/usr/bin/sh", "-c", "sleep 2; printf '%s' '{\"ok\":true}'"];
|
||||||
|
return ["/usr/bin/sh", "-c", "sleep 0.5; printf '%s' '{\"ok\":true}'"];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleActionStreamFinished(text: string): void {
|
||||||
|
if (root.fixtureTransitionDraining) {
|
||||||
|
root.fixtureTransitionStreamFinished = true;
|
||||||
|
fixtureTransitionTimer.restart();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (root.activeAction === null || root.actionStreamFinished)
|
||||||
|
return;
|
||||||
|
root.actionResponseText = text;
|
||||||
|
root.actionStreamFinished = true;
|
||||||
|
actionCompletionTimer.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleActionExited(exitCode: int): void {
|
||||||
|
// This fixture-only delay gives the contract a deterministic window
|
||||||
|
// where the process stopped but its exit bookkeeping is still pending.
|
||||||
|
if (root.fixtureMode
|
||||||
|
&& root.fixtureProcessMode === "delayed-exit"
|
||||||
|
&& root.activeAction !== null
|
||||||
|
&& !root.fixtureTransitionDraining) {
|
||||||
|
root.delayedFixtureExitCode = exitCode;
|
||||||
|
delayedFixtureExitTimer.restart();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.recordActionExited(exitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordActionExited(exitCode: int): void {
|
||||||
|
if (root.fixtureTransitionDraining) {
|
||||||
|
root.fixtureTransitionExited = true;
|
||||||
|
fixtureTransitionTimer.restart();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (root.activeAction === null || root.actionExited)
|
||||||
|
return;
|
||||||
|
root.actionExited = true;
|
||||||
|
root.actionExitCode = exitCode;
|
||||||
|
actionCompletionTimer.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryCompleteAction(): void {
|
||||||
|
if (root.activeAction === null)
|
||||||
|
return;
|
||||||
|
if (actionProc.running) {
|
||||||
|
actionCompletionTimer.restart();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!root.actionExited || !root.actionStreamFinished)
|
||||||
|
return;
|
||||||
|
root.consumeAction(root.actionResponseText, root.actionExitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function consumeAction(text: string, exitCode: int): void {
|
||||||
|
if (root.activeAction === null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const completedAction = root.activeAction;
|
||||||
|
let ok = false;
|
||||||
|
let errorCode = text === "" ? "action-failed" : "invalid-response";
|
||||||
|
try {
|
||||||
|
const result = JSON.parse(text);
|
||||||
|
ok = exitCode === 0 && result.ok === true;
|
||||||
|
errorCode = String(result.error || "action-failed");
|
||||||
|
} catch (error) {
|
||||||
|
// Empty output from a failed process is an action failure, while
|
||||||
|
// malformed non-empty output remains an invalid response.
|
||||||
|
}
|
||||||
|
|
||||||
|
actionCompletionTimer.stop();
|
||||||
|
root.actionQueue = root.actionQueue.slice(1);
|
||||||
|
root.activeAction = null;
|
||||||
|
root.finishActionState(completedAction.entityId);
|
||||||
|
|
||||||
|
const nextErrors = Object.assign({}, root.entityErrors);
|
||||||
|
if (ok) {
|
||||||
|
delete nextErrors[completedAction.entityId];
|
||||||
|
if (root.fixtureMode && root.fixtureProcessMode !== "")
|
||||||
|
root.applyFixtureAction(completedAction);
|
||||||
|
else
|
||||||
|
refreshDelay.restart();
|
||||||
|
} else {
|
||||||
|
nextErrors[completedAction.entityId] = errorCode;
|
||||||
|
}
|
||||||
|
root.entityErrors = nextErrors;
|
||||||
|
queueAdvanceTimer.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishActionState(entityId: string): void {
|
||||||
|
root.busyEntityIds = root.busyEntityIds.filter(id => id !== entityId);
|
||||||
|
const nextPending = Object.assign({}, root.pendingBrightness);
|
||||||
|
delete nextPending[entityId];
|
||||||
|
root.pendingBrightness = nextPending;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFixtureAction(action: var): void {
|
||||||
|
root.catalog = root.catalog.map(entity => {
|
||||||
|
if (entity.id !== action.entityId)
|
||||||
return entity;
|
return entity;
|
||||||
|
if (action.kind === "brightness") {
|
||||||
|
return Object.assign({}, entity, {
|
||||||
|
state: action.percent === 0 ? "off" : "on",
|
||||||
|
active: action.percent > 0,
|
||||||
|
brightnessPct: action.percent
|
||||||
|
});
|
||||||
|
}
|
||||||
return Object.assign({}, entity, {
|
return Object.assign({}, entity, {
|
||||||
active: !entity.active,
|
active: !entity.active,
|
||||||
state: entity.active ? "off" : "on"
|
state: entity.active ? "off" : "on"
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return;
|
root.rebuildSelection();
|
||||||
}
|
|
||||||
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 {
|
function open(): void {
|
||||||
@@ -111,61 +354,235 @@ Singleton {
|
|||||||
|
|
||||||
function fixtureEntities(): var {
|
function fixtureEntities(): var {
|
||||||
return [
|
return [
|
||||||
{ id: "light.fixture_all", name: "All lights", domain: "light", state: "on", available: true, active: true },
|
{ id: "light.fixture_all", sourceName: "All lights", state: "on", available: true, active: true, dimmable: true, brightnessPct: 82 },
|
||||||
{ id: "light.fixture_kitchen", name: "Kitchen", domain: "light", state: "on", available: true, active: true },
|
{ id: "light.fixture_kitchen", sourceName: "Kitchen", state: "on", available: true, active: true, dimmable: true, brightnessPct: 71 },
|
||||||
{ id: "light.fixture_living", name: "Living room", domain: "light", state: "off", available: true, active: false },
|
{ id: "light.fixture_living", sourceName: "Living room", state: "off", available: true, active: false, dimmable: true, brightnessPct: 36 },
|
||||||
{ id: "light.fixture_bedroom", name: "Bedroom", domain: "light", state: "on", available: true, active: true },
|
{ id: "light.fixture_bedroom", sourceName: "Bedroom", state: "on", available: true, active: true, dimmable: true, brightnessPct: 48 },
|
||||||
{ id: "light.fixture_hall", name: "Hall", domain: "light", state: "off", available: true, active: false },
|
{ id: "light.fixture_hall", sourceName: "Hall", state: "off", available: true, active: false, dimmable: false, brightnessPct: 0 },
|
||||||
{ id: "light.fixture_desk", name: "Desk", domain: "light", state: "off", available: true, active: false },
|
{ id: "light.fixture_desk", sourceName: "Desk", state: "on", available: true, active: true, dimmable: true, brightnessPct: 24 },
|
||||||
{ id: "light.fixture_corner", name: "Corner lamp", domain: "light", state: "unavailable", available: false, active: false }
|
{ id: "light.fixture_corner", sourceName: "Corner lamp", state: "unavailable", available: false, active: false, dimmable: false, brightnessPct: 0 }
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyFixture(name: string): void {
|
function fixturePreferenceRecords(): var {
|
||||||
if (["ready", "stale", "unavailable"].indexOf(name) < 0)
|
return [
|
||||||
|
{ id: "light.fixture_all", alias: " Whole home " },
|
||||||
|
{ id: "light.fixture_kitchen", alias: "Kitchen island" },
|
||||||
|
{ id: "light.fixture_living", alias: "" },
|
||||||
|
{ id: "light.fixture_bedroom", alias: "" },
|
||||||
|
{ id: "light.fixture_hall", alias: "" },
|
||||||
|
{ id: "light.fixture_desk", alias: "Office" },
|
||||||
|
{ id: "light.fixture_corner", alias: "" }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetActionState(): void {
|
||||||
|
actionCompletionTimer.stop();
|
||||||
|
queueAdvanceTimer.stop();
|
||||||
|
root.actionQueue = [];
|
||||||
|
root.activeAction = null;
|
||||||
|
root.busyEntityIds = [];
|
||||||
|
root.pendingBrightness = {};
|
||||||
|
root.entityErrors = {};
|
||||||
|
root.actionResponseText = "";
|
||||||
|
root.actionStreamFinished = false;
|
||||||
|
root.actionExited = false;
|
||||||
|
root.actionExitCode = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginFixtureTransition(): void {
|
||||||
|
if (root.fixtureTransitionDraining)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
const shouldDrain = root.fixtureMode
|
||||||
|
&& root.fixtureProcessMode !== ""
|
||||||
|
&& (actionProc.running || root.activeAction !== null);
|
||||||
|
const streamAlreadyFinished = root.actionStreamFinished;
|
||||||
|
const exitAlreadyObserved = root.actionExited;
|
||||||
|
|
||||||
|
fixtureTransitionTimer.stop();
|
||||||
|
root.fixtureTransitionDraining = shouldDrain;
|
||||||
|
root.fixtureTransitionStreamFinished = shouldDrain && streamAlreadyFinished;
|
||||||
|
root.fixtureTransitionExited = shouldDrain && exitAlreadyObserved;
|
||||||
|
root.resetActionState();
|
||||||
|
|
||||||
|
if (!shouldDrain)
|
||||||
|
return;
|
||||||
|
if (actionProc.running)
|
||||||
|
actionProc.running = false;
|
||||||
|
fixtureTransitionTimer.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryFinishFixtureTransition(): void {
|
||||||
|
if (!root.fixtureTransitionDraining)
|
||||||
|
return;
|
||||||
|
if (actionProc.running) {
|
||||||
|
fixtureTransitionTimer.restart();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!root.fixtureTransitionStreamFinished || !root.fixtureTransitionExited)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const target = root.pendingFixtureTarget;
|
||||||
|
root.pendingFixtureTarget = null;
|
||||||
|
root.fixtureTransitionDraining = false;
|
||||||
|
root.fixtureTransitionStreamFinished = false;
|
||||||
|
root.fixtureTransitionExited = false;
|
||||||
|
if (target !== null)
|
||||||
|
root.installFixtureTarget(target);
|
||||||
|
queueAdvanceTimer.restart();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFixture(name: string): void {
|
||||||
|
if (["ready", "available-extra", "stale", "stale-authentication", "stale-not-configured",
|
||||||
|
"unavailable", "missing-selected", "action-error",
|
||||||
|
"process-actions", "process-no-output", "process-delayed-exit",
|
||||||
|
"process-slow-actions"].indexOf(name) < 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
root.requestFixtureTarget({ fixture: true, name });
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestFixtureTarget(target: var): void {
|
||||||
|
if (root.fixtureTransitionDraining) {
|
||||||
|
// Preserve the old process's callback guard and keep only the
|
||||||
|
// latest replacement requested during that drain.
|
||||||
|
root.pendingFixtureTarget = target;
|
||||||
|
root.resetActionState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.pendingFixtureTarget = target;
|
||||||
|
root.beginFixtureTransition();
|
||||||
|
if (root.fixtureTransitionDraining)
|
||||||
|
return;
|
||||||
|
|
||||||
|
root.pendingFixtureTarget = null;
|
||||||
|
root.installFixtureTarget(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
function installFixtureTarget(target: var): void {
|
||||||
|
if (target.fixture)
|
||||||
|
root.installFixture(target.name);
|
||||||
|
else
|
||||||
|
root.installLiveState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function installFixture(name: string): void {
|
||||||
root.fixtureMode = true;
|
root.fixtureMode = true;
|
||||||
root.busyEntityId = "";
|
root.fixtureProcessMode = name === "process-actions"
|
||||||
|
? "success"
|
||||||
|
: (name === "process-no-output"
|
||||||
|
? "no-output"
|
||||||
|
: (name === "process-delayed-exit"
|
||||||
|
? "delayed-exit"
|
||||||
|
: (name === "process-slow-actions" ? "slow-success" : "")));
|
||||||
if (name === "unavailable") {
|
if (name === "unavailable") {
|
||||||
root.entities = [];
|
root.catalog = [];
|
||||||
|
root.fixtureFavorites = [];
|
||||||
|
root.rebuildSelection();
|
||||||
root.phase = "unavailable";
|
root.phase = "unavailable";
|
||||||
root.stale = false;
|
root.stale = false;
|
||||||
root.lastError = "not-configured";
|
root.lastError = "not-configured";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
root.entities = root.fixtureEntities();
|
|
||||||
root.phase = name === "stale" ? "degraded" : "ready";
|
root.catalog = root.fixtureEntities();
|
||||||
root.stale = name === "stale";
|
root.fixtureFavorites = root.fixturePreferenceRecords();
|
||||||
root.lastError = name === "stale" ? "unreachable" : "";
|
if (name === "available-extra") {
|
||||||
|
root.catalog = root.catalog.concat([{
|
||||||
|
id: "light.fixture_guest",
|
||||||
|
sourceName: "Guest lamp",
|
||||||
|
state: "off",
|
||||||
|
available: true,
|
||||||
|
active: false,
|
||||||
|
dimmable: true,
|
||||||
|
brightnessPct: 0
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
if (name === "missing-selected") {
|
||||||
|
root.fixtureFavorites = root.fixtureFavorites.concat([
|
||||||
|
{ id: "light.fixture_missing", alias: "Porch" }
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
root.rebuildSelection();
|
||||||
|
const isStale = ["stale", "stale-authentication", "stale-not-configured"].indexOf(name) >= 0;
|
||||||
|
root.phase = isStale ? "degraded" : "ready";
|
||||||
|
root.stale = isStale;
|
||||||
|
root.lastError = name === "stale-authentication"
|
||||||
|
? "authentication-required"
|
||||||
|
: (name === "stale-not-configured"
|
||||||
|
? "not-configured"
|
||||||
|
: (name === "stale" ? "unreachable" : ""));
|
||||||
|
if (name === "action-error") {
|
||||||
|
root.entityErrors = {
|
||||||
|
"light.fixture_kitchen": "request-failed"
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearFixture(): void {
|
function clearFixture(): void {
|
||||||
|
root.requestFixtureTarget({ fixture: false, name: "" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function installLiveState(): void {
|
||||||
root.fixtureMode = false;
|
root.fixtureMode = false;
|
||||||
|
root.fixtureProcessMode = "";
|
||||||
root.phase = "loading";
|
root.phase = "loading";
|
||||||
root.entities = [];
|
root.catalog = [];
|
||||||
|
root.selectedEntities = [];
|
||||||
|
root.fixtureFavorites = [];
|
||||||
root.stale = false;
|
root.stale = false;
|
||||||
root.busyEntityId = "";
|
|
||||||
root.lastError = "";
|
root.lastError = "";
|
||||||
root.refresh();
|
root.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: refreshProc
|
id: refreshProc
|
||||||
command: [root.helperPath, "snapshot"]
|
command: [root.helperPath, "catalog"]
|
||||||
stdout: StdioCollector {
|
stdout: StdioCollector {
|
||||||
onStreamFinished: root.consumeSnapshot(this.text)
|
onStreamFinished: root.consumeCatalog(this.text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: actionProc
|
id: actionProc
|
||||||
stdout: StdioCollector {
|
stdout: StdioCollector {
|
||||||
onStreamFinished: root.consumeAction(this.text)
|
onStreamFinished: root.handleActionStreamFinished(this.text)
|
||||||
}
|
}
|
||||||
onExited: (code, status) => {
|
onExited: (code, status) => root.handleActionExited(code)
|
||||||
if (root.busyEntityId !== "")
|
|
||||||
root.consumeAction("");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: actionCompletionTimer
|
||||||
|
interval: 0
|
||||||
|
onTriggered: root.tryCompleteAction()
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: queueAdvanceTimer
|
||||||
|
interval: 0
|
||||||
|
onTriggered: {
|
||||||
|
if (root.fixtureTransitionDraining)
|
||||||
|
return;
|
||||||
|
if (actionProc.running) {
|
||||||
|
queueAdvanceTimer.restart();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.startNextAction();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: fixtureTransitionTimer
|
||||||
|
interval: 0
|
||||||
|
onTriggered: root.tryFinishFixtureTransition()
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: delayedFixtureExitTimer
|
||||||
|
interval: 1000
|
||||||
|
onTriggered: root.recordActionExited(root.delayedFixtureExitCode)
|
||||||
}
|
}
|
||||||
|
|
||||||
Timer {
|
Timer {
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ Singleton {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openSettings(page: string): void {
|
function openSettings(page: string): void {
|
||||||
const allowed = ["home", "appearance", "displays", "connectivity", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "services", "about"];
|
const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "services", "about"];
|
||||||
root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home";
|
root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home";
|
||||||
DesktopPreferences.set("lastPage", root.settingsPage);
|
DesktopPreferences.set("lastPage", root.settingsPage);
|
||||||
root.settingsOpen = true;
|
root.settingsOpen = true;
|
||||||
|
|||||||
@@ -27,13 +27,16 @@ Singleton {
|
|||||||
property bool hyprpaperActive: false
|
property bool hyprpaperActive: false
|
||||||
property bool hypridleActive: false
|
property bool hypridleActive: false
|
||||||
property bool vicinaeActive: false
|
property bool vicinaeActive: false
|
||||||
|
property bool bluebubblesDetected: false
|
||||||
|
|
||||||
property string hyprlandVersion: ""
|
property string hyprlandVersion: ""
|
||||||
property string quickshellVersion: "0.3.0"
|
property string quickshellVersion: "0.3.0"
|
||||||
property string lastError: ""
|
property string lastError: ""
|
||||||
|
|
||||||
readonly property bool busy: monitorQuery.running || serviceQuery.running || versionQuery.running
|
readonly property bool busy: monitorQuery.running || serviceQuery.running || versionQuery.running
|
||||||
|| configWrite.running || configVerify.running
|
|| configWrite.running || configVerify.running || bluebubblesQuery.running
|
||||||
|
|
||||||
|
readonly property bool bluebubblesAvailable: root.bluebubblesDetected
|
||||||
|
|
||||||
readonly property bool autoHdr: DesktopPreferences.get("autoHdr")
|
readonly property bool autoHdr: DesktopPreferences.get("autoHdr")
|
||||||
readonly property int vrrPolicy: DesktopPreferences.get("vrrPolicy")
|
readonly property int vrrPolicy: DesktopPreferences.get("vrrPolicy")
|
||||||
@@ -120,6 +123,12 @@ Singleton {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: bluebubblesQuery
|
||||||
|
command: ["flatpak", "info", "app.bluebubbles.BlueBubbles"]
|
||||||
|
onExited: (exitCode, exitStatus) => root.bluebubblesDetected = exitCode === 0
|
||||||
|
}
|
||||||
|
|
||||||
// Reads the written options back out of the compositor. This is the only
|
// Reads the written options back out of the compositor. This is the only
|
||||||
// thing that decides whether a write succeeded.
|
// thing that decides whether a write succeeded.
|
||||||
Process {
|
Process {
|
||||||
@@ -148,6 +157,8 @@ Singleton {
|
|||||||
serviceQuery.running = true;
|
serviceQuery.running = true;
|
||||||
if (!versionQuery.running && !root.hyprlandVersion)
|
if (!versionQuery.running && !root.hyprlandVersion)
|
||||||
versionQuery.running = true;
|
versionQuery.running = true;
|
||||||
|
if (!bluebubblesQuery.running)
|
||||||
|
bluebubblesQuery.running = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseMonitors(text: string): void {
|
function parseMonitors(text: string): void {
|
||||||
@@ -371,7 +382,8 @@ Singleton {
|
|||||||
"nextcloud": ["nextcloud"],
|
"nextcloud": ["nextcloud"],
|
||||||
"rustdesk": ["rustdesk"],
|
"rustdesk": ["rustdesk"],
|
||||||
"kdeconnect": ["kdeconnect-app"],
|
"kdeconnect": ["kdeconnect-app"],
|
||||||
"mission-center": ["flatpak", "run", "io.missioncenter.MissionCenter"]
|
"mission-center": ["flatpak", "run", "io.missioncenter.MissionCenter"],
|
||||||
|
"bluebubbles": ["flatpak", "run", "app.bluebubbles.BlueBubbles"]
|
||||||
};
|
};
|
||||||
const command = commands[id];
|
const command = commands[id];
|
||||||
if (!command) {
|
if (!command) {
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ ShellRoot {
|
|||||||
IntelligenceResult {}
|
IntelligenceResult {}
|
||||||
ActivityPanel {}
|
ActivityPanel {}
|
||||||
PowerMenu {}
|
PowerMenu {}
|
||||||
SettingsWindow {}
|
SettingsWindow { id: settingsWindow }
|
||||||
|
|
||||||
// Toasts are their own always-on layer; they must be able to appear
|
// Toasts are their own always-on layer; they must be able to appear
|
||||||
// without any overlay being open.
|
// without any overlay being open.
|
||||||
@@ -258,14 +258,27 @@ ShellRoot {
|
|||||||
function fixture(name: string): void { HomeAssistant.applyFixture(name); }
|
function fixture(name: string): void { HomeAssistant.applyFixture(name); }
|
||||||
function reset(): void { HomeAssistant.clearFixture(); }
|
function reset(): void { HomeAssistant.clearFixture(); }
|
||||||
function refresh(): void { HomeAssistant.refresh(); }
|
function refresh(): void { HomeAssistant.refresh(); }
|
||||||
|
function brightness(id: string, percent: int): void { HomeAssistant.setBrightness(id, percent); }
|
||||||
|
function toggle(id: string): void { HomeAssistant.toggleEntity(id); }
|
||||||
function status(): string {
|
function status(): string {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
fixture: HomeAssistant.fixtureMode,
|
fixture: HomeAssistant.fixtureMode,
|
||||||
phase: HomeAssistant.phase,
|
phase: HomeAssistant.phase,
|
||||||
|
discoveredCount: HomeAssistant.discoveredCount,
|
||||||
configuredCount: HomeAssistant.configuredCount,
|
configuredCount: HomeAssistant.configuredCount,
|
||||||
visibleCount: HomeAssistant.visibleEntities.length,
|
visibleCount: HomeAssistant.visibleEntities.length,
|
||||||
|
selectedIds: HomeAssistant.selectedEntities.map(entity => entity.id),
|
||||||
|
entities: HomeAssistant.selectedEntities,
|
||||||
stale: HomeAssistant.stale,
|
stale: HomeAssistant.stale,
|
||||||
busy: HomeAssistant.busyEntityId !== "",
|
busy: HomeAssistant.busyEntityIds.length > 0,
|
||||||
|
busyEntityIds: HomeAssistant.busyEntityIds,
|
||||||
|
pendingBrightness: HomeAssistant.pendingBrightness,
|
||||||
|
entityErrors: HomeAssistant.entityErrors,
|
||||||
|
actionProcessRunning: HomeAssistant.actionProcessRunning,
|
||||||
|
actionStreamFinished: HomeAssistant.actionStreamFinished,
|
||||||
|
fixtureTransitionDraining: HomeAssistant.fixtureTransitionDraining,
|
||||||
|
queuedActionCount: HomeAssistant.actionQueue.length,
|
||||||
|
actionActive: HomeAssistant.activeAction !== null,
|
||||||
lastError: HomeAssistant.lastError
|
lastError: HomeAssistant.lastError
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -278,7 +291,13 @@ ShellRoot {
|
|||||||
function close(): void { ShellState.closeSettings(); }
|
function close(): void { ShellState.closeSettings(); }
|
||||||
function page(name: string): void { ShellState.openSettings(name); }
|
function page(name: string): void { ShellState.openSettings(name); }
|
||||||
function status(): string {
|
function status(): string {
|
||||||
return JSON.stringify({ open: ShellState.settingsOpen, page: ShellState.settingsPage });
|
return JSON.stringify({
|
||||||
|
open: ShellState.settingsOpen,
|
||||||
|
page: ShellState.settingsPage,
|
||||||
|
discoveredCount: HomeAssistant.discoveredCount,
|
||||||
|
selectedCount: HomeAssistant.configuredCount,
|
||||||
|
homePhone: settingsWindow.homePhoneDiagnostics
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,6 +312,7 @@ ShellRoot {
|
|||||||
autoHdr: SystemSettings.autoHdr,
|
autoHdr: SystemSettings.autoHdr,
|
||||||
vrrPolicy: SystemSettings.vrrPolicy,
|
vrrPolicy: SystemSettings.vrrPolicy,
|
||||||
directScanoutPolicy: SystemSettings.directScanoutPolicy,
|
directScanoutPolicy: SystemSettings.directScanoutPolicy,
|
||||||
|
bluebubblesAvailable: SystemSettings.bluebubblesAvailable,
|
||||||
busy: SystemSettings.busy,
|
busy: SystemSettings.busy,
|
||||||
lastError: SystemSettings.lastError
|
lastError: SystemSettings.lastError
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -70,7 +70,7 @@
|
|||||||
- Produces: `normalize_catalog(raw: Sequence[object]) -> list[dict[str, object]]`, `collect_catalog(config: Config) -> dict[str, object]`, `discovered_light_ids(config: Config) -> set[str]`, `toggle(config: Config, entity_id: str) -> dict[str, object]`, `set_brightness(config: Config, entity_id: str, percent: int) -> dict[str, object]`, and CLI commands `catalog`, `toggle ENTITY_ID`, `brightness ENTITY_ID PERCENT`.
|
- Produces: `normalize_catalog(raw: Sequence[object]) -> list[dict[str, object]]`, `collect_catalog(config: Config) -> dict[str, object]`, `discovered_light_ids(config: Config) -> set[str]`, `toggle(config: Config, entity_id: str) -> dict[str, object]`, `set_brightness(config: Config, entity_id: str, percent: int) -> dict[str, object]`, and CLI commands `catalog`, `toggle ENTITY_ID`, `brightness ENTITY_ID PERCENT`.
|
||||||
- Produces catalog entities with exactly `id`, `sourceName`, `state`, `available`, `active`, `dimmable`, and `brightnessPct`; the top level also contains `legacyEntityIds` for one-time migration.
|
- Produces catalog entities with exactly `id`, `sourceName`, `state`, `available`, `active`, `dimmable`, and `brightnessPct`; the top level also contains `legacyEntityIds` for one-time migration.
|
||||||
|
|
||||||
- [ ] **Step 1: Expand the fake Home Assistant and write failing catalog tests**
|
- [x] **Step 1: Expand the fake Home Assistant and write failing catalog tests**
|
||||||
|
|
||||||
Change the fake `/api/states` response to include an on dimmable light, an off dimmable light, a sensor, a malformed light, and an unavailable light:
|
Change the fake `/api/states` response to include an on dimmable light, an off dimmable light, a sensor, a malformed light, and an unavailable light:
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ Change the fake `/api/states` response to include an on dimmable light, an off d
|
|||||||
|
|
||||||
Add assertions that `collect_catalog(self.config())` returns Kitchen, Hall, and Corner in source order; returns no sensor, malformed entity, or raw attribute key; rounds Kitchen brightness to 50; sets Hall brightness to 0; and reports all three as dimmable.
|
Add assertions that `collect_catalog(self.config())` returns Kitchen, Hall, and Corner in source order; returns no sensor, malformed entity, or raw attribute key; rounds Kitchen brightness to 50; sets Hall brightness to 0; and reports all three as dimmable.
|
||||||
|
|
||||||
- [ ] **Step 2: Write failing action authorization and payload tests**
|
- [x] **Step 2: Write failing action authorization and payload tests**
|
||||||
|
|
||||||
Teach the fake POST handler to accept all three exact service routes and record bodies. Add these tests:
|
Teach the fake POST handler to accept all three exact service routes and record bodies. Add these tests:
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@ def test_brightness_zero_uses_turn_off(self) -> None:
|
|||||||
|
|
||||||
Add table-driven validation for `-1`, `101`, `1.5`, and `bright`, expecting `invalid-brightness` before any POST. Keep the existing authentication-redaction and configuration-precedence tests.
|
Add table-driven validation for `-1`, `101`, `1.5`, and `bright`, expecting `invalid-brightness` before any POST. Keep the existing authentication-redaction and configuration-precedence tests.
|
||||||
|
|
||||||
- [ ] **Step 3: Run the focused unit suite and verify RED**
|
- [x] **Step 3: Run the focused unit suite and verify RED**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -144,7 +144,7 @@ python3 tests/quickshell/home_assistant_bridge_test.py -v
|
|||||||
|
|
||||||
Expected: failures identify missing `collect_catalog`, live-catalog authorization, and `set_brightness`; the existing tests remain green.
|
Expected: failures identify missing `collect_catalog`, live-catalog authorization, and `set_brightness`; the existing tests remain green.
|
||||||
|
|
||||||
- [ ] **Step 4: Implement normalized catalog output**
|
- [x] **Step 4: Implement normalized catalog output**
|
||||||
|
|
||||||
Change `Config.configured` to require only `base_url` and `token`; `entity_ids` becomes migration metadata, not an operational requirement. Replace configured-only normalization with:
|
Change `Config.configured` to require only `base_url` and `token`; `entity_ids` becomes migration metadata, not an operational requirement. Replace configured-only normalization with:
|
||||||
|
|
||||||
@@ -188,7 +188,7 @@ def normalize_catalog(raw: Sequence[object]) -> list[dict[str, object]]:
|
|||||||
|
|
||||||
`collect_catalog()` must return `legacyEntityIds: list(config.entity_ids)` on both success and safe failures, while retaining the current `ok`, `configured`, `generatedAt`, `entities`, and redacted `error` envelope. Keep `snapshot` as a compatibility alias for this release, but make QML and live shape tests call `catalog`.
|
`collect_catalog()` must return `legacyEntityIds: list(config.entity_ids)` on both success and safe failures, while retaining the current `ok`, `configured`, `generatedAt`, `entities`, and redacted `error` envelope. Keep `snapshot` as a compatibility alias for this release, but make QML and live shape tests call `catalog`.
|
||||||
|
|
||||||
- [ ] **Step 5: Implement discovered-light authorization and brightness commands**
|
- [x] **Step 5: Implement discovered-light authorization and brightness commands**
|
||||||
|
|
||||||
Fetch `/api/states` immediately before every action, derive a set only from `normalize_catalog()`, and reject anything absent with `ValueError("entity-not-discovered")`. Validate the percentage as an ASCII integer string at the CLI boundary and as an integer in `set_brightness()`; reject booleans and values outside 0–100 with `ValueError("invalid-brightness")`.
|
Fetch `/api/states` immediately before every action, derive a set only from `normalize_catalog()`, and reject anything absent with `ValueError("entity-not-discovered")`. Validate the percentage as an ASCII integer string at the CLI boundary and as an integer in `set_brightness()`; reject booleans and values outside 0–100 with `ValueError("invalid-brightness")`.
|
||||||
|
|
||||||
@@ -223,7 +223,7 @@ def set_brightness(config: Config, entity_id: str, percent: int) -> dict[str, ob
|
|||||||
|
|
||||||
Do not print the discovery response, request body, URL, or token on action failure.
|
Do not print the discovery response, request body, URL, or token on action failure.
|
||||||
|
|
||||||
- [ ] **Step 6: Make unit and live read-only contracts GREEN**
|
- [x] **Step 6: Make unit and live read-only contracts GREEN**
|
||||||
|
|
||||||
Update `home-assistant-helper-contract.sh` to call `catalog`, assert the exact seven entity keys, assert `legacyEntityIds` is an array, and report only the count:
|
Update `home-assistant-helper-contract.sh` to call `catalog`, assert the exact seven entity keys, assert `legacyEntityIds` is an array, and report only the count:
|
||||||
|
|
||||||
@@ -247,7 +247,7 @@ tests/quickshell/home-assistant-helper-contract.sh
|
|||||||
|
|
||||||
Expected: all unit tests PASS; the live contract prints a redacted light count and performs GET requests only.
|
Expected: all unit tests PASS; the live contract prints a redacted light count and performs GET requests only.
|
||||||
|
|
||||||
- [ ] **Step 7: Commit the helper boundary**
|
- [x] **Step 7: Commit the helper boundary**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add config/dot/quickshell/scripts/panama-home-assistant \
|
git add config/dot/quickshell/scripts/panama-home-assistant \
|
||||||
@@ -271,7 +271,7 @@ git commit -m "Add Home Assistant light catalog and dimming"
|
|||||||
- Produces: `initialized: bool`, `favorites: var`, `saveError: string`, `initialize(legacyIds)`, `isSelected(entityId)`, `aliasFor(entityId, sourceName)`, `add(entityId)`, `remove(entityId)`, `setAlias(entityId, alias)`, `move(entityId, targetIndex)`, `retrySave()`, and state file `Quickshell.stateDir + "/panama-home.json"`.
|
- Produces: `initialized: bool`, `favorites: var`, `saveError: string`, `initialize(legacyIds)`, `isSelected(entityId)`, `aliasFor(entityId, sourceName)`, `add(entityId)`, `remove(entityId)`, `setAlias(entityId, alias)`, `move(entityId, targetIndex)`, `retrySave()`, and state file `Quickshell.stateDir + "/panama-home.json"`.
|
||||||
- Produces favorite records with exactly `{ id: string, alias: string }`; every mutator assigns a cloned array so QML change notification and persistence are deterministic.
|
- Produces favorite records with exactly `{ id: string, alias: string }`; every mutator assigns a cloned array so QML change notification and persistence are deterministic.
|
||||||
|
|
||||||
- [ ] **Step 1: Write the isolated persistence harness and failing restart contract**
|
- [x] **Step 1: Write the isolated persistence harness and failing restart contract**
|
||||||
|
|
||||||
Create an IPC harness with these methods:
|
Create an IPC harness with these methods:
|
||||||
|
|
||||||
@@ -302,7 +302,7 @@ The Bash contract must use a temporary `XDG_STATE_HOME`, initialize `light.kitch
|
|||||||
|
|
||||||
Then remove both records, restart again, call `initialize` with a different legacy list, and assert the selection stays empty. Assert the JSON file contains only `initialized` and `favorites` keys and no strings matching `token`, `url`, or `api` case-insensitively.
|
Then remove both records, restart again, call `initialize` with a different legacy list, and assert the selection stays empty. Assert the JSON file contains only `initialized` and `favorites` keys and no strings matching `token`, `url`, or `api` case-insensitively.
|
||||||
|
|
||||||
- [ ] **Step 2: Run the contract and verify RED**
|
- [x] **Step 2: Run the contract and verify RED**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -312,7 +312,7 @@ tests/quickshell/home-preferences-contract.sh
|
|||||||
|
|
||||||
Expected: FAIL because the singleton and harness do not exist.
|
Expected: FAIL because the singleton and harness do not exist.
|
||||||
|
|
||||||
- [ ] **Step 3: Implement the atomic preference singleton**
|
- [x] **Step 3: Implement the atomic preference singleton**
|
||||||
|
|
||||||
Register `singleton HomePreferences 1.0 HomePreferences.qml`. Build `HomePreferences.qml` around this adapter:
|
Register `singleton HomePreferences 1.0 HomePreferences.qml`. Build `HomePreferences.qml` around this adapter:
|
||||||
|
|
||||||
@@ -339,7 +339,7 @@ FileView {
|
|||||||
|
|
||||||
Use a 180 ms single-shot persistence timer. `initialize()` filters IDs through `^light\.[a-z0-9_]+$`, removes duplicates while preserving order, seeds only when `initialized === false`, then sets `initialized = true`. `setAlias()` trims with `String(value).trim()`. `move()` clamps the target index to `0..length - 1`. `retrySave()` directly invokes `preferencesFile.writeAdapter()`. On save failure, leave the mutated `favorites` array untouched so the user can retry without re-entering edits.
|
Use a 180 ms single-shot persistence timer. `initialize()` filters IDs through `^light\.[a-z0-9_]+$`, removes duplicates while preserving order, seeds only when `initialized === false`, then sets `initialized = true`. `setAlias()` trims with `String(value).trim()`. `move()` clamps the target index to `0..length - 1`. `retrySave()` directly invokes `preferencesFile.writeAdapter()`. On save failure, leave the mutated `favorites` array untouched so the user can retry without re-entering edits.
|
||||||
|
|
||||||
- [ ] **Step 4: Run the restart contract and inspect the private file shape**
|
- [x] **Step 4: Run the restart contract and inspect the private file shape**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -349,7 +349,7 @@ tests/quickshell/home-preferences-contract.sh
|
|||||||
|
|
||||||
Expected: PASS for initial migration, trim, reorder, remove, restart persistence, and initialized-empty behavior. The temporary file is valid JSON and contains no credential-like fields.
|
Expected: PASS for initial migration, trim, reorder, remove, restart persistence, and initialized-empty behavior. The temporary file is valid JSON and contains no credential-like fields.
|
||||||
|
|
||||||
- [ ] **Step 5: Commit preference ownership**
|
- [x] **Step 5: Commit preference ownership**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add config/dot/quickshell/config/HomePreferences.qml \
|
git add config/dot/quickshell/config/HomePreferences.qml \
|
||||||
@@ -373,7 +373,7 @@ git commit -m "Persist Home accessory preferences"
|
|||||||
- Produces: `catalog: var`, `selectedEntities: var`, `visibleEntities: var`, `discoveredCount: int`, `configuredCount: int`, `busyEntityIds: var`, `pendingBrightness: var`, `entityErrors: var`, `refresh()`, `toggleEntity(id)`, `setBrightness(id, percent)`, `isBusy(id)`, `pendingFor(id)`, `errorFor(id)`, and current phase/stale/open behavior.
|
- Produces: `catalog: var`, `selectedEntities: var`, `visibleEntities: var`, `discoveredCount: int`, `configuredCount: int`, `busyEntityIds: var`, `pendingBrightness: var`, `entityErrors: var`, `refresh()`, `toggleEntity(id)`, `setBrightness(id, percent)`, `isBusy(id)`, `pendingFor(id)`, `errorFor(id)`, and current phase/stale/open behavior.
|
||||||
- Produces selected entity objects with `id`, `sourceName`, `name`, `state`, `available`, `active`, `dimmable`, and `brightnessPct`; `name` is the trimmed Panama alias or `sourceName` fallback.
|
- Produces selected entity objects with `id`, `sourceName`, `name`, `state`, `available`, `active`, `dimmable`, and `brightnessPct`; `name` is the trimmed Panama alias or `sourceName` fallback.
|
||||||
|
|
||||||
- [ ] **Step 1: Extend the fixture contract for ordering, missing entities, and local action state**
|
- [x] **Step 1: Extend the fixture contract for ordering, missing entities, and local action state**
|
||||||
|
|
||||||
Change the `ready` fixture assertion to require `discoveredCount == 7`, `configuredCount == 7`, `visibleCount == 4`, and ordered aliases. Add fixture-only IPC methods `brightness(id, percent)` and `toggle(id)`, then assert:
|
Change the `ready` fixture assertion to require `discoveredCount == 7`, `configuredCount == 7`, `visibleCount == 4`, and ordered aliases. Add fixture-only IPC methods `brightness(id, percent)` and `toggle(id)`, then assert:
|
||||||
|
|
||||||
@@ -389,7 +389,7 @@ jq -e '.entities[] | select(.id == "light.fixture_living") | .active == true and
|
|||||||
|
|
||||||
Add `missing-selected` and `action-error` fixtures. `missing-selected` retains one preferred ID absent from catalog as unavailable. `action-error` returns `entityErrors["light.fixture_kitchen"] == "request-failed"` while Hall has no error and the global phase is still ready.
|
Add `missing-selected` and `action-error` fixtures. `missing-selected` retains one preferred ID absent from catalog as unavailable. `action-error` returns `entityErrors["light.fixture_kitchen"] == "request-failed"` while Hall has no error and the global phase is still ready.
|
||||||
|
|
||||||
- [ ] **Step 2: Run the service contract and verify RED**
|
- [x] **Step 2: Run the service contract and verify RED**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -399,7 +399,7 @@ tests/quickshell/control-center-services-contract.sh
|
|||||||
|
|
||||||
Expected: the new catalog counts, selected IDs, brightness fixture action, and per-entity errors are absent.
|
Expected: the new catalog counts, selected IDs, brightness fixture action, and per-entity errors are absent.
|
||||||
|
|
||||||
- [ ] **Step 3: Replace configured entities with catalog/preference resolution**
|
- [x] **Step 3: Replace configured entities with catalog/preference resolution**
|
||||||
|
|
||||||
Import `qs.config`. Change the refresh command to `[helperPath, "catalog"]`. On a successful live catalog:
|
Import `qs.config`. Change the refresh command to `[helperPath, "catalog"]`. On a successful live catalog:
|
||||||
|
|
||||||
@@ -429,7 +429,7 @@ root.lastError = "";
|
|||||||
|
|
||||||
Connect to `HomePreferences.favoritesChanged` and rebuild immediately. In fixture mode, resolve against fixture-local favorite records instead of mutating or reading the user's durable list.
|
Connect to `HomePreferences.favoritesChanged` and rebuild immediately. In fixture mode, resolve against fixture-local favorite records instead of mutating or reading the user's durable list.
|
||||||
|
|
||||||
- [ ] **Step 4: Implement per-entity sequential actions**
|
- [x] **Step 4: Implement per-entity sequential actions**
|
||||||
|
|
||||||
Replace the one global busy ID with a queue and cloned maps:
|
Replace the one global busy ID with a queue and cloned maps:
|
||||||
|
|
||||||
@@ -452,7 +452,7 @@ function setBrightness(entityId: string, percent: int): void {
|
|||||||
|
|
||||||
Each accepted action adds only its entity ID to `busyEntityIds`; brightness also stores the requested percentage in `pendingBrightness`. One `Process` runs queue entries in order, allowing unrelated tiles to enqueue without a global disable. Completion removes only that entity's busy/pending fields. Success clears only that entity's error and starts the existing 350 ms catalog refresh. Failure leaves catalog/phase intact, removes the preview so the UI snaps to confirmed brightness, and writes the safe code only to `entityErrors[entityId]`. A new action on an entity clears its previous inline error.
|
Each accepted action adds only its entity ID to `busyEntityIds`; brightness also stores the requested percentage in `pendingBrightness`. One `Process` runs queue entries in order, allowing unrelated tiles to enqueue without a global disable. Completion removes only that entity's busy/pending fields. Success clears only that entity's error and starts the existing 350 ms catalog refresh. Failure leaves catalog/phase intact, removes the preview so the UI snaps to confirmed brightness, and writes the safe code only to `entityErrors[entityId]`. A new action on an entity clears its previous inline error.
|
||||||
|
|
||||||
- [ ] **Step 5: Expand fixtures and diagnostics without touching durable preferences**
|
- [x] **Step 5: Expand fixtures and diagnostics without touching durable preferences**
|
||||||
|
|
||||||
Fixture entities must include brightness percentages, dimmable flags, aliases, one off light, and one unavailable light. Add status fields:
|
Fixture entities must include brightness percentages, dimmable flags, aliases, one off light, and one unavailable light. Add status fields:
|
||||||
|
|
||||||
@@ -475,7 +475,7 @@ return JSON.stringify({
|
|||||||
|
|
||||||
Fixture actions update only in-memory fixture catalog. `clearFixture()` clears fixture action state and returns to a live `catalog` refresh; it never changes `HomePreferences`.
|
Fixture actions update only in-memory fixture catalog. `clearFixture()` clears fixture action state and returns to a live `catalog` refresh; it never changes `HomePreferences`.
|
||||||
|
|
||||||
- [ ] **Step 6: Run service and preference regression contracts**
|
- [x] **Step 6: Run service and preference regression contracts**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -486,7 +486,7 @@ tests/quickshell/control-center-services-contract.sh
|
|||||||
|
|
||||||
Expected: both PASS; fixture cleanup returns live mode and the durable preference contract remains unchanged.
|
Expected: both PASS; fixture cleanup returns live mode and the durable preference contract remains unchanged.
|
||||||
|
|
||||||
- [ ] **Step 7: Commit service composition**
|
- [x] **Step 7: Commit service composition**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add config/dot/quickshell/services/HomeAssistant.qml \
|
git add config/dot/quickshell/services/HomeAssistant.qml \
|
||||||
@@ -518,7 +518,7 @@ git commit -m "Compose Home catalog with accessory preferences"
|
|||||||
- `HomeFavoriteCard` consumes `favorite`, `sourceName`, `index`, `featured`; emits `aliasCommitted(id, alias)`, `removeRequested(id)`, and `moveRequested(id, targetIndex)`.
|
- `HomeFavoriteCard` consumes `favorite`, `sourceName`, `index`, `featured`; emits `aliasCommitted(id, alias)`, `removeRequested(id)`, and `moveRequested(id, targetIndex)`.
|
||||||
- `AvailableLightRow` consumes `entity`; emits `addRequested(id)`.
|
- `AvailableLightRow` consumes `entity`; emits `addRequested(id)`.
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing route, component, and privacy-safe page contracts**
|
- [x] **Step 1: Write failing route, component, and privacy-safe page contracts**
|
||||||
|
|
||||||
Add `home-phone` to the route array immediately after `connectivity`. The focused page contract must assert:
|
Add `home-phone` to the route array immediately after `connectivity`. The focused page contract must assert:
|
||||||
|
|
||||||
@@ -534,7 +534,7 @@ rg -Fq 'HomePreferences.retrySave' config/dot/quickshell/modules/settings/HomePh
|
|||||||
|
|
||||||
Using the ready fixture, route Settings to `home-phone` and assert one tiled `Panama Settings` client. IPC status must report `page == "home-phone"`, `discoveredCount == 7`, and `selectedCount == 7`. The test may read fixture state but must not call preference mutators.
|
Using the ready fixture, route Settings to `home-phone` and assert one tiled `Panama Settings` client. IPC status must report `page == "home-phone"`, `discoveredCount == 7`, and `selectedCount == 7`. The test may read fixture state but must not call preference mutators.
|
||||||
|
|
||||||
- [ ] **Step 2: Run the settings contracts and verify RED**
|
- [x] **Step 2: Run the settings contracts and verify RED**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -545,7 +545,7 @@ tests/quickshell/home-phone-settings-contract.sh
|
|||||||
|
|
||||||
Expected: `home-phone` falls back to Home and the new components are missing.
|
Expected: `home-phone` falls back to Home and the new components are missing.
|
||||||
|
|
||||||
- [ ] **Step 3: Add BlueBubbles availability and fixed launch mapping**
|
- [x] **Step 3: Add BlueBubbles availability and fixed launch mapping**
|
||||||
|
|
||||||
Add a dedicated probe process and expose its mutable result through a read-only public property:
|
Add a dedicated probe process and expose its mutable result through a read-only public property:
|
||||||
|
|
||||||
@@ -568,7 +568,7 @@ Start it from `refresh()` when not running. Extend `openApplication()` with exac
|
|||||||
|
|
||||||
Expose `bluebubblesAvailable` through the existing `settings-system` IPC status for read-only tests. Do not derive this property from KDE Connect and do not construct a shell command string.
|
Expose `bluebubblesAvailable` through the existing `settings-system` IPC status for read-only tests. Do not derive this property from KDE Connect and do not construct a shell command string.
|
||||||
|
|
||||||
- [ ] **Step 4: Build the page shell and Home Assistant health card**
|
- [x] **Step 4: Build the page shell and Home Assistant health card**
|
||||||
|
|
||||||
Create a `Flickable` page matching existing 34 px horizontal/30 px top insets. Use title `Home & Phone` and subtitle `Choose what appears in Control Center and keep phone continuity close at hand.` The Home Assistant card shows:
|
Create a `Flickable` page matching existing 34 px horizontal/30 px top insets. Use title `Home & Phone` and subtitle `Choose what appears in Control Center and keep phone continuity close at hand.` The Home Assistant card shows:
|
||||||
|
|
||||||
@@ -578,7 +578,7 @@ Create a `Flickable` page matching existing 34 px horizontal/30 px top insets. U
|
|||||||
- `Home Assistant is not configured` for `not-configured`;
|
- `Home Assistant is not configured` for `not-configured`;
|
||||||
- Refresh and Open buttons wired only to `HomeAssistant.refresh()` and `HomeAssistant.open()`.
|
- Refresh and Open buttons wired only to `HomeAssistant.refresh()` and `HomeAssistant.open()`.
|
||||||
|
|
||||||
- [ ] **Step 5: Build selected cards with alias editing and drag reordering**
|
- [x] **Step 5: Build selected cards with alias editing and drag reordering**
|
||||||
|
|
||||||
Render `HomeAssistant.selectedEntities` in a two-column `GridView`. Each `HomeFavoriteCard` shows alias in a `TextInput`, source name beneath it, a quiet `Control Center` badge for indexes 0–3, a remove button, and a six-dot drag handle.
|
Render `HomeAssistant.selectedEntities` in a two-column `GridView`. Each `HomeFavoriteCard` shows alias in a `TextInput`, source name beneath it, a quiet `Control Center` badge for indexes 0–3, a remove button, and a six-dot drag handle.
|
||||||
|
|
||||||
@@ -592,7 +592,7 @@ root.moveRequested(favorite.id, Math.min(modelCount - 1, row * 2 + column));
|
|||||||
|
|
||||||
Commit alias on editing finished, not on every keystroke. Preserve duplicate aliases. An empty trimmed alias displays the current source name in Control Center.
|
Commit alias on editing finished, not on every keystroke. Preserve duplicate aliases. An empty trimmed alias displays the current source name in Control Center.
|
||||||
|
|
||||||
- [ ] **Step 6: Build searchable Available lights and persistence failure state**
|
- [x] **Step 6: Build searchable Available lights and persistence failure state**
|
||||||
|
|
||||||
Define:
|
Define:
|
||||||
|
|
||||||
@@ -609,7 +609,7 @@ The Available lights card contains a search field and rows showing source name,
|
|||||||
|
|
||||||
When `HomePreferences.saveError` is non-empty, show one inline amber row with the exact safe message and a Retry button calling `retrySave()`. Do not route this error through global `SystemSettings.lastError`.
|
When `HomePreferences.saveError` is non-empty, show one inline amber row with the exact safe message and a Retry button calling `retrySave()`. Do not route this error through global `SystemSettings.lastError`.
|
||||||
|
|
||||||
- [ ] **Step 7: Add Phone continuity and route registration**
|
- [x] **Step 7: Add Phone continuity and route registration**
|
||||||
|
|
||||||
Add a final compact card showing Messages, `Opens BlueBubbles`, installed/unavailable status, and an Open button enabled only by `SystemSettings.bluebubblesAvailable`. Wire the sidebar destination between connectivity and desktop, add the loader component and qmldir registrations, and allow `home-phone` in `ShellState.openSettings()`.
|
Add a final compact card showing Messages, `Opens BlueBubbles`, installed/unavailable status, and an Open button enabled only by `SystemSettings.bluebubblesAvailable`. Wire the sidebar destination between connectivity and desktop, add the loader component and qmldir registrations, and allow `home-phone` in `ShellState.openSettings()`.
|
||||||
|
|
||||||
@@ -624,7 +624,7 @@ Extend settings diagnostics with read-only counts:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 8: Run Settings, system, and persistence contracts**
|
- [x] **Step 8: Run Settings, system, and persistence contracts**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -637,7 +637,7 @@ tests/quickshell/home-preferences-contract.sh
|
|||||||
|
|
||||||
Expected: all PASS; Panama Settings remains one normal tiled client, fixture tests leave real Home preferences unchanged, and no BlueBubbles process starts.
|
Expected: all PASS; Panama Settings remains one normal tiled client, fixture tests leave real Home preferences unchanged, and no BlueBubbles process starts.
|
||||||
|
|
||||||
- [ ] **Step 9: Commit the management UI**
|
- [x] **Step 9: Commit the management UI**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add config/dot/quickshell/services/SystemSettings.qml \
|
git add config/dot/quickshell/services/SystemSettings.qml \
|
||||||
@@ -670,7 +670,7 @@ git commit -m "Add Home and Phone settings"
|
|||||||
- Produces: `HomeBrightnessSlider.value: int`, `enabled: bool`, `previewChanged(int)`, and `committed(int)`; `HomeTile` emits `powerRequested` and `brightnessRequested(int)`.
|
- Produces: `HomeBrightnessSlider.value: int`, `enabled: bool`, `previewChanged(int)`, and `committed(int)`; `HomeTile` emits `powerRequested` and `brightnessRequested(int)`.
|
||||||
- Produces a two-column resting and expanded shelf with slider hit areas that do not bubble power clicks.
|
- Produces a two-column resting and expanded shelf with slider hit areas that do not bubble power clicks.
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing structure and fixture interaction assertions**
|
- [x] **Step 1: Write failing structure and fixture interaction assertions**
|
||||||
|
|
||||||
Require `HomeBrightnessSlider.qml` and its qmldir entry. Static assertions must prove:
|
Require `HomeBrightnessSlider.qml` and its qmldir entry. Static assertions must prove:
|
||||||
|
|
||||||
@@ -684,7 +684,7 @@ rg -Fq 'ShellState.openSettings("home-phone")' config/dot/quickshell/modules/qui
|
|||||||
|
|
||||||
Keep the live panel mapping and exclusive expansion assertions. Add ready-fixture status assertions that resting count is four and expanded selected count is seven.
|
Keep the live panel mapping and exclusive expansion assertions. Add ready-fixture status assertions that resting count is four and expanded selected count is seven.
|
||||||
|
|
||||||
- [ ] **Step 2: Run the Control Center contract and verify RED**
|
- [x] **Step 2: Run the Control Center contract and verify RED**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -694,13 +694,13 @@ tests/quickshell/control-center-contract.sh
|
|||||||
|
|
||||||
Expected: the two-column shelf, slider, and Home & Phone management route are absent.
|
Expected: the two-column shelf, slider, and Home & Phone management route are absent.
|
||||||
|
|
||||||
- [ ] **Step 3: Implement a slider with local preview and one commit**
|
- [x] **Step 3: Implement a slider with local preview and one commit**
|
||||||
|
|
||||||
Create a focused slider instead of changing shared `ValueSlider.qml`. It accepts integer 0–100 and keeps `previewValue` local while pressed. Pointer press/move emits `previewChanged(previewValue)`; pointer release emits `committed(previewValue)` once. Wheel steps by 5 and commits once per wheel event. External value changes update preview only when not pressed.
|
Create a focused slider instead of changing shared `ValueSlider.qml`. It accepts integer 0–100 and keeps `previewValue` local while pressed. Pointer press/move emits `previewChanged(previewValue)`; pointer release emits `committed(previewValue)` once. Wheel steps by 5 and commits once per wheel event. External value changes update preview only when not pressed.
|
||||||
|
|
||||||
Use a 10 px rounded track, amber-to-warm Prism fill, 16 px light knob, and a 16 px effective vertical hit expansion. Expose the hit area only inside the slider component so tile power clicks cannot intercept dimming.
|
Use a 10 px rounded track, amber-to-warm Prism fill, 16 px light knob, and a 16 px effective vertical hit expansion. Expose the hit area only inside the slider component so tile power clicks cannot intercept dimming.
|
||||||
|
|
||||||
- [ ] **Step 4: Rebuild `HomeTile` as the approved larger accessory control**
|
- [x] **Step 4: Rebuild `HomeTile` as the approved larger accessory control**
|
||||||
|
|
||||||
Use a 124 px minimum height, 14 px radius, 13 px insets, active amber glass, and quiet inactive glass. Layout:
|
Use a 124 px minimum height, 14 px radius, 13 px insets, active amber glass, and quiet inactive glass. Layout:
|
||||||
|
|
||||||
@@ -711,7 +711,7 @@ Use a 124 px minimum height, 14 px radius, 13 px insets, active amber glass, and
|
|||||||
|
|
||||||
The tile body and bulb call `powerRequested()` only when available and not busy. The slider remains enabled only when available, dimmable, and not busy. While dragging, percentage text uses local preview. On failure the service removes pending state, causing the slider and text to bind back to confirmed `entity.brightnessPct`.
|
The tile body and bulb call `powerRequested()` only when available and not busy. The slider remains enabled only when available, dimmable, and not busy. While dragging, percentage text uses local preview. On failure the service removes pending state, causing the slider and text to bind back to confirmed `entity.brightnessPct`.
|
||||||
|
|
||||||
- [ ] **Step 5: Rebuild `HomeControls` resting, expanded, and setup states**
|
- [x] **Step 5: Rebuild `HomeControls` resting, expanded, and setup states**
|
||||||
|
|
||||||
Resting state uses `Grid { columns: 2; columnSpacing: 8; rowSpacing: 8 }` over `visibleEntities`. Expanded state uses the same two-column tile language over every `selectedEntities` item inside its existing `Section`/scroll boundary. Wire each delegate:
|
Resting state uses `Grid { columns: 2; columnSpacing: 8; rowSpacing: 8 }` over `visibleEntities`. Expanded state uses the same two-column tile language over every `selectedEntities` item inside its existing `Section`/scroll boundary. Wire each delegate:
|
||||||
|
|
||||||
@@ -728,7 +728,7 @@ HomeTile {
|
|||||||
|
|
||||||
At the expanded list footer add `Manage in Settings`, which closes Control Center and opens `home-phone`. If the initialized selection is empty, replace the shelf with one setup row opening `home-phone`. Preserve loading, authentication, not-configured, stale, Retry, and Open Home Assistant states, but base selected counts on `configuredCount` and discovery copy on `discoveredCount`.
|
At the expanded list footer add `Manage in Settings`, which closes Control Center and opens `home-phone`. If the initialized selection is empty, replace the shelf with one setup row opening `home-phone`. Preserve loading, authentication, not-configured, stale, Retry, and Open Home Assistant states, but base selected counts on `configuredCount` and discovery copy on `discoveredCount`.
|
||||||
|
|
||||||
- [ ] **Step 6: Run service and Control Center contracts**
|
- [x] **Step 6: Run service and Control Center contracts**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -739,7 +739,7 @@ tests/quickshell/control-center-contract.sh
|
|||||||
|
|
||||||
Expected: PASS. Opening and expanding Home maps one panel, displays the fixture shelf, and does not invoke the real Home Assistant API.
|
Expected: PASS. Opening and expanding Home maps one panel, displays the fixture shelf, and does not invoke the real Home Assistant API.
|
||||||
|
|
||||||
- [ ] **Step 7: Commit the accessory shelf**
|
- [x] **Step 7: Commit the accessory shelf**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add config/dot/quickshell/modules/quicksettings/HomeBrightnessSlider.qml \
|
git add config/dot/quickshell/modules/quicksettings/HomeBrightnessSlider.qml \
|
||||||
@@ -763,13 +763,13 @@ git commit -m "Build the Home accessory shelf"
|
|||||||
- Consumes: `SystemSettings.bluebubblesAvailable`, `SystemSettings.openApplication("bluebubbles")`, and existing KDE Connect capability/reachability/action interfaces.
|
- Consumes: `SystemSettings.bluebubblesAvailable`, `SystemSettings.openApplication("bluebubbles")`, and existing KDE Connect capability/reachability/action interfaces.
|
||||||
- Produces: four equal action models where `share`, `clipboard`, and `ring` use KDE Connect support/reachability, while `messages` uses only BlueBubbles availability.
|
- Produces: four equal action models where `share`, `clipboard`, and `ring` use KDE Connect support/reachability, while `messages` uses only BlueBubbles availability.
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing independent-enable contract**
|
- [x] **Step 1: Write the failing independent-enable contract**
|
||||||
|
|
||||||
The static contract must require a Messages model, exact application ID invocation, and an enable expression independent of `KdeConnect.phoneReachable`. It must also assert the fixed command array exists in `SystemSettings.qml`. The live read-only portion calls only `settings-system status` and requires `bluebubblesAvailable == true` on this workstation.
|
The static contract must require a Messages model, exact application ID invocation, and an enable expression independent of `KdeConnect.phoneReachable`. It must also assert the fixed command array exists in `SystemSettings.qml`. The live read-only portion calls only `settings-system status` and requires `bluebubblesAvailable == true` on this workstation.
|
||||||
|
|
||||||
Add a guard that fails if the script contains `openApplication` in an executed `qs ipc call`; the test must never launch the client.
|
Add a guard that fails if the script contains `openApplication` in an executed `qs ipc call`; the test must never launch the client.
|
||||||
|
|
||||||
- [ ] **Step 2: Run the contract and verify RED**
|
- [x] **Step 2: Run the contract and verify RED**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -779,7 +779,7 @@ tests/quickshell/phone-messages-contract.sh
|
|||||||
|
|
||||||
Expected: FAIL because PhoneControls still has three KDE-only actions.
|
Expected: FAIL because PhoneControls still has three KDE-only actions.
|
||||||
|
|
||||||
- [ ] **Step 3: Split action capability from action enablement**
|
- [x] **Step 3: Split action capability from action enablement**
|
||||||
|
|
||||||
Build four fixed models:
|
Build four fixed models:
|
||||||
|
|
||||||
@@ -794,11 +794,11 @@ readonly property var actionModels: [
|
|||||||
|
|
||||||
Render four equal columns even if a KDE plugin is unavailable; unavailable actions remain visible but disabled so the card does not jump. `invoke("messages")` calls only `SystemSettings.openApplication("bluebubbles")` and closes the Control Center on successful handoff. Keep file dialog, clipboard, and ring paths unchanged.
|
Render four equal columns even if a KDE plugin is unavailable; unavailable actions remain visible but disabled so the card does not jump. `invoke("messages")` calls only `SystemSettings.openApplication("bluebubbles")` and closes the Control Center on successful handoff. Keep file dialog, clipboard, and ring paths unchanged.
|
||||||
|
|
||||||
- [ ] **Step 4: Add missing-client copy to Phone details**
|
- [x] **Step 4: Add missing-client copy to Phone details**
|
||||||
|
|
||||||
When BlueBubbles is absent, append one quiet detail row: `BlueBubbles is not installed` with no action. Keep `Actions become available when the iPhone reconnects` scoped to the three KDE actions so it does not imply Messages requires proximity.
|
When BlueBubbles is absent, append one quiet detail row: `BlueBubbles is not installed` with no action. Keep `Actions become available when the iPhone reconnects` scoped to the three KDE actions so it does not imply Messages requires proximity.
|
||||||
|
|
||||||
- [ ] **Step 5: Run phone and Control Center contracts**
|
- [x] **Step 5: Run phone and Control Center contracts**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -810,7 +810,7 @@ tests/quickshell/control-center-services-contract.sh
|
|||||||
|
|
||||||
Expected: PASS; the offline KDE fixture still exposes an enabled Messages action when BlueBubbles is installed, and no client launches during testing.
|
Expected: PASS; the offline KDE fixture still exposes an enabled Messages action when BlueBubbles is installed, and no client launches during testing.
|
||||||
|
|
||||||
- [ ] **Step 6: Commit Messages integration**
|
- [x] **Step 6: Commit Messages integration**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add config/dot/quickshell/modules/quicksettings/PhoneControls.qml \
|
git add config/dot/quickshell/modules/quicksettings/PhoneControls.qml \
|
||||||
@@ -832,7 +832,7 @@ git commit -m "Add BlueBubbles to Phone controls"
|
|||||||
- Consumes: completed helper, preferences, service, Settings, Accessory Shelf, and BlueBubbles integration.
|
- Consumes: completed helper, preferences, service, Settings, Accessory Shelf, and BlueBubbles integration.
|
||||||
- Produces: user-facing operating notes, a clean live shell, visual evidence for all required states, a final verification commit, and synchronized `origin/main`.
|
- Produces: user-facing operating notes, a clean live shell, visual evidence for all required states, a final verification commit, and synchronized `origin/main`.
|
||||||
|
|
||||||
- [ ] **Step 1: Update durable user documentation**
|
- [x] **Step 1: Update durable user documentation**
|
||||||
|
|
||||||
Document:
|
Document:
|
||||||
|
|
||||||
@@ -844,7 +844,7 @@ Document:
|
|||||||
|
|
||||||
Do not include entity IDs, friendly names, URLs, tokens, or the contents of the user's preference file.
|
Do not include entity IDs, friendly names, URLs, tokens, or the contents of the user's preference file.
|
||||||
|
|
||||||
- [ ] **Step 2: Run focused automated verification**
|
- [x] **Step 2: Run focused automated verification**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -862,7 +862,7 @@ tests/quickshell/settings-pages-contract.sh
|
|||||||
|
|
||||||
Expected: every command exits 0. The helper contract performs read-only GETs; fixtures perform no real light action; BlueBubbles remains closed unless it was already open.
|
Expected: every command exits 0. The helper contract performs read-only GETs; fixtures perform no real light action; BlueBubbles remains closed unless it was already open.
|
||||||
|
|
||||||
- [ ] **Step 3: Run the complete Quickshell regression suite**
|
- [x] **Step 3: Run the complete Quickshell regression suite**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@@ -913,7 +913,7 @@ Reset fixtures and leave the interfaces open for the user. Ask the user to perfo
|
|||||||
|
|
||||||
Do not perform those actions on the user's behalf.
|
Do not perform those actions on the user's behalf.
|
||||||
|
|
||||||
- [ ] **Step 7: Commit documentation and plan completion**
|
- [x] **Step 7: Commit documentation and plan completion**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add config/dot/hypr/README.md \
|
git add config/dot/hypr/README.md \
|
||||||
|
|||||||
@@ -2,72 +2,205 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
source_config_path="$project_root/config/dot/quickshell"
|
||||||
|
quicksettings_path="$source_config_path/modules/quicksettings"
|
||||||
|
state_home="$(mktemp -d /tmp/panama-control-center-ui.XXXXXX)"
|
||||||
|
config_path="$state_home/quickshell"
|
||||||
|
helper_log="$state_home/home-helper.log"
|
||||||
|
shell_log="$state_home/quickshell.log"
|
||||||
|
|
||||||
fail() {
|
fail() {
|
||||||
printf 'Control Center contract: %s\n' "$1" >&2
|
printf 'Control Center contract: %s\n' "$1" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
qs_for_test() {
|
||||||
|
QS_CONFIG_PATH="$config_path" XDG_STATE_HOME="$state_home" \
|
||||||
|
QS_DISABLE_CRASH_HANDLER=1 \
|
||||||
|
PANAMA_HOME_HELPER_LOG="$helper_log" \
|
||||||
|
qs -p "$config_path" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_test_shell() {
|
||||||
|
qs_for_test kill >/dev/null 2>&1 || true
|
||||||
|
for _ in $(seq 1 80); do
|
||||||
|
if ! qs_for_test list 2>/dev/null | rg '^Instance ' >/dev/null \
|
||||||
|
&& ! qs_for_test ipc show >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
qs ipc call quicksettings close >/dev/null 2>&1 || true
|
qs_for_test ipc call quicksettings close >/dev/null 2>&1 || true
|
||||||
qs ipc call kdeconnect reset >/dev/null 2>&1 || true
|
qs_for_test ipc call kdeconnect reset >/dev/null 2>&1 || true
|
||||||
qs ipc call home-assistant reset >/dev/null 2>&1 || true
|
qs_for_test ipc call home-assistant reset >/dev/null 2>&1 || true
|
||||||
|
if stop_test_shell; then
|
||||||
|
rm -rf "$state_home"
|
||||||
|
else
|
||||||
|
printf 'Control Center contract: branch shell did not stop; retained %s\n' \
|
||||||
|
"$state_home" >&2
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
start_test_shell() {
|
||||||
|
stop_test_shell || fail 'pre-existing branch shell did not stop cleanly'
|
||||||
|
for _attempt in 1 2; do
|
||||||
|
qs_for_test --daemonize >"$shell_log" 2>&1
|
||||||
|
for _ in $(seq 1 80); do
|
||||||
|
if qs_for_test ipc show 2>/dev/null | rg '^target quicksettings$' >/dev/null; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
stop_test_shell || fail 'failed branch-shell attempt did not stop cleanly'
|
||||||
|
done
|
||||||
|
sed -n '1,200p' "$shell_log" >&2
|
||||||
|
fail 'isolated branch shell did not start'
|
||||||
|
}
|
||||||
|
|
||||||
rg -Fq 'readonly property int controlCenterWidth: 430' \
|
rg -Fq 'readonly property int controlCenterWidth: 430' \
|
||||||
"$project_root/config/dot/quickshell/config/Theme.qml" \
|
"$source_config_path/config/Theme.qml" \
|
||||||
|| fail 'approved Control Center width is missing'
|
|| fail 'approved Control Center width is missing'
|
||||||
rg -Fq 'readonly property int controlCenterTopGap: 2' \
|
rg -Fq 'readonly property int controlCenterTopGap: 2' \
|
||||||
"$project_root/config/dot/quickshell/config/Theme.qml" \
|
"$source_config_path/config/Theme.qml" \
|
||||||
|| fail 'approved top attachment is missing'
|
|| fail 'approved top attachment is missing'
|
||||||
rg -Fq 'margins.top: Theme.barHeight + Theme.controlCenterTopGap' \
|
rg -Fq 'margins.top: Theme.barHeight + Theme.controlCenterTopGap' \
|
||||||
"$project_root/config/dot/quickshell/modules/quicksettings/QuickSettings.qml" \
|
"$quicksettings_path/QuickSettings.qml" \
|
||||||
|| fail 'Control Center is not tightly attached to the bar'
|
|| fail 'Control Center is not tightly attached to the bar'
|
||||||
rg -Fq 'implicitWidth: Theme.controlCenterWidth' \
|
rg -Fq 'implicitWidth: Theme.controlCenterWidth' \
|
||||||
"$project_root/config/dot/quickshell/modules/quicksettings/QuickSettings.qml" \
|
"$quicksettings_path/QuickSettings.qml" \
|
||||||
|| fail 'Control Center window does not use its geometry token'
|
|| fail 'Control Center window does not use its geometry token'
|
||||||
rg -Fq 'HomeControls' \
|
rg -Fq 'HomeControls' \
|
||||||
"$project_root/config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml" \
|
"$quicksettings_path/QuickSettingsPanel.qml" \
|
||||||
|| fail 'Home controls are not mounted'
|
|| fail 'Home controls are not mounted'
|
||||||
rg -Fq 'PhoneControls' \
|
rg -Fq 'PhoneControls' \
|
||||||
"$project_root/config/dot/quickshell/modules/quicksettings/QuickSettingsPanel.qml" \
|
"$quicksettings_path/QuickSettingsPanel.qml" \
|
||||||
|| fail 'Phone controls are not mounted'
|
|| fail 'Phone controls are not mounted'
|
||||||
rg -Fq 'visible: KdeConnect.phoneReachable' \
|
rg -Fq 'visible: KdeConnect.phoneReachable' \
|
||||||
"$project_root/config/dot/quickshell/modules/bar/StatusCluster.qml" \
|
"$source_config_path/modules/bar/StatusCluster.qml" \
|
||||||
|| fail 'reachable phone state is absent from the bar'
|
|| fail 'reachable phone state is absent from the bar'
|
||||||
|
|
||||||
for component in ControlSectionHeader HomeControls HomeTile PhoneControls RecentExchange; do
|
for component in ControlSectionHeader HomeBrightnessSlider HomeControls HomeTile PhoneActions PhoneControls RecentExchange; do
|
||||||
[[ -f "$project_root/config/dot/quickshell/modules/quicksettings/$component.qml" ]] \
|
[[ -f "$quicksettings_path/$component.qml" ]] \
|
||||||
|| fail "$component is missing"
|
|| fail "$component is missing"
|
||||||
done
|
done
|
||||||
|
|
||||||
qs ipc call home-assistant fixture ready >/dev/null
|
[[ "$(rg -c 'id: "(share|clipboard|ring|messages)"' "$quicksettings_path/PhoneActions.qml")" -eq 4 ]] \
|
||||||
qs ipc call kdeconnect fixture reachable >/dev/null
|
|| fail 'Phone controls do not expose four stable action models'
|
||||||
qs ipc call quicksettings open >/dev/null
|
rg -Fq 'columns: 4' "$quicksettings_path/PhoneControls.qml" \
|
||||||
|
|| fail 'Phone actions are not arranged in four equal columns'
|
||||||
|
if rg -Fq '.filter(' "$quicksettings_path/PhoneActions.qml"; then
|
||||||
|
fail 'Phone action columns change when a KDE capability is unavailable'
|
||||||
|
fi
|
||||||
|
rg -Fq 'SystemSettings.bluebubblesAvailable' "$quicksettings_path/PhoneControls.qml" \
|
||||||
|
|| fail 'Messages action is not independently enabled by BlueBubbles'
|
||||||
|
rg -Fq 'KdeConnect.phoneReachable' "$quicksettings_path/PhoneControls.qml" \
|
||||||
|
|| fail 'KDE action reachability behavior is missing'
|
||||||
|
|
||||||
|
[[ -f "$quicksettings_path/qmldir" ]] \
|
||||||
|
|| fail 'quick-settings module manifest is missing'
|
||||||
|
rg -Fq 'HomeBrightnessSlider 1.0 HomeBrightnessSlider.qml' \
|
||||||
|
"$quicksettings_path/qmldir" \
|
||||||
|
|| fail 'brightness slider is not registered in the quick-settings module'
|
||||||
|
rg -Fq 'PhoneActions 1.0 PhoneActions.qml' \
|
||||||
|
"$quicksettings_path/qmldir" \
|
||||||
|
|| fail 'Phone actions model is not registered in the quick-settings module'
|
||||||
|
|
||||||
|
[[ "$(rg -c '^[[:space:]]*columns: 2$' "$quicksettings_path/HomeControls.qml")" -ge 2 ]] \
|
||||||
|
|| fail 'resting and expanded Home shelves are not both two-column grids'
|
||||||
|
rg -Fq 'model: HomeAssistant.visibleEntities' "$quicksettings_path/HomeControls.qml" \
|
||||||
|
|| fail 'resting Home shelf does not use the first four selected lights'
|
||||||
|
rg -Fq 'model: HomeAssistant.selectedEntities' "$quicksettings_path/HomeControls.qml" \
|
||||||
|
|| fail 'expanded Home shelf does not use every selected light'
|
||||||
|
rg -Fq 'HomeAssistant.pendingFor(' "$quicksettings_path/HomeControls.qml" \
|
||||||
|
|| fail 'Home tiles do not receive per-light pending brightness'
|
||||||
|
rg -Fq 'HomeAssistant.setBrightness(' "$quicksettings_path/HomeControls.qml" \
|
||||||
|
|| fail 'Home brightness commits are not wired to the service'
|
||||||
|
rg -Fq 'ShellState.openSettings("home-phone")' "$quicksettings_path/HomeControls.qml" \
|
||||||
|
|| fail 'Manage in Settings does not open Home & Phone'
|
||||||
|
|
||||||
|
rg -Fq 'signal previewChanged(int value)' "$quicksettings_path/HomeBrightnessSlider.qml" \
|
||||||
|
|| fail 'brightness slider has no preview contract'
|
||||||
|
rg -Fq 'signal committed(int value)' "$quicksettings_path/HomeBrightnessSlider.qml" \
|
||||||
|
|| fail 'brightness slider has no release-commit contract'
|
||||||
|
rg -Fq 'onReleased: event =>' "$quicksettings_path/HomeBrightnessSlider.qml" \
|
||||||
|
|| fail 'brightness slider does not route pointer release through its interaction state'
|
||||||
|
rg -Fq 'root.releasePointerInteraction();' "$quicksettings_path/HomeBrightnessSlider.qml" \
|
||||||
|
|| fail 'brightness slider release is not wired to one-shot commit semantics'
|
||||||
|
rg -Fq 'onCanceled: root.cancelPointerInteraction()' "$quicksettings_path/HomeBrightnessSlider.qml" \
|
||||||
|
|| fail 'brightness slider does not restore its external value when a Flickable steals the pointer'
|
||||||
|
if rg -Fq 'preventStealing: true' "$quicksettings_path/HomeBrightnessSlider.qml"; then
|
||||||
|
fail 'brightness slider blocks the expanded shelf from stealing vertical drags'
|
||||||
|
fi
|
||||||
|
rg -Fq 'onWheel:' "$quicksettings_path/HomeBrightnessSlider.qml" \
|
||||||
|
|| fail 'brightness slider has no wheel commit path'
|
||||||
|
rg -Fq 'onCommitted: value => root.brightnessRequested(value)' "$quicksettings_path/HomeTile.qml" \
|
||||||
|
|| fail 'Home tile does not forward the slider release commit'
|
||||||
|
rg -Fq 'accessibleName: root.entity.name + " brightness"' "$quicksettings_path/HomeTile.qml" \
|
||||||
|
|| fail 'Home tile does not give its dimmer an accessory-specific accessible name'
|
||||||
|
|
||||||
|
cp -a "$source_config_path" "$config_path"
|
||||||
|
: >"$helper_log"
|
||||||
|
cat >"$config_path/scripts/panama-home-assistant" <<'EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
printf '%s\n' "$*" >>"$PANAMA_HOME_HELPER_LOG"
|
||||||
|
case "${1:-}" in
|
||||||
|
catalog)
|
||||||
|
printf '%s\n' '{"ok":false,"configured":false,"entities":[],"legacyEntityIds":[],"error":"test-helper"}'
|
||||||
|
;;
|
||||||
|
toggle|brightness)
|
||||||
|
printf '%s\n' '{"ok":false,"error":"contract-action-forbidden"}'
|
||||||
|
exit 73
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
EOF
|
||||||
|
chmod +x "$config_path/scripts/panama-home-assistant"
|
||||||
|
|
||||||
|
start_test_shell
|
||||||
|
|
||||||
|
qs_for_test ipc call home-assistant fixture ready >/dev/null
|
||||||
|
home_ready="$(qs_for_test ipc call home-assistant status)"
|
||||||
|
jq -e '.fixture == true and .visibleCount == 4 and .configuredCount == 7' \
|
||||||
|
<<<"$home_ready" >/dev/null \
|
||||||
|
|| fail 'Home fixture does not expose four resting and seven expanded accessories'
|
||||||
|
|
||||||
|
qs_for_test ipc call kdeconnect fixture reachable >/dev/null
|
||||||
|
qs_for_test ipc call quicksettings open >/dev/null
|
||||||
|
|
||||||
hyprctl layers | rg -q 'namespace: qs-popover-quicksettings' \
|
hyprctl layers | rg -q 'namespace: qs-popover-quicksettings' \
|
||||||
|| fail 'Control Center layer did not map'
|
|| fail 'Control Center layer did not map'
|
||||||
jq -e '.open == true and .expandedSection == ""' \
|
jq -e '.open == true and .expandedSection == ""' \
|
||||||
<<<"$(qs ipc call quicksettings status)" >/dev/null \
|
<<<"$(qs_for_test ipc call quicksettings status)" >/dev/null \
|
||||||
|| fail 'Control Center did not open in its resting state'
|
|| fail 'Control Center did not open in its resting state'
|
||||||
|
|
||||||
qs ipc call quicksettings section home >/dev/null
|
qs_for_test ipc call quicksettings section home >/dev/null
|
||||||
jq -e '.open == true and .expandedSection == "home"' \
|
jq -e '.open == true and .expandedSection == "home"' \
|
||||||
<<<"$(qs ipc call quicksettings status)" >/dev/null \
|
<<<"$(qs_for_test ipc call quicksettings status)" >/dev/null \
|
||||||
|| fail 'Home section did not expand'
|
|| fail 'Home section did not expand'
|
||||||
|
|
||||||
qs ipc call quicksettings section phone >/dev/null
|
qs_for_test ipc call quicksettings section phone >/dev/null
|
||||||
jq -e '.open == true and .expandedSection == "phone"' \
|
jq -e '.open == true and .expandedSection == "phone"' \
|
||||||
<<<"$(qs ipc call quicksettings status)" >/dev/null \
|
<<<"$(qs_for_test ipc call quicksettings status)" >/dev/null \
|
||||||
|| fail 'Phone did not replace Home as the expanded section'
|
|| fail 'Phone did not replace Home as the expanded section'
|
||||||
|
|
||||||
qs ipc call quicksettings section phone >/dev/null
|
qs_for_test ipc call quicksettings section phone >/dev/null
|
||||||
jq -e '.open == true and .expandedSection == ""' \
|
jq -e '.open == true and .expandedSection == ""' \
|
||||||
<<<"$(qs ipc call quicksettings status)" >/dev/null \
|
<<<"$(qs_for_test ipc call quicksettings status)" >/dev/null \
|
||||||
|| fail 'expanded Phone section did not collapse'
|
|| fail 'expanded Phone section did not collapse'
|
||||||
|
|
||||||
|
if rg '^(toggle|brightness)( |$)' "$helper_log" >&2; then
|
||||||
|
fail 'Control Center contract attempted a real Home action path'
|
||||||
|
fi
|
||||||
|
|
||||||
trap - EXIT
|
trap - EXIT
|
||||||
cleanup
|
cleanup
|
||||||
|
[[ ! -e "$state_home" ]] \
|
||||||
|
|| fail 'temporary Control Center state was not removed after shell exit'
|
||||||
printf 'Control Center contract: PASS\n'
|
printf 'Control Center contract: PASS\n'
|
||||||
|
|||||||
@@ -2,25 +2,108 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
state_home="$(mktemp -d /tmp/panama-control-center-state.XXXXXX)"
|
||||||
|
source_config_path="$repo_dir/config/dot/quickshell"
|
||||||
|
config_path="$state_home/quickshell"
|
||||||
|
helper_log="$state_home/home-helper.log"
|
||||||
|
shell_log="$state_home/quickshell.log"
|
||||||
|
|
||||||
|
: >"$helper_log"
|
||||||
|
cp -a "$source_config_path" "$config_path"
|
||||||
|
cat >"$config_path/scripts/panama-home-assistant" <<'EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
printf '%s\n' "$*" >>"$PANAMA_HOME_HELPER_LOG"
|
||||||
|
case "${1:-}" in
|
||||||
|
catalog)
|
||||||
|
printf '%s\n' '{"ok":false,"error":"test-helper"}'
|
||||||
|
;;
|
||||||
|
toggle|brightness)
|
||||||
|
printf '%s\n' '{"ok":true}'
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
EOF
|
||||||
|
chmod +x "$config_path/scripts/panama-home-assistant"
|
||||||
|
|
||||||
fail() {
|
fail() {
|
||||||
printf 'Control Center services contract: %s\n' "$1" >&2
|
printf 'Control Center services contract: %s\n' "$1" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
qs_for_test() {
|
||||||
|
QS_CONFIG_PATH="$config_path" XDG_STATE_HOME="$state_home" \
|
||||||
|
PANAMA_HOME_HELPER_LOG="$helper_log" \
|
||||||
|
qs -p "$config_path" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_test_shell() {
|
||||||
|
qs_for_test kill >/dev/null 2>&1 || true
|
||||||
|
for _ in $(seq 1 80); do
|
||||||
|
if ! qs_for_test list 2>/dev/null | rg '^Instance ' >/dev/null \
|
||||||
|
&& ! qs_for_test ipc show >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
qs ipc call kdeconnect reset >/dev/null 2>&1 || true
|
qs_for_test ipc call kdeconnect reset >/dev/null 2>&1 || true
|
||||||
qs ipc call home-assistant reset >/dev/null 2>&1 || true
|
qs_for_test ipc call home-assistant reset >/dev/null 2>&1 || true
|
||||||
qs ipc call status-events reset >/dev/null 2>&1 || true
|
qs_for_test ipc call status-events reset >/dev/null 2>&1 || true
|
||||||
qs ipc call quicksettings close >/dev/null 2>&1 || true
|
qs_for_test ipc call quicksettings close >/dev/null 2>&1 || true
|
||||||
|
if stop_test_shell; then
|
||||||
|
rm -rf "$state_home"
|
||||||
|
else
|
||||||
|
printf 'Control Center services contract: branch shell did not stop; retained %s\n' \
|
||||||
|
"$state_home" >&2
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
qs ipc show | rg -q '^target kdeconnect$' \
|
start_test_shell() {
|
||||||
|
stop_test_shell || fail 'pre-existing branch shell did not stop cleanly'
|
||||||
|
for _attempt in 1 2; do
|
||||||
|
qs_for_test --daemonize >"$shell_log" 2>&1
|
||||||
|
for _ in $(seq 1 80); do
|
||||||
|
if qs_for_test ipc show 2>/dev/null | rg '^target home-assistant$' >/dev/null; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
stop_test_shell || fail 'failed branch-shell attempt did not stop cleanly'
|
||||||
|
done
|
||||||
|
sed -n '1,200p' "$shell_log" >&2
|
||||||
|
fail 'isolated branch shell did not start'
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_home_status() {
|
||||||
|
local filter="$1"
|
||||||
|
local message="$2"
|
||||||
|
local status=""
|
||||||
|
|
||||||
|
for _ in $(seq 1 80); do
|
||||||
|
status="$(qs_for_test ipc call home-assistant status)"
|
||||||
|
if jq -e "$filter" <<<"$status" >/dev/null; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
printf 'Last Home status: %s\n' "$status" >&2
|
||||||
|
fail "$message"
|
||||||
|
}
|
||||||
|
|
||||||
|
start_test_shell
|
||||||
|
|
||||||
|
qs_for_test ipc show | rg '^target kdeconnect$' >/dev/null \
|
||||||
|| fail 'KDE Connect IPC target is missing'
|
|| fail 'KDE Connect IPC target is missing'
|
||||||
qs ipc show | rg -q '^target home-assistant$' \
|
qs_for_test ipc show | rg '^target home-assistant$' >/dev/null \
|
||||||
|| fail 'Home Assistant IPC target is missing'
|
|| fail 'Home Assistant IPC target is missing'
|
||||||
|
|
||||||
qs ipc call kdeconnect fixture reachable >/dev/null
|
qs_for_test ipc call kdeconnect fixture reachable >/dev/null
|
||||||
jq -e '
|
jq -e '
|
||||||
.fixture == true and
|
.fixture == true and
|
||||||
.available == true and
|
.available == true and
|
||||||
@@ -28,65 +111,270 @@ jq -e '
|
|||||||
.actionCount == 4 and
|
.actionCount == 4 and
|
||||||
.transferActive == false and
|
.transferActive == false and
|
||||||
.ongoingCount == 0
|
.ongoingCount == 0
|
||||||
' <<<"$(qs ipc call kdeconnect status)" >/dev/null \
|
' <<<"$(qs_for_test ipc call kdeconnect status)" >/dev/null \
|
||||||
|| fail 'reachable phone fixture is malformed'
|
|| fail 'reachable phone fixture is malformed'
|
||||||
|
|
||||||
qs ipc call kdeconnect fixture offline >/dev/null
|
qs_for_test ipc call kdeconnect fixture offline >/dev/null
|
||||||
jq -e '
|
jq -e '
|
||||||
.fixture == true and
|
.fixture == true and
|
||||||
.available == true and
|
.available == true and
|
||||||
.reachable == false and
|
.reachable == false and
|
||||||
.pairedCount == 1 and
|
.pairedCount == 1 and
|
||||||
.ongoingCount == 0
|
.ongoingCount == 0
|
||||||
' <<<"$(qs ipc call kdeconnect status)" >/dev/null \
|
' <<<"$(qs_for_test ipc call kdeconnect status)" >/dev/null \
|
||||||
|| fail 'offline phone fixture is malformed'
|
|| fail 'offline phone fixture is malformed'
|
||||||
|
|
||||||
qs ipc call kdeconnect fixture transfer >/dev/null
|
qs_for_test ipc call kdeconnect fixture transfer >/dev/null
|
||||||
jq -e '
|
jq -e '
|
||||||
.fixture == true and
|
.fixture == true and
|
||||||
.transferActive == true and
|
.transferActive == true and
|
||||||
.transferFileName == "Fixture document.pdf" and
|
.transferFileName == "Fixture document.pdf" and
|
||||||
.ongoingCount == 1
|
.ongoingCount == 1
|
||||||
' <<<"$(qs ipc call kdeconnect status)" >/dev/null \
|
' <<<"$(qs_for_test ipc call kdeconnect status)" >/dev/null \
|
||||||
|| fail 'phone transfer did not enter Ongoing'
|
|| fail 'phone transfer did not enter Ongoing'
|
||||||
|
|
||||||
qs ipc call kdeconnect cancel >/dev/null
|
qs_for_test ipc call kdeconnect cancel >/dev/null
|
||||||
jq -e '.transferActive == false and .ongoingCount == 0' \
|
jq -e '.transferActive == false and .ongoingCount == 0' \
|
||||||
<<<"$(qs ipc call kdeconnect status)" >/dev/null \
|
<<<"$(qs_for_test ipc call kdeconnect status)" >/dev/null \
|
||||||
|| fail 'phone transfer did not leave Ongoing'
|
|| fail 'phone transfer did not leave Ongoing'
|
||||||
|
|
||||||
qs ipc call home-assistant fixture ready >/dev/null
|
qs_for_test ipc call home-assistant fixture ready >/dev/null
|
||||||
|
home_ready="$(qs_for_test ipc call home-assistant status)"
|
||||||
jq -e '
|
jq -e '
|
||||||
.fixture == true and
|
.fixture == true and
|
||||||
.phase == "ready" and
|
.phase == "ready" and
|
||||||
|
.discoveredCount == 7 and
|
||||||
.configuredCount == 7 and
|
.configuredCount == 7 and
|
||||||
.visibleCount == 4 and
|
.visibleCount == 4 and
|
||||||
|
.selectedIds[0:4] == [
|
||||||
|
"light.fixture_all",
|
||||||
|
"light.fixture_kitchen",
|
||||||
|
"light.fixture_living",
|
||||||
|
"light.fixture_bedroom"
|
||||||
|
] and
|
||||||
|
(.entities[0:4] | map(.name)) == [
|
||||||
|
"Whole home",
|
||||||
|
"Kitchen island",
|
||||||
|
"Living room",
|
||||||
|
"Bedroom"
|
||||||
|
] and
|
||||||
.stale == false and
|
.stale == false and
|
||||||
|
.busy == false and
|
||||||
.lastError == ""
|
.lastError == ""
|
||||||
' <<<"$(qs ipc call home-assistant status)" >/dev/null \
|
' <<<"$home_ready" >/dev/null \
|
||||||
|| fail 'ready Home fixture is malformed'
|
|| fail 'ready Home fixture is malformed'
|
||||||
|
|
||||||
qs ipc call home-assistant fixture stale >/dev/null
|
qs_for_test ipc call home-assistant brightness light.fixture_living 64 >/dev/null
|
||||||
|
jq -e '
|
||||||
|
.entities[] |
|
||||||
|
select(.id == "light.fixture_living") |
|
||||||
|
.active == true and .state == "on" and .brightnessPct == 64
|
||||||
|
' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \
|
||||||
|
|| fail 'Home brightness fixture did not update local catalog state'
|
||||||
|
|
||||||
|
qs_for_test ipc call home-assistant toggle light.fixture_living >/dev/null
|
||||||
|
jq -e '
|
||||||
|
.entities[] |
|
||||||
|
select(.id == "light.fixture_living") |
|
||||||
|
.active == false and .state == "off"
|
||||||
|
' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \
|
||||||
|
|| fail 'Home toggle fixture did not update local catalog state'
|
||||||
|
|
||||||
|
qs_for_test ipc call home-assistant fixture process-actions >/dev/null
|
||||||
|
qs_for_test ipc call home-assistant brightness light.fixture_living 64 >/dev/null
|
||||||
|
qs_for_test ipc call home-assistant toggle light.fixture_hall >/dev/null
|
||||||
|
jq -e '
|
||||||
|
.busy == true and
|
||||||
|
.busyEntityIds == ["light.fixture_living", "light.fixture_hall"] and
|
||||||
|
.pendingBrightness["light.fixture_living"] == 64
|
||||||
|
' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \
|
||||||
|
|| fail 'process-backed Home actions did not remain per-entity busy'
|
||||||
|
wait_for_home_status '
|
||||||
|
.busy == false and
|
||||||
|
.busyEntityIds == [] and
|
||||||
|
.pendingBrightness == {} and
|
||||||
|
(.entities[] | select(.id == "light.fixture_living") | .active == true and .brightnessPct == 64) and
|
||||||
|
(.entities[] | select(.id == "light.fixture_hall") | .active == true)
|
||||||
|
' 'process-backed Home actions did not complete in queue order'
|
||||||
|
|
||||||
|
qs_for_test ipc call home-assistant fixture process-no-output >/dev/null
|
||||||
|
qs_for_test ipc call home-assistant toggle light.fixture_kitchen >/dev/null
|
||||||
|
qs_for_test ipc call home-assistant toggle light.fixture_hall >/dev/null
|
||||||
|
wait_for_home_status '
|
||||||
|
.phase == "ready" and
|
||||||
|
.busy == false and
|
||||||
|
.busyEntityIds == [] and
|
||||||
|
.entityErrors["light.fixture_kitchen"] == "action-failed" and
|
||||||
|
(.entityErrors["light.fixture_hall"] // "") == "" and
|
||||||
|
(.entities[] | select(.id == "light.fixture_hall") | .active == true)
|
||||||
|
' 'nonzero no-output Home action did not fail safely and continue the queue'
|
||||||
|
|
||||||
|
qs_for_test ipc call home-assistant fixture process-actions >/dev/null
|
||||||
|
qs_for_test ipc call home-assistant brightness light.fixture_living 88 >/dev/null
|
||||||
|
jq -e '
|
||||||
|
.busyEntityIds == ["light.fixture_living"] and
|
||||||
|
.pendingBrightness["light.fixture_living"] == 88
|
||||||
|
' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \
|
||||||
|
|| fail 'fixture transition setup did not start an in-flight action'
|
||||||
|
qs_for_test ipc call home-assistant fixture process-no-output >/dev/null
|
||||||
|
qs_for_test ipc call home-assistant toggle light.fixture_hall >/dev/null
|
||||||
|
wait_for_home_status '
|
||||||
|
.phase == "ready" and
|
||||||
|
.busy == false and
|
||||||
|
.busyEntityIds == [] and
|
||||||
|
.pendingBrightness == {} and
|
||||||
|
.entityErrors == {} and
|
||||||
|
(.entities[] |
|
||||||
|
select(.id == "light.fixture_living") |
|
||||||
|
.active == false and .brightnessPct == 36) and
|
||||||
|
(.entities[] | select(.id == "light.fixture_hall") | .active == true)
|
||||||
|
' 'switching process fixtures stranded or misattributed the new action'
|
||||||
|
|
||||||
|
qs_for_test ipc call home-assistant fixture process-delayed-exit >/dev/null
|
||||||
|
qs_for_test ipc call home-assistant brightness light.fixture_living 88 >/dev/null
|
||||||
|
wait_for_home_status '
|
||||||
|
.busyEntityIds == ["light.fixture_living"] and
|
||||||
|
.pendingBrightness["light.fixture_living"] == 88 and
|
||||||
|
.actionProcessRunning == false and
|
||||||
|
.actionStreamFinished == true
|
||||||
|
' 'nested fixture transition setup did not reach its delayed-exit window'
|
||||||
|
qs_for_test ipc call home-assistant fixture process-no-output >/dev/null
|
||||||
|
qs_for_test ipc call home-assistant fixture process-slow-actions >/dev/null
|
||||||
|
qs_for_test ipc call home-assistant brightness light.fixture_desk 57 >/dev/null
|
||||||
|
jq -e '
|
||||||
|
.fixture == true and
|
||||||
|
.fixtureTransitionDraining == true and
|
||||||
|
.busy == false and
|
||||||
|
.busyEntityIds == [] and
|
||||||
|
.pendingBrightness == {} and
|
||||||
|
.entityErrors == {} and
|
||||||
|
.queuedActionCount == 0 and
|
||||||
|
.actionActive == false and
|
||||||
|
.actionProcessRunning == false and
|
||||||
|
(.entities[] |
|
||||||
|
select(.id == "light.fixture_desk") |
|
||||||
|
.active == true and .brightnessPct == 24)
|
||||||
|
' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \
|
||||||
|
|| fail 'nested fixture transition accepted an action during its drain'
|
||||||
|
wait_for_home_status '
|
||||||
|
.fixture == true and
|
||||||
|
.fixtureTransitionDraining == false and
|
||||||
|
.busy == false and
|
||||||
|
.queuedActionCount == 0 and
|
||||||
|
.actionActive == false and
|
||||||
|
.actionProcessRunning == false
|
||||||
|
' 'nested fixture transition did not install the latest stable fixture'
|
||||||
|
qs_for_test ipc call home-assistant brightness light.fixture_desk 57 >/dev/null
|
||||||
|
wait_for_home_status '
|
||||||
|
.phase == "ready" and
|
||||||
|
.fixtureTransitionDraining == false and
|
||||||
|
.busy == false and
|
||||||
|
.busyEntityIds == [] and
|
||||||
|
.pendingBrightness == {} and
|
||||||
|
.entityErrors == {} and
|
||||||
|
.queuedActionCount == 0 and
|
||||||
|
.actionActive == false and
|
||||||
|
(.entities[] |
|
||||||
|
select(.id == "light.fixture_living") |
|
||||||
|
.active == false and .brightnessPct == 36) and
|
||||||
|
(.entities[] |
|
||||||
|
select(.id == "light.fixture_desk") |
|
||||||
|
.active == true and .brightnessPct == 57)
|
||||||
|
' 'nested fixture transitions released the drain or corrupted the latest action'
|
||||||
|
|
||||||
|
qs_for_test ipc call home-assistant fixture process-delayed-exit >/dev/null
|
||||||
|
qs_for_test ipc call home-assistant brightness light.fixture_living 88 >/dev/null
|
||||||
|
wait_for_home_status '
|
||||||
|
.fixture == true and
|
||||||
|
.busyEntityIds == ["light.fixture_living"] and
|
||||||
|
.pendingBrightness["light.fixture_living"] == 88 and
|
||||||
|
.actionProcessRunning == false and
|
||||||
|
.actionStreamFinished == true
|
||||||
|
' 'reset-to-live setup did not reach its process drain window'
|
||||||
|
qs_for_test ipc call home-assistant reset >/dev/null
|
||||||
|
qs_for_test ipc call home-assistant toggle light.fixture_hall >/dev/null
|
||||||
|
qs_for_test ipc call home-assistant brightness light.fixture_living 63 >/dev/null
|
||||||
|
jq -e '
|
||||||
|
.fixture == true and
|
||||||
|
.fixtureTransitionDraining == true and
|
||||||
|
.busy == false and
|
||||||
|
.busyEntityIds == [] and
|
||||||
|
.pendingBrightness == {} and
|
||||||
|
.entityErrors == {} and
|
||||||
|
.queuedActionCount == 0 and
|
||||||
|
.actionActive == false and
|
||||||
|
.actionProcessRunning == false
|
||||||
|
' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \
|
||||||
|
|| fail 'reset-to-live drain accepted an action from the old fixture'
|
||||||
|
wait_for_home_status '
|
||||||
|
.fixture == false and
|
||||||
|
.fixtureTransitionDraining == false and
|
||||||
|
.busy == false and
|
||||||
|
.busyEntityIds == [] and
|
||||||
|
.pendingBrightness == {} and
|
||||||
|
.entityErrors == {} and
|
||||||
|
.queuedActionCount == 0 and
|
||||||
|
.actionActive == false and
|
||||||
|
.actionProcessRunning == false
|
||||||
|
' 'reset-to-live left stale fixture action state or started a replacement process'
|
||||||
|
if rg '^(toggle|brightness)( |$)' "$helper_log" >&2; then
|
||||||
|
fail 'reset-to-live issued a stale fixture action to the live helper'
|
||||||
|
fi
|
||||||
|
|
||||||
|
qs_for_test ipc call home-assistant fixture missing-selected >/dev/null
|
||||||
|
jq -e '
|
||||||
|
.fixture == true and
|
||||||
|
.phase == "ready" and
|
||||||
|
.discoveredCount == 7 and
|
||||||
|
.configuredCount == 8 and
|
||||||
|
(.entities[] |
|
||||||
|
select(.id == "light.fixture_missing") |
|
||||||
|
.sourceName == "fixture missing" and
|
||||||
|
.name == "Porch" and
|
||||||
|
.state == "unavailable" and
|
||||||
|
.available == false and
|
||||||
|
.active == false and
|
||||||
|
.dimmable == false and
|
||||||
|
.brightnessPct == 0)
|
||||||
|
' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \
|
||||||
|
|| fail 'missing Home selection was not retained as unavailable'
|
||||||
|
|
||||||
|
qs_for_test ipc call home-assistant fixture action-error >/dev/null
|
||||||
|
jq -e '
|
||||||
|
.fixture == true and
|
||||||
|
.phase == "ready" and
|
||||||
|
.stale == false and
|
||||||
|
.entityErrors["light.fixture_kitchen"] == "request-failed" and
|
||||||
|
(.entityErrors["light.fixture_hall"] // "") == "" and
|
||||||
|
.lastError == ""
|
||||||
|
' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \
|
||||||
|
|| fail 'Home action error was not isolated to one entity'
|
||||||
|
|
||||||
|
qs_for_test ipc call home-assistant fixture stale >/dev/null
|
||||||
jq -e '
|
jq -e '
|
||||||
.fixture == true and
|
.fixture == true and
|
||||||
.phase == "degraded" and
|
.phase == "degraded" and
|
||||||
|
.discoveredCount == 7 and
|
||||||
.configuredCount == 7 and
|
.configuredCount == 7 and
|
||||||
.visibleCount == 4 and
|
.visibleCount == 4 and
|
||||||
.stale == true and
|
.stale == true and
|
||||||
.lastError == "unreachable"
|
.lastError == "unreachable"
|
||||||
' <<<"$(qs ipc call home-assistant status)" >/dev/null \
|
' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \
|
||||||
|| fail 'stale Home fixture did not retain entities'
|
|| fail 'stale Home fixture did not retain entities'
|
||||||
|
|
||||||
qs ipc call home-assistant fixture unavailable >/dev/null
|
qs_for_test ipc call home-assistant fixture unavailable >/dev/null
|
||||||
jq -e '
|
jq -e '
|
||||||
.fixture == true and
|
.fixture == true and
|
||||||
.phase == "unavailable" and
|
.phase == "unavailable" and
|
||||||
|
.discoveredCount == 0 and
|
||||||
.configuredCount == 0 and
|
.configuredCount == 0 and
|
||||||
.visibleCount == 0 and
|
.visibleCount == 0 and
|
||||||
.lastError == "not-configured"
|
.lastError == "not-configured"
|
||||||
' <<<"$(qs ipc call home-assistant status)" >/dev/null \
|
' <<<"$(qs_for_test ipc call home-assistant status)" >/dev/null \
|
||||||
|| fail 'unavailable Home fixture is malformed'
|
|| fail 'unavailable Home fixture is malformed'
|
||||||
|
|
||||||
trap - EXIT
|
trap - EXIT
|
||||||
cleanup
|
cleanup
|
||||||
|
[[ ! -e "$state_home" ]] \
|
||||||
|
|| fail 'temporary Home preferences state was not removed after shell exit'
|
||||||
printf 'Control Center services contract: PASS\n'
|
printf 'Control Center services contract: PASS\n'
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
QtObject {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property bool available: false
|
||||||
|
property var devices: []
|
||||||
|
property bool transferActive: false
|
||||||
|
property string lastError: ""
|
||||||
|
property var recentExchange: null
|
||||||
|
property int actionCount: 0
|
||||||
|
|
||||||
|
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 var phoneActions: root.preferredPhone?.actions ?? []
|
||||||
|
|
||||||
|
function supports(action: string): bool {
|
||||||
|
return root.phoneActions.indexOf(action) >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh(): void {}
|
||||||
|
function sendFile(path: string): void { root.actionCount += 1; }
|
||||||
|
function sendClipboard(): void { root.actionCount += 1; }
|
||||||
|
function ring(): void { root.actionCount += 1; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
pragma Singleton
|
||||||
|
|
||||||
|
import QtQuick
|
||||||
|
|
||||||
|
QtObject {
|
||||||
|
property bool bluebubblesAvailable: true
|
||||||
|
property int launchCount: 0
|
||||||
|
|
||||||
|
function openApplication(id: string): bool {
|
||||||
|
launchCount += 1;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,24 +12,14 @@ helper="$project_root/config/dot/quickshell/scripts/panama-home-assistant"
|
|||||||
|
|
||||||
[[ -x "$helper" ]] || fail 'helper is missing or not executable'
|
[[ -x "$helper" ]] || fail 'helper is missing or not executable'
|
||||||
|
|
||||||
probe="$($helper probe)"
|
catalog="$($helper catalog)"
|
||||||
jq -e '
|
jq -e '
|
||||||
.configured == true and
|
.ok == true and .configured == true and .error == "" and
|
||||||
.reachable == true and
|
(.entities | type == "array" and length > 0) and
|
||||||
(.entityCount | type == "number" and . > 0) and
|
([.entities[] | (keys | sort) == (["active", "available", "brightnessPct", "dimmable", "id", "sourceName", "state"] | sort)] | all) and
|
||||||
.error == ""
|
([.entities[] | (.id | startswith("light.")) and (.brightnessPct >= 0 and .brightnessPct <= 100)] | all) and
|
||||||
' <<<"$probe" >/dev/null || fail 'live API probe failed'
|
(.legacyEntityIds | type == "array")
|
||||||
|
' <<<"$catalog" >/dev/null || fail 'live catalog shape is invalid'
|
||||||
|
|
||||||
snapshot="$($helper snapshot)"
|
printf 'Home Assistant helper contract: PASS (configured=true, %s lights; contents redacted)\n' \
|
||||||
jq -e --argjson expected "$(jq '.entityCount' <<<"$probe")" '
|
"$(jq '.entities | length' <<<"$catalog")"
|
||||||
.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")"
|
|
||||||
|
|||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
harness="$repo_dir/config/dot/quickshell/home-brightness-slider-harness.qml"
|
||||||
|
state_home="$(mktemp -d /tmp/panama-home-brightness-slider.XXXXXX)"
|
||||||
|
shell_log="$state_home/quickshell.log"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'Home brightness slider contract: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
qs_for_harness() {
|
||||||
|
QS_DISABLE_CRASH_HANDLER=1 XDG_STATE_HOME="$state_home" \
|
||||||
|
qs -p "$harness" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
qs_for_harness kill >/dev/null 2>&1 || true
|
||||||
|
rm -rf "$state_home"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
start_harness() {
|
||||||
|
qs_for_harness --daemonize >"$shell_log" 2>&1
|
||||||
|
for _ in $(seq 1 50); do
|
||||||
|
if qs_for_harness ipc show 2>/dev/null \
|
||||||
|
| rg -q '^target home-brightness-slider-test$'; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
sed -n '1,160p' "$shell_log" >&2
|
||||||
|
fail 'isolated slider harness did not start'
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_status() {
|
||||||
|
local filter="$1"
|
||||||
|
local message="$2"
|
||||||
|
local status=""
|
||||||
|
|
||||||
|
status="$(qs_for_harness ipc call home-brightness-slider-test status)"
|
||||||
|
jq -e "$filter" <<<"$status" >/dev/null || {
|
||||||
|
printf 'Slider status: %s\n' "$status" >&2
|
||||||
|
fail "$message"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start_harness
|
||||||
|
|
||||||
|
qs_for_harness ipc call home-brightness-slider-test reset 30 >/dev/null
|
||||||
|
assert_status \
|
||||||
|
'.accessibleRoleIsSlider == true and .accessibleName == "Desk lamp brightness" and .accessibleDescription == "30 percent, range 0 to 100" and .accessibleFocusable == true' \
|
||||||
|
'slider accessibility metadata is incomplete or incorrect'
|
||||||
|
qs_for_harness ipc call home-brightness-slider-test press 140 >/dev/null
|
||||||
|
assert_status \
|
||||||
|
'.confirmedValue == 30 and .previewValue == 70 and .interactionActive == true and .commitCount == 0 and .accessibleDescription == "70 percent, range 0 to 100"' \
|
||||||
|
'pointer press did not remain a local preview'
|
||||||
|
qs_for_harness ipc call home-brightness-slider-test release >/dev/null
|
||||||
|
assert_status \
|
||||||
|
'.previewValue == 70 and .interactionActive == false and .commitCount == 1 and .lastCommit == 70' \
|
||||||
|
'one pointer release did not emit exactly one commit'
|
||||||
|
qs_for_harness ipc call home-brightness-slider-test release >/dev/null
|
||||||
|
assert_status '.commitCount == 1' 'release without an interaction emitted another commit'
|
||||||
|
|
||||||
|
qs_for_harness ipc call home-brightness-slider-test reset 30 >/dev/null
|
||||||
|
qs_for_harness ipc call home-brightness-slider-test wheel 120 >/dev/null
|
||||||
|
assert_status \
|
||||||
|
'.previewValue == 35 and .interactionActive == false and .commitCount == 1 and .lastCommit == 35' \
|
||||||
|
'one wheel event did not emit exactly one five-point commit'
|
||||||
|
|
||||||
|
qs_for_harness ipc call home-brightness-slider-test reset 30 >/dev/null
|
||||||
|
qs_for_harness ipc call home-brightness-slider-test press 160 >/dev/null
|
||||||
|
assert_status \
|
||||||
|
'.previewValue == 80 and .interactionActive == true and .commitCount == 0' \
|
||||||
|
'cancellation setup did not enter local preview state'
|
||||||
|
qs_for_harness ipc call home-brightness-slider-test external 45 >/dev/null
|
||||||
|
assert_status \
|
||||||
|
'.confirmedValue == 45 and .previewValue == 80 and .interactionActive == true and .commitCount == 0' \
|
||||||
|
'external confirmed or pending state replaced an active local preview'
|
||||||
|
qs_for_harness ipc call home-brightness-slider-test cancel >/dev/null
|
||||||
|
assert_status \
|
||||||
|
'.confirmedValue == 45 and .previewValue == 45 and .interactionActive == false and .commitCount == 0 and .lastCommit == -1' \
|
||||||
|
'canceled interaction committed or failed to restore the external value'
|
||||||
|
|
||||||
|
trap - EXIT
|
||||||
|
cleanup
|
||||||
|
printf 'Home brightness slider contract: PASS\n'
|
||||||
+307
@@ -0,0 +1,307 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
state_home="$(mktemp -d /tmp/panama-home-phone-settings-state.XXXXXX)"
|
||||||
|
source_config_path="$repo_dir/config/dot/quickshell"
|
||||||
|
config_path="$state_home/quickshell"
|
||||||
|
test_bin="$state_home/bin"
|
||||||
|
shell_log="$state_home/quickshell.log"
|
||||||
|
flatpak_log="$state_home/flatpak.log"
|
||||||
|
|
||||||
|
cleanup_bootstrap() {
|
||||||
|
rm -rf "$state_home"
|
||||||
|
}
|
||||||
|
trap cleanup_bootstrap EXIT
|
||||||
|
|
||||||
|
mkdir -p "$test_bin"
|
||||||
|
cp -a "$source_config_path" "$config_path"
|
||||||
|
: >"$flatpak_log"
|
||||||
|
|
||||||
|
cat >"$config_path/scripts/panama-home-assistant" <<'EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
catalog)
|
||||||
|
printf '%s\n' '{"ok":false,"error":"test-helper"}'
|
||||||
|
;;
|
||||||
|
toggle|brightness)
|
||||||
|
printf '%s\n' '{"ok":true}'
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
EOF
|
||||||
|
chmod +x "$config_path/scripts/panama-home-assistant"
|
||||||
|
|
||||||
|
cat >"$test_bin/hyprctl" <<'EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "-j" && "${2:-}" == "monitors" ]]; then
|
||||||
|
printf '%s\n' '[{"focused":true,"name":"TEST-1","description":"Home and Phone contract","width":1920,"height":1080,"refreshRate":60,"scale":1,"currentFormat":"XRGB8888","colorManagementPreset":"srgb","vrr":false}]'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ "${1:-}" == "keyword" ]]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
exec /usr/sbin/hyprctl "$@"
|
||||||
|
EOF
|
||||||
|
chmod +x "$test_bin/hyprctl"
|
||||||
|
|
||||||
|
cat >"$test_bin/flatpak" <<'EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
printf '%s\n' "$*" >>"$PANAMA_FLATPAK_LOG"
|
||||||
|
if [[ "${1:-}" == "info" && "${2:-}" == "app.bluebubbles.BlueBubbles" ]]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
exit 97
|
||||||
|
EOF
|
||||||
|
chmod +x "$test_bin/flatpak"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'home phone settings contract: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_contains() {
|
||||||
|
local needle="$1"
|
||||||
|
local file="$2"
|
||||||
|
rg -Fq "$needle" "$file" || fail "$file is missing: $needle"
|
||||||
|
}
|
||||||
|
|
||||||
|
home_page="$repo_dir/config/dot/quickshell/modules/settings/HomePhonePage.qml"
|
||||||
|
favorite_card="$repo_dir/config/dot/quickshell/modules/settings/HomeFavoriteCard.qml"
|
||||||
|
available_row="$repo_dir/config/dot/quickshell/modules/settings/AvailableLightRow.qml"
|
||||||
|
system_settings="$repo_dir/config/dot/quickshell/services/SystemSettings.qml"
|
||||||
|
|
||||||
|
[[ -f "$home_page" ]] || fail 'HomePhonePage.qml is missing'
|
||||||
|
[[ -f "$favorite_card" ]] || fail 'HomeFavoriteCard.qml is missing'
|
||||||
|
[[ -f "$available_row" ]] || fail 'AvailableLightRow.qml is missing'
|
||||||
|
assert_contains 'text: "Home & Phone"' "$home_page"
|
||||||
|
assert_contains 'text: "Choose what appears in Control Center and keep phone continuity close at hand."' "$home_page"
|
||||||
|
assert_contains 'Connected · ' "$home_page"
|
||||||
|
assert_contains 'Last update unavailable · showing saved controls' "$home_page"
|
||||||
|
assert_contains 'Authentication required' "$home_page"
|
||||||
|
assert_contains 'Home Assistant is not configured' "$home_page"
|
||||||
|
assert_contains 'HomePreferences.setAlias' "$home_page"
|
||||||
|
assert_contains 'HomePreferences.move' "$home_page"
|
||||||
|
assert_contains 'HomePreferences.remove' "$home_page"
|
||||||
|
assert_contains 'HomePreferences.add' "$home_page"
|
||||||
|
assert_contains 'HomePreferences.retrySave' "$home_page"
|
||||||
|
assert_contains 'Choose lights below to build your Control Center shelf.' "$home_page"
|
||||||
|
assert_contains 'All discovered lights are already selected' "$home_page"
|
||||||
|
assert_contains 'No lights discovered' "$home_page"
|
||||||
|
assert_contains 'No lights match that search' "$home_page"
|
||||||
|
assert_contains 'Opens BlueBubbles' "$home_page"
|
||||||
|
[[ "$(rg -Fc 'required property var modelData' "$home_page")" -ge 2 ]] \
|
||||||
|
|| fail 'HomePhonePage.qml does not bind both reusable delegates to modelData'
|
||||||
|
if rg -Fq 'index: model.index' "$home_page"; then
|
||||||
|
fail 'HomePhonePage.qml reads an undefined model.index instead of the delegate index'
|
||||||
|
fi
|
||||||
|
if rg -qi 'token|bearer|api/states' "$home_page"; then
|
||||||
|
fail 'HomePhonePage.qml crosses the credential or REST privacy boundary'
|
||||||
|
fi
|
||||||
|
assert_contains 'signal aliasCommitted(string id, string alias)' "$favorite_card"
|
||||||
|
assert_contains 'signal removeRequested(string id)' "$favorite_card"
|
||||||
|
assert_contains 'signal moveRequested(string id, int targetIndex)' "$favorite_card"
|
||||||
|
assert_contains 'DragHandler {' "$favorite_card"
|
||||||
|
assert_contains 'onEditingFinished:' "$favorite_card"
|
||||||
|
assert_contains 'text: "Control Center"' "$favorite_card"
|
||||||
|
assert_contains 'activeFocusOnTab: true' "$favorite_card"
|
||||||
|
assert_contains 'signal addRequested(string id)' "$available_row"
|
||||||
|
assert_contains 'activeFocusOnTab: true' "$available_row"
|
||||||
|
assert_contains 'readonly property bool bluebubblesAvailable: root.bluebubblesDetected' "$system_settings"
|
||||||
|
assert_contains 'command: ["flatpak", "info", "app.bluebubbles.BlueBubbles"]' "$system_settings"
|
||||||
|
assert_contains '"bluebubbles": ["flatpak", "run", "app.bluebubbles.BlueBubbles"]' "$system_settings"
|
||||||
|
|
||||||
|
qs_for_test() {
|
||||||
|
PATH="$test_bin:$PATH" QS_CONFIG_PATH="$config_path" XDG_STATE_HOME="$state_home" \
|
||||||
|
PANAMA_FLATPAK_LOG="$flatpak_log" qs -p "$config_path" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_test_shell() {
|
||||||
|
qs_for_test kill >/dev/null 2>&1 || true
|
||||||
|
for _ in $(seq 1 80); do
|
||||||
|
if ! qs_for_test list 2>/dev/null | rg '^Instance ' >/dev/null \
|
||||||
|
&& ! qs_for_test ipc show >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
qs_for_test ipc call settings close >/dev/null 2>&1 || true
|
||||||
|
if stop_test_shell; then
|
||||||
|
rm -rf "$state_home"
|
||||||
|
else
|
||||||
|
printf 'home phone settings contract: branch shell did not stop; retained %s\n' \
|
||||||
|
"$state_home" >&2
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
start_test_shell() {
|
||||||
|
stop_test_shell || fail 'pre-existing branch shell did not stop cleanly'
|
||||||
|
for _attempt in 1 2; do
|
||||||
|
qs_for_test --daemonize >"$shell_log" 2>&1
|
||||||
|
for _ in $(seq 1 80); do
|
||||||
|
if qs_for_test ipc show 2>/dev/null | rg '^target settings-system$' >/dev/null; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
stop_test_shell || fail 'failed branch-shell attempt did not stop cleanly'
|
||||||
|
done
|
||||||
|
sed -n '1,240p' "$shell_log" >&2
|
||||||
|
fail 'isolated branch shell did not start'
|
||||||
|
}
|
||||||
|
|
||||||
|
start_test_shell
|
||||||
|
qs_for_test ipc call home-assistant fixture ready >/dev/null
|
||||||
|
qs_for_test ipc call settings page home-phone >/dev/null
|
||||||
|
|
||||||
|
status='{}'
|
||||||
|
for _ in $(seq 1 40); do
|
||||||
|
status="$(qs_for_test ipc call settings status | jq -c .)"
|
||||||
|
if jq -e '.page == "home-phone" and .discoveredCount == 7 and .selectedCount == 7' \
|
||||||
|
<<<"$status" >/dev/null; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
jq -e '
|
||||||
|
.open == true and
|
||||||
|
.page == "home-phone" and
|
||||||
|
.discoveredCount == 7 and
|
||||||
|
.selectedCount == 7 and
|
||||||
|
.homePhone.availableLightIds == [] and
|
||||||
|
.homePhone.availableEmptyText == "All discovered lights are already selected"
|
||||||
|
' \
|
||||||
|
<<<"$status" >/dev/null || fail "Home & Phone diagnostics are incomplete: $status"
|
||||||
|
|
||||||
|
qs_for_test ipc call home-assistant fixture available-extra >/dev/null
|
||||||
|
available_status='{}'
|
||||||
|
for _ in $(seq 1 40); do
|
||||||
|
available_status="$(qs_for_test ipc call settings status | jq -c .)"
|
||||||
|
if jq -e '
|
||||||
|
.discoveredCount == 8 and
|
||||||
|
.selectedCount == 7 and
|
||||||
|
.homePhone.availableLightIds == ["light.fixture_guest"]
|
||||||
|
' <<<"$available_status" >/dev/null; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
jq -e '
|
||||||
|
.discoveredCount == 8 and
|
||||||
|
.selectedCount == 7 and
|
||||||
|
.homePhone.availableLightIds == ["light.fixture_guest"]
|
||||||
|
' <<<"$available_status" >/dev/null \
|
||||||
|
|| fail "an unselected fixture light was not the only available row: $available_status"
|
||||||
|
|
||||||
|
qs_for_test ipc call home-assistant fixture stale-authentication >/dev/null
|
||||||
|
authentication_status='{}'
|
||||||
|
for _ in $(seq 1 40); do
|
||||||
|
authentication_status="$(qs_for_test ipc call settings status | jq -c .)"
|
||||||
|
if jq -e '
|
||||||
|
.discoveredCount == 7 and
|
||||||
|
.selectedCount == 7 and
|
||||||
|
.homePhone.homeStatus == "Authentication required"
|
||||||
|
' <<<"$authentication_status" >/dev/null; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
jq -e '
|
||||||
|
.discoveredCount == 7 and
|
||||||
|
.selectedCount == 7 and
|
||||||
|
.homePhone.homeStatus == "Authentication required"
|
||||||
|
' <<<"$authentication_status" >/dev/null \
|
||||||
|
|| fail "stale authentication did not retain actionable copy: $authentication_status"
|
||||||
|
|
||||||
|
qs_for_test ipc call home-assistant fixture stale-not-configured >/dev/null
|
||||||
|
not_configured_status='{}'
|
||||||
|
for _ in $(seq 1 40); do
|
||||||
|
not_configured_status="$(qs_for_test ipc call settings status | jq -c .)"
|
||||||
|
if jq -e '
|
||||||
|
.discoveredCount == 7 and
|
||||||
|
.selectedCount == 7 and
|
||||||
|
.homePhone.homeStatus == "Home Assistant is not configured"
|
||||||
|
' <<<"$not_configured_status" >/dev/null; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
jq -e '
|
||||||
|
.discoveredCount == 7 and
|
||||||
|
.selectedCount == 7 and
|
||||||
|
.homePhone.homeStatus == "Home Assistant is not configured"
|
||||||
|
' <<<"$not_configured_status" >/dev/null \
|
||||||
|
|| fail "stale not-configured state did not retain actionable copy: $not_configured_status"
|
||||||
|
|
||||||
|
qs_for_test ipc call home-assistant fixture unavailable >/dev/null
|
||||||
|
empty_status='{}'
|
||||||
|
for _ in $(seq 1 40); do
|
||||||
|
empty_status="$(qs_for_test ipc call settings status | jq -c .)"
|
||||||
|
if jq -e '
|
||||||
|
.discoveredCount == 0 and
|
||||||
|
.selectedCount == 0 and
|
||||||
|
.homePhone.homeStatus == "Home Assistant is not configured" and
|
||||||
|
.homePhone.availableLightIds == [] and
|
||||||
|
.homePhone.availableEmptyText == "No lights discovered"
|
||||||
|
' <<<"$empty_status" >/dev/null; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
jq -e '
|
||||||
|
.discoveredCount == 0 and
|
||||||
|
.selectedCount == 0 and
|
||||||
|
.homePhone.homeStatus == "Home Assistant is not configured" and
|
||||||
|
.homePhone.availableLightIds == [] and
|
||||||
|
.homePhone.availableEmptyText == "No lights discovered"
|
||||||
|
' <<<"$empty_status" >/dev/null \
|
||||||
|
|| fail "an empty catalog did not render its distinct copy: $empty_status"
|
||||||
|
|
||||||
|
system_status="$(qs_for_test ipc call settings-system status | jq -c .)"
|
||||||
|
jq -e '.bluebubblesAvailable == true' <<<"$system_status" >/dev/null \
|
||||||
|
|| fail "BlueBubbles availability was not exposed: $system_status"
|
||||||
|
|
||||||
|
shell_pid="$(qs_for_test list | awk '/Process ID:/ { print $3; exit }')"
|
||||||
|
[[ "$shell_pid" =~ ^[0-9]+$ ]] || fail 'could not identify the branch shell process'
|
||||||
|
for _ in $(seq 1 40); do
|
||||||
|
if /usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \
|
||||||
|
'[.[] | select(.pid == $pid and .title == "Panama Settings" and .floating == false)] | length == 1' >/dev/null; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
/usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \
|
||||||
|
'[.[] | select(.pid == $pid and .title == "Panama Settings" and .floating == false)] | length == 1' >/dev/null \
|
||||||
|
|| fail 'the branch shell did not own exactly one tiled Panama Settings client'
|
||||||
|
|
||||||
|
if [[ -n "${PANAMA_TEST_SCREENSHOT_PATH:-}" ]]; then
|
||||||
|
geometry="$(/usr/sbin/hyprctl -j clients | jq -r --argjson pid "$shell_pid" \
|
||||||
|
'.[] | select(.pid == $pid and .title == "Panama Settings") | "\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"')"
|
||||||
|
[[ -n "$geometry" ]] || fail 'could not resolve the Settings client geometry'
|
||||||
|
grim -g "$geometry" "$PANAMA_TEST_SCREENSHOT_PATH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
rg -Fxq 'info app.bluebubbles.BlueBubbles' "$flatpak_log" \
|
||||||
|
|| fail 'the fixed BlueBubbles availability probe did not run'
|
||||||
|
if rg -q '^run ' "$flatpak_log"; then
|
||||||
|
fail 'the contract started BlueBubbles'
|
||||||
|
fi
|
||||||
|
if find "$state_home" -name panama-home.json -print -quit | rg -q .; then
|
||||||
|
fail 'the read-only fixture route wrote Home preferences'
|
||||||
|
fi
|
||||||
|
|
||||||
|
trap - EXIT
|
||||||
|
cleanup
|
||||||
|
[[ ! -e "$state_home" ]] || fail 'temporary Home & Phone state was not removed after shell exit'
|
||||||
|
printf 'home phone settings contract: PASS\n'
|
||||||
+124
@@ -0,0 +1,124 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
harness="$repo_dir/config/dot/quickshell/home-preferences-harness.qml"
|
||||||
|
state_home="$(mktemp -d /tmp/panama-home-preferences-state.XXXXXX)"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'home preferences contract: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
qs_for_harness() {
|
||||||
|
XDG_STATE_HOME="$state_home" qs -p "$harness" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
qs_for_harness kill >/dev/null 2>&1 || true
|
||||||
|
rm -rf "$state_home"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
start_harness() {
|
||||||
|
qs_for_harness --daemonize >/dev/null
|
||||||
|
for _ in $(seq 1 40); do
|
||||||
|
if qs_for_harness ipc show 2>/dev/null | rg -q '^target home-pref-test$'; then
|
||||||
|
# Construct the lazy singleton and let its explicit reload finish
|
||||||
|
# before issuing mutations through the IPC boundary.
|
||||||
|
qs_for_harness ipc call home-pref-test status >/dev/null
|
||||||
|
sleep 0.2
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
fail 'test IPC target did not start'
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_harness() {
|
||||||
|
qs_for_harness kill >/dev/null 2>&1 || true
|
||||||
|
for _ in $(seq 1 40); do
|
||||||
|
if ! qs_for_harness ipc show >/dev/null 2>&1; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
fail 'test shell did not stop cleanly'
|
||||||
|
}
|
||||||
|
|
||||||
|
status_without_state_dir() {
|
||||||
|
qs_for_harness ipc call home-pref-test status | jq -c 'del(.stateDir)'
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_status() {
|
||||||
|
local expected="$1"
|
||||||
|
local actual=""
|
||||||
|
|
||||||
|
for _ in $(seq 1 40); do
|
||||||
|
actual="$(status_without_state_dir)"
|
||||||
|
if jq -e --argjson expected "$expected" \
|
||||||
|
'.initialized == $expected.initialized and .favorites == $expected.favorites and .saveError == $expected.saveError' \
|
||||||
|
<<<"$actual" >/dev/null; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
fail "unexpected status: $actual"
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_file_content() {
|
||||||
|
local expected="$1"
|
||||||
|
|
||||||
|
for _ in $(seq 1 40); do
|
||||||
|
state_file="$(find "$state_home" -name panama-home.json -print -quit)"
|
||||||
|
if [[ -n "$state_file" ]] \
|
||||||
|
&& jq -e --argjson expected "$expected" \
|
||||||
|
'.initialized == $expected.initialized and .favorites == $expected.favorites' \
|
||||||
|
"$state_file" >/dev/null; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
fail 'preferences file did not contain the complete atomic update'
|
||||||
|
}
|
||||||
|
|
||||||
|
# A leading JSON whitespace prevents qs from expanding the array into IPC
|
||||||
|
# positional arguments; JSON.parse() intentionally accepts that whitespace.
|
||||||
|
initial_ids=' ["light.kitchen","light.hall","light.desk"]'
|
||||||
|
expected='{"initialized":true,"favorites":[{"id":"light.desk","alias":""},{"id":"light.kitchen","alias":"Island"}],"saveError":""}'
|
||||||
|
expected_file='{"initialized":true,"favorites":[{"id":"light.desk","alias":""},{"id":"light.kitchen","alias":"Island"}]}'
|
||||||
|
empty_expected='{"initialized":true,"favorites":[],"saveError":""}'
|
||||||
|
empty_file='{"initialized":true,"favorites":[]}'
|
||||||
|
|
||||||
|
start_harness
|
||||||
|
qs_for_harness ipc call home-pref-test initialize "$initial_ids" >/dev/null
|
||||||
|
qs_for_harness ipc call home-pref-test alias light.kitchen ' Island ' >/dev/null
|
||||||
|
qs_for_harness ipc call home-pref-test move light.desk 0 >/dev/null
|
||||||
|
qs_for_harness ipc call home-pref-test remove light.hall >/dev/null
|
||||||
|
wait_for_status "$expected"
|
||||||
|
wait_for_file_content "$expected_file"
|
||||||
|
|
||||||
|
stop_harness
|
||||||
|
start_harness
|
||||||
|
wait_for_status "$expected"
|
||||||
|
|
||||||
|
qs_for_harness ipc call home-pref-test remove light.desk >/dev/null
|
||||||
|
qs_for_harness ipc call home-pref-test remove light.kitchen >/dev/null
|
||||||
|
wait_for_status "$empty_expected"
|
||||||
|
wait_for_file_content "$empty_file"
|
||||||
|
stop_harness
|
||||||
|
start_harness
|
||||||
|
wait_for_status "$empty_expected"
|
||||||
|
qs_for_harness ipc call home-pref-test initialize ' ["light.new","light.other"]' >/dev/null
|
||||||
|
wait_for_status "$empty_expected"
|
||||||
|
|
||||||
|
jq -e '(keys | sort) == ["favorites", "initialized"]' "$state_file" >/dev/null \
|
||||||
|
|| fail 'preferences file contains keys other than initialized and favorites'
|
||||||
|
if jq -r '.. | strings' "$state_file" | rg -qi 'token|url|api'; then
|
||||||
|
fail 'preferences file contains credential-like data'
|
||||||
|
fi
|
||||||
|
|
||||||
|
trap - EXIT
|
||||||
|
cleanup
|
||||||
|
printf 'home preferences contract: PASS\n'
|
||||||
@@ -14,6 +14,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|||||||
|
|
||||||
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
ROOT = pathlib.Path(__file__).resolve().parents[2]
|
||||||
HELPER = ROOT / "config/dot/quickshell/scripts/panama-home-assistant"
|
HELPER = ROOT / "config/dot/quickshell/scripts/panama-home-assistant"
|
||||||
|
QML_SERVICE = ROOT / "config/dot/quickshell/services/HomeAssistant.qml"
|
||||||
|
|
||||||
loader = importlib.machinery.SourceFileLoader("panama_home_assistant", str(HELPER))
|
loader = importlib.machinery.SourceFileLoader("panama_home_assistant", str(HELPER))
|
||||||
spec = importlib.util.spec_from_loader(loader.name, loader)
|
spec = importlib.util.spec_from_loader(loader.name, loader)
|
||||||
@@ -54,20 +55,36 @@ class FakeHomeAssistant(BaseHTTPRequestHandler):
|
|||||||
if self.path == "/api/states":
|
if self.path == "/api/states":
|
||||||
self._json(
|
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",
|
"entity_id": "light.kitchen",
|
||||||
"state": "on",
|
"state": "on",
|
||||||
"attributes": {"friendly_name": "Kitchen"},
|
"attributes": {
|
||||||
|
"friendly_name": "Kitchen",
|
||||||
|
"brightness": 128,
|
||||||
|
"supported_color_modes": ["brightness"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entity_id": "light.hall",
|
||||||
|
"state": "off",
|
||||||
|
"attributes": {
|
||||||
|
"friendly_name": "Hall",
|
||||||
|
"supported_color_modes": ["color_temp"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{"entity_id": "sensor.private", "state": "1", "attributes": {"token": "never-return"}},
|
||||||
|
{
|
||||||
|
"entity_id": "light.malformed",
|
||||||
|
"state": "on",
|
||||||
|
"attributes": "invalid",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"entity_id": "light.corner",
|
||||||
|
"state": "unavailable",
|
||||||
|
"attributes": {
|
||||||
|
"friendly_name": "Corner",
|
||||||
|
"supported_color_modes": ["brightness"],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -78,7 +95,11 @@ class FakeHomeAssistant(BaseHTTPRequestHandler):
|
|||||||
length = int(self.headers.get("Content-Length", "0"))
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
body = self.rfile.read(length)
|
body = self.rfile.read(length)
|
||||||
self._record(body)
|
self._record(body)
|
||||||
if self.path == "/api/services/homeassistant/toggle":
|
if self.path in {
|
||||||
|
"/api/services/homeassistant/toggle",
|
||||||
|
"/api/services/light/turn_on",
|
||||||
|
"/api/services/light/turn_off",
|
||||||
|
}:
|
||||||
self._json([])
|
self._json([])
|
||||||
return
|
return
|
||||||
self._json({"message": "not found"}, 404)
|
self._json({"message": "not found"}, 404)
|
||||||
@@ -123,33 +144,71 @@ class HomeAssistantBridgeTest(unittest.TestCase):
|
|||||||
self.assertEqual(config.base_url, "https://home.example")
|
self.assertEqual(config.base_url, "https://home.example")
|
||||||
self.assertEqual(config.entity_ids, ("light.kitchen", "light.hall"))
|
self.assertEqual(config.entity_ids, ("light.kitchen", "light.hall"))
|
||||||
|
|
||||||
def test_snapshot_filters_and_preserves_configured_order(self) -> None:
|
def test_config_does_not_require_legacy_entity_ids(self) -> None:
|
||||||
snapshot = bridge.collect_snapshot(self.config())
|
self.assertTrue(
|
||||||
|
bridge.Config(
|
||||||
self.assertTrue(snapshot["ok"])
|
base_url="https://home.example",
|
||||||
self.assertEqual(
|
token="fixture-token",
|
||||||
[item["name"] for item in snapshot["entities"]],
|
entity_ids=(),
|
||||||
["Kitchen", "Hall"],
|
).configured
|
||||||
)
|
)
|
||||||
self.assertNotIn("sensor.private", json.dumps(snapshot))
|
|
||||||
|
def test_legacy_entity_ids_remain_migration_metadata(self) -> None:
|
||||||
|
config = bridge.resolve_config(
|
||||||
|
{
|
||||||
|
"PANAMA_HOME_ASSISTANT_URL": "https://home.example",
|
||||||
|
"PANAMA_HOME_ASSISTANT_TOKEN": "fixture-token",
|
||||||
|
},
|
||||||
|
legacy=lambda: bridge.Config(
|
||||||
|
base_url="https://legacy.example",
|
||||||
|
token="legacy-token",
|
||||||
|
entity_ids=("light.kitchen",),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(config.base_url, "https://home.example")
|
||||||
|
self.assertEqual(config.token, "fixture-token")
|
||||||
|
self.assertEqual(config.entity_ids, ("light.kitchen",))
|
||||||
|
|
||||||
|
def test_catalog_filters_and_normalizes_discovered_lights(self) -> None:
|
||||||
|
catalog = bridge.collect_catalog(self.config())
|
||||||
|
|
||||||
|
self.assertTrue(catalog["ok"])
|
||||||
|
self.assertEqual(
|
||||||
|
[item["sourceName"] for item in catalog["entities"]],
|
||||||
|
["Kitchen", "Hall", "Corner"],
|
||||||
|
)
|
||||||
|
self.assertEqual(catalog["entities"][0]["brightnessPct"], 50)
|
||||||
|
self.assertEqual(catalog["entities"][1]["brightnessPct"], 0)
|
||||||
|
self.assertTrue(all(item["dimmable"] for item in catalog["entities"]))
|
||||||
|
rendered = json.dumps(catalog)
|
||||||
|
self.assertNotIn("sensor.private", rendered)
|
||||||
|
self.assertNotIn("light.malformed", rendered)
|
||||||
|
self.assertNotIn("token", rendered)
|
||||||
|
|
||||||
def test_snapshot_uses_bearer_authentication(self) -> None:
|
def test_snapshot_uses_bearer_authentication(self) -> None:
|
||||||
bridge.collect_snapshot(self.config())
|
bridge.collect_catalog(self.config())
|
||||||
|
|
||||||
request = FakeHomeAssistant.requests[-1]
|
request = FakeHomeAssistant.requests[-1]
|
||||||
self.assertEqual(request["method"], "GET")
|
self.assertEqual(request["method"], "GET")
|
||||||
self.assertEqual(request["path"], "/api/states")
|
self.assertEqual(request["path"], "/api/states")
|
||||||
self.assertEqual(request["authorization"], "Bearer fixture-token")
|
self.assertEqual(request["authorization"], "Bearer fixture-token")
|
||||||
|
|
||||||
def test_toggle_rejects_an_unconfigured_entity(self) -> None:
|
def test_snapshot_remains_a_catalog_compatibility_alias(self) -> None:
|
||||||
with self.assertRaisesRegex(ValueError, "entity-not-configured"):
|
snapshot = bridge.collect_snapshot(self.config())
|
||||||
bridge.ensure_configured(
|
|
||||||
"light.office",
|
self.assertTrue(snapshot["ok"])
|
||||||
("light.kitchen",),
|
self.assertEqual(
|
||||||
|
[item["sourceName"] for item in snapshot["entities"]],
|
||||||
|
["Kitchen", "Hall", "Corner"],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_toggle_calls_the_homeassistant_service(self) -> None:
|
def test_action_rejects_an_entity_not_in_the_live_catalog(self) -> None:
|
||||||
result = bridge.toggle(self.config(), "light.kitchen")
|
with self.assertRaisesRegex(ValueError, "entity-not-discovered"):
|
||||||
|
bridge.toggle(self.config(), "light.office")
|
||||||
|
|
||||||
|
def test_toggle_authorizes_against_discovered_catalog(self) -> None:
|
||||||
|
result = bridge.toggle(self.config(), "light.corner")
|
||||||
|
|
||||||
self.assertTrue(result["ok"])
|
self.assertTrue(result["ok"])
|
||||||
request = FakeHomeAssistant.requests[-1]
|
request = FakeHomeAssistant.requests[-1]
|
||||||
@@ -157,9 +216,61 @@ class HomeAssistantBridgeTest(unittest.TestCase):
|
|||||||
self.assertEqual(request["path"], "/api/services/homeassistant/toggle")
|
self.assertEqual(request["path"], "/api/services/homeassistant/toggle")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
json.loads(request["body"]),
|
json.loads(request["body"]),
|
||||||
{"entity_id": "light.kitchen"},
|
{"entity_id": "light.corner"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_brightness_uses_turn_on_for_positive_percent(self) -> None:
|
||||||
|
bridge.set_brightness(self.config(), "light.kitchen", 62)
|
||||||
|
|
||||||
|
request = FakeHomeAssistant.requests[-1]
|
||||||
|
self.assertEqual(request["path"], "/api/services/light/turn_on")
|
||||||
|
self.assertEqual(
|
||||||
|
json.loads(request["body"]),
|
||||||
|
{"entity_id": "light.kitchen", "brightness_pct": 62},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_brightness_zero_uses_turn_off(self) -> None:
|
||||||
|
bridge.set_brightness(self.config(), "light.hall", 0)
|
||||||
|
|
||||||
|
request = FakeHomeAssistant.requests[-1]
|
||||||
|
self.assertEqual(request["path"], "/api/services/light/turn_off")
|
||||||
|
self.assertEqual(
|
||||||
|
json.loads(request["body"]),
|
||||||
|
{"entity_id": "light.hall"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_brightness_rejects_invalid_values_before_any_request(self) -> None:
|
||||||
|
for value in (-1, 101, 1.5, "bright"):
|
||||||
|
with self.subTest(value=value):
|
||||||
|
with self.assertRaisesRegex(ValueError, "invalid-brightness"):
|
||||||
|
bridge.set_brightness(self.config(), "light.kitchen", value)
|
||||||
|
self.assertEqual(FakeHomeAssistant.requests, [])
|
||||||
|
|
||||||
|
def test_parse_brightness_rejects_invalid_cli_values(self) -> None:
|
||||||
|
for value in ("-1", "101", "1.5", "bright"):
|
||||||
|
with self.subTest(value=value):
|
||||||
|
with self.assertRaisesRegex(ValueError, "invalid-brightness"):
|
||||||
|
bridge.parse_brightness(value)
|
||||||
|
|
||||||
|
def test_qml_refreshes_through_the_catalog_command(self) -> None:
|
||||||
|
self.assertIn('command: [root.helperPath, "catalog"]', QML_SERVICE.read_text())
|
||||||
|
|
||||||
|
def test_qml_composes_catalog_and_preferences_for_selected_entities(self) -> None:
|
||||||
|
source = QML_SERVICE.read_text()
|
||||||
|
|
||||||
|
self.assertIn(
|
||||||
|
"root.catalog = Array.isArray(result.entities) ? result.entities : [];",
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"const favorites = root.fixtureMode\n"
|
||||||
|
" ? root.fixtureFavorites\n"
|
||||||
|
" : HomePreferences.favorites;",
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
self.assertIn("name: alias || entity.sourceName,", source)
|
||||||
|
self.assertIn("root.selectedEntities = nextSelection;", source)
|
||||||
|
|
||||||
def test_authentication_error_is_redacted(self) -> None:
|
def test_authentication_error_is_redacted(self) -> None:
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
bridge.public_http_error(401, "sensitive response"),
|
bridge.public_http_error(401, "sensitive response"),
|
||||||
|
|||||||
Executable
+145
@@ -0,0 +1,145 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
phone_controls="$project_root/config/dot/quickshell/modules/quicksettings/PhoneControls.qml"
|
||||||
|
phone_actions="$project_root/config/dot/quickshell/modules/quicksettings/PhoneActions.qml"
|
||||||
|
quicksettings_qmldir="$project_root/config/dot/quickshell/modules/quicksettings/qmldir"
|
||||||
|
system_settings="$project_root/config/dot/quickshell/services/SystemSettings.qml"
|
||||||
|
harness="$project_root/config/dot/quickshell/phone-controls-harness.qml"
|
||||||
|
kde_fixture="$project_root/tests/quickshell/fixtures/PhoneControlsKdeConnect.qml"
|
||||||
|
settings_fixture="$project_root/tests/quickshell/fixtures/PhoneControlsSystemSettings.qml"
|
||||||
|
state_home="$(mktemp -d /tmp/panama-phone-controls.XXXXXX)"
|
||||||
|
config_path="$state_home/quickshell"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
printf 'Phone Messages contract: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ -f "$phone_controls" ]] || fail 'Phone controls component is missing'
|
||||||
|
[[ -f "$phone_actions" ]] || fail 'Nonvisual Phone actions component is missing'
|
||||||
|
[[ -f "$system_settings" ]] || fail 'System settings service is missing'
|
||||||
|
[[ -f "$harness" ]] || fail 'Phone controls runtime harness is missing'
|
||||||
|
[[ -f "$kde_fixture" ]] || fail 'Phone controls KDE fixture is missing'
|
||||||
|
[[ -f "$settings_fixture" ]] || fail 'Phone controls System Settings fixture is missing'
|
||||||
|
|
||||||
|
rg -Fq 'PhoneActions 1.0 PhoneActions.qml' "$quicksettings_qmldir" \
|
||||||
|
|| fail 'Phone actions component is not registered'
|
||||||
|
rg -Fq 'PhoneActions {' "$phone_controls" \
|
||||||
|
|| fail 'Phone controls do not consume the shared action model'
|
||||||
|
rg -Fq 'readonly property var actionModels: phoneActions.actionModels' "$phone_controls" \
|
||||||
|
|| fail 'Phone controls do not render the shared action models'
|
||||||
|
rg -Fq 'PhoneActions {' "$harness" \
|
||||||
|
|| fail 'Runtime harness does not use the nonvisual action model'
|
||||||
|
if rg -Fq 'PhoneControls {' "$harness"; then
|
||||||
|
fail 'Runtime harness still instantiates the visual Phone controls tree'
|
||||||
|
fi
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
XDG_STATE_HOME="$state_home" QS_DISABLE_CRASH_HANDLER=1 \
|
||||||
|
qs -p "$config_path/phone-controls-harness.qml" kill >/dev/null 2>&1 || true
|
||||||
|
rm -rf "$state_home"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
qs_for_harness() {
|
||||||
|
XDG_STATE_HOME="$state_home" QS_DISABLE_CRASH_HANDLER=1 \
|
||||||
|
qs -p "$config_path/phone-controls-harness.qml" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
start_harness() {
|
||||||
|
cp -a "$project_root/config/dot/quickshell" "$config_path"
|
||||||
|
cp "$kde_fixture" "$config_path/services/KdeConnect.qml"
|
||||||
|
cp "$settings_fixture" "$config_path/services/SystemSettings.qml"
|
||||||
|
qs_for_harness --daemonize >/dev/null
|
||||||
|
for _ in $(seq 1 40); do
|
||||||
|
if qs_for_harness ipc show 2>/dev/null | rg -q '^target phone-controls-test$'; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
fail 'isolated Phone controls harness did not start'
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_fixture_state() {
|
||||||
|
local fixture="$1"
|
||||||
|
local expected="$2"
|
||||||
|
|
||||||
|
qs_for_harness ipc call phone-controls-test fixture "$fixture" >/dev/null
|
||||||
|
local actual
|
||||||
|
actual="$(qs_for_harness ipc call phone-controls-test status)"
|
||||||
|
[[ -n "$actual" ]] || fail 'Phone controls do not expose runtime action enablement'
|
||||||
|
jq -e --argjson expected "$expected" '
|
||||||
|
.bluebubblesAvailable == true and .appLaunches == 0 and .phoneActions == 0 and
|
||||||
|
(.actions | length == 4) and
|
||||||
|
([.actions[] | select(.id == "messages") | .enabled] == [true]) and
|
||||||
|
([.actions[] | select(.id != "messages") | .enabled] == $expected)
|
||||||
|
' <<<"$actual" >/dev/null \
|
||||||
|
|| fail "unexpected $fixture action state: $actual"
|
||||||
|
}
|
||||||
|
|
||||||
|
# These source checks do not evaluate the launch branch. The isolated runtime
|
||||||
|
# harness below uses fixture singletons and only reads action-model status.
|
||||||
|
|
||||||
|
rg -Fq '"bluebubbles": ["flatpak", "run", "app.bluebubbles.BlueBubbles"]' "$system_settings" \
|
||||||
|
|| fail 'BlueBubbles does not use the fixed Flatpak argument vector'
|
||||||
|
rg -Fq 'readonly property bool bluebubblesAvailable: root.bluebubblesDetected' "$system_settings" \
|
||||||
|
|| fail 'BlueBubbles availability is not exposed independently'
|
||||||
|
rg -Fq 'command: ["flatpak", "info", "app.bluebubbles.BlueBubbles"]' "$system_settings" \
|
||||||
|
|| fail 'BlueBubbles installed-state probe is missing'
|
||||||
|
|
||||||
|
for action in share clipboard ring messages; do
|
||||||
|
rg -Fq "id: \"$action\"" "$phone_actions" \
|
||||||
|
|| fail "Phone actions do not include $action"
|
||||||
|
done
|
||||||
|
|
||||||
|
if rg -Fq '.filter(' "$phone_actions"; then
|
||||||
|
fail 'Phone actions are filtered instead of keeping four stable columns'
|
||||||
|
fi
|
||||||
|
rg -Fq 'columns: 4' "$phone_controls" \
|
||||||
|
|| fail 'Phone actions do not use four equal columns'
|
||||||
|
rg -Fq 'root.actionModels.length - 1' "$phone_controls" \
|
||||||
|
|| fail 'Phone action widths are not calculated from the fixed model count'
|
||||||
|
rg -Fq 'SystemSettings.bluebubblesAvailable' "$phone_controls" \
|
||||||
|
|| fail 'Messages enablement does not read BlueBubbles availability'
|
||||||
|
rg -Fq 'KdeConnect.phoneReachable' "$phone_controls" \
|
||||||
|
|| fail 'KDE action reachability semantics are missing'
|
||||||
|
rg -Fq 'function actionEnabled(action: string): bool' "$phone_controls" \
|
||||||
|
|| fail 'Phone controls do not expose action enablement'
|
||||||
|
rg -Fq 'KdeConnect.supports(action)' "$phone_actions" \
|
||||||
|
|| fail 'KDE action capability semantics are missing'
|
||||||
|
rg -Fq 'else if (action === "messages")' "$phone_controls" \
|
||||||
|
|| fail 'Messages has no independent invocation branch'
|
||||||
|
rg -Fq 'SystemSettings.openApplication("bluebubbles")' "$phone_controls" \
|
||||||
|
|| fail 'Messages does not use the allow-listed BlueBubbles launcher'
|
||||||
|
rg -Fq 'BlueBubbles is not installed' "$phone_controls" \
|
||||||
|
|| fail 'Missing BlueBubbles has no quiet explanatory row'
|
||||||
|
rg -Fq 'KDE Connect actions are unavailable until the iPhone reconnects' "$phone_controls" \
|
||||||
|
|| fail 'Offline copy does not scope unavailability to KDE Connect actions'
|
||||||
|
rg -Fq 'border.width: actionMouse.activeFocus ? 2 : 0' "$phone_controls" \
|
||||||
|
|| fail 'Phone actions do not visibly indicate keyboard focus'
|
||||||
|
rg -Fq 'Accessible.role: Accessible.Button' "$phone_controls" \
|
||||||
|
|| fail 'Phone actions do not expose an Accessible button role'
|
||||||
|
rg -Fq 'Accessible.name: actionButton.modelData.label' "$phone_controls" \
|
||||||
|
|| fail 'Phone actions do not expose their accessible name'
|
||||||
|
rg -Fq 'Accessible.description: root.actionAccessibleDescription(actionButton.modelData.id)' "$phone_controls" \
|
||||||
|
|| fail 'Phone actions do not expose their availability state'
|
||||||
|
rg -Fq 'Accessible.focusable: actionMouse.enabled' "$phone_controls" \
|
||||||
|
|| fail 'Phone action accessibility does not respect disabled state'
|
||||||
|
rg -Fq 'Accessible.onPressAction:' "$phone_controls" \
|
||||||
|
|| fail 'Phone actions do not provide an accessible press handler'
|
||||||
|
|
||||||
|
# A static negative guard keeps the Messages branch from accidentally inheriting
|
||||||
|
# KDE Connect reachability, transfer, or plugin conditions.
|
||||||
|
messages_branch="$(sed -n '/else if (action === "messages")/,/^ }/p' "$phone_controls")"
|
||||||
|
printf '%s\n' "$messages_branch" | rg -Fq 'KdeConnect.' \
|
||||||
|
&& fail 'Messages is coupled to KDE Connect'
|
||||||
|
|
||||||
|
start_harness
|
||||||
|
assert_fixture_state offline '[false, false, false]'
|
||||||
|
assert_fixture_state unsupported '[true, false, false]'
|
||||||
|
assert_fixture_state transfer '[false, false, false]'
|
||||||
|
|
||||||
|
printf 'Phone Messages contract: PASS\n'
|
||||||
@@ -2,47 +2,135 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
state_home="$(mktemp -d /tmp/panama-settings-pages-state.XXXXXX)"
|
||||||
|
source_config_path="$repo_dir/config/dot/quickshell"
|
||||||
|
config_path="$state_home/quickshell"
|
||||||
|
test_bin="$state_home/bin"
|
||||||
|
shell_log="$state_home/quickshell.log"
|
||||||
|
|
||||||
|
cleanup_bootstrap() {
|
||||||
|
rm -rf "$state_home"
|
||||||
|
}
|
||||||
|
trap cleanup_bootstrap EXIT
|
||||||
|
|
||||||
|
mkdir -p "$test_bin"
|
||||||
|
cp -a "$source_config_path" "$config_path"
|
||||||
|
|
||||||
|
cat >"$config_path/scripts/panama-home-assistant" <<'EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
catalog)
|
||||||
|
printf '%s\n' '{"ok":false,"error":"test-helper"}'
|
||||||
|
;;
|
||||||
|
toggle|brightness)
|
||||||
|
printf '%s\n' '{"ok":true}'
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
EOF
|
||||||
|
chmod +x "$config_path/scripts/panama-home-assistant"
|
||||||
|
|
||||||
|
cat >"$test_bin/hyprctl" <<'EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "-j" && "${2:-}" == "monitors" ]]; then
|
||||||
|
printf '%s\n' '[{"focused":true,"name":"TEST-1","description":"Settings contract","width":1920,"height":1080,"refreshRate":60,"scale":1,"currentFormat":"XRGB8888","colorManagementPreset":"srgb","vrr":false}]'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ "${1:-}" == "keyword" ]]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
exec /usr/sbin/hyprctl "$@"
|
||||||
|
EOF
|
||||||
|
chmod +x "$test_bin/hyprctl"
|
||||||
|
|
||||||
|
cat >"$test_bin/flatpak" <<'EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ "${1:-}" == "info" ]]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
exit 97
|
||||||
|
EOF
|
||||||
|
chmod +x "$test_bin/flatpak"
|
||||||
|
|
||||||
fail() {
|
fail() {
|
||||||
printf 'settings pages contract: %s\n' "$1" >&2
|
printf 'settings pages contract: %s\n' "$1" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
qs_for_test() {
|
||||||
|
PATH="$test_bin:$PATH" QS_CONFIG_PATH="$config_path" XDG_STATE_HOME="$state_home" \
|
||||||
|
qs -p "$config_path" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_test_shell() {
|
||||||
|
qs_for_test kill >/dev/null 2>&1 || true
|
||||||
|
for _ in $(seq 1 80); do
|
||||||
|
if ! qs_for_test list 2>/dev/null | rg '^Instance ' >/dev/null \
|
||||||
|
&& ! qs_for_test ipc show >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
qs ipc call settings close >/dev/null 2>&1 || true
|
qs_for_test ipc call settings close >/dev/null 2>&1 || true
|
||||||
|
if stop_test_shell; then
|
||||||
|
rm -rf "$state_home"
|
||||||
|
else
|
||||||
|
printf 'settings pages contract: branch shell did not stop; retained %s\n' \
|
||||||
|
"$state_home" >&2
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
# Start from a clean slate. Closing the Settings window is asynchronous: the
|
start_test_shell() {
|
||||||
# shell reports it closed as soon as it drops its own state, while the toplevel
|
stop_test_shell || fail 'pre-existing branch shell did not stop cleanly'
|
||||||
# survives until the compositor destroys it. Without this wait, running straight
|
for _attempt in 1 2; do
|
||||||
# after settings-window-contract sees the outgoing window and reads it as a
|
qs_for_test --daemonize >"$shell_log" 2>&1
|
||||||
# duplicate.
|
for _ in $(seq 1 80); do
|
||||||
qs ipc call settings close >/dev/null 2>&1 || true
|
if qs_for_test ipc show 2>/dev/null | rg '^target settings$' >/dev/null; then
|
||||||
for _ in $(seq 1 40); do
|
return
|
||||||
hyprctl -j clients | jq -e '[.[] | select(.title == "Panama Settings")] | length == 0' >/dev/null && break
|
fi
|
||||||
sleep 0.1
|
|
||||||
done
|
|
||||||
hyprctl -j clients | jq -e '[.[] | select(.title == "Panama Settings")] | length == 0' >/dev/null \
|
|
||||||
|| fail 'a Settings window was still open when the contract started'
|
|
||||||
|
|
||||||
pages=(home appearance displays connectivity desktop sound notifications screen-intelligence shortcuts services about)
|
|
||||||
for page in "${pages[@]}"; do
|
|
||||||
qs ipc call settings page "$page" >/dev/null
|
|
||||||
for _ in $(seq 1 20); do
|
|
||||||
[[ "$(qs ipc call settings status | jq -r .page)" == "$page" ]] && break
|
|
||||||
sleep 0.1
|
sleep 0.1
|
||||||
done
|
done
|
||||||
[[ "$(qs ipc call settings status | jq -r .page)" == "$page" ]] || fail "$page did not route"
|
stop_test_shell || fail 'failed branch-shell attempt did not stop cleanly'
|
||||||
hyprctl -j clients | jq -e '[.[] | select(.title == "Panama Settings")] | length == 1' >/dev/null \
|
done
|
||||||
|| fail "$page created a missing or duplicate Settings window"
|
sed -n '1,200p' "$shell_log" >&2
|
||||||
|
fail 'isolated branch shell did not start'
|
||||||
|
}
|
||||||
|
|
||||||
|
start_test_shell
|
||||||
|
qs_for_test ipc call home-assistant fixture ready >/dev/null
|
||||||
|
shell_pid="$(qs_for_test list | awk '/Process ID:/ { print $3; exit }')"
|
||||||
|
[[ "$shell_pid" =~ ^[0-9]+$ ]] || fail 'could not identify the branch shell process'
|
||||||
|
|
||||||
|
pages=(home appearance displays connectivity home-phone desktop sound notifications screen-intelligence shortcuts services about)
|
||||||
|
for page in "${pages[@]}"; do
|
||||||
|
qs_for_test ipc call settings page "$page" >/dev/null
|
||||||
|
for _ in $(seq 1 20); do
|
||||||
|
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "$page" ]] && break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "$page" ]] || fail "$page did not route"
|
||||||
|
/usr/sbin/hyprctl -j clients | jq -e --argjson pid "$shell_pid" \
|
||||||
|
'[.[] | select(.pid == $pid and .title == "Panama Settings" and .floating == false)] | length == 1' >/dev/null \
|
||||||
|
|| fail "$page created a missing, floating, or duplicate Settings window"
|
||||||
done
|
done
|
||||||
|
|
||||||
qs ipc call settings page '__unsupported__' >/dev/null
|
qs_for_test ipc call settings page '__unsupported__' >/dev/null
|
||||||
[[ "$(qs ipc call settings status | jq -r .page)" == "home" ]] || fail 'unsupported page did not fall back to Home'
|
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "home" ]] || fail 'unsupported page did not fall back to Home'
|
||||||
|
|
||||||
hyprctl -j binds | jq -e '.[] | select(.description == "Panama Settings" and .key == "I" and .modmask == 64)' >/dev/null \
|
/usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Panama Settings" and .key == "I" and .modmask == 64)' >/dev/null \
|
||||||
|| fail 'Super+I is not registered as Panama Settings'
|
|| fail 'Super+I is not registered as Panama Settings'
|
||||||
hyprctl -j binds | jq -e '.[] | select(.description == "Screen Intelligence" and .key == "S" and .modmask == 65)' >/dev/null \
|
/usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Screen Intelligence" and .key == "S" and .modmask == 65)' >/dev/null \
|
||||||
|| fail 'Super+Shift+S is not registered as Screen Intelligence'
|
|| fail 'Super+Shift+S is not registered as Screen Intelligence'
|
||||||
|
|
||||||
desktop_file="$HOME/.local/share/applications/panama-settings.desktop"
|
desktop_file="$HOME/.local/share/applications/panama-settings.desktop"
|
||||||
@@ -57,4 +145,5 @@ desktop-file-validate "$intelligence_desktop_file" >/dev/null || fail 'Screen In
|
|||||||
|
|
||||||
trap - EXIT
|
trap - EXIT
|
||||||
cleanup
|
cleanup
|
||||||
|
[[ ! -e "$state_home" ]] || fail 'temporary Settings state was not removed after shell exit'
|
||||||
printf 'settings pages contract: PASS\n'
|
printf 'settings pages contract: PASS\n'
|
||||||
|
|||||||
Reference in New Issue
Block a user