maniacx / Bluetooth-Battery-Meter

A Gnome extension featuring indicator icons in system tray, serving as meter for Bluetooth device battery levels and providing detailed battery levels via icon/text in the Bluetooth quick settings menu.
https://extensions.gnome.org/extension/6670/bluetooth-battery-meter/
GNU General Public License v3.0
90 stars 11 forks source link

multiple battery levels #21

Open Bodyash opened 7 months ago

Bodyash commented 7 months ago

Hello

I have sony wf-c700n

On android phone it shows case charge level and also charge level for left and right headphone

Is it possible on linux?

maniacx commented 7 months ago

Current this extension getting values from Gnome Bluetooth which is reported by bluez it reports only a single battery. So currently it might not be possible.

However I will keep this issue open, right I do not know how get information for L/R/case, but if I find something I will update it here. If you know any other way for getting individual battery info for your sony device using scripts or commandline, let me know.

Bodyash commented 7 months ago

bluetoothctl reports only one battery level image

How it looks on android:

image

And whats interesting - bluetothctl reports 70%, while android reports 50% for case and 100% for L/R

maniacx commented 7 months ago

I also do not know how bluez report 70%. Seem like bluez is reporting average of left and right.

Also i think bluez interacts with pipewire for headphone battery information.

Correct way would be to get the minimum of left and right instead of average.

I think it is better to create an issue with bluez.

https://github.com/bluez/bluez/issues

  1. One request would be not to use average and to use minumum of left and right earbuds as it is critical to know if the one earbud battery is low and need to be charged.
  2. Second request would be to report Left, Right and or Case Battery info if possible.

But somebody needs to create a request for them to consider and start working on it.

As for extension to get this information directly would require directly communucation with RFCOMM, which I do not know if its possible, and even if it is possible would require lot of work. But if bluez manages to report Left Right and or Case information, than I can add the functionality to extension.

dennisorlando commented 7 months ago

Yikes, I was just about to create another extension to display multiple battery levels. There should be multiple "Battery Level" GATT characteristics available in bluetoothctl for a single device, thus the sequence should be:

bluetoothctl gatt.list-attributes | grep -B 2 "Battery Level"

