[PATCH BlueZ v2 3/4] fastpair: Add Message Stream battery profile

Matthias Kurz <[email protected]>
Newsgroups org.kernel.vger.linux-bluetooth
Message-ID <2265ad3658e0f56472ddf0f3396e79e01ef8ea88.1787327795.git.m.kurz@irregular.at>
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
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.