* [PATCH BlueZ 1/4] battery: Add component battery objects
2026-08-19 22:31 [PATCH BlueZ 0/4] Add component batteries and Fast Pair Message Stream Matthias Kurz
@ 2026-08-19 22:31 ` Matthias Kurz
2026-08-19 23:37 ` Add component batteries and Fast Pair Message Stream bluez.test.bot
2026-08-19 22:31 ` [PATCH BlueZ 2/4] doc: Document component battery objects Matthias Kurz
` (4 subsequent siblings)
5 siblings, 1 reply; 11+ messages in thread
From: Matthias Kurz @ 2026-08-19 22:31 UTC (permalink / raw)
To: linux-bluetooth
Allow one Bluetooth device to export multiple Battery1 objects while
keeping the legacy aggregate object at the device path.
Component objects expose their parent Device1 path, a stable identifier,
an optional percentage, and an optional charging state. Extend
BatteryProvider1 handling with the same semantics so external providers can
publish multiple batteries too.
Keep component objects and their new properties behind the experimental
D-Bus flag while the API is being established.
Assisted-by: Codex:gpt-5.6-sol
---
src/battery.c | 401 ++++++++++++++++++++++++++++++++++++++++++--------
src/battery.h | 4 +
2 files changed, 347 insertions(+), 58 deletions(-)
diff --git a/src/battery.c b/src/battery.c
index fa30fde47..181bf3846 100644
--- a/src/battery.c
+++ b/src/battery.c
@@ -35,11 +35,18 @@
#define BATTERY_MAX_PERCENTAGE 100
+struct battery_provider;
+
struct btd_battery {
char *path; /* D-Bus object path */
+ char *device_path; /* Parent Device1 object path, if any */
+ char *identifier; /* Stable component identifier, if any */
uint8_t percentage; /* valid between 0 to 100 inclusively */
+ int charging; /* 0 or 1 when known, -1 otherwise */
char *source; /* Descriptive source of the battery info */
char *provider_path; /* The provider root path, if any */
+ char *provider_object_path; /* The provider battery object, if any */
+ struct battery_provider *provider; /* Does not own pointer */
struct bt_battery *filter;
};
@@ -86,18 +93,29 @@ static bool match_path(const void *data, const void *user_data)
return g_strcmp0(battery->path, path) == 0;
}
-static struct btd_battery *battery_new(const char *path, const char *source,
- const char *provider_path)
+static struct btd_battery *battery_new(const char *path,
+ const char *device_path,
+ const char *identifier,
+ const char *source,
+ const char *provider_path,
+ const char *provider_object_path,
+ struct battery_provider *provider)
{
struct btd_battery *battery;
battery = new0(struct btd_battery, 1);
battery->path = g_strdup(path);
+ battery->device_path = g_strdup(device_path);
+ battery->identifier = g_strdup(identifier);
battery->percentage = UINT8_MAX;
+ battery->charging = -1;
if (source)
battery->source = g_strdup(source);
if (provider_path)
battery->provider_path = g_strdup(provider_path);
+ if (provider_object_path)
+ battery->provider_object_path = g_strdup(provider_object_path);
+ battery->provider = provider;
battery->filter = bt_battery_new();
return battery;
@@ -105,11 +123,12 @@ static struct btd_battery *battery_new(const char *path, const char *source,
static void battery_free(struct btd_battery *battery)
{
- if (battery->path)
- g_free(battery->path);
-
- if (battery->source)
- g_free(battery->source);
+ g_free(battery->path);
+ g_free(battery->device_path);
+ g_free(battery->identifier);
+ g_free(battery->source);
+ g_free(battery->provider_path);
+ g_free(battery->provider_object_path);
if (battery->filter) {
bt_battery_free(battery->filter);
@@ -157,15 +176,83 @@ static gboolean property_source_exists(const GDBusPropertyTable *property,
return battery->source != NULL;
}
+static gboolean property_device_get(const GDBusPropertyTable *property,
+ DBusMessageIter *iter, void *data)
+{
+ struct btd_battery *battery = data;
+
+ dbus_message_iter_append_basic(iter, DBUS_TYPE_OBJECT_PATH,
+ &battery->device_path);
+
+ return TRUE;
+}
+
+static gboolean property_device_exists(const GDBusPropertyTable *property,
+ void *data)
+{
+ struct btd_battery *battery = data;
+
+ return battery->device_path != NULL;
+}
+
+static gboolean property_identifier_get(const GDBusPropertyTable *property,
+ DBusMessageIter *iter, void *data)
+{
+ struct btd_battery *battery = data;
+
+ dbus_message_iter_append_basic(iter, DBUS_TYPE_STRING,
+ &battery->identifier);
+
+ return TRUE;
+}
+
+static gboolean property_identifier_exists(const GDBusPropertyTable *property,
+ void *data)
+{
+ struct btd_battery *battery = data;
+
+ return battery->identifier != NULL;
+}
+
+static gboolean property_charging_get(const GDBusPropertyTable *property,
+ DBusMessageIter *iter, void *data)
+{
+ struct btd_battery *battery = data;
+ dbus_bool_t charging = battery->charging;
+
+ dbus_message_iter_append_basic(iter, DBUS_TYPE_BOOLEAN, &charging);
+
+ return TRUE;
+}
+
+static gboolean property_charging_exists(const GDBusPropertyTable *property,
+ void *data)
+{
+ struct btd_battery *battery = data;
+
+ return battery->charging >= 0;
+}
+
static const GDBusPropertyTable battery_properties[] = {
{ "Percentage", "y", property_percentage_get, NULL,
property_percentage_exists },
{ "Source", "s", property_source_get, NULL, property_source_exists },
+ { "Device", "o", property_device_get, NULL, property_device_exists,
+ G_DBUS_PROPERTY_FLAG_EXPERIMENTAL },
+ { "Identifier", "s", property_identifier_get, NULL,
+ property_identifier_exists, G_DBUS_PROPERTY_FLAG_EXPERIMENTAL },
+ { "Charging", "b", property_charging_get, NULL,
+ property_charging_exists, G_DBUS_PROPERTY_FLAG_EXPERIMENTAL },
{}
};
-struct btd_battery *btd_battery_register(const char *path, const char *source,
- const char *provider_path)
+static struct btd_battery *battery_register(const char *path,
+ const char *device_path,
+ const char *identifier,
+ const char *source,
+ const char *provider_path,
+ const char *provider_object_path,
+ struct battery_provider *provider)
{
struct btd_battery *battery;
@@ -181,7 +268,8 @@ struct btd_battery *btd_battery_register(const char *path, const char *source,
return NULL;
}
- battery = battery_new(path, source, provider_path);
+ battery = battery_new(path, device_path, identifier, source,
+ provider_path, provider_object_path, provider);
battery_add(battery);
if (!g_dbus_register_interface(btd_get_dbus_connection(), battery->path,
@@ -201,8 +289,78 @@ struct btd_battery *btd_battery_register(const char *path, const char *source,
return battery;
}
+struct btd_battery *btd_battery_register(const char *path, const char *source,
+ const char *provider_path)
+{
+ return battery_register(path, NULL, NULL, source, provider_path, NULL,
+ NULL);
+}
+
+static char *battery_build_component_path(const char *device_path,
+ const char *identifier)
+{
+ const unsigned char *str = (const unsigned char *) identifier;
+ GString *path;
+
+ if (!device_path || !identifier || !identifier[0])
+ return NULL;
+
+ path = g_string_new(device_path);
+ g_string_append(path, "/battery_");
+
+ for (; *str; str++) {
+ if (g_ascii_isalnum(*str))
+ g_string_append_c(path, *str);
+ else
+ g_string_append_printf(path, "_%02x", *str);
+ }
+
+ return g_string_free(path, FALSE);
+}
+
+static struct btd_battery *
+battery_register_component(const char *device_path, const char *identifier,
+ const char *source, const char *provider_path,
+ const char *provider_object_path,
+ struct battery_provider *provider)
+{
+ struct btd_battery *battery;
+ char *path;
+
+ if (!(g_dbus_get_flags() & G_DBUS_FLAG_ENABLE_EXPERIMENTAL)) {
+ DBG("component batteries require experimental interfaces");
+ return NULL;
+ }
+
+ path = battery_build_component_path(device_path, identifier);
+ if (!path) {
+ error("error registering battery: invalid component");
+ return NULL;
+ }
+
+ battery = battery_register(path, device_path, identifier, source,
+ provider_path, provider_object_path,
+ provider);
+ g_free(path);
+
+ return battery;
+}
+
+struct btd_battery *btd_battery_register_component(const char *device_path,
+ const char *identifier,
+ const char *source)
+{
+ return battery_register_component(device_path, identifier, source, NULL,
+ NULL, NULL);
+}
+
bool btd_battery_unregister(struct btd_battery *battery)
{
+ if (!battery) {
+ error("error unregistering battery: battery is null");
+ return false;
+ }
+
DBG("path = %s", battery->path);
if (!queue_find(batteries, NULL, battery)) {
@@ -227,6 +385,11 @@ bool btd_battery_unregister(struct btd_battery *battery)
bool btd_battery_update(struct btd_battery *battery, uint8_t percentage)
{
+ if (!battery) {
+ error("error updating battery: battery is null");
+ return false;
+ }
+
DBG("path = %s", battery->path);
if (!queue_find(batteries, NULL, battery)) {
@@ -234,7 +397,7 @@ bool btd_battery_update(struct btd_battery *battery, uint8_t percentage)
return false;
}
- if (percentage > BATTERY_MAX_PERCENTAGE) {
+ if (percentage > BATTERY_MAX_PERCENTAGE && percentage != UINT8_MAX) {
error("error updating battery: percentage is not valid");
return false;
}
@@ -242,16 +405,76 @@ bool btd_battery_update(struct btd_battery *battery, uint8_t percentage)
if (battery->percentage == percentage)
return true;
- battery->percentage = bt_battery_charge(battery->filter, percentage);
+ if (percentage == UINT8_MAX) {
+ battery->percentage = percentage;
+ bt_battery_free(battery->filter);
+ free(battery->filter);
+ battery->filter = bt_battery_new();
+ } else {
+ battery->percentage = bt_battery_charge(battery->filter,
+ percentage);
+ }
+
g_dbus_emit_property_changed(btd_get_dbus_connection(), battery->path,
BATTERY_INTERFACE, "Percentage");
return true;
}
-static struct btd_battery *find_battery_by_path(const char *path)
+bool btd_battery_update_charging(struct btd_battery *battery, int charging)
{
- return queue_find(batteries, match_path, path);
+ if (!battery) {
+ error("error updating battery: battery is null");
+ return false;
+ }
+
+ DBG("path = %s", battery->path);
+
+ if (!queue_find(batteries, NULL, battery)) {
+ error("error updating battery: battery is not registered");
+ return false;
+ }
+
+ if (charging < -1 || charging > 1) {
+ error("error updating battery: charging state is not valid");
+ return false;
+ }
+
+ if (battery->charging == charging)
+ return true;
+
+ battery->charging = charging;
+ g_dbus_emit_property_changed(btd_get_dbus_connection(), battery->path,
+ BATTERY_INTERFACE, "Charging");
+
+ return true;
+}
+
+struct provider_battery_match {
+ struct battery_provider *provider;
+ const char *object_path;
+};
+
+static bool match_provider_battery(const void *data, const void *user_data)
+{
+ const struct btd_battery *battery = data;
+ const struct provider_battery_match *match = user_data;
+
+ return battery->provider == match->provider &&
+ g_strcmp0(battery->provider_object_path,
+ match->object_path) == 0;
+}
+
+static struct btd_battery *find_provider_battery(
+ struct battery_provider *provider,
+ const char *object_path)
+{
+ struct provider_battery_match match = {
+ .provider = provider,
+ .object_path = object_path,
+ };
+
+ return queue_find(batteries, match_provider_battery, &match);
}
static void provided_battery_property_changed_cb(GDBusProxy *proxy,
@@ -259,29 +482,51 @@ static void provided_battery_property_changed_cb(GDBusProxy *proxy,
DBusMessageIter *iter,
void *user_data)
{
- uint8_t percentage = 0;
- const char *export_path;
- DBusMessageIter dev_iter;
+ struct btd_battery *battery;
+ struct battery_provider *provider = user_data;
+ const char *path = g_dbus_proxy_get_path(proxy);
- if (g_dbus_proxy_get_property(proxy, "Device", &dev_iter) == FALSE)
+ battery = find_provider_battery(provider, path);
+ if (!battery)
return;
- dbus_message_iter_get_basic(&dev_iter, &export_path);
+ if (!strcmp(name, "Percentage")) {
+ uint8_t percentage = UINT8_MAX;
+
+ if (iter) {
+ if (dbus_message_iter_get_arg_type(iter) !=
+ DBUS_TYPE_BYTE)
+ return;
+
+ dbus_message_iter_get_basic(iter, &percentage);
+ }
- if (strcmp(name, "Percentage") != 0)
+ DBG("battery percentage changed on %s, percentage = %d", path,
+ percentage);
+ btd_battery_update(battery, percentage);
return;
+ }
+
+ if (!strcmp(name, "Charging")) {
+ dbus_bool_t value;
+ int charging = -1;
- if (iter) {
- if (dbus_message_iter_get_arg_type(iter) != DBUS_TYPE_BYTE)
+ if (!(g_dbus_get_flags() & G_DBUS_FLAG_ENABLE_EXPERIMENTAL))
return;
- dbus_message_iter_get_basic(iter, &percentage);
- }
+ if (iter) {
+ if (dbus_message_iter_get_arg_type(iter) !=
+ DBUS_TYPE_BOOLEAN)
+ return;
- DBG("battery percentage changed on %s, percentage = %d",
- g_dbus_proxy_get_path(proxy), percentage);
+ dbus_message_iter_get_basic(iter, &value);
+ charging = value;
+ }
- btd_battery_update(find_battery_by_path(export_path), percentage);
+ DBG("battery charging changed on %s, charging = %d", path,
+ charging);
+ btd_battery_update_charging(battery, charging);
+ }
}
static void provided_battery_added_cb(GDBusProxy *proxy, void *user_data)
@@ -290,10 +535,13 @@ static void provided_battery_added_cb(GDBusProxy *proxy, void *user_data)
struct btd_battery *battery;
struct btd_device *device;
const char *path = g_dbus_proxy_get_path(proxy);
- const char *export_path;
+ const char *device_path;
+ const char *identifier = NULL;
const char *source = NULL;
uint8_t percentage;
+ dbus_bool_t charging;
DBusMessageIter iter;
+ bool experimental;
if (strcmp(g_dbus_proxy_get_interface(proxy),
BATTERY_PROVIDER_INTERFACE) != 0)
@@ -304,68 +552,105 @@ static void provided_battery_added_cb(GDBusProxy *proxy, void *user_data)
return;
}
- dbus_message_iter_get_basic(&iter, &export_path);
+ if (dbus_message_iter_get_arg_type(&iter) != DBUS_TYPE_OBJECT_PATH) {
+ warn("Battery object %s has an invalid device path", path);
+ return;
+ }
+
+ dbus_message_iter_get_basic(&iter, &device_path);
device = btd_adapter_find_device_by_path(provider->manager->adapter,
- export_path);
+ device_path);
if (!device || device_is_temporary(device)) {
warn("Ignoring non-existent device path for battery %s",
- export_path);
+ device_path);
return;
}
- if (find_battery_by_path(export_path)) {
- DBG("Battery for %s is already provided, ignoring the new one",
- export_path);
+ experimental = g_dbus_get_flags() & G_DBUS_FLAG_ENABLE_EXPERIMENTAL;
+
+ if (g_dbus_proxy_get_property(proxy, "Identifier", &iter) == TRUE) {
+ if (dbus_message_iter_get_arg_type(&iter) != DBUS_TYPE_STRING) {
+ warn("Battery object %s has an invalid identifier",
+ path);
+ return;
+ }
+
+ dbus_message_iter_get_basic(&iter, &identifier);
+ if (!identifier[0]) {
+ warn("Battery object %s has an empty identifier", path);
+ return;
+ }
+
+ if (!experimental) {
+ warn("Ignoring experimental component battery %s",
+ path);
+ return;
+ }
+ }
+
+ if (g_dbus_proxy_get_property(proxy, "Source", &iter) == TRUE &&
+ dbus_message_iter_get_arg_type(&iter) ==
+ DBUS_TYPE_STRING)
+ dbus_message_iter_get_basic(&iter, &source);
+
+ if (identifier) {
+ battery = battery_register_component(device_path, identifier,
+ source, provider->path,
+ path, provider);
+ } else {
+ battery = battery_register(device_path, NULL, NULL, source,
+ provider->path, path, provider);
+ }
+
+ if (!battery) {
+ warn("Unable to add battery object %s for %s", path,
+ device_path);
return;
}
g_dbus_proxy_set_property_watch(
proxy, provided_battery_property_changed_cb, provider);
- if (g_dbus_proxy_get_property(proxy, "Source", &iter) == TRUE)
- dbus_message_iter_get_basic(&iter, &source);
-
- battery = btd_battery_register(export_path, source, provider->path);
-
DBG("provided battery added %s", path);
/* Percentage property may not be immediately available, that's okay
* since we monitor changes to this property.
*/
- if (g_dbus_proxy_get_property(proxy, "Percentage", &iter) == FALSE)
- return;
-
- dbus_message_iter_get_basic(&iter, &percentage);
+ if (g_dbus_proxy_get_property(proxy, "Percentage", &iter) == TRUE &&
+ dbus_message_iter_get_arg_type(&iter) ==
+ DBUS_TYPE_BYTE) {
+ dbus_message_iter_get_basic(&iter, &percentage);
+ btd_battery_update(battery, percentage);
+ }
- btd_battery_update(battery, percentage);
+ if (experimental) {
+ if (g_dbus_proxy_get_property(proxy, "Charging",
+ &iter) == TRUE &&
+ dbus_message_iter_get_arg_type(&iter) ==
+ DBUS_TYPE_BOOLEAN) {
+ dbus_message_iter_get_basic(&iter, &charging);
+ btd_battery_update_charging(battery, charging);
+ }
+ }
}
static void provided_battery_removed_cb(GDBusProxy *proxy, void *user_data)
{
struct battery_provider *provider = user_data;
struct btd_battery *battery;
- const char *export_path;
- DBusMessageIter iter;
+ const char *path = g_dbus_proxy_get_path(proxy);
if (strcmp(g_dbus_proxy_get_interface(proxy),
BATTERY_PROVIDER_INTERFACE) != 0)
return;
- if (g_dbus_proxy_get_property(proxy, "Device", &iter) == FALSE)
- return;
+ DBG("provided battery removed %s", path);
- dbus_message_iter_get_basic(&iter, &export_path);
-
- DBG("provided battery removed %s", g_dbus_proxy_get_path(proxy));
-
- battery = find_battery_by_path(export_path);
+ battery = find_provider_battery(provider, path);
if (!battery)
return;
- if (g_strcmp0(battery->provider_path, provider->path) != 0)
- return;
-
g_dbus_proxy_set_property_watch(proxy, NULL, NULL);
btd_battery_unregister(battery);
@@ -384,7 +669,7 @@ static void unregister_if_path_has_prefix(void *data, void *user_data)
struct btd_battery *battery = data;
struct battery_provider *provider = user_data;
- if (g_strcmp0(battery->provider_path, provider->path) == 0)
+ if (battery->provider == provider)
btd_battery_unregister(battery);
}
@@ -392,7 +677,7 @@ static void battery_provider_free(gpointer data)
{
struct battery_provider *provider = data;
- /* Unregister batteries under the root path of provider->path */
+ /* Unregister batteries registered by this provider. */
queue_foreach(batteries, unregister_if_path_has_prefix, provider);
if (provider->owner)
diff --git a/src/battery.h b/src/battery.h
index 271659474..2b459809c 100644
--- a/src/battery.h
+++ b/src/battery.h
@@ -14,8 +14,12 @@ struct btd_battery_provider_manager;
struct btd_battery *btd_battery_register(const char *path, const char *source,
const char *provider_path);
+struct btd_battery *btd_battery_register_component(const char *device_path,
+ const char *identifier,
+ const char *source);
bool btd_battery_unregister(struct btd_battery *battery);
bool btd_battery_update(struct btd_battery *battery, uint8_t percentage);
+bool btd_battery_update_charging(struct btd_battery *battery, int charging);
struct btd_battery_provider_manager *
btd_battery_provider_manager_create(struct btd_adapter *adapter);
--
2.55.0
^ permalink raw reply related [flat|nested] 11+ messages in thread* RE: Add component batteries and Fast Pair Message Stream
2026-08-19 22:31 ` [PATCH BlueZ 1/4] battery: Add component battery objects Matthias Kurz
@ 2026-08-19 23:37 ` bluez.test.bot
0 siblings, 0 replies; 11+ messages in thread
From: bluez.test.bot @ 2026-08-19 23:37 UTC (permalink / raw)
To: linux-bluetooth, m.kurz
[-- Attachment #1: Type: text/plain, Size: 4661 bytes --]
This is automated email and please do not reply to this email!
Dear submitter,
Thank you for submitting the patches to the linux bluetooth mailing list.
This is a CI test results with your patch series:
PW Link:https://patchwork.kernel.org/project/bluetooth/list/?series=1148725
---Test result---
Test Summary:
CheckPatch FAIL 2.89 seconds
GitLint PASS 1.31 seconds
BuildEll PASS 20.10 seconds
BluezMake PASS 554.31 seconds
MakeCheck PASS 19.07 seconds
MakeDistcheck PASS 153.38 seconds
CheckValgrind PASS 222.00 seconds
CheckSmatch PASS 296.51 seconds
bluezmakeextell PASS 95.54 seconds
IncrementalBuild PASS 589.67 seconds
ScanBuild PASS 877.69 seconds
Details
##############################
Test: CheckPatch - FAIL
Desc: Run checkpatch.pl script
Output:
[BlueZ,1/4] battery: Add component battery objects
WARNING:BAD_SIGN_OFF: Non-standard signature: Assisted-by:
#112:
Assisted-by: Codex:gpt-5.6-sol
ERROR:BAD_SIGN_OFF: Unrecognized email address: 'Codex:gpt-5.6-sol'
#112:
Assisted-by: Codex:gpt-5.6-sol
/github/workspace/src/patch/14758177.patch total: 1 errors, 1 warnings, 575 lines checked
NOTE: For some of the reported defects, checkpatch may be able to
mechanically convert to the typical style using --fix or --fix-inplace.
/github/workspace/src/patch/14758177.patch has style problems, please review.
NOTE: Ignored message types: COMMIT_MESSAGE COMPLEX_MACRO CONST_STRUCT FILE_PATH_CHANGES MISSING_SIGN_OFF PREFER_PACKED SPDX_LICENSE_TAG SPLIT_STRING SSCANF_TO_KSTRTO
NOTE: If any of the errors are false positives, please report
them to the maintainer, see CHECKPATCH in MAINTAINERS.
[BlueZ,2/4] doc: Document component battery objects
WARNING:BAD_SIGN_OFF: Non-standard signature: Assisted-by:
#105:
Assisted-by: Codex:gpt-5.6-sol
ERROR:BAD_SIGN_OFF: Unrecognized email address: 'Codex:gpt-5.6-sol'
#105:
Assisted-by: Codex:gpt-5.6-sol
/github/workspace/src/patch/14758178.patch total: 1 errors, 1 warnings, 70 lines checked
NOTE: For some of the reported defects, checkpatch may be able to
mechanically convert to the typical style using --fix or --fix-inplace.
/github/workspace/src/patch/14758178.patch has style problems, please review.
NOTE: Ignored message types: COMMIT_MESSAGE COMPLEX_MACRO CONST_STRUCT FILE_PATH_CHANGES MISSING_SIGN_OFF PREFER_PACKED SPDX_LICENSE_TAG SPLIT_STRING SSCANF_TO_KSTRTO
NOTE: If any of the errors are false positives, please report
them to the maintainer, see CHECKPATCH in MAINTAINERS.
[BlueZ,3/4] fastpair: Add Message Stream battery profile
WARNING:BAD_SIGN_OFF: Non-standard signature: Assisted-by:
#116:
Assisted-by: Codex:gpt-5.6-sol
ERROR:BAD_SIGN_OFF: Unrecognized email address: 'Codex:gpt-5.6-sol'
#116:
Assisted-by: Codex:gpt-5.6-sol
/github/workspace/src/patch/14758179.patch total: 1 errors, 1 warnings, 1139 lines checked
NOTE: For some of the reported defects, checkpatch may be able to
mechanically convert to the typical style using --fix or --fix-inplace.
/github/workspace/src/patch/14758179.patch has style problems, please review.
NOTE: Ignored message types: COMMIT_MESSAGE COMPLEX_MACRO CONST_STRUCT FILE_PATH_CHANGES MISSING_SIGN_OFF PREFER_PACKED SPDX_LICENSE_TAG SPLIT_STRING SSCANF_TO_KSTRTO
NOTE: If any of the errors are false positives, please report
them to the maintainer, see CHECKPATCH in MAINTAINERS.
[BlueZ,4/4] test: Add Fast Pair Message Stream tool
WARNING:BAD_SIGN_OFF: Non-standard signature: Assisted-by:
#109:
Assisted-by: Codex:gpt-5.6-sol
ERROR:BAD_SIGN_OFF: Unrecognized email address: 'Codex:gpt-5.6-sol'
#109:
Assisted-by: Codex:gpt-5.6-sol
ERROR:EXECUTE_PERMISSIONS: do not set execute permissions for source files
#130: FILE: test/test-fastpair
/github/workspace/src/patch/14758180.patch total: 2 errors, 1 warnings, 569 lines checked
NOTE: For some of the reported defects, checkpatch may be able to
mechanically convert to the typical style using --fix or --fix-inplace.
/github/workspace/src/patch/14758180.patch has style problems, please review.
NOTE: Ignored message types: COMMIT_MESSAGE COMPLEX_MACRO CONST_STRUCT FILE_PATH_CHANGES MISSING_SIGN_OFF PREFER_PACKED SPDX_LICENSE_TAG SPLIT_STRING SSCANF_TO_KSTRTO
NOTE: If any of the errors are false positives, please report
them to the maintainer, see CHECKPATCH in MAINTAINERS.
https://github.com/bluez/bluez/pull/2416
---
Regards,
Linux Bluetooth
^ permalink raw reply [flat|nested] 11+ messages in thread
* [PATCH BlueZ 2/4] doc: Document component battery objects
2026-08-19 22:31 [PATCH BlueZ 0/4] Add component batteries and Fast Pair Message Stream Matthias Kurz
2026-08-19 22:31 ` [PATCH BlueZ 1/4] battery: Add component battery objects Matthias Kurz
@ 2026-08-19 22:31 ` Matthias Kurz
2026-08-19 22:31 ` [PATCH BlueZ 3/4] fastpair: Add Message Stream battery profile Matthias Kurz
` (3 subsequent siblings)
5 siblings, 0 replies; 11+ messages in thread
From: Matthias Kurz @ 2026-08-19 22:31 UTC (permalink / raw)
To: linux-bluetooth
Describe the experimental component Battery1 properties, their opaque
object paths, and how BatteryProvider1 implementations publish several
batteries for one device.
Assisted-by: Codex:gpt-5.6-sol
---
doc/org.bluez.Battery.rst | 35 +++++++++++++++++++++++++++++--
doc/org.bluez.BatteryProvider.rst | 16 ++++++++++++++
2 files changed, 49 insertions(+), 2 deletions(-)
diff --git a/doc/org.bluez.Battery.rst b/doc/org.bluez.Battery.rst
index 5f9c6e7c6..e68c5d260 100644
--- a/doc/org.bluez.Battery.rst
+++ b/doc/org.bluez.Battery.rst
@@ -17,15 +17,27 @@ Interface
:Service: org.bluez
:Interface: org.bluez.Battery1
:Object path: [variable prefix]/{hci0,hci1,...}/dev_{BDADDR}
+ [/battery_{identifier}]
+
+Component battery objects are experimental. Their object paths are
+implementation details and shall be treated as opaque. Clients shall use the
+``Device`` and ``Identifier`` properties to associate a component with its
+parent device and its stable identity.
+
+For diagnostic purposes, ASCII letters and digits in the identifier are kept
+in the object path. Every other byte is encoded as an underscore followed by
+two lowercase hexadecimal digits.
Properties
----------
-byte Percentage [readonly]
-``````````````````````````
+byte Percentage [readonly, optional]
+````````````````````````````````````
The percentage of battery left as an unsigned 8-bit integer.
+The property is absent while the battery level is unknown.
+
string Source [readonly, optional]
``````````````````````````````````
@@ -36,3 +48,22 @@ This property is informational only and may be useful for debugging purposes.
Providers from **org.bluez.BatteryProvider(5)** may make use of this property to
indicate where the battery report comes from (e.g. "HFP 1.7", "HID", or the
profile UUID).
+
+object Device [readonly, optional, experimental]
+````````````````````````````````````````````````````````````
+
+The object path of the device containing this battery.
+
+This property is present on component battery objects below the device object.
+
+string Identifier [readonly, optional, experimental]
+````````````````````````````````````````````````````````````
+
+A stable identifier for this battery within the device, such as ``left``,
+``right``, or ``case``.
+
+boolean Charging [readonly, optional, experimental]
+````````````````````````````````````````````````````````````
+
+Indicates whether this battery is currently charging. The property is absent
+while the charging state is unknown.
diff --git a/doc/org.bluez.BatteryProvider.rst b/doc/org.bluez.BatteryProvider.rst
index 2373cebf9..b79cbe6f5 100644
--- a/doc/org.bluez.BatteryProvider.rst
+++ b/doc/org.bluez.BatteryProvider.rst
@@ -30,3 +30,19 @@ object Device [readonly]
````````````````````````
The object path of the device that has this battery.
+
+string Identifier [readonly, optional, experimental]
+````````````````````````````````````````````````````````````
+
+A non-empty identifier that is unique among the batteries for this device.
+Multiple batteries may refer to the same device when each provides a unique
+identifier. They are reflected as component **org.bluez.Battery1** objects.
+
+A provider object without this property represents the legacy aggregate
+battery and is reflected directly on the device object.
+
+boolean Charging [readonly, optional, experimental]
+````````````````````````````````````````````````````````````
+
+Indicates whether this battery is currently charging. The property is absent
+while the charging state is unknown.
--
2.55.0
^ permalink raw reply related [flat|nested] 11+ messages in thread* [PATCH BlueZ 3/4] fastpair: Add Message Stream battery profile
2026-08-19 22:31 [PATCH BlueZ 0/4] Add component batteries and Fast Pair Message Stream Matthias Kurz
2026-08-19 22:31 ` [PATCH BlueZ 1/4] battery: Add component battery objects Matthias Kurz
2026-08-19 22:31 ` [PATCH BlueZ 2/4] doc: Document component battery objects Matthias Kurz
@ 2026-08-19 22:31 ` Matthias Kurz
2026-08-19 22:31 ` [PATCH BlueZ 4/4] test: Add Fast Pair Message Stream tool Matthias Kurz
` (2 subsequent siblings)
5 siblings, 0 replies; 11+ messages in thread
From: Matthias Kurz @ 2026-08-19 22:31 UTC (permalink / raw)
To: linux-bluetooth
Connect to the experimental Fast Pair Message Stream service advertised by
compatible BR/EDR devices and parse its 16-bit-length framed messages.
Publish left, right, and case values as component Battery1 objects.
Include unknown percentage and charging-state transitions. Decode unknown
levels using the generic Battery Notification status bit while treating
the TWS-specific case value 0xff as unavailable.
If only the Message Stream is lost, invalidate its values and reconnect.
Use exponential backoff and reset it only after a battery-producing stream
remains stable. Remove the objects once the BR/EDR bearer disappears.
Keep pending connection callbacks alive through cancellation and ignore
callbacks for detached or superseded channels.
Assisted-by: Codex:gpt-5.6-sol
---
.gitignore | 1 +
Makefile.am | 8 +
Makefile.plugins | 5 +
profiles/fastpair/fastpair.c | 650 +++++++++++++++++++++++++++++
profiles/fastpair/message-stream.c | 129 ++++++
profiles/fastpair/message-stream.h | 42 ++
unit/test-fastpair.c | 286 +++++++++++++
7 files changed, 1121 insertions(+)
create mode 100644 profiles/fastpair/fastpair.c
create mode 100644 profiles/fastpair/message-stream.c
create mode 100644 profiles/fastpair/message-stream.h
create mode 100644 unit/test-fastpair.c
diff --git a/.gitignore b/.gitignore
index c5efe8536..76612904f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -116,6 +116,7 @@ unit/test-hog
unit/test-bap
unit/test-bass
unit/test-battery
+unit/test-fastpair
unit/test-tmap
unit/test-gmap
unit/test-mcp
diff --git a/Makefile.am b/Makefile.am
index 2754e1b7f..7771feaaa 100644
--- a/Makefile.am
+++ b/Makefile.am
@@ -799,6 +799,14 @@ unit_test_battery_SOURCES = unit/test-battery.c
unit_test_battery_LDADD = src/libshared-glib.la \
lib/libbluetooth-internal.la $(GLIB_LIBS)
+unit_tests += unit/test-fastpair
+
+unit_test_fastpair_SOURCES = unit/test-fastpair.c \
+ profiles/fastpair/message-stream.h \
+ profiles/fastpair/message-stream.c
+unit_test_fastpair_LDADD = src/libshared-glib.la \
+ lib/libbluetooth-internal.la $(GLIB_LIBS)
+
unit_tests += unit/test-rap
unit_test_rap_SOURCES = unit/test-rap.c $(btio_sources)
diff --git a/Makefile.plugins b/Makefile.plugins
index ac667beda..4b41510f0 100644
--- a/Makefile.plugins
+++ b/Makefile.plugins
@@ -88,6 +88,11 @@ endif
builtin_modules += battery
builtin_sources += profiles/battery/battery.c
+builtin_modules += fastpair
+builtin_sources += profiles/fastpair/fastpair.c \
+ profiles/fastpair/message-stream.h \
+ profiles/fastpair/message-stream.c
+
builtin_modules += rap
builtin_sources += profiles/ranging/rap.c \
profiles/ranging/rap_hci.c
diff --git a/profiles/fastpair/fastpair.c b/profiles/fastpair/fastpair.c
new file mode 100644
index 000000000..cbb56ac53
--- /dev/null
+++ b/profiles/fastpair/fastpair.c
@@ -0,0 +1,650 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+/*
+ *
+ * BlueZ - Bluetooth protocol stack for Linux
+ *
+ * Copyright (C) 2026 Matthias Kurz
+ *
+ */
+
+#ifdef HAVE_CONFIG_H
+#include <config.h>
+#endif
+
+#define _GNU_SOURCE
+#include <errno.h>
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include <glib.h>
+
+#include "bluetooth/bluetooth.h"
+#include "bluetooth/sdp.h"
+#include "bluetooth/sdp_lib.h"
+#include "bluetooth/uuid.h"
+
+#include "btio/btio.h"
+#include "src/adapter.h"
+#include "src/battery.h"
+#include "src/device.h"
+#include "src/log.h"
+#include "src/plugin.h"
+#include "src/profile.h"
+#include "src/service.h"
+
+#include "message-stream.h"
+
+#define FASTPAIR_BATTERY_SOURCE "Fast Pair Message Stream"
+#define FASTPAIR_RECONNECT_MIN 1
+#define FASTPAIR_RECONNECT_MAX 60
+
+struct fastpair {
+ int ref_count;
+ struct btd_service *service;
+ GIOChannel *io;
+ guint io_id;
+ guint reconnect_id;
+ guint disconnect_id;
+ unsigned int reconnect_delay;
+ gint64 connected_since;
+ bool handling_bredr_disconnect;
+ bool battery_update_received;
+ struct fastpair_message_stream *stream;
+ struct btd_battery *batteries[FASTPAIR_BATTERY_COUNT];
+ bool registration_failed[FASTPAIR_BATTERY_COUNT];
+};
+
+static unsigned int service_state_id;
+
+static const char *battery_identifiers[FASTPAIR_BATTERY_COUNT] = {
+ "left",
+ "right",
+ "case",
+};
+
+static struct fastpair *fastpair_ref(struct fastpair *fastpair)
+{
+ __sync_fetch_and_add(&fastpair->ref_count, 1);
+
+ return fastpair;
+}
+
+static void fastpair_unref(void *data)
+{
+ struct fastpair *fastpair = data;
+
+ if (__sync_sub_and_fetch(&fastpair->ref_count, 1))
+ return;
+
+ g_free(fastpair);
+}
+
+static void fastpair_cancel_reconnect(struct fastpair *fastpair)
+{
+ if (!fastpair->reconnect_id)
+ return;
+
+ g_source_remove(fastpair->reconnect_id);
+ fastpair->reconnect_id = 0;
+}
+
+static void fastpair_unregister_batteries(struct fastpair *fastpair)
+{
+ unsigned int i;
+
+ for (i = 0; i < FASTPAIR_BATTERY_COUNT; i++) {
+ if (!fastpair->batteries[i])
+ continue;
+
+ btd_battery_unregister(fastpair->batteries[i]);
+ fastpair->batteries[i] = NULL;
+ }
+
+ memset(fastpair->registration_failed, 0,
+ sizeof(fastpair->registration_failed));
+}
+
+static void fastpair_invalidate_batteries(struct fastpair *fastpair)
+{
+ unsigned int i;
+
+ for (i = 0; i < FASTPAIR_BATTERY_COUNT; i++) {
+ if (!fastpair->batteries[i])
+ continue;
+
+ btd_battery_update(fastpair->batteries[i],
+ FASTPAIR_BATTERY_UNKNOWN);
+ btd_battery_update_charging(fastpair->batteries[i],
+ FASTPAIR_CHARGING_UNKNOWN);
+ }
+}
+
+static void fastpair_handle_bredr_disconnect(struct fastpair *fastpair);
+
+static void fastpair_device_disconnected(struct btd_device *device,
+ gboolean removal, void *user_data)
+{
+ struct fastpair *fastpair = user_data;
+
+ DBG("%s disconnected%s", device_get_path(device),
+ removal ? " and removed" : "");
+
+ fastpair->disconnect_id = 0;
+ fastpair_handle_bredr_disconnect(fastpair);
+}
+
+static void fastpair_watch_disconnect(struct fastpair *fastpair)
+{
+ struct btd_device *device;
+
+ if (fastpair->handling_bredr_disconnect || fastpair->disconnect_id)
+ return;
+
+ device = btd_service_get_device(fastpair->service);
+ fastpair->disconnect_id = device_add_disconnect_watch(device,
+ fastpair_device_disconnected,
+ fastpair, NULL);
+}
+
+static void fastpair_unwatch_disconnect(struct fastpair *fastpair)
+{
+ struct btd_device *device;
+
+ if (!fastpair->disconnect_id)
+ return;
+
+ device = btd_service_get_device(fastpair->service);
+ device_remove_disconnect_watch(device, fastpair->disconnect_id);
+ fastpair->disconnect_id = 0;
+}
+
+static void fastpair_reset_connection(struct fastpair *fastpair)
+{
+ fastpair_cancel_reconnect(fastpair);
+
+ if (fastpair->io_id) {
+ g_source_remove(fastpair->io_id);
+ fastpair->io_id = 0;
+ }
+
+ if (fastpair->io) {
+ g_io_channel_shutdown(fastpair->io, TRUE, NULL);
+ g_io_channel_unref(fastpair->io);
+ fastpair->io = NULL;
+ }
+
+ fastpair_message_stream_free(fastpair->stream);
+ fastpair->stream = NULL;
+ fastpair->connected_since = 0;
+ fastpair->battery_update_received = false;
+}
+
+static bool fastpair_stream_was_stable(struct fastpair *fastpair)
+{
+ gint64 duration;
+
+ if (!fastpair->battery_update_received || !fastpair->connected_since)
+ return false;
+
+ duration = g_get_monotonic_time() - fastpair->connected_since;
+
+ return duration >= (gint64) FASTPAIR_RECONNECT_MAX *
+ G_USEC_PER_SEC;
+}
+
+static void fastpair_handle_bredr_disconnect(struct fastpair *fastpair)
+{
+ btd_service_state_t state;
+
+ if (fastpair->handling_bredr_disconnect)
+ return;
+
+ fastpair->handling_bredr_disconnect = true;
+ state = fastpair->service ? btd_service_get_state(fastpair->service) :
+ BTD_SERVICE_STATE_UNAVAILABLE;
+
+ fastpair_reset_connection(fastpair);
+
+ if (state == BTD_SERVICE_STATE_CONNECTING)
+ btd_service_connecting_complete(fastpair->service, -ENOTCONN);
+ else if (state == BTD_SERVICE_STATE_CONNECTED ||
+ state == BTD_SERVICE_STATE_DISCONNECTING)
+ btd_service_disconnecting_complete(fastpair->service, 0);
+
+ fastpair->reconnect_delay = FASTPAIR_RECONNECT_MIN;
+ fastpair_unregister_batteries(fastpair);
+ fastpair->handling_bredr_disconnect = false;
+}
+
+static void fastpair_schedule_reconnect(struct fastpair *fastpair);
+
+static bool fastpair_connection_error_is_transient(int err)
+{
+ switch (err) {
+ case -ECONNABORTED:
+ case -ENOENT:
+ case -ENOTSUP:
+ case -EPROTO:
+ return false;
+ default:
+ return true;
+ }
+}
+
+static gboolean fastpair_auto_connect(gpointer user_data)
+{
+ struct fastpair *fastpair = user_data;
+ struct btd_device *device;
+ btd_service_state_t state;
+ int err;
+
+ fastpair->reconnect_id = 0;
+ if (!fastpair->service)
+ return FALSE;
+
+ device = btd_service_get_device(fastpair->service);
+ state = btd_service_get_state(fastpair->service);
+
+ if (!btd_device_bdaddr_type_connected(device, BDADDR_BREDR)) {
+ fastpair_handle_bredr_disconnect(fastpair);
+ return FALSE;
+ }
+
+ if (state != BTD_SERVICE_STATE_DISCONNECTED)
+ return FALSE;
+
+ err = btd_service_connect(fastpair->service);
+ if (err < 0 && err != -EALREADY) {
+ DBG("unable to auto-connect Message Stream: %s",
+ strerror(-err));
+ if (fastpair_connection_error_is_transient(err))
+ fastpair_schedule_reconnect(fastpair);
+ }
+
+ return FALSE;
+}
+
+static void fastpair_schedule_connect(struct fastpair *fastpair,
+ unsigned int delay)
+{
+ struct btd_device *device;
+ btd_service_state_t state;
+
+ if (!fastpair->service || fastpair->reconnect_id)
+ return;
+
+ device = btd_service_get_device(fastpair->service);
+ state = btd_service_get_state(fastpair->service);
+
+ if (!device_is_paired(device, BDADDR_BREDR) ||
+ !btd_device_bdaddr_type_connected(device,
+ BDADDR_BREDR) ||
+ (state != BTD_SERVICE_STATE_UNAVAILABLE &&
+ state != BTD_SERVICE_STATE_DISCONNECTED))
+ return;
+
+ if (delay)
+ fastpair->reconnect_id = g_timeout_add_seconds(
+ delay, fastpair_auto_connect, fastpair);
+ else
+ fastpair->reconnect_id = g_idle_add(fastpair_auto_connect,
+ fastpair);
+}
+
+static void fastpair_schedule_auto_connect(struct fastpair *fastpair)
+{
+ fastpair_schedule_connect(fastpair, 0);
+}
+
+static void fastpair_schedule_reconnect(struct fastpair *fastpair)
+{
+ unsigned int delay = fastpair->reconnect_delay;
+
+ fastpair_schedule_connect(fastpair, delay);
+ if (!fastpair->reconnect_id || delay >= FASTPAIR_RECONNECT_MAX)
+ return;
+
+ fastpair->reconnect_delay = MIN(delay * 2,
+ FASTPAIR_RECONNECT_MAX);
+}
+
+static void fastpair_service_state_cb(struct btd_service *service,
+ btd_service_state_t old_state,
+ btd_service_state_t new_state,
+ void *user_data)
+{
+ struct btd_device *device;
+ struct btd_service *fastpair_service;
+ struct fastpair *fastpair;
+
+ device = btd_service_get_device(service);
+ fastpair_service = btd_device_get_service(device,
+ FASTPAIR_MESSAGE_STREAM_UUID);
+ if (!fastpair_service)
+ return;
+
+ fastpair = btd_service_get_user_data(fastpair_service);
+ if (!fastpair)
+ return;
+
+ /*
+ * A disconnect watch can run before the connected services settle and
+ * is not invoked again. Use service transitions as a fallback once the
+ * BR/EDR bearer itself is gone.
+ */
+ if (!btd_device_bdaddr_type_connected(device, BDADDR_BREDR)) {
+ if (new_state == BTD_SERVICE_STATE_UNAVAILABLE ||
+ new_state == BTD_SERVICE_STATE_DISCONNECTED)
+ fastpair_handle_bredr_disconnect(fastpair);
+ return;
+ }
+
+ if (new_state != BTD_SERVICE_STATE_CONNECTED)
+ return;
+
+ /*
+ * Disconnect watches are one-shot, so restore ours after reconnection.
+ */
+ fastpair_watch_disconnect(fastpair);
+
+ if (fastpair_service != service)
+ fastpair_schedule_auto_connect(fastpair);
+}
+
+static void fastpair_update_batteries(struct fastpair *fastpair,
+ const uint8_t *payload,
+ uint16_t length)
+{
+ struct fastpair_battery values[FASTPAIR_BATTERY_COUNT];
+ struct btd_device *device;
+ const char *path;
+ unsigned int i;
+
+ if (!fastpair_message_get_batteries(
+ FASTPAIR_DEVICE_INFORMATION_GROUP,
+ FASTPAIR_BATTERY_UPDATE_CODE, payload, length, values))
+ return;
+
+ fastpair->battery_update_received = true;
+
+ device = btd_service_get_device(fastpair->service);
+ path = device_get_path(device);
+
+ for (i = 0; i < FASTPAIR_BATTERY_COUNT; i++) {
+ if (!fastpair->batteries[i] &&
+ !fastpair->registration_failed[i]) {
+ fastpair->batteries[i] = btd_battery_register_component(
+ path, battery_identifiers[i],
+ FASTPAIR_BATTERY_SOURCE);
+ if (!fastpair->batteries[i])
+ fastpair->registration_failed[i] = true;
+ }
+
+ if (!fastpair->batteries[i])
+ continue;
+
+ btd_battery_update(fastpair->batteries[i],
+ values[i].percentage);
+ btd_battery_update_charging(fastpair->batteries[i],
+ values[i].charging);
+ }
+}
+
+static void fastpair_message(uint8_t group, uint8_t code,
+ const uint8_t *payload, uint16_t length,
+ void *user_data)
+{
+ struct fastpair *fastpair = user_data;
+
+ DBG("group 0x%02x code 0x%02x length %u", group, code, length);
+
+ if (group != FASTPAIR_DEVICE_INFORMATION_GROUP ||
+ code != FASTPAIR_BATTERY_UPDATE_CODE)
+ return;
+
+ fastpair_update_batteries(fastpair, payload, length);
+}
+
+static void fastpair_disconnected(struct fastpair *fastpair, int err,
+ bool reconnect)
+{
+ struct btd_device *device;
+ btd_service_state_t state;
+ bool bredr_connected;
+ bool stream_was_stable;
+
+ if (!fastpair->service) {
+ fastpair_reset_connection(fastpair);
+ return;
+ }
+
+ device = btd_service_get_device(fastpair->service);
+ state = btd_service_get_state(fastpair->service);
+ bredr_connected = btd_device_bdaddr_type_connected(device,
+ BDADDR_BREDR);
+ if (!bredr_connected) {
+ fastpair_handle_bredr_disconnect(fastpair);
+ return;
+ }
+
+ stream_was_stable = fastpair_stream_was_stable(fastpair);
+ fastpair_reset_connection(fastpair);
+
+ if (state == BTD_SERVICE_STATE_CONNECTING)
+ btd_service_connecting_complete(fastpair->service, err);
+ else if (state == BTD_SERVICE_STATE_CONNECTED ||
+ state == BTD_SERVICE_STATE_DISCONNECTING)
+ btd_service_disconnecting_complete(fastpair->service, 0);
+
+ /* Short battery-producing sessions must continue backing off. */
+ if (stream_was_stable)
+ fastpair->reconnect_delay = FASTPAIR_RECONNECT_MIN;
+
+ fastpair_invalidate_batteries(fastpair);
+ if (reconnect)
+ fastpair_schedule_reconnect(fastpair);
+}
+
+static gboolean fastpair_io_cb(GIOChannel *io, GIOCondition condition,
+ gpointer user_data)
+{
+ struct fastpair *fastpair = user_data;
+ uint8_t buffer[4096];
+ ssize_t len;
+ int fd;
+
+ if (condition & G_IO_IN) {
+ fd = g_io_channel_unix_get_fd(io);
+
+ do {
+ len = read(fd, buffer, sizeof(buffer));
+ } while (len < 0 && errno == EINTR);
+
+ if (len > 0) {
+ if (!fastpair_message_stream_feed(fastpair->stream,
+ buffer, len)) {
+ error("Invalid Fast Pair Message Stream frame");
+ goto failed;
+ }
+ } else if (!len) {
+ goto failed;
+ } else if (errno != EAGAIN && errno != EWOULDBLOCK) {
+ error("Fast Pair Message Stream read failed: %s",
+ strerror(errno));
+ goto failed;
+ }
+ }
+
+ if (condition & (G_IO_HUP | G_IO_ERR | G_IO_NVAL))
+ goto failed;
+
+ return TRUE;
+
+failed:
+ fastpair->io_id = 0;
+ fastpair_disconnected(fastpair, -EIO, true);
+ return FALSE;
+}
+
+static void fastpair_connect_cb(GIOChannel *io, GError *err,
+ gpointer user_data)
+{
+ struct fastpair *fastpair = user_data;
+
+ /* A closed pending channel can still dispatch its btio source. */
+ if (!fastpair->service || fastpair->io != io)
+ return;
+
+ if (err) {
+ error("Fast Pair Message Stream connection failed: %s",
+ err->message);
+ fastpair_disconnected(fastpair, -EIO, true);
+ return;
+ }
+
+ fastpair->stream = fastpair_message_stream_new(fastpair_message,
+ fastpair);
+ if (!fastpair->stream) {
+ fastpair_disconnected(fastpair, -ENOMEM, true);
+ return;
+ }
+ fastpair->connected_since = g_get_monotonic_time();
+
+ fastpair->io_id = g_io_add_watch(io,
+ G_IO_IN | G_IO_HUP | G_IO_ERR | G_IO_NVAL,
+ fastpair_io_cb, fastpair);
+ memset(fastpair->registration_failed, 0,
+ sizeof(fastpair->registration_failed));
+ btd_service_connecting_complete(fastpair->service, 0);
+}
+
+static int fastpair_connect(struct btd_service *service)
+{
+ struct fastpair *fastpair = btd_service_get_user_data(service);
+ struct btd_device *device = btd_service_get_device(service);
+ struct btd_adapter *adapter = device_get_adapter(device);
+ const sdp_record_t *record;
+ sdp_list_t *protos;
+ GError *err = NULL;
+ GIOChannel *io;
+ int channel;
+
+ if (fastpair->io)
+ return -EALREADY;
+
+ record = btd_device_get_record(device, FASTPAIR_MESSAGE_STREAM_UUID);
+ if (!record)
+ return -ENOENT;
+
+ if (sdp_get_access_protos(record, &protos) < 0) {
+ error("Unable to get Fast Pair access protocols");
+ return -EPROTO;
+ }
+
+ channel = sdp_get_proto_port(protos, RFCOMM_UUID);
+ sdp_list_foreach(protos, (sdp_list_func_t) sdp_list_free, NULL);
+ sdp_list_free(protos, NULL);
+ if (channel <= 0) {
+ error("Unable to get Fast Pair RFCOMM channel");
+ return -EPROTO;
+ }
+
+ /* Keep the callback context alive until btio destroys its source. */
+ fastpair_ref(fastpair);
+ io = bt_io_connect(fastpair_connect_cb, fastpair, fastpair_unref, &err,
+ BT_IO_OPT_SOURCE_BDADDR,
+ btd_adapter_get_address(adapter),
+ BT_IO_OPT_DEST_BDADDR, device_get_address(device),
+ BT_IO_OPT_SEC_LEVEL, BT_IO_SEC_MEDIUM,
+ BT_IO_OPT_CHANNEL, channel,
+ BT_IO_OPT_INVALID);
+ if (!io) {
+ fastpair_unref(fastpair);
+ error("Unable to start Fast Pair connection: %s",
+ err ? err->message : strerror(EIO));
+ g_clear_error(&err);
+ return -EIO;
+ }
+
+ fastpair->io = io;
+
+ return 0;
+}
+
+static int fastpair_disconnect(struct btd_service *service)
+{
+ struct fastpair *fastpair = btd_service_get_user_data(service);
+
+ if (!fastpair->io)
+ return -ENOTCONN;
+
+ fastpair_disconnected(fastpair, 0, false);
+
+ return 0;
+}
+
+static int fastpair_probe(struct btd_service *service)
+{
+ struct fastpair *fastpair;
+
+ fastpair = g_new0(struct fastpair, 1);
+ fastpair->ref_count = 1;
+ fastpair->service = service;
+ fastpair->reconnect_delay = FASTPAIR_RECONNECT_MIN;
+ btd_service_set_user_data(service, fastpair);
+ fastpair_watch_disconnect(fastpair);
+ fastpair_schedule_auto_connect(fastpair);
+
+ return 0;
+}
+
+static void fastpair_remove(struct btd_service *service)
+{
+ struct fastpair *fastpair = btd_service_get_user_data(service);
+
+ btd_service_set_user_data(service, NULL);
+ fastpair_unwatch_disconnect(fastpair);
+ fastpair->service = NULL;
+ fastpair_reset_connection(fastpair);
+ fastpair_unregister_batteries(fastpair);
+ fastpair_unref(fastpair);
+}
+
+static struct btd_profile fastpair_profile = {
+ .name = "fastpair",
+ .priority = BTD_PROFILE_PRIORITY_LOW,
+ .bearer = BTD_PROFILE_BEARER_BREDR,
+ .remote_uuid = FASTPAIR_MESSAGE_STREAM_UUID,
+ .auto_connect = true,
+ .experimental = true,
+ .device_probe = fastpair_probe,
+ .device_remove = fastpair_remove,
+ .connect = fastpair_connect,
+ .disconnect = fastpair_disconnect,
+};
+
+static int fastpair_init(void)
+{
+ int err;
+
+ err = btd_profile_register(&fastpair_profile);
+ if (err < 0)
+ return err;
+
+ service_state_id = btd_service_add_state_cb(fastpair_service_state_cb,
+ NULL);
+
+ return 0;
+}
+
+static void fastpair_exit(void)
+{
+ btd_service_remove_state_cb(service_state_id);
+ btd_profile_unregister(&fastpair_profile);
+}
+
+BLUETOOTH_PLUGIN_DEFINE(fastpair, VERSION,
+ BLUETOOTH_PLUGIN_PRIORITY_DEFAULT,
+ fastpair_init, fastpair_exit)
diff --git a/profiles/fastpair/message-stream.c b/profiles/fastpair/message-stream.c
new file mode 100644
index 000000000..cfb8d639d
--- /dev/null
+++ b/profiles/fastpair/message-stream.c
@@ -0,0 +1,129 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+/*
+ *
+ * BlueZ - Bluetooth protocol stack for Linux
+ *
+ * Copyright (C) 2026 Matthias Kurz
+ *
+ */
+
+#ifdef HAVE_CONFIG_H
+#include <config.h>
+#endif
+
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdint.h>
+
+#include <glib.h>
+
+#include "message-stream.h"
+
+#define MESSAGE_HEADER_LENGTH 4
+
+struct fastpair_message_stream {
+ GByteArray *buffer;
+ fastpair_message_func_t callback;
+ void *user_data;
+};
+
+struct fastpair_message_stream *
+fastpair_message_stream_new(fastpair_message_func_t callback, void *user_data)
+{
+ struct fastpair_message_stream *stream;
+
+ if (!callback)
+ return NULL;
+
+ stream = g_new0(struct fastpair_message_stream, 1);
+ stream->buffer = g_byte_array_new();
+ stream->callback = callback;
+ stream->user_data = user_data;
+
+ return stream;
+}
+
+void fastpair_message_stream_free(struct fastpair_message_stream *stream)
+{
+ if (!stream)
+ return;
+
+ g_byte_array_unref(stream->buffer);
+ g_free(stream);
+}
+
+bool fastpair_message_stream_feed(struct fastpair_message_stream *stream,
+ const void *data, size_t length)
+{
+ const uint8_t *bytes = data;
+
+ if (!stream || (!data && length))
+ return false;
+
+ if (length > G_MAXUINT || stream->buffer->len > G_MAXUINT - length)
+ return false;
+
+ if (!length)
+ return true;
+
+ g_byte_array_append(stream->buffer, bytes, length);
+
+ while (stream->buffer->len >= MESSAGE_HEADER_LENGTH) {
+ const uint8_t *header = stream->buffer->data;
+ uint16_t payload_length;
+ guint frame_length;
+
+ payload_length = ((uint16_t) header[2] << 8) | header[3];
+ frame_length = MESSAGE_HEADER_LENGTH + payload_length;
+ if (stream->buffer->len < frame_length)
+ break;
+
+ stream->callback(header[0], header[1],
+ stream->buffer->data + MESSAGE_HEADER_LENGTH,
+ payload_length, stream->user_data);
+ g_byte_array_remove_range(stream->buffer, 0, frame_length);
+ }
+
+ return true;
+}
+
+bool fastpair_message_get_batteries(uint8_t group, uint8_t code,
+ const uint8_t *payload, uint16_t length,
+ struct fastpair_battery *batteries)
+{
+ unsigned int i;
+
+ if (group != FASTPAIR_DEVICE_INFORMATION_GROUP ||
+ code != FASTPAIR_BATTERY_UPDATE_CODE ||
+ length != FASTPAIR_BATTERY_COUNT ||
+ !payload || !batteries)
+ return false;
+
+ for (i = 0; i < FASTPAIR_BATTERY_COUNT; i++) {
+ uint8_t percentage = payload[i] & 0x7f;
+ bool case_unavailable;
+ bool level_valid = percentage <= 100;
+ bool status_valid;
+
+ batteries[i].percentage = level_valid ? percentage :
+ FASTPAIR_BATTERY_UNKNOWN;
+
+ /*
+ * Battery Notification retains the status bit for an unknown
+ * level (0bS1111111). The TWS requirements separately define
+ * 0xff as invalid when the case level is unsupported. Do not
+ * infer charging from that sentinel. Reserved levels have no
+ * defined charging state.
+ */
+ case_unavailable = i == FASTPAIR_BATTERY_COUNT - 1 &&
+ payload[i] == 0xff;
+ status_valid = level_valid ||
+ (percentage == 0x7f && !case_unavailable);
+ if (!status_valid)
+ batteries[i].charging = FASTPAIR_CHARGING_UNKNOWN;
+ else
+ batteries[i].charging = !!(payload[i] & 0x80);
+ }
+
+ return true;
+}
diff --git a/profiles/fastpair/message-stream.h b/profiles/fastpair/message-stream.h
new file mode 100644
index 000000000..01a7c2da8
--- /dev/null
+++ b/profiles/fastpair/message-stream.h
@@ -0,0 +1,42 @@
+/* SPDX-License-Identifier: GPL-2.0-or-later */
+/*
+ *
+ * BlueZ - Bluetooth protocol stack for Linux
+ *
+ * Copyright (C) 2026 Matthias Kurz
+ *
+ */
+
+#include <stdbool.h>
+#include <stddef.h>
+#include <stdint.h>
+
+#define FASTPAIR_MESSAGE_STREAM_UUID \
+ "df21fe2c-2515-4fdb-8886-f12c4d67927c"
+
+#define FASTPAIR_DEVICE_INFORMATION_GROUP 0x03
+#define FASTPAIR_BATTERY_UPDATE_CODE 0x03
+#define FASTPAIR_BATTERY_COUNT 3
+#define FASTPAIR_BATTERY_UNKNOWN UINT8_MAX
+#define FASTPAIR_CHARGING_UNKNOWN (-1)
+
+struct fastpair_message_stream;
+
+struct fastpair_battery {
+ uint8_t percentage;
+ int charging;
+};
+
+typedef void (*fastpair_message_func_t)(uint8_t group, uint8_t code,
+ const uint8_t *payload, uint16_t length,
+ void *user_data);
+
+struct fastpair_message_stream *
+fastpair_message_stream_new(fastpair_message_func_t callback, void *user_data);
+void fastpair_message_stream_free(struct fastpair_message_stream *stream);
+bool fastpair_message_stream_feed(struct fastpair_message_stream *stream,
+ const void *data, size_t length);
+
+bool fastpair_message_get_batteries(uint8_t group, uint8_t code,
+ const uint8_t *payload, uint16_t length,
+ struct fastpair_battery *batteries);
diff --git a/unit/test-fastpair.c b/unit/test-fastpair.c
new file mode 100644
index 000000000..e35d84b04
--- /dev/null
+++ b/unit/test-fastpair.c
@@ -0,0 +1,286 @@
+// SPDX-License-Identifier: GPL-2.0-or-later
+/*
+ *
+ * BlueZ - Bluetooth protocol stack for Linux
+ *
+ * Copyright (C) 2026 Matthias Kurz
+ *
+ */
+
+#ifdef HAVE_CONFIG_H
+#include <config.h>
+#endif
+
+#include <stdint.h>
+#include <string.h>
+
+#include <glib.h>
+
+#include "src/shared/tester.h"
+
+#include "profiles/fastpair/message-stream.h"
+
+struct expected_message {
+ uint8_t group;
+ uint8_t code;
+ uint16_t length;
+ const uint8_t *payload;
+};
+
+struct parse_context {
+ const struct expected_message *messages;
+ unsigned int count;
+ unsigned int seen;
+};
+
+static void message_cb(uint8_t group, uint8_t code, const uint8_t *payload,
+ uint16_t length, void *user_data)
+{
+ struct parse_context *context = user_data;
+ const struct expected_message *expected;
+
+ g_assert_cmpuint(context->seen, <, context->count);
+ expected = &context->messages[context->seen++];
+ g_assert_cmpuint(group, ==, expected->group);
+ g_assert_cmpuint(code, ==, expected->code);
+ g_assert_cmpuint(length, ==, expected->length);
+ if (length)
+ g_assert_cmpmem(payload, length, expected->payload,
+ expected->length);
+}
+
+static void test_complete_message(const void *data)
+{
+ static const uint8_t payload[] = { 0x60, 0x00, 0x5c };
+ static const uint8_t frame[] = {
+ 0x03, 0x03, 0x00, sizeof(payload), 0x60, 0x00, 0x5c,
+ };
+ static const struct expected_message messages[] = {
+ { 0x03, 0x03, sizeof(payload), payload },
+ };
+ struct parse_context context = {
+ .messages = messages,
+ .count = G_N_ELEMENTS(messages),
+ };
+ struct fastpair_message_stream *stream;
+
+ stream = fastpair_message_stream_new(message_cb, &context);
+ g_assert_nonnull(stream);
+ g_assert_true(fastpair_message_stream_feed(stream, frame,
+ sizeof(frame)));
+ g_assert_cmpuint(context.seen, ==, context.count);
+
+ fastpair_message_stream_free(stream);
+ tester_test_passed();
+}
+
+static void test_fragmented_messages(const void *data)
+{
+ static const uint8_t battery[] = { 0x60, 0x00, 0x5c };
+ static const uint8_t other[] = { 0xaa, 0xbb };
+ static const uint8_t frames[] = {
+ 0x03, 0x03, 0x00, sizeof(battery), 0x60, 0x00, 0x5c,
+ 0x01, 0x02, 0x00, sizeof(other), 0xaa, 0xbb,
+ };
+ static const struct expected_message messages[] = {
+ { 0x03, 0x03, sizeof(battery), battery },
+ { 0x01, 0x02, sizeof(other), other },
+ };
+ struct parse_context context = {
+ .messages = messages,
+ .count = G_N_ELEMENTS(messages),
+ };
+ struct fastpair_message_stream *stream;
+
+ stream = fastpair_message_stream_new(message_cb, &context);
+ g_assert_nonnull(stream);
+ /* Split after the first byte of the first payload. */
+ g_assert_true(fastpair_message_stream_feed(stream, frames, 5));
+ g_assert_cmpuint(context.seen, ==, 0);
+ g_assert_true(fastpair_message_stream_feed(stream, frames + 5,
+ sizeof(frames) - 5));
+ g_assert_cmpuint(context.seen, ==, context.count);
+
+ fastpair_message_stream_free(stream);
+ tester_test_passed();
+}
+
+static void test_coalesced_partial_message(const void *data)
+{
+ static const uint8_t battery[] = { 0x60, 0x00, 0x5c };
+ static const uint8_t other[] = { 0xaa, 0xbb };
+ static const uint8_t frames[] = {
+ 0x03, 0x03, 0x00, sizeof(battery), 0x60, 0x00, 0x5c,
+ 0x01, 0x02, 0x00, sizeof(other), 0xaa, 0xbb,
+ };
+ static const struct expected_message messages[] = {
+ { 0x03, 0x03, sizeof(battery), battery },
+ { 0x01, 0x02, sizeof(other), other },
+ };
+ struct parse_context context = {
+ .messages = messages,
+ .count = G_N_ELEMENTS(messages),
+ };
+ struct fastpair_message_stream *stream;
+ size_t first_feed = 4 + sizeof(battery) + 2;
+
+ stream = fastpair_message_stream_new(message_cb, &context);
+ g_assert_nonnull(stream);
+ g_assert_true(fastpair_message_stream_feed(stream, frames, first_feed));
+ g_assert_cmpuint(context.seen, ==, 1);
+ g_assert_true(fastpair_message_stream_feed(stream, frames + first_feed,
+ sizeof(frames) - first_feed));
+ g_assert_cmpuint(context.seen, ==, context.count);
+
+ fastpair_message_stream_free(stream);
+ tester_test_passed();
+}
+
+static void test_invalid_input(const void *data)
+{
+ static const uint8_t frame[] = { 0x01, 0x02, 0x00, 0x00 };
+ struct parse_context context = {};
+ struct fastpair_message_stream *stream;
+
+ g_assert_false(fastpair_message_stream_feed(NULL, frame,
+ sizeof(frame)));
+
+ stream = fastpair_message_stream_new(message_cb, &context);
+ g_assert_nonnull(stream);
+ g_assert_false(fastpair_message_stream_feed(stream, NULL, 1));
+ g_assert_true(fastpair_message_stream_feed(stream, NULL, 0));
+
+ fastpair_message_stream_free(stream);
+ tester_test_passed();
+}
+
+static void test_zero_length_message(const void *data)
+{
+ static const uint8_t frame[] = { 0x01, 0x02, 0x00, 0x00 };
+ static const struct expected_message messages[] = {
+ { 0x01, 0x02, 0, NULL },
+ };
+ struct parse_context context = {
+ .messages = messages,
+ .count = G_N_ELEMENTS(messages),
+ };
+ struct fastpair_message_stream *stream;
+
+ stream = fastpair_message_stream_new(message_cb, &context);
+ g_assert_nonnull(stream);
+ g_assert_true(fastpair_message_stream_feed(stream, frame,
+ sizeof(frame)));
+ g_assert_cmpuint(context.seen, ==, context.count);
+
+ fastpair_message_stream_free(stream);
+ tester_test_passed();
+}
+
+static void test_maximum_length_message(const void *data)
+{
+ struct expected_message message;
+ struct parse_context context = {
+ .messages = &message,
+ .count = 1,
+ };
+ struct fastpair_message_stream *stream;
+ uint8_t *frame;
+ size_t frame_length = 4 + UINT16_MAX;
+
+ frame = g_malloc(frame_length);
+ frame[0] = 0x01;
+ frame[1] = 0x02;
+ frame[2] = 0xff;
+ frame[3] = 0xff;
+ memset(frame + 4, 0xa5, UINT16_MAX);
+
+ message.group = frame[0];
+ message.code = frame[1];
+ message.length = UINT16_MAX;
+ message.payload = frame + 4;
+
+ stream = fastpair_message_stream_new(message_cb, &context);
+ g_assert_nonnull(stream);
+ g_assert_true(fastpair_message_stream_feed(stream, frame, 1024));
+ g_assert_cmpuint(context.seen, ==, 0);
+ g_assert_true(fastpair_message_stream_feed(stream, frame + 1024,
+ frame_length - 1024));
+ g_assert_cmpuint(context.seen, ==, context.count);
+
+ fastpair_message_stream_free(stream);
+ g_free(frame);
+ tester_test_passed();
+}
+
+static void test_battery_message(const void *data)
+{
+ static const uint8_t payload[] = { 0x60, 0xaa, 0x7f };
+ static const uint8_t unknown[] = { 0xff, 0x7f, 0xff };
+ static const uint8_t reserved[] = { 0x65, 0xe5, 0x65 };
+ struct fastpair_battery batteries[FASTPAIR_BATTERY_COUNT];
+
+ g_assert_true(fastpair_message_get_batteries(0x03, 0x03,
+ payload, sizeof(payload),
+ batteries));
+ g_assert_cmpuint(batteries[0].percentage, ==, 96);
+ g_assert_false(batteries[0].charging);
+ g_assert_cmpuint(batteries[1].percentage, ==, 42);
+ g_assert_true(batteries[1].charging);
+ g_assert_cmpuint(batteries[2].percentage, ==,
+ FASTPAIR_BATTERY_UNKNOWN);
+ g_assert_cmpint(batteries[2].charging, ==, 0);
+
+ g_assert_true(fastpair_message_get_batteries(0x03, 0x03,
+ unknown, sizeof(unknown),
+ batteries));
+ g_assert_cmpuint(batteries[0].percentage, ==,
+ FASTPAIR_BATTERY_UNKNOWN);
+ g_assert_cmpint(batteries[0].charging, ==, 1);
+ g_assert_cmpuint(batteries[1].percentage, ==,
+ FASTPAIR_BATTERY_UNKNOWN);
+ g_assert_cmpint(batteries[1].charging, ==, 0);
+ g_assert_cmpuint(batteries[2].percentage, ==,
+ FASTPAIR_BATTERY_UNKNOWN);
+ g_assert_cmpint(batteries[2].charging, ==,
+ FASTPAIR_CHARGING_UNKNOWN);
+
+ g_assert_true(fastpair_message_get_batteries(0x03, 0x03,
+ reserved, sizeof(reserved),
+ batteries));
+ for (unsigned int i = 0; i < FASTPAIR_BATTERY_COUNT; i++) {
+ g_assert_cmpuint(batteries[i].percentage, ==,
+ FASTPAIR_BATTERY_UNKNOWN);
+ g_assert_cmpint(batteries[i].charging, ==,
+ FASTPAIR_CHARGING_UNKNOWN);
+ }
+
+ g_assert_false(fastpair_message_get_batteries(0x03, 0x02,
+ payload, sizeof(payload),
+ batteries));
+ g_assert_false(fastpair_message_get_batteries(0x03, 0x03, payload,
+ sizeof(payload) - 1,
+ batteries));
+ tester_test_passed();
+}
+
+int main(int argc, char *argv[])
+{
+ tester_init(&argc, &argv);
+
+ tester_add("/fastpair/complete-message", NULL, NULL,
+ test_complete_message, NULL);
+ tester_add("/fastpair/fragmented-messages", NULL, NULL,
+ test_fragmented_messages, NULL);
+ tester_add("/fastpair/coalesced-partial-message", NULL, NULL,
+ test_coalesced_partial_message, NULL);
+ tester_add("/fastpair/invalid-input", NULL, NULL,
+ test_invalid_input, NULL);
+ tester_add("/fastpair/zero-length-message", NULL, NULL,
+ test_zero_length_message, NULL);
+ tester_add("/fastpair/maximum-length-message", NULL, NULL,
+ test_maximum_length_message, NULL);
+ tester_add("/fastpair/battery-message", NULL, NULL,
+ test_battery_message, NULL);
+
+ return tester_run();
+}
--
2.55.0
^ permalink raw reply related [flat|nested] 11+ messages in thread* [PATCH BlueZ 4/4] test: Add Fast Pair Message Stream tool
2026-08-19 22:31 [PATCH BlueZ 0/4] Add component batteries and Fast Pair Message Stream Matthias Kurz
` (2 preceding siblings ...)
2026-08-19 22:31 ` [PATCH BlueZ 3/4] fastpair: Add Message Stream battery profile Matthias Kurz
@ 2026-08-19 22:31 ` Matthias Kurz
2026-08-20 13:59 ` [PATCH BlueZ 0/4] Add component batteries and Fast Pair Message Stream Bastien Nocera
2026-08-20 14:00 ` Luiz Augusto von Dentz
5 siblings, 0 replies; 11+ messages in thread
From: Matthias Kurz @ 2026-08-19 22:31 UTC (permalink / raw)
To: linux-bluetooth
Add a standalone profile client that prints Fast Pair Message Stream frames
and can optionally publish left, right, and case values through
BatteryProvider1.
Publish dynamic component lifecycle signals, scope providers per adapter,
and invalidate measurements when a stream closes. Warn users to disable the
built-in Fast Pair plugin when using the external profile.
Assisted-by: Codex:gpt-5.6-sol
---
Makefile.tools | 2 +-
test/test-fastpair | 561 +++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 562 insertions(+), 1 deletion(-)
create mode 100755 test/test-fastpair
diff --git a/Makefile.tools b/Makefile.tools
index b3ef4ae1c..630646aff 100644
--- a/Makefile.tools
+++ b/Makefile.tools
@@ -552,7 +552,7 @@ test_scripts += test/bluezutils.py \
test/test-discovery test/test-manager test/test-adapter \
test/test-device test/simple-agent \
test/simple-endpoint \
- test/test-network test/test-profile \
+ test/test-network test/test-profile test/test-fastpair \
test/service-record.dtd \
test/service-did.xml test/service-spp.xml test/service-opp.xml \
test/service-ftp.xml test/simple-player test/test-nap \
diff --git a/test/test-fastpair b/test/test-fastpair
new file mode 100755
index 000000000..c6347c9ca
--- /dev/null
+++ b/test/test-fastpair
@@ -0,0 +1,561 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: LGPL-2.1-or-later
+
+import argparse
+import os
+import signal
+
+import dbus
+import dbus.mainloop.glib
+import dbus.service
+from gi.repository import GLib
+
+
+BLUEZ_SERVICE = "org.bluez"
+BATTERY_PROVIDER_INTERFACE = "org.bluez.BatteryProvider1"
+BATTERY_PROVIDER_MANAGER_INTERFACE = "org.bluez.BatteryProviderManager1"
+DEVICE_INTERFACE = "org.bluez.Device1"
+OBJECT_MANAGER_INTERFACE = "org.freedesktop.DBus.ObjectManager"
+PROFILE_INTERFACE = "org.bluez.Profile1"
+PROFILE_MANAGER_INTERFACE = "org.bluez.ProfileManager1"
+PROPERTIES_INTERFACE = "org.freedesktop.DBus.Properties"
+
+FAST_PAIR_MESSAGE_STREAM_UUID = "df21fe2c-2515-4fdb-8886-f12c4d67927c"
+PROFILE_PATH = "/org/bluez/test/fastpair_message_stream"
+BATTERY_PROVIDER_PATH = "/org/bluez/test/fastpair_batteries"
+
+DEVICE_INFORMATION_GROUP = 0x03
+BATTERY_UPDATE_CODE = 0x03
+BATTERY_UPDATE_LENGTH = 3
+
+COMPONENT_IDENTIFIERS = ("left", "right", "case")
+
+
+class InvalidArgsException(dbus.exceptions.DBusException):
+ _dbus_error_name = "org.freedesktop.DBus.Error.InvalidArgs"
+
+
+def decode_component(identifier, value):
+ percentage = value & 0x7f
+ level_valid = percentage <= 100
+ if not level_valid:
+ percentage = None
+
+ # Battery Notification retains the status bit for an unknown level.
+ # The TWS requirements separately define 0xff as invalid when the case
+ # level is unsupported. Reserved levels have no charging state.
+ case_unavailable = identifier == "case" and value == 0xff
+ status_valid = (level_valid or
+ ((value & 0x7f) == 0x7f and not case_unavailable))
+ charging = bool(value & 0x80) if status_valid else None
+
+ return percentage, charging
+
+
+class BatteryComponent(dbus.service.Object):
+ def __init__(self, bus, provider_path, device, identifier):
+ device_name = device.rsplit("/", 1)[-1]
+ path = "%s/%s/battery_%s" % (
+ provider_path, device_name, identifier)
+ super().__init__(bus, path)
+ self.path = path
+ self.device = device
+ self.identifier = identifier
+ self.percentage = None
+ self.charging = None
+
+ def get_properties(self):
+ properties = {
+ "Device": dbus.ObjectPath(self.device),
+ "Identifier": self.identifier,
+ "Source": FAST_PAIR_MESSAGE_STREAM_UUID,
+ }
+
+ if self.percentage is not None:
+ properties["Percentage"] = dbus.Byte(self.percentage)
+
+ if self.charging is not None:
+ properties["Charging"] = dbus.Boolean(self.charging)
+
+ return {BATTERY_PROVIDER_INTERFACE: properties}
+
+ def update(self, value):
+ percentage, charging = decode_component(self.identifier, value)
+ changed = {}
+ invalidated = []
+
+ if self.percentage != percentage:
+ self.percentage = percentage
+ if percentage is None:
+ invalidated.append("Percentage")
+ else:
+ changed["Percentage"] = dbus.Byte(percentage)
+
+ if self.charging != charging:
+ self.charging = charging
+ if charging is None:
+ invalidated.append("Charging")
+ else:
+ changed["Charging"] = dbus.Boolean(charging)
+
+ if changed or invalidated:
+ self.PropertiesChanged(
+ BATTERY_PROVIDER_INTERFACE,
+ changed,
+ dbus.Array(invalidated, signature="s"))
+
+ def invalidate(self):
+ invalidated = []
+
+ if self.percentage is not None:
+ self.percentage = None
+ invalidated.append("Percentage")
+
+ if self.charging is not None:
+ self.charging = None
+ invalidated.append("Charging")
+
+ if invalidated:
+ self.PropertiesChanged(
+ BATTERY_PROVIDER_INTERFACE,
+ {}, dbus.Array(invalidated, signature="s"))
+
+ @dbus.service.method(PROPERTIES_INTERFACE, in_signature="s",
+ out_signature="a{sv}")
+ def GetAll(self, interface):
+ if interface != BATTERY_PROVIDER_INTERFACE:
+ raise InvalidArgsException()
+
+ return self.get_properties()[BATTERY_PROVIDER_INTERFACE]
+
+ @dbus.service.signal(PROPERTIES_INTERFACE, signature="sa{sv}as")
+ def PropertiesChanged(self, interface, changed, invalidated):
+ pass
+
+
+class AdapterBatteryProvider(dbus.service.Object):
+ def __init__(self, bus, adapter):
+ adapter_name = adapter.rsplit("/", 1)[-1]
+ self.path = "%s/%s" % (BATTERY_PROVIDER_PATH, adapter_name)
+ super().__init__(bus, self.path)
+ self.bus = bus
+ self.adapter = adapter
+ self.components = {}
+ self.registered = False
+ self.manager = dbus.Interface(
+ self.bus.get_object(BLUEZ_SERVICE, adapter),
+ BATTERY_PROVIDER_MANAGER_INTERFACE)
+
+ def add_device(self, device):
+ for identifier in COMPONENT_IDENTIFIERS:
+ key = (device, identifier)
+ if key in self.components:
+ continue
+
+ component = BatteryComponent(
+ self.bus, self.path, device, identifier)
+ self.components[key] = component
+ if self.registered:
+ self.InterfacesAdded(
+ dbus.ObjectPath(component.path),
+ component.get_properties())
+
+ def register(self):
+ self.registered = True
+
+ def registered():
+ print("Registered component battery provider on %s" %
+ self.adapter)
+
+ def failed(error):
+ self.registered = False
+ print("Battery provider registration failed on %s: %s" %
+ (self.adapter, error))
+
+ self.manager.RegisterBatteryProvider(
+ self.path,
+ reply_handler=registered,
+ error_handler=failed)
+
+ def update(self, device, payload):
+ if len(payload) != len(COMPONENT_IDENTIFIERS):
+ return
+
+ if (device, COMPONENT_IDENTIFIERS[0]) not in self.components:
+ self.add_device(device)
+
+ for identifier, value in zip(COMPONENT_IDENTIFIERS, payload):
+ self.components[(device, identifier)].update(value)
+
+ def invalidate(self, device):
+ for identifier in COMPONENT_IDENTIFIERS:
+ component = self.components.get((device, identifier))
+ if component:
+ component.invalidate()
+
+ def unregister(self):
+ for component in list(self.components.values()):
+ if self.registered:
+ self.InterfacesRemoved(
+ dbus.ObjectPath(component.path),
+ [BATTERY_PROVIDER_INTERFACE])
+ component.remove_from_connection()
+
+ self.components.clear()
+
+ if self.registered:
+ try:
+ self.manager.UnregisterBatteryProvider(self.path)
+ except dbus.exceptions.DBusException:
+ pass
+
+ self.registered = False
+ self.remove_from_connection()
+
+ @dbus.service.method(OBJECT_MANAGER_INTERFACE,
+ out_signature="a{oa{sa{sv}}}")
+ def GetManagedObjects(self):
+ return {
+ dbus.ObjectPath(component.path): component.get_properties()
+ for component in self.components.values()
+ }
+
+ @dbus.service.signal(OBJECT_MANAGER_INTERFACE,
+ signature="oa{sa{sv}}")
+ def InterfacesAdded(self, object_path, interfaces_and_properties):
+ pass
+
+ @dbus.service.signal(OBJECT_MANAGER_INTERFACE, signature="oas")
+ def InterfacesRemoved(self, object_path, interfaces):
+ pass
+
+
+class BatteryProvider:
+ def __init__(self, bus):
+ self.bus = bus
+ self.providers = {}
+
+ def add_device(self, device):
+ adapter = device.rsplit("/dev_", 1)[0]
+ provider = self.providers.get(adapter)
+ if provider:
+ provider.add_device(device)
+ return
+
+ provider = AdapterBatteryProvider(self.bus, adapter)
+ self.providers[adapter] = provider
+ provider.add_device(device)
+ provider.register()
+
+ def update(self, device, payload):
+ if len(payload) != len(COMPONENT_IDENTIFIERS):
+ return
+
+ adapter = device.rsplit("/dev_", 1)[0]
+ if adapter not in self.providers:
+ self.add_device(device)
+
+ self.providers[adapter].update(device, payload)
+
+ def invalidate(self, device):
+ adapter = device.rsplit("/dev_", 1)[0]
+ provider = self.providers.get(adapter)
+ if provider:
+ provider.invalidate(device)
+
+ def unregister(self):
+ for provider in self.providers.values():
+ provider.unregister()
+
+ self.providers.clear()
+
+
+def format_component(name, value):
+ percentage, charging = decode_component(name, value)
+
+ if percentage is None:
+ level = "unknown"
+ else:
+ level = "%d%%" % percentage
+
+ if charging is None:
+ status = " charging=unknown"
+ elif charging:
+ status = " charging"
+ else:
+ status = ""
+
+ return "%s=%s%s" % (name, level, status)
+
+
+class MessageStreamConnection:
+ def __init__(self, profile, device, fd):
+ self.profile = profile
+ self.device = device
+ self.fd = fd
+ self.buffer = bytearray()
+ self.watch = None
+
+ os.set_blocking(self.fd, False)
+ self.watch = GLib.io_add_watch(
+ self.fd,
+ GLib.IO_IN | GLib.IO_HUP | GLib.IO_ERR | GLib.IO_NVAL,
+ self._io_event)
+
+ def close(self):
+ if self.watch is not None:
+ GLib.source_remove(self.watch)
+ self.watch = None
+
+ if self.fd >= 0:
+ os.close(self.fd)
+ self.fd = -1
+
+ self.profile.connection_closed(self.device, self)
+
+ def _io_event(self, source, condition):
+ if condition & GLib.IO_IN:
+ try:
+ data = os.read(self.fd, 4096)
+ except BlockingIOError:
+ data = None
+ except OSError as error:
+ print("Read failed for %s: %s" % (self.device, error))
+ self.close()
+ return False
+
+ if data == b"":
+ print("Message Stream closed by %s" % self.device)
+ self.close()
+ return False
+
+ if data:
+ self.buffer.extend(data)
+ if not self._parse_frames():
+ self.close()
+ return False
+
+ if condition & (GLib.IO_HUP | GLib.IO_ERR | GLib.IO_NVAL):
+ print("Message Stream disconnected from %s" % self.device)
+ self.close()
+ return False
+
+ return True
+
+ def _parse_frames(self):
+ while len(self.buffer) >= 4:
+ group = self.buffer[0]
+ code = self.buffer[1]
+ payload_length = int.from_bytes(self.buffer[2:4], "big")
+
+ frame_length = 4 + payload_length
+ if len(self.buffer) < frame_length:
+ return True
+
+ payload = bytes(self.buffer[4:frame_length])
+ del self.buffer[:frame_length]
+ self._handle_frame(group, code, payload)
+
+ return True
+
+ def _handle_frame(self, group, code, payload):
+ hex_payload = " ".join("%02x" % byte for byte in payload)
+ print("Message group=0x%02x code=0x%02x length=%d payload=%s" %
+ (group, code, len(payload), hex_payload))
+
+ if group != DEVICE_INFORMATION_GROUP or code != BATTERY_UPDATE_CODE:
+ return
+
+ if len(payload) != BATTERY_UPDATE_LENGTH:
+ print("Invalid battery update length: %d" % len(payload))
+ return
+
+ components = (
+ format_component("left", payload[0]),
+ format_component("right", payload[1]),
+ format_component("case", payload[2]),
+ )
+ print("Battery update: %s" % ", ".join(components))
+ self.profile.update_batteries(self.device, payload)
+
+
+class FastPairProfile(dbus.service.Object):
+ def __init__(self, bus, mainloop, battery_provider=None):
+ super().__init__(bus, PROFILE_PATH)
+ self.mainloop = mainloop
+ self.battery_provider = battery_provider
+ self.connections = {}
+
+ @dbus.service.method(PROFILE_INTERFACE, in_signature="", out_signature="")
+ def Release(self):
+ print("Profile released")
+ self.close_all()
+ self.mainloop.quit()
+
+ @dbus.service.method(PROFILE_INTERFACE, in_signature="", out_signature="")
+ def Cancel(self):
+ print("Connection cancelled")
+
+ @dbus.service.method(PROFILE_INTERFACE, in_signature="oha{sv}",
+ out_signature="")
+ def NewConnection(self, device, fd, properties):
+ device = str(device)
+ raw_fd = fd.take()
+
+ if device in self.connections:
+ self.connections[device].close()
+
+ print("Message Stream connected to %s" % device)
+ self.connections[device] = MessageStreamConnection(
+ self, device, raw_fd)
+
+ @dbus.service.method(PROFILE_INTERFACE, in_signature="o",
+ out_signature="")
+ def RequestDisconnection(self, device):
+ device = str(device)
+ print("Disconnect requested for %s" % device)
+
+ connection = self.connections.get(device)
+ if connection:
+ connection.close()
+
+ def connection_closed(self, device, connection):
+ if self.connections.get(device) is connection:
+ del self.connections[device]
+ if self.battery_provider:
+ self.battery_provider.invalidate(device)
+
+ def update_batteries(self, device, payload):
+ if self.battery_provider:
+ self.battery_provider.update(device, payload)
+
+ def close_all(self):
+ for connection in list(self.connections.values()):
+ connection.close()
+
+
+def find_devices(bus):
+ manager = dbus.Interface(bus.get_object(BLUEZ_SERVICE, "/"),
+ OBJECT_MANAGER_INTERFACE)
+ objects = manager.GetManagedObjects()
+ devices = []
+
+ for path, interfaces in objects.items():
+ properties = interfaces.get(DEVICE_INTERFACE)
+ if not properties:
+ continue
+
+ uuids = [str(uuid).lower() for uuid in properties.get("UUIDs", [])]
+ if FAST_PAIR_MESSAGE_STREAM_UUID not in uuids:
+ continue
+
+ devices.append((str(path), str(properties.get("Alias", path))))
+
+ return devices
+
+
+def connect_profile(bus, device_path):
+ device = dbus.Interface(bus.get_object(BLUEZ_SERVICE, device_path),
+ DEVICE_INTERFACE)
+
+ def connected():
+ print("ConnectProfile completed")
+
+ def failed(error):
+ print("ConnectProfile failed: %s" % error)
+
+ device.ConnectProfile(FAST_PAIR_MESSAGE_STREAM_UUID,
+ reply_handler=connected, error_handler=failed)
+ return True
+
+
+def parse_args():
+ parser = argparse.ArgumentParser(
+ description="Inspect Google Fast Pair Message Stream battery updates",
+ epilog="Disable bluetoothd's built-in fastpair plugin with "
+ "-P fastpair before using this external profile against an "
+ "experimental daemon.")
+ parser.add_argument(
+ "--connect",
+ metavar="DEVICE_PATH",
+ help="actively connect the profile on a BlueZ Device1 object")
+ parser.add_argument(
+ "--no-auto-connect",
+ action="store_true",
+ help="do not connect automatically when a matching device connects")
+ parser.add_argument(
+ "--publish-batteries",
+ action="store_true",
+ help="publish left, right, and case through BatteryProvider1")
+ return parser.parse_args()
+
+
+def main():
+ args = parse_args()
+
+ dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
+ bus = dbus.SystemBus()
+ mainloop = GLib.MainLoop()
+ devices = find_devices(bus)
+ battery_provider = BatteryProvider(bus) if args.publish_batteries else None
+
+ if battery_provider:
+ if args.connect:
+ battery_device = args.connect
+ elif len(devices) == 1:
+ battery_device = devices[0][0]
+ else:
+ print("--publish-batteries needs --connect when the cached "
+ "device is not unique")
+ return 1
+
+ battery_provider.add_device(battery_device)
+
+ profile = FastPairProfile(bus, mainloop, battery_provider)
+ manager = dbus.Interface(bus.get_object(BLUEZ_SERVICE, "/org/bluez"),
+ PROFILE_MANAGER_INTERFACE)
+
+ options = {
+ "Name": "Fast Pair Message Stream battery probe",
+ "Role": "client",
+ "AutoConnect": dbus.Boolean(not args.no_auto_connect),
+ "RequireAuthentication": dbus.Boolean(True),
+ }
+ manager.RegisterProfile(PROFILE_PATH, FAST_PAIR_MESSAGE_STREAM_UUID,
+ options)
+
+ print("Registered Fast Pair Message Stream profile")
+ print("Ensure bluetoothd's built-in fastpair plugin is disabled with "
+ "-P fastpair")
+ if devices:
+ print("Cached devices supporting the profile:")
+ for path, alias in devices:
+ print(" %s: %s" % (alias, path))
+ else:
+ print("No cached device advertises the Message Stream UUID")
+
+ if args.connect and not connect_profile(bus, args.connect):
+ return 1
+
+ def stop(signum, frame):
+ mainloop.quit()
+
+ signal.signal(signal.SIGINT, stop)
+ signal.signal(signal.SIGTERM, stop)
+
+ try:
+ mainloop.run()
+ finally:
+ profile.close_all()
+ if battery_provider:
+ battery_provider.unregister()
+ try:
+ manager.UnregisterProfile(PROFILE_PATH)
+ except dbus.exceptions.DBusException:
+ pass
+
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
--
2.55.0
^ permalink raw reply related [flat|nested] 11+ messages in thread* Re: [PATCH BlueZ 0/4] Add component batteries and Fast Pair Message Stream
2026-08-19 22:31 [PATCH BlueZ 0/4] Add component batteries and Fast Pair Message Stream Matthias Kurz
` (3 preceding siblings ...)
2026-08-19 22:31 ` [PATCH BlueZ 4/4] test: Add Fast Pair Message Stream tool Matthias Kurz
@ 2026-08-20 13:59 ` Bastien Nocera
2026-08-20 17:52 ` Matthias Kurz
2026-08-20 14:00 ` Luiz Augusto von Dentz
5 siblings, 1 reply; 11+ messages in thread
From: Bastien Nocera @ 2026-08-20 13:59 UTC (permalink / raw)
To: Matthias Kurz, linux-bluetooth
Hey Matthias,
I have a couple of very high-level comments about various patches, I'm
putting them here so as to avoid getting too deep into the details.
On Thu, 2026-08-20 at 00:31 +0200, Matthias Kurz wrote:
> True wireless earbuds can report separate charge states for the left
> bud,
> right bud, and charging case. Battery1 currently has one fixed object
> per
> Device1, so BlueZ cannot expose those values without collapsing them
> into
> one percentage.
>
> Extend the battery core and provider API to support child Battery1
> objects
> with a stable identifier, optional percentage, and optional charging
> state.
> The existing Battery1 object at the Device1 path remains the
> aggregate
> compatibility interface. Component objects and their new properties
> remain
> experimental.
I know of one direct consumer of the org.bluez.Battery1 interface, and
it's upower.
Did you verify whether your changes cause the current versions of
UPower any problems? Do you have any work planned on upower to add
support for those sub-devices?
I think that it might be very useful to show how exactly D-Bus objects
appear on the bus, as well their paths, interfaces and properties, so
people without the hardware can reproduce "mock" versions using python-
dbusmock:
https://github.com/martinpitt/python-dbusmock
This is most likely what I would do to be able to test gnome-
bluetooth's battery information, where it coalesces info from both
bluetoothd and upower to show battery info next to Bluetooth devices.
>
> Add an experimental Fast Pair Message Stream profile which connects
> to the
> advertised RFCOMM service and publishes its left, right, and case
> battery
> updates through the new component objects. The generic unknown-level
> status
> bit is retained for earbuds. Treat the TWS-specific case value 0xff
> as
> unavailable.
I've seen some magic numbers appearing in the implementation. It might
be useful to have those defined in a header which you can reference in
the tests.
> If the Message Stream closes while BR/EDR remains connected,
> invalidate the
> values and reconnect with exponential backoff. Reset the backoff only
> after
> a battery-producing stream remains connected for the maximum backoff
> interval, and do not retry permanent local errors. Once BR/EDR
> disappears,
> cancel pending work and remove the component objects. The final patch
> adds
> a standalone diagnostic and provider tool for interoperability
> testing.
>
> This was tested with Pixel Buds Pro using an ASan/UBSan build. The
> live
> tests covered fresh left/right/case reports, an unavailable case
> value,
> explicit Message Stream disconnection, remote device disconnection,
> reconnection, adapter power-down, and cancellation of a profile
> connection
> in progress. Component properties were invalidated or removed as
> appropriate, and the daemon reported no sanitizer failure.
>
> The full 40-test make check suite passes under ASan/UBSan. The Fast
> Pair
> parser tests cover payload fragmentation, a complete frame followed
> by a
> partial frame, invalid input, a zero-length frame, the maximum 65535-
> byte
> payload, unknown-level status bits, the unavailable-case sentinel,
> and
> reserved battery values. The Python tool compiles and its matching
> decoder
> was checked directly.
Tests are great, but what's the coverage like? :)
Cheers
>
> Matthias Kurz (4):
> battery: Add component battery objects
> doc: Document component battery objects
> fastpair: Add Message Stream battery profile
> test: Add Fast Pair Message Stream tool
>
> .gitignore | 1 +
> Makefile.am | 8 +
> Makefile.plugins | 5 +
> Makefile.tools | 2 +-
> doc/org.bluez.Battery.rst | 35 +-
> doc/org.bluez.BatteryProvider.rst | 16 +
> profiles/fastpair/fastpair.c | 650
> +++++++++++++++++++++++++++++
> profiles/fastpair/message-stream.c | 129 ++++++
> profiles/fastpair/message-stream.h | 42 ++
> src/battery.c | 401 +++++++++++++++---
> src/battery.h | 4 +
> test/test-fastpair | 561 +++++++++++++++++++++++++
> unit/test-fastpair.c | 286 +++++++++++++
> 13 files changed, 2079 insertions(+), 61 deletions(-)
> create mode 100644 profiles/fastpair/fastpair.c
> create mode 100644 profiles/fastpair/message-stream.c
> create mode 100644 profiles/fastpair/message-stream.h
> create mode 100755 test/test-fastpair
> create mode 100644 unit/test-fastpair.c
^ permalink raw reply [flat|nested] 11+ messages in thread* Re: [PATCH BlueZ 0/4] Add component batteries and Fast Pair Message Stream
2026-08-20 13:59 ` [PATCH BlueZ 0/4] Add component batteries and Fast Pair Message Stream Bastien Nocera
@ 2026-08-20 17:52 ` Matthias Kurz
0 siblings, 0 replies; 11+ messages in thread
From: Matthias Kurz @ 2026-08-20 17:52 UTC (permalink / raw)
To: Bastien Nocera; +Cc: linux-bluetooth
Hi Bastien,
Thanks for the review.
Existing UPower releases continue to use org.bluez.Battery1 on the
Device1 object path. This series does not change that aggregate object's
API or behaviour. The component objects and their new properties are
experimental.
With experimental BlueZ enabled, current UPower does not understand the
child Battery1 objects. It may log a coldplug warning because it expects
Device1 on the same path, and then ignores that component. The existing
aggregate battery remains available, so this does not regress the
currently displayed value.
I have working UPower support for the component objects here:
https://gitlab.freedesktop.org/mkurz/upower/-/commits/fastpair-multi-battery
The branch is currently based on:
https://gitlab.freedesktop.org/upower/upower/-/merge_requests/337
It creates one UPower device for every component, handles optional and
invalidated Percentage and Charging properties, and falls back to the
aggregate battery when no components are present. It extends UPower's
python-dbusmock-based BlueZ integration tests. I have not opened an
UPower MR yet while the BlueZ API is still under review.
The object layout observed with Pixel Buds Pro was, with the address
anonymised:
/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF/battery_left
/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF/battery_right
/org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF/battery_case
Each child implements org.bluez.Battery1. A GetAll snapshot was:
battery_left:
Percentage = 92
Source = "Fast Pair Message Stream"
Device = /org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF
Identifier = "left"
Charging = false
battery_right:
Percentage = 90
Source = "Fast Pair Message Stream"
Device = /org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF
Identifier = "right"
Charging = false
battery_case:
Source = "Fast Pair Message Stream"
Device = /org/bluez/hci0/dev_AA_BB_CC_DD_EE_FF
Identifier = "case"
Charging = false
Percentage was absent from the case object because its reported level was
unknown. I will include this layout in the v2 cover letter so consumers
can reproduce it with python-dbusmock.
For v2 I have also moved the Message Stream layout, battery masks,
sentinels and component indexes into the shared header used by the C
unit test. The Python diagnostic tool uses corresponding named
constants.
unit/test-fastpair contains seven cases covering complete, fragmented,
coalesced/partial, zero-length and maximum-length frames, invalid input,
and the battery decoding rules. GCC/gcov reports 100% line coverage
(55/55 lines) and 95.83% branch-direction coverage (46/48 outcomes) for
profiles/fastpair/message-stream.c. I also added test-fastpair to
doc/test-coverage.txt. The complete BlueZ make check suite passes all
40 test programs.
The daemon/profile lifecycle and D-Bus integration are not fully
unit-covered, but were exercised under ASan with real Pixel Buds Pro
hardware.
I also tested the complete path through patched BlueZ, UPower, Solid,
PowerDevil, BlueZQt and BlueDevil. KDE Plasma's Power & Battery applet
displayed separate left, right and case batteries, including an unknown
case level, and the Bluetooth applet displayed all three components.
The related independent KDE changes are:
https://invent.kde.org/frameworks/solid/-/merge_requests/264
https://invent.kde.org/plasma/powerdevil/-/merge_requests/666
The BlueZQt and BlueDevil component-display changes remain local while
the BlueZ component API is under review.
Regards,
Matthias
^ permalink raw reply [flat|nested] 11+ messages in thread
* Re: [PATCH BlueZ 0/4] Add component batteries and Fast Pair Message Stream
2026-08-19 22:31 [PATCH BlueZ 0/4] Add component batteries and Fast Pair Message Stream Matthias Kurz
` (4 preceding siblings ...)
2026-08-20 13:59 ` [PATCH BlueZ 0/4] Add component batteries and Fast Pair Message Stream Bastien Nocera
@ 2026-08-20 14:00 ` Luiz Augusto von Dentz
2026-08-20 17:54 ` Matthias Kurz
5 siblings, 1 reply; 11+ messages in thread
From: Luiz Augusto von Dentz @ 2026-08-20 14:00 UTC (permalink / raw)
To: Matthias Kurz; +Cc: linux-bluetooth
Hi Matthias,
On Wed, Aug 19, 2026 at 6:32 PM Matthias Kurz <m.kurz@irregular.at> wrote:
>
> True wireless earbuds can report separate charge states for the left bud,
> right bud, and charging case. Battery1 currently has one fixed object per
> Device1, so BlueZ cannot expose those values without collapsing them into
> one percentage.
>
> Extend the battery core and provider API to support child Battery1 objects
> with a stable identifier, optional percentage, and optional charging state.
> The existing Battery1 object at the Device1 path remains the aggregate
> compatibility interface. Component objects and their new properties remain
> experimental.
>
> Add an experimental Fast Pair Message Stream profile which connects to the
> advertised RFCOMM service and publishes its left, right, and case battery
> updates through the new component objects. The generic unknown-level status
> bit is retained for earbuds. Treat the TWS-specific case value 0xff as
> unavailable.
Wait, what are you talking about here don't really match the Fast Pair spec:
https://developers.google.com/nearby/fast-pair/specifications/bledevice#message_stream_PSM
That seems to be BLE specific and uses L2CAP not RFCOMM.
> If the Message Stream closes while BR/EDR remains connected, invalidate the
> values and reconnect with exponential backoff. Reset the backoff only after
> a battery-producing stream remains connected for the maximum backoff
> interval, and do not retry permanent local errors. Once BR/EDR disappears,
> cancel pending work and remove the component objects. The final patch adds
> a standalone diagnostic and provider tool for interoperability testing.
>
> This was tested with Pixel Buds Pro using an ASan/UBSan build. The live
> tests covered fresh left/right/case reports, an unavailable case value,
> explicit Message Stream disconnection, remote device disconnection,
> reconnection, adapter power-down, and cancellation of a profile connection
> in progress. Component properties were invalidated or removed as
> appropriate, and the daemon reported no sanitizer failure.
I would really like some clarification on what protocol source was
used, because this doesn't seem to match anything like:
https://developers.google.com/nearby/fast-pair/specifications/extensions/batterynotification
> The full 40-test make check suite passes under ASan/UBSan. The Fast Pair
> parser tests cover payload fragmentation, a complete frame followed by a
> partial frame, invalid input, a zero-length frame, the maximum 65535-byte
> payload, unknown-level status bits, the unavailable-case sentinel, and
> reserved battery values. The Python tool compiles and its matching decoder
> was checked directly.
>
> Matthias Kurz (4):
> battery: Add component battery objects
> doc: Document component battery objects
> fastpair: Add Message Stream battery profile
> test: Add Fast Pair Message Stream tool
>
> .gitignore | 1 +
> Makefile.am | 8 +
> Makefile.plugins | 5 +
> Makefile.tools | 2 +-
> doc/org.bluez.Battery.rst | 35 +-
> doc/org.bluez.BatteryProvider.rst | 16 +
> profiles/fastpair/fastpair.c | 650 +++++++++++++++++++++++++++++
> profiles/fastpair/message-stream.c | 129 ++++++
> profiles/fastpair/message-stream.h | 42 ++
> src/battery.c | 401 +++++++++++++++---
> src/battery.h | 4 +
> test/test-fastpair | 561 +++++++++++++++++++++++++
> unit/test-fastpair.c | 286 +++++++++++++
> 13 files changed, 2079 insertions(+), 61 deletions(-)
> create mode 100644 profiles/fastpair/fastpair.c
> create mode 100644 profiles/fastpair/message-stream.c
> create mode 100644 profiles/fastpair/message-stream.h
> create mode 100755 test/test-fastpair
> create mode 100644 unit/test-fastpair.c
>
> --
> 2.55.0
>
--
Luiz Augusto von Dentz
^ permalink raw reply [flat|nested] 11+ messages in thread