then you copy paste the /org/bluez/* address that you got and:

bluetoothctl
menu gatt
select-attribute <address>
read

I made a script using bash and pydbus to do that automatically, I would love to get a feedback if it works. One issue is that I have no idea if you can somehow get a label for the batteries, i.e. which one is left and which one is right:

import subprocess

from pydbus import SystemBus

bash_script = """

#!/bin/bash

bluetoothctl gatt.list-attributes | grep -B 2 "Battery Level" --no-group-separator | grep "bluez" | while read line; do
    echo ${line}
done

"""

process = subprocess.Popen(
    bash_script, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
result, error = process.communicate()

if error:
    print(error)
    exit(1)

bus = SystemBus()

for path in result.decode().strip().split("\n"):
    battery_characteristic = bus.get("org.bluez", path)
    value = battery_characteristic.ReadValue({})
    print(value)
dennisorlando commented 7 months ago

I just found this by the way -> https://bbs.archlinux.org/viewtopic.php?id=236499 It appears that there's a high chance that most of the headsets use a different protocol than BLE to send battery level information

maniacx commented 7 months ago

menuback

Yikes, I was just about to create another extension to display multiple battery levels. There should be multiple "Battery Level" GATT characteristics available in bluetoothctl for a single device, thus the sequence should be:

bluetoothctl gatt.list-attributes | grep -B 2 "Battery Level"

then you copy paste the /org/bluez/* address that you got and:

bluetoothctl
menu gatt
select-attribute <address>
read

I made a script using bash and pydbus to do that automatically, I would love to get a feedback if it works. One issue is that I have no idea if you can somehow get a label for the batteries, i.e. which one is left and which one is right:

import subprocess

from pydbus import SystemBus

bash_script = """

#!/bin/bash

bluetoothctl gatt.list-attributes | grep -B 2 "Battery Level" --no-group-separator | grep "bluez" | while read line; do
  echo ${line}
done

"""

process = subprocess.Popen(
    bash_script, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE
)
result, error = process.communicate()

if error:
    print(error)
    exit(1)

bus = SystemBus()

for path in result.decode().strip().split("\n"):
    battery_characteristic = bus.get("org.bluez", path)
    value = battery_characteristic.ReadValue({})
    print(value)

I have Jabra Elite 75t and using Jabras android app, I can get earBuds battery level (no left or right) and case battery level. However forthe above above commands does now work. bluetoothctl gatt.list-attributes | grep -B 2 "Battery Level" manually getting list attributes shows device

May be this might work for Sony headset that BodyAsh uses, for jabra it doesnot.

So for Jabra it displays two mac address. One public address that I can connect to

    Name: Jabra Elite 75t
    Alias: Jabra Elite 75t
    Class: 0x00240404 (2360324)
    Icon: audio-headset
    Paired: yes
    Bonded: yes
    Trusted: yes
    Blocked: no
    Connected: yes
    LegacyPairing: no
    UUID: Serial Port               (00001101-0000-1000-8000-00805f9b34fb)
    UUID: Headset                   (00001108-0000-1000-8000-00805f9b34fb)
    UUID: Audio Sink                (0000110b-0000-1000-8000-00805f9b34fb)
    UUID: A/V Remote Control Target (0000110c-0000-1000-8000-00805f9b34fb)
    UUID: Advanced Audio Distribu.. (0000110d-0000-1000-8000-00805f9b34fb)
    UUID: A/V Remote Control        (0000110e-0000-1000-8000-00805f9b34fb)
    UUID: Handsfree                 (0000111e-0000-1000-8000-00805f9b34fb)
    UUID: PnP Information           (00001200-0000-1000-8000-00805f9b34fb)
    Modalias: bluetooth:v0067p24A7d0200
    ManufacturerData.Key: 0x0067 (103)
    ManufacturerData.Value:
  03 07 01 60 03 0b a7 24                          ...`...$        
    Battery Percentage: 0x64 (100)

one random address (assuming this is case)

device 6A:BA:3F:18:E3:34 (random)
    Name: Jabra Elite 75t
    Alias: Jabra Elite 75t
    Paired: no
    Bonded: no
    Trusted: no
    Blocked: no
    Connected: no
    LegacyPairing: no
    UUID: Battery Service           (0000180f-0000-1000-8000-00805f9b34fb)
    UUID: Device Information        (0000180a-0000-1000-8000-00805f9b34fb)
    UUID: GN Netcom                 (0000feff-0000-1000-8000-00805f9b34fb)

But I have no clue how to get the case battery level.

maniacx commented 7 months ago

I just found this by the way -> https://bbs.archlinux.org/viewtopic.php?id=236499 It appears that there's a high chance that most of the headsets use a different protocol than BLE to send battery level information

That just reminds me there is a python that a Gnome extension uses. I read script and there was something about gettting L / R and Case Battey level using RFCOMM. But unfortnately the script only works when my jabra headset is disconnected and it only shows the earbuds battery level, but that might work for other devices.

This is the extension https://github.com/MichalW/gnome-bluetooth-battery-indicator

This is the script that it used https://github.com/TheWeirdDev/Bluetooth_Headset_Battery_Level

Genteure commented 1 month ago

I'd love to see this implemented. I have a pair of ZMK keyboard that reports two battery values.

Multiple battery level reporting is added in ZMK here: https://github.com/zmkfirmware/zmk/pull/2045 There's lots of info and scripts/tools in the comments.

# info XX:XX:XX:XX:XX:XX
Device XX:XX:XX:XX:XX:XX (random)
    Name: My keyboard
    Alias: My keyboard
    Appearance: 0x03c1 (961)
    Icon: input-keyboard
    Paired: yes
    Bonded: yes
    Trusted: yes
    Blocked: no
    Connected: yes
    WakeAllowed: yes
    LegacyPairing: no
    UUID: Generic Access Profile    (00001800-0000-1000-8000-00805f9b34fb)
    UUID: Generic Attribute Profile (00001801-0000-1000-8000-00805f9b34fb)
    UUID: Device Information        (0000180a-0000-1000-8000-00805f9b34fb)
    UUID: Battery Service           (0000180f-0000-1000-8000-00805f9b34fb)
    UUID: Human Interface Device    (00001812-0000-1000-8000-00805f9b34fb)
    Modalias: bluetooth:somestringhere
    AdvertisingFlags:
  06                                               .               
    Battery Percentage: 0x4f (79)

# gatt.list-attributes 
Primary Service (Handle 0x0000)
    /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX/service001b
    0000180a-0000-1000-8000-00805f9b34fb
    Device Information
Characteristic (Handle 0x0000)
    /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX/service001b/char0020
    00002a50-0000-1000-8000-00805f9b34fb
    PnP ID
Characteristic (Handle 0x0000)
    /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX/service001b/char001e
    00002a29-0000-1000-8000-00805f9b34fb
    Manufacturer Name String
Characteristic (Handle 0x0000)
    /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX/service001b/char001c
    00002a24-0000-1000-8000-00805f9b34fb
    Model Number String
Primary Service (Handle 0x0000)
    /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX/service0015
    0000180f-0000-1000-8000-00805f9b34fb
    Battery Service
Characteristic (Handle 0x0000)
    /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX/service0015/char0016
    00002a19-0000-1000-8000-00805f9b34fb
    Battery Level
Descriptor (Handle 0x0000)
    /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX/service0015/char0016/desc001a
    00002901-0000-1000-8000-00805f9b34fb
    Characteristic User Description
Descriptor (Handle 0x0000)
    /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX/service0015/char0016/desc0019
    00002904-0000-1000-8000-00805f9b34fb
    Characteristic Format
Descriptor (Handle 0x0000)
    /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX/service0015/char0016/desc0018
    00002902-0000-1000-8000-00805f9b34fb
    Client Characteristic Configuration
Primary Service (Handle 0x0000)
    /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX/service0010
    0000180f-0000-1000-8000-00805f9b34fb
    Battery Service
Characteristic (Handle 0x0000)
    /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX/service0010/char0011
    00002a19-0000-1000-8000-00805f9b34fb
    Battery Level
Descriptor (Handle 0x0000)
    /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX/service0010/char0011/desc0014
    00002904-0000-1000-8000-00805f9b34fb
    Characteristic Format
Descriptor (Handle 0x0000)
    /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX/service0010/char0011/desc0013
    00002902-0000-1000-8000-00805f9b34fb
    Client Characteristic Configuration
Primary Service (Handle 0x0000)
    /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX/service0001
    00001801-0000-1000-8000-00805f9b34fb
    Generic Attribute Profile
Characteristic (Handle 0x0000)
    /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX/service0001/char0007
    00002b2a-0000-1000-8000-00805f9b34fb
    Database Hash
Characteristic (Handle 0x0000)
    /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX/service0001/char0005
    00002b29-0000-1000-8000-00805f9b34fb
    Client Supported Features
Characteristic (Handle 0x0000)
    /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX/service0001/char0002
    00002a05-0000-1000-8000-00805f9b34fb
    Service Changed
Descriptor (Handle 0x0000)
    /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX/service0001/char0002/desc0004
    00002902-0000-1000-8000-00805f9b34fb
    Client Characteristic Configuration

# gatt.select-attribute /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX/service0010/char0011
# gatt.read
4f

# gatt.select-attribute /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX/service0015/char0016
# gatt.read
1b

I'm also able to read battery levels on my phone using "nRF Connect" (by Nordic Semiconductor ASA on Google Play).

Screenshot (click to unfold, long image) ![Screenshot](https://github.com/user-attachments/assets/adadf1ca-74d8-472c-a083-05bd8b380d0d)
maniacx commented 1 month ago

hello @Genteure

test-gatt-battery-service.zip

Above is the rough code for getting battery level using GATT Extract and run it using

gjs-console /home/$USER/Downloads/test-gatt-battery-service.js

Let me know the results

Genteure commented 1 month ago

I changed line 119 to print(`BAT${i} = ${level}%, ${path}`);

Number of battery levels reported: 2
BAT1 = null%, /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX/service0015/char0016
BAT2 = null%, /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX/service0010/char0011

Edit: it's the real null not literal string null, typeof(level) gives object.

After running bluetoothctl gatt.select-attribute; gatt.read it becomes

Number of battery levels reported: 2
BAT1 = 25%, /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX/service0015/char0016
BAT2 = 67%, /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX/service0010/char0011

I plugged the cable in, both bluetoothctl info [ADDR] and this extension is showing 100% charge as expected (a hardware limitation, it's reading the USB charging voltage instead of the raw battery output)

image

Number of battery levels reported: 2
BAT1 = 25%, /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX/service0015/char0016
BAT2 = 67%, /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX/service0010/char0011

Observations:

  1. Looks like script is reading cached values from bluez?
  2. BAT2 is the "main" battery, this info could be read from another gatt attribute.

Ubuntu 24.04 LTS

$ gnome-shell --version
GNOME Shell 46.0

$ gjs --version
gjs 1.80.2

$ bluetoothd -v
5.72

$ bluetoothctl -v
bluetoothctl: 5.72

$ dbus-daemon --version
D-Bus Message Bus Daemon 1.14.10
maniacx commented 1 month ago

Hey @Genteure Thank you. For the corrections.

  1. Can you re-upload / attached the corrected script. You might have to zip as github doesnt except .js files.

Looks like script is reading cached values from bluez?

  1. Why do you think the readings are cached? Are they incorrect? And is there any other way to verify the actual batterry percentage of the keyboard? (like a android/apple/windows app or an display on the keyboard itself reporting balltery level)

BAT2 is the "main" battery, this info could be read from another gatt attribute.

  1. How can we read this info to know which is main and which is aux battery
Genteure commented 1 month ago

I only added ${path} to the print call to see the corresponding path and changed the mac address. I'm away from my machine right now, I can upload it later if you want.

Why do you think the readings are cached?

Because the value read by the script only changes after I called gatt.read in bluetoothctl. I searched for bluez docs and indeed the value property is cached.

https://github.com/bluez/bluez/blob/master/doc/org.bluez.GattCharacteristic.rst#arraybyte-value-read-only-optional

This is a python script that reads battery values. https://gist.github.com/madushan1000/9744eb6350a5dd9685fb6bfbb25fbb8a There's a bit of python object mapping magic but I think it's calling the array{byte} ReadValue(dict options) method. It also reads the characteristic descriptor for battery name.

maniacx commented 1 month ago

@Genteure

test2-gatt-battery-service.zip

Can you test this script?

BAT2 is the "main" battery, this info could be read from another gatt attribute.

is there any way to distinguish which is main and which is aux?

Genteure commented 1 month ago

I got a script working with my zmk keyboard based on your scripts and various docs. It should also work with any other bluetooth device that exposes multiple battery services.

const { Gio, GLib } = imports.gi;

// import GLib from 'gi://GLib';
// import Gio from 'gi://Gio';

const BLUEZ_BUS_NAME = 'org.bluez';
const BLUEZ_ROOT_PATH = '/';

const BLUEZ_DEVICE = 'org.bluez.Device1';
const BLUEZ_GATT_SERVICE = 'org.bluez.GattService1'
const BLUEZ_GATT_CHARACTERISCITC = 'org.bluez.GattCharacteristic1'
const BLUEZ_GATT_DESCRIPTOR = 'org.bluez.GattDescriptor1'

const UUID_SERVICE_BATTERY = "0000180f-0000-1000-8000-00805f9b34fb"
const UUID_CHAR_BATTERY_LEVEL = "00002a19-0000-1000-8000-00805f9b34fb"
// Characteristic Presentation Format
const UUID_CHAR_PRESENTATION_FORMAT = "00002904-0000-1000-8000-00805f9b34fb"
// Characteristic User Description
const UUID_CHAR_USER_DESC = "00002901-0000-1000-8000-00805f9b34fb"

const bus = Gio.DBus.system;

function getManagedObjects() {
    const objManager = Gio.DBusProxy.new_sync(
        bus,
        Gio.DBusProxyFlags.NONE,
        null,
        BLUEZ_BUS_NAME,
        BLUEZ_ROOT_PATH,
        'org.freedesktop.DBus.ObjectManager',
        null,
    );

    let result = objManager.call_sync(
        'GetManagedObjects',
        null,
        Gio.DBusCallFlags.NONE,
        -1,
        null
    );

    return result.get_child_value(0).deepUnpack();
}

/**
 * Searches for devices with battery service.
 *
 * @param {Object} managedObjects - The managed objects containing device information.
 * @returns {Array<string>} - An array of object paths for devices with battery service.
 */
