diff --git a/packages/pb-service/CHANGELOG.md b/packages/pb-service/CHANGELOG.md index c6b1a78..00a463e 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 failed reading the hub's name. + ## [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..c1e0bd1 100644 --- a/packages/pb-service/src/pb_service/hub/ble.py +++ b/packages/pb-service/src/pb_service/hub/ble.py @@ -115,6 +115,11 @@ 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: + # 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( 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..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() @@ -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: 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.