Handle Wi-Fi and Bluetooth in Settings

Network & Devices was 92 lines and two buttons that opened GNOME. It now
scans, joins, and pairs directly through Quickshell.Networking and
Quickshell.Bluetooth -- NetworkManager and BlueZ over DBus, no shelling
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 the page being visible. Wi-Fi scanning and especially
Bluetooth discovery hold the radio, and running either for a list nobody
is looking at spends airtime on nothing.

Joining a secured network gets a real password field, not the clipboard
popover's search box with different placeholder 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.

Two bugs found by looking at the rendered page, both silent:

The device lookups used enum names that do not exist --
NetworkDeviceType.Wifi rather than DeviceType.Wifi -- so both returned
null and the page reported "No Wi-Fi adapter" on a machine whose Wi-Fi
was connected. Nothing was logged; QML resolves an unknown enum member
to undefined and compares happily.

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. The labels now use the same buckets as the icons in
quicksettings/WifiList.qml so the two cannot disagree.

The contract compares what the service resolves against what nmcli
reports, rather than only checking that nothing crashed.

Also makes the Home Assistant bridge hermetic: resolve_config read the
user's private env file even when a caller supplied an explicit
environment, so adding a real PANAMA_HOME_ASSISTANT_ENTITIES to that
file silently overrode a fixture asserting the legacy fallback. An
explicit environment is now the whole environment; production still
reads the file. Its live contract skips when no token is configured --
an absent credential is not a defect, and a suite expected to be red
stops being read -- while a configured-but-broken bridge still fails.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
Gabriel Brown
2026-08-18 05:50:07 -04:00
parent b9800bfbab
commit 3c521cf5fa
10 changed files with 750 additions and 44 deletions
@@ -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; }
}
}
@@ -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
}
}
@@ -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: "WiFi"
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: "WiFi"
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")
}
}
}
@@ -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
}
}
}
@@ -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
}
}
@@ -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
@@ -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()
@@ -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()
}
+99
View File
@@ -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'
@@ -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