Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds a new Bluetooth-specific battery provider that reads GATT Battery Service percentages directly from BlueZ via BluezQt, then integrates it into the existing multi-provider device merge pipeline so Bluetooth devices that show 0% via UPower can display correct battery levels.
Changes:
- Added
BluezProvider.qmlusingBluezQt.Managerto expose connected Bluetooth devices with battery percentages and a disconnect action. - Integrated the Bluez provider into
main.qmland enhancedmergeDevices()to deduplicate case-insensitively and prefer non-zero battery readings. - Added BlueZ integration configuration (enable toggle + polling interval) to the config UI and KConfig schema.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| contents/ui/providers/BluezProvider.qml | New provider that reads Bluetooth battery % directly from BlueZ/BluezQt and exposes devices to the merge pipeline. |
| contents/ui/main.qml | Adds Bluez provider to the provider list and adjusts merge logic for case-insensitive dedup + non-zero preference. |
| contents/ui/config/Modules.qml | Adds UI controls for enabling BlueZ integration and configuring polling interval. |
| contents/config/main.xml | Adds KConfig entries for BlueZ enablement and polling interval defaults. |
Suppressed comments (2)
contents/ui/providers/BluezProvider.qml:169
- The BlueZ event Connections stay active even when BlueZ integration is disabled, which can cause
devicesto be repopulated and extra work to be done. Gate the Connections on the same config toggle.
Connections {
target: btManager
function onDeviceAdded() { updateDevices() }
contents/ui/providers/BluezProvider.qml:182
- The polling Timer runs unconditionally (
running: true), so it will continue to callupdateDevices()even when BlueZ integration is disabled. TierunningtobluezEnabledand considertriggeredOnStart: trueso devices populate immediately when enabled.
Timer {
interval: Plasmoid.configuration.bluezPollingTime * 1000
running: true
repeat: true
onTriggered: updateDevices()
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| function updateDevices() { | ||
| if (!btManager.operational) { | ||
| devices = [] | ||
| return | ||
| } |
| function makeDisconnect(address) { | ||
| return function() { | ||
| disconnectSource.connectSource("bluetoothctl disconnect " + address) | ||
| } | ||
| } |
| // Normalise to lowercase for case-insensitive dedup | ||
| // the same device's Bluetooth MAC address can appear as "AA:BB:CC:..." from BluezProvider and | ||
| // "aa:bb:cc:..." from UPowerProvider, which causes a dublicate device, therefore avoiding dublication | ||
| var id = (device.serial || device.objectPath || "").toLowerCase(); |
| Item { | ||
| Kirigami.FormData.isSection: true | ||
| Kirigami.FormData.label: i18n("Bluez Integration") |
| // battery percentage >= 0, and build a device object for each. | ||
| // The device object shape matches what main.qml's mergeDevices() expects | ||
| // (see: main.qml -> fullRepresentation -> device properties used). | ||
| function updateDevices() { |
There was a problem hiding this comment.
The enable toggle doesn't actually work. updateDevices() has no bluezEnabled guard, the Timer runs unconditionally (running: true), and the Connections block keeps firing on BlueZ signals even when the integration is disabled. So within ~5 seconds of unchecking the box, or immediately on the next BlueZ signal onBluezEnabledChanged clears devices and it just gets repopulated again.
Fix in BluezProvider.qml:
- :65 — early return at the top of updateDevices():
if (!bluezEnabled) { devices = []; return }
(also covers the manual refresh path, since main.qml'srefreshDevices()calls refresh() → updateDevices() directly) - :180 —
running: true→running: bluezEnabled - :168 —
Connections { enabled: bluezEnabled }to stop signal-driven repopulation entirely
While here, consider triggeredOnStart: true on the timer so devices populate immediately on load instead of after the first 5s tick.
Compare KDEConnectProvider.qml:228, which already ties running to its config flag and guards its handlers.
| // "aa:bb:cc:..." from UPowerProvider, which causes a dublicate device, therefore avoiding dublication | ||
| var id = (device.serial || device.objectPath || "").toLowerCase(); | ||
|
|
||
| if (id) { |
There was a problem hiding this comment.
consider flipping the if's + early returns, i think it makes the code easier to follow
| merged.push(device); | ||
| seenIds[id] = merged.length - 1; | ||
| } else { | ||
| var existingDevice = merged[existing]; |
There was a problem hiding this comment.
this might cause issues with multi-battery data (e.g L/R/Case)
i think the merging rule should be no battery data < single battery data < multi battery data
a quick idea i had was to add some batteryDataScore helper:
function batteryDataScore(device) {
if (device.batteries && device.batteries.length > 1) return 2;
if (device.percentage > 0) return 1;
return 0;
}which you can then use like:
if (batteryDataScore(device) > batteryDataScore(merged[existing])) {
merged[existing] = device;
}but all ideas are welcome
| // Normalise to lowercase for case-insensitive dedup | ||
| // the same device's Bluetooth MAC address can appear as "AA:BB:CC:..." from BluezProvider and | ||
| // "aa:bb:cc:..." from UPowerProvider, which causes a dublicate device, therefore avoiding dublication | ||
| var id = (device.serial || device.objectPath || "").toLowerCase(); |
There was a problem hiding this comment.
adding as claude flagged this too:
The hidden-devices list is matched by device.serial (case-sensitive indexOf in main.qml). UPower normalizes its extracted MAC to uppercase (UPowerProvider.qml:97). If mergeDevices replaces a UPower entry with a BluezProvider entry and the user had previously hidden that device (serial saved as e.g. AA:BB:CC:DD:EE:FF), the new entry's serial is whatever dev.address returns from BlueZ. If the cases differ, the indexOf match fails and the device silently reappears.
Easy fix: normalize dev.address to uppercase when building the device object, same as UPower does:
serial: dev.address.toUpperCase(),
bluetoothAddress: dev.address.toUpperCase(),| function onOperationalChanged() { updateDevices() } | ||
| } | ||
|
|
||
| // Fallback polling timer in case BlueZ events are missed. |
There was a problem hiding this comment.
missing the initial updateDevices() call on Component.onCompleted, so the list stays empty until the first timer tick (~5s) or the first BlueZ signal
consider adding
Component.onCompleted: updateDevices()| // Executable data source used to run bluetoothctl for disconnect. | ||
| // Same pattern as UPowerProvider.qml's btDisconnectSource. | ||
| P5Support.DataSource { | ||
| id: disconnectSource |
There was a problem hiding this comment.
minor but disconnectSource id might shadow the disconnectSource function
| @@ -0,0 +1,184 @@ | |||
| import QtQuick 2.15 | |||
| import org.kde.bluezqt as BluezQt | |||
There was a problem hiding this comment.
running the plasmoid on a system without org.kde.bluezqt available will cause the entire widget to crash, and it seems it is a possibility on some systems
i think it's better to not rely on it at all, and read BlueZ through bluetoothctl at runtime (you're already using it to disconnect), e.g:
bluetoothctl info <MAC>
should also give you the icon field, so getDeviceType can go away
There was a problem hiding this comment.
i'm doing something similar in the upower provider too
|
@birrkan thank you so much for this! i really appreciate the contribution! |
|
@birrkan Hey, it's been a while and I would love to push this branch. Will you have time to address the comments? |
Some Bluetooth devices (e.g. 8bitdo controllers) expose battery level through the Bluetooth GATT Battery Service but not through UPower. The system Bluetooth menu reads this data from BlueZ directly.
This merge adds a new BluezProvider that uses BluezQt.Manager to read battery percentages directly from BlueZ, bypassing UPower entirely. The provider is integrated into the existing multi-provider pipeline:
device (BlueZ data replaces UPower's 0% entries)
before:

after :
