Add Bluetooth RSSI polling (Experimental) and CLI-based pairing helpers with code cleanup and restructurization for better maintainability

- Introduced Bluetooth RSSI polling using `bluetoothctl` for connected devices with interval configuration.
- Added reusable helpers for CLI-based device pairing and connection.
- Enhanced Bluetooth panel with an opt-in toggle for RSSI polling.
- Updated settings and defaults for RSSI polling configuration.
- Refactored Bluetooth utilities for standardized device handling (icons, deduplication, signal parsing, etc.).
This commit is contained in:
danny
2026-01-02 03:09:16 +01:00
parent a3e277ce20
commit ba45c67d93
12 changed files with 635 additions and 475 deletions
+17 -2
View File
@@ -478,6 +478,7 @@
"connect": "Connect",
"connected-devices": "Connected devices",
"connecting": "Connecting...",
"disconnecting": "Disconnecting...",
"device-address": "Device address",
"disabled": "Bluetooth is disabled",
"disconnect": "Disconnect",
@@ -494,6 +495,14 @@
"refresh-devices": "Refresh devices",
"scanning": "Scanning for devices...",
"signal": "Signal",
"signal-text": {
"unknown": "Signal: Unknown",
"excellent": "Signal: Excellent",
"good": "Signal: Good",
"fair": "Signal: Fair",
"poor": "Signal: Poor",
"very-poor": "Signal: Very poor"
},
"title": "Bluetooth",
"trusted": "Trusted",
"unpair": "Unpair"
@@ -1940,7 +1949,11 @@
"network": {
"bluetooth": {
"description": "Activate Bluetooth management.",
"label": "Enable Bluetooth"
"label": "Enable Bluetooth",
"rssi-polling": {
"label": "Bluetooth signal polling",
"description": "Periodically sample RSSI for connected devices via bluetoothctl. May not be available for all devices; uses minimal resources when enabled."
}
},
"section": {
"description": "Manage Wi-Fi and Bluetooth connections."
@@ -2677,7 +2690,8 @@
"pair-failed": "Failed to pair device",
"passkey-required": "Passkey required by the device",
"pincode-required": "PIN code required by the device",
"state-change-failed": "Failed to change Bluetooth state"
"state-change-failed": "Failed to change Bluetooth state",
"address-copied": "Address copied to clipboard"
},
"clipboard": {
"unavailable": "Clipboard history unavailable",
@@ -2827,6 +2841,7 @@
"refresh-devices": "Refresh devices",
"refresh-wallhaven": "Refresh Wallhaven results",
"refresh-wallpaper-list": "Refresh wallpaper list",
"copy-address": "Copy address",
"remove-widget": "Remove widget",
"screen-recorder-not-installed": "Screen recorder (not installed)",
"search": "Search",
+3 -1
View File
@@ -289,7 +289,9 @@
"animationSpeed": 1
},
"network": {
"wifiEnabled": true
"wifiEnabled": true,
"bluetoothRssiPollingEnabled": false,
"bluetoothRssiPollIntervalMs": 10000
},
"sessionMenu": {
"enableCountdown": true,
+4
View File
@@ -520,6 +520,10 @@ Singleton {
// network
property JsonObject network: JsonObject {
property bool wifiEnabled: true
// Opt-in Bluetooth RSSI polling (uses bluetoothctl)
property bool bluetoothRssiPollingEnabled: false
// Polling interval in milliseconds for RSSI queries
property int bluetoothRssiPollIntervalMs: 10000
}
// session menu
+42
View File
@@ -0,0 +1,42 @@
.pragma library
var pairAndConnectScript = (addr, pairWaitSeconds, attempts, intervalSec) => {
// Produces a shell script that pairs, trusts and attempts to connect repeatedly.
return `
addr='${addr}'
{
echo 'agent KeyboardDisplay'
echo 'default-agent'
echo 'power on'
echo "pair $addr"
# Give time for potential confirmation prompt; send 'yes' optimistically (no-op if not needed)
sleep 1
echo 'yes'
# Mark device trusted
echo "trust $addr"
# Attempt multiple connects within the session
for i in $(seq 1 ${attempts}); do
echo "connect $addr"
sleep ${intervalSec}
done
echo 'quit'
} | bluetoothctl &
# Wait up to ${pairWaitSeconds}s for pairing to complete
for i in $(seq 1 ${pairWaitSeconds}); do
if bluetoothctl info "$addr" | grep -q 'Paired: yes'; then
break
fi
sleep 1
done
# Check connection state for ~${attempts * intervalSec}s total
for i in $(seq 1 ${attempts}); do
if bluetoothctl info "$addr" | grep -q 'Connected: yes'; then
exit 0
fi
sleep ${intervalSec}
done
exit 1
`;
};
+127
View File
@@ -0,0 +1,127 @@
.pragma library
// Address helpers
var macFromDevice = (dev) => {
if (!dev) return "";
if (dev.address && dev.address.length > 0) return dev.address;
if (dev.nativePath && dev.nativePath.indexOf("/dev_") !== -1)
return dev.nativePath.split("dev_")[1].split("_").join(":");
return "";
};
var deviceKey = (dev) => {
if (!dev) return "";
if (dev.address && dev.address.length > 0) return dev.address.toUpperCase();
if (dev.nativePath && dev.nativePath.length > 0) return dev.nativePath;
if (dev.devicePath && dev.devicePath.length > 0) return dev.devicePath;
return (dev.name || dev.deviceName || "") + "|" + (dev.icon || "");
};
var dedupeDevices = (list) => {
if (!list || list.length === 0) return [];
var seen = ({});
var out = [];
for (var i = 0; i < list.length; ++i) {
var d = list[i];
if (!d) continue;
var k = deviceKey(d);
if (k && !seen[k]) { seen[k] = true; out.push(d); }
}
return out;
};
// RSSI parsing
var parseRssiOutput = (text) => {
try {
text = text || "";
var mParen = text.match(/\(\s*(-?\d+)\s*(?:d?b?m?)?\s*\)/i);
if (mParen && mParen.length > 1) return Number(mParen[1]);
var mDec = text.match(/RSSI:\s*(-?\d+)/i);
if (mDec && mDec.length > 1) return Number(mDec[1]);
var mHex = text.match(/RSSI:\s*0x([0-9a-fA-F]+)/i);
if (mHex && mHex.length > 1) {
var v = parseInt(mHex[1], 16);
if (v >= 0x80000000) v = v - 0x100000000; // 32-bit two's complement
else if (v >= 0x8000) v = v - 0x10000; // 16-bit
else if (v >= 0x80) v = v - 0x100; // 8-bit
return v;
}
} catch (e) {}
return null;
};
var dbmToPercent = (dbm) => {
if (dbm === null || dbm === undefined || isNaN(dbm)) return null;
// Clamp simple linear map roughly from -100..0 dBm to 0..100%
var pct = Math.round((Number(dbm) + 100) * 2);
if (isNaN(pct)) return null;
return Math.max(0, Math.min(100, pct));
};
// Signal helpers
var signalPercent = (device, cache, _version) => {
if (!device) return null;
try {
var addr = macFromDevice(device);
if (addr && cache && cache[addr] !== undefined) {
var cached = Number(cache[addr]) | 0;
return Math.max(0, Math.min(100, cached));
}
} catch (e) {}
var s = device && device.signalStrength;
if (s === undefined || s <= 0) return null;
var p = Number(s) | 0;
return Math.max(0, Math.min(100, p));
};
var signalIcon = (p) => {
if (p === null) return "antenna-bars-off";
if (p >= 80) return "antenna-bars-5";
if (p >= 60) return "antenna-bars-4";
if (p >= 40) return "antenna-bars-3";
if (p >= 20) return "antenna-bars-2";
return "antenna-bars-1";
};
// Icon mapping
var deviceIcon = (name, icon) => {
var s1 = (name || "").toLowerCase();
var s2 = (icon || "").toLowerCase();
// Prefer icon-based hints for display devices first to avoid "audio" catching TVs
var displayHints = ["display", "tv", "monitor", "projector", "screen", "chromecast", "cast"];
for (var dh = 0; dh < displayHints.length; dh++) {
if (s2.indexOf(displayHints[dh]) !== -1) return "bt-device-tv";
}
var tests = [
[["controller", "gamepad"], "bt-device-gamepad"],
[["microphone"], "bt-device-microphone"],
[["pod", "bud", "minor"], "bt-device-earbuds"],
[["headset", "arctis", "major"], "bt-device-headset"],
[["headphone"], "bt-device-headphones"],
[["mouse"], "bt-device-mouse"],
[["keyboard"], "bt-device-keyboard"],
[["watch"], "bt-device-watch"],
[["display", "tv", "monitor", "projector", "screen", "chromecast", "cast"], "bt-device-tv"],
[["speaker", "audio", "sound"], "bt-device-speaker"],
[["phone", "iphone", "android", "samsung"], "bt-device-phone"]
];
for (var i = 0; i < tests.length; i++) {
var keys = tests[i][0];
var out = tests[i][1];
for (var j = 0; j < keys.length; j++) {
var k = keys[j];
if (s1.indexOf(k) !== -1 || s2.indexOf(k) !== -1) return out;
}
}
return "bt-device-generic";
};
// Battery percent helper
var batteryPercent = (device) => {
if (!device || !device.batteryAvailable || device.battery === undefined) return null;
var val = Math.round(Number(device.battery) * 100);
if (isNaN(val)) return null;
return Math.max(0, Math.min(100, val));
};
+4 -1
View File
@@ -88,7 +88,10 @@ Item {
autoHide: false
forceOpen: !isBarVertical && root.displayMode === "alwaysShow"
forceClose: isBarVertical || root.displayMode === "alwaysHide" || text === ""
onClicked: PanelService.getPanel("bluetoothPanel", screen)?.toggle(this)
onClicked: {
var p = PanelService.getPanel("bluetoothPanel", screen);
if (p) p.toggle(this);
}
onRightClicked: {
var popupMenuWindow = PanelService.getPopupMenuWindow(screen);
if (popupMenuWindow) {
@@ -132,46 +132,45 @@ NBox {
// Status
NText {
text: BluetoothService.getStatusString(modelData)
text: {
const k = BluetoothService.getStatusKey(modelData);
if (k === "pairing") return I18n.tr("bluetooth.panel.pairing");
if (k === "blocked") return I18n.tr("bluetooth.panel.blocked");
if (k === "connecting") return I18n.tr("bluetooth.panel.connecting");
if (k === "disconnecting") return I18n.tr("bluetooth.panel.disconnecting");
return "";
}
visible: text !== ""
pointSize: Style.fontSizeXS
color: getContentColor(Color.mOnSurfaceVariant)
}
// Signal Strength
// Signal strength: show only in the expanded info panel (hidden in compact row)
RowLayout {
visible: modelData.signalStrength !== undefined
visible: false
Layout.fillWidth: true
spacing: Style.marginXS
}
// Battery (icon + percent)
RowLayout {
visible: modelData.batteryAvailable
spacing: Style.marginXS
NIcon {
icon: "battery"
pointSize: Style.fontSizeXS
color: getContentColor(Color.mOnSurface)
}
// Device signal strength - "Unknown" when not connected
NText {
text: BluetoothService.getSignalStrength(modelData)
text: {
var b = BluetoothService.getBatteryPercent(modelData);
return b === null ? "-" : (b + "%");
}
pointSize: Style.fontSizeXS
color: getContentColor(Color.mOnSurfaceVariant)
}
NIcon {
visible: modelData.signalStrength > 0 && !modelData.pairing && !modelData.blocked
icon: BluetoothService.getSignalIcon(modelData)
pointSize: Style.fontSizeXS
color: getContentColor(Color.mOnSurface)
}
NText {
visible: modelData.signalStrength > 0 && !modelData.pairing && !modelData.blocked
text: (modelData.signalStrength !== undefined && modelData.signalStrength > 0) ? modelData.signalStrength + "%" : ""
pointSize: Style.fontSizeXS
color: getContentColor(Color.mOnSurface)
}
}
// Battery
NText {
visible: modelData.batteryAvailable
text: BluetoothService.getBattery(modelData)
pointSize: Style.fontSizeXS
color: getContentColor(Color.mOnSurfaceVariant)
}
}
@@ -322,12 +321,7 @@ NBox {
}
}
NText {
// Extract value from helper (remove leading label if present)
text: (function () {
var s = BluetoothService.getSignalStrength(modelData);
var idx = s.indexOf(":");
return idx !== -1 ? s.substring(idx + 1).trim() : s;
})()
text: BluetoothService.getSignalStrength(modelData)
pointSize: Style.fontSizeXS
color: Color.mOnSurface
Layout.fillWidth: true
@@ -353,11 +347,10 @@ NBox {
}
}
NText {
text: modelData.batteryAvailable ? (function () {
var b = BluetoothService.getBattery(modelData);
var i = b.indexOf(":");
return i !== -1 ? b.substring(i + 1).trim() : b;
})() : "-"
text: {
var b = BluetoothService.getBatteryPercent(modelData);
return b === null ? "-" : (b + "%");
}
pointSize: Style.fontSizeXS
color: Color.mOnSurface
Layout.fillWidth: true
@@ -437,6 +430,7 @@ NBox {
}
}
NText {
id: macAddressText
text: modelData.address || "-"
pointSize: Style.fontSizeXS
color: Color.mOnSurface
@@ -446,6 +440,24 @@ NBox {
elide: Text.ElideNone
maximumLineCount: 2
clip: true
// Click-to-copy MAC address
MouseArea {
anchors.fill: parent
enabled: (modelData.address && modelData.address.length > 0)
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: TooltipService.show(parent, I18n.tr("tooltips.copy-address"))
onExited: TooltipService.hide()
onClicked: {
const addr = modelData.address || "";
if (addr.length > 0) {
// Copy to clipboard via wl-copy (runtime dependency)
Quickshell.execDetached(["wl-copy", addr]);
ToastService.showNotice(I18n.tr("bluetooth.panel.title"), I18n.tr("toast.bluetooth.address-copied"), "bluetooth");
}
}
}
}
}
}
+5 -11
View File
@@ -75,14 +75,10 @@ SmartPanel {
NIconButton {
enabled: BluetoothService.enabled
icon: BluetoothService.adapter && BluetoothService.adapter.discovering ? "stop" : "refresh"
icon: BluetoothService.scanningActive ? "stop" : "refresh"
tooltipText: I18n.tr("tooltips.refresh-devices")
baseSize: Style.baseWidgetSize * 0.8
onClicked: {
if (BluetoothService.adapter) {
BluetoothService.adapter.discovering = !BluetoothService.adapter.discovering;
}
}
onClicked: BluetoothService.toggleDiscovery()
}
NIconButton {
@@ -272,7 +268,7 @@ SmartPanel {
// Empty state when no devices
NBox {
visible: {
if (!BluetoothService.adapter || BluetoothService.adapter.discovering || !Bluetooth.devices)
if (!Bluetooth.devices || BluetoothService.scanningActive)
return false;
var availableCount = Bluetooth.devices.values.filter(dev => {
@@ -310,9 +306,7 @@ SmartPanel {
icon: "refresh"
Layout.alignment: Qt.AlignHCenter
onClicked: {
if (BluetoothService.adapter) {
BluetoothService.adapter.discovering = !BluetoothService.adapter.discovering;
}
BluetoothService.toggleDiscovery();
}
}
@@ -327,7 +321,7 @@ SmartPanel {
Layout.fillWidth: true
Layout.preferredHeight: columnScanning.implicitHeight + Style.marginM * 2
visible: {
if (!BluetoothService.adapter || !BluetoothService.adapter.discovering || !Bluetooth.devices) {
if (!Bluetooth.devices || !BluetoothService.scanningActive) {
return false;
}
@@ -10,6 +10,9 @@ NIconButtonHot {
icon: BluetoothService.enabled ? "bluetooth" : "bluetooth-off"
tooltipText: I18n.tr("quickSettings.bluetooth.tooltip.action")
onClicked: PanelService.getPanel("bluetoothPanel", screen)?.toggle(this)
onClicked: {
var p = PanelService.getPanel("bluetoothPanel", screen);
if (p) p.toggle(this);
}
onRightClicked: BluetoothService.setBluetoothEnabled(!BluetoothService.enabled)
}
@@ -35,6 +35,19 @@ ColumnLayout {
onToggled: checked => BluetoothService.setBluetoothEnabled(checked)
}
// Bluetooth signal strength polling (RSSI via bluetoothctl)
NToggle {
label: I18n.tr("settings.network.bluetooth.rssi-polling.label")
description: I18n.tr("settings.network.bluetooth.rssi-polling.description")
checked: Settings.data && Settings.data.network && Settings.data.network.bluetoothRssiPollingEnabled
enabled: BluetoothService.enabled
onToggled: function(checked) {
if (Settings.data && Settings.data.network) {
Settings.data.network.bluetoothRssiPollingEnabled = checked;
}
}
}
NDivider {
Layout.fillWidth: true
Layout.topMargin: Style.marginL
+69
View File
@@ -0,0 +1,69 @@
import QtQuick
import Quickshell
import Quickshell.Io
import "../../Helpers/BluetoothUtils.js" as BluetoothUtils
QtObject {
id: root
// Controls
property bool enabled: false
property int intervalMs: 10000
property var connectedDevices: []
// Output cache and version for bindings
property var cache: ({}) // addr -> percent (0..100)
property int version: 0
// Internal rotation state
property int _index: 0
property string _currentAddr: ""
// Single process reused for RSSI queries
property Process rssiProcess: Process {
id: proc
running: false
stdout: StdioCollector { id: out }
onExited: function (exitCode, exitStatus) {
try {
var text = out.text || "";
var dbm = BluetoothUtils.parseRssiOutput(text);
if (root._currentAddr !== "" && dbm !== null) {
var pct = BluetoothUtils.dbmToPercent(dbm);
if (pct !== null) {
root.cache[root._currentAddr] = pct;
root.version++;
}
}
} catch (e) {
} finally {
root._currentAddr = "";
}
}
}
// Periodic RSSI polling timer
property Timer rssiTimer: Timer {
interval: root.intervalMs
repeat: true
running: root.enabled
onTriggered: {
var list = root.connectedDevices || [];
if (!list || list.length === 0)
return;
if (root._index >= list.length)
root._index = 0;
var dev = list[root._index++];
if (!dev)
return;
var addr = BluetoothUtils.macFromDevice(dev);
if (!addr || addr.length < 7)
return;
if (proc.running)
return; // avoid overlap
root._currentAddr = addr;
proc.command = ["sh", "-c", `bluetoothctl info "${addr}"`];
try { proc.running = true; } catch (e) {}
}
}
}
+297 -421
View File
@@ -1,34 +1,35 @@
pragma Singleton
import QtQuick
import QtQml
import Quickshell
import Quickshell.Bluetooth
import Quickshell.Io
import qs.Commons
import qs.Services.UI
import "."
import "../../Helpers/BluetoothUtils.js" as BluetoothUtils
import "../../Helpers/BluetoothScripts.js" as BluetoothScripts
Singleton {
QtObject {
id: root
// ---- Constants (centralized tunables) ----
readonly property int ctlPollMs: 1500
readonly property int ctlPollSoonMs: 250
readonly property int scanAutoStopMs: 6000
property bool airplaneModeToggled: false
property bool lastBluetoothBlocked: false
readonly property BluetoothAdapter adapter: Bluetooth.defaultAdapter
readonly property int state: (adapter && adapter.state !== undefined) ? adapter.state : 0
readonly property bool available: (adapter !== null)
readonly property bool enabled: (adapter && adapter.enabled !== undefined) ? adapter.enabled : false
readonly property bool blocked: (adapter && adapter.state === BluetoothAdapterState.Blocked)
readonly property bool discovering: (adapter && adapter.discovering) ? adapter.discovering : false
// Adapter discoverability (advertising) flag
readonly property bool discoverable: (adapter && adapter.discoverable !== undefined) ? adapter.discoverable : false
// Power/blocked state
property bool enabled: false // driven by bluetoothctl
property bool ctlPowered: false
property bool ctlDiscovering: false
property bool ctlDiscoverable: false
// Adapter discoverability (advertising) flag (driven by bluetoothctl)
readonly property bool discoverable: root.ctlDiscoverable
readonly property var devices: adapter ? adapter.devices : null
readonly property var pairedDevices: {
if (!adapter || !adapter.devices) {
return [];
}
return adapter.devices.values.filter(function (dev) {
return dev && (dev.paired || dev.trusted);
});
}
readonly property var connectedDevices: {
if (!adapter || !adapter.devices) {
return [];
@@ -38,198 +39,224 @@ Singleton {
});
}
readonly property var allDevicesWithBattery: {
if (!adapter || !adapter.devices) {
return [];
}
return adapter.devices.values.filter(function (dev) {
return dev && dev.batteryAvailable && dev.battery > 0;
});
// Experimental: besteffort RSSI polling for connected devices (without root)
// Enabled in debug mode or via user setting in Settings > Network
property bool rssiPollingEnabled: (Settings && (Settings.isDebug || (Settings.data && Settings.data.network && Settings.data.network.bluetoothRssiPollingEnabled))) ? true : false
// Interval can be configured from Settings; defaults to 10s
property int rssiPollIntervalMs: (Settings && Settings.data && Settings.data.network && Settings.data.network.bluetoothRssiPollIntervalMs) ? Settings.data.network.bluetoothRssiPollIntervalMs : 10000
// RSSI helper subcomponent
property BluetoothRssi rssi: BluetoothRssi {
enabled: root.enabled && root.rssiPollingEnabled
intervalMs: root.rssiPollIntervalMs
connectedDevices: root.connectedDevices
}
// Tunables for CLI pairing/connect flow
property int pairWaitSeconds: 20
property int connectAttempts: 5
property int connectRetryIntervalMs: 2000
// Internal: temporarily pause discovery during pair/connect to reduce HCI churn
// Use a resume deadline to coalesce overlapping pauses safely
property bool _discoveryWasRunning: false
property double _discoveryResumeAtMs: 0
// Timer used to restore discovery after temporary pause during pair/connect
property Timer restoreDiscoveryTimer: Timer {
repeat: false
onTriggered: {
const now = Date.now();
if (now < root._discoveryResumeAtMs) {
// Not yet time to resume; reschedule
interval = Math.max(100, root._discoveryResumeAtMs - now);
restart();
return;
}
if (root._discoveryWasRunning) {
root.setScanActive(true, 0);
}
root._discoveryWasRunning = false;
root._discoveryResumeAtMs = 0;
}
}
function _pauseDiscoveryFor(ms) {
try {
// Remember if discovery was running before the first pause
root._discoveryWasRunning = root._discoveryWasRunning || !!root.ctlDiscovering;
if (root.ctlDiscovering) {
root.setScanActive(false, 0);
}
if (ms && ms > 0) {
const now = Date.now();
const resumeAt = now + ms;
if (resumeAt > root._discoveryResumeAtMs)
root._discoveryResumeAtMs = resumeAt;
restoreDiscoveryTimer.interval = Math.max(100, root._discoveryResumeAtMs - now);
restoreDiscoveryTimer.restart();
}
} catch (_) {}
}
// Unify discovery controls and autostop window
function setScanActive(active, durationMs) {
// Cancel any scheduled resume so manual toggle wins
try {
root._discoveryResumeAtMs = 0;
restoreDiscoveryTimer.stop();
root._discoveryWasRunning = false;
} catch (_) {}
btExec(["bluetoothctl", "scan", active ? "on" : "off"]);
if (active && durationMs && durationMs > 0) {
manualScanTimer.interval = durationMs;
manualScanTimer.restart();
} else {
if (manualScanTimer.running) manualScanTimer.stop();
}
requestCtlPoll(ctlPollSoonMs);
}
// Explicit toggle that cancels any pending restore so UI button behaves predictably
function toggleDiscovery() {
if (!adapter)
return;
setScanActive(!root.ctlDiscovering, scanAutoStopMs);
}
// Auto-stop manual discovery after a short window
property Timer manualScanTimer: Timer {
repeat: false
onTriggered: {
// Stop scan via bluetoothctl if currently active
if (root.ctlDiscovering) {
root.setScanActive(false, 0);
}
}
}
// Exposed scanning flag for UI button state, driven by bluetoothctl state
readonly property bool scanningActive: (root.ctlDiscovering === true) || manualScanTimer.running
function init() {
Logger.i("Bluetooth", "Service started");
}
// --- Bluetooth Agent ---
// Registers an authentication agent with BlueZ so pairing that requires
// user interaction (numeric comparison, passkey, etc.) can complete.
// Note: We keep the first implementation minimal to unblock common cases
// (numeric comparison). A richer UI prompt can be added later.
// The Quickshell Bluetooth module provides the Agent type and handlers.
// Pending request context (exposed for future UI prompts)
property var pendingPairDevice: null
property string pendingPairType: "" // "confirmation" | "passkey" | "pincode"
property string pendingPairPasskey: ""
// Dynamically create agent if the type exists (older Quickshell builds may not provide BluetoothAgent)
property var btAgent: null
property bool btAgentRegistered: false
// Track if we attempted to start an external fallback agent
property bool fallbackAgentAttempted: false
// Start a fallback agent using bluetoothctl when Quickshell's BluetoothAgent
// type is unavailable. This registers a BlueZ agent with KeyboardDisplay
// capability so pairing can proceed.
function startFallbackAgent() {
if (fallbackAgentAttempted)
return;
fallbackAgentAttempted = true;
try {
Logger.i("Bluetooth", "Starting fallback bluetoothctl agent (KeyboardDisplay)");
fallbackBluetoothctlAgent.running = true;
} catch (e) {
Logger.w("Bluetooth", "Failed to start fallback bluetoothctl agent", e);
}
}
// Force-start the fallback agent shortly after startup to guarantee
// BlueZ has an agent even if the dynamic QML agent is unavailable or fails.
Timer {
id: fallbackForceTimer
interval: 500
running: true
repeat: false
onTriggered: startFallbackAgent()
}
Component.onCompleted: {
try {
const qml = `
import QtQuick
import Quickshell
import Quickshell.Bluetooth
import qs.Commons
import qs.Services.UI
BluetoothAgent {
id: dynAgent
capability: BluetoothAgentCapability.KeyboardDisplay
onRequestConfirmation: function(device, passkey, accept, reject) {
try {
Logger.i("Bluetooth", "Agent RequestConfirmation", passkey);
ToastService.showNotice(I18n.tr("bluetooth.panel.title"), I18n.tr("toast.bluetooth.confirm-code", { value: passkey }), "bluetooth");
accept();
} catch (e) {
Logger.w("Bluetooth", "Agent RequestConfirmation failed", e);
reject();
}
// Prime state immediately so UI reflects correct power/discovery flags
pollCtlState();
}
onRequestPasskey: function(device, accept, reject) {
try {
Logger.i("Bluetooth", "Agent RequestPasskey");
ToastService.showWarning(I18n.tr("bluetooth.panel.title"), I18n.tr("toast.bluetooth.passkey-required"));
} catch (e) {
Logger.w("Bluetooth", "Agent RequestPasskey handler error", e);
} finally {
reject();
}
}
// Note: We intentionally avoid creating or managing a custom BlueZ agent in-process.
// Pairing flows are delegated to `bluetoothctl` as needed to keep behavior
// consistent and reduce maintenance complexity.
onRequestPinCode: function(device, accept, reject) {
try {
Logger.i("Bluetooth", "Agent RequestPinCode");
ToastService.showWarning(I18n.tr("bluetooth.panel.title"), I18n.tr("toast.bluetooth.pincode-required"));
} catch (e) {
Logger.w("Bluetooth", "Agent RequestPinCode handler error", e);
} finally {
reject();
}
}
// No implicit discovery auto-start; state polled from bluetoothctl instead
onDisplayPasskey: function(device, passkey) {
try {
Logger.i("Bluetooth", "Agent DisplayPasskey", passkey);
ToastService.showNotice(I18n.tr("bluetooth.panel.title"), I18n.tr("toast.bluetooth.display-code", { value: passkey }), "bluetooth");
} catch (e) {
Logger.w("Bluetooth", "Agent DisplayPasskey handler error", e);
}
}
onAuthorizeService: function(device, uuid, accept, reject) {
Logger.d("Bluetooth", "Agent AuthorizeService", uuid);
accept();
}
onCancel: function() {
Logger.d("Bluetooth", "Agent request canceled");
}
}
`;
btAgent = Qt.createQmlObject(qml, root, "DynamicBluetoothAgent");
// Attempt to register the agent from the outer scope so we can
// trigger a fallback if registration fails at runtime.
try {
Bluetooth.agent = btAgent;
if (btAgent.register)
btAgent.register();
Logger.i("Bluetooth", "BluetoothAgent registered (dynamic)");
btAgentRegistered = true;
} catch (regErr) {
Logger.w("Bluetooth", "Failed to register BluetoothAgent (dynamic)", regErr);
btAgentRegistered = false;
startFallbackAgent();
}
} catch (e) {
Logger.i("Bluetooth", "BluetoothAgent type appears unavailable; starting fallback agent");
btAgentRegistered = false;
startFallbackAgent();
}
}
// External fallback agent process (bt-agent or bluetoothctl)
Process {
id: fallbackBluetoothctlAgent
// Prefer bt-agent (if available). Otherwise, fall back to bluetoothctl
// and register as the default agent, keeping the session alive.
command: ["sh", "-c", "pkill -f '^bt-agent( |$)' 2>/dev/null || true; pkill -f '^bluetoothctl( |$)' 2>/dev/null || true; " + "if command -v bt-agent >/dev/null 2>&1; then exec bt-agent -c DisplayYesNo; " + "else exec sh -c \"printf 'agent off\nagent on\nagent KeyboardDisplay\ndefault-agent\n'; cat - | bluetoothctl\"; fi"]
running: false
stdout: StdioCollector {}
stderr: StdioCollector {
onStreamFinished: {
if (text && text.trim()) {
Logger.w("Bluetooth", "bluetoothctl agent stderr:", text.trim());
}
}
}
}
Timer {
id: discoveryTimer
interval: 1000
repeat: false
onTriggered: {
if (adapter)
adapter.discovering = true;
}
}
Connections {
// Track adapter state changes (for enabled/disabled logging only; avoid discovery writes here)
property Connections adapterConnections: Connections {
target: adapter
function onStateChanged() {
if (!adapter) {
Logger.w("Bluetooth", "onStateChanged", "No adapter available");
if (!adapter)
return;
}
if (adapter.state === BluetoothAdapterState.Enabling || adapter.state === BluetoothAdapterState.Disabling) {
return;
}
Logger.d("Bluetooth", "onStateChanged", adapter.state);
const bluetoothBlockedToggled = (root.blocked !== lastBluetoothBlocked);
root.lastBluetoothBlocked = root.blocked;
if (bluetoothBlockedToggled) {
checkWifiBlocked.running = true;
} else if (adapter.state === BluetoothAdapterState.Enabled) {
ToastService.showNotice(I18n.tr("bluetooth.panel.title"), I18n.tr("toast.bluetooth.enabled"), "bluetooth");
discoveryTimer.running = true;
if (adapter.state === BluetoothAdapterState.Enabled) {
Logger.d("Bluetooth", "Adapter enabled");
// Keep UI default to refresh icon; bluetoothctl polling will set ctlDiscovering accordingly.
} else if (adapter.state === BluetoothAdapterState.Disabled) {
Logger.d("Bluetooth", "Adapter disabled");
}
}
}
// --- bluetoothctl state polling ---
property Process ctlShowProcess: Process {
id: ctlProc
running: false
stdout: StdioCollector { id: ctlStdout }
onExited: function (exitCode, exitStatus) {
try {
var text = ctlStdout.text || "";
// Parse Powered/Discoverable/Discovering lines
var mp = text.match(/\bPowered:\s*(yes|no)\b/i);
if (mp && mp.length > 1) {
root.ctlPowered = (mp[1].toLowerCase() === "yes");
root.enabled = root.ctlPowered;
}
var md = text.match(/\bDiscoverable:\s*(yes|no)\b/i);
if (md && md.length > 1) {
root.ctlDiscoverable = (md[1].toLowerCase() === "yes");
}
var ms = text.match(/\bDiscovering:\s*(yes|no)\b/i);
if (ms && ms.length > 1) {
root.ctlDiscovering = (ms[1].toLowerCase() === "yes");
}
} catch (e) {
Logger.d("Bluetooth", "Failed to parse bluetoothctl show output", e);
}
}
}
function pollCtlState() {
if (ctlProc.running)
return;
try {
ctlProc.command = ["bluetoothctl", "show"];
ctlProc.running = true;
} catch (_) {}
}
// Periodic state polling
property Timer ctlPollTimer: Timer {
interval: ctlPollMs
repeat: true
running: true
onTriggered: pollCtlState()
}
// Short-delay poll scheduler
property Timer pollCtlStateSoonTimer: Timer {
interval: ctlPollSoonMs
repeat: false
onTriggered: pollCtlState()
}
function requestCtlPoll(delayMs) {
pollCtlStateSoonTimer.interval = Math.max(50, delayMs || ctlPollSoonMs);
pollCtlStateSoonTimer.restart();
}
// Adapter power (enable/disable) via bluetoothctl
function setBluetoothEnabled(state) {
Logger.i("Bluetooth", "SetBluetoothEnabled", state);
try {
btExec(["bluetoothctl", "power", state ? "on" : "off"]);
root.ctlPowered = !!state;
root.enabled = root.ctlPowered;
if (state) {
ToastService.showNotice(I18n.tr("bluetooth.panel.title"), I18n.tr("toast.bluetooth.enabled"), "bluetooth");
} else {
ToastService.showNotice(I18n.tr("bluetooth.panel.title"), I18n.tr("toast.bluetooth.disabled"), "bluetooth-off");
}
requestCtlPoll(ctlPollSoonMs);
} catch (e) {
Logger.w("Bluetooth", "Enable/Disable failed", e);
ToastService.showWarning(I18n.tr("bluetooth.panel.title"), I18n.tr("toast.bluetooth.state-change-failed"));
}
}
// Toggle adapter discoverability (advertising visibility) via bluetoothctl
function setDiscoverable(state) {
try {
btExec(["bluetoothctl", "discoverable", state ? "on" : "off"]);
root.ctlDiscoverable = !!state; // optimistic
requestCtlPoll(ctlPollSoonMs);
if (state) {
ToastService.showNotice(I18n.tr("bluetooth.panel.title"), I18n.tr("toast.bluetooth.discoverable-enabled"), "broadcast");
} else {
ToastService.showNotice(I18n.tr("bluetooth.panel.title"), I18n.tr("toast.bluetooth.discoverable-disabled"), "broadcast-off");
}
Logger.i("Bluetooth", "Discoverable state set to:", state);
} catch (e) {
Logger.w("Bluetooth", "Failed to change discoverable state", e);
ToastService.showWarning(I18n.tr("bluetooth.panel.title"), I18n.tr("toast.bluetooth.discoverable-change-failed"));
}
}
@@ -256,43 +283,7 @@ BluetoothAgent {
if (!device) {
return "bt-device-generic";
}
var name = (device.name || device.deviceName || "").toLowerCase();
var icon = (device.icon || "").toLowerCase();
if (icon.indexOf("controller") !== -1 || icon.indexOf("gamepad") !== -1 || name.indexOf("controller") !== -1 || name.indexOf("gamepad") !== -1) {
return "bt-device-gamepad";
}
if (icon.indexOf("microphone") !== -1 || name.indexOf("microphone") !== -1) {
return "bt-device-microphone";
}
if (name.indexOf("pod") !== -1 || name.indexOf("bud") !== -1 || name.indexOf("minor") !== -1) {
return "bt-device-earbuds";
}
if (icon.indexOf("headset") !== -1 || name.indexOf("arctis") !== -1 || name.indexOf("headset") !== -1 || name.indexOf("major") !== -1) {
return "bt-device-headset";
}
if (icon.indexOf("headphone") !== -1 || name.indexOf("headphone") !== -1) {
return "bt-device-headphones";
}
if (icon.indexOf("mouse") !== -1 || name.indexOf("mouse") !== -1) {
return "bt-device-mouse";
}
if (icon.indexOf("keyboard") !== -1 || name.indexOf("keyboard") !== -1) {
return "bt-device-keyboard";
}
if (icon.indexOf("watch") !== -1 || name.indexOf("watch") !== -1) {
return "bt-device-watch";
}
if (icon.indexOf("speaker") !== -1 || name.indexOf("speaker") !== -1 || name.indexOf("audio") !== -1 || name.indexOf("sound") !== -1) {
return "bt-device-speaker";
}
if (icon.indexOf("display") !== -1 || name.indexOf("tv") !== -1) {
return "bt-device-tv";
}
if (icon.indexOf("phone") !== -1 || name.indexOf("phone") !== -1 || name.indexOf("iphone") !== -1 || name.indexOf("android") !== -1 || name.indexOf("samsung") !== -1) {
return "bt-device-phone";
}
return "bt-device-generic";
return BluetoothUtils.deviceIcon(device.name || device.deviceName, device.icon);
}
function canConnect(device) {
@@ -315,62 +306,51 @@ BluetoothAgent {
return false;
return device.connected && !device.pairing && !device.blocked;
}
// Status string for a device (translated)
function getStatusString(device) {
if (device.state === BluetoothDeviceState.Connecting) {
return I18n.tr("bluetooth.panel.connecting");
}
if (device.pairing) {
return I18n.tr("bluetooth.panel.pairing");
}
if (device.blocked) {
return I18n.tr("bluetooth.panel.blocked");
}
if (!device)
return "";
try {
if (device.pairing)
return I18n.tr("bluetooth.panel.pairing");
if (device.blocked)
return I18n.tr("bluetooth.panel.blocked");
if (device.state === BluetoothDeviceState.Connecting)
return I18n.tr("bluetooth.panel.connecting");
if (device.state === BluetoothDeviceState.Disconnecting)
return I18n.tr("bluetooth.panel.disconnecting");
} catch (_) {}
return "";
}
// Textual signal quality (translated)
function getSignalStrength(device) {
if (!device || device.signalStrength === undefined || device.signalStrength <= 0) {
return "Signal: Unknown";
}
var signal = device.signalStrength;
if (signal >= 80) {
return "Signal: Excellent";
}
if (signal >= 60) {
return "Signal: Good";
}
if (signal >= 40) {
return "Signal: Fair";
}
if (signal >= 20) {
return "Signal: Poor";
}
return "Signal: Very poor";
var p = getSignalPercent(device);
if (p === null)
return I18n.tr("bluetooth.panel.signal-text.unknown");
if (p >= 80) return I18n.tr("bluetooth.panel.signal-text.excellent");
if (p >= 60) return I18n.tr("bluetooth.panel.signal-text.good");
if (p >= 40) return I18n.tr("bluetooth.panel.signal-text.fair");
if (p >= 20) return I18n.tr("bluetooth.panel.signal-text.poor");
return I18n.tr("bluetooth.panel.signal-text.very-poor");
}
function getBattery(device) {
return "Battery: " + Math.round(device.battery * 100) + "%";
// Numeric helpers for UI rendering
function getSignalPercent(device) {
// Establish binding dependency so UI updates when RSSI cache changes
var _v = rssi.version;
return BluetoothUtils.signalPercent(device, rssi.cache, _v);
}
function getBatteryPercent(device) {
return BluetoothUtils.batteryPercent(device);
}
function getSignalIcon(device) {
if (!device || device.signalStrength === undefined || device.signalStrength <= 0) {
return "antenna-bars-off";
}
var signal = device.signalStrength;
if (signal >= 80) {
return "antenna-bars-5";
}
if (signal >= 60) {
return "antenna-bars-4";
}
if (signal >= 40) {
return "antenna-bars-3";
}
if (signal >= 20) {
return "antenna-bars-2";
}
return "antenna-bars-1";
var p = getSignalPercent(device);
return BluetoothUtils.signalIcon(p);
}
function isDeviceBusy(device) {
@@ -383,34 +363,12 @@ BluetoothAgent {
// Return a stable unique key for a device (prefer MAC address)
function deviceKey(device) {
if (!device)
return "";
if (device.address && device.address.length > 0)
return device.address.toUpperCase();
if (device.nativePath && device.nativePath.length > 0)
return device.nativePath;
if (device.devicePath && device.devicePath.length > 0)
return device.devicePath;
return (device.name || device.deviceName || "") + "|" + (device.icon || "");
return BluetoothUtils.deviceKey(device);
}
// Deduplicate a list of devices using the stable key
function dedupeDevices(devList) {
if (!devList || devList.length === 0)
return [];
const seen = ({});
const out = [];
for (let i = 0; i < devList.length; ++i) {
const d = devList[i];
if (!d)
continue;
const key = deviceKey(d);
if (key && !seen[key]) {
seen[key] = true;
out.push(d);
}
}
return out;
return BluetoothUtils.dedupeDevices(devList);
}
// Separate capability helpers
@@ -424,79 +382,69 @@ BluetoothAgent {
function pairDevice(device) {
if (!device)
return;
ToastService.showNotice(I18n.tr("bluetooth.panel.title"), I18n.tr("bluetooth.panel.pairing"), "bluetooth");
// Delegate pairing to bluetoothctl which registers/uses its own agent
try {
// If the in-app agent is not registered/available, use bluetoothctl which manages its own agent
if (!btAgentRegistered) {
pairWithBluetoothctl(device);
return;
}
if (typeof device.pair === 'function') {
device.pair();
} else {
// Fallback: trust and connect (most stacks will pair during connect)
device.trusted = true;
device.connect();
}
pairWithBluetoothctl(device);
} catch (e) {
Logger.w("Bluetooth", "pairDevice failed", e);
ToastService.showWarning(I18n.tr("bluetooth.panel.title"), I18n.tr("toast.bluetooth.pair-failed"));
// CLI fallback: use bluetoothctl to perform pairing with an internal agent
// This mirrors the manual pairing flow that works for the user.
try {
pairWithBluetoothctl(device);
} catch (e3) {
Logger.w("Bluetooth", "pairWithBluetoothctl failed", e3);
// Fallback to connect if pair not supported
try {
device.trusted = true;
device.connect();
} catch (e2) {
Logger.w("Bluetooth", "pairDevice connect fallback failed", e2);
ToastService.showWarning(I18n.tr("bluetooth.panel.title"), I18n.tr("toast.bluetooth.connect-failed"));
}
}
}
}
// Pair using bluetoothctl which registers its own BlueZ agent internally.
// Useful on systems where the QML BluetoothAgent type is unavailable.
function pairWithBluetoothctl(device) {
if (!device)
return;
var addr = "";
try {
if (device.address && device.address.length > 0) {
addr = device.address;
} else if (device.nativePath && device.nativePath.indexOf("/dev_") !== -1) {
// Extract MAC from nativePath like /org/bluez/hci0/dev_XX_XX_...
addr = device.nativePath.split("dev_")[1].replaceAll("_", ":");
}
} catch (_) {}
var addr = BluetoothUtils.macFromDevice(device);
if (!addr || addr.length < 7) {
Logger.w("Bluetooth", "pairWithBluetoothctl: no valid address for device");
return;
}
Logger.i("Bluetooth", "pairWithBluetoothctl", addr);
const script = `(
printf 'agent DisplayYesNo\n';
printf 'default-agent\n';
printf 'pair ${addr}\n';
sleep 2;
printf 'yes\n';
printf 'trust ${addr}\n';
sleep 1;
printf 'connect ${addr}\n';
printf 'quit\n';
) | bluetoothctl`;
// Compute bounded waits from tunables
const pairWait = Math.max(5, Number(root.pairWaitSeconds) | 0);
const attempts = Math.max(1, Number(root.connectAttempts) | 0);
const intervalMs = Math.max(500, Number(root.connectRetryIntervalMs) | 0);
const intervalSec = Math.max(1, Math.round(intervalMs / 1000));
// Pause discovery during pair/connect to avoid interference
const totalPauseMs = (pairWait * 1000) + (attempts * intervalSec * 1000) + 2000;
_pauseDiscoveryFor(totalPauseMs);
// Auto-confirm pairing with bluetoothctl ("yes"). Build script via helper.
const script = BluetoothScripts.pairAndConnectScript(addr, pairWait, attempts, intervalSec);
btExec(["sh", "-c", script]);
}
// --- Helper to run bluetoothctl and scripts with consistent error logging ---
function btExec(args) {
try {
Quickshell.execDetached(["sh", "-c", script]);
Quickshell.execDetached(args);
} catch (e) {
Logger.w("Bluetooth", "execDetached bluetoothctl failed", e);
Logger.w("Bluetooth", "btExec failed", e);
}
}
// Status key for a device (untranslated)
function getStatusKey(device) {
if (!device)
return "";
try {
if (device.pairing)
return "pairing";
if (device.blocked)
return "blocked";
if (device.state === BluetoothDeviceState.Connecting)
return "connecting";
if (device.state === BluetoothDeviceState.Disconnecting)
return "disconnecting";
} catch (_) {}
return "";
}
function unpairDevice(device) {
// Alias to forgetDevice for clarity in UI
forgetDevice(device);
@@ -539,76 +487,4 @@ BluetoothAgent {
ToastService.showWarning(I18n.tr("bluetooth.panel.title"), I18n.tr("toast.bluetooth.forget-failed"));
}
}
function setBluetoothEnabled(state) {
if (!adapter) {
Logger.w("Bluetooth", "No adapter available");
return;
}
Logger.i("Bluetooth", "SetBluetoothEnabled", state);
try {
adapter.enabled = state;
} catch (e) {
Logger.w("Bluetooth", "Enable/Disable failed", e);
ToastService.showWarning(I18n.tr("bluetooth.panel.title"), I18n.tr("toast.bluetooth.state-change-failed"));
}
}
// Toggle adapter discoverability (advertising visibility)
function setDiscoverable(state) {
if (!adapter) {
Logger.w("Bluetooth", "setDiscoverable: No adapter available");
return;
}
try {
adapter.discoverable = state;
if (state) {
ToastService.showNotice(I18n.tr("bluetooth.panel.title"), I18n.tr("toast.bluetooth.discoverable-enabled"), "broadcast");
} else {
ToastService.showNotice(I18n.tr("bluetooth.panel.title"), I18n.tr("toast.bluetooth.discoverable-disabled"), "broadcast-off");
}
Logger.i("Bluetooth", "Discoverable state set to:", state);
} catch (e) {
Logger.w("Bluetooth", "Failed to change discoverable state", e);
ToastService.showWarning(I18n.tr("bluetooth.panel.title"), I18n.tr("toast.bluetooth.discoverable-change-failed"));
}
}
Process {
id: checkWifiBlocked
running: false
command: ["rfkill", "list", "wifi"]
stdout: StdioCollector {
onStreamFinished: {
var wifiBlocked = text && text.trim().indexOf("Soft blocked: yes") !== -1;
Logger.d("Network", "Wi-Fi adapter was detected as blocked:", wifiBlocked);
// Check if airplane mode has been toggled
if (wifiBlocked && wifiBlocked === root.blocked) {
root.airplaneModeToggled = true;
NetworkService.setWifiEnabled(false);
ToastService.showNotice(I18n.tr("toast.airplane-mode.title"), I18n.tr("toast.airplane-mode.enabled"), "plane");
} else if (!wifiBlocked && wifiBlocked === root.blocked) {
root.airplaneModeToggled = true;
NetworkService.setWifiEnabled(true);
ToastService.showNotice(I18n.tr("toast.airplane-mode.title"), I18n.tr("toast.airplane-mode.disabled"), "plane-off");
} else if (adapter.enabled) {
ToastService.showNotice(I18n.tr("bluetooth.panel.title"), I18n.tr("toast.bluetooth.enabled"), "bluetooth");
discoveryTimer.running = true;
} else {
ToastService.showNotice(I18n.tr("bluetooth.panel.title"), I18n.tr("toast.bluetooth.disabled"), "bluetooth-off");
}
root.airplaneModeToggled = false;
}
}
stderr: StdioCollector {
onStreamFinished: {
if (text && text.trim()) {
Logger.w("Bluetooth", "rfkill (wifi) stderr:", text.trim());
}
}
}
}
}