function searchDevicesWithBatteryService(managedObjects) {
    let devices = new Set();

    for (let [objectPath, interfaces] of Object.entries(managedObjects)) {
        if (BLUEZ_GATT_SERVICE in interfaces) {
            let service = interfaces[BLUEZ_GATT_SERVICE];
            let uuid = service['UUID'].deepUnpack();
            if (uuid === UUID_SERVICE_BATTERY) {
                const devicePath = service['Device'].deepUnpack();
                // print(`Found device: ${devicePath}`);
                // example: /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX
                devices.add(devicePath);
            }
        }
    }

    return [...devices];
}

/**
 * Calls the ReadValue method on a characteristic or descriptor to read its value.
 * @param {string} path - The path of the characteristic or descriptor.
 * @param {string} interface - Either org.bluez.GattCharacteristic1 or org.bluez.GattDescriptor1.
 * @returns {Uint8Array} - The value.
 */
function readBluetoothValue(path, interface) {
    const callResult = bus.call_sync(
        BLUEZ_BUS_NAME,
        path,
        interface,
        'ReadValue',
        new GLib.Variant('(a{sv})', [{}]),
        null,
        Gio.DBusCallFlags.NONE,
        -1,
        null
    );

    const byteArray = callResult.get_child_value(0).deepUnpack();
    return byteArray
}

