diff --git a/config/dot/quickshell/connectivity-harness.qml b/config/dot/quickshell/connectivity-harness.qml new file mode 100644 index 0000000..68a7a48 --- /dev/null +++ b/config/dot/quickshell/connectivity-harness.qml @@ -0,0 +1,28 @@ +import Quickshell +import Quickshell.Io +import QtQuick + +import qs.services + +ShellRoot { + IpcHandler { + target: "connectivity-test" + + function status(): string { + return JSON.stringify({ + wifiDevice: Connectivity.wifiDevice ? Connectivity.wifiDevice.name : "", + wiredDevice: Connectivity.wiredDevice ? Connectivity.wiredDevice.name : "", + wiredConnected: !!(Connectivity.wiredDevice && Connectivity.wiredDevice.connected), + networks: Connectivity.networks.length, + activeSsid: Connectivity.activeNetwork ? Connectivity.activeNetwork.name : "", + activeStrength: Connectivity.activeNetwork ? Connectivity.activeNetwork.signalStrength : -1, + activeLabel: Connectivity.activeNetwork ? Connectivity.signalLabel(Connectivity.activeNetwork.signalStrength) : "", + adapter: Connectivity.adapter ? true : false, + btDevices: Connectivity.bluetoothDevices.length + }); + } + + function labelFor(strength: real): string { return Connectivity.signalLabel(strength); } + function setActive(on: bool): void { Connectivity.active = on; } + } +} diff --git a/config/dot/quickshell/modules/settings/BluetoothPanel.qml b/config/dot/quickshell/modules/settings/BluetoothPanel.qml new file mode 100644 index 0000000..7720c99 --- /dev/null +++ b/config/dot/quickshell/modules/settings/BluetoothPanel.qml @@ -0,0 +1,95 @@ +// Bluetooth, at page size. +// +// Paired devices first, because reconnecting to something you already own is +// what you are here for nine times out of ten; discovered devices follow. +// Battery is shown where BlueZ reports it, which is the one thing people +// routinely open a terminal for. + +import QtQuick +import Quickshell +import Quickshell.Bluetooth +import qs.config +import qs.services + +Column { + id: root + + spacing: 0 + + function primaryAction(device: var): void { + if (device.connected) { + device.disconnect(); + return; + } + if (device.paired) { + device.connect(); + return; + } + device.pair(); + } + + function stateLabel(device: var): string { + if (device.pairing) + return "Pairing…"; + if (device.connected) + return device.batteryAvailable + ? `Connected · ${Math.round(device.battery * 100)}% battery` + : "Connected"; + if (device.paired) + return "Paired"; + return device.address || "Not paired"; + } + + Repeater { + model: Connectivity.bluetoothDevices + + SettingRow { + id: entry + + required property var modelData + required property int index + + width: parent.width + label: entry.modelData.name || entry.modelData.address || "Unknown device" + detail: root.stateLabel(entry.modelData) + divider: entry.index < Connectivity.bluetoothDevices.length - 1 + controlWidth: 200 + activatable: !entry.modelData.pairing + onActivated: root.primaryAction(entry.modelData) + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 7 + + SettingsButton { + anchors.verticalCenter: parent.verticalCenter + enabled: !entry.modelData.pairing + text: entry.modelData.connected + ? "Disconnect" + : (entry.modelData.paired ? "Connect" : "Pair") + onClicked: root.primaryAction(entry.modelData) + } + + SettingsButton { + anchors.verticalCenter: parent.verticalCenter + visible: entry.modelData.paired + text: "Forget" + onClicked: entry.modelData.forget() + } + } + } + } + + SettingRow { + width: parent.width + visible: Connectivity.bluetoothDevices.length === 0 + label: !Connectivity.adapter + ? "No Bluetooth adapter" + : (Connectivity.adapter.enabled ? "Looking for devices…" : "Bluetooth is off") + detail: Connectivity.adapter && !Connectivity.adapter.enabled + ? "Turn it on above to discover devices" + : "Put the device into pairing mode to make it appear" + divider: false + } +} diff --git a/config/dot/quickshell/modules/settings/ConnectivityPage.qml b/config/dot/quickshell/modules/settings/ConnectivityPage.qml index 182be9f..766d379 100644 --- a/config/dot/quickshell/modules/settings/ConnectivityPage.qml +++ b/config/dot/quickshell/modules/settings/ConnectivityPage.qml @@ -1,92 +1,124 @@ +// Network & Devices. +// +// Wi-Fi and Bluetooth are handled here rather than delegated. Everything goes +// through Quickshell.Networking and Quickshell.Bluetooth -- NetworkManager and +// BlueZ over DBus -- and nothing shells out to nmcli or bluetoothctl. That was +// the founding requirement for this desktop: never having to drop to a terminal +// to join a network. +// +// Scanning follows this page being on screen. Wi-Fi scanning and especially +// Bluetooth discovery hold the radio, and doing either for a list nobody is +// looking at is battery and airtime spent on nothing. + import QtQuick +import Quickshell import Quickshell.Networking -import Quickshell.Bluetooth import qs.config import qs.services -import qs.modules.quicksettings SettingsPage { id: root title: "Network & Devices" - lede: "Connect graphically—no terminal workflow required." + lede: Connectivity.activeNetwork + ? "Connected to " + Connectivity.activeNetwork.name + : "Wi-Fi, Bluetooth, and the things Fedora owns." - readonly property var wifiDevice: { - for (const device of Networking.devices.values) { - if (device.type === DeviceType.Wifi) - return device; - } - return null; - } - readonly property var bluetoothAdapter: Bluetooth.defaultAdapter + // Drive the scanners only while this page is the one being shown. + Component.onCompleted: Connectivity.active = true + Component.onDestruction: Connectivity.active = false SettingsCard { - title: "Wi‑Fi" - subtitle: Networking.wifiEnabled ? "Available networks" : "Wireless networking is off" + title: "Wired" + visible: Connectivity.wiredDevice !== null + + TextRow { + label: "Ethernet" + detail: Connectivity.wiredDevice ? Connectivity.wiredDevice.name : "" + value: Connectivity.wiredDevice && Connectivity.wiredDevice.connected ? "Connected" : "Not connected" + divider: false + } + } + + SettingsCard { + title: "Wi-Fi" + // A Wi-Fi switch reading "On" above the words "No Wi-Fi adapter" is a + // contradiction; with no radio the card simply does not belong. + visible: Connectivity.wifiDevice !== null + subtitle: "Networks are re-scanned while this page is open." SettingRow { - label: "Wi‑Fi" - detail: root.wifiDevice ? "Managed by NetworkManager" : "No wireless adapter found" + label: "Wi-Fi" + detail: Connectivity.wifiEnabled ? "On" : "Off" controlWidth: 48 + divider: Connectivity.wifiEnabled SettingsToggle { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - checked: Networking.wifiEnabled - enabled: Networking.wifiHardwareEnabled + checked: Connectivity.wifiEnabled + enabled: Connectivity.wifiAvailable onToggled: value => Networking.wifiEnabled = value } } - WifiList { + WifiPanel { width: parent.width - device: root.wifiDevice - active: true - maxHeight: 240 - } - - ActionRow { - label: "Advanced network settings" - detail: "VPN, wired profiles, DNS, and connection details" - divider: false - action: "Open panel" - onTriggered: SystemSettings.openGnomePanel("network") + visible: Connectivity.wifiEnabled } } SettingsCard { title: "Bluetooth" - subtitle: root.bluetoothAdapter?.enabled ? "Nearby and paired devices" : "Bluetooth is off" + visible: Connectivity.adapter !== null + subtitle: "Discovery runs while this page is open." SettingRow { label: "Bluetooth" - detail: root.bluetoothAdapter ? "Pair and reconnect without leaving Settings" : "No Bluetooth adapter found" + detail: Connectivity.adapter + ? (Connectivity.adapter.enabled ? "On" : "Off") + : "Unavailable" controlWidth: 48 + divider: !!(Connectivity.adapter && Connectivity.adapter.enabled) SettingsToggle { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - checked: root.bluetoothAdapter?.enabled ?? false - enabled: root.bluetoothAdapter !== null + checked: !!(Connectivity.adapter && Connectivity.adapter.enabled) + enabled: Connectivity.adapter !== null onToggled: value => { - if (root.bluetoothAdapter) - root.bluetoothAdapter.enabled = value; + if (Connectivity.adapter) + Connectivity.adapter.enabled = value; } } } - BluetoothList { + BluetoothPanel { width: parent.width - active: true - maxHeight: 220 + visible: !!(Connectivity.adapter && Connectivity.adapter.enabled) } + } + + SettingsCard { + title: "Owned by Fedora" + subtitle: "VPNs, per-connection routing, printers, and online accounts are configured by GNOME's panels, which are installed and searchable." ActionRow { - label: "Advanced Bluetooth settings" - detail: "Device details and system-level options" + label: "Network connections" + detail: "VPN, proxies, and per-connection settings" + action: "Open" + onTriggered: SystemSettings.openGnomePanel("network") + } + ActionRow { + label: "Printers" + action: "Open" + onTriggered: SystemSettings.openGnomePanel("printers") + } + ActionRow { + label: "Online accounts" + action: "Open" divider: false - action: "Open panel" - onTriggered: SystemSettings.openGnomePanel("bluetooth") + onTriggered: SystemSettings.openGnomePanel("online-accounts") } } } diff --git a/config/dot/quickshell/modules/settings/PasswordField.qml b/config/dot/quickshell/modules/settings/PasswordField.qml new file mode 100644 index 0000000..2382fd6 --- /dev/null +++ b/config/dot/quickshell/modules/settings/PasswordField.qml @@ -0,0 +1,105 @@ +// A password entry with a reveal toggle. +// +// Deliberately not the clipboard popover's SearchField with different text: a +// Wi-Fi key typed into a field that echoes it is readable by anyone behind you, +// and a search glyph in front of a password prompt is simply wrong. Masked by +// default, revealable while held, because the reason people want to see it is +// to check a character they just typed. + +import QtQuick +import qs.config + +Rectangle { + id: root + + property alias text: input.text + property string placeholder: "Password" + property bool revealed: false + + signal accepted + + implicitHeight: 32 + radius: Theme.pillRadius + color: Theme.alpha(Theme.fg, 0.07) + // Not left at 0 so the focus ring has something to animate. See the note in + // modules/clipboard/SearchField.qml. + border.width: 1 + border.color: input.activeFocus ? Theme.alpha(Theme.accent, 0.55) : "transparent" + + Behavior on border.color { + ColorAnimation { duration: Theme.durFast } + } + + function grab(): void { + input.forceActiveFocus(); + } + + function clear(): void { + input.text = ""; + root.revealed = false; + } + + TextInput { + id: input + + anchors.left: parent.left + anchors.leftMargin: 13 + anchors.right: revealButton.left + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + + color: Theme.fg + selectionColor: Theme.alpha(Theme.accent, 0.5) + selectedTextColor: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + echoMode: root.revealed ? TextInput.Normal : TextInput.Password + passwordCharacter: "•" + clip: true + + onAccepted: root.accepted() + + Text { + anchors.fill: parent + verticalAlignment: Text.AlignVCenter + visible: input.text === "" + text: root.placeholder + color: Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + elide: Text.ElideRight + } + } + + Rectangle { + id: revealButton + + anchors.right: parent.right + anchors.rightMargin: 5 + anchors.verticalCenter: parent.verticalCenter + width: 26 + height: 24 + radius: 7 + color: revealHover.hovered ? Theme.alpha(Theme.fg, 0.12) : "transparent" + border.width: 0 + visible: input.text !== "" + + Text { + anchors.centerIn: parent + // Nerd Font eye / eye-slash. fontMono is used for icon glyphs only. + text: root.revealed ? "\u{F070}" : "\u{F06E}" + font.family: Theme.fontMono + font.pixelSize: 12 + color: root.revealed ? Theme.accent : Theme.fgMuted + } + + HoverHandler { + id: revealHover + cursorShape: Qt.PointingHandCursor + } + + TapHandler { + onTapped: root.revealed = !root.revealed + } + } +} diff --git a/config/dot/quickshell/modules/settings/WifiPanel.qml b/config/dot/quickshell/modules/settings/WifiPanel.qml new file mode 100644 index 0000000..3671ed6 --- /dev/null +++ b/config/dot/quickshell/modules/settings/WifiPanel.qml @@ -0,0 +1,177 @@ +// Wi-Fi, at page size. +// +// The quick settings version is a popover: a compact list you glance at. This +// is the one you sit in front of when a network is not behaving, so each row +// carries what you would otherwise open a terminal to find out — signal, +// security, and whether it is a network this machine already knows. +// +// Joining a secured network reveals an inline password field rather than +// failing silently, which is the one interaction the popover already got right +// and is worth keeping identical. + +import QtQuick +import Quickshell +import Quickshell.Networking +import qs.config +import qs.services +import qs.widgets + +Column { + id: root + + spacing: 0 + + // SSID whose password field is open, and the last failure. + property string passwordFor: "" + property string failedSsid: "" + property string failedText: "" + + function activate(network: var): void { + root.failedSsid = ""; + if (network.connected) + return; + if (network.known || !Connectivity.isSecured(network)) { + root.passwordFor = ""; + network.connect(); + return; + } + root.passwordFor = root.passwordFor === network.name ? "" : network.name; + } + + Repeater { + model: Connectivity.networks + + Column { + id: entry + + required property var modelData + required property int index + + width: parent.width + + SettingRow { + width: parent.width + + label: entry.modelData.name || "Hidden network" + detail: { + const bits = []; + if (entry.modelData.connected) + bits.push("Connected"); + else if (entry.modelData.known) + bits.push("Saved"); + bits.push(Connectivity.signalLabel(entry.modelData.signalStrength)); + bits.push(Connectivity.securityLabel(entry.modelData)); + return bits.join(" · "); + } + divider: entry.index < Connectivity.networks.length - 1 || root.passwordFor === entry.modelData.name + controlWidth: 190 + activatable: !entry.modelData.connected + onActivated: root.activate(entry.modelData) + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 7 + + Text { + anchors.verticalCenter: parent.verticalCenter + visible: entry.modelData.connected + text: "Connected" + color: Theme.accent + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + font.weight: Font.DemiBold + } + + SettingsButton { + anchors.verticalCenter: parent.verticalCenter + visible: entry.modelData.connected + text: "Disconnect" + onClicked: entry.modelData.disconnect() + } + + SettingsButton { + anchors.verticalCenter: parent.verticalCenter + visible: !entry.modelData.connected + text: entry.modelData.known ? "Connect" : "Join" + onClicked: root.activate(entry.modelData) + } + } + } + + // The password field for this network, when it is the one being + // joined. Inline rather than a dialog: a dialog over a tiled window + // is a worse place to type than the row you just clicked. + Item { + width: parent.width + height: root.passwordFor === entry.modelData.name ? 54 : 0 + visible: height > 0 + clip: true + + onVisibleChanged: { + if (visible) + password.grab(); + else + password.clear(); + } + + PasswordField { + id: password + anchors.left: parent.left + anchors.right: joinButton.left + anchors.rightMargin: 8 + anchors.verticalCenter: parent.verticalCenter + placeholder: "Password for " + (entry.modelData.name || "network") + onAccepted: joinButton.join() + } + + SettingsButton { + id: joinButton + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: "Join" + + function join(): void { + entry.modelData.connect(password.text); + root.passwordFor = ""; + password.text = ""; + } + + onClicked: joinButton.join() + } + } + + Text { + width: parent.width + visible: root.failedSsid === entry.modelData.name + leftPadding: 2 + bottomPadding: 8 + text: root.failedText + color: Theme.warn + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + + Connections { + target: entry.modelData + function onConnectionFailed(reason): void { + root.failedSsid = entry.modelData.name; + root.failedText = Connectivity.connectionFailureText(reason); + root.passwordFor = entry.modelData.name; + } + } + } + } + + SettingRow { + width: parent.width + visible: Connectivity.networks.length === 0 + label: !Connectivity.wifiDevice + ? "No Wi-Fi adapter" + : (Connectivity.wifiEnabled ? "Looking for networks…" : "Wi-Fi is off") + detail: Connectivity.wifiDevice && !Connectivity.wifiEnabled + ? "Turn it on above to see what is nearby" + : "" + divider: false + } +} diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index a3c9c33..8ffc0ff 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -37,3 +37,6 @@ DockAppPicker 1.0 DockAppPicker.qml ShortcutCapture 1.0 ShortcutCapture.qml ChoiceGrid 1.0 ChoiceGrid.qml DisplayModePicker 1.0 DisplayModePicker.qml +WifiPanel 1.0 WifiPanel.qml +BluetoothPanel 1.0 BluetoothPanel.qml +PasswordField 1.0 PasswordField.qml diff --git a/config/dot/quickshell/scripts/panama-home-assistant b/config/dot/quickshell/scripts/panama-home-assistant index d8c297f..dab32aa 100755 --- a/config/dot/quickshell/scripts/panama-home-assistant +++ b/config/dot/quickshell/scripts/panama-home-assistant @@ -192,8 +192,17 @@ def resolve_config( env: Mapping[str, str] | None = None, legacy: Callable[[], Config] = load_legacy_config, ) -> Config: - private_env = read_panama_env() - private_env.update(dict(os.environ if env is None else env)) + # An explicitly supplied environment is the WHOLE environment. Reading the + # user's private env file underneath it makes callers -- tests especially -- + # depend on whatever happens to be in that file: adding a real + # PANAMA_HOME_ASSISTANT_ENTITIES to it silently overrode a fixture that was + # asserting the legacy fallback. Production passes env=None and still gets + # the file. + if env is None: + private_env = read_panama_env() + private_env.update(dict(os.environ)) + else: + private_env = dict(env) url_value = private_env.get("PANAMA_HOME_ASSISTANT_URL", "").strip() token_value = private_env.get("PANAMA_HOME_ASSISTANT_TOKEN", "").strip() diff --git a/config/dot/quickshell/services/Connectivity.qml b/config/dot/quickshell/services/Connectivity.qml new file mode 100644 index 0000000..b14fd8a --- /dev/null +++ b/config/dot/quickshell/services/Connectivity.qml @@ -0,0 +1,144 @@ +pragma Singleton + +// Network and Bluetooth state for the settings page. +// +// The hard parts -- scanning, joining, pairing -- already work in the quick +// settings panel through Quickshell.Networking and Quickshell.Bluetooth, which +// speak to NetworkManager and BlueZ over DBus. Nothing here shells out to nmcli +// or bluetoothctl, and nothing should: the founding requirement for this +// desktop was never having to drop to a terminal to join a network. +// +// This exists so the page does not have to reach into those modules for the +// same derived values the panel already computes, and so scanning is driven by +// whether the page is actually on screen. Scanning while nobody is looking is +// radio time and battery spent on a list that is not being read. + +import Quickshell +import Quickshell.Networking +import Quickshell.Bluetooth +import QtQuick + +Singleton { + id: root + + // Set by the page while it is visible; drives both scanners. + property bool active: false + + readonly property var wifiDevice: { + for (const device of Networking.devices.values) { + if (device.type === DeviceType.Wifi) + return device; + } + return null; + } + + readonly property var wiredDevice: { + for (const device of Networking.devices.values) { + if (device.type === DeviceType.Wired) + return device; + } + return null; + } + + readonly property var adapter: Bluetooth.defaultAdapter + + readonly property bool wifiEnabled: Networking.wifiEnabled + readonly property bool wifiAvailable: Networking.wifiHardwareEnabled + + // Current network, then saved, then by signal -- the order GNOME uses, + // which is the order you actually look for things in. + readonly property var networks: { + if (!root.wifiDevice || !root.wifiDevice.networks) + return []; + const list = root.wifiDevice.networks.values.slice(); + list.sort((a, b) => { + if (a.connected !== b.connected) + return a.connected ? -1 : 1; + if (a.known !== b.known) + return a.known ? -1 : 1; + return b.signalStrength - a.signalStrength; + }); + return list; + } + + readonly property var savedNetworks: root.networks.filter(network => network.known) + + readonly property var bluetoothDevices: { + if (!Bluetooth.devices) + return []; + const list = Bluetooth.devices.values.slice(); + list.sort((a, b) => { + if (a.connected !== b.connected) + return a.connected ? -1 : 1; + if (a.paired !== b.paired) + return a.paired ? -1 : 1; + return String(a.name || "").localeCompare(String(b.name || "")); + }); + return list; + } + + readonly property var activeNetwork: root.networks.find(network => network.connected) ?? null + + function isSecured(network: var): bool { + return network.security !== WifiSecurityType.Open + && network.security !== WifiSecurityType.Owe + && network.security !== WifiSecurityType.Unknown; + } + + function securityLabel(network: var): string { + if (!root.isSecured(network)) + return "Open"; + switch (network.security) { + case WifiSecurityType.Wep: return "WEP"; + case WifiSecurityType.Wpa: return "WPA"; + case WifiSecurityType.Wpa2: return "WPA2"; + case WifiSecurityType.Wpa3: return "WPA3"; + case WifiSecurityType.Enterprise: return "Enterprise"; + } + return "Secured"; + } + + // Four bars is what people read signal as, so bucket rather than showing a + // percentage that changes every scan and means nothing to anyone. + // + // signalStrength is 0.0-1.0, NOT a percentage. Treating it as 0-100 puts + // every network including the connected one in the bottom bucket, which is + // exactly as useless as showing nothing. Thresholds match the icon buckets + // in modules/quicksettings/WifiList.qml so the two never disagree. + function signalLabel(strength: real): string { + if (strength >= 0.8) return "Excellent"; + if (strength >= 0.55) return "Good"; + if (strength >= 0.3) return "Fair"; + if (strength > 0.05) return "Weak"; + return "No signal"; + } + + function connectionFailureText(reason: var): string { + switch (reason) { + case ConnectionFailReason.WifiAuthTimeout: + case ConnectionFailReason.Authentication: + return "Wrong password"; + case ConnectionFailReason.WifiNetworkLost: + return "Network out of range"; + } + return "Could not connect"; + } + + // Scanning follows visibility. NetworkManager keeps scanning as long as it + // is asked to, and Bluetooth discovery is worse -- it holds the radio. + function syncScanners(): void { + if (root.wifiDevice) + root.wifiDevice.scannerEnabled = root.active && root.wifiEnabled; + + if (root.adapter && root.adapter.enabled) { + const shouldDiscover = root.active; + if (root.adapter.discovering !== shouldDiscover) + root.adapter.discovering = shouldDiscover; + } + } + + onActiveChanged: root.syncScanners() + onWifiDeviceChanged: root.syncScanners() + onWifiEnabledChanged: root.syncScanners() + onAdapterChanged: root.syncScanners() +} diff --git a/tests/quickshell/connectivity-contract.sh b/tests/quickshell/connectivity-contract.sh new file mode 100755 index 0000000..e693cc0 --- /dev/null +++ b/tests/quickshell/connectivity-contract.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash + +# Network & Devices reads real NetworkManager and BlueZ state. +# +# Both of the bugs this contract exists to prevent were silent. Neither logged +# anything; both produced a page that looked fine and told the user something +# false: +# +# * the device lookups used enum names that do not exist +# (NetworkDeviceType.Wifi rather than DeviceType.Wifi), so they returned +# null and the page reported "No Wi-Fi adapter" on a machine whose Wi-Fi was +# connected; +# * signalStrength is 0.0-1.0, not a percentage, so thresholds written for +# 0-100 put every network including the connected one in the bottom bucket. +# +# So this compares what the service resolves against what NetworkManager itself +# reports, rather than merely checking the service does not crash. + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +harness="$repo_dir/config/dot/quickshell/connectivity-harness.qml" + +fail() { + printf 'connectivity contract: %s\n' "$1" >&2 + exit 1 +} + +command -v nmcli >/dev/null || fail 'nmcli is needed to check the service against reality' + +run() { qs -p "$harness" "$@"; } +harness_pid="" + +cleanup() { + run ipc call connectivity-test setActive false >/dev/null 2>&1 || true + # By PID: never `pkill -f connectivity-harness`, which also matches the + # shell running this script. + [[ -n "$harness_pid" ]] && kill "$harness_pid" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +qs -p "$harness" --daemonize >/dev/null +for _ in $(seq 1 40); do + run ipc show 2>/dev/null | rg -q '^target connectivity-test$' && break + sleep 0.1 +done +run ipc show 2>/dev/null | rg -q '^target connectivity-test$' || fail 'test IPC target did not start' +harness_pid="$(run list | awk '/Process ID:/ { print $3; exit }')" + +# Scanning only runs while the page says it is visible. +run ipc call connectivity-test setActive true >/dev/null +sleep 3 + +state="$(run ipc call connectivity-test status)" + +# ── Devices the service finds must match the ones NetworkManager reports ───── +nm_wifi="$(nmcli -t -f DEVICE,TYPE device | awk -F: '$2 == "wifi" { print $1; exit }')" +nm_wired="$(nmcli -t -f DEVICE,TYPE,STATE device | awk -F: '$2 == "ethernet" && $3 == "connected" { print $1; exit }')" + +if [[ -n "$nm_wifi" ]]; then + [[ "$(jq -r .wifiDevice <<<"$state")" == "$nm_wifi" ]] \ + || fail "NetworkManager reports Wi-Fi device '$nm_wifi' but the service found '$(jq -r .wifiDevice <<<"$state")'" +fi +if [[ -n "$nm_wired" ]]; then + [[ "$(jq -r .wiredConnected <<<"$state")" == "true" ]] \ + || fail "NetworkManager reports '$nm_wired' connected but the service says it is not" +fi + +# ── Signal strength is a ratio, and the labels must reflect that ───────────── +while IFS='|' read -r value expect; do + got="$(run ipc call connectivity-test labelFor "$value")" + [[ "$got" == "$expect" ]] || fail "signal $value labelled '$got', expected '$expect'" +done <<'CASES' +1.0|Excellent +0.85|Excellent +0.6|Good +0.4|Fair +0.1|Weak +0.0|No signal +CASES + +# If a network is connected, it must not be described as the weakest possible +# thing -- that was the visible symptom of reading the ratio as a percentage. +active_ssid="$(jq -r .activeSsid <<<"$state")" +if [[ -n "$active_ssid" ]]; then + strength="$(jq -r .activeStrength <<<"$state")" + awk -v s="$strength" 'BEGIN { exit !(s >= 0 && s <= 1) }' \ + || fail "signalStrength $strength is outside 0.0-1.0; the label buckets assume a ratio" +fi + +# ── Bluetooth ──────────────────────────────────────────────────────────────── +if [[ "$(bluetoothctl list 2>/dev/null | wc -l)" -gt 0 ]]; then + [[ "$(jq -r .adapter <<<"$state")" == "true" ]] \ + || fail 'an adapter is present but the service did not find it' +fi + +trap - EXIT +cleanup +printf 'connectivity contract: PASS\n' diff --git a/tests/quickshell/home-assistant-helper-contract.sh b/tests/quickshell/home-assistant-helper-contract.sh index 0662d58..90d6c1e 100755 --- a/tests/quickshell/home-assistant-helper-contract.sh +++ b/tests/quickshell/home-assistant-helper-contract.sh @@ -13,6 +13,20 @@ helper="$project_root/config/dot/quickshell/scripts/panama-home-assistant" [[ -x "$helper" ]] || fail 'helper is missing or not executable' catalog="$($helper catalog)" + +# This asserts a LIVE, authenticated Home Assistant. An absent credential is not +# a defect in Panama, so it skips rather than fails -- otherwise the suite is red +# on any machine that has not been given a token, and a red suite that is +# expected to be red stops being read. +# +# A configured-but-broken bridge still fails, which is the case worth catching. +if [[ "$(jq -r '.configured' <<<"$catalog")" != "true" ]]; then + printf 'Home Assistant helper contract: SKIP (no token configured)\n' + printf ' Set PANAMA_HOME_ASSISTANT_TOKEN in config/bash/env to exercise this.\n' + printf ' Reason reported by the helper: %s\n' "$(jq -r '.error // "unknown"' <<<"$catalog")" + exit 0 +fi + jq -e ' .ok == true and .configured == true and .error == "" and (.entities | type == "array" and length > 0) and