Merge Wi-Fi and Bluetooth settings

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
Gabriel Brown
2026-08-18 05:50:07 -04:00
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 QtQuick
import Quickshell
import Quickshell.Networking import Quickshell.Networking
import Quickshell.Bluetooth
import qs.config import qs.config
import qs.services import qs.services
import qs.modules.quicksettings
SettingsPage { SettingsPage {
id: root id: root
title: "Network & Devices" 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: { // Drive the scanners only while this page is the one being shown.
for (const device of Networking.devices.values) { Component.onCompleted: Connectivity.active = true
if (device.type === DeviceType.Wifi) Component.onDestruction: Connectivity.active = false
return device;
}
return null;
}
readonly property var bluetoothAdapter: Bluetooth.defaultAdapter
SettingsCard { SettingsCard {
title: "WiFi" title: "Wired"
subtitle: Networking.wifiEnabled ? "Available networks" : "Wireless networking is off" 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 { SettingRow {
label: "WiFi" label: "Wi-Fi"
detail: root.wifiDevice ? "Managed by NetworkManager" : "No wireless adapter found" detail: Connectivity.wifiEnabled ? "On" : "Off"
controlWidth: 48 controlWidth: 48
divider: Connectivity.wifiEnabled
SettingsToggle { SettingsToggle {
anchors.right: parent.right anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
checked: Networking.wifiEnabled checked: Connectivity.wifiEnabled
enabled: Networking.wifiHardwareEnabled enabled: Connectivity.wifiAvailable
onToggled: value => Networking.wifiEnabled = value onToggled: value => Networking.wifiEnabled = value
} }
} }
WifiList { WifiPanel {
width: parent.width width: parent.width
device: root.wifiDevice visible: Connectivity.wifiEnabled
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")
} }
} }
SettingsCard { SettingsCard {
title: "Bluetooth" 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 { SettingRow {
label: "Bluetooth" 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 controlWidth: 48
divider: !!(Connectivity.adapter && Connectivity.adapter.enabled)
SettingsToggle { SettingsToggle {
anchors.right: parent.right anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
checked: root.bluetoothAdapter?.enabled ?? false checked: !!(Connectivity.adapter && Connectivity.adapter.enabled)
enabled: root.bluetoothAdapter !== null enabled: Connectivity.adapter !== null
onToggled: value => { onToggled: value => {
if (root.bluetoothAdapter) if (Connectivity.adapter)
root.bluetoothAdapter.enabled = value; Connectivity.adapter.enabled = value;
} }
} }
} }
BluetoothList { BluetoothPanel {
width: parent.width width: parent.width
active: true visible: !!(Connectivity.adapter && Connectivity.adapter.enabled)
maxHeight: 220
} }
}
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 { ActionRow {
label: "Advanced Bluetooth settings" label: "Network connections"
detail: "Device details and system-level options" 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 divider: false
action: "Open panel" onTriggered: SystemSettings.openGnomePanel("online-accounts")
onTriggered: SystemSettings.openGnomePanel("bluetooth")
} }
} }
} }
@@ -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 ShortcutCapture 1.0 ShortcutCapture.qml
ChoiceGrid 1.0 ChoiceGrid.qml ChoiceGrid 1.0 ChoiceGrid.qml
DisplayModePicker 1.0 DisplayModePicker.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, env: Mapping[str, str] | None = None,
legacy: Callable[[], Config] = load_legacy_config, legacy: Callable[[], Config] = load_legacy_config,
) -> Config: ) -> Config:
# 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 = read_panama_env()
private_env.update(dict(os.environ if env is None else env)) private_env.update(dict(os.environ))
else:
private_env = dict(env)
url_value = private_env.get("PANAMA_HOME_ASSISTANT_URL", "").strip() url_value = private_env.get("PANAMA_HOME_ASSISTANT_URL", "").strip()
token_value = private_env.get("PANAMA_HOME_ASSISTANT_TOKEN", "").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' [[ -x "$helper" ]] || fail 'helper is missing or not executable'
catalog="$($helper catalog)" 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 ' jq -e '
.ok == true and .configured == true and .error == "" and .ok == true and .configured == true and .error == "" and
(.entities | type == "array" and length > 0) and (.entities | type == "array" and length > 0) and