All of lore.kernel.org
 help / color / mirror / Atom feed
* 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; 7+ 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] 7+ messages in thread

* [PATCH BlueZ v2 0/4] Add component batteries and Fast Pair Message Stream
@ 2026-08-21 16:07 Matthias Kurz
  2026-08-21 16:08 ` [PATCH BlueZ v2 1/4] battery: Add component battery objects Matthias Kurz
                   ` (3 more replies)
  0 siblings, 4 replies; 7+ messages in thread
From: Matthias Kurz @ 2026-08-21 16:07 UTC (permalink / raw)
  To: linux-bluetooth; +Cc: Bastien Nocera, Luiz Augusto von Dentz

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. The Message Stream
specification defines both a fixed endpoint UUID over RFCOMM and an L2CAP PSM
transport:

https://developers.google.com/nearby/fast-pair/specifications/extensions/messagestream

This series implements the RFCOMM variant. Pixel Buds Pro advertise the fixed
UUID in SDP, from which the profile obtains the RFCOMM channel. L2CAP Message
Stream support is left for future work.

The Device Information extension defines battery update message group 0x03,
code 0x03, with three component bytes using the Battery Notification
encoding:

https://developers.google.com/nearby/fast-pair/specifications/extensions/deviceinformation
https://developers.google.com/nearby/fast-pair/specifications/extensions/batterynotification

The generic unknown-level status bit is retained for earbuds. The
TWS-specific unsupported-case value 0xff is treated as unavailable:

https://developers.google.com/nearby/fast-pair/specifications/devicefeaturerequirement/devicefeaturerequirement_hearables

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.

Existing UPower versions continue to consume the aggregate Battery1 object.
When experimental BlueZ is enabled, they may log a coldplug warning for a
child object and ignore it because they expect Device1 at the same path. The
aggregate remains available, so the currently displayed value is preserved.

UPower support for component objects, including python-dbusmock integration
tests, is available here:

https://gitlab.freedesktop.org/mkurz/upower/-/commits/fastpair-multi-battery

It is based on:

https://gitlab.freedesktop.org/upower/upower/-/merge_requests/337

With the Bluetooth address anonymised, the object hierarchy observed with
Pixel Buds Pro was:

  /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. GetAll returned:

  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. This layout can be reproduced with python-dbusmock.

The complete path was tested 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. Related KDE
changes are:

https://invent.kde.org/frameworks/solid/-/merge_requests/264
https://invent.kde.org/plasma/powerdevil/-/merge_requests/666

The series 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 seven Fast
Pair parser tests provide 100% line coverage (55/55) and 95.83%
branch-direction coverage (46/48) for message-stream.c. They cover complete,
fragmented, coalesced/partial, zero-length and maximum-length frames, invalid
input, unknown-level status bits, the unavailable-case sentinel, and
reserved battery values.

Changes in v2:
- Clarify the RFCOMM transport and cite the protocol sources.
- Gate component objects and properties behind experimental mode.
- Document opaque component paths and the provider properties separately.
- Replace Message Stream magic numbers with shared named constants.
- Correct unknown-level and TWS unsupported-case decoding.
- Invalidate stale values and make disconnect handling reliable with
  dual-mode devices.
- Add reconnect backoff, permanent-error handling, and safe pending-connect
  cancellation.
- Improve the diagnostic provider's ObjectManager lifecycle and isolate it
  per adapter.
- Expand parser tests and record their coverage in doc/test-coverage.txt.

v1:
https://lore.kernel.org/r/20260819223144.82045-1-m.kurz@irregular.at

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 +
 doc/test-coverage.txt              |   3 +-
 profiles/fastpair/fastpair.c       | 650 +++++++++++++++++++++++++++++
 profiles/fastpair/message-stream.c | 135 ++++++
 profiles/fastpair/message-stream.h |  58 +++
 src/battery.c                      | 401 +++++++++++++++---
 src/battery.h                      |   4 +
 test/test-fastpair                 | 568 +++++++++++++++++++++++++
 unit/test-fastpair.c               | 322 ++++++++++++++
 14 files changed, 2146 insertions(+), 62 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


base-commit: c73fa2f9ae2d366cb8a4f101fa9a5ccd9f33a4ea
-- 
2.55.0

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

* [PATCH BlueZ v2 1/4] battery: Add component battery objects
  2026-08-21 16:07 [PATCH BlueZ v2 0/4] Add component batteries and Fast Pair Message Stream Matthias Kurz