function listBatteryLevels(managedObjects, devicePath) {
    // We are looping through the managed objects multiple times and
    // the algorithm can be optimized, but unless we have a large number
    // of devices it should be fine. I'm keeping it simple for now.
    const gattServicePath = [];

    // Find GATT caracteristics for battery level.
    for (let [objectPath, interfaces] of Object.entries(managedObjects)) {
        if (objectPath.startsWith(devicePath) && BLUEZ_GATT_CHARACTERISCITC in interfaces) {
            let characteristic = interfaces[BLUEZ_GATT_CHARACTERISCITC];
            let uuid = characteristic['UUID'].deepUnpack();
            if (uuid === UUID_CHAR_BATTERY_LEVEL) {
                // print(`Characteristic: ${characteristic['UUID'].deepUnpack()}`);
                // example: /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX/serviceXX/charXX
                gattServicePath.push(objectPath);
            }
        }
    }

    const batteryLevels = [];

    // Read battery level and user description for each battery.
    for (let servicePath of gattServicePath) {

        // result should be a single byte representing the battery percentage
        const batteryByteArray = readBluetoothValue(servicePath, BLUEZ_GATT_CHARACTERISCITC);
        const batteryPercent = batteryByteArray[0];

        let batteryLevel = {
            batteryPercent: batteryPercent,
            displayName: "Main",
            sortOrder: -1,
            userDesc: null,
            presentDesc: null
        };

        // Read Characteristic Presentation Format for battery description.
        for (let [objectPath, interfaces] of Object.entries(managedObjects)) {
            if (objectPath.startsWith(servicePath) && BLUEZ_GATT_DESCRIPTOR in interfaces) {
                let descriptor = interfaces[BLUEZ_GATT_DESCRIPTOR];
                let uuid = descriptor['UUID'].deepUnpack();
                if (uuid === UUID_CHAR_PRESENTATION_FORMAT) {
                    const byteArray = readBluetoothValue(objectPath, BLUEZ_GATT_DESCRIPTOR);
                    // print(`Presentation Format: (length: ${byteArray.length}) ${formatByteArray(byteArray)}`);

                    // byte 0: format, 0x04 for unsigned 8-bit integer
                    // byte 1: exponent, 0x00
                    // byte 2-3: unit, 0x27ad for percentage
                    // byte 4: namespace, 0x01 for Bluetooth SIG assigned numbers
                    // byte 5-6: description

                    if (byteArray.length !== 7) {
                        print(`Invalid Presentation Format: ${formatByteArray(byteArray)}`);
                        batteryLevel = null;
                        break;
                    }
                    if (byteArray[0] !== 0x04 || byteArray[1] !== 0x00 || byteArray[2] !== 0xad || byteArray[3] !== 0x27 || byteArray[4] !== 0x01) {
                        print(`Unsupported Presentation Format: ${formatByteArray(byteArray)}`);
                        batteryLevel = null;
                        break;
                    }

                    // byte 5-6 is the description
                    let cpfDesc = byteArray[5] | (byteArray[6] << 8);
                    if (cpfDesc === 0x0106) { // main
                        batteryLevel.sortOrder = -1;
                    } else {
                        batteryLevel.sortOrder = cpfDesc;
                    }
                    batteryLevel.presentDesc = cpfDescToName(cpfDesc);

                } else if (uuid === UUID_CHAR_USER_DESC) {
                    const byteArray = readBluetoothValue(objectPath, BLUEZ_GATT_DESCRIPTOR);
                    // print(`User Description: (length: ${byteArray.length}) ${decodeByteArray(byteArray)}`);
                    batteryLevel.userDesc = decodeByteArray(byteArray);
                }
            }
        }

        if (batteryLevel) { // if we have a valid battery level

            if (batteryLevel.userDesc) { // prefer user description
                batteryLevel.displayName = batteryLevel.userDesc;
            } else if (batteryLevel.presentDesc) { // fallback to presentation format
                batteryLevel.displayName = batteryLevel.presentDesc;
            }

            batteryLevels.push(batteryLevel);
        }
    }

    batteryLevels.sort((a, b) => a.sortOrder - b.sortOrder); // sort by sortOrder, which is the CPF description

    return batteryLevels;
}

// Bluetooth SIG GATT Characteristic Presentation Format Description
// https://www.bluetooth.com/wp-content/uploads/Files/Specification/Assigned_Numbers.html#bookmark46
function cpfDescToName(cpf) {
    if (cpf < 0) return 'Invalid';
    if (cpf === 0x0000) return 'Unknown';
    if (cpf <= 0x00FF) {
        const tenth = cpf % 10, moduloHundred = cpf % 100;
        if (tenth === 1 && moduloHundred !== 11) { return cpf + "st"; }
        if (tenth === 2 && moduloHundred !== 12) { return cpf + "nd"; }
        if (tenth === 3 && moduloHundred !== 13) { return cpf + "rd"; }
        return cpf + "th";
    }
    switch (cpf) {
        case 0x0100: return 'Front';
        case 0x0101: return 'Back';
        case 0x0102: return 'Top';
        case 0x0103: return 'Bottom';
        case 0x0104: return 'Upper';
        case 0x0105: return 'Lower';
        case 0x0106: return 'Main';
        case 0x0107: return 'Backup';
        case 0x0108: return 'Auxiliary';
        case 0x0109: return 'Supplementary';
        case 0x010A: return 'Flash';
        case 0x010B: return 'Inside';
        case 0x010C: return 'Outside';
        case 0x010D: return 'Left';
        case 0x010E: return 'Right';
        case 0x010F: return 'Internal';
        case 0x0110: return 'External';
        default: return 'Invalid';
    }
}

/**
 * Formats a Uint8Array into a list of byte text for display.
 * @param {Uint8Array} byteArray - The Uint8Array to format.
 * @returns {string} - The formatted string of byte text.
 */
