From b3fac08b8f1fbb64562f00897817cf0659c45abf Mon Sep 17 00:00:00 2001 From: David Lechner Date: Sat, 24 Oct 2026 12:39:30 -0500 Subject: [PATCH 1/2] pb-service: get a Bluetooth hub's name from bleak, not its GATT Connecting to any hub over Bluetooth on macOS failed in the handshake with BleakCharacteristicNotFoundError for 0x2A00. CoreBluetooth keeps the Generic Access service to itself, so the Device Name characteristic is never among the services bleak sees. HubLink gains read_name(). Over Bluetooth it is BleakClient.name, which asks the OS for the name it already has, from that characteristic or the advertisement. Over USB and RFCOMM it is still a read of 0x2A00, which the hub answers itself, so no OS stands in the way. Reproduced with an Inventor hub on macOS before this change, and the same hub connects after it. Fixes: https://github.com/pybricks/support/issues/2870 --- packages/pb-service/CHANGELOG.md | 4 ++++ packages/pb-service/src/pb_service/hub/ble.py | 6 ++++++ packages/pb-service/src/pb_service/hub/connection.py | 4 +--- packages/pb-service/src/pb_service/hub/fake.py | 4 ++++ packages/pb-service/src/pb_service/hub/stream/link.py | 5 +++++ packages/pb-service/src/pb_service/hub/transport.py | 11 +++++++++++ .../pb-service/src/pb_service/hub/usb/transport.py | 4 +--- packages/pb-service/tests/test_combined.py | 3 +++ 8 files changed, 35 insertions(+), 6 deletions(-) diff --git a/packages/pb-service/CHANGELOG.md b/packages/pb-service/CHANGELOG.md index c6b1a78..6d9d173 100644 --- a/packages/pb-service/CHANGELOG.md +++ b/packages/pb-service/CHANGELOG.md @@ -5,6 +5,10 @@ Notable changes to `pybricks-service`. The format is based on ## [Unreleased] +### Fixed + +- Connecting over Bluetooth on macOS, which hid the hub's name from us. + ## [1.0.0b1] - 2026-10-18 ### Added diff --git a/packages/pb-service/src/pb_service/hub/ble.py b/packages/pb-service/src/pb_service/hub/ble.py index 16859fa..0ef1579 100644 --- a/packages/pb-service/src/pb_service/hub/ble.py +++ b/packages/pb-service/src/pb_service/hub/ble.py @@ -115,6 +115,12 @@ def __init__(self, client: BleakClient) -> None: async def read_characteristic(self, uuid: str) -> bytes: return bytes(await self._client.read_gatt_char(uuid)) + async def read_name(self) -> str: + # CoreBluetooth hides the Generic Access service, so the Device Name + # characteristic cannot be read on macOS. bleak asks the OS instead, + # which has the name from that characteristic or the advertisement. + return self._client.name + async def write_characteristic( self, uuid: str, data: bytes, *, response: bool = True ) -> None: diff --git a/packages/pb-service/src/pb_service/hub/connection.py b/packages/pb-service/src/pb_service/hub/connection.py index 39fa8b1..9e53864 100644 --- a/packages/pb-service/src/pb_service/hub/connection.py +++ b/packages/pb-service/src/pb_service/hub/connection.py @@ -210,9 +210,7 @@ async def _run(self, *, task_status: TaskStatus[HubInfo]) -> None: async def _handshake(self, link: HubLink) -> HubInfo: """Read the hub's identity and capabilities.""" - name = (await link.read_characteristic(p.DEVICE_NAME_UUID)).decode( - "utf-8", "replace" - ) + name = await link.read_name() firmware = (await link.read_characteristic(p.FIRMWARE_REVISION_UUID)).decode( "utf-8", "replace" ) diff --git a/packages/pb-service/src/pb_service/hub/fake.py b/packages/pb-service/src/pb_service/hub/fake.py index c072bd6..8b8f723 100644 --- a/packages/pb-service/src/pb_service/hub/fake.py +++ b/packages/pb-service/src/pb_service/hub/fake.py @@ -341,6 +341,10 @@ async def read_characteristic(self, uuid: str) -> bytes: self._require_connected() return self._hub.read(uuid) + async def read_name(self) -> str: + self._require_connected() + return self._hub.name + async def write_characteristic( self, uuid: str, data: bytes, *, response: bool = True ) -> None: diff --git a/packages/pb-service/src/pb_service/hub/stream/link.py b/packages/pb-service/src/pb_service/hub/stream/link.py index fff57a7..9170be8 100644 --- a/packages/pb-service/src/pb_service/hub/stream/link.py +++ b/packages/pb-service/src/pb_service/hub/stream/link.py @@ -89,6 +89,11 @@ async def read_characteristic(self, uuid: str) -> bytes: if reply is not None and reply[0] == selector: return reply[1] + async def read_name(self) -> str: + return (await self.read_characteristic(p.DEVICE_NAME_UUID)).decode( + "utf-8", "replace" + ) + async def write_characteristic( self, uuid: str, data: bytes, *, response: bool = True ) -> None: diff --git a/packages/pb-service/src/pb_service/hub/transport.py b/packages/pb-service/src/pb_service/hub/transport.py index b0eaf27..6f081cc 100644 --- a/packages/pb-service/src/pb_service/hub/transport.py +++ b/packages/pb-service/src/pb_service/hub/transport.py @@ -266,6 +266,17 @@ async def read_characteristic(self, uuid: str) -> bytes: """ ... + async def read_name(self) -> str: + """Read the hub's name. + + Not a characteristic read on every transport: some operating systems + keep the GATT Device Name characteristic to themselves. + + Returns: + What the hub is called. + """ + ... + async def write_characteristic( self, uuid: str, data: bytes, *, response: bool = True ) -> None: diff --git a/packages/pb-service/src/pb_service/hub/usb/transport.py b/packages/pb-service/src/pb_service/hub/usb/transport.py index 86bf2db..1e58464 100644 --- a/packages/pb-service/src/pb_service/hub/usb/transport.py +++ b/packages/pb-service/src/pb_service/hub/usb/transport.py @@ -215,9 +215,7 @@ async def _probe( async with anyio.create_task_group() as tg: tg.start_soon(link.run, None) - name = ( - await link.read_characteristic(p.DEVICE_NAME_UUID) - ).decode("utf-8", "replace") + name = await link.read_name() raw_pnp = await link.read_characteristic(p.PNP_ID_UUID) tg.cancel_scope.cancel() diff --git a/packages/pb-service/tests/test_combined.py b/packages/pb-service/tests/test_combined.py index 00319ff..d5a456c 100644 --- a/packages/pb-service/tests/test_combined.py +++ b/packages/pb-service/tests/test_combined.py @@ -98,6 +98,9 @@ def is_connected(self) -> bool: async def read_characteristic(self, uuid: str) -> bytes: return b"" + async def read_name(self) -> str: + return "" + async def write_characteristic( self, uuid: str, data: bytes, *, response: bool = True ) -> None: From 9d3df05da55b9df8ae7335347cf90f7061b01438 Mon Sep 17 00:00:00 2001 From: David Lechner Date: Sat, 24 Oct 2026 12:50:10 -0500 Subject: [PATCH 2/2] pb-service: test that a hub is named without its Device Name characteristic The fake hub no longer serves 0x2A00, as macOS will not let it be read, so a handshake that goes back to reading it fails here rather than only on a Mac. Before, it served the name and every test passed either way. Also simpler wording for the comment on read_name and the changelog. --- packages/pb-service/CHANGELOG.md | 2 +- packages/pb-service/src/pb_service/hub/ble.py | 5 ++--- packages/pb-service/src/pb_service/hub/fake.py | 4 ++-- packages/pb-service/tests/test_connection.py | 17 +++++++++++++++++ 4 files changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/pb-service/CHANGELOG.md b/packages/pb-service/CHANGELOG.md index 6d9d173..00a463e 100644 --- a/packages/pb-service/CHANGELOG.md +++ b/packages/pb-service/CHANGELOG.md @@ -7,7 +7,7 @@ Notable changes to `pybricks-service`. The format is based on ### Fixed -- Connecting over Bluetooth on macOS, which hid the hub's name from us. +- Connecting over Bluetooth on macOS, which failed reading the hub's name. ## [1.0.0b1] - 2026-10-18 diff --git a/packages/pb-service/src/pb_service/hub/ble.py b/packages/pb-service/src/pb_service/hub/ble.py index 0ef1579..c1e0bd1 100644 --- a/packages/pb-service/src/pb_service/hub/ble.py +++ b/packages/pb-service/src/pb_service/hub/ble.py @@ -116,9 +116,8 @@ async def read_characteristic(self, uuid: str) -> bytes: return bytes(await self._client.read_gatt_char(uuid)) async def read_name(self) -> str: - # CoreBluetooth hides the Generic Access service, so the Device Name - # characteristic cannot be read on macOS. bleak asks the OS instead, - # which has the name from that characteristic or the advertisement. + # Not a read of the Device Name characteristic, which macOS does not + # allow. bleak asks the OS for the name instead, on every platform. return self._client.name async def write_characteristic( diff --git a/packages/pb-service/src/pb_service/hub/fake.py b/packages/pb-service/src/pb_service/hub/fake.py index 8b8f723..f279a63 100644 --- a/packages/pb-service/src/pb_service/hub/fake.py +++ b/packages/pb-service/src/pb_service/hub/fake.py @@ -295,8 +295,8 @@ def read(self, uuid: str) -> bytes: HubRejection: If this hub has no such characteristic. """ - if uuid == p.DEVICE_NAME_UUID: - return self.name.encode() + # No Device Name, as on macOS, where CoreBluetooth keeps the Generic + # Access service to itself: the name has to come from elsewhere. if uuid == p.FIRMWARE_REVISION_UUID: return self.firmware.encode() diff --git a/packages/pb-service/tests/test_connection.py b/packages/pb-service/tests/test_connection.py index 9c48339..756f412 100644 --- a/packages/pb-service/tests/test_connection.py +++ b/packages/pb-service/tests/test_connection.py @@ -100,6 +100,23 @@ async def test_handshake_reads_identity_and_capabilities(hub: FakeHub) -> None: connection.close("done") +async def test_handshake_names_the_hub_without_the_device_name_characteristic( + hub: FakeHub, +) -> None: + # macOS will not let the Device Name characteristic be read, and the fake + # hub does not serve it either, so reading it here fails as it did there. + # https://github.com/pybricks/support/issues/2870 + transport = FakeHubTransport(hub) + + with pytest.raises(HubRejection): + hub.read(p.DEVICE_NAME_UUID) + + async with anyio.create_task_group() as tg: + connection, info = await _start(tg, transport, hub) + assert info.name == hub.name + connection.close("done") + + async def test_old_firmware_is_refused_with_advice() -> None: # A hub too old for the capabilities characteristic cannot be supported, # and the message should say what to do about it.