From 25a4f65095bf1c5fc6cbcb81524fe101cf2098ae Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 11 Jan 2026 22:14:53 +0000 Subject: [PATCH 01/19] Hide export and unexport methods --- bluez_peripheral/advert.py | 245 ++++++++++++++---------- bluez_peripheral/agent.py | 17 +- bluez_peripheral/base.py | 121 +++++++++--- bluez_peripheral/gatt/base.py | 73 ------- bluez_peripheral/gatt/characteristic.py | 3 +- bluez_peripheral/gatt/descriptor.py | 3 +- bluez_peripheral/gatt/service.py | 32 +--- docs/source/spelling_wordlist.txt | 3 +- pyproject.toml | 1 + tests/.pylintrc | 1 + tests/unit/test_advert.py | 66 ++----- tests/unit/util.py | 12 +- 12 files changed, 285 insertions(+), 292 deletions(-) diff --git a/bluez_peripheral/advert.py b/bluez_peripheral/advert.py index 5f1a510..0791370 100644 --- a/bluez_peripheral/advert.py +++ b/bluez_peripheral/advert.py @@ -1,5 +1,4 @@ -import inspect -from typing import Collection, Dict, Callable, Optional, Union, Awaitable +from typing import Collection, Dict, Optional, Union, Any import struct from dbus_fast import Variant @@ -12,11 +11,11 @@ from .adapter import Adapter from .flags import AdvertisingIncludes from .flags import AdvertisingPacketType -from .base import BaseServiceInterface +from .base import BaseServiceInterface, UniquePathMixin from .error import bluez_error_wrapper -class Advertisement(BaseServiceInterface): +class Advertisement(UniquePathMixin): """ An advertisement for a particular service or collection of services that can be registered and broadcast to nearby devices. Represents an `org.bluez.LEAdvertisement1 `_ instance. @@ -38,30 +37,132 @@ class Advertisement(BaseServiceInterface): release_callback: A function to call when the advert release function is called. The default release callback will unexport the advert. """ - _INTERFACE = "org.bluez.LEAdvertisement1" _DEFAULT_PATH_PREFIX = "/com/spacecheese/bluez_peripheral/advert" + def _advert_service_factory(self) -> "BaseServiceInterface": + advert = self + + class _AdvertService(BaseServiceInterface): + _INTERFACE = "org.bluez.LEAdvertisement1" + + def _get_default_path(self, **_: Any) -> str: + raise NotImplementedError() + + @method("Release") + async def _release(self): # type: ignore + await advert._release() + + @dbus_property(PropertyAccess.READ, "Type") + def _get_type(self) -> "s": # type: ignore + return advert._type.name.lower() + + @dbus_property( + PropertyAccess.READ, + "ServiceUUIDs", + disabled=(advert._service_uuids is None), + ) + def _get_service_uuids(self) -> "as": # type: ignore + if advert._service_uuids is None: + raise NotImplementedError() + + return [str(id) for id in advert._service_uuids] + + @dbus_property( + PropertyAccess.READ, "LocalName", disabled=(advert._local_name is None) + ) + def _get_local_name(self) -> "s": # type: ignore + return advert._local_name + + @dbus_property( + PropertyAccess.READ, "Appearance", disabled=(advert._appearance is None) + ) + def _get_appearance(self) -> "q": # type: ignore + return advert._appearance + + @dbus_property( + PropertyAccess.READ, "Timeout", disabled=(advert._timeout is None) + ) + def _get_timeout(self) -> "q": # type: ignore + return advert._timeout + + @dbus_property( + PropertyAccess.READ, + "ManufacturerData", + disabled=(advert._manufacturer_data is None), + ) + def _get_manufacturer_data(self) -> "a{qv}": # type: ignore + return advert._manufacturer_data + + @dbus_property( + PropertyAccess.READ, + "SolicitUUIDs", + disabled=(advert._solicit_uuids is None), + ) + def _get_solicit_uuids(self) -> "as": # type: ignore + if advert._solicit_uuids is None: + raise NotImplementedError() + + return [str(key) for key in advert._solicit_uuids] + + @dbus_property( + PropertyAccess.READ, + "ServiceData", + disabled=(advert._service_data is None), + ) + def _get_service_data(self) -> "a{sv}": # type: ignore + if advert._service_data is None: + raise NotImplementedError() + + return {str(key): val for key, val in advert._service_data.items()} + + @dbus_property( + PropertyAccess.READ, + "Discoverable", + disabled=(advert._discoverable is None), + ) + def _get_discoverable(self) -> "b": # type: ignore + return advert._discoverable + + @dbus_property( + PropertyAccess.READ, + "Includes", + disabled=(advert._includes == AdvertisingIncludes.NONE), + ) + def _get_includes(self) -> "as": # type: ignore + return [ + _snake_to_kebab(inc.name) + for inc in AdvertisingIncludes + if advert._includes & inc and inc.name is not None + ] + + @dbus_property( + PropertyAccess.READ, "Duration", disabled=(advert._duration is None) + ) + def _get_duration(self) -> "q": # type: ignore + return advert._duration + + return _AdvertService() + def __init__( self, - local_name: str, - service_uuids: Collection[UUIDLike], + local_name: Optional[str] = None, + service_uuids: Optional[Collection[UUIDLike]] = None, *, - appearance: Union[int, bytes], - timeout: int = 0, - discoverable: bool = True, + appearance: Optional[Union[int, bytes]] = None, + timeout: Optional[int] = None, + discoverable: Optional[bool] = None, packet_type: AdvertisingPacketType = AdvertisingPacketType.PERIPHERAL, manufacturer_data: Optional[Dict[int, bytes]] = None, solicit_uuids: Optional[Collection[UUIDLike]] = None, service_data: Optional[Dict[UUIDLike, bytes]] = None, includes: AdvertisingIncludes = AdvertisingIncludes.NONE, - duration: int = 2, - release_callback: Optional[ - Union[Callable[[], None], Callable[[], Awaitable[None]]] - ] = None, + duration: Optional[int] = 2, ): self._type = packet_type # Convert any string uuids to uuid16. - self._service_uuids = [UUID16.parse_uuid(uuid) for uuid in service_uuids] + self._service_uuids = None + if service_uuids is not None: + self._service_uuids = [UUID16.parse_uuid(uuid) for uuid in service_uuids] self._local_name = local_name # Convert the appearance to a uint16 if it isn't already an int. if isinstance(appearance, bytes): @@ -70,38 +171,32 @@ def __init__( self._appearance = appearance self._timeout = timeout - if manufacturer_data is None: - manufacturer_data = {} - self._manufacturer_data = { - k: Variant("ay", v) for k, v in manufacturer_data.items() - } + self._manufacturer_data = None + if manufacturer_data is not None: + self._manufacturer_data = { + k: Variant("ay", v) for k, v in manufacturer_data.items() + } - if solicit_uuids is None: - solicit_uuids = [] - self._solicit_uuids = [UUID16.parse_uuid(uuid) for uuid in solicit_uuids] + self._solicit_uuids = None + if solicit_uuids is not None: + self._solicit_uuids = [UUID16.parse_uuid(uuid) for uuid in solicit_uuids] - if service_data is None: - service_data = {} - self._service_data = { - UUID16.parse_uuid(k): Variant("ay", v) for k, v in service_data.items() - } + self._service_data = None + if service_data is not None: + self._service_data = { + UUID16.parse_uuid(k): Variant("ay", v) for k, v in service_data.items() + } self._discoverable = discoverable self._includes = includes self._duration = duration - def _default_release_callback() -> None: - self.unexport() - - self._release_callback: Union[Callable[[], None], Callable[[], Awaitable[None]]] - if release_callback is None: - self._release_callback = _default_release_callback - else: - self._release_callback = release_callback - self._adapter: Optional[Adapter] = None + self._service = self._advert_service_factory() - super().__init__() + async def _release(self) -> None: + self._adapter = None + self._service._unexport() async def register( self, @@ -117,8 +212,9 @@ async def register( adapter: The adapter to use. path: The dbus path to use for registration. """ - - self.export(bus, path=path) + if path is None: + path = self._get_default_path() + self._service._export(bus, path=path) if adapter is None: adapter = await Adapter.get_first(bus) @@ -126,76 +222,27 @@ async def register( # Get the LEAdvertisingManager1 interface for the target adapter. interface = adapter.get_advertising_manager() async with bluez_error_wrapper(): - await interface.call_register_advertisement(self.export_path, {}) # type: ignore - + await interface.call_register_advertisement(self._service.export_path, {}) # type: ignore self._adapter = adapter - @method("Release") - async def _release(self): # type: ignore - if inspect.iscoroutinefunction(self._release_callback): - await self._release_callback() - else: - self._release_callback() - async def unregister(self) -> None: """ Unregister this advertisement from bluez to stop advertising. """ - if not self._adapter or not self.is_exported: + if self._adapter is None: raise ValueError("This advertisement is not registered") interface = self._adapter.get_advertising_manager() async with bluez_error_wrapper(): - await interface.call_unregister_advertisement(self.export_path) # type: ignore + await interface.call_unregister_advertisement(self._service.export_path) # type: ignore self._adapter = None - self.unexport() - - @dbus_property(PropertyAccess.READ, "Type") - def _get_type(self) -> "s": # type: ignore - return self._type.name.lower() - - @dbus_property(PropertyAccess.READ, "ServiceUUIDs") - def _get_service_uuids(self) -> "as": # type: ignore - return [str(id) for id in self._service_uuids] + self._service._unexport() - @dbus_property(PropertyAccess.READ, "LocalName") - def _get_local_name(self) -> "s": # type: ignore - return self._local_name - - @dbus_property(PropertyAccess.READ, "Appearance") - def _get_appearance(self) -> "q": # type: ignore - return self._appearance - - @dbus_property(PropertyAccess.READ, "Timeout") - def _get_timeout(self) -> "q": # type: ignore - return self._timeout - - @dbus_property(PropertyAccess.READ, "ManufacturerData") - def _get_manufacturer_data(self) -> "a{qv}": # type: ignore - return self._manufacturer_data - - @dbus_property(PropertyAccess.READ, "SolicitUUIDs") - def _get_solicit_uuids(self) -> "as": # type: ignore - return [str(key) for key in self._solicit_uuids] - - @dbus_property(PropertyAccess.READ, "ServiceData") - def _get_service_data(self) -> "a{sv}": # type: ignore - return {str(key): val for key, val in self._service_data.items()} - - @dbus_property(PropertyAccess.READ, "Discoverable") - def _get_discoverable(self) -> "b": # type: ignore - return self._discoverable - - @dbus_property(PropertyAccess.READ, "Includes") - def _get_includes(self) -> "as": # type: ignore - return [ - _snake_to_kebab(inc.name) - for inc in AdvertisingIncludes - if self._includes & inc and inc.name is not None - ] - - @dbus_property(PropertyAccess.READ, "Duration") - def _get_duration(self) -> "q": # type: ignore - return self._duration + @property + def export_path(self) -> Optional[str]: + """ + Returns the message bus path that the advert is exported on or None. + """ + return self._service.export_path diff --git a/bluez_peripheral/agent.py b/bluez_peripheral/agent.py index fa2c9fc..817d685 100644 --- a/bluez_peripheral/agent.py +++ b/bluez_peripheral/agent.py @@ -7,7 +7,7 @@ from .util import _snake_to_pascal from .error import RejectedError, bluez_error_wrapper -from .base import BaseServiceInterface +from .base import BaseServiceInterface, UniquePathMixin class AgentCapability(Enum): @@ -32,7 +32,7 @@ class AgentCapability(Enum): """ -class BaseAgent(BaseServiceInterface): +class BaseAgent(UniquePathMixin, BaseServiceInterface): """The abstract base agent for all bluez agents. Subclass this if one of the existing agents does not meet your requirements. Alternatively bluez supports several built in agents which can be selected using the bluetoothctl cli. Represents an `org.bluez.Agent1 `_ instance. @@ -50,12 +50,12 @@ def __init__( capability: AgentCapability, ): self._capability: AgentCapability = capability - + self._registered = False super().__init__() @method("Release") def _release(self): # type: ignore - pass + self._unexport() @method("Cancel") def _cancel(self): # type: ignore @@ -81,7 +81,7 @@ async def register( The invoking process requires superuser if this is true. path: The path to expose this message bus on. """ - self.export(bus, path=path) + self._export(bus, path=path) interface = await self._get_manager_interface(bus) async with bluez_error_wrapper(): @@ -91,13 +91,15 @@ async def register( async with bluez_error_wrapper(): await interface.call_request_default_agent(self.export_path) # type: ignore + self._registered = True + async def unregister(self) -> None: """Unregister this agent with bluez and remove it from the specified message bus. Args: bus: The message bus used to expose the agent. """ - if not self.is_exported: + if not self._registered: raise ValueError("agent has not been registered") assert self._export_bus is not None @@ -105,7 +107,8 @@ async def unregister(self) -> None: async with bluez_error_wrapper(): await interface.call_unregister_agent(self.export_path) # type: ignore - self.unexport() + self._unexport() + self._registered = False class TestAgent(BaseAgent): diff --git a/bluez_peripheral/base.py b/bluez_peripheral/base.py index 7986de8..7f367b1 100644 --- a/bluez_peripheral/base.py +++ b/bluez_peripheral/base.py @@ -1,17 +1,12 @@ -from typing import Optional +from typing import Optional, Any from dbus_fast.aio.message_bus import MessageBus from dbus_fast.service import ServiceInterface -class BaseServiceInterface(ServiceInterface): - """ - Base class for bluez_peripheral ServiceInterface implementations. - """ - - _INTERFACE = "" +class UniquePathMixin: """ - The dbus interface name implemented by this component. + Mixin which calculates the default path for exported components with a _get_default_path. """ _DEFAULT_PATH_PREFIX: Optional[str] = None @@ -21,13 +16,7 @@ class BaseServiceInterface(ServiceInterface): _default_path_count: int = 0 - _export_bus: Optional[MessageBus] = None - _export_path: Optional[str] = None - - def __init__(self) -> None: - super().__init__(name=self._INTERFACE) - - def _get_unique_export_path(self) -> str: + def _get_default_path(self, **_: Any) -> str: if self._DEFAULT_PATH_PREFIX is None: raise NotImplementedError() @@ -36,7 +25,27 @@ def _get_unique_export_path(self) -> str: return res - def export(self, bus: MessageBus, *, path: Optional[str] = None) -> None: + +class BaseServiceInterface(ServiceInterface): + """ + Base class for bluez_peripheral ServiceInterface implementations. + """ + + _INTERFACE = "" + """ + The dbus interface name implemented by this component. + """ + + _export_bus: Optional[MessageBus] = None + _export_path: Optional[str] = None + + def __init__(self) -> None: + super().__init__(name=self._INTERFACE) + + def _get_default_path(self, **_: Any) -> str: + raise NotImplementedError() + + def _export(self, bus: MessageBus, *, path: Optional[str] = None) -> None: """ Export this service interface. If no path is provided a unique value is generated based on _DEFAULT_PATH_PREFIX and a type scoped export counter. @@ -45,13 +54,13 @@ def export(self, bus: MessageBus, *, path: Optional[str] = None) -> None: raise NotImplementedError() if path is None: - path = self._get_unique_export_path() + path = self._get_default_path() bus.export(path, self) self._export_path = path self._export_bus = bus - def unexport(self) -> None: + def _unexport(self) -> None: """ Unexport this service interface. """ @@ -68,13 +77,79 @@ def unexport(self) -> None: @property def export_path(self) -> Optional[str]: """ - The dbus path on which this interface is currently exported. + Returns the message bus path that the ServiceInterface is exported on or None. """ return self._export_path - @property - def is_exported(self) -> bool: + +class HierarchicalServiceInterface(BaseServiceInterface): + """ + Base class for a member of a hierarchy of ServiceInterfaces which should be exported and unexported as a group. + """ + + _BUS_PREFIX = "" + """ + The prefix used by default when exporting this ServiceInterface as a child of another component. + """ + + def __init__(self) -> None: + super().__init__() + + self._parent: Optional["HierarchicalServiceInterface"] = None + self._children: list["HierarchicalServiceInterface"] = [] + + def _get_default_path(self, **kwargs: Any) -> str: + num = kwargs.get("num") + + if self._parent is not None and num is not None: + return f"{self._parent.export_path}/{self._BUS_PREFIX}{num}" + + raise ValueError("path or parent must be specified") + + def add_child(self, child: "HierarchicalServiceInterface") -> None: """ - Whether this service interface is exported and visible to dbus clients. + Adds a child service interface. """ - return self._export_bus is not None and self._export_path is not None + if not self.export_path is None: + raise ValueError("Registered components cannot be modified") + + self._children.append(child) + child._parent = self # pylint: disable=protected-access + + def remove_child(self, child: "HierarchicalServiceInterface") -> None: + """ + Removes a child service interface. + """ + if not self.export_path is None: + raise ValueError("Registered components cannot be modified") + + self._children.remove(child) + child._parent = None # pylint: disable=protected-access + + def _export( + self, bus: MessageBus, *, num: Optional[int] = 0, path: Optional[str] = None + ) -> None: + """ + Attempts to export this component and all registered children. Either ``num`` or ``path`` must be provided. + + Args: + bus: The message bus to export this and all children on. + num: An optional index of this component within it's parent. + path: An optional absolute path indicating where this component should be exported. + If no ``path`` is specified then this component must have been registered using another components :class:`HierarchicalServiceInterface.add_child()` method. + """ + if path is None: + path = self._get_default_path(num=num) + + super()._export(bus, path=path) + for i, c in enumerate(self._children): + c._export(bus, num=i) + + def _unexport(self) -> None: + """ + Attempts to unexport this component and all registered children from the specified message bus. + """ + for c in self._children: + c._unexport() + + super()._unexport() diff --git a/bluez_peripheral/gatt/base.py b/bluez_peripheral/gatt/base.py index 31a4027..10bdbfc 100644 --- a/bluez_peripheral/gatt/base.py +++ b/bluez_peripheral/gatt/base.py @@ -16,86 +16,13 @@ from dbus_fast import Variant, DBusError from dbus_fast.constants import PropertyAccess from dbus_fast.service import method, dbus_property -from dbus_fast.aio.message_bus import MessageBus from ..error import FailedError, NotSupportedError -from ..base import BaseServiceInterface if TYPE_CHECKING: from .service import Service -class HierarchicalServiceInterface(BaseServiceInterface): - """ - Base class for a member of a hierarchy of ServiceInterfaces which should be exported and unexported as a group. - """ - - _BUS_PREFIX = "" - """ - The prefix used by default when exporting this ServiceInterface as a child of another component. - """ - - def __init__(self) -> None: - super().__init__() - - self._parent: Optional["HierarchicalServiceInterface"] = None - self._children: list["HierarchicalServiceInterface"] = [] - - def add_child(self, child: "HierarchicalServiceInterface") -> None: - """ - Adds a child service interface. - """ - if self.is_exported: - raise ValueError("Registered components cannot be modified") - - self._children.append(child) - child._parent = self # pylint: disable=protected-access - - def remove_child(self, child: "HierarchicalServiceInterface") -> None: - """ - Removes a child service interface. - """ - if self.is_exported: - raise ValueError("Registered components cannot be modified") - - self._children.remove(child) - child._parent = None # pylint: disable=protected-access - - def export( - self, bus: MessageBus, *, num: Optional[int] = 0, path: Optional[str] = None - ) -> None: - """ - Attempts to export this component and all registered children. Either ``num`` or ``path`` must be provided. - - Args: - bus: The message bus to export this and all children on. - num: An optional index of this component within it's parent. - path: An optional absolute path indicating where this component should be exported. - If no ``path`` is specified then this component must have been registered using another components :class:`HierarchicalServiceInterface.add_child()` method. - """ - if path is None: - if self._parent is not None: - path = f"{self._parent.export_path}/{self._BUS_PREFIX}{num}" - else: - raise ValueError("path or parent must be specified") - - super().export(bus, path=path) - for i, c in enumerate(self._children): - c.export(bus, num=i) - - def unexport(self) -> None: - """ - Attempts to unexport this component and all registered children from the specified message bus. - """ - if not self.is_exported: - raise ValueError("Cannot unexport a component which is not exported") - - for c in self._children: - c.unexport() - - super().unexport() - - ReadOptionsT = TypeVar("ReadOptionsT") """ The type of options supplied by a dbus ReadValue access. diff --git a/bluez_peripheral/gatt/characteristic.py b/bluez_peripheral/gatt/characteristic.py index 2466f54..9ce81df 100644 --- a/bluez_peripheral/gatt/characteristic.py +++ b/bluez_peripheral/gatt/characteristic.py @@ -5,7 +5,8 @@ from dbus_fast.constants import PropertyAccess from dbus_fast.service import method, dbus_property -from .base import HierarchicalServiceInterface, ServiceAttribute +from ..base import HierarchicalServiceInterface +from .base import ServiceAttribute from ..uuid16 import UUIDLike, UUID16 from ..util import _snake_to_kebab, _getattr_variant from ..error import NotSupportedError diff --git a/bluez_peripheral/gatt/descriptor.py b/bluez_peripheral/gatt/descriptor.py index 6d8e625..4a35f0e 100644 --- a/bluez_peripheral/gatt/descriptor.py +++ b/bluez_peripheral/gatt/descriptor.py @@ -5,7 +5,8 @@ from dbus_fast.service import dbus_property from dbus_fast.constants import PropertyAccess -from .base import HierarchicalServiceInterface, ServiceAttribute +from ..base import HierarchicalServiceInterface +from .base import ServiceAttribute from ..uuid16 import UUID16, UUIDLike from ..util import _snake_to_kebab, _getattr_variant diff --git a/bluez_peripheral/gatt/service.py b/bluez_peripheral/gatt/service.py index de4393e..cf531d8 100644 --- a/bluez_peripheral/gatt/service.py +++ b/bluez_peripheral/gatt/service.py @@ -5,7 +5,7 @@ from dbus_fast.service import dbus_property from dbus_fast.aio.message_bus import MessageBus -from .base import HierarchicalServiceInterface +from ..base import HierarchicalServiceInterface, UniquePathMixin from .characteristic import characteristic from ..uuid16 import UUID16, UUIDLike from ..adapter import Adapter @@ -118,7 +118,7 @@ def _get_includes(self) -> "ao": # type: ignore return paths -class ServiceCollection(HierarchicalServiceInterface): +class ServiceCollection(UniquePathMixin, HierarchicalServiceInterface): """A collection of services that are registered with the bluez GATT manager as a group.""" _INTERFACE = "org.spacecheese.ServiceCollection1" @@ -135,7 +135,6 @@ def __init__(self, services: Optional[List[Service]] = None): for s in services: self.add_child(s) - self._bus: Optional[MessageBus] = None self._adapter: Optional[Adapter] = None async def register( @@ -155,39 +154,20 @@ async def register( adapter: The adapter that should be used to deliver the collection of services. """ self._adapter = await Adapter.get_first(bus) if adapter is None else adapter - - self.export(bus, path=path) + self._export(bus, path=path) manager = self._adapter.get_gatt_manager() async with bluez_error_wrapper(): await manager.call_register_application(self.export_path, {}) # type: ignore - self._bus = bus - async def unregister(self) -> None: """Unregister this service using the bluez service manager.""" - if not self.is_exported: - raise ValueError("Cannot unexport a component which is not exported") - - assert self._bus is not None - assert self._adapter is not None + if self._adapter is None: + raise ValueError("Cannot unregister a component which is not registered") manager = self._adapter.get_gatt_manager() async with bluez_error_wrapper(): await manager.call_unregister_application(self.export_path) # type: ignore - self.unexport() - + self._unexport() self._adapter = None - self._bus = None - - def export( - self, bus: MessageBus, *, num: Optional[int] = None, path: Optional[str] = None - ) -> None: - """ - Export this ServiceCollection on the specified message bus. - """ - if path is None: - path = self._get_unique_export_path() - - super().export(bus, num=num, path=path) diff --git a/docs/source/spelling_wordlist.txt b/docs/source/spelling_wordlist.txt index 85f87d6..932ab09 100644 --- a/docs/source/spelling_wordlist.txt +++ b/docs/source/spelling_wordlist.txt @@ -32,4 +32,5 @@ unexport unexported LEAdvertisingManager GattManager -unpairs \ No newline at end of file +unpairs +Mixin \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 2fb5a4e..c497435 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ disable = [ "too-few-public-methods", "too-many-public-methods", "duplicate-code", + "protected-access", ] [tool.mypy] diff --git a/tests/.pylintrc b/tests/.pylintrc index cd299d0..5ee1583 100644 --- a/tests/.pylintrc +++ b/tests/.pylintrc @@ -4,6 +4,7 @@ disable= missing-class-docstring, missing-function-docstring, too-many-arguments, + too-many-function-args, too-many-positional-arguments, too-few-public-methods, redefined-outer-name, diff --git a/tests/unit/test_advert.py b/tests/unit/test_advert.py index 8256f3a..ad8dd68 100644 --- a/tests/unit/test_advert.py +++ b/tests/unit/test_advert.py @@ -1,5 +1,4 @@ from uuid import UUID -import asyncio import pytest from dbus_fast import Variant @@ -61,7 +60,9 @@ async def test_includes_empty(bus_name, bus_path, message_bus, background_advert introspection = await message_bus.introspect(bus_name, bus_path) proxy_object = message_bus.get_proxy_object(bus_name, bus_path, introspection) interface = proxy_object.get_interface("org.bluez.LEAdvertisement1") - assert await interface.get_includes() == [] + + with pytest.raises(AttributeError): + await interface.get_includes() @pytest.mark.asyncio @@ -95,70 +96,27 @@ async def test_illegal_unregister(): @pytest.mark.asyncio -async def test_default_release(message_bus, bus_name, bus_path, background_advert): +async def test_release(message_bus, bus_name, bus_path, background_advert): advert = Advertisement( "Attribs Test", ["180A", "180D"], appearance=0x0340, timeout=2 ) - background_advert(advert, path=bus_path) - - introspection = await message_bus.introspect(bus_name, bus_path) - proxy_object = message_bus.get_proxy_object(bus_name, bus_path, introspection) - interface = proxy_object.get_interface("org.bluez.LEAdvertisement1") - await interface.call_release() - - assert not advert.is_exported - - -@pytest.mark.asyncio -async def test_custom_sync_release(message_bus, bus_name, bus_path, background_advert): - foreground_loop = asyncio.get_running_loop() - released = foreground_loop.create_future() - - def _release_callback(): - foreground_loop.call_soon_threadsafe(released.set_result, ()) - - advert = Advertisement( - "Attribs Test", - ["180A", "180D"], - appearance=0x0340, - timeout=2, - release_callback=_release_callback, + background_manager = background_advert(advert, path=bus_path) + mock_advertising_manager = ( + background_manager.adapter.get_advertising_manager.return_value ) - background_advert(advert, path=bus_path) + + mock_advertising_manager.reset_mock() introspection = await message_bus.introspect(bus_name, bus_path) proxy_object = message_bus.get_proxy_object(bus_name, bus_path, introspection) interface = proxy_object.get_interface("org.bluez.LEAdvertisement1") await interface.call_release() - await asyncio.wait_for(released, timeout=0.1) - - assert advert.is_exported - -@pytest.mark.asyncio -async def test_custom_async_release(message_bus, bus_name, bus_path, background_advert): - foreground_loop = asyncio.get_running_loop() - released = foreground_loop.create_future() - - async def _release_callback(): - foreground_loop.call_soon_threadsafe(released.set_result, ()) - - advert = Advertisement( - "Attribs Test", - ["180A", "180D"], - appearance=0x0340, - timeout=2, - release_callback=_release_callback, - ) - background_advert(advert, path=bus_path) + assert mock_advertising_manager.call_register_advertisement.await_count == 0 + assert mock_advertising_manager.call_unregister_advertisement.await_count == 0 introspection = await message_bus.introspect(bus_name, bus_path) - proxy_object = message_bus.get_proxy_object(bus_name, bus_path, introspection) - interface = proxy_object.get_interface("org.bluez.LEAdvertisement1") - await interface.call_release() - await asyncio.wait_for(released, timeout=0.1) - - assert advert.is_exported + assert "org.bluez.LEAdvertisement1" not in introspection.interfaces @pytest.mark.asyncio diff --git a/tests/unit/util.py b/tests/unit/util.py index 9042de1..145aecd 100644 --- a/tests/unit/util.py +++ b/tests/unit/util.py @@ -256,9 +256,7 @@ def register(self, services: ServiceCollection, **kwargs): if path is None: path = services.export_path gatt_manager.call_register_application.assert_awaited_with(path, {}) - if services.is_exported: - # If the export didn't fail. - assert services.export_path == path + assert services.export_path == path assert len(gatt_manager.mock_calls) == before_calls + 1 assert gatt_manager.call_register_application.await_count == before_awaits + 1 @@ -281,7 +279,7 @@ def unregister(self): self._services = None async def cleanup(self): - if self._services is not None and self._services.is_exported: + if self._services is not None and self._services.export_path is not None: self.unregister() @@ -308,8 +306,8 @@ def register(self, advert: Advertisement, **kwargs): if path is None: path = advert.export_path advertising_manager.call_register_advertisement.assert_awaited_with(path, {}) - if advert.is_exported: - assert advert.export_path == path + + assert advert.export_path == path assert len(advertising_manager.mock_calls) == before_calls + 1 assert ( advertising_manager.call_register_advertisement.await_count @@ -342,5 +340,5 @@ def unregister(self): self._advert = None async def cleanup(self): - if self._advert is not None and self._advert.is_exported: + if self._advert is not None and self._advert.export_path is not None: self.unregister() From 0ea1ce9e0a65bf99f3eda55f450b9ad5f1a0ddbd Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 11 Jan 2026 22:15:07 +0000 Subject: [PATCH 02/19] Reword contributing notes --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 63195cd..d56dc1f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,4 +24,4 @@ You can then run all pre-commit checks manually: pre-commit run --hook-stage manual --all-files ``` -For instructions on running tests locally please consult the respective markdown files- this process more more complex since bluez, dbus and the hci_vhci kernel module are required. \ No newline at end of file +For instructions on running tests locally please consult the respective markdown files- these require some specific environment setup. \ No newline at end of file From 684074da52567c5b08f65975642387a6d530149b Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Mon, 12 Jan 2026 19:40:37 +0000 Subject: [PATCH 03/19] Add bluez-5.50 image id --- .github/workflows/python-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 948235b..32511db 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -76,7 +76,7 @@ jobs: runs-on: ubuntu-24.04 strategy: matrix: - image_id: ["ubuntu-24.04-bluez-5.64", "ubuntu-24.04-bluez-5.66", "ubuntu-24.04-bluez-5.70"] + image_id: ["ubuntu-18.04-bluez-5.50", "ubuntu-24.04-bluez-5.64", "ubuntu-24.04-bluez-5.66", "ubuntu-24.04-bluez-5.70"] fail-fast: false steps: - uses: actions/checkout@v4 From 66b4c905cc88f2487e2b5f50a620ba36912c4805 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Mon, 12 Jan 2026 19:41:31 +0000 Subject: [PATCH 04/19] Remove image_id variable --- .github/workflows/python-test.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 32511db..fcd4fae 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -85,16 +85,14 @@ jobs: sudo apt-get update sudo apt-get install -y qemu-system-x86 qemu-utils - name: Download qemu images - env: - image_repo: "spacecheese/bluez_images" run: | image_id=${{ matrix.image_id }} mkdir -p tests/loopback/assets curl -L -o tests/loopback/assets/id_ed25519 \ - https://github.com/$image_repo/releases/latest/download/id_ed25519 + https://github.com/spacecheese/bluez_images/releases/latest/download/id_ed25519 chmod 600 tests/loopback/assets/id_ed25519 curl -L -o tests/loopback/assets/image.qcow2 \ - https://github.com/$image_repo/releases/latest/download/${image_id}.qcow2 + https://github.com/spacecheese/bluez_images/releases/latest/download/${image_id}.qcow2 - name: Run loopback Tests run: | tests/loopback/test.sh tests/loopback/assets/image.qcow2 tests/loopback/assets/id_ed25519 . From d82c868c40fb4b3e40250ed861c609bac81509d7 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Mon, 12 Jan 2026 20:17:51 +0000 Subject: [PATCH 05/19] Remove bluez-5.50 image --- .github/workflows/python-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index fcd4fae..1d7fd69 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -76,7 +76,7 @@ jobs: runs-on: ubuntu-24.04 strategy: matrix: - image_id: ["ubuntu-18.04-bluez-5.50", "ubuntu-24.04-bluez-5.64", "ubuntu-24.04-bluez-5.66", "ubuntu-24.04-bluez-5.70"] + image_id: ["ubuntu-24.04-bluez-5.64", "ubuntu-24.04-bluez-5.66", "ubuntu-24.04-bluez-5.70"] fail-fast: false steps: - uses: actions/checkout@v4 From 312fccb33f35dfdd2a8683a22f89d568e049ce3c Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 13 Jan 2026 00:12:31 +0000 Subject: [PATCH 06/19] Fix typo in readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2d5da58..ee6b038 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Characteristics may operate in a number of modes depending on their purpose. By ## Usage -When using this library please remember that services are not implicitly threaded. **The thread used to register your service must regularly yeild otherwise your service will not work** (particularly notifications). Therefore you must frequently yield to the asyncio event loop (for example using asyncio.sleep) and ideally use multithreading. +When using this library please remember that services are not implicitly threaded. **The thread used to register your service must regularly yield otherwise your service will not work** (particularly notifications). Therefore you must frequently yield to the asyncio event loop (for example using asyncio.sleep) and ideally use multithreading. The easiest way to use the library is to create a class describing the service that you wish to provide. From eb40dfc58dffb92d68ec156fb383b569bf455e7f Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 13 Jan 2026 00:13:06 +0000 Subject: [PATCH 07/19] Update advert constructor docstring --- bluez_peripheral/advert.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bluez_peripheral/advert.py b/bluez_peripheral/advert.py index 0791370..701f601 100644 --- a/bluez_peripheral/advert.py +++ b/bluez_peripheral/advert.py @@ -25,7 +25,7 @@ class Advertisement(UniquePathMixin): serviceUUIDs: A list of service UUIDs advertise. appearance: The appearance value to advertise. See the `Bluetooth SIG Assigned Numbers `_ (Search for "Appearance Values") - timeout: The time from registration until this advert is removed (defaults to zero meaning never timeout). + timeout: The time from registration until this advert is removed (defaults to None meaning never timeout). discoverable: Whether or not the device this advert should be generally discoverable. packetType: The type of advertising packet requested. manufacturerData: Any manufacturer specific data to include in the advert. @@ -34,7 +34,6 @@ class Advertisement(UniquePathMixin): includes: Fields that can be optionally included in the advertising packet. Only the :class:`bluez_peripheral.flags.AdvertisingIncludes.TX_POWER` flag seems to work correctly with bluez. duration: Duration of the advert when multiple adverts are ongoing. - release_callback: A function to call when the advert release function is called. The default release callback will unexport the advert. """ _DEFAULT_PATH_PREFIX = "/com/spacecheese/bluez_peripheral/advert" From d3ae85621dcb71cae8ae34b2fba68bb3bb656096 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 13 Jan 2026 00:14:10 +0000 Subject: [PATCH 08/19] Align loopback script excludes --- tests/loopback/remote_run.sh | 2 +- tests/loopback/test.sh | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/loopback/remote_run.sh b/tests/loopback/remote_run.sh index c4410fa..6b7c129 100755 --- a/tests/loopback/remote_run.sh +++ b/tests/loopback/remote_run.sh @@ -15,7 +15,7 @@ rsync -a --progress --rsync-path="sudo rsync" \ --exclude docs/ \ --exclude serial.log \ --exclude='*.venv*' \ - --exclude='*/__pycache__/*' \ + --exclude='*/__pycache__*' \ . tester@localhost:/bluez_peripheral ssh -i tests/loopback/assets/id_ed25519 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2244 -L 5678:localhost:5678 tester@localhost " diff --git a/tests/loopback/test.sh b/tests/loopback/test.sh index d57a213..12643a8 100755 --- a/tests/loopback/test.sh +++ b/tests/loopback/test.sh @@ -60,6 +60,8 @@ rsync -a --progress --rsync-path="sudo rsync" \ --exclude tests/loopback/assets/ \ --exclude docs/ \ --exclude serial.log \ + --exclude='*.venv*' \ + --exclude='*/__pycache__*' \ $PROJ_ROOT tester@localhost:/bluez_peripheral $SSH -p 2244 tester@localhost " From ad522d78bc846a8854d6f58949c76b7e9bf3c0e5 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Fri, 23 Jan 2026 00:14:44 +0000 Subject: [PATCH 09/19] Implement UUID16.__repr__ --- bluez_peripheral/uuid16.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bluez_peripheral/uuid16.py b/bluez_peripheral/uuid16.py index 20700fd..c2baab2 100644 --- a/bluez_peripheral/uuid16.py +++ b/bluez_peripheral/uuid16.py @@ -135,5 +135,8 @@ def __ne__(self, __o: object) -> bool: def __str__(self) -> str: return self.hex + def __repr__(self) -> str: + return f"{self.__class__.__name__}('{self.hex}')" + def __hash__(self) -> builtins.int: return hash(self.uuid) From 92f50a5bd3f37e72d0c5763fb973ba48d0ae2cea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vincent=20L=C3=A5ngstr=C3=B6m?= Date: Sat, 4 Apr 2026 14:45:17 +0300 Subject: [PATCH 10/19] Implement min/max advertisement interval Made-with: Cursor --- bluez_peripheral/advert.py | 82 ++++++++++++++++++++++++++++++++--- tests/unit/test_advert.py | 89 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+), 7 deletions(-) diff --git a/bluez_peripheral/advert.py b/bluez_peripheral/advert.py index 701f601..322b6ff 100644 --- a/bluez_peripheral/advert.py +++ b/bluez_peripheral/advert.py @@ -1,18 +1,47 @@ -from typing import Collection, Dict, Optional, Union, Any import struct +from typing import Any, Collection, Dict, Optional, Union from dbus_fast import Variant -from dbus_fast.constants import PropertyAccess -from dbus_fast.service import method, dbus_property from dbus_fast.aio.message_bus import MessageBus +from dbus_fast.constants import PropertyAccess +from dbus_fast.service import dbus_property, method -from .uuid16 import UUID16, UUIDLike -from .util import _snake_to_kebab from .adapter import Adapter -from .flags import AdvertisingIncludes -from .flags import AdvertisingPacketType from .base import BaseServiceInterface, UniquePathMixin from .error import bluez_error_wrapper +from .flags import AdvertisingIncludes, AdvertisingPacketType +from .util import _snake_to_kebab +from .uuid16 import UUID16, UUIDLike + +# BlueZ src/advertising.c parse_min_interval / parse_max_interval: HCI slot = ms / 0.625, +# valid slots 0x20 .. 0xFFFFFF (see doc/org.bluez.LEAdvertisement.rst). +_ADV_INTERVAL_SLOT_MIN = 0x20 +_ADV_INTERVAL_SLOT_MAX = 0xFFFFFF + + +def _adv_interval_ms_to_slot(ms: int) -> int: + """Convert advertising interval from milliseconds to HCI units (matches BlueZ C division).""" + return int(ms / 0.625) + + +def _validate_advertising_intervals_ms(min_ms: int, max_ms: int) -> None: + min_slot = _adv_interval_ms_to_slot(min_ms) + max_slot = _adv_interval_ms_to_slot(max_ms) + if min_slot < _ADV_INTERVAL_SLOT_MIN or min_slot > _ADV_INTERVAL_SLOT_MAX: + raise ValueError( + "min_advertising_interval_ms is out of range for BlueZ LE advertising " + f"(HCI slot {min_slot} not in [{_ADV_INTERVAL_SLOT_MIN:#x}, {_ADV_INTERVAL_SLOT_MAX:#x}])" + ) + if max_slot < _ADV_INTERVAL_SLOT_MIN or max_slot > _ADV_INTERVAL_SLOT_MAX: + raise ValueError( + "max_advertising_interval_ms is out of range for BlueZ LE advertising " + f"(HCI slot {max_slot} not in [{_ADV_INTERVAL_SLOT_MIN:#x}, {_ADV_INTERVAL_SLOT_MAX:#x}])" + ) + if min_slot > max_slot: + raise ValueError( + "min_advertising_interval_ms must be <= max_advertising_interval_ms " + f"(HCI slots {min_slot} > {max_slot})" + ) class Advertisement(UniquePathMixin): @@ -34,6 +63,9 @@ class Advertisement(UniquePathMixin): includes: Fields that can be optionally included in the advertising packet. Only the :class:`bluez_peripheral.flags.AdvertisingIncludes.TX_POWER` flag seems to work correctly with bluez. duration: Duration of the advert when multiple adverts are ongoing. + min_advertising_interval_ms: Optional minimum advertising interval (ms); must be set together with max. + max_advertising_interval_ms: Optional maximum advertising interval (ms); must be set together with min. + See ``MinInterval`` / ``MaxInterval`` in the BlueZ LEAdvertisement documentation. """ _DEFAULT_PATH_PREFIX = "/com/spacecheese/bluez_peripheral/advert" @@ -140,6 +172,22 @@ def _get_includes(self) -> "as": # type: ignore def _get_duration(self) -> "q": # type: ignore return advert._duration + @dbus_property( + PropertyAccess.READ, + "MinInterval", + disabled=(advert._min_advertising_interval_ms is None), + ) + def _get_min_interval(self) -> "u": # type: ignore + return advert._min_advertising_interval_ms + + @dbus_property( + PropertyAccess.READ, + "MaxInterval", + disabled=(advert._max_advertising_interval_ms is None), + ) + def _get_max_interval(self) -> "u": # type: ignore + return advert._max_advertising_interval_ms + return _AdvertService() def __init__( @@ -156,6 +204,8 @@ def __init__( service_data: Optional[Dict[UUIDLike, bytes]] = None, includes: AdvertisingIncludes = AdvertisingIncludes.NONE, duration: Optional[int] = 2, + min_advertising_interval_ms: Optional[int] = None, + max_advertising_interval_ms: Optional[int] = None, ): self._type = packet_type # Convert any string uuids to uuid16. @@ -190,6 +240,24 @@ def __init__( self._includes = includes self._duration = duration + self._min_advertising_interval_ms: Optional[int] = None + self._max_advertising_interval_ms: Optional[int] = None + if (min_advertising_interval_ms is not None) ^ ( + max_advertising_interval_ms is not None + ): + raise ValueError( + "min_advertising_interval_ms and max_advertising_interval_ms must " + "both be set or both be omitted" + ) + if min_advertising_interval_ms is not None: + assert max_advertising_interval_ms is not None + _validate_advertising_intervals_ms( + min_advertising_interval_ms, + max_advertising_interval_ms, + ) + self._min_advertising_interval_ms = min_advertising_interval_ms + self._max_advertising_interval_ms = max_advertising_interval_ms + self._adapter: Optional[Adapter] = None self._service = self._advert_service_factory() diff --git a/tests/unit/test_advert.py b/tests/unit/test_advert.py index ad8dd68..0cdaaeb 100644 --- a/tests/unit/test_advert.py +++ b/tests/unit/test_advert.py @@ -7,6 +7,47 @@ from bluez_peripheral.flags import AdvertisingPacketType +def test_intervals_validation_partial_kwarg() -> None: + with pytest.raises(ValueError, match="both be set"): + Advertisement( + "x", + [], + min_advertising_interval_ms=100, + ) + + +def test_intervals_validation_min_gt_max() -> None: + with pytest.raises(ValueError, match="<="): + Advertisement( + "x", + [], + min_advertising_interval_ms=200, + max_advertising_interval_ms=100, + ) + + +def test_intervals_validation_slot_too_small() -> None: + # Min slot 0x20 <=> 20 ms; 19 ms is the largest integer ms with int(ms/0.625) < 0x20. + with pytest.raises(ValueError, match="out of range"): + Advertisement( + "x", + [], + min_advertising_interval_ms=20 - 1, + max_advertising_interval_ms=20, + ) + + +def test_intervals_validation_slot_too_large() -> None: + # Max slot 0xFFFFFF; 10485759 is last integer max_ms still in range, 10485760 is first over. + with pytest.raises(ValueError, match="out of range"): + Advertisement( + "x", + [], + min_advertising_interval_ms=20, + max_advertising_interval_ms=10485759 + 1, + ) + + @pytest.fixture def bus_name(): return "com.spacecheese.test" @@ -162,6 +203,54 @@ async def test_args_service_data(message_bus, bus_name, bus_path, background_adv } +@pytest.mark.asyncio +async def test_intervals_optional_omitted( + bus_name, bus_path, message_bus, background_advert +): + advert = Advertisement( + "Testing Device Name", + ["180A", "180D"], + appearance=0x0340, + timeout=2, + packet_type=AdvertisingPacketType.PERIPHERAL, + ) + background_advert(advert, path=bus_path) + + introspection = await message_bus.introspect(bus_name, bus_path) + le_iface = next( + i for i in introspection.interfaces if i.name == "org.bluez.LEAdvertisement1" + ) + prop_names = {p.name for p in le_iface.properties} + assert "MinInterval" not in prop_names + assert "MaxInterval" not in prop_names + + +@pytest.mark.asyncio +async def test_intervals_exposed(bus_name, bus_path, message_bus, background_advert): + advert = Advertisement( + "Testing Device Name", + ["180A", "180D"], + appearance=0x0340, + timeout=2, + packet_type=AdvertisingPacketType.PERIPHERAL, + min_advertising_interval_ms=100, + max_advertising_interval_ms=150, + ) + background_advert(advert, path=bus_path) + + introspection = await message_bus.introspect(bus_name, bus_path) + le_iface = next( + i for i in introspection.interfaces if i.name == "org.bluez.LEAdvertisement1" + ) + prop_names = {p.name for p in le_iface.properties} + assert "MinInterval" in prop_names + assert "MaxInterval" in prop_names + proxy_object = message_bus.get_proxy_object(bus_name, bus_path, introspection) + interface = proxy_object.get_interface("org.bluez.LEAdvertisement1") + assert await interface.get_min_interval() == 100 + assert await interface.get_max_interval() == 150 + + @pytest.mark.asyncio async def test_default_path(message_bus, bus_name, background_advert): adverts = [ From c09127117ed684b4b4c96268796f2144e8530f46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vincent=20L=C3=A5ngstr=C3=B6m?= Date: Sat, 4 Apr 2026 15:50:20 +0300 Subject: [PATCH 11/19] Fix sphinx tests --- bluez_peripheral/advert.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/bluez_peripheral/advert.py b/bluez_peripheral/advert.py index 322b6ff..02f307d 100644 --- a/bluez_peripheral/advert.py +++ b/bluez_peripheral/advert.py @@ -13,14 +13,14 @@ from .util import _snake_to_kebab from .uuid16 import UUID16, UUIDLike -# BlueZ src/advertising.c parse_min_interval / parse_max_interval: HCI slot = ms / 0.625, +# bluez src/advertising.c parse_min_interval / parse_max_interval: HCI slot = ms / 0.625, # valid slots 0x20 .. 0xFFFFFF (see doc/org.bluez.LEAdvertisement.rst). _ADV_INTERVAL_SLOT_MIN = 0x20 _ADV_INTERVAL_SLOT_MAX = 0xFFFFFF def _adv_interval_ms_to_slot(ms: int) -> int: - """Convert advertising interval from milliseconds to HCI units (matches BlueZ C division).""" + """Convert advertising interval from milliseconds to HCI units (matches bluez C division).""" return int(ms / 0.625) @@ -29,12 +29,12 @@ def _validate_advertising_intervals_ms(min_ms: int, max_ms: int) -> None: max_slot = _adv_interval_ms_to_slot(max_ms) if min_slot < _ADV_INTERVAL_SLOT_MIN or min_slot > _ADV_INTERVAL_SLOT_MAX: raise ValueError( - "min_advertising_interval_ms is out of range for BlueZ LE advertising " + "min_advertising_interval_ms is out of range for bluez LE advertising " f"(HCI slot {min_slot} not in [{_ADV_INTERVAL_SLOT_MIN:#x}, {_ADV_INTERVAL_SLOT_MAX:#x}])" ) if max_slot < _ADV_INTERVAL_SLOT_MIN or max_slot > _ADV_INTERVAL_SLOT_MAX: raise ValueError( - "max_advertising_interval_ms is out of range for BlueZ LE advertising " + "max_advertising_interval_ms is out of range for bluez LE advertising " f"(HCI slot {max_slot} not in [{_ADV_INTERVAL_SLOT_MIN:#x}, {_ADV_INTERVAL_SLOT_MAX:#x}])" ) if min_slot > max_slot: @@ -63,9 +63,9 @@ class Advertisement(UniquePathMixin): includes: Fields that can be optionally included in the advertising packet. Only the :class:`bluez_peripheral.flags.AdvertisingIncludes.TX_POWER` flag seems to work correctly with bluez. duration: Duration of the advert when multiple adverts are ongoing. - min_advertising_interval_ms: Optional minimum advertising interval (ms); must be set together with max. - max_advertising_interval_ms: Optional maximum advertising interval (ms); must be set together with min. - See ``MinInterval`` / ``MaxInterval`` in the BlueZ LEAdvertisement documentation. + min_advertising_interval_ms: Optional minimum advertising interval in milliseconds; must be set together with max. + max_advertising_interval_ms: Optional maximum advertising interval in milliseconds; must be set together with min. + See ``MinInterval`` / ``MaxInterval`` in the bluez LEAdvertisement documentation. """ _DEFAULT_PATH_PREFIX = "/com/spacecheese/bluez_peripheral/advert" From 53a2b977ff9e0fe4c11c5eb098ad688614d5e58d Mon Sep 17 00:00:00 2001 From: Space Cheese <12695808+spacecheese@users.noreply.github.com> Date: Sat, 4 Apr 2026 17:21:01 +0000 Subject: [PATCH 12/19] Link bluez reference in contents --- docs/source/index.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/source/index.rst b/docs/source/index.rst index 35711c0..83cdde6 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -11,5 +11,6 @@ Contents advertising pairing API Reference + Bluez Reference Github PyPi \ No newline at end of file From 2060856c503e23be5f659fb9710e81d0b46eb3d8 Mon Sep 17 00:00:00 2001 From: Space Cheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:05:51 +0000 Subject: [PATCH 13/19] Add interactive mode to remote_run.sh --- tests/loopback/remote_run.sh | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/loopback/remote_run.sh b/tests/loopback/remote_run.sh index 6b7c129..422d31e 100755 --- a/tests/loopback/remote_run.sh +++ b/tests/loopback/remote_run.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash RUN_CMD="${1:-python3 -m debugpy --listen 0.0.0.0:5678 --wait-for-client -m pytest tests/unit}" -DEBUG=0 +INTERACTIVE=0 while getopts ":i" opt; do case $opt in - i) DEBUG=1 ;; + i) INTERACTIVE=1 ;; \?) echo "Invalid option: -$OPTARG" >&2; exit 1 ;; esac done @@ -18,6 +18,10 @@ rsync -a --progress --rsync-path="sudo rsync" \ --exclude='*/__pycache__*' \ . tester@localhost:/bluez_peripheral -ssh -i tests/loopback/assets/id_ed25519 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2244 -L 5678:localhost:5678 tester@localhost " - source ~/venv/bin/activate && pip3 install debugpy && cd /bluez_peripheral && $RUN_CMD -" \ No newline at end of file +if (( INTERACTIVE )); then + ssh -i tests/loopback/assets/id_ed25519 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2244 -L 5678:localhost:5678 tester@localhost || true +else + ssh -i tests/loopback/assets/id_ed25519 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p 2244 -L 5678:localhost:5678 tester@localhost " + source ~/venv/bin/activate && pip3 install debugpy && cd /bluez_peripheral && $RUN_CMD + " +fi \ No newline at end of file From 63b0c4bc556fd314e4ce43d84973b291ab64fb24 Mon Sep 17 00:00:00 2001 From: Space Cheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:28:19 +0000 Subject: [PATCH 14/19] Adapter and Device fixes --- bluez_peripheral/adapter.py | 39 +++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/bluez_peripheral/adapter.py b/bluez_peripheral/adapter.py index eb9146d..b7271cf 100644 --- a/bluez_peripheral/adapter.py +++ b/bluez_peripheral/adapter.py @@ -59,12 +59,13 @@ async def get_uuids(self) -> Collection[UUIDLike]: async def get_manufacturer_data(self) -> Dict[int, bytes]: """Returns the manufacturer data.""" - return await self._device_interface.get_manufacturer_data() # type: ignore + data = await self._device_interface.get_manufacturer_data() + return {k: v.value for k, v in data.items()} # type: ignore - async def get_service_data(self) -> List[Tuple[UUIDLike, bytes]]: + async def get_service_data(self) -> Dict[UUIDLike, bytes]: """Returns the service data.""" data = await self._device_interface.get_service_data() # type: ignore - return [(UUID16.parse_uuid(u), d) for u, d in data] + return {UUID16.parse_uuid(k): v.value for k, v in data.items()} class Adapter: @@ -215,28 +216,37 @@ def _interface_added(path: str, intfs_and_props: Dict[str, Dict[str, Variant]]): ) object_manager_interface.on_interfaces_added(_interface_added) # type: ignore - yielded_paths = set() + # Yield any devices which are already present. + devs = await self.get_devices() + yielded_paths = set(devs) + + for d in devs: + yield d async def _stop_discovery() -> None: await asyncio.sleep(duration) await self.stop_discovery() await self.start_discovery() - stop_task = None + timeout_task = None if duration > 0: - stop_task = asyncio.create_task(_stop_discovery()) + timeout_task = asyncio.create_task(_stop_discovery()) adapter_path = self._adapter_interface.path while not self._discovery_stopped.is_set(): - if stop_task is not None: + if timeout_task is None: + path, intfs_and_props = await queue.get() + else: queue_task = asyncio.create_task(queue.get()) + # Block until either a timeout or we find a device. done, _ = await asyncio.wait( - [queue_task, stop_task], + [queue_task, timeout_task], return_when=asyncio.FIRST_COMPLETED, ) - if stop_task in done and queue_task not in done: + # Break out if we timed out and didn't find a device. + if timeout_task in done and queue_task not in done: queue_task.cancel() try: await queue_task @@ -245,9 +255,7 @@ async def _stop_discovery() -> None: break path, intfs_and_props = queue_task.result() - else: - path, intfs_and_props = await queue.get() - + if ( path.startswith(adapter_path) and path in yielded_paths @@ -258,10 +266,11 @@ async def _stop_discovery() -> None: yield await self._get_device(path) yielded_paths.add(path) - if stop_task is not None and stop_task.done(): - stop_task.cancel() + # Cancel the timeout task if it's still pending (discovery must have been cancelled by someone else). + if timeout_task is not None and not timeout_task.done(): + timeout_task.cancel() try: - await stop_task + await timeout_task except asyncio.CancelledError: pass From 80f1f6baea95779c0a1f9f1e8a51c7bce65af7f2 Mon Sep 17 00:00:00 2001 From: Space Cheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:36:05 +0000 Subject: [PATCH 15/19] Check advert loopback values --- tests/loopback/test_advert.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/tests/loopback/test_advert.py b/tests/loopback/test_advert.py index 947fbf9..eaa563c 100644 --- a/tests/loopback/test_advert.py +++ b/tests/loopback/test_advert.py @@ -29,9 +29,7 @@ async def test_advertisement(message_bus, unpaired_adapters): assert loopback_device is not None assert await loopback_device.get_alias() == "Heart Monitor" assert await loopback_device.get_appearance() == 0x0340 - uuids = set(await loopback_device.get_uuids()) - assert uuids == set([UUID16("180D"), UUID16("1234")]) - + assert {UUID16("180D"), UUID16("1234")}.issubset(await loopback_device.get_uuids()) @pytest.mark.asyncio async def test_advanced_data(message_bus, unpaired_adapters): @@ -49,7 +47,15 @@ async def test_advanced_data(message_bus, unpaired_adapters): service_data={"180A": b"\0x01\0x02"}, ) await advert.register(message_bus, adapter=adapters[0]) + loopback_device = None + async for device in adapters[1].discover_devices(duration=5.0): + loopback_device = device + assert loopback_device is not None + assert await loopback_device.get_alias() == "Testing Device Name" + assert await loopback_device.get_appearance() == 0x0340 + assert {UUID16("180A"), UUID16("180D")}.issubset(await loopback_device.get_uuids()) + assert await loopback_device.get_service_data() == {UUID16("180A"): b"\0x01\0x02"} @pytest.mark.asyncio async def test_manufacturer_data(message_bus, unpaired_adapters): @@ -69,3 +75,12 @@ async def test_manufacturer_data(message_bus, unpaired_adapters): }, ) await advert.register(message_bus, adapter=adapters[0]) + loopback_device = None + async for device in adapters[1].discover_devices(duration=5.0): + loopback_device = device + + assert loopback_device is not None + assert await loopback_device.get_alias() == "Testing Device Name" + assert await loopback_device.get_appearance() == 0x0340 + assert {UUID16("180A"), UUID16("180D")}.issubset(await loopback_device.get_uuids()) + assert await loopback_device.get_manufacturer_data() == {0: b"\0x0\0x1\0x2"} From ca569c28abc159280be314995e8e878dfd1993d3 Mon Sep 17 00:00:00 2001 From: Space Cheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 7 Apr 2026 15:11:35 +0000 Subject: [PATCH 16/19] Fix formatting --- bluez_peripheral/adapter.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bluez_peripheral/adapter.py b/bluez_peripheral/adapter.py index b7271cf..416b632 100644 --- a/bluez_peripheral/adapter.py +++ b/bluez_peripheral/adapter.py @@ -60,7 +60,7 @@ async def get_uuids(self) -> Collection[UUIDLike]: async def get_manufacturer_data(self) -> Dict[int, bytes]: """Returns the manufacturer data.""" data = await self._device_interface.get_manufacturer_data() - return {k: v.value for k, v in data.items()} # type: ignore + return {k: v.value for k, v in data.items()} # type: ignore async def get_service_data(self) -> Dict[UUIDLike, bytes]: """Returns the service data.""" @@ -255,7 +255,7 @@ async def _stop_discovery() -> None: break path, intfs_and_props = queue_task.result() - + if ( path.startswith(adapter_path) and path in yielded_paths @@ -266,7 +266,7 @@ async def _stop_discovery() -> None: yield await self._get_device(path) yielded_paths.add(path) - # Cancel the timeout task if it's still pending (discovery must have been cancelled by someone else). + # Cancel the timeout task if it's still pending (discovery must have been cancelled by someone else). if timeout_task is not None and not timeout_task.done(): timeout_task.cancel() try: From 3227f1380ced46c7239742adad2fbf6a723c78f4 Mon Sep 17 00:00:00 2001 From: Space Cheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:11:55 +0000 Subject: [PATCH 17/19] pre-commit fixes --- bluez_peripheral/adapter.py | 26 ++++++++++++++++---------- tests/loopback/test_advert.py | 2 ++ 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/bluez_peripheral/adapter.py b/bluez_peripheral/adapter.py index 416b632..5826200 100644 --- a/bluez_peripheral/adapter.py +++ b/bluez_peripheral/adapter.py @@ -59,8 +59,8 @@ async def get_uuids(self) -> Collection[UUIDLike]: async def get_manufacturer_data(self) -> Dict[int, bytes]: """Returns the manufacturer data.""" - data = await self._device_interface.get_manufacturer_data() - return {k: v.value for k, v in data.items()} # type: ignore + data = await self._device_interface.get_manufacturer_data() # type: ignore + return {k: v.value for k, v in data.items()} async def get_service_data(self) -> Dict[UUIDLike, bytes]: """Returns the service data.""" @@ -204,24 +204,30 @@ async def discover_devices(self, duration: float = 10.0) -> AsyncIterator[Device asyncio.Queue() ) + adapter_path = self._adapter_interface.path + bus = self._adapter_interface.bus + def _interface_added(path: str, intfs_and_props: Dict[str, Dict[str, Variant]]): # type: ignore queue.put_nowait((path, intfs_and_props)) - introspection = await self._adapter_interface.bus.introspect("org.bluez", "/") - proxy = self._adapter_interface.bus.get_proxy_object( - "org.bluez", "/", introspection - ) + introspection = await bus.introspect("org.bluez", "/") + proxy = bus.get_proxy_object("org.bluez", "/", introspection) object_manager_interface = proxy.get_interface( "org.freedesktop.DBus.ObjectManager" ) object_manager_interface.on_interfaces_added(_interface_added) # type: ignore + yielded_paths = set() + # Yield any devices which are already present. - devs = await self.get_devices() - yielded_paths = set(devs) + device_nodes = (await bus.introspect("org.bluez", adapter_path)).nodes + for node in device_nodes: + if node.name is None: + continue - for d in devs: - yield d + node_path = adapter_path + "/" + node.name + yield await self._get_device(node_path) + yielded_paths.add(node_path) async def _stop_discovery() -> None: await asyncio.sleep(duration) diff --git a/tests/loopback/test_advert.py b/tests/loopback/test_advert.py index eaa563c..59ef3f3 100644 --- a/tests/loopback/test_advert.py +++ b/tests/loopback/test_advert.py @@ -31,6 +31,7 @@ async def test_advertisement(message_bus, unpaired_adapters): assert await loopback_device.get_appearance() == 0x0340 assert {UUID16("180D"), UUID16("1234")}.issubset(await loopback_device.get_uuids()) + @pytest.mark.asyncio async def test_advanced_data(message_bus, unpaired_adapters): adapters = unpaired_adapters @@ -57,6 +58,7 @@ async def test_advanced_data(message_bus, unpaired_adapters): assert {UUID16("180A"), UUID16("180D")}.issubset(await loopback_device.get_uuids()) assert await loopback_device.get_service_data() == {UUID16("180A"): b"\0x01\0x02"} + @pytest.mark.asyncio async def test_manufacturer_data(message_bus, unpaired_adapters): adapters = unpaired_adapters From 2d09876fd42144aff318c33d89efbb00dcd91fdf Mon Sep 17 00:00:00 2001 From: Space Cheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:00:41 +0000 Subject: [PATCH 18/19] Add workflow permissions --- .github/workflows/python-publish.yml | 3 +++ .github/workflows/python-test.yml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 6827736..3d665df 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -4,6 +4,9 @@ on: release: types: [published] +permissions: + contents: read + jobs: deploy: runs-on: ubuntu-24.04 diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 1d7fd69..a4b30b7 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -2,6 +2,9 @@ name: Test Python Package on: [ pull_request, workflow_dispatch ] +permissions: + contents: read + jobs: test-docs: runs-on: ubuntu-24.04 From 2ab6233661f71bf60af38910d66686c3fbebb56c Mon Sep 17 00:00:00 2001 From: Space Cheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:06:57 +0000 Subject: [PATCH 19/19] Update bluetooth spec links --- bluez_peripheral/agent.py | 2 +- docs/source/pairing.rst | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bluez_peripheral/agent.py b/bluez_peripheral/agent.py index 817d685..24886c8 100644 --- a/bluez_peripheral/agent.py +++ b/bluez_peripheral/agent.py @@ -12,7 +12,7 @@ class AgentCapability(Enum): """The IO Capabilities of the local device supported by the agent. - See Tables 5.5 and 5.7 of the `Bluetooth Core Spec Part C. `_ + See Tables 5.5 and 5.7 of the `Bluetooth Core Spec Part C. `_ """ KEYBOARD_DISPLAY = 0 diff --git a/docs/source/pairing.rst b/docs/source/pairing.rst index b80c5e2..8b5a5d1 100644 --- a/docs/source/pairing.rst +++ b/docs/source/pairing.rst @@ -142,7 +142,7 @@ Note that IO Capability is not the only factor in selecting a pairing algorithm. `Part 2 `_ `Part 3 `_ - `Bluetooth Core Spec v5.2 `_ + `Bluetooth Core Spec v5.2 `_ Vol 3, Part H, Table 2.8 (source of :ref:`pairing-io`) Bluez Documentation