Skip to content

add BluezQt provider for Bluetooth device battery - #40

Open
birrkan wants to merge 1 commit into
itayavra:masterfrom
birrkan:feature/bluez-battery-provider
Open

birrkan wants to merge 1 commit into
itayavra:masterfrom
birrkan:feature/bluez-battery-provider

Conversation

@birrkan

@birrkan birrkan commented Jul 29, 2026

Copy link
Copy Markdown

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:

  • New file: contents/ui/providers/BluezProvider.qml
  • Case-insensitive serial dedup in mergeDevices():
    • 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
  • Prefers non-zero percentage when two providers report the same
    device (BlueZ data replaces UPower's 0% entries)

before:
Screenshot_20260729_203613

after :
Screenshot_20260729_212052

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.qml using BluezQt.Manager to expose connected Bluetooth devices with battery percentages and a disconnect action.
  • Integrated the Bluez provider into main.qml and enhanced mergeDevices() 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 devices to 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 call updateDevices() even when BlueZ integration is disabled. Tie running to bluezEnabled and consider triggeredOnStart: true so 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.

Comment on lines +65 to +69
function updateDevices() {
if (!btManager.operational) {
devices = []
return
}
Comment on lines +98 to +102
function makeDisconnect(address) {
return function() {
disconnectSource.connectSource("bluetoothctl disconnect " + address)
}
}
Comment thread contents/ui/main.qml
Comment on lines +132 to +135
// 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();
Comment on lines +128 to +130
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() {

@itayavra itayavra Jul 31, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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's refreshDevices() calls refresh() → updateDevices() directly)
  • :180 — running: truerunning: 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.

Comment thread contents/ui/main.qml
// "aa:bb:cc:..." from UPowerProvider, which causes a dublicate device, therefore avoiding dublication
var id = (device.serial || device.objectPath || "").toLowerCase();

if (id) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

consider flipping the if's + early returns, i think it makes the code easier to follow

Comment thread contents/ui/main.qml
merged.push(device);
seenIds[id] = merged.length - 1;
} else {
var existingDevice = merged[existing];

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread contents/ui/main.qml
// 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();

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor but disconnectSource id might shadow the disconnectSource function

@@ -0,0 +1,184 @@
import QtQuick 2.15
import org.kde.bluezqt as BluezQt

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm doing something similar in the upower provider too

@itayavra

Copy link
Copy Markdown
Owner

@birrkan thank you so much for this! i really appreciate the contribution!

@itayavra

Copy link
Copy Markdown
Owner

@birrkan Hey, it's been a while and I would love to push this branch. Will you have time to address the comments?
If you're busy or stuck, happy to take over the fixes and push to your branch

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants