#!/bin/bash set -euo pipefail source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" grep -q '^ConditionPathIsDirectory=/sys/class/bluetooth$' "$ROOT/default/systemd/user/bt-agent.service" || \ fail "bt-agent is skipped on machines without Bluetooth hardware" pass "bt-agent is skipped on machines without Bluetooth hardware" run_node_test <<'JS' const fs = require('fs') const bluetooth = requireFromRoot('shell/plugins/panels/bluetooth/Model.js') const panelSource = fs.readFileSync(root + '/shell/plugins/panels/bluetooth/Panel.qml', 'utf8') assert(/IpcHandler[\s\S]*?function toggleBluetooth\(\) \{ root\.toggleBluetooth\(\) \}/.test(panelSource), 'bluetooth exposes the radio toggle over IPC') assert(/manageIpc: false/.test(panelSource), 'bluetooth owns its IPC handler so it can extend the target methods') // Writing adapter.enabled sets BlueZ Powered, which does not survive a reboot. assert(/function toggleBluetooth\(\)[\s\S]*?execDetached\(\["omarchy-bluetooth-power", adapter\.enabled \? "off" : "on"\]\)/.test(panelSource), 'bluetooth toggles the radio through the rfkill soft block') assert(!/adapter\.enabled = /.test(panelSource), 'bluetooth never writes the adapter power state directly') assert(bluetooth.isUuidLike('0000110b-0000-1000-8000-00805f9b34fb'), 'bluetooth detects UUID-like names') assert(bluetooth.isAddressLike('AA:BB:CC:DD:EE:FF'), 'bluetooth detects address-like names') assertEqual(bluetooth.normalizedAddress('AA:BB_CC-dd-ee-ff'), 'aabbccddeeff', 'bluetooth normalizes BlueZ and PipeWire address formats') assert(!bluetooth.hasHumanName({ name: 'AA:BB:CC:DD:EE:FF' }), 'bluetooth rejects address-only device labels') assert(bluetooth.hasHumanName({ deviceName: 'MX Master 3S' }), 'bluetooth accepts human device labels') const devices = [ { name: 'Speaker', connected: false, paired: true, address: '2' }, { name: 'Headphones', connected: true, address: '1' }, { name: 'Keyboard', connected: false, address: '3' }, { name: 'AA:BB:CC:DD:EE:FF', connected: true, address: '4' }, { name: 'Mouse', connected: false, trusted: true, address: '5' } ] const arrayLikeDevices = { 0: devices[0], 1: devices[1], length: 2 } assertDeepEqual( bluetooth.toArray(arrayLikeDevices).map(bluetooth.deviceLabel), ['Speaker', 'Headphones'], 'bluetooth converts Quickshell QObjectList-style values into arrays' ) const lists = bluetooth.deviceLists(devices) assertDeepEqual(lists.connected.map(bluetooth.deviceLabel), ['Headphones'], 'bluetooth groups connected devices') assertDeepEqual(lists.known.map(bluetooth.deviceLabel), ['Mouse', 'Speaker'], 'bluetooth groups known devices by label') assertDeepEqual(lists.discovered.map(bluetooth.deviceLabel), ['Keyboard'], 'bluetooth groups discovered devices') assertDeepEqual(bluetooth.visibleSections(lists, true), ['connected', 'known', 'discovered'], 'bluetooth shows discovered section while scanning') assertDeepEqual(bluetooth.visibleSections(lists, false), ['connected', 'known'], 'bluetooth hides discovered section when not scanning') const arrayLikeLists = bluetooth.deviceLists({ 0: { name: 'Earbuds', connected: true, address: '6' }, 1: { name: 'Trackpad', paired: true, address: '7' }, 2: { name: 'Gamepad', address: '8' }, length: 3 }) assertDeepEqual(arrayLikeLists.connected.map(bluetooth.deviceLabel), ['Earbuds'], 'bluetooth groups connected devices from array-like values') assertDeepEqual(arrayLikeLists.known.map(bluetooth.deviceLabel), ['Trackpad'], 'bluetooth groups known devices from array-like values') assertDeepEqual(arrayLikeLists.discovered.map(bluetooth.deviceLabel), ['Gamepad'], 'bluetooth groups discovered devices from array-like values') assertDeepEqual( bluetooth.deviceRow({ name: 'Deadbeef', address: '1', connected: false }), { address: '1', name: 'Deadbeef', deviceName: '', connected: false, state: -1, batteryAvailable: false, battery: 0, pairing: false }, 'bluetooth projects device rows with primitives only' ) assertEqual( bluetooth.deviceLabel(bluetooth.deviceRow({ name: 'Generic', deviceName: 'MX Master 3S', address: '2', connected: true })), 'MX Master 3S', 'bluetooth keeps deviceName in row projections so labels survive QObject-free rows' ) assertDeepEqual( bluetooth.withPendingAction({ a: 'connecting' }, 'b', 'forgetting'), { a: 'connecting', b: 'forgetting' }, 'bluetooth adds pending actions immutably' ) assertDeepEqual(bluetooth.withPendingAction({ a: 'connecting' }, 'a', ''), {}, 'bluetooth clears pending actions immutably') const bluetoothSink = { isSink: true, isStream: false, ready: true, name: 'bluez_output.AA_BB_CC_DD_EE_FF.1', properties: { 'device.product.name': 'JBL Go 3' } } assert( bluetooth.bluetoothSinkMatchesDevice(bluetoothSink, { address: 'AA:BB:CC:DD:EE:FF', name: 'JBL Go 3' }), 'bluetooth matches audio sinks by device address' ) assert( bluetooth.bluetoothSinkMatchesDevice( { isSink: true, isStream: false, ready: true, name: 'alsa_output.usb-speaker', properties: { 'device.product.name': 'JBL Go 3' } }, { address: '11:22:33:44:55:66', name: 'JBL Go 3' } ), 'bluetooth matches audio sinks by human device label when address is unavailable' ) assert( !bluetooth.bluetoothSinkMatchesDevice({ isSink: false, isStream: false, ready: true, name: 'bluez_output.AA_BB_CC_DD_EE_FF.1', properties: {} }, { address: 'AA:BB:CC:DD:EE:FF', name: 'JBL Go 3' }), 'bluetooth ignores non-sink nodes when matching audio outputs' ) JS # Turning Bluetooth off is an rfkill soft block, not a bluetoothctl power off, # because only the block survives a reboot. These mocks stand in for that pair: # rfkill moves the block, and bluetoothd powers the adapter up once it is gone. device_tmp=$(mktemp -d) trap 'rm -rf "$device_tmp"' EXIT mock_bin="$device_tmp/bin" mkdir -p "$mock_bin" export POWERED_FILE="$device_tmp/powered" cat >"$mock_bin/bluetoothctl" <<'SH' #!/bin/bash printf '%s\n' "$*" >>"$BLUETOOTHCTL_LOG" [[ $1 == "power" && $2 == "on" ]] && echo yes >"$POWERED_FILE" [[ $1 == "list" ]] && for c in ${MOCK_CONTROLLERS:-AA:BB:CC:DD:EE:FF}; do printf 'Controller %s mock\n' "$c"; done # Per-controller state where a test set it, the shared file otherwise. if [[ $1 == "show" ]]; then state="$POWERED_FILE" [[ -n ${2:-} && -f "$POWERED_FILE.$2" ]] && state="$POWERED_FILE.$2" printf '\tPowered: %s\n' "$(cat "$state")" fi exit 0 SH cat >"$mock_bin/rfkill" <<'SH' #!/bin/bash printf 'rfkill %s\n' "$*" >>"$BLUETOOTHCTL_LOG" # Lifting the block is normally all it takes: AutoEnable is left at its default, # so bluetoothd powers the adapter up on its own. RFKILL_INERT stands in for the # adapter that was powered down without a block, where it does not. [[ $1 == "unblock" && -z ${RFKILL_INERT:-} ]] && echo yes >"$POWERED_FILE" [[ $1 == "block" ]] && echo no >"$POWERED_FILE" exit 0 SH chmod +x "$mock_bin/bluetoothctl" "$mock_bin/rfkill" # $ROOT/bin so omarchy-bluetooth-device resolves the real omarchy-bluetooth-power. bluetooth_run() { local powered="$1" shift echo "$powered" >"$POWERED_FILE" : >"$device_tmp/log" PATH="$mock_bin:$ROOT/bin:$PATH" BLUETOOTHCTL_LOG="$device_tmp/log" \ OMARCHY_BLUETOOTH_POWER_WAIT_SECONDS=0 "$@" || fail "$* exits cleanly with Powered: $powered" printf '%s' "$device_tmp/log" } bluetooth_power() { bluetooth_run "$1" "$ROOT/bin/omarchy-bluetooth-power" "$2" } # Off has to be the block. A bluetoothctl power off would read the same until the # next boot, then quietly come back on. off_log=$(bluetooth_power yes off) grep -qx "rfkill block bluetooth" "$off_log" || fail "bluetooth turns off with an rfkill block" "$(cat "$off_log")" pass "bluetooth turns off with an rfkill block" grep -q "power off" "$off_log" && fail "bluetooth does not also power the adapter down" "$(cat "$off_log")" pass "bluetooth does not also power the adapter down" # Unblocking is enough on its own, so there is nothing left to ask bluetoothctl. on_log=$(bluetooth_power no on) grep -qx "rfkill unblock bluetooth" "$on_log" || fail "bluetooth turns on by lifting the block" "$(cat "$on_log")" pass "bluetooth turns on by lifting the block" grep -q "power on" "$on_log" && fail "bluetooth leaves the power-on to bluetoothd when the block is lifted" "$(cat "$on_log")" pass "bluetooth leaves the power-on to bluetoothd when the block is lifted" # An adapter powered down without a block is one bluetoothd will not pick up. inert_log=$(RFKILL_INERT=1 bluetooth_power no on) grep -qx "power on" "$inert_log" || fail "bluetooth powers the adapter on when unblocking does not" "$(cat "$inert_log")" pass "bluetooth powers the adapter on when unblocking does not" # The panel switch reads Powered, so that is what toggle has to invert. toggle_on_log=$(bluetooth_power yes toggle) grep -qx "rfkill block bluetooth" "$toggle_on_log" || fail "bluetooth toggles a powered adapter off" "$(cat "$toggle_on_log")" pass "bluetooth toggles a powered adapter off" toggle_off_log=$(bluetooth_power no toggle) grep -qx "rfkill unblock bluetooth" "$toggle_off_log" || fail "bluetooth toggles an unpowered adapter on" "$(cat "$toggle_off_log")" pass "bluetooth toggles an unpowered adapter on" # The power-on shortcut is the whole point of skipping the stabilization sleep: # pair/connect from the panel run against an adapter that is already powered. bluetooth_device_log() { bluetooth_run "$1" "$ROOT/bin/omarchy-bluetooth-device" connect AA:BB:CC:DD:EE:FF } powered_log=$(bluetooth_device_log yes) grep -q "rfkill" "$powered_log" && fail "bluetooth skips the power-on delay when the adapter is already powered" pass "bluetooth skips the power-on delay when the adapter is already powered" grep -qx "connect AA:BB:CC:DD:EE:FF" "$powered_log" || fail "bluetooth still connects when the adapter is already powered" pass "bluetooth still connects when the adapter is already powered" # Connecting to a device while Bluetooth is off has to lift the block first — # BlueZ refuses to power an adapter up while one is set. unpowered_log=$(bluetooth_device_log no) grep -qx "rfkill unblock bluetooth" "$unpowered_log" || fail "bluetooth lifts the block before connecting" "$(cat "$unpowered_log")" pass "bluetooth lifts the block before connecting" grep -qx "connect AA:BB:CC:DD:EE:FF" "$unpowered_log" || fail "bluetooth connects once the adapter is up" "$(cat "$unpowered_log")" pass "bluetooth connects once the adapter is up" # Blocking hits every radio at once, so the read has to span them too. A bare # bluetoothctl show reports the default controller and misses a powered dongle. echo yes >"$POWERED_FILE.11:22:33:44:55:66" export MOCK_CONTROLLERS="AA:BB:CC:DD:EE:FF 11:22:33:44:55:66" multi_log=$(bluetooth_power no toggle) unset MOCK_CONTROLLERS rm -f "$POWERED_FILE.11:22:33:44:55:66" grep -qx "rfkill block bluetooth" "$multi_log" || fail "bluetooth counts a secondary controller as on" "$(cat "$multi_log")" pass "bluetooth counts a secondary controller as on" # AutoEnable=false was the old attempt at persistence and never worked. Left set, # it would also keep bluetoothd from powering the adapter up after an unblock. grep -q 'AutoEnable=false' "$ROOT/install/hardware/bluetooth.sh" && fail "bluetooth install leaves AutoEnable at its default" pass "bluetooth install leaves AutoEnable at its default"