@ 2026-08-21 16:08 ` Matthias Kurz
  2026-08-21 17:08   ` Add component batteries and Fast Pair Message Stream bluez.test.bot
  2026-08-21 16:08 ` [PATCH BlueZ v2 2/4] doc: Document component battery objects Matthias Kurz
                   ` (2 subsequent siblings)
  3 siblings, 1 reply; 7+ messages in thread
From: Matthias Kurz @ 2026-08-21 16:08 UTC (permalink / raw)
  To: linux-bluetooth; +Cc: Bastien Nocera, Luiz Augusto von Dentz

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] 7+ messages in thread

* [PATCH BlueZ v2 2/4] doc: Document component battery objects
  2026-08-21 16:07 [PATCH BlueZ v2 0/4] Add component batteries and Fast Pair Message Stream Matthias Kurz
  2026-08-21 16:08 ` [PATCH BlueZ v2 1/4] battery: Add component battery objects Matthias Kurz
@ 2026-08-21 16:08 ` Matthias Kurz
  2026-08-21 16:08 ` [PATCH BlueZ v2 3/4] fastpair: Add Message Stream battery profile Matthias Kurz
  2026-08-21 16:08 ` [PATCH BlueZ v2 4/4] test: Add Fast Pair Message Stream tool Matthias Kurz
  3 siblings, 0 replies; 7+ messages in thread
From: Matthias Kurz @ 2026-08-21 16:08 UTC (permalink / raw)
  To: linux-bluetooth; +Cc: Bastien Nocera, Luiz Augusto von Dentz

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] 7+ messages in thread

* [PATCH BlueZ v2 3/4] fastpair: Add Message Stream battery profile
  2026-08-21 16:07 [PATCH BlueZ v2 0/4] Add component batteries and Fast Pair Message Stream Matthias Kurz
  2026-08-21 16:08 ` [PATCH BlueZ v2 1/4] battery: Add component battery objects Matthias Kurz
  2026-08-21 16:08 ` [PATCH BlueZ v2 2/4] doc: Document component battery objects Matthias Kurz
@ 2026-08-21 16:08 ` Matthias Kurz
  2026-08-21 16:08 ` [PATCH BlueZ v2 4/4] test: Add Fast Pair Message Stream tool Matthias Kurz
  3 siblings, 0 replies; 7+ messages in thread
From: Matthias Kurz @ 2026-08-21 16:08 UTC (permalink / raw)
  To: linux-bluetooth; +Cc: Bastien Nocera, Luiz Augusto von Dentz

Connect to the experimental Fast Pair Message Stream service advertised by
compatible BR/EDR devices and parse its 16-bit-length framed messages.

The Message Stream specification defines a fixed endpoint UUID over RFCOMM
and a separate L2CAP PSM transport. This implementation supports the RFCOMM
variant; L2CAP support is left for future work:

https://developers.google.com/nearby/fast-pair/specifications/extensions/messagestream

Publish left, right, and case values as component Battery1 objects. The
Device Information extension defines the battery update message. Its three
component bytes use the Battery Notification encoding:

https://developers.google.com/nearby/fast-pair/specifications/extensions/deviceinformation
https://developers.google.com/nearby/fast-pair/specifications/extensions/batterynotification

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, as required for hearables:

https://developers.google.com/nearby/fast-pair/specifications/devicefeaturerequirement/devicefeaturerequirement_hearables

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 +
 doc/test-coverage.txt              |   3 +-
 profiles/fastpair/fastpair.c       | 650 +++++++++++++++++++++++++++++
 profiles/fastpair/message-stream.c | 135 ++++++
 profiles/fastpair/message-stream.h |  58 +++
 unit/test-fastpair.c               | 322 ++++++++++++++
 8 files changed, 1181 insertions(+), 1 deletion(-)
 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/doc/test-coverage.txt b/doc/test-coverage.txt
index b92a2ae59..6b62c6e60 100644
--- a/doc/test-coverage.txt
+++ b/doc/test-coverage.txt
@@ -31,8 +31,9 @@ test-gdbus-client	  13	D-Bus client handling
 test-gatt		 180	GATT qualification test cases
 test-hog		   6	HID Over GATT qualification test cases
 test-battery		  10	Battery charge test cases
+test-fastpair		   7	Fast Pair Message Stream parsing
 			-----
