All of lore.kernel.org
 help / color / mirror / Atom feed
* [PATCH BlueZ 0/4] Add component batteries and Fast Pair Message Stream
@ 2026-08-19 22:31 Matthias Kurz
  2026-08-19 22:31 ` [PATCH BlueZ 1/4] battery: Add component battery objects Matthias Kurz
                   ` (3 more replies)
  0 siblings, 4 replies; 6+ messages in thread
From: Matthias Kurz @ 2026-08-19 22:31 UTC (permalink / raw)
  To: linux-bluetooth

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.

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.

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

^ permalink raw reply	[flat|nested] 6+ messages in thread

* [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
                   ` (2 subsequent siblings)
  3 siblings, 1 reply; 6+ 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] 6+ 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
  2026-08-19 22:31 ` [PATCH BlueZ 4/4] test: Add Fast Pair Message Stream tool Matthias Kurz
  3 siblings, 0 replies; 6+ 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] 6+ 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
  3 siblings, 0 replies; 6+ 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] 6+ 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
  3 siblings, 0 replies; 6+ 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] 6+ 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; 6+ 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] 6+ messages in thread

end of thread, other threads:[~2026-08-19 23:37 UTC | newest]

Thread overview: 6+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
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 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
2026-08-19 22:31 ` [PATCH BlueZ 3/4] fastpair: Add Message Stream battery profile Matthias Kurz
2026-08-19 22:31 ` [PATCH BlueZ 4/4] test: Add Fast Pair Message Stream tool Matthias Kurz

This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.