function formatByteArray(byteArray) {
    return Array.from(byteArray).map(byte => byte.toString(16).padStart(2, '0')).join(' ');
}

/**
 * Decodes a Uint8Array into a string.
 * @param {Uint8Array} byteArray - The Uint8Array to decode.
 * @returns {string} - The decoded string.
 */
function decodeByteArray(byteArray) {
    return new TextDecoder('utf-8').decode(byteArray);
}

function main() {
    let managedObjects = getManagedObjects();
    let devicePaths = searchDevicesWithBatteryService(managedObjects);
    for (let path of devicePaths) {
        try {

            let batteryLevels = listBatteryLevels(managedObjects, path);
            print(`Device: ${path}`);
            for (let batteryLevel of batteryLevels) {
                print(`  ${batteryLevel.displayName}: ${batteryLevel.batteryPercent}%`);
                print(`      Description: ${batteryLevel.userDesc}`);
                print(`      Presentation: ${batteryLevel.presentDesc}`);
            }

        } catch (error) {
            print(`Error reading battery level for ${path}: ${error}`);
        }
    }
}

main();
Device: /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX
  Main: 42%
      Description: null
      Presentation: Main
  Peripheral 0: 22%
      Description: Peripheral 0
      Presentation: Auxiliary
maniacx commented 1 month ago

Nice thanks.

So I am also trying to get left right case battery levels of headphone such as galaxy buds, sony etc through rfcomm but haven't been successfully. I am trying do to this purely in gjs without any python script or other language scripts.

If at all I succeed with getting L,R case level for earphones, I will make a new GUI that supports both GATT and Rfcomm to display multiple battery levels.

Genteure commented 1 month ago

Have you been able to get a serial connection to your headphones?

I'm giving a try on my main earbuds "Redmi Buds 5 Pro". I got a few binary logs and a rfcomm UUID from the app com.mi.earphone, and it looks pretty promising.

The app seems to be calling BluetoothDevice.createRfcommSocketToServiceRecord (UUID uuid) with the UUID 0000fd2d-0000-1000-8000-00805f9b34fb. 0xfd2d is an assigned number to Xiaomi Inc. so that looks correct. https://www.bluetooth.com/wp-content/uploads/Files/Specification/Assigned_Numbers.html#bookmark117

This uuid is also listed in bluetoothctl info:

Device XXXXXXXXXXXX (public)
    Name: Redmi Buds 5 Pro
    Alias: Redmi Buds 5 Pro
    Class: 0x00244404 (2376708)
    Icon: audio-headset
    Paired: yes
    Bonded: yes
    Trusted: no
    Blocked: no
    Connected: yes
    LegacyPairing: no
    UUID: Vendor specific           (00000000-0000-0000-0099-aabbccddeeff)
    UUID: Vendor specific           (00000000-deca-fade-deca-deafdecacaff)
    UUID: Audio Sink                (0000110b-0000-1000-8000-00805f9b34fb)
    UUID: A/V Remote Control Target (0000110c-0000-1000-8000-00805f9b34fb)
    UUID: A/V Remote Control        (0000110e-0000-1000-8000-00805f9b34fb)
    UUID: Handsfree                 (0000111e-0000-1000-8000-00805f9b34fb)
    UUID: PnP Information           (00001200-0000-1000-8000-00805f9b34fb)
    UUID: Audio Input Control       (00001843-0000-1000-8000-00805f9b34fb)
    UUID: Volume Control            (00001844-0000-1000-8000-00805f9b34fb)
    UUID: Volume Offset Control     (00001845-0000-1000-8000-00805f9b34fb)
    UUID: Coordinated Set Identif.. (00001846-0000-1000-8000-00805f9b34fb)
    UUID: Microphone Control        (0000184d-0000-1000-8000-00805f9b34fb)
    UUID: Audio Stream Control      (0000184e-0000-1000-8000-00805f9b34fb)
    UUID: Broadcast Audio Scan      (0000184f-0000-1000-8000-00805f9b34fb)
    UUID: Published Audio Capabil.. (00001850-0000-1000-8000-00805f9b34fb)
    UUID: Common Audio              (00001853-0000-1000-8000-00805f9b34fb)
    UUID: Telephony and Media Audio (00001855-0000-1000-8000-00805f9b34fb)
    UUID: Unknown                   (0000fd2d-0000-1000-8000-00805f9b34fb)
    UUID: Vendor specific           (0000ff01-0000-1000-8000-00805f9b34ff)
    UUID: Vendor specific           (fa349b5f-8050-0030-0010-00001bbb231d)
    Modalias: bluetooth:xxxxxxxxxx
    Battery Percentage: 0x41 (65)

Now the problem: There's no RFCOMM port listed for fd2d under linux.

Running sdptool browse <addr> shows 4 Service with "RFCOMM", and fd2d is not listed anywhere in the output. Redacted output showing only service with RFCOMM listed:

Service Name: Hands-Free unit
Service RecHandle: 0x10000
Service Class ID List:
  "Handsfree" (0x111e)
  "Generic Audio" (0x1203)
Protocol Descriptor List:
  "L2CAP" (0x0100)
  "RFCOMM" (0x0003)
    Channel: 1
Language Base Attr List:
  code_ISO639: 0x656e
  encoding:    0x6a
  base_offset: 0x100
Profile Descriptor List:
  "Handsfree" (0x111e)
    Version: 0x0108
-----
Service Name: BTNOTIFYR
Service RecHandle: 0x20000000
Service Class ID List:
  UUID 128: 0000ff01-0000-1000-8000-00805f9b34ff
Protocol Descriptor List:
  "L2CAP" (0x0100)
  "RFCOMM" (0x0003)
    Channel: 8
Language Base Attr List:
  code_ISO639: 0x656e
  encoding:    0x6a
  base_offset: 0x100
-----
Service Name: Airoha_APP
Service RecHandle: 0x20000002
Service Class ID List:
  UUID 128: 00000000-0000-0000-0099-aabbccddeeff
