extract BluetoothService singleton from BluetoothModule and BluetoothMenu

This commit is contained in:
Damocles 2026-04-18 10:38:35 +02:00
parent d646d9b0fe
commit de35cf016c
4 changed files with 142 additions and 127 deletions

View file

@ -1,6 +1,5 @@
import QtQuick import QtQuick
import Quickshell import Quickshell
import Quickshell.Io
import "." as M import "." as M
import "../services" as S import "../services" as S
@ -19,7 +18,7 @@ M.HoverPanel {
Text { Text {
anchors.centerIn: parent anchors.centerIn: parent
text: "\uF011" text: "\uF011"
color: menuWindow._btEnabled ? menuWindow.accentColor : S.Theme.base04 color: S.BluetoothService.enabled ? menuWindow.accentColor : S.Theme.base04
font.pixelSize: S.Theme.fontSize font.pixelSize: S.Theme.fontSize
font.family: S.Theme.iconFontFamily font.family: S.Theme.iconFontFamily
@ -35,78 +34,16 @@ M.HoverPanel {
} }
TapHandler { TapHandler {
onTapped: { onTapped: S.BluetoothService.setPower(!S.BluetoothService.enabled)
powerProc._action = menuWindow._btEnabled ? "off" : "on";
powerProc.running = true;
}
} }
} }
} }
onVisibleChanged: if (visible) onVisibleChanged: if (visible)
scanner.running = true S.BluetoothService.refresh()
property var _devices: []
property bool _btEnabled: true
property Process _scanner: Process {
id: scanner
running: false
command: ["sh", "-c", "bluetoothctl show 2>/dev/null | awk '/Powered:/{print $2; exit}';" + "echo '---DEVICES---';" + "bluetoothctl devices Paired 2>/dev/null | while read -r _ mac name; do " + "info=$(bluetoothctl info \"$mac\" 2>/dev/null); " + "conn=$(echo \"$info\" | grep -c 'Connected: yes'); " + "bat=$(echo \"$info\" | awk -F'[(): ]' '/Battery Percentage/{for(i=1;i<=NF;i++) if($i+0==$i && $i!=\"\") print $i}'); " + "echo \"$mac:$conn:${bat:-}:$name\"; " + "done"]
stdout: StdioCollector {
onStreamFinished: {
const sections = text.split("---DEVICES---");
menuWindow._btEnabled = (sections[0] || "").trim() === "yes";
const devs = [];
for (const line of (sections[1] || "").trim().split("\n")) {
if (!line)
continue;
const i1 = line.indexOf(":");
const i2 = line.indexOf(":", i1 + 1);
const i3 = line.indexOf(":", i2 + 1);
if (i3 < 0)
continue;
devs.push({
"mac": line.slice(0, i1),
"connected": line.slice(i1 + 1, i2) === "1",
"battery": parseInt(line.slice(i2 + 1, i3)) || -1,
"name": line.slice(i3 + 1)
});
}
devs.sort((a, b) => {
if (a.connected !== b.connected)
return a.connected ? -1 : 1;
return a.name.localeCompare(b.name);
});
menuWindow._devices = devs;
}
}
}
property Process _powerProc: Process {
id: powerProc
property string _action: ""
command: ["bluetoothctl", "power", _action]
onRunningChanged: if (!running) {
scanner.running = true;
menuWindow.keepOpen(500);
}
}
property Process _toggleProc: Process {
id: toggleProc
property string action: ""
property string mac: ""
command: ["bluetoothctl", action, mac]
onRunningChanged: if (!running) {
scanner.running = true;
menuWindow.keepOpen(500);
}
}
Repeater { Repeater {
model: menuWindow._devices model: S.BluetoothService.devices
delegate: Item { delegate: Item {
id: entry id: entry
@ -167,21 +104,20 @@ M.HoverPanel {
} }
TapHandler { TapHandler {
onTapped: { onTapped: {
toggleProc.action = entry.modelData.connected ? "disconnect" : "connect"; S.BluetoothService.toggleDevice(entry.modelData.mac, !entry.modelData.connected);
toggleProc.mac = entry.modelData.mac; menuWindow.keepOpen(500);
toggleProc.running = true;
} }
} }
} }
} }
Text { Text {
visible: menuWindow._devices.length === 0 visible: S.BluetoothService.devices.length === 0
width: menuWindow.contentWidth width: menuWindow.contentWidth
height: 32 height: 32
horizontalAlignment: Text.AlignHCenter horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter verticalAlignment: Text.AlignVCenter
text: menuWindow._btEnabled ? "No paired devices" : "Bluetooth is off" text: S.BluetoothService.enabled ? "No paired devices" : "Bluetooth is off"
color: S.Theme.base04 color: S.Theme.base04
font.pixelSize: S.Theme.fontSize font.pixelSize: S.Theme.fontSize
font.family: S.Theme.fontFamily font.family: S.Theme.fontFamily

View file

@ -1,72 +1,24 @@
import QtQuick import QtQuick
import Quickshell import Quickshell
import Quickshell.Io
import "." as M import "." as M
import "../services" as S import "../services" as S
M.BarSection { M.BarSection {
id: root id: root
spacing: S.Theme.moduleSpacing spacing: S.Theme.moduleSpacing
opacity: S.Modules.bluetooth.enable && root.state !== "unavailable" ? 1 : 0 opacity: S.Modules.bluetooth.enable && S.BluetoothService.state !== "unavailable" ? 1 : 0
visible: opacity > 0 visible: opacity > 0
tooltip: { tooltip: {
if (root.state === "off") if (S.BluetoothService.state === "off")
return "Bluetooth: off"; return "Bluetooth: off";
if (root.state === "connected") if (S.BluetoothService.state === "connected")
return "Bluetooth: " + root.device + (root.batteryPct >= 0 ? "\nBattery: " + root.batteryPct + "%" : ""); return "Bluetooth: " + S.BluetoothService.device + (S.BluetoothService.batteryPct >= 0 ? "\nBattery: " + S.BluetoothService.batteryPct + "%" : "");
return "Bluetooth: on"; return "Bluetooth: on";
} }
property string state: "unavailable"
property string device: ""
property int batteryPct: -1
function _parse(text) {
const lines = text.trim().split("\n");
const t = lines[0] || "";
const sep = t.indexOf(":");
root.state = sep === -1 ? t : t.slice(0, sep);
root.device = sep === -1 ? "" : t.slice(sep + 1);
root.batteryPct = -1;
for (let i = 1; i < lines.length; i++) {
if (lines[i].startsWith("bat:"))
root.batteryPct = parseInt(lines[i].slice(4)) || -1;
}
}
Process {
id: proc
running: S.Modules.bluetooth.enable
command: ["sh", "-c", "s=$(bluetoothctl show 2>/dev/null); " + "[ -z \"$s\" ] && echo unavailable && exit; " + "echo \"$s\" | grep -q 'Powered: yes' || { echo off:; exit; }; " + "info=$(bluetoothctl info 2>/dev/null); " + "d=$(echo \"$info\" | awk -F': ' '/\\tName:/{n=$2}/Connected: yes/{c=1}END{if(c)print n}'); " + "[ -n \"$d\" ] && echo \"connected:$d\" || { echo on:; exit; }; " + "bat=$(echo \"$info\" | awk -F': ' '/Battery Percentage.*\\(/{gsub(/[^0-9]/,\"\",$2);print $2}'); " + "[ -n \"$bat\" ] && echo \"bat:$bat\""]
stdout: StdioCollector {
onStreamFinished: root._parse(text)
}
}
// Event-driven: watch BlueZ DBus property changes
Process {
id: btMonitor
running: S.Modules.bluetooth.enable
command: ["sh", "-c", "dbus-monitor --system \"interface='org.freedesktop.DBus.Properties',member='PropertiesChanged',path_namespace='/org/bluez'\" 2>/dev/null"]
stdout: SplitParser {
splitMarker: "\n"
onRead: _debounce.restart()
}
}
Timer {
id: _debounce
interval: 500
onTriggered: proc.running = true
}
Timer {
interval: 60000
running: S.Modules.bluetooth.enable
repeat: true
onTriggered: proc.running = true
}
M.BarIcon { M.BarIcon {
icon: "\uF294" icon: "\uF294"
color: root.state === "off" ? S.Theme.base04 : root.accentColor color: S.BluetoothService.state === "off" ? S.Theme.base04 : root.accentColor
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
TapHandler { TapHandler {
onTapped: { onTapped: {
@ -76,8 +28,8 @@ M.BarSection {
} }
} }
M.BarLabel { M.BarLabel {
visible: root.state === "connected" visible: S.BluetoothService.state === "connected"
label: root.device + (root.batteryPct >= 0 ? " " + root.batteryPct + "%" : "") label: S.BluetoothService.device + (S.BluetoothService.batteryPct >= 0 ? " " + S.BluetoothService.batteryPct + "%" : "")
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
TapHandler { TapHandler {
onTapped: { onTapped: {

View file

@ -0,0 +1,126 @@
pragma Singleton
import QtQuick
import Quickshell.Io
import "." as S
QtObject {
id: root
// Adapter state: "unavailable" | "off" | "on" | "connected"
property string state: "unavailable"
property string device: ""
property int batteryPct: -1
// Paired device list and power state (for menu)
property var devices: []
property bool enabled: true
function refresh() {
_statusProc.running = true;
_scannerProc.running = true;
}
function setPower(on) {
_powerProc._action = on ? "on" : "off";
_powerProc.running = true;
}
function toggleDevice(mac, connect) {
_toggleProc._action = connect ? "connect" : "disconnect";
_toggleProc._mac = mac;
_toggleProc.running = true;
}
// Status polling (bar icon state)
property Process _statusProc: Process {
running: S.Modules.bluetooth.enable
command: ["sh", "-c", "s=$(bluetoothctl show 2>/dev/null); " + "[ -z \"$s\" ] && echo unavailable && exit; " + "echo \"$s\" | grep -q 'Powered: yes' || { echo off:; exit; }; " + "info=$(bluetoothctl info 2>/dev/null); " + "d=$(echo \"$info\" | awk -F': ' '/\\tName:/{n=$2}/Connected: yes/{c=1}END{if(c)print n}'); " + "[ -n \"$d\" ] && echo \"connected:$d\" || { echo on:; exit; }; " + "bat=$(echo \"$info\" | awk -F': ' '/Battery Percentage.*\\(/{gsub(/[^0-9]/,\"\",$2);print $2}'); " + "[ -n \"$bat\" ] && echo \"bat:$bat\""]
stdout: StdioCollector {
onStreamFinished: {
const lines = text.trim().split("\n");
const t = lines[0] || "";
const sep = t.indexOf(":");
root.state = sep === -1 ? t : t.slice(0, sep);
root.device = sep === -1 ? "" : t.slice(sep + 1);
root.batteryPct = -1;
for (let i = 1; i < lines.length; i++) {
if (lines[i].startsWith("bat:"))
root.batteryPct = parseInt(lines[i].slice(4)) || -1;
}
}
}
}
// Event-driven: watch BlueZ DBus property changes
property Process _monitor: Process {
running: S.Modules.bluetooth.enable
command: ["sh", "-c", "dbus-monitor --system \"interface='org.freedesktop.DBus.Properties',member='PropertiesChanged',path_namespace='/org/bluez'\" 2>/dev/null"]
stdout: SplitParser {
splitMarker: "\n"
onRead: _debounce.restart()
}
}
property Timer _debounce: Timer {
interval: 500
onTriggered: root.refresh()
}
property Timer _fallbackPoll: Timer {
interval: 60000
running: S.Modules.bluetooth.enable
repeat: true
onTriggered: root.refresh()
}
// Paired device scanner (for menu)
property Process _scannerProc: Process {
command: ["sh", "-c", "bluetoothctl show 2>/dev/null | awk '/Powered:/{print $2; exit}';" + "echo '---DEVICES---';" + "bluetoothctl devices Paired 2>/dev/null | while read -r _ mac name; do " + "info=$(bluetoothctl info \"$mac\" 2>/dev/null); " + "conn=$(echo \"$info\" | grep -c 'Connected: yes'); " + "bat=$(echo \"$info\" | awk -F'[(): ]' '/Battery Percentage/{for(i=1;i<=NF;i++) if($i+0==$i && $i!=\"\") print $i}'); " + "echo \"$mac:$conn:${bat:-}:$name\"; " + "done"]
stdout: StdioCollector {
onStreamFinished: {
const sections = text.split("---DEVICES---");
root.enabled = (sections[0] || "").trim() === "yes";
const devs = [];
for (const line of (sections[1] || "").trim().split("\n")) {
if (!line)
continue;
const i1 = line.indexOf(":");
const i2 = line.indexOf(":", i1 + 1);
const i3 = line.indexOf(":", i2 + 1);
if (i3 < 0)
continue;
devs.push({
mac: line.slice(0, i1),
connected: line.slice(i1 + 1, i2) === "1",
battery: parseInt(line.slice(i2 + 1, i3)) || -1,
name: line.slice(i3 + 1)
});
}
devs.sort((a, b) => {
if (a.connected !== b.connected)
return a.connected ? -1 : 1;
return a.name.localeCompare(b.name);
});
root.devices = devs;
}
}
}
// Action processes
property Process _powerProc: Process {
property string _action: ""
command: ["bluetoothctl", "power", _action]
onRunningChanged: if (!running)
root.refresh()
}
property Process _toggleProc: Process {
property string _action: ""
property string _mac: ""
command: ["bluetoothctl", _action, _mac]
onRunningChanged: if (!running)
root.refresh()
}
}

View file

@ -10,3 +10,4 @@ singleton LockService 1.0 LockService.qml
singleton BacklightService 1.0 BacklightService.qml singleton BacklightService 1.0 BacklightService.qml
singleton MprisService 1.0 MprisService.qml singleton MprisService 1.0 MprisService.qml
singleton NetworkService 1.0 NetworkService.qml singleton NetworkService 1.0 NetworkService.qml
singleton BluetoothService 1.0 BluetoothService.qml