-			 771
+			 778
 
 
 Automated end-to-end testing
diff --git a/profiles/fastpair/fastpair.c b/profiles/fastpair/fastpair.c
new file mode 100644
index 000000000..b879ea28a
--- /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] = {
+	[FASTPAIR_BATTERY_LEFT] = "left",
+	[FASTPAIR_BATTERY_RIGHT] = "right",
+	[FASTPAIR_BATTERY_CASE] = "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_PERCENTAGE_UNKNOWN);
+		btd_battery_update_charging(fastpair->batteries[i],
+					FASTPAIR_BATTERY_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..1d8770c3e
--- /dev/null
+++ b/profiles/fastpair/message-stream.c
@@ -0,0 +1,135 @@
+// 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"
+
+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 >= FASTPAIR_MESSAGE_HEADER_LENGTH) {
+		const uint8_t *header = stream->buffer->data;
+		uint16_t payload_length;
+		guint frame_length;
+
+		payload_length = ((uint16_t)
+			header[FASTPAIR_MESSAGE_LENGTH_OFFSET] << 8) |
+			header[FASTPAIR_MESSAGE_LENGTH_OFFSET + 1];
+		frame_length = FASTPAIR_MESSAGE_HEADER_LENGTH + payload_length;
+		if (stream->buffer->len < frame_length)
+			break;
+
+		stream->callback(header[FASTPAIR_MESSAGE_GROUP_OFFSET],
+				header[FASTPAIR_MESSAGE_CODE_OFFSET],
+				stream->buffer->data +
+					FASTPAIR_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] & FASTPAIR_BATTERY_LEVEL_MASK;
+		bool case_unavailable;
+		bool level_valid = percentage <= FASTPAIR_BATTERY_LEVEL_MAX;
+		bool status_valid;
+
+		batteries[i].percentage = level_valid ? percentage :
+					FASTPAIR_BATTERY_PERCENTAGE_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_CASE &&
+				payload[i] == FASTPAIR_BATTERY_CASE_UNAVAILABLE;
+		status_valid = level_valid ||
+				(percentage == FASTPAIR_BATTERY_LEVEL_UNKNOWN &&
+				 !case_unavailable);
+
+		if (!status_valid)
+			batteries[i].charging =
+				FASTPAIR_BATTERY_CHARGING_UNKNOWN;
+		else
+			batteries[i].charging =
+				!!(payload[i] & FASTPAIR_BATTERY_CHARGING_MASK);
+	}
+
+	return true;
+}
diff --git a/profiles/fastpair/message-stream.h b/profiles/fastpair/message-stream.h
new file mode 100644
index 000000000..78455fe5f
--- /dev/null
+++ b/profiles/fastpair/message-stream.h
@@ -0,0 +1,58 @@
+/* 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_MESSAGE_GROUP_OFFSET 0
+#define FASTPAIR_MESSAGE_CODE_OFFSET 1
+#define FASTPAIR_MESSAGE_LENGTH_OFFSET 2
+#define FASTPAIR_MESSAGE_HEADER_LENGTH 4
+
+#define FASTPAIR_DEVICE_INFORMATION_GROUP 0x03
+#define FASTPAIR_BATTERY_UPDATE_CODE 0x03
+#define FASTPAIR_BATTERY_LEVEL_MASK 0x7f
+#define FASTPAIR_BATTERY_CHARGING_MASK 0x80
+#define FASTPAIR_BATTERY_LEVEL_MAX 100
+#define FASTPAIR_BATTERY_LEVEL_UNKNOWN FASTPAIR_BATTERY_LEVEL_MASK
+#define FASTPAIR_BATTERY_CASE_UNAVAILABLE UINT8_MAX
+#define FASTPAIR_BATTERY_PERCENTAGE_UNKNOWN UINT8_MAX
+#define FASTPAIR_BATTERY_CHARGING_UNKNOWN (-1)
+
+enum fastpair_battery_component {
+	FASTPAIR_BATTERY_LEFT,
+	FASTPAIR_BATTERY_RIGHT,
+	FASTPAIR_BATTERY_CASE,
+	FASTPAIR_BATTERY_COUNT,
+};
+
+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..c840c8758
--- /dev/null
+++ b/unit/test-fastpair.c
@@ -0,0 +1,322 @@
+// 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[] = {
+		FASTPAIR_DEVICE_INFORMATION_GROUP,
+		FASTPAIR_BATTERY_UPDATE_CODE,
+		0x00, sizeof(payload), 0x60, 0x00, 0x5c,
+	};
+	static const struct expected_message messages[] = {
+		{ FASTPAIR_DEVICE_INFORMATION_GROUP,
+		  FASTPAIR_BATTERY_UPDATE_CODE, 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[] = {
+		FASTPAIR_DEVICE_INFORMATION_GROUP,
+		FASTPAIR_BATTERY_UPDATE_CODE,
+		0x00, sizeof(battery), 0x60, 0x00, 0x5c,
+		0x01, 0x02, 0x00, sizeof(other), 0xaa, 0xbb,
+	};
+	static const struct expected_message messages[] = {
+		{ FASTPAIR_DEVICE_INFORMATION_GROUP,
+		  FASTPAIR_BATTERY_UPDATE_CODE, 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[] = {
+		FASTPAIR_DEVICE_INFORMATION_GROUP,
+		FASTPAIR_BATTERY_UPDATE_CODE,
+		0x00, sizeof(battery), 0x60, 0x00, 0x5c,
+		0x01, 0x02, 0x00, sizeof(other), 0xaa, 0xbb,
+	};
+	static const struct expected_message messages[] = {
+		{ FASTPAIR_DEVICE_INFORMATION_GROUP,
+		  FASTPAIR_BATTERY_UPDATE_CODE, 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 = FASTPAIR_MESSAGE_HEADER_LENGTH +
+							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;
+	size_t oversized_length = (size_t) G_MAXUINT + 1;
+
+	g_assert_null(fastpair_message_stream_new(NULL, NULL));
+	fastpair_message_stream_free(NULL);
+
+	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));
+
+	if (oversized_length > G_MAXUINT)
+		g_assert_false(fastpair_message_stream_feed(stream, frame,
+							oversized_length));
+
+	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 = FASTPAIR_MESSAGE_HEADER_LENGTH + UINT16_MAX;
+
+	frame = g_malloc(frame_length);
+	frame[FASTPAIR_MESSAGE_GROUP_OFFSET] = 0x01;
+	frame[FASTPAIR_MESSAGE_CODE_OFFSET] = 0x02;
+	frame[FASTPAIR_MESSAGE_LENGTH_OFFSET] = 0xff;
+	frame[FASTPAIR_MESSAGE_LENGTH_OFFSET + 1] = 0xff;
+	memset(frame + FASTPAIR_MESSAGE_HEADER_LENGTH, 0xa5, UINT16_MAX);
+
+	message.group = frame[FASTPAIR_MESSAGE_GROUP_OFFSET];
+	message.code = frame[FASTPAIR_MESSAGE_CODE_OFFSET];
+	message.length = UINT16_MAX;
+	message.payload = frame + FASTPAIR_MESSAGE_HEADER_LENGTH;
+
+	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(
+					FASTPAIR_DEVICE_INFORMATION_GROUP,
+					FASTPAIR_BATTERY_UPDATE_CODE,
+					payload, sizeof(payload), batteries));
+	g_assert_cmpuint(batteries[FASTPAIR_BATTERY_LEFT].percentage, ==, 96);
+	g_assert_false(batteries[FASTPAIR_BATTERY_LEFT].charging);
+	g_assert_cmpuint(batteries[FASTPAIR_BATTERY_RIGHT].percentage, ==, 42);
+	g_assert_true(batteries[FASTPAIR_BATTERY_RIGHT].charging);
+	g_assert_cmpuint(batteries[FASTPAIR_BATTERY_CASE].percentage, ==,
+					FASTPAIR_BATTERY_PERCENTAGE_UNKNOWN);
+	g_assert_cmpint(batteries[FASTPAIR_BATTERY_CASE].charging, ==, 0);
+
+	g_assert_true(fastpair_message_get_batteries(
+					FASTPAIR_DEVICE_INFORMATION_GROUP,
+					FASTPAIR_BATTERY_UPDATE_CODE,
+					unknown, sizeof(unknown), batteries));
+	g_assert_cmpuint(batteries[FASTPAIR_BATTERY_LEFT].percentage, ==,
+					FASTPAIR_BATTERY_PERCENTAGE_UNKNOWN);
+	g_assert_cmpint(batteries[FASTPAIR_BATTERY_LEFT].charging, ==, 1);
+	g_assert_cmpuint(batteries[FASTPAIR_BATTERY_RIGHT].percentage, ==,
+					FASTPAIR_BATTERY_PERCENTAGE_UNKNOWN);
+	g_assert_cmpint(batteries[FASTPAIR_BATTERY_RIGHT].charging, ==, 0);
+	g_assert_cmpuint(batteries[FASTPAIR_BATTERY_CASE].percentage, ==,
+					FASTPAIR_BATTERY_PERCENTAGE_UNKNOWN);
+	g_assert_cmpint(batteries[FASTPAIR_BATTERY_CASE].charging, ==,
+					FASTPAIR_BATTERY_CHARGING_UNKNOWN);
+
+	g_assert_true(fastpair_message_get_batteries(
+					FASTPAIR_DEVICE_INFORMATION_GROUP,
+					FASTPAIR_BATTERY_UPDATE_CODE,
+					reserved, sizeof(reserved), batteries));
+	for (unsigned int i = 0; i < FASTPAIR_BATTERY_COUNT; i++) {
+		g_assert_cmpuint(batteries[i].percentage, ==,
+					FASTPAIR_BATTERY_PERCENTAGE_UNKNOWN);
+		g_assert_cmpint(batteries[i].charging, ==,
+					FASTPAIR_BATTERY_CHARGING_UNKNOWN);
+	}
+
+	g_assert_false(fastpair_message_get_batteries(
+					FASTPAIR_DEVICE_INFORMATION_GROUP - 1,
+					FASTPAIR_BATTERY_UPDATE_CODE,
+					payload, sizeof(payload), batteries));
+	g_assert_false(fastpair_message_get_batteries(
+					FASTPAIR_DEVICE_INFORMATION_GROUP,
+					FASTPAIR_BATTERY_UPDATE_CODE - 1,
+					payload, sizeof(payload), batteries));
+	g_assert_false(fastpair_message_get_batteries(
+					FASTPAIR_DEVICE_INFORMATION_GROUP,
+					FASTPAIR_BATTERY_UPDATE_CODE,
+					payload, sizeof(payload) - 1,
+					batteries));
+	g_assert_false(fastpair_message_get_batteries(
+					FASTPAIR_DEVICE_INFORMATION_GROUP,
+					FASTPAIR_BATTERY_UPDATE_CODE,
+					NULL, sizeof(payload), batteries));
+	g_assert_false(fastpair_message_get_batteries(
+					FASTPAIR_DEVICE_INFORMATION_GROUP,
+					FASTPAIR_BATTERY_UPDATE_CODE,
+					payload, sizeof(payload), NULL));
+	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] 7+ messages in thread

* [PATCH BlueZ v2 4/4] test: Add Fast Pair Message Stream tool
  2026-08-21 16:07 [PATCH BlueZ v2 0/4] Add component batteries and Fast Pair Message Stream Matthias Kurz
                   ` (2 preceding siblings ...)
  2026-08-21 16:08 ` [PATCH BlueZ v2 3/4] fastpair: Add Message Stream battery profile Matthias Kurz
@ 2026-08-21 16:08 ` Matthias Kurz
  3 siblings, 0 replies; 7+ messages in thread
From: Matthias Kurz @ 2026-08-21 16:08 UTC (permalink / raw)
  To: linux-bluetooth; +Cc: Bastien Nocera, Luiz Augusto von Dentz

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 | 568 +++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 569 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..9db85a122
--- /dev/null
+++ b/test/test-fastpair
@@ -0,0 +1,568 @@
+#!/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
+BATTERY_LEVEL_MASK = 0x7f
+BATTERY_CHARGING_MASK = 0x80
+BATTERY_LEVEL_MAX = 100
+BATTERY_LEVEL_UNKNOWN = BATTERY_LEVEL_MASK
+BATTERY_CASE_UNAVAILABLE = 0xff
+
+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 & BATTERY_LEVEL_MASK
+	level_valid = percentage <= BATTERY_LEVEL_MAX
+	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 == BATTERY_CASE_UNAVAILABLE)
+	status_valid = (level_valid or
+		((value & BATTERY_LEVEL_MASK) == BATTERY_LEVEL_UNKNOWN and
+		 not case_unavailable))
+	charging = bool(value & BATTERY_CHARGING_MASK) 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] 7+ messages in thread

* RE: Add component batteries and Fast Pair Message Stream
  2026-08-21 16:08 ` [PATCH BlueZ v2 1/4] battery: Add component battery objects Matthias Kurz
@ 2026-08-21 17:08   ` bluez.test.bot
  0 siblings, 0 replies; 7+ messages in thread
From: bluez.test.bot @ 2026-08-21 17:08 UTC (permalink / raw)
  To: linux-bluetooth, m.kurz

[-- Attachment #1: Type: text/plain, Size: 4875 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=1149924

---Test result---

Test Summary:
CheckPatch                    FAIL      3.10 seconds
GitLint                       PASS      1.34 seconds
BuildEll                      PASS      21.53 seconds
BluezMake                     PASS      626.39 seconds
MakeCheck                     PASS      19.77 seconds
MakeDistcheck                 PASS      165.13 seconds
CheckValgrind                 PASS      236.59 seconds
CheckSmatch                   PASS      320.33 seconds
bluezmakeextell               PASS      103.89 seconds
IncrementalBuild              PASS      669.66 seconds
ScanBuild                     PASS      1035.62 seconds

Details
##############################
Test: CheckPatch - FAIL
Desc: Run checkpatch.pl script
Output:
[BlueZ,v2,1/4] battery: Add component battery objects
WARNING:BAD_SIGN_OFF: Non-standard signature: Assisted-by:
#114: 
Assisted-by: Codex:gpt-5.6-sol

ERROR:BAD_SIGN_OFF: Unrecognized email address: 'Codex:gpt-5.6-sol'
#114: 
Assisted-by: Codex:gpt-5.6-sol

/github/workspace/src/patch/14762094.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/14762094.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,v2,2/4] doc: Document component battery objects
WARNING:BAD_SIGN_OFF: Non-standard signature: Assisted-by:
#107: 
Assisted-by: Codex:gpt-5.6-sol

ERROR:BAD_SIGN_OFF: Unrecognized email address: 'Codex:gpt-5.6-sol'
#107: 
Assisted-by: Codex:gpt-5.6-sol

/github/workspace/src/patch/14762093.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/14762093.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,v2,3/4] fastpair: Add Message Stream battery profile
WARNING:COMMIT_LOG_LONG_LINE: Possible unwrapped commit description (prefer a maximum 75 chars per line)
#110: 
https://developers.google.com/nearby/fast-pair/specifications/extensions/messagestream

WARNING:BAD_SIGN_OFF: Non-standard signature: Assisted-by:
#132: 
Assisted-by: Codex:gpt-5.6-sol

ERROR:BAD_SIGN_OFF: Unrecognized email address: 'Codex:gpt-5.6-sol'
#132: 
Assisted-by: Codex:gpt-5.6-sol

/github/workspace/src/patch/14762096.patch total: 1 errors, 2 warnings, 1207 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/14762096.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,v2,4/4] test: Add Fast Pair Message Stream tool
WARNING:BAD_SIGN_OFF: Non-standard signature: Assisted-by:
#111: 
Assisted-by: Codex:gpt-5.6-sol

ERROR:BAD_SIGN_OFF: Unrecognized email address: 'Codex:gpt-5.6-sol'
#111: 
Assisted-by: Codex:gpt-5.6-sol

ERROR:EXECUTE_PERMISSIONS: do not set execute permissions for source files
#132: FILE: test/test-fastpair

/github/workspace/src/patch/14762095.patch total: 2 errors, 1 warnings, 576 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/14762095.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/2428

---
Regards,
Linux Bluetooth


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

end of thread, other threads:[~2026-08-21 17:08 UTC | newest]

Thread overview: 7+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
2026-08-21 16:07 [PATCH BlueZ v2 0/4] Add component batteries and Fast Pair Message Stream Matthias Kurz
2026-08-21 16:08 ` [PATCH BlueZ v2 1/4] battery: Add component battery objects Matthias Kurz
2026-08-21 17:08   ` Add component batteries and Fast Pair Message Stream bluez.test.bot
2026-08-21 16:08 ` [PATCH BlueZ v2 2/4] doc: Document component battery objects Matthias Kurz
2026-08-21 16:08 ` [PATCH BlueZ v2 3/4] fastpair: Add Message Stream battery profile Matthias Kurz
2026-08-21 16:08 ` [PATCH BlueZ v2 4/4] test: Add Fast Pair Message Stream tool Matthias Kurz
  -- strict thread matches above, loose matches on Subject: below --
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

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.