Protocol Descriptor List:
  "L2CAP" (0x0100)
  "RFCOMM" (0x0003)
    Channel: 21
Language Base Attr List:
  code_ISO639: 0x656e
  encoding:    0x6a
  base_offset: 0x100
Profile Descriptor List:
  "Serial Port" (0x1101)
    Version: 0x0102
-----
Service Name: Airoha_IAP2
Service RecHandle: 0x2000000d
Service Class ID List:
  UUID 128: 00000000-deca-fade-deca-deafdecacaff
Protocol Descriptor List:
  "L2CAP" (0x0100)
  "RFCOMM" (0x0003)
    Channel: 30
Language Base Attr List:
  code_ISO639: 0x656e
  encoding:    0x6a
  base_offset: 0x100

I rebooted this machine into Windows and paired the earbuds there, that service is showing up with the name "xiaoai", so it looks like is something with linux/bluez.

service fd2d

I tried changing ControllerMode = bredr in bluez config but that made no difference.

Any suggestions on what to try next?

maniacx commented 1 month ago

Does your mi buds report left, right and case charging values in android app? Unfortunately I do not have bluetooth device that reports seperate battery level even in android OEM app ( Jabra earbuds)

Honestly I do not know much about bluetooth, and I think you know much more than I do :) I am still learning.

Can you test this python script. I want to know if earbud do send seperate battery levels test-rfcomm.zip

Change the MAC address in the script, add yours.

First test is with earbuds connected. Most likely I will not get any data. Let the script complete (may take some time) and exit.

Disconnect the earbuds but keep it ON and run the script. The script should be able to connect to one of the channels and get data. Post the data here.

You should have python3 installed. The script is based on https://github.com/TheWeirdDev/Bluetooth_Headset_Battery_Level/blob/master/bluetooth_battery.py

and also I had a look at pipewire https://gitlab.freedesktop.org/pipewire/pipewire/-/blob/master/spa/plugins/bluez5/backend-native.c#L1112

You can even try TheWeirdDev's script and see what it output. Most likely that also will work only when disconnected. But you will also have to install python-bluez for that.

Genteure commented 1 month ago

I just started digging about bluetooth in the last month of two.

Yes the app does have separate display for left, right and case.

test-rfcomm.py gives really inconsistent result, it behaves like the connection is being rate limited. It feels like I can only try to connect about 5 times in a row, and any more it fails with [Errno 111] Connection refused even when it should be able to connect. Needs to wait ~1 minute between scans.

Successfully connected on channel 1!
Received data: AT+BRSF=1023
Transmit BRSF
Received data: AT+CIND=?
Transmit CIND=
Received data: AT+CIND?
Transmit CIND?
Received data: AT+CMER=3,0,0,1
Transmit OK
Received data: AT+BIND=1,2
Transmit OK
Received data: AT+BIND=?
Transmit BIND=?
Received data: AT+BIND?
Transmit BIND?
Received data: AT+NREC=0
Transmit OK
Received data: AT+CCWA=1
Transmit OK
Received data: AT+BIA=0,1,1,1,0
Transmit OK
Received data: AT+CLIP=1
Transmit OK
AT+VGS=10data: AT+XAPL=MTK-HB-0400,2
Transmit XAPL
AT+BIEV=2,100: AT+BIEV=2,100
Transmit BIEV
Error with channel 1: invalid literal for int() with base 10: b'100\rAT+BIEV'
Closing socket

00000000-0000-0000-0099-aabbccddeeff Airoha_APP port 21, able to connect, to me feels like an example phone app by Airoha (the chip maker) left unused and not deleted by xiaomi. 00000000-deca-fade-deca-deafdecacaff Airoha_IAP2 port 30, able to connect, is Apple related stuff https://wiomoc.de/misc/posts/mfi_iap.html 0000ff01-0000-1000-8000-00805f9b34ff BTNOTIFYR port 8, unable to connect, I have no idea what this is.

The only other port I'm able to connect is 24, which might be the fd2d service? I tried sending the binary data I got from the app log but got no response.


I went through the app again, there's something called MMA Service which I have no idea what it stands for. The app checks a few flags from both the system and a device database (unsure about this) and if conditions are met, packed everything into a android.os.Parcel and passed it to a android.os.IBinder. It also receives data through a android.os.Binder implementation, and unpacks byte[] from android.os.Parcel. I don't have android dev experience but based on the naming and the behavior, I'm guessing another (perhaps system level) component is handling the real communication. The app itself did open a rfcomm socket but left unused. I'm going to try again on another non xiaomi phone later.


Request: FEDCBAC402000500FFFFFFFFEF

Example incomplete response: FEDCBA04020038000011005265646D69204275647320352050726F0501418841880202C605032717506C0204000205000306

-parseTargetInfo- type :0,value: 5265646D69204275647320352050726F
-parseTargetInfo- type :1,value: 41884188
-parseTargetInfo- >> hBuf : 0001000000000001, lBuf : 0100000001000000
-parseTargetInfo- >> version : 16776, name : 4.1.8.8
-parseTargetInfo- >> hBuf : 0001000000000001, lBuf : 0100000001000000
-parseTargetInfo- >> subversion : 16776, subname : 4.1.8.8
-parseTargetInfo- type :2,value: C6
-parseTargetInfo- type :3,value: 2717506C
-parseTargetInfo- type :4,value: 00
-parseTargetInfo- type :5,value: 00
-parseTargetInfo- type :6,value: 4188
-parseTargetInfo- >> uboot version : 16776, name : 4.1.8.8
-parseTargetInfo- type :8,value: 01
-parseTargetInfo- type :7,value: C6D546
-parseTargetInfo- type :13,value: 01

Not sure about what each byte means yet, but 02 is "get target info", the byte following 02 is a incrementing sequence number used to correspond responds to requests.

In the response there are 10 smaller sections, the format is 1 byte length, 1 byte type, (length-1) byte value. We are interested in type 7.

