pragma Singleton import QtQuick import Quickshell import Quickshell.Bluetooth import Quickshell.Io import "../../Helpers/BluetoothUtils.js" as BluetoothUtils import qs.Commons import qs.Services.System import qs.Services.UI Singleton { id: root // Constants (centralized tunables) readonly property int ctlPollMs: 10000 readonly property int ctlPollSoonMs: 250 readonly property BluetoothAdapter adapter: Bluetooth.defaultAdapter // Airplane mode status readonly property bool airplaneModeEnabled: Settings.data.network.airplaneModeEnabled property bool airplaneModeToggled: false // Power/blocked/availability state property bool ctlAvailable: false readonly property bool bluetoothAvailable: !!adapter || root.ctlAvailable readonly property bool enabled: adapter?.enabled ?? root.ctlPowered property bool ctlPowered: false property bool ctlPowerBlocked: false property bool ctlDiscovering: false property bool ctlDiscoverable: false // Exposed scanning flag for UI button state; reflects adapter discovery when available readonly property bool scanningActive: adapter?.discovering ?? root.ctlDiscovering // Adapter discoverability (advertising) flag readonly property bool discoverable: adapter?.discoverable ?? root.ctlDiscoverable readonly property var devices: adapter ? adapter.devices : null readonly property var connectedDevices: { if (!adapter || !adapter.devices) { return []; } return adapter.devices.values.filter(dev => dev && dev.connected); } // Current backend in use for scanning (e.g., "native", "bluetoothctl") property string backendUsed: "" // Experimental: best‑effort RSSI polling for connected devices (without root) // Enabled in debug mode or via user setting in Settings > Network property bool rssiPollingEnabled: Settings?.data?.network?.bluetoothRssiPollingEnabled || Settings?.isDebug || false // Interval can be configured from Settings; defaults to 60s property int rssiPollIntervalMs: Settings?.data?.network?.bluetoothRssiPollIntervalMs || 60000 // RSSI helper sub‑component property BluetoothRssi rssi: BluetoothRssi { enabled: root.enabled && root.rssiPollingEnabled intervalMs: root.rssiPollIntervalMs connectedDevices: root.connectedDevices } // Tunables for CLI pairing/connect flow property int pairWaitSeconds: 45 property int connectAttempts: 5 property int connectRetryIntervalMs: 2000 // Internal variables property bool _discoveryWasRunning: false property bool _ctlInit: false property var _autoConnectQueue: [] // Persistent cache for per-device auto-connect toggle property string cacheFile: Settings.cacheDir + "bluetooth_devices.json" FileView { id: cacheFileView path: root.cacheFile printErrors: false JsonAdapter { id: cacheAdapter property var autoConnectSettings: ({}) } } function getDeviceAutoConnect(mac) { if (!mac || !cacheAdapter.autoConnectSettings) { return false; } const settings = cacheAdapter.autoConnectSettings[mac]; return settings ? !!settings.autoConnect : false; } function setDeviceAutoConnect(device, enabled) { if (!device || !device.address) { return; } const mac = device.address; let settings = cacheAdapter.autoConnectSettings || ({}); if (enabled) { settings[mac] = { autoConnect: true, deviceName: device.name || device.deviceName || "" }; } else { delete settings[mac]; } cacheAdapter.autoConnectSettings = settings; cacheFileView.writeAdapter(); } Connections { target: Settings.data.network function onBluetoothAutoConnectChanged() { if (Settings.data.network.bluetoothAutoConnect && adapter && adapter.enabled) { autoConnectTimer.restart(); } else { autoConnectTimer.stop(); } } } Timer { id: autoConnectTimer interval: 1500 repeat: false onTriggered: root.attemptAutoConnect() } Timer { id: autoConnectStepTimer interval: 500 repeat: false onTriggered: { var device = root._autoConnectQueue.shift(); if (device && device.paired && !device.connected && !device.blocked) { Logger.i("Bluetooth", "Auto-connecting to:", device.name || device.deviceName); connectDeviceWithTrust(device); } if (root._autoConnectQueue.length > 0) { autoConnectStepTimer.restart(); } } } function init() { Logger.i("Bluetooth", "Service started"); } Component.onCompleted: { pollCtlState(); // Ensure Airplane Mode persists upon reboot if (root.airplaneModeEnabled) { Quickshell.execDetached(["rfkill", "block", "wifi"]); Quickshell.execDetached(["rfkill", "block", "bluetooth"]); } // Auto-connect on startup autoConnectTimer.restart(); } // Handle system wakeup to force-poll and ensure state is up-to-date Connections { target: Time function onResumed() { Logger.i("Bluetooth", "System resumed - forcing state poll"); requestCtlPoll(); } } // Track adapter state changes Connections { target: adapter function onStateChanged() { if (!adapter || adapter.state === BluetoothAdapter.Enabling || adapter.state === BluetoothAdapter.Disabling) { return; } checkAirplaneMode.running = true; } function onEnabledChanged() { if (adapter && adapter.enabled && Settings.data.network.bluetoothAutoConnect) { autoConnectTimer.restart(); } } } onAdapterChanged: { pollCtlState(); if (!adapter) { ctlPollTimer.interval = 2000; } } // Re-run polling once bluetoothctl availability is known Connections { target: ProgramCheckerService function onBluetoothctlAvailableChanged() { if (!adapter && ProgramCheckerService.bluetoothctlAvailable) { requestCtlPoll(0); } } } function setAirplaneMode(state) { if (state) { Quickshell.execDetached(["rfkill", "block", "wifi"]); Quickshell.execDetached(["rfkill", "block", "bluetooth"]); } else { Quickshell.execDetached(["rfkill", "unblock", "wifi"]); Quickshell.execDetached(["rfkill", "unblock", "bluetooth"]); } if (!adapter) { root.ctlPowered = !state; root.ctlPowerBlocked = state; root.airplaneModeToggled = true; NetworkService.setWifiEnabled(!state); Settings.data.network.airplaneModeEnabled = state; ToastService.showNotice(I18n.tr("toast.airplane-mode.title"), state ? I18n.tr("common.enabled") : I18n.tr("common.disabled"), state ? "plane" : "plane-off"); Logger.i("AirplaneMode", state ? "Wi-Fi & Bluetooth adapter blocked" : "Wi-Fi & Bluetooth adapter unblocked"); root.airplaneModeToggled = false; } } Process { id: checkAirplaneMode running: false command: ["rfkill", "list"] stdout: StdioCollector { onStreamFinished: { var output = this.text || ""; var wifiBlocked = /^\d+:.*Wireless LAN[^\n]*\n\s*Soft blocked:\s*yes/im.test(output); var btBlocked = /^\d+:.*Bluetooth[^\n]*\n\s*Soft blocked:\s*yes/im.test(output); var isAirplaneModeActive = wifiBlocked && btBlocked; // Check if airplane mode has been toggled if (isAirplaneModeActive && !root.airplaneModeEnabled) { root.airplaneModeToggled = true; NetworkService.setWifiEnabled(false); Settings.data.network.airplaneModeEnabled = true; ToastService.showNotice(I18n.tr("toast.airplane-mode.title"), I18n.tr("common.enabled"), "plane"); Logger.i("AirplaneMode", "Wi-Fi & Bluetooth adapter blocked"); } else if (!isAirplaneModeActive && root.airplaneModeEnabled) { root.airplaneModeToggled = true; NetworkService.setWifiEnabled(true); Settings.data.network.airplaneModeEnabled = false; ToastService.showNotice(I18n.tr("toast.airplane-mode.title"), I18n.tr("common.disabled"), "plane-off"); Logger.i("AirplaneMode", "Wi-Fi & Bluetooth adapter unblocked"); } else if (adapter ? adapter.enabled : root.ctlPowered) { ToastService.showNotice(I18n.tr("common.bluetooth"), I18n.tr("common.enabled"), "bluetooth"); Logger.d("Bluetooth", "Adapter enabled"); } else { ToastService.showNotice(I18n.tr("common.bluetooth"), I18n.tr("common.disabled"), "bluetooth-off"); Logger.d("Bluetooth", "Adapter disabled"); } root.airplaneModeToggled = false; } } stderr: StdioCollector { onStreamFinished: { if (text && text.trim()) { Logger.w("AirplaneMode", "rfkill stderr:", text.trim()); } } } } // Periodic state polling readonly property bool _lockScreenActive: PanelService.lockScreen?.active ?? false Timer { id: ctlPollTimer interval: adapter ? ctlPollMs : 2000 repeat: true running: (adapter || ProgramCheckerService.bluetoothctlAvailable) && !_lockScreenActive onTriggered: { pollCtlState(); var targetInterval = adapter ? ctlPollMs : 2000; if (interval !== targetInterval) { interval = targetInterval; } } } function requestCtlPoll(delayMs) { if (!adapter && !ProgramCheckerService.bluetoothctlAvailable) { return; } ctlPollTimer.interval = Math.max(50, delayMs || ctlPollSoonMs); ctlPollTimer.restart(); } function pollCtlState() { if (!adapter || !ProgramCheckerService.bluetoothctlAvailable) { return; } if (ctlShowProcess.running) { return; } try { ctlShowProcess.running = true; } catch (_) {} } // bluetoothctl state polling Process { id: ctlShowProcess command: ["bluetoothctl", "show"] running: false stdout: StdioCollector { id: ctlStdout } onExited: function (exitCode, exitStatus) { try { var text = ctlStdout.text || ""; var lines = text.split('\n'); var foundController = false; var powered = false; var powerBlocked = false; var discoverable = false; var discovering = false; for (var i = 0; i < lines.length; i++) { var line = lines[i].trim(); if (line.indexOf("Controller") === 0) { foundController = true; } var mp = line.match(/\bPowered:\s*(yes|no)\b/i); if (mp) { powered = (mp[1].toLowerCase() === "yes"); } var mps = line.match(/\bPowerState:\s*([A-Za-z-]+)\b/i); if (mps) { powerBlocked = (mps[1].toLowerCase() === "off-blocked"); } var md = line.match(/\bDiscoverable:\s*(yes|no)\b/i); if (md) { discoverable = (md[1].toLowerCase() === "yes"); } var ms = line.match(/\bDiscovering:\s*(yes|no)\b/i); if (ms) { discovering = (ms[1].toLowerCase() === "yes"); } } if (!adapter && (root.ctlPowered !== powered || root.ctlPowerBlocked !== powerBlocked)) { root.ctlPowered = powered; root.ctlPowerBlocked = powerBlocked; if (root._ctlInit) { checkAirplaneMode.running = true; } root._ctlInit = true; } root.ctlAvailable = foundController; root.ctlPowered = powered; root.ctlPowerBlocked = powerBlocked; root.ctlDiscoverable = discoverable; root.ctlDiscovering = discovering; } catch (e) { Logger.d("Bluetooth", "Failed to parse bluetoothctl show output", e); } } } // Persistent process for bluetoothctl scanning when native discovery is unavailable Process { id: bluetoothctlScanProcess command: ["bluetoothctl", "scan", "on"] onExited: Logger.d("Bluetooth", "bluetoothctl scan process exited.") } // Unify discovery controls function setScanActive(active) { if (!adapter && !ProgramCheckerService.bluetoothctlAvailable) { Logger.d("Bluetooth", "Scan request ignored: bluetoothctl unavailable"); return; } var nativeSuccess = false; try { if (adapter && adapter.discovering !== undefined) { if (active || adapter.discovering) { // Only attempt to set if activating, or if deactivating and currently currently discovering adapter.discovering = active; } nativeSuccess = true; // Mark as success if adapter was handled without error } } catch (e) { Logger.e("Bluetooth", "setScanActive native failed", e); } if (!nativeSuccess) { if (active) { bluetoothctlScanProcess.running = true; } else { bluetoothctlScanProcess.running = false; btExec(["bluetoothctl", "scan", "off"]); } } else if (bluetoothctlScanProcess.running) { bluetoothctlScanProcess.running = false; } requestCtlPoll(ctlPollSoonMs); } // Adapter power (enable/disable) via bluetoothctl function setBluetoothEnabled(state) { Logger.i("Bluetooth", "SetBluetoothEnabled", state); if (!adapter && !ProgramCheckerService.bluetoothctlAvailable) { Logger.i("Bluetooth", "Enable/Disable skipped: no adapter or bluetoothctl"); return; } try { if (adapter) { adapter.enabled = state; } else { root.ctlPowered = state; btExec(["bluetoothctl", "power", state ? "on" : "off"]); ToastService.showNotice(I18n.tr("common.bluetooth"), state ? I18n.tr("common.enabled") : I18n.tr("common.disabled"), state ? "bluetooth" : "bluetooth-off"); Logger.d("Bluetooth", state ? "Adapter enabled" : "Adapter disabled"); } } catch (e) { Logger.w("Bluetooth", "Enable/Disable failed", e); ToastService.showWarning(I18n.tr("common.bluetooth"), I18n.tr("toast.bluetooth.state-change-failed")); } } // Toggle adapter discoverability (advertising visibility) via bluetoothctl function setDiscoverable(state) { if (!adapter && !ProgramCheckerService.bluetoothctlAvailable) { Logger.d("Bluetooth", "Discoverable change skipped: no adapter or bluetoothctl"); return; } try { if (adapter) { adapter.discoverable = state; } else { btExec(["bluetoothctl", "discoverable", state ? "on" : "off"]); root.ctlDiscoverable = state; // optimistic requestCtlPoll(ctlPollSoonMs); } Logger.i("Bluetooth", "Discoverable state set to:", state); } catch (e) { Logger.w("Bluetooth", "Failed to change discoverable state", e); ToastService.showWarning(I18n.tr("common.bluetooth"), I18n.tr("toast.bluetooth.discoverable-change-failed")); } } function sortDevices(devices) { return devices.sort(function (a, b) { var aName = a.name || a.deviceName || ""; var bName = b.name || b.deviceName || ""; var aHasRealName = aName.indexOf(" ") !== -1 && aName.length > 3; var bHasRealName = bName.indexOf(" ") !== -1 && bName.length > 3; if (aHasRealName && !bHasRealName) { return -1; } if (!aHasRealName && bHasRealName) { return 1; } var aSignal = (a.signalStrength !== undefined && a.signalStrength > 0) ? a.signalStrength : 0; var bSignal = (b.signalStrength !== undefined && b.signalStrength > 0) ? b.signalStrength : 0; return bSignal - aSignal; }); } function getDeviceIcon(device) { if (!device) { return "bt-device-generic"; } return BluetoothUtils.deviceIcon(device.name || device.deviceName, device.icon); } function canConnect(device) { if (!device) { return false; } return !device.connected && (device.paired || device.trusted) && !device.pairing && !device.blocked; } function canDisconnect(device) { if (!device) { return false; } return device.connected && !device.pairing && !device.blocked; } // Textual signal quality (translated) function getSignalStrength(device) { 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"); } // 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) { var p = getSignalPercent(device); return BluetoothUtils.signalIcon(p); } function isDeviceBusy(device) { if (!device) { return false; } return device.pairing || device.state === BluetoothDevice.Disconnecting || device.state === BluetoothDevice.Connecting; } // Return a stable unique key for a device (prefer MAC address) function deviceKey(device) { return BluetoothUtils.deviceKey(device); } // Deduplicate a list of devices using the stable key function dedupeDevices(devList) { return BluetoothUtils.dedupeDevices(devList); } // Separate capability helpers function canPair(device) { if (!device) { return false; } return !device.connected && !device.paired && !device.trusted && !device.pairing && !device.blocked; } // Pairing and unpairing helpers function pairDevice(device) { if (!device) { return; } ToastService.showNotice(I18n.tr("common.bluetooth"), I18n.tr("common.pairing"), "bluetooth"); try { pairWithBluetoothctl(device); } catch (e) { Logger.w("Bluetooth", "pairDevice failed", e); ToastService.showWarning(I18n.tr("common.bluetooth"), I18n.tr("toast.bluetooth.pair-failed")); } } // Interaction state property bool pinRequired: false function submitPin(pin) { if (pairingProcess.running) { pairingProcess.write(pin + "\n"); root.pinRequired = false; } } function cancelPairing() { if (pairingProcess.running) { pairingProcess.running = false; } root.pinRequired = false; } // Interactive pairing process Process { id: pairingProcess stdout: SplitParser { onRead: data => { Logger.d("Bluetooth", data); if (data.indexOf("PIN_REQUIRED") !== -1) { root.pinRequired = true; Logger.i("Bluetooth", "PIN required for pairing"); } } } onExited: { root.pinRequired = false; Logger.i("Bluetooth", "Pairing process exited."); // Restore discovery if we paused it if (root._discoveryWasRunning) { root.setScanActive(true); } root._discoveryWasRunning = false; root.requestCtlPoll(); } environment: ({ "LC_ALL": "C" }) } // Pair using bluetoothctl which registers its own BlueZ agent internally. function pairWithBluetoothctl(device) { if (!device) { return; } 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); if (pairingProcess.running) { pairingProcess.running = false; } root.pinRequired = false; 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)); // Temporarily pause discovery during pair/connect to reduce HCI churn root._discoveryWasRunning = root.scanningActive; if (root.scanningActive) { root.setScanActive(false); } const scriptPath = Quickshell.shellDir + "/Scripts/python/src/network/bluetooth-pair.py"; pairingProcess.command = ["python3", scriptPath, String(addr), String(pairWait), String(attempts), String(intervalSec)]; pairingProcess.running = true; } // Helper to run bluetoothctl and scripts with consistent error logging function btExec(args) { try { Quickshell.execDetached(args); } catch (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 === BluetoothDevice.Connecting) return "connecting"; if (device.state === BluetoothDevice.Disconnecting) return "disconnecting"; } catch (_) {} return ""; } function unpairDevice(device) { forgetDevice(device); } function attemptAutoConnect() { if (airplaneModeEnabled || !adapter || !adapter.enabled || !Settings.data.network.bluetoothAutoConnect) { return; } _autoConnectQueue = adapter.devices.values.filter(dev => dev && dev.paired && !dev.connected && !dev.blocked && getDeviceAutoConnect(dev.address) === true); if (root._autoConnectQueue.length > 0) { autoConnectStepTimer.restart(); } } function connectDeviceWithTrust(device) { if (!device) { return; } try { device.trusted = true; device.connect(); } catch (e) { Logger.w("Bluetooth", "connectDeviceWithTrust failed", e); ToastService.showWarning(I18n.tr("common.bluetooth"), I18n.tr("toast.bluetooth.connect-failed")); } } function disconnectDevice(device) { if (!device) { return; } try { device.disconnect(); } catch (e) { Logger.w("Bluetooth", "disconnectDevice failed", e); ToastService.showWarning(I18n.tr("common.bluetooth"), I18n.tr("toast.bluetooth.disconnect-failed")); } } function forgetDevice(device) { if (!device) { return; } try { device.trusted = false; device.forget(); } catch (e) { Logger.w("Bluetooth", "forgetDevice failed", e); ToastService.showWarning(I18n.tr("common.bluetooth"), I18n.tr("toast.bluetooth.forget-failed")); } } }