From db8c5fc3d86345c7fa89841cab2532cb699c14fa Mon Sep 17 00:00:00 2001 From: Frederik Leonhardt Date: Mon, 5 May 2025 21:34:39 +1200 Subject: [PATCH 01/10] Update to pybricks 3.6.x --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index bdf0cee..16969dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ "bluetooth-adapters ~= 0.16", "cachetools ~= 5.3", "dbus-fast ~= 2.15", - "pybricks ~= 3.5.0", + "pybricks ~= 3.6.0", ] [dependency-groups] From f2ea6ff92284fb9ebc2bb6c9b436618cd4b6b4a0 Mon Sep 17 00:00:00 2001 From: Frederik Leonhardt Date: Mon, 5 May 2025 21:40:41 +1200 Subject: [PATCH 02/10] Add py.typed marker --- src/pb_ble/py.typed | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/pb_ble/py.typed diff --git a/src/pb_ble/py.typed b/src/pb_ble/py.typed new file mode 100644 index 0000000..e69de29 From a38028cf1f4a6aa1a31614f9a0e343c5b4e86b19 Mon Sep 17 00:00:00 2001 From: Frederik Leonhardt Date: Mon, 5 May 2025 21:40:17 +1200 Subject: [PATCH 03/10] Stop unpacking single values - Use packing args to construct tuple from input --- src/pb_ble/constants.py | 4 +--- src/pb_ble/messages.py | 11 +++++++---- src/pb_ble/vhub.py | 12 ++++++++---- tests/test_messages.py | 6 ++++-- tests/test_vhub.py | 22 ++++++++++++++++++++++ 5 files changed, 42 insertions(+), 13 deletions(-) diff --git a/src/pb_ble/constants.py b/src/pb_ble/constants.py index f315d47..4eb248a 100644 --- a/src/pb_ble/constants.py +++ b/src/pb_ble/constants.py @@ -17,9 +17,7 @@ PybricksBroadcastValue: TypeAlias = bool | int | float | str | bytes """Type of a value that can be broadcast.""" -PybricksBroadcastData: TypeAlias = ( - PybricksBroadcastValue | tuple[PybricksBroadcastValue] -) +PybricksBroadcastData: TypeAlias = tuple[PybricksBroadcastValue] """Type of the broadcast data.""" diff --git a/src/pb_ble/messages.py b/src/pb_ble/messages.py index 7b02544..af7b500 100644 --- a/src/pb_ble/messages.py +++ b/src/pb_ble/messages.py @@ -12,9 +12,9 @@ from enum import IntEnum from struct import pack, unpack, unpack_from -from typing import Literal, Tuple +from typing import Literal, Tuple, cast -from .constants import PybricksBroadcast, PybricksBroadcastValue +from .constants import PybricksBroadcast, PybricksBroadcastData, PybricksBroadcastValue def decode_message( @@ -45,9 +45,12 @@ def decode_message( decoded_data.append(val) if single_object: - return PybricksBroadcast(channel, decoded_data[0]) + decoded_value: PybricksBroadcastValue = decoded_data[0] + return PybricksBroadcast(channel, (decoded_value,)) else: - return PybricksBroadcast(channel, tuple(decoded_data)) # type: ignore # https://github.com/python/mypy/issues/7509 + return PybricksBroadcast( + channel, cast(PybricksBroadcastData, tuple(decoded_data)) + ) def encode_message(channel: int = 0, *values: PybricksBroadcastValue) -> bytes: diff --git a/src/pb_ble/vhub.py b/src/pb_ble/vhub.py index 1088902..80a91ac 100644 --- a/src/pb_ble/vhub.py +++ b/src/pb_ble/vhub.py @@ -1,6 +1,6 @@ import sys from contextlib import AsyncExitStack -from typing import ClassVar, Optional, Sequence, Tuple, Union +from typing import ClassVar, Optional, Sequence, Tuple, Union, cast from dbus_fast.aio import MessageBus, ProxyObject from dbus_fast.constants import BusType @@ -16,6 +16,8 @@ from .constants import ( PYBRICKS_MAX_CHANNEL, PYBRICKS_MIN_CHANNEL, + PybricksBroadcastData, + PybricksBroadcastValue, ScanningMode, ) @@ -61,13 +63,15 @@ async def __aenter__(self): raise return self - async def broadcast(self, data: Union[bool, int, float, str, bytes]) -> None: - if data is None: + async def broadcast(self, *data: PybricksBroadcastValue | None) -> None: # type: ignore [override] + if len(data) == 0: + raise ValueError("Broadcast must be a value or tuple.") + if None in data: await self._broadcaster.stop_broadcast(self._adv) else: if not self._broadcaster.is_broadcasting(self._adv): await self._broadcaster.broadcast(self._adv) - self._adv.message = data + self._adv.message = cast(PybricksBroadcastData, tuple(data)) def observe( self, channel: int diff --git a/tests/test_messages.py b/tests/test_messages.py index 4940b1a..18f1633 100644 --- a/tests/test_messages.py +++ b/tests/test_messages.py @@ -5,6 +5,7 @@ class TestPybricksBleDecodeMessage: + # TODO: Check behaviour against reference implementation def test_decode_message_single_object(self): # channel 200 # single object marker @@ -13,9 +14,10 @@ def test_decode_message_single_object(self): channel, data = decode_message(message) assert channel == 200 - assert not isinstance(data, tuple) + assert isinstance(data, tuple) + assert len(data) == 1 - assert data == 5 + assert data[0] == 5 # TODO: Check behaviour against reference implementation def test_decode_message_single_object_tuple(self): diff --git a/tests/test_vhub.py b/tests/test_vhub.py index 8b786df..8062d6c 100644 --- a/tests/test_vhub.py +++ b/tests/test_vhub.py @@ -16,15 +16,37 @@ class TestVirtualBLE: async def test_create_vble(self, adapter): ble = await get_virtual_ble(broadcast_channel=1, observe_channels=[2]) assert ble is not None + assert not ble._broadcaster.is_broadcasting() async def test_observe(self): ble = await get_virtual_ble(broadcast_channel=1, observe_channels=[2]) data = ble.observe(2) assert data is None + assert not ble._broadcaster.is_broadcasting() async def test_broadcast(self): ble = await get_virtual_ble(broadcast_channel=1, observe_channels=[2]) await ble.broadcast(42) + assert ble._broadcaster.is_broadcasting() + assert ble._adv.message == (42,) + + async def test_broadcast_multiple(self): + ble = await get_virtual_ble(broadcast_channel=1, observe_channels=[2]) + await ble.broadcast(42, 24) + assert ble._broadcaster.is_broadcasting() + assert ble._adv.message == (42, 24) + + async def test_broadcast_none(self): + ble = await get_virtual_ble(broadcast_channel=1, observe_channels=[2]) + await ble.broadcast(None) + assert not ble._broadcaster.is_broadcasting() + + async def test_broadcast_start_stop(self): + ble = await get_virtual_ble(broadcast_channel=1, observe_channels=[2]) + await ble.broadcast(42) + assert ble._broadcaster.is_broadcasting() + await ble.broadcast(None) + assert not ble._broadcaster.is_broadcasting() async def test_context(self): async with await get_virtual_ble( From 49a77e3aefb2471ac6ec8e60d1181dcb31a0e2fd Mon Sep 17 00:00:00 2001 From: Frederik Leonhardt Date: Mon, 23 Jun 2025 23:46:16 +1200 Subject: [PATCH 04/10] Broadcaster: Fix stopping of broadcast if no broadcast was active --- src/pb_ble/bluezdbus/broadcaster.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/pb_ble/bluezdbus/broadcaster.py b/src/pb_ble/bluezdbus/broadcaster.py index 2457364..3a29a71 100644 --- a/src/pb_ble/bluezdbus/broadcaster.py +++ b/src/pb_ble/bluezdbus/broadcaster.py @@ -83,7 +83,8 @@ async def stop_broadcast(self, adv: str | BroadcastAdvertisement): pass finally: self.bus.unexport(path) - del self.advertisements[path] + if path in self.advertisements: + del self.advertisements[path] async def stop(self): """ @@ -114,7 +115,8 @@ async def broadcast(self, adv: BroadcastAdvertisement): def release_advertisement(path): try: self.bus.unexport(path) - del self.advertisements[path] + if path in self.advertisements: + del self.advertisements[path] finally: on_release(path) From 42b1670ec859d4b2fae740e915f325b2034ed32b Mon Sep 17 00:00:00 2001 From: Frederik Leonhardt Date: Fri, 13 Jun 2025 22:55:42 +1200 Subject: [PATCH 05/10] Update to pytest-asyncio 1.0.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 16969dd..70f2999 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ dev = [ "async-timer ~= 1.1.6", "mypy ~= 1.15.0", "pytest ~= 8.3", - "pytest-asyncio ~= 0.26.0", + "pytest-asyncio ~= 1.0.0", "python-dbusmock ~= 0.34.3", "ruff ~= 0.11.8", "types-cachetools ~= 5.3", From d9be137899ee51ecb11edcd4bfc16a66bfd9e6ca Mon Sep 17 00:00:00 2001 From: Frederik Leonhardt Date: Fri, 13 Jun 2025 22:55:56 +1200 Subject: [PATCH 06/10] Update to mypy 1.16 and add typecheck to CI --- .github/workflows/test.yml | 2 ++ pyproject.toml | 2 +- src/pb_ble/messages.py | 9 +++++++-- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 59e9f82..54f5a0d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,5 +18,7 @@ jobs: run: make .venv - name: Run lint run: make lint + - name: Run typecheck + run: make typecheck - name: Run tests run: make test diff --git a/pyproject.toml b/pyproject.toml index 70f2999..24c5959 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ debug = ["bluetooth-data-tools ~= 1.15"] dev = [ "async-timer ~= 1.1.6", - "mypy ~= 1.15.0", + "mypy ~= 1.16.0", "pytest ~= 8.3", "pytest-asyncio ~= 1.0.0", "python-dbusmock ~= 0.34.3", diff --git a/src/pb_ble/messages.py b/src/pb_ble/messages.py index af7b500..d2b9707 100644 --- a/src/pb_ble/messages.py +++ b/src/pb_ble/messages.py @@ -102,9 +102,14 @@ def unpack_pnp_id(data: bytes) -> Tuple[Literal["BT", "USB"], int, int, int]: :return: Tuple containing the vendor ID type (`BT` or `USB`), the vendor ID, the product ID and the product revision. """ + vid_type: int + vid: int + pid: int + rev: int + vid_type, vid, pid, rev = unpack(" Date: Wed, 18 Jun 2025 22:15:07 +1200 Subject: [PATCH 07/10] Fix typing issue in test fixture signature --- tests/fixtures/bluez5_mock.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/fixtures/bluez5_mock.py b/tests/fixtures/bluez5_mock.py index 230d141..afc7cd2 100644 --- a/tests/fixtures/bluez5_mock.py +++ b/tests/fixtures/bluez5_mock.py @@ -1,8 +1,8 @@ import sys import unittest.mock -import dbus import pytest +from dbus.proxies import ProxyObject from dbusmock import SpawnedMock from dbusmock.testcase import PrivateDBus @@ -19,7 +19,7 @@ def pytest_runtest_setup(item) -> None: @pytest.fixture -def bluez_mock(dbusmock_system: PrivateDBus) -> YieldFixture[dbus.proxies.ProxyObject]: +def bluez_mock(dbusmock_system: PrivateDBus) -> YieldFixture[ProxyObject]: template = "bluez5" parameters = { "advertise": True, @@ -34,9 +34,7 @@ def bluez_mock(dbusmock_system: PrivateDBus) -> YieldFixture[dbus.proxies.ProxyO @pytest.fixture(autouse=True) -def adapter_mock( - bluez_mock: dbus.proxies.ProxyObject, adapter_name: str -) -> YieldFixture[str]: +def adapter_mock(bluez_mock: ProxyObject, adapter_name: str) -> YieldFixture[str]: device_name = adapter_name # Mock out the DBus adapter From 85a57bb0af704ec71eb845bbf7a0ff19841315f5 Mon Sep 17 00:00:00 2001 From: Frederik Leonhardt Date: Mon, 23 Jun 2025 23:16:38 +1200 Subject: [PATCH 08/10] Message protocol: add debug logging --- src/pb_ble/messages.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/pb_ble/messages.py b/src/pb_ble/messages.py index d2b9707..56b7bbc 100644 --- a/src/pb_ble/messages.py +++ b/src/pb_ble/messages.py @@ -10,12 +10,15 @@ """ +import logging from enum import IntEnum from struct import pack, unpack, unpack_from from typing import Literal, Tuple, cast from .constants import PybricksBroadcast, PybricksBroadcastData, PybricksBroadcastValue +logger = logging.getLogger(__name__) + def decode_message( data: bytes, @@ -30,18 +33,23 @@ def decode_message( or a tuple. """ + logger.debug(f"decoding[{len(data)}]: {data!r}") + # idx 0 is the channel channel: int = unpack_from(" bytes: # idx 0 is the channel encoded_channel = pack(" {encoded_channel!r}") # idx 1 is the message start encoded_data = bytearray(encoded_channel) @@ -73,10 +82,14 @@ def encode_message(channel: int = 0, *values: PybricksBroadcastValue) -> bytes: if len(values) == 1: # set SINGLE_OBJECT marker header = PybricksBleBroadcastDataType.SINGLE_OBJECT << 5 + logger.debug(f"data[{len(encoded_data)}]: SINGLE_OBJECT marker") encoded_data.append(header) for val in values: header, encoded_val = _encode_value(val) + logger.debug( + f"data[{len(encoded_data)}] of {type(val)!s}: {val!r} -> ({header!r}, {encoded_val!r})" + ) encoded_data.append(header) if encoded_val is not None: @@ -88,7 +101,9 @@ def encode_message(channel: int = 0, *values: PybricksBroadcastValue) -> bytes: f"Payload too large: {len(encoded_data)} bytes (maximum is {OBSERVED_DATA_MAX_SIZE} bytes)" ) - return bytes(encoded_data) + message = bytes(encoded_data) + logger.debug(f"encoded[{len(message)}]: {message!r}") + return message def unpack_pnp_id(data: bytes) -> Tuple[Literal["BT", "USB"], int, int, int]: From 6c47aabefe3c547ca5b0cb8659bd80d8002d2f2f Mon Sep 17 00:00:00 2001 From: Frederik Leonhardt Date: Mon, 23 Jun 2025 23:17:29 +1200 Subject: [PATCH 09/10] Update test cases after verifying behaviour of Pybricks 3.6.1 --- tests/test_messages.py | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/tests/test_messages.py b/tests/test_messages.py index 18f1633..6b4f188 100644 --- a/tests/test_messages.py +++ b/tests/test_messages.py @@ -5,7 +5,8 @@ class TestPybricksBleDecodeMessage: - # TODO: Check behaviour against reference implementation + # h.ble.broadcast(5) + @pytest.mark.skip("Auto-unboxing of single-object values not supported") def test_decode_message_single_object(self): # channel 200 # single object marker @@ -14,12 +15,11 @@ def test_decode_message_single_object(self): channel, data = decode_message(message) assert channel == 200 - assert isinstance(data, tuple) - assert len(data) == 1 + assert isinstance(data, int), type(data) - assert data[0] == 5 + assert data == 5 - # TODO: Check behaviour against reference implementation + # h.ble.broadcast((5,)) def test_decode_message_single_object_tuple(self): # channel 200 # int8: 5 @@ -27,10 +27,23 @@ def test_decode_message_single_object_tuple(self): channel, data = decode_message(message) assert channel == 200 + assert isinstance(data, tuple), type(data) + assert len(data) == 1 + + assert data == (5,) + + # h.ble.broadcast((5,)) + def test_decode_message_single_object_tuple_0(self): + # channel 0 + # int8: 5 + message = b"\x00a\x05" + channel, data = decode_message(message) + + assert channel == 0 assert isinstance(data, tuple) assert len(data) == 1 - assert data[0] == 5 + assert data == (5,) def test_decode_message_int8_int16_int32(self): # channel: 200 @@ -129,9 +142,9 @@ def test_encode_message_single_object(self): data = encode_message(200, 5) assert data == b"\xc8\x00\x61\x05" - @pytest.mark.skip("Check behaviour against reference implementation") + @pytest.mark.skip("Encoding single-object tuples is not supported") def test_encode_message_single_object_tuple(self): - data = encode_message(200, (1)) + data = encode_message(200, (1,)) # type: ignore[arg-type] assert data == b"\xc8\x61\x01" def test_encode_message_int8_int16_int32(self): From a071499f9b57defde5e2e73765936b744844c973 Mon Sep 17 00:00:00 2001 From: Frederik Leonhardt Date: Mon, 23 Jun 2025 23:41:10 +1200 Subject: [PATCH 10/10] Message protocol: Unpack single values When encountering a message with the single object marker, return the value as-is without wrapping it into a tuple container. This matches the Pybricks behaviour, and partically reverts changes made in a38028cf1f4a6aa1a31614f9a0e343c5b4e86b19. However, the virtual hub implementation still wraps single values into a single object tuple when returning from observer(). This is due to the type signature on the method of the BLE Hub API, which does require a tuple to be returned. This is likely a bug/inaccuracy of the API types, as the Pybricks firmware itself does not adhere to the type annotation and returns either a single value, or a tuple. --- src/pb_ble/constants.py | 4 +++- src/pb_ble/messages.py | 2 +- src/pb_ble/vhub.py | 13 ++++++++++++- tests/test_messages.py | 1 - tests/test_vhub.py | 6 +++--- 5 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/pb_ble/constants.py b/src/pb_ble/constants.py index 4eb248a..81d5bdd 100644 --- a/src/pb_ble/constants.py +++ b/src/pb_ble/constants.py @@ -17,7 +17,9 @@ PybricksBroadcastValue: TypeAlias = bool | int | float | str | bytes """Type of a value that can be broadcast.""" -PybricksBroadcastData: TypeAlias = tuple[PybricksBroadcastValue] +PybricksBroadcastData: TypeAlias = ( + PybricksBroadcastValue | tuple[PybricksBroadcastValue, ...] +) """Type of the broadcast data.""" diff --git a/src/pb_ble/messages.py b/src/pb_ble/messages.py index 56b7bbc..c9d9cc8 100644 --- a/src/pb_ble/messages.py +++ b/src/pb_ble/messages.py @@ -54,7 +54,7 @@ def decode_message( if single_object: decoded_value: PybricksBroadcastValue = decoded_data[0] - return PybricksBroadcast(channel, (decoded_value,)) + return PybricksBroadcast(channel, decoded_value) else: return PybricksBroadcast( channel, cast(PybricksBroadcastData, tuple(decoded_data)) diff --git a/src/pb_ble/vhub.py b/src/pb_ble/vhub.py index 80a91ac..a82ae90 100644 --- a/src/pb_ble/vhub.py +++ b/src/pb_ble/vhub.py @@ -77,7 +77,18 @@ def observe( self, channel: int ) -> Optional[Tuple[Union[bool, int, float, str, bytes], ...]]: advertisement = self._observer.observe(channel) - return advertisement.data if advertisement is not None else None + + if advertisement is not None: + if isinstance(advertisement.data, tuple): + return advertisement.data + else: + # TODO: Pybricks does expose single-value broadcasts + # in a single-object tuple. However, that doesn't match + # the type signature of the observe() method. To adhere + # to the type signature, we only return wrapped values. + return (advertisement.data,) + else: + return None def signal_strength(self, channel: int) -> int: advertisement = self._observer.observe(channel) diff --git a/tests/test_messages.py b/tests/test_messages.py index 6b4f188..2e29f26 100644 --- a/tests/test_messages.py +++ b/tests/test_messages.py @@ -6,7 +6,6 @@ class TestPybricksBleDecodeMessage: # h.ble.broadcast(5) - @pytest.mark.skip("Auto-unboxing of single-object values not supported") def test_decode_message_single_object(self): # channel 200 # single object marker diff --git a/tests/test_vhub.py b/tests/test_vhub.py index 8062d6c..cb29364 100644 --- a/tests/test_vhub.py +++ b/tests/test_vhub.py @@ -18,17 +18,17 @@ async def test_create_vble(self, adapter): assert ble is not None assert not ble._broadcaster.is_broadcasting() - async def test_observe(self): + async def test_observe_none(self): ble = await get_virtual_ble(broadcast_channel=1, observe_channels=[2]) data = ble.observe(2) assert data is None assert not ble._broadcaster.is_broadcasting() - async def test_broadcast(self): + async def test_broadcast_single(self): ble = await get_virtual_ble(broadcast_channel=1, observe_channels=[2]) await ble.broadcast(42) assert ble._broadcaster.is_broadcasting() - assert ble._adv.message == (42,) + assert ble._adv.message == 42 async def test_broadcast_multiple(self): ble = await get_virtual_ble(broadcast_channel=1, observe_channels=[2])