C6D546 is the battery info, one byte per side in the order of left, right, case. The first bit is charging status. 0xC6 & 127 == 70, 0xD5 & 127 = 85, 0x46 = 70. So it's left charging 70%, right charging 85%, case not charging 70%. These numbers matches what was displayed in the app.

The protocol is there, just need a way to send it and receive the response.


This would be so much easier if I have a pair of Galaxy Buds or airpods and having a known working reference.

Genteure commented 1 month ago

Looks like gjs does not support opening rfcomm sockets. I tried passing in raw numbers but that didn't work either.

const { Gio } = imports.gi;

const PF_BLUETOOTH = 31;
const BTPROTO_RFCOMM = 3;

const sock = Gio.Socket.new(PF_BLUETOOTH, Gio.SocketType.SOCK_STREAM, BTPROTO_RFCOMM);
JS ERROR: Error: 31 is not a valid value for enum argument family

I think the only way to keep most of the logic within gjs is creating a rfcomm socket elsewhere and call Gio.Socket.new_from_fd(fd).


I found the unfortunate reason why I didn't got a response from my earbuds: the connection needs to be authenticated.

SPP_SEND::data(26 Bytes) [FEDCBAC450001215019B014C7C182E2202D22AE800F3EE93FAEF]
SPP_RCV::data:[FEDCBA0450001300150177B109A3026955718791028FC916C151EF](27 Bytes)
SPP_SEND::data(11 Bytes) [FEDCBAC4510003160100EF]
SPP_RCV::data:[FEDCBA04510003001601EF](11 Bytes)

SPP_RCV::data:[FEDCBAC050001200015F1146ED177BB4C70575B45F5034C373EF](26 Bytes)
SPP_SEND::data(27 Bytes) [FEDCBA0450001300000174395BA52FC7F9DC30938A84E494D3F2EF]
SPP_RCV::data:[FEDCBAC0510003010100EF](11 Bytes)
SPP_SEND::data(11 Bytes) [FEDCBA04510003000101EF]

SPP_SEND::data(13 Bytes) [FEDCBAC402000517FFFFFFFFEF]
SPP_RCV::data:[FEDCBA04020038001711005265646D69204275647320352050726F0501418841880202E405032717506C0204010205000306](64 Bytes)

FEDCBA looks like the preamble, and EF at the end is the postamble.

The 4th byte is flags.

The 5th byte is message id.

The app first authenticate the device (first 4 messages). If we were to implement this, we can just send some random bytes (or the same bytes all the time) and not check the response, we don't need to check if it's a genuine xiaomi earbuds anyway.

Then the device authenticate the app (the next 4 messages). It generates a byte array and expect the app to do some math on it. In the app the calculation is implemented in a native libxm_bluetooth.so binary.

import socket
import threading
import time

def receive_data(sock):
    while True:
        data = sock.recv(1024)
        print(f">> {data}")

def main():
    host = 'XX:XX:XX:XX:XX:XX'
    port = 24
    # sock = bluetooth.BluetoothSocket(bluetooth.RFCOMM)
    sock = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_STREAM, socket.BTPROTO_RFCOMM)

    try:
        sock.connect((host, port))
        print("Connected")
    except socket.error as e:
        print(f"Failed to connect: {e}")
        return

    threading.Thread(target=receive_data, args=(sock,)).start()

    # FEDCBAC450001215019B014C7C182E2202D22AE800F3EE93FAEF
    sock.send(b"\xFE\xDC\xBA\xC4\x50\x00\x12\x15\x01\x9B\x01\x4C\x7C\x18\x2E\x22\x02\xD2\x2A\xE8\x00\xF3\xEE\x93\xFA\xEF")
    time.sleep(1)

    # FEDCBAC4510003160100EF
    sock.send(b"\xFE\xDC\xBA\xC4\x51\x00\x03\x16\x01\x00\xEF")
    time.sleep(6)

if __name__ == "__main__":
    main()
Connected
>> b'\xfe\xdc\xba\x04P\x00\x13\x00\x15\x01w\xb1\t\xa3\x02iUq\x87\x91\x02\x8f\xc9\x16\xc1Q\xef'
>> b'\xfe\xdc\xba\x04Q\x00\x03\x00\x16\x01\xef'
>> b'\xfe\xdc\xba\xc0P\x00\x12\x00\x01\xc9\x1c\x03\xc8o\x0e\xa7\xb6\n\xb7\xf8\xdc\xec\x1e\x05\xe4\xef'
>> b'\xfe\xdc\xba\xc0P\x00\x12\x00\x01\xc9\x1c\x03\xc8o\x0e\xa7\xb6\n\xb7\xf8\xdc\xec\x1e\x05\xe4\xef'
>> b'\xfe\xdc\xba\xc0P\x00\x12\x00\x01\xc9\x1c\x03\xc8o\x0e\xa7\xb6\n\xb7\xf8\xdc\xec\x1e\x05\xe4\xef'

I don't think I can make any progress beyond this. I have zero knowledge about native binary reverse engineering.

maniacx commented 1 month ago

Looking from implementing this into extension perspective, I am looking for a standard way rather than reverse engineering packets, each and every manufacturer/model earbuds.

I am assuming IPHONEACCEV reports individual battery as well, but I do not have earbuds that reports L,R and Case levels, or even L and R channels individually. First I need to confirm this.

If it does, than there are many other hurdles.

RFCOMM in purely GJS hurdles. You are correct, Gio.Sockets does not support bluetooth RFCOMM/L2CAP, and only way around might be to find a way to call Gio.Socket.new_from_fd(fd).

PythonBluez hurdles Incase I cannot do it using gjs only, gnome-extension-reviewer may make an exemption to use python script. PythonBluez are deprecated, but I think we can workaround by only importing sockets which is a standard with python and the probably we can use gjs to do other bluetooth related stuff such as getting uuid, mac address (just a thought)

RFCOMM hurdles I think since pipewire / pulse audio uses these port, still I am not sure if there is a way around establishing reliable rfcomm connections. I am not sure but I think, this is the reason why these python scripts only work when my earbuds are disconnected but paired and still ON. I do not have OEM specific Vendor UUID for my earbuds but I think it might be a possibility if bluetooth device have vendor uuid.

You could check this using btmon

sudo btmon | grep -A1 -E "IPHONEACCEV|BIEV|XEVENT=BATTERY"

My earbuds output of btmon

sudo btmon | grep -A1 -E "IPHONEACCEV|BIEV|XEVENT=BATTERY"

        41 54 2b 42 49 45 56 3d 32 2c 37 31 0d 40        AT+BIEV=2,71.@  
< SCO Data TX: Handle 257 flags 0x00 dlen 60            #15404 [hci0] 64.494182
--
        41 54 2b 49 50 48 4f 4e 45 41 43 43 45 56 3d 32  AT+IPHONEACCEV=2
        2c 31 2c 37 2c 32 2c 30 0d 40                    ,1,7,2,0.@  

This is what I get on my earbuds.

BIEV report as battery level 71 AT+BIEV=2,71

AT+IPHONEACCEV=2,1,7,2,0 The 7 indicates the level of Battery that should be incremented by 1 and multipled by 10, so level is 80%

I just found some discussion here

https://github.com/TheWeirdDev/Bluetooth_Headset_Battery_Level/issues/16

https://github.com/TheWeirdDev/Bluetooth_Headset_Battery_Level/issues/33

Some examples of custom UUID's used in script https://github.com/timschneeb/GalaxyBuds-BatteryLevel

https://github.com/Toxblh/sony-headphones-control-linux/blob/master/prototype/main.py

With your knowledge of Bluetooth & programming I think you will be able to achieve this..

Genteure commented 1 month ago

I made a bluetooth hci capture on my phone.

image

I don't see any IPHONEACCEV.

There are lots of AT+XIAOMI=1,1,74,228,228,75,229 with different numbers, although after AT+XIAOMI=FF....FF exchanges. The earbuds sends the first AT+XIAOMI message. I'm 100% sure those numbers are battery statuses. 228 = 0b10000000 | 100, and 75 is the case battery at the time.

Looking at bluetooth captures on my linux machine, there's no AT+XIAOMI messages at all.

image

I tried connecting to HFP (RFCOMM channel 1) using a python script based on https://github.com/TheWeirdDev/Bluetooth_Headset_Battery_Level/blob/68c086298bbc930f6f2e8e3d588a41f5a88aa79b/bluetooth_battery.py#L149-L195 and modified based on the hci pcap I took on my phone, but I also wasn't able to get a AT+XIAOMI message from the earbuds.


IPHONEACCEV looks like an Apple specific command, it's not that universal like a bluetooth standard. Xiaomi clearly made their own commands too. For my specific earbud model, there's a IAP2 bluetooth services. This is my speculation but I wouldn't be surprised if there's a protocol (potentially newer than custom AT commands) in there for communicating battery info to Apple devices.

The technical specification for iAP2 is only available to the members of the MFi licensing program https://apple.stackexchange.com/questions/216219


I think since pipewire / pulse audio uses these port, still I am not sure if there is a way around establishing reliable rfcomm connections. I am not sure but I think, this is the reason why these python scripts only work when my earbuds are disconnected but paired and still ON.

Yes indeed. When I connect to RFCOMM channel 1 (HFP) on my earbuds using a python script, the "bluetooth connected" sound plays. And when the earbuds is connected via "normal" methods, from wireshark captures it's obvious one of the components (I'm not sure which) is connecting to it. Honestly at this point I don't think this path is very viable.

vulpes2 commented 2 weeks ago

Both of the devices mentioned in this thread are impossible to support via pure GJS because they need RFCOMM.

For the Sony device mentioned above, you can use the service df21fe2c-2515-4fdb-8886-f12c4d67927c which is Google Fast Pair Service (GFPS). This is a well documented service available in many different brands of headphones, connect via RFCOMM and you can query battery information right away. https://developers.google.com/nearby/fast-pair/specifications/extensions/messagestream

For the Xiaomi device, since data is exchanged via proprietary AT commands in-band on the HFP RFCOMM channel, support for this must be integrated into pipewire or pulseaudio. Pipewire will query for battery status over the HFP RFCOMM channel and register it with BlueZ over DBus, and it's possible to register multiple batteries with BlueZ for one device as far as I can tell. So if you ever figure out how to make the headphones report battery info, please consider submitting a patch to pipewire.

maniacx commented 2 weeks ago

Both of the devices mentioned in this thread are impossible to support via pure GJS because they need RFCOMM.

But we have been trying with Python script using bluez/socket lib and were not a successful to get even a single battery level (forget about multiple ) using RFcomm when device connected and or streaming.

Only when I disconnected the device and then run the script I can communicate with RFcomm and get battery level.

I guess Pipewire/pa take over the RFcomm channel and hence the script cannot established RFcomm connection when device is connected/streaming. I didn't find a way around this. My knowledge regarding Bluetooth protocols is very limited.

vulpes2 commented 2 weeks ago

That's correct, if the data can only be accessed in-band, they must be implemented in pipewire or pulseaudio instead. The HFP RFCOMM channel will always be used by pipewire or pulseaudio, and it's not possible to multiplex that connection.

For GFPS there is no such restriction since it's a separate service, and there are existing implementations such as this project: https://github.com/TheWeirdDev/Bluetooth_Headset_Battery_Level. If you can do python scripting in the extension, support for GFPS can be implemented easily.

vulpes2 commented 2 weeks ago

BIEV report as battery level 71 AT+BIEV=2,71

AT+IPHONEACCEV=2,1,7,2,0 The 7 indicates the level of Battery that should be incremented by 1 and multipled by 10, so level is 80%

By the way, I forgot to point out that this is incorrect. 2,1,7,2,0 decodes as "two kv pairs, battery 80%, undocked". First number the number of kv pairs. Key 1 means battery and the value is 0-9, key 2 means docking status, value is 0 (undocked) or 1 (docked). The docking status doesn't necessarily refer to the earbuds being in the charging case, Apple did not provide any clarifications on that.