diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 205cf75..6827736 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -6,15 +6,14 @@ on: jobs: deploy: - - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: - python-version: '3.x' + python-version: '3.11.x' cache: 'pip' - name: Install dependencies run: | diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 6f51f13..4ec9537 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -4,42 +4,153 @@ on: [ pull_request, workflow_dispatch ] jobs: test-docs: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: - python-version: '3.9.x' + python-version: '3.11.x' cache: 'pip' - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r docs/requirements.txt - sudo apt-get update && sudo apt-get install -y enchant-2 aspell-en + sudo apt-get update && sudo apt-get install -y enchant-2 - name: Build Docs run: | cd docs make clean make html + - name: Spelling Check + run: | + cd docs + make spelling + - name: Test Code Snippets + run: | + cd docs + make doctest + - name: Link Check + run: | + cd docs make linkcheck - test: - runs-on: ubuntu-22.04 + + test-container: + runs-on: ubuntu-24.04 + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12"] + fail-fast: false + permissions: + pages: write + id-token: write steps: - uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: - python-version: '3.9.x' + python-version: ${{ matrix.python-version }} cache: 'pip' - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r tests/requirements.txt + pip install "coverage[toml]" - name: Add DBus Config run: | - sudo cp tests/com.spacecheese.test.conf /etc/dbus-1/system.d + sudo cp tests/unit/com.spacecheese.test.conf /etc/dbus-1/system.d - name: Run Tests run: | - python -m unittest discover -s tests -p "test_*.py" -v + coverage run --source=. --branch -m pytest tests/unit -v + - name: Report Coverage + run: | + coverage html + coverage report -m + - name: Upload Coverage Artifact + uses: actions/upload-artifact@v4 + with: + name: coverage-${{ matrix.python-version }} + path: htmlcov/ + + test-qemu: + 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"] + fail-fast: false + steps: + - uses: actions/checkout@v4 + - name: Install dependencies + run: | + 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 + 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 + - name: Run loopback Tests + run: | + tests/loopback/test.sh tests/loopback/assets/image.qcow2 tests/loopback/assets/id_ed25519 . + + format-check: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11.x' + cache: 'pip' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install black + - name: Check Formatting + run: | + python -m black --check bluez_peripheral + python -m black --check tests + + type-hint-check: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11.x' + cache: 'pip' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install mypy + - name: Check type hints + run: | + python -m mypy bluez_peripheral + + lint-check: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11.x' + cache: 'pip' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pylint + - name: Run lint checks + run: | + python -m pylint bluez_peripheral diff --git a/.gitignore b/.gitignore index 1eecd93..cef3daf 100644 --- a/.gitignore +++ b/.gitignore @@ -138,4 +138,7 @@ dmypy.json cython_debug/ # VSCode Config Files -.vscode/ \ No newline at end of file +.vscode/ + +# Testing images +tests/loopback/assets \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..1d74f44 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,54 @@ +repos: + - repo: https://github.com/psf/black + rev: 25.12.0 + hooks: + - id: black + name: black (code formatting) + files: ^(bluez_peripheral/|tests/) + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.19.1 + hooks: + - id: mypy + name: mypy (type hints) + additional_dependencies: + - dbus_fast + files: ^bluez_peripheral/ + + - repo: https://github.com/pylint-dev/pylint + rev: v4.0.4 + hooks: + - id: pylint + additional_dependencies: + - dbus_fast + files: ^bluez_peripheral/ + + - repo: local + hooks: + - id: docs-build + name: docs (build) + entry: bash -c "cd docs && make clean && make html" + language: system + pass_filenames: false + stages: [manual] + + - id: docs-spelling + name: docs (spell check) + entry: bash -c "cd docs && make spelling" + language: system + pass_filenames: false + stages: [manual] + + - id: docs-doctest + name: docs (test code snippets) + entry: bash -c "cd docs && make doctest" + language: system + pass_filenames: false + stages: [manual] + + - id: docs-linkcheck + name: docs (linkcheck) + entry: bash -c "cd docs && make linkcheck" + language: system + pass_filenames: false + stages: [manual] diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..b079729 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,13 @@ +version: 2 + +build: + os: ubuntu-24.04 + tools: + python: "3.11" + +sphinx: + configuration: docs/source/conf.py + +python: + install: + - requirements: docs/requirements.txt \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..63195cd --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,27 @@ +# Contributing +Contributions of are encouraged and greatly appreciated. + +## Getting Started +Code must pass the following CI checks before it will be accepted: +- Be preformatted with [black](https://github.com/psf/black) +- A mypy type hinting check +- A pylint check (including full docstring coverage) +- shphinx linkcheck, spelling and doctest (code snippet tests) +- Unit and end-to-end tests + +Most of these checks can be run locally using [pre-commit](https://pre-commit.com/). To configure this run: +```bash +pip install pre-commit +pre-commit install +``` +pylint, mypy and formatting checks will then be automatically run as part of your git hooks. sphinx tests can also be run by pre-commit, though you must first install some additional dependencies to build the documentation: +```bash +pip install -r docs/requirements.txt +sudo apt-get install enchant-2 # This depends on your distribution- for more see the pyenchant documentation +``` +You can then run all pre-commit checks manually: +```bash +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 diff --git a/README.md b/README.md index 30f63e4..2d5da58 100644 --- a/README.md +++ b/README.md @@ -22,29 +22,25 @@ Install bluez (eg. `sudo apt-get install bluez`) ## GATT Overview -GATT is a BLE protocol that allows you to offer services to other devices. -You can find a list of standardised services on the [Bluetooth SIG website](https://www.bluetooth.com/specifications/specs/) (you can largely ignore profiles when working with BLE). You should refer to the "Service Characteristics" in these specifications for the purposes of this library. +GATT is a protocol that allows you to offer services to other devices. +You can find a list of standardized services on the [Bluetooth SIG website](https://www.bluetooth.com/specifications/specs/). -![Peripheral Hierarchy Diagram](https://doc.qt.io/qt-5/images/peripheral-structure.png) +![Peripheral Hierarchy Diagram](https://doc.qt.io/qt-6/images/peripheral-structure.png) _Courtesey of Qt documentation (GNU Free Documentation License)_ -A peripheral defines a list of services that it provides. Services are a collection of characteristics which expose particular data (eg. a heart rate or mouse position). Characteristics may also have descriptors that contain metadata (eg. the units of a characteristic). Services can optionally include other services. All BLE attributes (Services, Characterisics and Descriptors) are identified by a 16-bit number [assigned by the Bluetooth SIG](https://www.bluetooth.com/specifications/assigned-numbers/). +A peripheral defines a list of services that it provides. Services are a collection of characteristics which expose particular data (eg. a heart rate or mouse position). Characteristics may also have descriptors that contain metadata (eg. the units of a characteristic). Services can optionally include other services. All BLE attributes (Services, Characteristics and Descriptors) are identified by a 16-bit number [assigned by the Bluetooth SIG](https://www.bluetooth.com/specifications/assigned-numbers/). -Characteristics may operate in a number of modes depending on their purpose. By default characteristics are read-only in this library however they may also be writable and provide notification (like an event system) when their value changes. Additionally some characteristics require security protection. You can read more about BLE on the [Bluetooth SIG blog](https://www.bluetooth.com/blog/a-developers-guide-to-bluetooth/). +Characteristics may operate in a number of modes depending on their purpose. By default characteristics are read-only in this library however they may also be writable and provide notification (like an event system) when their value changes. Additionally some characteristics may require security protection. You can read more about BLE on the [Bluetooth SIG blog](https://www.bluetooth.com/blog/a-developers-guide-to-bluetooth/). [This video](https://www.youtube.com/watch?v=BZwOrQ6zkzE) gives a more in-depth overview of BLE. ## Usage -There are a few important things you need to remember when using this library: - -- **Do not attempt to create the Generic Access Service or a Client Characteristic Configuration Descriptor** (if you don't know what this means don't worry). These are both handled automatically by Bluez and attempting to define them will result in errors. -- Services are not implicitly threaded. **If you register a service in your main thread blocking that thread will stop your service (and particularly notifications) from working**. Therefore you must frequently yeild 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 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. The easiest way to use the library is to create a class describing the service that you wish to provide. ```python -from bluez_peripheral.gatt.service import Service -from bluez_peripheral.gatt.characteristic import characteristic, CharacteristicFlags as CharFlags +from bluez_peripheral.gatt import Service, characteristic, CharacteristicFlags as CharFlags import struct @@ -89,15 +85,16 @@ async def main(): service = HeartRateService() await service.register(bus) - # An agent is required to handle pairing + # An agent is required if you wish to handle pairing. agent = NoIoAgent() - # This script needs superuser for this to work. + # This line needs superuser for this to work. await agent.register(bus) adapter = await Adapter.get_first(bus) # Start an advert that will last for 60 seconds. - advert = Advertisement("Heart Monitor", ["180D"], 0x0340, 60) + advert = Advertisement("Heart Monitor", ["180D"], + appearance=0x0340, timeout=60) await advert.register(bus, adapter) while True: diff --git a/bluez_peripheral/__init__.py b/bluez_peripheral/__init__.py index e69de29..99c1381 100644 --- a/bluez_peripheral/__init__.py +++ b/bluez_peripheral/__init__.py @@ -0,0 +1,3 @@ +from .advert import Advertisement +from .uuid16 import UUID16 +from .util import get_message_bus diff --git a/bluez_peripheral/adapter.py b/bluez_peripheral/adapter.py new file mode 100644 index 0000000..9293777 --- /dev/null +++ b/bluez_peripheral/adapter.py @@ -0,0 +1,243 @@ +from typing import Collection, Dict, Tuple, List + +from dbus_fast.aio import MessageBus, ProxyInterface +from dbus_fast.aio.proxy_object import ProxyObject +from dbus_fast import InvalidIntrospectionError, InterfaceNotFoundError + +from .util import _kebab_to_shouting_snake +from .flags import AdvertisingIncludes +from .uuid16 import UUID16, UUIDLike + + +class Device: + """A bluetooth device discovered by an adapter. + Represents an `org.bluez.Device1 `_ instance. + """ + + _INTERFACE = "org.bluez.Device1" + _device_interface: ProxyInterface + + def __init__(self, proxy: ProxyObject): + self._proxy = proxy + self._device_interface = proxy.get_interface(self._INTERFACE) + + async def get_paired(self) -> bool: + """Returns true if the parent adapter is paired with this device. False otherwise.""" + return await self._device_interface.get_paired() # type: ignore + + async def pair(self) -> None: + """Attempts to pair the parent adapter with this device.""" + await self._device_interface.call_pair() # type: ignore + + async def remove(self, adapter: "Adapter") -> None: + """Disconnects and unpairs from this device.""" + interface = adapter.get_adapter_interface() + await interface.call_remove_device(self._device_interface._path) # type: ignore # pylint: disable=protected-access + + async def get_name(self) -> str: + """Returns the display name of this device (use alias instead to get the display name).""" + return await self._device_interface.get_name() # type: ignore + + async def get_alias(self) -> str: + """Returns the alias of this device.""" + return await self._device_interface.get_alias() # type: ignore + + async def get_appearance(self) -> int: + """Returns the appearance of the device.""" + return await self._device_interface.get_appearance() # type: ignore + + async def get_uuids(self) -> Collection[UUIDLike]: + """Returns the collection of UUIDs representing the services available on this device.""" + ids = await self._device_interface.get_uui_ds() # type: ignore + return [UUID16.parse_uuid(i) for i in ids] + + async def get_manufacturer_data(self) -> Dict[int, bytes]: + """Returns the manufacturer data.""" + return await self._device_interface.get_manufacturer_data() # type: ignore + + async def get_service_data(self) -> List[Tuple[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] + + +class Adapter: + """A bluetooth adapter. + Represents an `org.bluez.Adapter1 `_ instance. + """ + + _INTERFACE = "org.bluez.Adapter1" + _GATT_MANAGER_INTERFACE = "org.bluez.GattManager1" + _ADVERTISING_MANAGER_INTERFACE = "org.bluez.LEAdvertisingManager1" + _proxy: ProxyObject + _adapter_interface: ProxyInterface + + def __init__(self, proxy: ProxyObject): + self._proxy = proxy + self._adapter_interface = proxy.get_interface(self._INTERFACE) + + def get_adapter_interface(self) -> ProxyInterface: + """Returns the org.bluez.Adapter associated with this adapter.""" + return self._adapter_interface + + def get_gatt_manager(self) -> ProxyInterface: + """Returns the org.bluez.GattManager1 interface associated with this adapter.""" + return self._proxy.get_interface(self._GATT_MANAGER_INTERFACE) + + def get_advertising_manager(self) -> ProxyInterface: + """Returns the org.bluez.LEAdvertisingManager1 interface associated with this adapter.""" + return self._proxy.get_interface(self._ADVERTISING_MANAGER_INTERFACE) + + async def get_address(self) -> str: + """Read the bluetooth address of this device.""" + return await self._adapter_interface.get_address() # type: ignore + + async def get_name(self) -> str: + """Read the bluetooth hostname of this system.""" + return await self._adapter_interface.get_name() # type: ignore + + async def get_alias(self) -> str: + """The user friendly name of the device.""" + return await self._adapter_interface.get_alias() # type: ignore + + async def set_alias(self, val: str) -> None: + """Set the user friendly name for this device. + Changing the device hostname directly is preferred. + Writing an empty string will result in the alias resetting to the device hostname. + """ + await self._adapter_interface.set_alias(val) # type: ignore + + async def get_powered(self) -> bool: + """Indicates if the adapter is on or off.""" + return await self._adapter_interface.get_powered() # type: ignore + + async def set_powered(self, val: bool) -> None: + """Turn this adapter on or off.""" + await self._adapter_interface.set_powered(val) # type: ignore + + async def get_pairable(self) -> bool: + """Indicates if the adapter is in pairable state or not.""" + return await self._adapter_interface.get_pairable() # type: ignore + + async def set_pairable(self, val: bool) -> None: + """Switch an adapter to pairable or non-pairable.""" + await self._adapter_interface.set_pairable(val) # type: ignore + + async def get_pairable_timeout(self) -> int: + """Get the current pairable timeout""" + return await self._adapter_interface.get_pairable_timeout() # type: ignore + + async def set_pairable_timeout(self, val: int) -> None: + """Set the pairable timeout in seconds. A value of zero means that the + timeout is disabled and it will stay in pairable mode forever.""" + await self._adapter_interface.set_pairable_timeout(val) # type: ignore + + async def get_discoverable(self) -> bool: + """Indicates if the adapter is discoverable.""" + return await self._adapter_interface.get_discoverable() # type: ignore + + async def set_discoverable(self, val: bool) -> None: + """Switch an adapter to discoverable or non-discoverable to either make it + visible or hide it.""" + await self._adapter_interface.set_discoverable(val) # type: ignore + + async def get_discoverable_timeout(self) -> int: + """Get the current discoverable timeout""" + return await self._adapter_interface.get_discoverable_timeout() # type: ignore + + async def set_discoverable_timeout(self, val: int) -> None: + """Set the discoverable timeout in seconds. A value of zero means that the + timeout is disabled and it will stay in discoverable mode forever.""" + await self._adapter_interface.set_discoverable_timeout(val) # type: ignore + + async def get_supported_advertising_includes(self) -> AdvertisingIncludes: + """Returns a flag set of the advertising includes supported by this adapter.""" + interface = self.get_advertising_manager() + includes = await interface.get_supported_includes() # type: ignore + flags = AdvertisingIncludes.NONE + for inc in includes: + inc = AdvertisingIncludes[_kebab_to_shouting_snake(inc)] + # Combine all the included flags. + flags |= inc + return flags + + async def start_discovery(self) -> None: + """Start searching for other bluetooth devices.""" + await self._adapter_interface.call_start_discovery() # type: ignore + + async def stop_discovery(self) -> None: + """Stop searching for other bluetooth devices.""" + await self._adapter_interface.call_stop_discovery() # type: ignore + + async def get_devices(self) -> List[Device]: + """Returns a list of devices which have been discovered by this adapter.""" + assert self._adapter_interface is not None + + path = self._adapter_interface.path + bus = self._adapter_interface.bus + + device_nodes = (await bus.introspect("org.bluez", path)).nodes + + devices = [] + for node in device_nodes: + if node.name is None: + continue + try: + introspection = await bus.introspect( + "org.bluez", path + "/" + node.name + ) + proxy = bus.get_proxy_object( + "org.bluez", path + "/" + node.name, introspection + ) + devices.append(Device(proxy)) + except InvalidIntrospectionError: + pass + + return devices + + @classmethod + async def get_all(cls, bus: MessageBus) -> List["Adapter"]: + """Get a list of available Bluetooth adapters. + + Args: + bus: The message bus used to query bluez. + + Returns: + A list of available bluetooth adapters. + """ + adapter_nodes = (await bus.introspect("org.bluez", "/org/bluez")).nodes + + adapters = [] + for node in adapter_nodes: + if node.name is None: + continue + try: + introspection = await bus.introspect( + "org.bluez", "/org/bluez/" + node.name + ) + proxy = bus.get_proxy_object( + "org.bluez", "/org/bluez/" + node.name, introspection + ) + adapters.append(cls(proxy)) + except (InvalidIntrospectionError, InterfaceNotFoundError): + pass + + return adapters + + @classmethod + async def get_first(cls, bus: MessageBus) -> "Adapter": + """Gets the first adapter listed by bluez. + + Args: + bus: The bus to use for adapter discovery. + + Raises: + ValueError: Raised when no bluetooth adapters are available. + + Returns: + The resulting adapter. + """ + adapters = await cls.get_all(bus) + if len(adapters) > 0: + return next(iter(adapters)) + raise ValueError("No bluetooth adapters could be found.") diff --git a/bluez_peripheral/advert.py b/bluez_peripheral/advert.py index fcb4091..385bf8e 100644 --- a/bluez_peripheral/advert.py +++ b/bluez_peripheral/advert.py @@ -1,121 +1,104 @@ -from dbus_fast.aio import MessageBus -from dbus_fast.aio.proxy_object import ProxyInterface -from dbus_fast.constants import PropertyAccess -from dbus_fast.service import ServiceInterface, method, dbus_property - -from enum import Enum, Flag, auto -from typing import Collection, Dict, Union, Callable, Optional +from typing import Collection, Dict, Callable, Optional, Union, List, Tuple import struct from uuid import UUID -from .uuid16 import UUID16 -from .util import * - - -class PacketType(Enum): - BROADCAST = 0 - """The relevant service(s) will be broadcast and do not require pairing. - """ - PERIPHERAL = 1 - """The relevant service(s) are associated with a peripheral role. - """ - +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 -class AdvertisingIncludes(Flag): - NONE = 0 - TX_POWER = auto() - """Transmission power should be included. - """ - APPEARANCE = auto() - """Device appearance number should be included. - """ - LOCAL_NAME = auto() - """The local name of this device should be included. - """ +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 -class Advertisement(ServiceInterface): +class Advertisement(BaseServiceInterface): """ 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. Args: localName: The device name to advertise. serviceUUIDs: A list of service UUIDs advertise. appearance: The appearance value to advertise. - `See the Bluetooth SIG recognised values. `_ + 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). discoverable: Whether or not the device this advert should be generally discoverable. - packet_type: The type of advertising packet requested. + packetType: The type of advertising packet requested. manufacturerData: Any manufacturer specific data to include in the advert. solicitUUIDs: Array of service UUIDs to attempt to solicit (not widely used). serviceData: Any service data elements to include. includes: Fields that can be optionally included in the advertising packet. - Only the :class:`AdvertisingIncludes.TX_POWER` flag seems to work correctly with bluez. + 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. - releaseCallback: A function to call when the advert release function is called. + release_callback: A function to call when the advert release function is called. """ _INTERFACE = "org.bluez.LEAdvertisement1" - _MANAGER_INTERFACE = "org.bluez.LEAdvertisingManager1" - - _defaultPathAdvertCount = 0 + _DEFAULT_PATH_PREFIX = "/com/spacecheese/bluez_peripheral/advert" def __init__( self, - localName: str, - serviceUUIDs: Collection[Union[str, bytes, UUID, UUID16, int]], + local_name: str, + service_uuids: Collection[UUIDLike], + *, appearance: Union[int, bytes], timeout: int = 0, discoverable: bool = True, - packetType: PacketType = PacketType.PERIPHERAL, - manufacturerData: Dict[int, bytes] = {}, - solicitUUIDs: Collection[Union[str, bytes, UUID, UUID16, int]] = [], - serviceData: Dict[Union[str, bytes, UUID, UUID16, int], bytes] = {}, + packet_type: AdvertisingPacketType = AdvertisingPacketType.PERIPHERAL, + manufacturer_data: Optional[Dict[int, bytes]] = None, + solicit_uuids: Optional[Collection[UUIDLike]] = None, + service_data: Optional[List[Tuple[UUIDLike, bytes]]] = None, includes: AdvertisingIncludes = AdvertisingIncludes.NONE, duration: int = 2, - releaseCallback: Optional[Callable[[], None]] = None, + release_callback: Optional[Callable[[], None]] = None, ): - self._type = packetType + self._type = packet_type # Convert any string uuids to uuid16. - self._serviceUUIDs = [ - UUID16.parse_uuid(uuid) for uuid in serviceUUIDs - ] - self._localName = localName + 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. - self._appearance = ( - appearance if type(appearance) is int else struct.unpack("H", appearance)[0] - ) + if isinstance(appearance, bytes): + self._appearance = struct.unpack("H", appearance)[0] + else: + self._appearance = appearance self._timeout = timeout - self._manufacturerData = {} - for key, value in manufacturerData.items(): - self._manufacturerData[key] = Variant("ay", value) + if manufacturer_data is None: + manufacturer_data = {} + self._manufacturer_data = {} + for key, value in manufacturer_data.items(): + self._manufacturer_data[key] = Variant("ay", value) - self._solicitUUIDs = [ - UUID16.parse_uuid(uuid) for uuid in solicitUUIDs - ] - - self._serviceData = {} - for key, value in serviceData.items(): - self._serviceData[key] = Variant("ay", value) + if solicit_uuids is None: + solicit_uuids = [] + self._solicit_uuids = [UUID16.parse_uuid(uuid) for uuid in solicit_uuids] + + if service_data is None: + service_data = [] + self._service_data: List[Tuple[UUID16 | UUID, Variant]] = [] + for i, dat in service_data: + self._service_data.append((UUID16.parse_uuid(i), Variant("ay", dat))) self._discoverable = discoverable self._includes = includes self._duration = duration - self.releaseCallback = releaseCallback + self._release_callback = release_callback - self._exportBus: Optional[MessageBus] = None - self._exportPath: Optional[str] = None self._adapter: Optional[Adapter] = None - super().__init__(self._INTERFACE) + super().__init__() async def register( self, bus: MessageBus, - adapter: Adapter = None, + *, path: Optional[str] = None, - ): + adapter: Optional[Adapter] = None, + ) -> None: """Register this advert with bluez to start advertising. Args: @@ -123,107 +106,81 @@ async def register( adapter: The adapter to use. path: The dbus path to use for registration. """ - # Generate a unique path name for this advert if one isn't already given. - if path is None: - path = "/com/spacecheese/bluez_peripheral/advert" + str( - Advertisement._defaultPathAdvertCount - ) - Advertisement._defaultPathAdvertCount += 1 - - self._exportBus = bus - self._exportPath = path - # Export this advert to the dbus. - bus.export(path, self) + self.export(bus, path=path) if adapter is None: adapter = await Adapter.get_first(bus) + # Get the LEAdvertisingManager1 interface for the target adapter. + interface = adapter.get_advertising_manager() + await interface.call_register_advertisement(self.export_path, {}) # type: ignore + self._adapter = adapter - # Get the LEAdvertisingManager1 interface for the target adapter. - interface = adapter._proxy.get_interface(self._MANAGER_INTERFACE) - await interface.call_register_advertisement(path, {}) + @method("Release") + def _release(self): # type: ignore + self.unexport() - async def unregister(self): + async def unregister(self) -> None: """ Unregister this advertisement from bluez to stop advertising. """ - if not self._exportBus or not self._adapter or not self._exportPath: - return - - interface = self._adapter._proxy.get_interface(self._MANAGER_INTERFACE) - - await interface.call_unregister_advertisement(self._exportPath) - - self.Release() - - @classmethod - async def GetSupportedIncludes(cls, adapter: Adapter) -> AdvertisingIncludes: - interface = adapter._proxy.get_interface(cls._MANAGER_INTERFACE) - includes = await interface.get_supported_includes() - flags = AdvertisingIncludes.NONE - for inc in includes: - inc = AdvertisingIncludes[kebab_to_shouting_snake(inc)] - # Combine all the included flags. - flags |= inc - return flags + if not self._adapter: + raise ValueError("This advertisement is not registered") - @method() - def Release(self): # type: ignore - self._exportBus.unexport(self._exportPath, self._INTERFACE) + interface = self._adapter.get_advertising_manager() - self._exportBus = None + await interface.call_unregister_advertisement(self._export_path) # type: ignore self._adapter = None - self._exportPath = None - if self.releaseCallback is not None: - self.releaseCallback() + if self._release_callback is not None: + self._release_callback() - @dbus_property(PropertyAccess.READ) - def Type(self) -> "s": # type: ignore + @dbus_property(PropertyAccess.READ, "Type") + def _get_type(self) -> "s": # type: ignore return self._type.name.lower() - @dbus_property(PropertyAccess.READ) - def ServiceUUIDs(self) -> "as": # type: ignore - return [str(id) for id in self._serviceUUIDs] + @dbus_property(PropertyAccess.READ, "ServiceUUIDs") + def _get_service_uuids(self) -> "as": # type: ignore + return [str(id) for id in self._service_uuids] - @dbus_property(PropertyAccess.READ) - def LocalName(self) -> "s": # type: ignore - return self._localName + @dbus_property(PropertyAccess.READ, "LocalName") + def _get_local_name(self) -> "s": # type: ignore + return self._local_name - @dbus_property(PropertyAccess.READ) - def Appearance(self) -> "q": # type: ignore + @dbus_property(PropertyAccess.READ, "Appearance") + def _get_appearance(self) -> "q": # type: ignore return self._appearance - @dbus_property(PropertyAccess.READ) - def Timeout(self) -> "q": # type: ignore + @dbus_property(PropertyAccess.READ, "Timeout") + def _get_timeout(self) -> "q": # type: ignore return self._timeout - @dbus_property(PropertyAccess.READ) - def ManufacturerData(self) -> "a{qv}": # type: ignore - return self._manufacturerData + @dbus_property(PropertyAccess.READ, "ManufacturerData") + def _get_manufacturer_data(self) -> "a{qv}": # type: ignore + return self._manufacturer_data - @dbus_property(PropertyAccess.READ) - def SolicitUUIDs(self) -> "as": # type: ignore - return [str(id) for id in self._solicitUUIDs] + @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) - def ServiceData(self) -> "a{sv}": # type: ignore - return dict((str(id), val) for id, val in self._serviceData.items()) + @dbus_property(PropertyAccess.READ, "ServiceData") + def _get_service_data(self) -> "a{sv}": # type: ignore + return dict((str(key), val) for key, val in self._service_data) - @dbus_property(PropertyAccess.READ) - def Discoverable(self) -> "b": # type: ignore + @dbus_property(PropertyAccess.READ, "Discoverable") + def _get_discoverable(self) -> "b": # type: ignore return self._discoverable - @dbus_property(PropertyAccess.READ) - def Includes(self) -> "as": # type: ignore + @dbus_property(PropertyAccess.READ, "Includes") + def _get_includes(self) -> "as": # type: ignore return [ - snake_to_kebab(inc.name) + _snake_to_kebab(inc.name) for inc in AdvertisingIncludes - if self._includes & inc + if self._includes & inc and inc.name is not None ] - @dbus_property(PropertyAccess.READ) - def Duration(self) -> "q": # type: ignore + @dbus_property(PropertyAccess.READ, "Duration") + def _get_duration(self) -> "q": # type: ignore return self._duration diff --git a/bluez_peripheral/agent.py b/bluez_peripheral/agent.py index 134c464..e3d0cf1 100644 --- a/bluez_peripheral/agent.py +++ b/bluez_peripheral/agent.py @@ -1,11 +1,13 @@ -from dbus_fast.service import ServiceInterface, method -from dbus_fast.aio import MessageBus -from dbus_fast import DBusError - -from typing import Awaitable, Callable +from typing import Awaitable, Callable, Optional from enum import Enum -from .util import * +from dbus_fast.service import method +from dbus_fast.aio.message_bus import MessageBus +from dbus_fast.aio.proxy_object import ProxyInterface + +from .util import _snake_to_pascal +from .error import RejectedError +from .base import BaseServiceInterface class AgentCapability(Enum): @@ -17,10 +19,10 @@ class AgentCapability(Enum): """Any pairing method can be used. """ DISPLAY_ONLY = 1 - """Device has no input but a pairing code can be displayed. + """Device has no input but a 6 digit pairing code can be displayed. """ DISPLAY_YES_NO = 2 - """Device can display and record the response to a yes/ no prompt. + """Device can display a 6 digit pairing code and record the response to a yes/ no prompt. """ KEYBOARD_ONLY = 3 """Device has no output but can be used to input a pairing code. @@ -30,9 +32,10 @@ class AgentCapability(Enum): """ -class BaseAgent(ServiceInterface): +class BaseAgent(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. Args: capability: The IO capabilities of the agent. @@ -40,58 +43,66 @@ class BaseAgent(ServiceInterface): _INTERFACE = "org.bluez.Agent1" _MANAGER_INTERFACE = "org.bluez.AgentManager1" + _DEFAULT_PATH_PREFIX = "/com/spacecheese/bluez_peripheral/agent" def __init__( self, capability: AgentCapability, ): - self._capability = capability + self._capability: AgentCapability = capability - self._path = None - super().__init__(self._INTERFACE) + super().__init__() - @method() - def Release(self): # type: ignore + @method("Release") + def _release(self): # type: ignore pass - @method() - def Cancel(self): # type: ignore + @method("Cancel") + def _cancle(self): # type: ignore pass - def _get_capability(self): - return snake_to_pascal(self._capability.name) + def _get_capability(self) -> str: + return _snake_to_pascal(self._capability.name) - async def _get_manager_interface(self, bus: MessageBus): + async def _get_manager_interface(self, bus: MessageBus) -> ProxyInterface: introspection = await bus.introspect("org.bluez", "/org/bluez") proxy = bus.get_proxy_object("org.bluez", "/org/bluez", introspection) return proxy.get_interface(self._MANAGER_INTERFACE) async def register( - self, bus: MessageBus, default: bool = True, path: str = "/com/spacecheese/ble" - ): + self, bus: MessageBus, *, path: Optional[str] = None, default: bool = True + ) -> None: """Expose this agent on the specified message bus and register it with the bluez agent manager. Args: bus: The message bus to expose the agent using. default: Whether or not the agent should be registered as default. Non-default agents will not be called to respond to incoming pairing requests. - The caller requires superuser if this is true. + The invoking process requires superuser if this is true. path: The path to expose this message bus on. """ - self._path = path - bus.export(path, self) + self.export(bus, path=path) interface = await self._get_manager_interface(bus) - await interface.call_register_agent(path, self._get_capability()) + await interface.call_register_agent(path, self._get_capability()) # type: ignore if default: - await interface.call_request_default_agent(self._path) + await interface.call_request_default_agent(self.export_path) # type: ignore - async def unregister(self, bus: MessageBus): - interface = await self._get_manager_interface(bus) - await interface.call_unregister_agent(self._path) + 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: + raise ValueError("agent has not been registered") + assert self._export_bus is not None + + interface = await self._get_manager_interface(self._export_bus) + await interface.call_unregister_agent(self.export_path) # type: ignore - bus.unexport(self._path, self._INTERFACE) + self.unexport() class TestAgent(BaseAgent): @@ -101,67 +112,55 @@ class TestAgent(BaseAgent): capability: The IO capability of the agent. """ - def __init__(self, capability: AgentCapability): - super().__init__(capability) + @method("Cancel") + def _cancel(self): # type: ignore + breakpoint() # pylint: disable=forgotten-debug-statement - @method() - def Cancel(): # type: ignore - breakpoint() - pass + @method("Release") + def _release(self): # type: ignore + breakpoint() # pylint: disable=forgotten-debug-statement - @method() - def Release(): # type: ignore - breakpoint() - pass + @method("RequestPinCode") + def _request_pin_code(self, _device: "o") -> "s": # type: ignore + breakpoint() # pylint: disable=forgotten-debug-statement - @method() - def RequestPinCode(self, device: "o") -> "s": # type: ignore - breakpoint() - pass + @method("DisplayPinCode") + def _display_pin_code(self, _device: "o", _pincode: "s"): # type: ignore + breakpoint() # pylint: disable=forgotten-debug-statement - @method() - def DisplayPinCode(self, device: "o", pincode: "s"): # type: ignore - breakpoint() - pass + @method("RequestPasskey") + def _request_passkey(self, _device: "o") -> "u": # type: ignore + breakpoint() # pylint: disable=forgotten-debug-statement - @method() - def RequestPasskey(self, device: "o") -> "u": # type: ignore - breakpoint() - pass + @method("DisplayPasskey") + def _display_passkey(self, _device: "o", _passkey: "u", _entered: "q"): # type: ignore + breakpoint() # pylint: disable=forgotten-debug-statement - @method() - def DisplayPasskey(self, device: "o", passkey: "u", entered: "q"): # type: ignore - breakpoint() - pass + @method("RequestConfirmation") + def _request_confirmation(self, _device: "o", _passkey: "u"): # type: ignore + breakpoint() # pylint: disable=forgotten-debug-statement - @method() - def RequestConfirmation(self, device: "o", passkey: "u"): # type: ignore - breakpoint() - pass + @method("RequestAuthorization") + def _request_authorization(self, _device: "o"): # type: ignore + breakpoint() # pylint: disable=forgotten-debug-statement - @method() - def RequestAuthorization(self, device: "o"): # type: ignore - breakpoint() - pass - - @method() - def AuthorizeService(self, device: "o", uuid: "s"): # type: ignore - breakpoint() - pass + @method("AuthorizeService") + def _authorize_service(self, _device: "o", _uuid: "s"): # type: ignore + breakpoint() # pylint: disable=forgotten-debug-statement class NoIoAgent(BaseAgent): """An agent with no input or output capabilities. All incoming pairing requests from all devices will be accepted unconditionally.""" - def __init__(self): + def __init__(self) -> None: super().__init__(AgentCapability.NO_INPUT_NO_OUTPUT) - @method() - def RequestAuthorization(self, device: "o"): # type: ignore + @method("RequestAuthorization") + def _request_authorization(self, device: "o"): # type: ignore pass - @method() - def AuthorizeService(self, device: "o", uuid: "s"): # type: ignore + @method("AuthorizeService") + def _authorize_service(self, device: "o", uuid: "s"): # type: ignore pass @@ -175,20 +174,20 @@ class YesNoAgent(BaseAgent): """ def __init__( - self, request_confirmation: Callable[[int], Awaitable[bool]], cancel: Callable + self, + request_confirmation: Callable[[int], Awaitable[bool]], + cancel: Callable[[], None], ): - self._request_confirmation = request_confirmation - self._cancel = cancel + self._request_confirmation_callback = request_confirmation + self._cancel_callback = cancel super().__init__(AgentCapability.DISPLAY_YES_NO) - @method() - async def RequestConfirmation(self, device: "o", passkey: "u"): # type: ignore - if not await self._request_confirmation(passkey): - raise DBusError( - "org.bluez.Error.Rejected", "The supplied passkey was rejected." - ) + @method("RequestConfirmation") + async def _request_confirmation(self, _device: "o", passkey: "u"): # type: ignore + if not await self._request_confirmation_callback(passkey): + raise RejectedError("The supplied passkey was rejected.") - @method() - def Cancel(self): # type: ignore - self._cancel() + @method("Cancel") + def _cancel(self): # type: ignore + self._cancel_callback() diff --git a/bluez_peripheral/base.py b/bluez_peripheral/base.py new file mode 100644 index 0000000..fa69d5f --- /dev/null +++ b/bluez_peripheral/base.py @@ -0,0 +1,76 @@ +from typing import Optional + +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 = "" + """ + The dbus interface name implemented by this component. + """ + + _DEFAULT_PATH_PREFIX = "/com/spacecheese/bluez_peripheral" + """ + The default prefix to use when a bus path is not specified for this interface during export. + """ + + _default_path_count: int = 0 + + _export_bus: Optional[MessageBus] = None + _export_path: Optional[str] = None + + def __init__(self) -> None: + super().__init__(name=self._INTERFACE) + + @property + def _default_export_path(self) -> str: + return self._DEFAULT_PATH_PREFIX + str(type(self)._default_path_count) + + 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. + """ + if self._INTERFACE is None: + raise NotImplementedError() + + if path is None: + path = self._default_export_path + type(self)._default_path_count += 1 + + bus.export(path, self) + self._export_path = path + self._export_bus = bus + + def unexport(self) -> None: + """ + Unexport this service interface. + """ + if self._INTERFACE is None: + raise NotImplementedError() + + if self._export_bus is None or self._export_path is None: + raise ValueError("This service interface is not exported") + + self._export_bus.unexport(self._export_path, self._INTERFACE) + self._export_path = None + self._export_bus = None + + @property + def export_path(self) -> Optional[str]: + """ + The dbus path on which this interface is currently exported. + """ + return self._export_path + + @property + def is_exported(self) -> bool: + """ + Whether this service interface is exported and visible to dbus clients. + """ + return self._export_bus is not None and self._export_path is not None diff --git a/bluez_peripheral/error.py b/bluez_peripheral/error.py new file mode 100644 index 0000000..adb94b8 --- /dev/null +++ b/bluez_peripheral/error.py @@ -0,0 +1,64 @@ +from dbus_fast import DBusError + + +class FailedError(DBusError): + """Raised when an operation failed.""" + + def __init__(self, message: str): + super().__init__("org.bluez.Error.Failed", message) + + +class InProgressError(DBusError): + """Raised when an operation was already in progress but was requested again.""" + + def __init__(self, message: str): + super().__init__("org.bluez.Error.InProgress", message) + + +class NotPermittedError(DBusError): + """Raised when a requested operation is not permitted.""" + + def __init__(self, message: str): + super().__init__("org.bluez.Error.NotPermitted", message) + + +class InvalidValueLengthError(DBusError): + """Raised when a written value was an illegal length.""" + + def __init__(self, message: str): + super().__init__("org.bluez.Error.InvalidValueLength", message) + + +class InvalidOffsetError(DBusError): + """Raised when an illegal offset is provided.""" + + def __init__(self, message: str): + super().__init__("org.bluez.Error.InvalidOffset", message) + + +class NotAuthorizedError(DBusError): + """Raised when a requester is not authorized to perform the requested operation.""" + + def __init__(self, message: str): + super().__init__("org.bluez.Error.NotAuthorized", message) + + +class NotConnectedError(DBusError): + """Raised when the operation could not be completed because the target is not connected.""" + + def __init__(self, message: str): + super().__init__("org.bluez.Error.NotConnected", message) + + +class NotSupportedError(DBusError): + """Raised when the requested operation is not supported.""" + + def __init__(self, message: str): + super().__init__("org.bluez.Error.NotSupported", message) + + +class RejectedError(DBusError): + """Raised when the pairing operation was rejected.""" + + def __init__(self, message: str): + super().__init__("org.bluez.Error.Rejected", message) diff --git a/bluez_peripheral/flags.py b/bluez_peripheral/flags.py new file mode 100644 index 0000000..626be11 --- /dev/null +++ b/bluez_peripheral/flags.py @@ -0,0 +1,27 @@ +from enum import Enum, Flag, auto + + +class AdvertisingPacketType(Enum): + """Represents the type of packet used to perform a service.""" + + BROADCAST = 0 + """The relevant service(s) will be broadcast and do not require pairing. + """ + PERIPHERAL = 1 + """The relevant service(s) are associated with a peripheral role. + """ + + +class AdvertisingIncludes(Flag): + """The fields to include in advertisements.""" + + NONE = 0 + TX_POWER = auto() + """Transmission power should be included. + """ + APPEARANCE = auto() + """Device appearance number should be included. + """ + LOCAL_NAME = auto() + """The local name of this device should be included. + """ diff --git a/bluez_peripheral/gatt/__init__.py b/bluez_peripheral/gatt/__init__.py index e69de29..ee660f7 100644 --- a/bluez_peripheral/gatt/__init__.py +++ b/bluez_peripheral/gatt/__init__.py @@ -0,0 +1,13 @@ +from .characteristic import characteristic +from .characteristic import CharacteristicFlags +from .characteristic import CharacteristicReadOptions +from .characteristic import CharacteristicWriteType +from .characteristic import CharacteristicWriteOptions + +from .descriptor import descriptor +from .descriptor import DescriptorFlags +from .descriptor import DescriptorReadOptions +from .descriptor import DescriptorWriteOptions + +from .service import Service +from .service import ServiceCollection diff --git a/bluez_peripheral/gatt/base.py b/bluez_peripheral/gatt/base.py new file mode 100644 index 0000000..31a4027 --- /dev/null +++ b/bluez_peripheral/gatt/base.py @@ -0,0 +1,232 @@ +import inspect +from abc import ABC, abstractmethod +from typing import ( + Any, + Optional, + TypeVar, + Generic, + Union, + Callable, + Awaitable, + Dict, + cast, + TYPE_CHECKING, +) + +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. +""" +WriteOptionsT = TypeVar("WriteOptionsT") +""" +The type of options supplied by a dbus WriteValue access. +""" +GetterType = Union[ + Callable[[Any, ReadOptionsT], bytes], + Callable[[Any, ReadOptionsT], Awaitable[bytes]], +] +SetterType = Union[ + Callable[[Any, bytes, WriteOptionsT], None], + Callable[[Any, bytes, WriteOptionsT], Awaitable[None]], +] + + +class ServiceAttribute(Generic[ReadOptionsT, WriteOptionsT], ABC): + """ + Base class for service components with a ReadValue and WriteValue dbus interface. + """ + + def __init__(self, *args: Any, **kwargs: Any): + super().__init__(*args, **kwargs) + + self._value = bytearray() + self._service: Optional["Service"] = None + + self._getter_func: Optional[GetterType[ReadOptionsT]] = None + self._setter_func: Optional[SetterType[WriteOptionsT]] = None + + @staticmethod + @abstractmethod + def _parse_read_options(options: Dict[str, Variant]) -> ReadOptionsT: + pass + + @staticmethod + @abstractmethod + def _parse_write_options(options: Dict[str, Variant]) -> WriteOptionsT: + pass + + @property + def service(self) -> Optional["Service"]: + """ + Gets the service that this attribute is a child of. + """ + return self._service + + @service.setter + def service(self, service: Optional["Service"]) -> None: + """ + Sets the service that this attribute is a child of (do no call directly). + """ + self._service = service + + # Decorators + def setter( + self, setter_func: SetterType[WriteOptionsT] + ) -> "ServiceAttribute[ReadOptionsT, WriteOptionsT]": + """ + Decorator for specifying a setter to be called by the ReadValue interface. + """ + self._setter_func = setter_func + return self + + def __call__( + self, + getter_func: Optional[GetterType[ReadOptionsT]] = None, + setter_func: Optional[SetterType[WriteOptionsT]] = None, + ) -> Any: + """ + Decorator for specifying a getter and setter pair to be called by the ReadValue and WriteValue interfaces. + """ + self._getter_func = getter_func + self._setter_func = setter_func + + return self + + # dbus Interface + @method("ReadValue") + async def _read_value(self, options: "a{sv}") -> "ay": # type: ignore + if self._getter_func is None: + raise NotSupportedError("No getter implemented") + + if self._service is None: + raise FailedError("No service provided") + + options = self._parse_read_options(options) + try: + if inspect.iscoroutinefunction(self._getter_func): + res = await self._getter_func(self._service, options) + else: + res = self._getter_func(self._service, options) + res = cast(bytes, res) + + self._value[options.offset :] = bytearray(res) + return res + except DBusError as e: + # Allow DBusErrors to bubble up normally. + raise e + except Exception as e: + # Report any other exception types. + print( + "Unrecognised exception type when reading descriptor value: \n" + str(e) + ) + raise e + + @method("WriteValue") + async def _write_value(self, data: "ay", options: "a{sv}"): # type: ignore + if self._setter_func is None: + raise NotSupportedError("No setter implemented") + + if self._service is None: + raise FailedError("No service provided") + + options = self._parse_write_options(options) + try: + if inspect.iscoroutinefunction(self._setter_func): + await self._setter_func(self._service, data, options) + else: + self._setter_func(self._service, data, options) + except DBusError as e: + raise e + except Exception as e: + print( + "Unrecognised exception type when writing descriptor value: \n" + str(e) + ) + raise e + self._value[options.offset :] = bytearray(data) + + @dbus_property(PropertyAccess.READ, "Value") + def _get_value(self) -> "ay": # type: ignore + return bytes(self._value) diff --git a/bluez_peripheral/gatt/characteristic.py b/bluez_peripheral/gatt/characteristic.py index 044d3f7..2466f54 100644 --- a/bluez_peripheral/gatt/characteristic.py +++ b/bluez_peripheral/gatt/characteristic.py @@ -1,16 +1,18 @@ -from dbus_fast import DBusError +from enum import Enum, Flag, auto +from typing import Optional, cast, Dict, TYPE_CHECKING + +from dbus_fast import Variant from dbus_fast.constants import PropertyAccess -from dbus_fast.service import ServiceInterface, method, dbus_property -from dbus_fast.aio import MessageBus +from dbus_fast.service import method, dbus_property -import inspect -from uuid import UUID -from enum import Enum, Flag, auto -from typing import Callable, Optional, Union, Awaitable +from .base import HierarchicalServiceInterface, ServiceAttribute +from ..uuid16 import UUIDLike, UUID16 +from ..util import _snake_to_kebab, _getattr_variant +from ..error import NotSupportedError +from .descriptor import DescriptorFlags, descriptor -from .descriptor import descriptor, DescriptorFlags -from ..uuid16 import UUID16 -from ..util import * +if TYPE_CHECKING: + from .service import Service class CharacteristicReadOptions: @@ -18,13 +20,10 @@ class CharacteristicReadOptions: Generally you can ignore these unless you have a long characteristic (eg > 48 bytes) or you have some specific authorization requirements. """ - def __init__(self): - self.__init__({}) - - def __init__(self, options): - self._offset = int(getattr_variant(options, "offset", 0)) - self._mtu = int(getattr_variant(options, "mtu", 0)) - self._device = getattr_variant(options, "device", None) + def __init__(self, options: Dict[str, Variant]): + self._offset = cast(int, _getattr_variant(options, "offset", 0)) + self._mtu = cast(int, _getattr_variant(options, "mtu", None)) + self._device = cast(str, _getattr_variant(options, "device", None)) @property def offset(self) -> int: @@ -37,7 +36,7 @@ def mtu(self) -> Optional[int]: return self._mtu @property - def device(self): + def device(self) -> Optional[str]: """The path of the remote device on the system dbus or None.""" return self._device @@ -61,54 +60,54 @@ class CharacteristicWriteOptions: Generally you can ignore these unless you have a long characteristic (eg > 48 bytes) or you have some specific authorization requirements. """ - def __init__(self): - self.__init__({}) + def __init__(self, options: Dict[str, Variant]): + t = _getattr_variant(options, "type", None) + self._type: Optional[CharacteristicWriteType] = None + if not t is None: + self._type = CharacteristicWriteType[t.upper()] - def __init__(self, options): - self._offset = int(getattr_variant(options, "offset", 0)) - type = getattr_variant(options, "type", None) - if not type is None: - type = CharacteristicWriteType[type.upper()] - self._type = type - self._mtu = int(getattr_variant(options, "mtu", 0)) - self._device = getattr_variant(options, "device", None) - self._link = getattr_variant(options, "link", None) - self._prepare_authorize = getattr_variant(options, "prepare-authorize", False) + self._offset = cast(int, _getattr_variant(options, "offset", 0)) + self._mtu = cast(int, _getattr_variant(options, "mtu", 0)) + self._device = cast(str, _getattr_variant(options, "device", None)) + self._link = cast(str, _getattr_variant(options, "link", None)) + self._prepare_authorize = cast( + bool, _getattr_variant(options, "prepare-authorize", False) + ) @property - def offset(self): + def offset(self) -> int: """A byte offset to use when writing to this characteristic.""" return self._offset @property - def type(self): + def type(self) -> Optional[CharacteristicWriteType]: """The type of write operation requested or None.""" return self._type @property - def mtu(self): + def mtu(self) -> int: """The exchanged Maximum Transfer Unit of the connection with the remote device or 0.""" return self._mtu @property - def device(self): + def device(self) -> str: """The path of the remote device on the system dbus or None.""" return self._device @property - def link(self): + def link(self) -> str: """The link type.""" return self._link @property - def prepare_authorize(self): + def prepare_authorize(self) -> bool: """True if prepare authorization request. False otherwise.""" return self._prepare_authorize class CharacteristicFlags(Flag): """Flags to use when specifying the read/ write routines that can be used when accessing the characteristic. - These are converted to `bluez flags `_ some of which are not clearly documented. + These are converted to `bluez flags `_. """ INVALID = 0 @@ -166,86 +165,82 @@ class CharacteristicFlags(Flag): """""" -class characteristic(ServiceInterface): +class characteristic( + ServiceAttribute[CharacteristicReadOptions, CharacteristicWriteOptions], + HierarchicalServiceInterface, +): # pylint: disable=invalid-name """Create a new characteristic with a specified UUID and flags. + Represents an `org.bluez.GattCharacteristic1 `_ instance. Args: - uuid: The UUID of the GATT characteristic. A list of standard ids is provided by the `Bluetooth SIG `_ + uuid: The UUID of the GATT characteristic. A list of standard ids is provided by the `Bluetooth SIG `_ flags: Flags defining the possible read/ write behavior of the attribute. See Also: - :ref:`quickstart` - - :ref:`characteristics_descriptors` + :ref:`services` """ _INTERFACE = "org.bluez.GattCharacteristic1" + _BUS_PREFIX = "char" def __init__( self, - uuid: Union[str, bytes, UUID, UUID16, int], + uuid: UUIDLike, flags: CharacteristicFlags = CharacteristicFlags.READ, ): - self.uuid = UUID16.parse_uuid(uuid) - self.getter_func = None - self.setter_func = None - self.flags = flags + super().__init__() + self.flags = flags + self._uuid = UUID16.parse_uuid(uuid) self._notify = False - self._service_path = None - self._descriptors = [] - self._service = None self._value = bytearray() - super().__init__(self._INTERFACE) + @staticmethod + def _parse_read_options( + options: Dict[str, Variant], + ) -> CharacteristicReadOptions: + return CharacteristicReadOptions(options) + + @staticmethod + def _parse_write_options( + options: Dict[str, Variant], + ) -> CharacteristicWriteOptions: + return CharacteristicWriteOptions(options) + + def add_child(self, child: HierarchicalServiceInterface) -> None: + if not isinstance(child, descriptor): + raise ValueError("characteristic child must be descriptor") + child.service = self.service + super().add_child(child) + + def add_descriptor(self, desc: "descriptor") -> None: + """ + Associated a descriptor with this characteristic. + """ + self.add_child(desc) + + @ServiceAttribute.service.setter # type: ignore[attr-defined, untyped-decorator] + def service(self, service: Optional["Service"]) -> None: + ServiceAttribute.service.fset(self, service) # type: ignore[attr-defined] # pylint: disable=no-member + for c in self._children: + assert isinstance(c, descriptor) + c.service = self.service - def changed(self, new_value: bytes): + def changed(self, new_value: bytes) -> None: """Call this function when the value of a notifiable or indicatable property changes to alert any subscribers. Args: new_value: The new value of the property to send to any subscribers. """ + self._value = bytearray(new_value) if self._notify: self.emit_properties_changed({"Value": new_value}, []) - # Decorators - def setter( - self, - setter_func: Union[ - Callable[["Service", bytes, CharacteristicWriteOptions], None], - Callable[["Service", bytes, CharacteristicWriteOptions], Awaitable[None]]], - ) -> "characteristic": - """A decorator for characteristic value setters.""" - self.setter_func = setter_func - return self - - def __call__( - self, - getter_func: Union[ - Callable[["Service", CharacteristicReadOptions], bytes], - Callable[["Service", CharacteristicReadOptions], Awaitable[bytes]] - ] = None, - setter_func: Union[ - Callable[["Service", bytes, CharacteristicWriteOptions], None], - Callable[["Service", bytes, CharacteristicWriteOptions], Awaitable[None]] - ] = None, - ) -> "characteristic": - """A decorator for characteristic value getters. - - Args: - get: The getter function for this characteristic. - set: The setter function for this characteristic. - - Returns: - This characteristic. - """ - self.getter_func = getter_func - self.setter_func = setter_func - return self - def descriptor( - self, uuid: Union[str, bytes, UUID, UUID16, int], flags: DescriptorFlags = DescriptorFlags.READ - ) -> "descriptor": + self, + uuid: UUIDLike, + flags: DescriptorFlags = DescriptorFlags.READ, + ) -> descriptor: """Create a new descriptor with the specified UUID and Flags. Args: @@ -255,151 +250,38 @@ def descriptor( # Use as a decorator for descriptors that need a getter. return descriptor(uuid, self, flags) - def _is_registered(self): - return not self._service_path is None - - def _set_service(self, service: "Service"): - self._service = service - - for desc in self._descriptors: - desc._set_service(service) - - def add_descriptor(self, desc: "descriptor"): - """Associate the specified descriptor with this characteristic. - - Args: - desc: The descriptor to associate. - - Raises: - ValueError: Raised when the containing service is currently registered and thus cannot be modified. - """ - if self._is_registered(): - raise ValueError( - "Registered characteristics cannot be modified. Please unregister the containing application." - ) - - self._descriptors.append(desc) - # Make sure that any descriptors have the correct service set at all times. - desc._set_service(self._service) - - def remove_descriptor(self, desc: "descriptor"): - """Remove the specified descriptor from this characteristic. - - Args: - desc: The descriptor to remove. - - Raises: - ValueError: Raised when the containing service is currently registered and thus cannot be modified. - """ - if self._is_registered(): - raise ValueError( - "Registered characteristics cannot be modified. Please unregister the containing application." - ) - - self._descriptors.remove(desc) - # Clear the parent service from any old descriptors. - desc._set_service(None) - - def _get_path(self) -> str: - return self._service_path + "/char{:d}".format(self._num) - - def _export(self, bus: MessageBus, service_path: str, num: int): - self._service_path = service_path - self._num = num - bus.export(self._get_path(), self) - - # Export and number each of the child descriptors. - i = 0 - for desc in self._descriptors: - desc._export(bus, self._get_path(), i) - i += 1 - - def _unexport(self, bus: MessageBus): - # Unexport this and each of the child descriptors. - bus.unexport(self._get_path(), self._INTERFACE) - for desc in self._descriptors: - desc._unexport(bus) - - self._service_path = None - - @method() - async def ReadValue(self, options: "a{sv}") -> "ay": # type: ignore - try: - res = [] - if inspect.iscoroutinefunction(self.getter_func): - res = await self.getter_func(self._service, CharacteristicReadOptions(options)) - else: - res = self.getter_func(self._service, CharacteristicReadOptions(options)) - - self._value = bytearray(res) - return bytes(self._value) - except DBusError as e: - # Allow DBusErrors to bubble up normally. - raise e - except Exception as e: - # Report any other exception types. - print( - "Unrecognised exception type when reading descriptor value: \n" + str(e) - ) - raise e - - @method() - async def WriteValue(self, data: "ay", options: "a{sv}"): # type: ignore - opts = CharacteristicWriteOptions(options) - try: - if inspect.iscoroutinefunction(self.setter_func): - await self.setter_func(self._service, data, opts) - else: - self.setter_func(self._service, data, opts) - except DBusError as e: - raise e - except Exception as e: - print( - "Unrecognised exception type when writing descriptor value: \n" + str(e) - ) - raise e - self._value[opts.offset : opts.offset + len(data)] = bytearray(data) - - @method() - def StartNotify(self): + @method("StartNotify") + def _start_notify(self) -> None: if not self.flags | CharacteristicFlags.NOTIFY: - raise DBusError( - "org.bluez.Error.NotSupported", - "The characteristic does not support notification.", - ) + raise NotSupportedError("The characteristic does not support notification.") self._notify = True - @method() - def StopNotify(self): + @method("StopNotify") + def _stop_notify(self) -> None: if not self.flags | CharacteristicFlags.NOTIFY: - raise DBusError( - "org.bluez.Error.NotSupported", - "The characteristic does not support notification.", - ) + raise NotSupportedError("The characteristic does not support notification.") self._notify = False - @dbus_property(PropertyAccess.READ) - def UUID(self) -> "s": # type: ignore - return str(self.uuid) + @dbus_property(PropertyAccess.READ, "UUID") + def _get_uuid(self) -> "s": # type: ignore + return str(self._uuid) - @dbus_property(PropertyAccess.READ) - def Service(self) -> "o": # type: ignore - return self._service_path + @dbus_property(PropertyAccess.READ, "Service") + def _get_service(self) -> "o": # type: ignore + assert self._service is not None - @dbus_property(PropertyAccess.READ) - def Flags(self) -> "as": # type: ignore + return self._service.export_path + + @dbus_property(PropertyAccess.READ, "Flags") + def _get_flags(self) -> "as": # type: ignore # Clear the extended properties flag (bluez doesn't seem to like this flag even though its in the docs). self.flags &= ~CharacteristicFlags.EXTENDED_PROPERTIES # Return a list of set string flag names. return [ - snake_to_kebab(flag.name) + _snake_to_kebab(flag.name) for flag in CharacteristicFlags - if self.flags & flag + if self.flags & flag and flag.name is not None ] - - @dbus_property(PropertyAccess.READ) - def Value(self) -> "ay": # type: ignore - return bytes(self._value) diff --git a/bluez_peripheral/gatt/descriptor.py b/bluez_peripheral/gatt/descriptor.py index 5f7256d..6d8e625 100644 --- a/bluez_peripheral/gatt/descriptor.py +++ b/bluez_peripheral/gatt/descriptor.py @@ -1,15 +1,17 @@ -from dbus_fast import DBusError -from dbus_fast.aio import MessageBus -from dbus_fast.service import ServiceInterface, method, dbus_property +from enum import Flag, auto +from typing import Callable, Union, Awaitable, Dict, TYPE_CHECKING, cast + +from dbus_fast import Variant +from dbus_fast.service import dbus_property from dbus_fast.constants import PropertyAccess -import inspect -from uuid import UUID -from enum import Flag, auto -from typing import Callable, Union, Awaitable +from .base import HierarchicalServiceInterface, ServiceAttribute +from ..uuid16 import UUID16, UUIDLike +from ..util import _snake_to_kebab, _getattr_variant -from ..uuid16 import UUID16 -from ..util import * +if TYPE_CHECKING: + from .service import Service + from .characteristic import characteristic class DescriptorReadOptions: @@ -17,25 +19,25 @@ class DescriptorReadOptions: Generally you can ignore these unless you have a long descriptor (eg > 48 bytes) or you have some specific authorization requirements. """ - def __init__(self, options): - self._offset = getattr_variant(options, "offset", 0) - self._link = getattr_variant(options, "link", None) - self._device = getattr_variant(options, "device", None) + def __init__(self, options: Dict[str, Variant]): + self._offset = _getattr_variant(options, "offset", 0) + self._link = _getattr_variant(options, "link", None) + self._device = _getattr_variant(options, "device", None) @property - def offset(self): + def offset(self) -> int: """A byte offset to use when writing to this descriptor.""" - return self._offset + return cast(int, self._offset) @property - def link(self): + def link(self) -> str: """The link type.""" - return self._link + return cast(str, self._link) @property - def device(self): + def device(self) -> str: """The path of the remote device on the system dbus or None.""" - return self._device + return cast(str, self._device) class DescriptorWriteOptions: @@ -43,36 +45,36 @@ class DescriptorWriteOptions: Generally you can ignore these unless you have a long descriptor (eg > 48 bytes) or you have some specific authorization requirements. """ - def __init__(self, options): - self._offset = getattr_variant(options, "offset", 0) - self._device = getattr_variant(options, "device", None) - self._link = getattr_variant(options, "link", None) - self._prepare_authorize = getattr_variant(options, "prepare-authorize", False) + def __init__(self, options: Dict[str, Variant]): + self._offset = _getattr_variant(options, "offset", 0) + self._device = _getattr_variant(options, "device", None) + self._link = _getattr_variant(options, "link", None) + self._prepare_authorize = _getattr_variant(options, "prepare-authorize", False) @property - def offset(self): + def offset(self) -> int: """A byte offset to use when writing to this descriptor.""" - return self._offset + return cast(int, self._offset) @property - def device(self): + def device(self) -> str: """The path of the remote device on the system dbus or None.""" - return self._device + return cast(str, self._device) @property - def link(self): + def link(self) -> str: """The link type.""" - return self._link + return cast(str, self._link) @property - def prepare_authorize(self): + def prepare_authorize(self) -> bool: """True if prepare authorization request. False otherwise.""" - return self._prepare_authorize + return cast(bool, self._prepare_authorize) class DescriptorFlags(Flag): """Flags to use when specifying the read/ write routines that can be used when accessing the descriptor. - These are converted to `bluez flags `_ some of which are not clearly documented. + These are converted to `bluez flags `_. """ INVALID = 0 @@ -98,134 +100,75 @@ class DescriptorFlags(Flag): """""" +GetterType = Union[ + Callable[["Service", DescriptorReadOptions], bytes], + Callable[["Service", DescriptorReadOptions], Awaitable[bytes]], +] +SetterType = Union[ + Callable[["Service", bytes, DescriptorWriteOptions], None], + Callable[["Service", bytes, DescriptorWriteOptions], Awaitable[None]], +] + + # Decorator for descriptor getters/ setters. -class descriptor(ServiceInterface): +class descriptor( + ServiceAttribute[DescriptorReadOptions, DescriptorWriteOptions], + HierarchicalServiceInterface, +): # pylint: disable=invalid-name """Create a new descriptor with a specified UUID and flags associated with the specified parent characteristic. + Represents an `org.bluez.GattDescriptor1 `_ instance. Args: - uuid: The UUID of this GATT descriptor. A list of standard ids is provided by the `Bluetooth SIG `_ + uuid: The UUID of this GATT descriptor. A list of standard ids is provided by the `Bluetooth SIG Assigned Numbers `_ characteristic: The parent characteristic to associate this descriptor with. flags: Flags defining the possible read/ write behavior of the attribute. See Also: - :ref:`quickstart` - - :ref:`characteristics_descriptors` + :ref:`services` """ _INTERFACE = "org.bluez.GattDescriptor1" + _BUS_PREFIX = "desc" def __init__( self, - uuid: Union[str, bytes, UUID, UUID16, int], - characteristic: "characteristic", # type: ignore + uuid: UUIDLike, + characteristic: "characteristic", flags: DescriptorFlags = DescriptorFlags.READ, ): - self.uuid = UUID16.parse_uuid(uuid) - self.getter_func = None - self.setter_func = None - self.characteristic = characteristic + super().__init__() + self.flags = flags - self._service = None + self._uuid = UUID16.parse_uuid(uuid) + self._characteristic = characteristic - self._characteristic_path = None - super().__init__(self._INTERFACE) + characteristic.add_child(self) - characteristic.add_descriptor(self) + @staticmethod + def _parse_read_options( + options: Dict[str, Variant], + ) -> DescriptorReadOptions: + return DescriptorReadOptions(options) - # Decorators - def setter( - self, - setter_func: Union[Callable[["Service", bytes, DescriptorWriteOptions], None], - Callable[["Service", bytes, DescriptorWriteOptions], Awaitable[None]]], - ) -> "descriptor": - """A decorator for descriptor value setters.""" - self.setter_func = setter_func - return setter_func - - def __call__( - self, - getter_func: Union[ - Callable[["Service", DescriptorReadOptions], bytes], - Callable[["Service", DescriptorReadOptions], Awaitable[bytes]] - ] = None, - setter_func: Union[ - Callable[["Service", bytes, DescriptorWriteOptions], None], - Callable[["Service", bytes, DescriptorWriteOptions], Awaitable[None]] - ] = None, - ) -> "descriptor": - """A decorator for characteristic value getters. - - Args: - getter_func: The getter function for this descriptor. - setter_func: The setter function for this descriptor. Defaults to None. - - Returns: - This descriptor - """ - self.getter_func = getter_func - self.setter_func = setter_func - return self - - def _set_service(self, service): - self._service = service - - # DBus - def _get_path(self) -> str: - return self._characteristic_path + "/desc{:d}".format(self._num) - - def _export(self, bus: MessageBus, characteristic_path: str, num: int): - self._characteristic_path = characteristic_path - self._num = num - bus.export(self._get_path(), self) - - def _unexport(self, bus: MessageBus): - bus.unexport(self._get_path(), self._INTERFACE) - self._characteristic_path = None - - @method() - async def ReadValue(self, options: "a{sv}") -> "ay": # type: ignore - try: - if inspect.iscoroutinefunction(self.getter_func): - return await self.getter_func(self._service, DescriptorReadOptions(options)) - else: - return self.getter_func(self._service, DescriptorReadOptions(options)) - except DBusError as e: - # Allow DBusErrors to bubble up normally. - raise e - except Exception as e: - # Report any other exception types. - print( - "Unrecognised exception type when reading descriptor value: \n" + str(e) - ) - raise e - - @method() - async def WriteValue(self, data: "ay", options: "a{sv}"): # type: ignore - try: - if inspect.iscoroutinefunction(self.setter_func): - await self.setter_func(self._service, data, DescriptorWriteOptions(options)) - else: - self.setter_func(self._service, data, DescriptorWriteOptions(options)) - except DBusError as e: - raise e - except Exception as e: - print( - "Unrecognised exception type when writing descriptor value: \n" + str(e) - ) - raise e - - @dbus_property(PropertyAccess.READ) - def UUID(self) -> "s": # type: ignore - return str(self.uuid) - - @dbus_property(PropertyAccess.READ) - def Characteristic(self) -> "o": # type: ignore - return self._characteristic_path - - @dbus_property(PropertyAccess.READ) - def Flags(self) -> "as": # type: ignore + @staticmethod + def _parse_write_options( + options: Dict[str, Variant], + ) -> DescriptorWriteOptions: + return DescriptorWriteOptions(options) + + @dbus_property(PropertyAccess.READ, "UUID") + def _get_uuid(self) -> "s": # type: ignore + return str(self._uuid) + + @dbus_property(PropertyAccess.READ, "Characteristic") + def _get_characteristic(self) -> "o": # type: ignore + return self._characteristic.export_path + + @dbus_property(PropertyAccess.READ, "Flags") + def _get_flags(self) -> "as": # type: ignore # Return a list of string flag names. return [ - snake_to_kebab(flag.name) for flag in DescriptorFlags if self.flags & flag + _snake_to_kebab(flag.name) + for flag in DescriptorFlags + if self.flags & flag and flag.name is not None ] diff --git a/bluez_peripheral/gatt/service.py b/bluez_peripheral/gatt/service.py index 789cd9d..735280e 100644 --- a/bluez_peripheral/gatt/service.py +++ b/bluez_peripheral/gatt/service.py @@ -1,120 +1,77 @@ -from dbus_fast.aio.proxy_object import ProxyObject +import inspect +from typing import List, Optional, Collection + from dbus_fast.constants import PropertyAccess -from dbus_fast.service import ServiceInterface, dbus_property -from dbus_fast.aio import MessageBus +from dbus_fast.service import dbus_property +from dbus_fast.aio.message_bus import MessageBus +from .base import HierarchicalServiceInterface from .characteristic import characteristic -from ..uuid16 import UUID16 -from ..util import * +from ..uuid16 import UUID16, UUIDLike +from ..adapter import Adapter -from uuid import UUID -from typing import Union -import inspect -# See https://git.kernel.org/pub/scm/bluetooth/bluez.git/tree/doc/gatt-api.txt -class Service(ServiceInterface): +class Service(HierarchicalServiceInterface): """Create a bluetooth service with the specified uuid. + Represents an `org.bluez.GattService1 `_ instance. Args: - uuid: The UUID of this service. A full list of recognized values is provided by the `Bluetooth SIG `_ + uuid: The UUID of this service. A full list of recognized values is provided by the `Bluetooth SIG Assigned Numbers `_ primary: True if this service is a primary service (instead of a secondary service). False otherwise. Defaults to True. includes: Any services to include in this service. Services must be registered at the time Includes is read to be included. """ _INTERFACE = "org.bluez.GattService1" + _BUS_PREFIX = "service" - def _populate(self): + def _populate(self) -> None: # Only interested in characteristic members. - members = inspect.getmembers(type(self), lambda m: type(m) is characteristic) + members = inspect.getmembers( + type(self), lambda m: isinstance(m, characteristic) + ) for _, member in members: - member._set_service(self) - # Some characteristics will occur multiple times due to different decorators. - if not member in self._characteristics: - self.add_characteristic(member) + if not member in self._children: + self.add_child(member) def __init__( self, - uuid: Union[str, bytes, UUID, UUID16, int], + uuid: UUIDLike, primary: bool = True, - includes: Collection["Service"] = [], + includes: Optional[Collection["Service"]] = None, ): - # Make sure uuid is a uuid16. + super().__init__() + self._uuid = UUID16.parse_uuid(uuid) self._primary = primary - self._characteristics = [] - self._path = None + self._path: Optional[str] = None + if includes is None: + includes = [] self._includes = includes self._populate() + self._collection: Optional[ServiceCollection] = None - super().__init__(self._INTERFACE) - - def is_registered(self) -> bool: - """Check if this service is registered with the bluez service manager. + def add_child(self, child: HierarchicalServiceInterface) -> None: + if not isinstance(child, characteristic): + raise ValueError("service child must be characteristic") + child.service = self + super().add_child(child) - Returns: - bool: True if the service is registered. False otherwise. + def add_characteristic(self, char: characteristic) -> None: """ - return not self._path is None - - def add_characteristic(self, char: characteristic): - """Add the specified characteristic to this service declaration. - - Args: - char: The characteristic to add. - - Raises: - ValueError: Raised when the service is registered with the bluez service manager and thus cannot be modified. - """ - if self.is_registered(): - raise ValueError( - "Registered services cannot be modified. Please unregister the containing application." - ) - - self._characteristics.append(char) - - def remove_characteristic(self, char: characteristic): - """Remove the specified characteristic from this service declaration. - - Args: - char: The characteristic to remove. - - Raises: - ValueError: Raised if the service is registered with the bluez service manager and thus cannot be modified. + Associated a characteristic with this service. """ - if self.is_registered(): - raise ValueError( - "Registered services cannot be modified. Please unregister the containing application." - ) - - self._characteristics.remove(char) - - def _export(self, bus: MessageBus, path: str): - self._path = path - - # Export this and number each child characteristic. - bus.export(path, self) - i = 0 - for char in self._characteristics: - char._export(bus, path, i) - i += 1 - - def _unexport(self, bus: MessageBus): - # Unexport this and every child characteristic. - bus.unexport(self._path, self._INTERFACE) - for char in self._characteristics: - char._unexport(bus) - - self._path = None + self.add_child(char) async def register( self, bus: MessageBus, - path: str = "/com/spacecheese/bluez_peripheral", - adapter: Adapter = None, - ): + *, + path: str, + adapter: Optional[Adapter] = None, + ) -> None: """Register this service as a standalone service. Using this multiple times will cause path conflicts. @@ -123,103 +80,67 @@ async def register( path: The base dbus path to export this service to. adapter: The adapter that will provide this service or None to select the first adapter. """ - self._collection = ServiceCollection([self]) - await self._collection.register(bus, path, adapter) + collection = ServiceCollection([self]) + await collection.register(bus, path=path, adapter=adapter) + self._collection = collection - async def unregister(self): + async def unregister(self) -> None: """Unregister this service. You may only use this if the service was registered using :class:`Service.register()` """ - collection = getattr(self, "_collection", None) - - if collection is None: + if self._collection is None: return - await collection.unregister() + await self._collection.unregister() + self._collection = None - @dbus_property(PropertyAccess.READ) - def UUID(self) -> "s": # type: ignore + @dbus_property(PropertyAccess.READ, "UUID") + def _get_uuid(self) -> "s": # type: ignore return str(self._uuid) - @dbus_property(PropertyAccess.READ) - def Primary(self) -> "b": # type: ignore + @dbus_property(PropertyAccess.READ, "Primary") + def _get_primary(self) -> "b": # type: ignore return self._primary - @dbus_property(PropertyAccess.READ) - def Includes(self) -> "ao": # type: ignore + @dbus_property(PropertyAccess.READ, "Includes") + def _get_includes(self) -> "ao": # type: ignore paths = [] for service in self._includes: - if not service._path is None: - paths.append(service._path) + if not service.export_path is None: + paths.append(service.export_path) - paths.append(self._path) + if not self.export_path is None: + paths.append(self.export_path) return paths -class ServiceCollection: +class ServiceCollection(HierarchicalServiceInterface): """A collection of services that are registered with the bluez GATT manager as a group.""" - _MANAGER_INTERFACE = "org.bluez.GattManager1" + _INTERFACE = "org.spacecheese.ServiceCollection1" - def _init(self, services: Collection[Service]): - self._path = None - self._adapter = None - self._services = services - - def __init__(self, services: Collection[Service] = []): + def __init__(self, services: Optional[List[Service]] = None): """Create a service collection populated with the specified list of services. Args: services: The services to provide. """ - self._path = None - self._adapter = None - self._services = services - - def add_service(self, service: Service): - """Add the specified service to this service collection. - - Args: - service: The service to add. - """ - if self.is_registered(): - raise ValueError( - "You may not modify a registered service or service collection." - ) - - self._services.append(service) - - def remove_service(self, service: Service): - """Remove the specified service from this collection. - - Args: - service: The service to remove. - """ - if self.is_registered(): - raise ValueError( - "You may not modify a registered service or service collection." - ) + super().__init__() + if services is not None: + for s in services: + self.add_child(s) - self._services.remove(service) - - async def _get_manager_interface(self): - return self._adapter._proxy.get_interface(self._MANAGER_INTERFACE) - - def is_registered(self) -> bool: - """Check if this service collection is registered with the bluez service manager. - - Returns: - True if the service is registered. False otherwise. - """ - return not self._path is None + self._bus: Optional[MessageBus] = None + self._adapter: Optional[Adapter] = None async def register( self, bus: MessageBus, + *, path: str = "/com/spacecheese/bluez_peripheral", - adapter: Adapter = None, - ): + adapter: Optional[Adapter] = None, + ) -> None: """Register this collection of services with the bluez service manager. Services and service collections that are registered may not be modified until they are unregistered. @@ -229,42 +150,26 @@ async def register( Each service will be an automatically numbered child of this base. adapter: The adapter that should be used to deliver the collection of services. """ - if self.is_registered(): - return - - self._bus = bus - self._path = path self._adapter = await Adapter.get_first(bus) if adapter is None else adapter - manager = await self._get_manager_interface() - - # Number and export each service. - i = 0 - for service in self._services: - service._export(bus, self._path + "/service{:d}".format(i)) - i += 1 + self.export(bus, path=path) - class EmptyServiceInterface(ServiceInterface): - pass + manager = self._adapter.get_gatt_manager() + await manager.call_register_application(self.export_path, {}) # type: ignore - # Export an empty interface on the root path so that bluez has an object manager to find. - bus.export(self._path, EmptyServiceInterface(self._path.replace("/", ".")[1:])) - await manager.call_register_application(self._path, {}) + self._bus = bus - async def unregister(self): + async def unregister(self) -> None: """Unregister this service using the bluez service manager.""" - if not self.is_registered(): + if not self.is_exported: return + assert self._bus is not None + assert self._adapter is not None - manager = await self._get_manager_interface() - - await manager.call_unregister_application(self._path) + manager = self._adapter.get_gatt_manager() + await manager.call_unregister_application(self.export_path) # type: ignore - for service in self._services: - service._unexport(self._bus) - # Unexport the root object manager. - self._bus.unexport(self._path) + self.unexport() - self._path = None self._adapter = None self._bus = None diff --git a/bluez_peripheral/util.py b/bluez_peripheral/util.py index 02a1455..a19b5cc 100644 --- a/bluez_peripheral/util.py +++ b/bluez_peripheral/util.py @@ -1,29 +1,26 @@ -from dbus_fast import Variant, BusType -from dbus_fast.aio import MessageBus -from dbus_fast.errors import InvalidIntrospectionError, InterfaceNotFoundError +from typing import Any, Dict -from typing import Any, Collection, Dict +from dbus_fast import Variant, DBusError +from dbus_fast.constants import BusType +from dbus_fast.aio.message_bus import MessageBus -from dbus_fast.aio.proxy_object import ProxyObject -from dbus_fast.errors import DBusError +def _getattr_variant(obj: Dict[str, Variant], key: str, default: Any) -> Any: + if key in obj: + return obj[key].value -def getattr_variant(object: Dict[str, Variant], key: str, default: Any): - if key in object: - return object[key].value - else: - return default + return default -def snake_to_kebab(s: str) -> str: +def _snake_to_kebab(s: str) -> str: return s.lower().replace("_", "-") -def kebab_to_shouting_snake(s: str) -> str: +def _kebab_to_shouting_snake(s: str) -> str: return s.upper().replace("-", "_") -def snake_to_pascal(s: str) -> str: +def _snake_to_pascal(s: str) -> str: split = s.split("_") pascal = "" @@ -52,120 +49,3 @@ async def is_bluez_available(bus: MessageBus) -> bool: return True except DBusError: return False - - -class Adapter: - """A bluetooth adapter.""" - - _INTERFACE = "org.bluez.Adapter1" - _adapter_interface = None - - def __init__(self, proxy: ProxyObject): - self._proxy = proxy - self._adapter_interface = proxy.get_interface(self._INTERFACE) - - async def get_address(self) -> str: - """Read the bluetooth address of this device.""" - return await self._adapter_interface.get_address() - - async def get_name(self) -> str: - """Read the bluetooth hostname of this system.""" - return await self._adapter_interface.get_name() - - async def get_alias(self) -> str: - """The user friendly name of the device.""" - return await self._adapter_interface.get_alias() - - async def set_alias(self, val: str): - """Set the user friendly name for this device. - Changing the device hostname directly is preferred. - Writing an empty string will result in the alias resetting to the device hostname. - """ - await self._adapter_interface.set_alias(val) - - async def get_powered(self) -> bool: - """Indicates if the adapter is on or off.""" - return await self._adapter_interface.get_powered() - - async def set_powered(self, val: bool): - """Turn this adapter on or off.""" - await self._adapter_interface.set_powered(val) - - async def get_pairable(self) -> bool: - """Indicates if the adapter is in pairable state or not.""" - return await self._adapter_interface.get_pairable() - - async def set_pairable(self, val: bool): - """Switch an adapter to pairable or non-pairable.""" - await self._adapter_interface.set_pairable(val) - - async def get_pairable_timeout(self) -> int: - """Get the current pairable timeout""" - return await self._adapter_interface.get_pairable_timeout() - - async def set_pairable_timeout(self, val: int): - """Set the pairable timeout in seconds. A value of zero means that the - timeout is disabled and it will stay in pairable mode forever.""" - await self._adapter_interface.set_pairable_timeout(val) - - async def get_discoverable(self) -> bool: - """Indicates if the adapter is discoverable.""" - return await self._adapter_interface.get_discoverable() - - async def set_discoverable(self, val: bool): - """Switch an adapter to discoverable or non-discoverable to either make it - visible or hide it.""" - await self._adapter_interface.set_discoverable(val) - - async def get_discoverable_timeout(self) -> int: - """Get the current discoverable timeout""" - return await self._adapter_interface.get_discoverable_timeout() - - async def set_discoverable_timeout(self, val: int): - """Set the discoverable timeout in seconds. A value of zero means that the - timeout is disabled and it will stay in discoverable mode forever.""" - await self._adapter_interface.set_discoverable_timeout(val) - - @classmethod - async def get_all(cls, bus: MessageBus) -> Collection["Adapter"]: - """Get a list of available Bluetooth adapters. - - Args: - bus: The message bus used to query bluez. - - Returns: - A list of available bluetooth adapters. - """ - adapter_nodes = (await bus.introspect("org.bluez", "/org/bluez")).nodes - - adapters = [] - for node in adapter_nodes: - try: - introspection = await bus.introspect("org.bluez", "/org/bluez/" + node.name) - proxy = bus.get_proxy_object( - "org.bluez", "/org/bluez/" + node.name, introspection - ) - adapters.append(cls(proxy)) - except (InvalidIntrospectionError, InterfaceNotFoundError): - pass - - return adapters - - @classmethod - async def get_first(cls, bus: MessageBus) -> "Adapter": - """Gets the first adapter listed by bluez. - - Args: - bus: The bus to use for adapter discovery. - - Raises: - ValueError: Raised when no bluetooth adapters are available. - - Returns: - The resulting adapter. - """ - adapters = await cls.get_all(bus) - if len(adapters) > 0: - return adapters[0] - else: - raise ValueError("No bluetooth adapters could be found.") diff --git a/bluez_peripheral/uuid16.py b/bluez_peripheral/uuid16.py index da2a937..20700fd 100644 --- a/bluez_peripheral/uuid16.py +++ b/bluez_peripheral/uuid16.py @@ -1,22 +1,23 @@ import builtins - +from typing import Union, Optional from uuid import UUID -from typing import Optional, Union + +UUIDLike = Union[str, bytes, UUID, "UUID16", int] class UUID16: """A container for BLE uuid16 values. Args: - hex (Optional[str]): A hexadecimal representation of a uuid16 or compatible uuid128. - bytes (Optional[bytes]): A 16-bit or 128-bit value representing a uuid16 or compatible uuid128. - int (Optional[int]): A numeric value representing a uuid16 (if < 2^16) or compatible uuid128. - uuid (Optional[UUID]): A compatible uuid128. + hex: A hexadecimal representation of a uuid16 or compatible uuid128. + bytes: A 16-bit or 128-bit value representing a uuid16 or compatible uuid128. + int: A numeric value representing a uuid16 (if < 2^16) or compatible uuid128. + uuid: A compatible uuid128. """ # 0000****--0000-1000-8000-00805F9B34FB _FIELDS = (0x00000000, 0x0000, 0x1000, 0x80, 0x00, 0x00805F9B34FB) - _uuid: UUID = None + _uuid: UUID def __init__( self, @@ -24,9 +25,11 @@ def __init__( bytes: Optional[bytes] = None, int: Optional[int] = None, uuid: Optional[UUID] = None, - ): + ): # pylint: disable=redefined-builtin if [hex, bytes, int, uuid].count(None) != 3: - raise TypeError("one of the hex, bytes or int arguments must be given") + raise TypeError( + "exactly one of the hex, bytes or int arguments must be given" + ) time_low = None @@ -36,112 +39,101 @@ def __init__( time_low = builtins.int(hex, 16) else: uuid = UUID(hex) - - if bytes is not None: + elif bytes is not None: if len(bytes) == 2: time_low = builtins.int.from_bytes(bytes, byteorder="big") elif len(bytes) == 16: uuid = UUID(bytes=bytes) else: - raise ValueError("bytes must be either 2 or 16-bytes long") - - if int is not None: - if int < 2**16 and int >= 0: + raise ValueError("uuid bytes must be exactly either 2 or 16 bytes long") + elif int is not None: + if 0 <= int < 2**16: time_low = int else: uuid = UUID(int=int) - if time_low is not None: - fields = [f for f in self._FIELDS] - fields[0] = time_low - self._uuid = UUID(fields=fields) + self._uuid = UUID(fields=(time_low,) + self._FIELDS[1:]) else: + assert uuid is not None if UUID16.is_in_range(uuid): self._uuid = uuid else: - raise ValueError( - "the supplied uuid128 was out of range" - ) + raise ValueError("the supplied uuid128 was out of range") @classmethod def is_in_range(cls, uuid: UUID) -> bool: """Determines if a supplied uuid128 is in the allowed uuid16 range. Returns: - bool: True if the uuid is in range, False otherwise. + True if the uuid is in range, False otherwise. """ if uuid.fields[0] & 0xFFFF0000 != cls._FIELDS[0]: return False - for i in range(1, 5): - if uuid.fields[i] != cls._FIELDS[i]: - return False - - return True + return uuid.fields[1:5] == cls._FIELDS[1:5] @classmethod - def parse_uuid(cls, uuid: Union[str, bytes, int, UUID]) -> Union[UUID, "UUID16"]: - if type(uuid) is UUID: + def parse_uuid(cls, uuid: UUIDLike) -> Union[UUID, "UUID16"]: + """Attempts to parse a supplied UUID representation to a UUID16. + If the resulting value is out of range a UUID128 will be returned instead.""" + if isinstance(uuid, UUID16): + return uuid + if isinstance(uuid, UUID): if cls.is_in_range(uuid): return UUID16(uuid=uuid) return uuid - - if type(uuid) is str: + if isinstance(uuid, str): try: return UUID16(hex=uuid) - except: + except ValueError: return UUID(hex=uuid) - - if type(uuid) is bytes: + if isinstance(uuid, builtins.bytes): try: return UUID16(bytes=uuid) - except: + except ValueError: return UUID(bytes=uuid) - - if type(uuid) is int: + if isinstance(uuid, builtins.int): try: return UUID16(int=uuid) - except: + except ValueError: return UUID(int=uuid) + raise ValueError("uuid is not a supported type") + @property def uuid(self) -> UUID: - """Returns the full uuid128 corresponding to this uuid16. - """ + """Returns the full uuid128 corresponding to this uuid16.""" return self._uuid @property - def int(self) -> int: - """Returns the 16-bit integer value corresponding to this uuid16. - """ + def int(self) -> builtins.int: + """Returns the 16-bit integer value corresponding to this uuid16.""" return self._uuid.time_low & 0xFFFF @property def bytes(self) -> bytes: - """Returns a two byte value corresponding to this uuid16. - """ + """Returns a two byte value corresponding to this uuid16.""" return self.int.to_bytes(2, byteorder="big") @property def hex(self) -> str: - """Returns a 4 character hex string representing this uuid16. - """ + """Returns a 4 character hex string representing this uuid16.""" return self.bytes.hex() def __eq__(self, __o: object) -> bool: - if type(__o) is UUID16: + if isinstance(__o, UUID16): return self._uuid == __o._uuid - elif type(__o) is UUID: + if isinstance(__o, UUID): return self._uuid == __o - else: - return False + + return False def __ne__(self, __o: object) -> bool: return not self.__eq__(__o) - def __str__(self): + def __str__(self) -> str: return self.hex - def __hash__(self): + def __hash__(self) -> builtins.int: return hash(self.uuid) diff --git a/docs/Makefile b/docs/Makefile index 8aeeca5..fe1383d 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -3,16 +3,32 @@ # You can set these variables from the command line, and also # from the environment for the first two. -SPHINXOPTS ?= -b spelling -n -j auto +SPHINXOPTS ?= -j auto -W SPHINXBUILD ?= sphinx-build SOURCEDIR = source BUILDDIR = build +SPHINXDOCOPTS ?= -e +SPHINXDOC ?= sphinx-apidoc +APIDOCDIR = source/ref +MODPATH = ../bluez_peripheral + # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -.PHONY: help Makefile +apidoc: + rm -r "$(APIDOCDIR)" + @$(SPHINXDOC) -o "$(APIDOCDIR)" "$(MODPATH)" $(SPHINXDOCOPTS) + rm "$(APIDOCDIR)/modules.rst" + +.PHONY: help Makefile apidoc spelling + +spelling: + @$(SPHINXBUILD) -b spelling -W "$(SOURCEDIR)" "$(BUILDDIR)" + +autobuild: + sphinx-autobuild "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). diff --git a/docs/requirements.txt b/docs/requirements.txt index a514539..22b4b6c 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,5 +1,7 @@ dbus_fast sphinx -sphinx_rtd_theme +sphinx-inline-tabs sphinxcontrib-spelling -m2r2 \ No newline at end of file +furo +sphinx_mdinclude +interrogate \ No newline at end of file diff --git a/docs/source/advertising.rst b/docs/source/advertising.rst new file mode 100644 index 0000000..3cd221a --- /dev/null +++ b/docs/source/advertising.rst @@ -0,0 +1,41 @@ +Advertising Services +==================== + +In BLE advertising is required for other devices to discover services that surrounding peripherals offer. To allow multiple adverts to operate simultaneously advertising is time-division multiplexed. + +.. hint:: + The "message bus" referred to here is a :py:class:`dbus_fast.aio.MessageBus`. + +A minimal :py:class:`advert` requires: + +* A name for the device transmitting the advert (the ``localName``). +* A collection of service UUIDs. +* An appearance describing how the device should appear to a user (see `Bluetooth SIG Assigned Numbers `_). +* A timeout specifying roughly how long the advert should be broadcast for (roughly since this is complicated by advert multiplexing). +* A reference to a specific bluetooth :py:class:`adapter` (since unlike with services, adverts are per-adapter). + +.. testcode:: + + from bluez_peripheral import get_message_bus, Advertisement + from bluez_peripheral.adapter import Adapter + + async def main(): + adapter = await Adapter.get_first(bus) + + # "Heart Monitor" is the name the user will be shown for this device. + # "180D" is the uuid16 for a heart rate service. + # 0x0340 is the appearance code for a generic heart rate sensor. + # 60 is the time (in seconds) until the advert stops. + advert = Advertisement("Heart Monitor", ["180D"], appearance=0x0340, timeout=60) + await advert.register(bus, adapter) + + if __name__ == "__main__": + asyncio.run(main()) + +.. TODO: Advertising includes +.. TODO: Advertisable characteristics + +.. seealso:: + + Bluez Documentation + `Advertising API `_ diff --git a/docs/source/characteristics_descriptors.rst b/docs/source/characteristics_descriptors.rst deleted file mode 100644 index 9972a14..0000000 --- a/docs/source/characteristics_descriptors.rst +++ /dev/null @@ -1,48 +0,0 @@ -.. _characteristics_descriptors: - -Characteristics/ Descriptors -============================ - -You should read the :doc:`quickstart guide ` before reading this. -If you were looking for a characteristic reference you can find it :doc:`here `. - -Characteristics are designed to work the same way as the built-in property class. -A list of Bluetooth SIG recognized characteristics, services and descriptors is -`available on their website `_. -These are recommended over creating a custom characteristic where possible since other devices may already support them. - -Exceptions ----------- - -Internally bluez_peripheral uses `dbus_fast `_ to communicate with bluez. -If you find that a characteristic or descriptor read or write access is invalid or not permitted for some reason you should raise a :py:class:`dbus_fast.DBusError` with a type string recognized by bluez. -The `bluez docs `_ include specific lists -of each access operation (the characteristic getters and setters map to ReadValue/ WriteValue calls) however in general you may use the following types:: - - org.bluez.Error.Failed - org.bluez.Error.InProgress - org.bluez.Error.NotPermitted - org.bluez.Error.InvalidValueLength - org.bluez.Error.NotAuthorized - org.bluez.Error.NotSupported - -Exceptions that are not a :py:class:`dbus_fast.DBusError` will still be returned to the caller but will result in a warning being printed to the terminal to aid in debugging. - -Read/ Write Options -------------------- - -bluez_peripheral does not check the validity of these options and only assigns them default values for convenience. -Normally you can ignore these options however one notable exception to this is when the size of you characteristic exceeds the negotiated Minimum Transfer Unit (MTU) of your connection with the remote device. -In this case bluez will read your characteristic multiple times (using the offset option to break it up). -This can be a problem if your characteristic exceeds 48 bytes in length (this is the minimum allowed by the Bluetooth specification) although in general -most devices have a larger default MTU (on the Raspberry Pi this appears to be 128 bytes). - -You may also choose to use these options to enforce authentication/ authorization. -The behavior of these options is untested so if you experiment with these or have experience working with them a GitHub issue would be greatly appreciated. - -Undocumented Flags ------------------- - -Some operation mode flags are currently undocumented in the reference. -The behavior of these flags is not clearly defined by the bluez documentation and the terminology used differs slightly from that in the Bluetooth Specifications. -If you have any insight into the functionality of these flags a GitHub issue would be greatly appreciated. \ No newline at end of file diff --git a/docs/source/conf.py b/docs/source/conf.py index de99ba0..7901a31 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -12,10 +12,13 @@ # import os import sys +import inspect +import importlib +import subprocess +from pathlib import Path from datetime import datetime -sys.path.insert(0, os.path.abspath(os.path.dirname(__file__) + '/../../')) - +sys.path.insert(0, os.path.abspath(os.path.dirname(__file__) + "/../../")) # -- Project information ----------------------------------------------------- @@ -28,7 +31,16 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = ["sphinxcontrib.spelling", "sphinx.ext.autodoc", "sphinx.ext.intersphinx", "sphinx.ext.napoleon"] +extensions = [ + "sphinxcontrib.spelling", + "sphinx.ext.autodoc", + "sphinx.ext.intersphinx", + "sphinx.ext.napoleon", + "sphinx.ext.linkcode", + "sphinx.ext.doctest", + "sphinx_inline_tabs", + "sphinx_mdinclude", +] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] @@ -38,6 +50,69 @@ # This pattern also affects html_static_path and html_extra_path. exclude_patterns = [] +nitpicky = True + +autodoc_typehints = "description" + +linkcheck_timeout = 10 + +# -- Linkcode ---------------------------------------------------------------- +def _get_git_ref(): + try: + ref = ( + subprocess.check_output( + ["git", "describe", "--tags", "--exact-match"], + stderr=subprocess.DEVNULL, + ) + .decode() + .strip() + ) + except subprocess.CalledProcessError: + ref = ( + subprocess.check_output( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], stderr=subprocess.DEVNULL + ) + .decode() + .strip() + ) + return ref + + +GIT_REF = _get_git_ref() + + +def linkcode_resolve(domain, info): + if domain != "py": + return None + + modname = info.get("module") + fullname = info.get("fullname") + if not modname: + return None + + obj = importlib.import_module(modname) + for part in fullname.split("."): + try: + obj = getattr(obj, part) + except AttributeError: + return None + + try: + src = inspect.getsourcefile(obj) + lines, lineno = inspect.getsourcelines(obj) + except Exception: + return None + + if src is None: + return None + + src = Path(src).relative_to(Path(__file__).parents[2]) + + return ( + f"https://github.com/spacecheese/bluez_peripheral/" + f"blob/{GIT_REF}/{src.as_posix()}#L{lineno}-L{lineno+len(lines)-1}" + ) + # -- Napoleon ---------------------------------------------------------------- napoleon_numpy_docstring = False @@ -45,8 +120,6 @@ intersphinx_mapping = { "python": ("https://docs.python.org/3", None), "dbus_fast": ("https://dbus-fast.readthedocs.io/en/latest/", None), - # Add a backup inv to fix mapping of dbus_fast.aio.proxy_object.ProxyObject and dbus_fast.aio.message_bus.MessageBus - "dbus_fast_alias": (os.path.abspath(os.path.dirname(__file__)), "dbus_fast.inv") } @@ -55,7 +128,7 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = "sphinx_rtd_theme" +html_theme = "furo" # # Add any paths that contain custom static files (such as style sheets) here, # # relative to this directory. They are copied after the builtin static files, diff --git a/docs/source/index.rst b/docs/source/index.rst index aa1ce18..35711c0 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,107 +1,15 @@ -.. _quickstart: - -bluez-peripheral Quickstart -=========================== - -This documentation assumes that you are vaguely familiar with the structure of a BLE GATT service (See the `README `_). -In bluez-peripheral classes are used to define services. -Your services should contain methods decorated with the characteristic and descriptor classes. -You can use these decorators the same way as the built-in `property class `_. - -.. code-block:: python - - from bluez_peripheral.gatt.service import Service - from bluez_peripheral.gatt.characteristic import characteristic, CharacteristicFlags as CharFlags - from bluez_peripheral.gatt.descriptor import descriptor, DescriptorFlags as DescFlags - - # Define a service like so. - class MyService(Service): - def __init__(self): - self._some_value = None - # Call the super constructor to set the UUID. - super().__init__("BEEF", True) - - # Use the characteristic decorator to define your own characteristics. - # Set the allowed access methods using the characteristic flags. - @characteristic("BEF0", CharFlags.READ) - def my_readonly_characteristic(self, options): - # Characteristics need to return bytes. - return bytes("Hello World!", "utf-8") - - # This is a write only characteristic. - @characteristic("BEF1", CharFlags.WRITE) - def my_writeonly_characteristic(self, options): - # This function is a placeholder. - # In Python 3.9+ you don't need this function (See PEP 614) - pass - - # In Python 3.9+: - # @characteristic("BEF1", CharFlags.WRITE).setter - # Define a characteristic writing function like so. - @my_writeonly_characteristic.setter - def my_writeonly_characteristic(self, value, options): - # Your characteristics will need to handle bytes. - self._some_value = value - - # Associate a descriptor with your characteristic like so. - # Descriptors have largely the same flags available as characteristics. - @descriptor("BEF2", my_readonly_characteristic, DescFlags.READ) - # Alternatively you could write this: - # @my_writeonly_characteristic.descriptor("BEF2", DescFlags.READ) - def my_readonly_descriptors(self, options): - # Descriptors also need to handle bytes. - return bytes("This characteristic is completely pointless!", "utf-8") - -Once you've defined your service you need to add it to a service collection which can then be registered with bluez. - -.. code-block:: python - - from bluez_peripheral.util import get_message_bus - - # This needs running in an awaitable context. - bus = await get_message_bus() - - # Instance and register your service. - service = MyService() - await service.register(bus) - -At this point your service would work but without anything knowing it exists you can't test it. -You need to advertise your service to allow other devices to connect to it. - -.. code-block:: python - - from bluez_peripheral.advert import Advertisement - - my_service_ids = ["BEEF"] # The services that we're advertising. - my_appearance = 0 # The appearance of my service. - # See https://specificationrefs.bluetooth.com/assigned-values/Appearance%20Values.pdf - my_timeout = 60 # Advert should last 60 seconds before ending (assuming other local - # services aren't being advertised). - advert = Advertisement("My Device Name", my_service_ids, my_appearance, my_timeout) - - -At this point you'll be be able to connect to your device using a bluetooth tester -and see your service (`nRF Connect for Mobile `_ is good for basic testing). - - -.. toctree:: - :maxdepth: 1 - :caption: Contents: - - characteristics_descriptors - pairing +.. mdinclude:: ../../README.md +Contents +======== .. toctree:: - :maxdepth: 4 - :caption: Reference: - - ref/bluez_peripheral - - - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` + :includehidden: + :maxdepth: 1 + + Home + services + advertising + pairing + API Reference + Github + PyPi \ No newline at end of file diff --git a/docs/source/pairing.rst b/docs/source/pairing.rst index 09cee71..b80c5e2 100644 --- a/docs/source/pairing.rst +++ b/docs/source/pairing.rst @@ -1,71 +1,149 @@ Pairing ======= -Before pairing devices will exchange their input/ output capabilities in order to select a pairing approach. -In some situations **out of band (OOB)** data (using alternative communication channels like NFC) may also be used in pairing though this is currently **unsupported**. -Pairing can be quite difficult to debug. -In between attempts you should make sure to fully remove both the peripheral from the host and the host from the peripheral. -Using bluez you can list paired devices using ``bluetoothctl list`` then remove any unwanted devices using ``bluetoothctl remove ``. +Pairing requires that the host and client exchange encryption keys in order to communicate securely. Agents ------ -An agent is a program that bluez uses to interface with the user during pairing. -bluez uses agents to determine what pairing mode should be used based on their indicated input/ output capabilities. +.. TODO: Investigate OOB pairing. -Selecting an Agent ------------------- +.. hint:: + Some devices use "out of band" data (for example communicated using NFC) to verify pairing. This approach is currently **unsupported** by bluez_peripheral agents. -There are three sources of potential agents: +.. warning:: + By default bluez_peripheral agents are registered as default (see the :py:func:`~bluez_peripheral.agent.BaseAgent.register` function ``default`` argument). This generally requires superuser permission. If an agent is not registered as default it will not be called in response to inbound pairing requests (only those outbound from the source program). -* Use a :ref:`bluez built in agent ` (Not recommended) -* Use a :ref:`bluez_peripheral built in agent ` (NoInputNoOutput or YesNoInput only) -* Use a :ref:`custom agent ` +An agent is a program used to authorize and secure a pairing. Each agent has associated Input/ Output capabilities (see :py:class:`~bluez_peripheral.agent.AgentCapability`) which are exchanged at the start of the pairing process. Devices with limited IO capabilities cannot support authentication which prevents access to attributes with certain flags (see :ref:`pairing-io`). -.. _bluez agent: +Using an Agent +-------------- -bluez Agents ------------- +.. hint:: + The "message bus" referred to here is a :py:class:`dbus_fast.aio.MessageBus`. -bluez supports a number of built in agents. -You can select an agent with given capability by using the following command in your terminal:: +There are three potential sources of agents: - bluetoothctl agent +.. tab:: bluez -This approach is not recommended since the bluez agents seem to be slightly unreliable. + Bluez supports a number of built in agents. You can select an agent with given capability by using the following command in your terminal: -.. _bluez_peripheral agent: + .. code-block:: shell -bluez_peripheral Agents ------------------------ + bluetoothctl agent -Using a bluez_peripheral agent is the preferred approach where possible. The README makes use of a bluez agent using the following code: + These agents are unreliable but the simplest to set up. -.. code-block:: python +.. tab:: bluez_peripheral - from bluez_peripheral.agent import NoIoAgent + bluez_peripheral includes built in :py:class:`~bluez_peripheral.agent.NoIoAgent` and :py:class:`~bluez_peripheral.agent.YesNoAgent` agents which can be used as below: - agent = NoIoAgent() - await agent.register(bus) + .. testcode:: -Note that if using a bluez_peripheral or custom agent your program must be run with root permissions. -Without root permission you do not have permission to set the default agent which is required to intercept incoming pairing requests. + from bluez_peripheral import get_message_bus + from bluez_peripheral.agent import NoIoAgent -.. _custom agent: + async def agent_builtin(): + bus = await get_message_bus() -Custom Agents -------------- + agent = NoIoAgent() + # By default agents are registered as default. + await agent.register(bus, default=True) -You can write a custom agent by sub-classing the :class:`bluez_peripheral.agent.BaseAgent` in the same way as the built in agents. -The recommended approach is first to instance and register the :class:`bluez_peripheral.agent.TestAgent` with your chosen capability setting. + # OR -.. code-block:: python + def accept_pairing(code: int) -> bool: + # TODO: Show the user the code and ask if it's correct. + # if (correct): + # return True + # else: + # return False - from bluez_peripheral.agent import TestAgent + return True - agent = TestAgent() - await agent.register(bus) + def cancel_pairing(): + # TODO: Notify the user that pairing was cancelled by the other device. + pass -Once you've registered this agent, assuming that you are broadcasting a valid advertisement, you may connect to your peripheral from another device. -During the pairing process the test agent will encounter breakpoints whenever one of its methods is called. -To implement your agent you should check which methods are called during the pairing process then implement them as required using the test agent as a template. + async def agent_custom(): + agent = YesNoAgent(accept_pairing, cancel_pairing) + await agent.register(bus) + + if __name__ == "__main__": + asyncio.run(agent_builtin()) + asyncio.run(agent_custom()) + +.. tab:: Custom Agents (Recommended) + + Support for custom agents in bluez_peripheral is limited. The recommended approach is to inherit the :class:`bluez_peripheral.agent.BaseAgent` in the same way as the built in agents. The :class:`bluez_peripheral.agent.TestAgent` can be instanced as shown for testing: + + .. testcode:: + + from bluez_peripheral import get_message_bus + from bluez_peripheral.agent import TestAgent + + async def main(): + bus = await get_message_bus() + + agent = TestAgent() + await agent.register(bus) + + if __name__ == "__main__": + asyncio.run(main()) + + The test agent will then fire :py:func:`breakpoints` when each of the interfaces functions is called during the pairing process. Note that when extending this class the type hints as used are important (see :doc:`dbus_fast services`). + +Debugging +--------- +Pairing can be quite difficult to debug. In between testing attempts ensure that the peripheral has been unpaired from the host **and** vice versa. Using linux you can list paired devices using ``bluetoothctl list`` then remove any unwanted devices using ``bluetoothctl remove ``. Additionally the linux bluetooth daemon stores persistent adapter metadata in the ``/var/lib/bluetooth/`` (see the bluetoothd manpages). + +.. _pairing-io: + +Pairing Security +---------------- + ++---------------------+-------------------------------------------------------------------------------------------------------------+ +| | Initiator | +| +---------------------+---------------------+---------------------+---------------------+---------------------+ +| Responder | Display Only | Display YesNo | Keyboard Only | NoInput NoOutput | Keyboard Display | ++=====================+=====================+=====================+=====================+=====================+=====================+ +| Display Only | Just Works | Just Works | Passkey Entry | Just Works | Passkey Entry | ++---------------------+---------------------+---------------------+---------------------+---------------------+---------------------+ +| Display YesNo | Just Works | Numeric Comparison | Passkey Entry | Just Works | Numeric Comparison | +| | | (*Just Works\**) | | | (*Passkey Entry\**) | ++---------------------+---------------------+---------------------+---------------------+---------------------+---------------------+ +| Keyboard Only | Passkey Entry | Passkey Entry | Passkey Entry | Just Works | Passkey Entry | ++---------------------+---------------------+---------------------+---------------------+---------------------+---------------------+ +| NoInput NoOutput | Just Works | Just Works | Just Works | Just Works | Just Works | ++---------------------+---------------------+---------------------+---------------------+---------------------+---------------------+ +| Keyboard Display | Passkey Entry | Numeric Comparison | Passkey Entry | Just Works | Numeric Comparison | +| | | (*Passkey Entry\**) | | | (*Passkey Entry\**) | ++---------------------+---------------------+---------------------+---------------------+---------------------+---------------------+ + +| *\* Types apply to LE Legacy Pairing only (used when the initiator or responder do not support "LE Secure Connection" pairing).* +| + +For completeness these pairing models are described below: + +* Just Works - Devices may pair with no user interaction (eg a phone connecting to a headset without a display). Since this has no MITM protection, connections established using this model **may not perform authentication** (ie. access authenticated attributes). +* Numeric Comparison - The user verifies 6 digit codes displayed by each device match each other. +* Passkey Entry - The user is shown a 6 digit code on one device and inputs that code on the other. +* Out of Band - A MITM resistant channel is established between the two devices using a different protocol (eg NFC). + +Note that IO Capability is not the only factor in selecting a pairing algorithm. Specifically: + +* Where neither device requests Man-In-The-Middle (MITM) protection, Just Works pairing will be used. +* Where both devices request it, OOB pairing will be used. + +.. seealso:: + + Bluetooth SIG Pairing Overview + `Part 1 `_ + `Part 2 `_ + `Part 3 `_ + + `Bluetooth Core Spec v5.2 `_ + Vol 3, Part H, Table 2.8 (source of :ref:`pairing-io`) + + Bluez Documentation + `Agent API `_ diff --git a/docs/source/ref/bluez_peripheral.adapter.rst b/docs/source/ref/bluez_peripheral.adapter.rst new file mode 100644 index 0000000..02ab667 --- /dev/null +++ b/docs/source/ref/bluez_peripheral.adapter.rst @@ -0,0 +1,7 @@ +bluez\_peripheral.adapter module +================================ + +.. automodule:: bluez_peripheral.adapter + :members: + :show-inheritance: + :undoc-members: diff --git a/docs/source/ref/advert.rst b/docs/source/ref/bluez_peripheral.advert.rst similarity index 66% rename from docs/source/ref/advert.rst rename to docs/source/ref/bluez_peripheral.advert.rst index 7c3e463..bbb8e2e 100644 --- a/docs/source/ref/advert.rst +++ b/docs/source/ref/bluez_peripheral.advert.rst @@ -2,4 +2,6 @@ bluez\_peripheral.advert module =============================== .. automodule:: bluez_peripheral.advert - :members: \ No newline at end of file + :members: + :show-inheritance: + :undoc-members: diff --git a/docs/source/ref/agent.rst b/docs/source/ref/bluez_peripheral.agent.rst similarity index 65% rename from docs/source/ref/agent.rst rename to docs/source/ref/bluez_peripheral.agent.rst index 7cd67e1..47b9c25 100644 --- a/docs/source/ref/agent.rst +++ b/docs/source/ref/bluez_peripheral.agent.rst @@ -2,4 +2,6 @@ bluez\_peripheral.agent module ============================== .. automodule:: bluez_peripheral.agent - :members: \ No newline at end of file + :members: + :show-inheritance: + :undoc-members: diff --git a/docs/source/ref/bluez_peripheral.base.rst b/docs/source/ref/bluez_peripheral.base.rst new file mode 100644 index 0000000..fb36053 --- /dev/null +++ b/docs/source/ref/bluez_peripheral.base.rst @@ -0,0 +1,7 @@ +bluez\_peripheral.base module +============================= + +.. automodule:: bluez_peripheral.base + :members: + :show-inheritance: + :undoc-members: diff --git a/docs/source/ref/bluez_peripheral.error.rst b/docs/source/ref/bluez_peripheral.error.rst new file mode 100644 index 0000000..d494221 --- /dev/null +++ b/docs/source/ref/bluez_peripheral.error.rst @@ -0,0 +1,7 @@ +bluez\_peripheral.error module +============================== + +.. automodule:: bluez_peripheral.error + :members: + :show-inheritance: + :undoc-members: diff --git a/docs/source/ref/bluez_peripheral.flags.rst b/docs/source/ref/bluez_peripheral.flags.rst new file mode 100644 index 0000000..a5796ef --- /dev/null +++ b/docs/source/ref/bluez_peripheral.flags.rst @@ -0,0 +1,7 @@ +bluez\_peripheral.flags module +============================== + +.. automodule:: bluez_peripheral.flags + :members: + :show-inheritance: + :undoc-members: diff --git a/docs/source/ref/bluez_peripheral.gatt.base.rst b/docs/source/ref/bluez_peripheral.gatt.base.rst new file mode 100644 index 0000000..c91b5da --- /dev/null +++ b/docs/source/ref/bluez_peripheral.gatt.base.rst @@ -0,0 +1,7 @@ +bluez\_peripheral.gatt.base module +================================== + +.. automodule:: bluez_peripheral.gatt.base + :members: + :show-inheritance: + :undoc-members: diff --git a/docs/source/ref/gatt/characteristic.rst b/docs/source/ref/bluez_peripheral.gatt.characteristic.rst similarity index 79% rename from docs/source/ref/gatt/characteristic.rst rename to docs/source/ref/bluez_peripheral.gatt.characteristic.rst index 2866ebe..db5c48b 100644 --- a/docs/source/ref/gatt/characteristic.rst +++ b/docs/source/ref/bluez_peripheral.gatt.characteristic.rst @@ -3,4 +3,5 @@ bluez\_peripheral.gatt.characteristic module .. automodule:: bluez_peripheral.gatt.characteristic :members: - :special-members: __call__ \ No newline at end of file + :show-inheritance: + :undoc-members: diff --git a/docs/source/ref/gatt/descriptor.rst b/docs/source/ref/bluez_peripheral.gatt.descriptor.rst similarity index 77% rename from docs/source/ref/gatt/descriptor.rst rename to docs/source/ref/bluez_peripheral.gatt.descriptor.rst index d252cf5..815386c 100644 --- a/docs/source/ref/gatt/descriptor.rst +++ b/docs/source/ref/bluez_peripheral.gatt.descriptor.rst @@ -3,4 +3,5 @@ bluez\_peripheral.gatt.descriptor module .. automodule:: bluez_peripheral.gatt.descriptor :members: - :show-inheritance: \ No newline at end of file + :show-inheritance: + :undoc-members: diff --git a/docs/source/ref/bluez_peripheral.gatt.rst b/docs/source/ref/bluez_peripheral.gatt.rst new file mode 100644 index 0000000..f54f518 --- /dev/null +++ b/docs/source/ref/bluez_peripheral.gatt.rst @@ -0,0 +1,21 @@ +bluez\_peripheral.gatt package +============================== + +Submodules +---------- + +.. toctree:: + :maxdepth: 4 + + bluez_peripheral.gatt.base + bluez_peripheral.gatt.characteristic + bluez_peripheral.gatt.descriptor + bluez_peripheral.gatt.service + +Module contents +--------------- + +.. automodule:: bluez_peripheral.gatt + :members: + :show-inheritance: + :undoc-members: diff --git a/docs/source/ref/gatt/service.rst b/docs/source/ref/bluez_peripheral.gatt.service.rst similarity index 76% rename from docs/source/ref/gatt/service.rst rename to docs/source/ref/bluez_peripheral.gatt.service.rst index 3ea5b7e..11985c0 100644 --- a/docs/source/ref/gatt/service.rst +++ b/docs/source/ref/bluez_peripheral.gatt.service.rst @@ -3,3 +3,5 @@ bluez\_peripheral.gatt.service module .. automodule:: bluez_peripheral.gatt.service :members: + :show-inheritance: + :undoc-members: diff --git a/docs/source/ref/bluez_peripheral.rst b/docs/source/ref/bluez_peripheral.rst index 15e37aa..12bb3e7 100644 --- a/docs/source/ref/bluez_peripheral.rst +++ b/docs/source/ref/bluez_peripheral.rst @@ -1,12 +1,33 @@ bluez\_peripheral package ========================= +Subpackages +----------- + +.. toctree:: + :maxdepth: 4 + + bluez_peripheral.gatt + +Submodules +---------- + .. toctree:: - :caption: Reference :maxdepth: 4 - advert - agent - uuid - util - gatt/gatt \ No newline at end of file + bluez_peripheral.adapter + bluez_peripheral.advert + bluez_peripheral.agent + bluez_peripheral.base + bluez_peripheral.error + bluez_peripheral.flags + bluez_peripheral.util + bluez_peripheral.uuid16 + +Module contents +--------------- + +.. automodule:: bluez_peripheral + :members: + :show-inheritance: + :undoc-members: diff --git a/docs/source/ref/util.rst b/docs/source/ref/bluez_peripheral.util.rst similarity index 87% rename from docs/source/ref/util.rst rename to docs/source/ref/bluez_peripheral.util.rst index e1e2948..3b5d7f3 100644 --- a/docs/source/ref/util.rst +++ b/docs/source/ref/bluez_peripheral.util.rst @@ -4,3 +4,4 @@ bluez\_peripheral.util module .. automodule:: bluez_peripheral.util :members: :show-inheritance: + :undoc-members: diff --git a/docs/source/ref/bluez_peripheral.uuid16.rst b/docs/source/ref/bluez_peripheral.uuid16.rst new file mode 100644 index 0000000..775aa20 --- /dev/null +++ b/docs/source/ref/bluez_peripheral.uuid16.rst @@ -0,0 +1,7 @@ +bluez\_peripheral.uuid16 module +=============================== + +.. automodule:: bluez_peripheral.uuid16 + :members: + :show-inheritance: + :undoc-members: diff --git a/docs/source/ref/gatt/gatt.rst b/docs/source/ref/gatt/gatt.rst deleted file mode 100644 index 9db82e5..0000000 --- a/docs/source/ref/gatt/gatt.rst +++ /dev/null @@ -1,10 +0,0 @@ -bluez\_peripheral.gatt package -============================== - -.. toctree:: - :maxdepth: 4 - :caption: Reference - - characteristic - descriptor - service \ No newline at end of file diff --git a/docs/source/ref/uuid.rst b/docs/source/ref/uuid.rst deleted file mode 100644 index 76bc8ac..0000000 --- a/docs/source/ref/uuid.rst +++ /dev/null @@ -1,5 +0,0 @@ -bluez\_peripheral.uuid module -============================= - -.. automodule:: bluez_peripheral.uuid16 - :members: \ No newline at end of file diff --git a/docs/source/services.rst b/docs/source/services.rst new file mode 100644 index 0000000..162c8af --- /dev/null +++ b/docs/source/services.rst @@ -0,0 +1,236 @@ +.. _services: + +Creating a Service +================== +Attribute Flags +--------------- +The behavior of a particular attribute is described by a set of flags. These flags are implemented using the :py:class:`~bluez_peripheral.gatt.characteristic.CharacteristicFlags` and :py:class:`~bluez_peripheral.gatt.descriptor.DescriptorFlags` enums. A single attribute may have multiple flags, in python you can combine these flags using the ``|`` operator (eg. ``CharacteristicFlags.READ | CharacteristicFlags.WRITE``). + +UUIDs +----- +.. hint:: + The Bluetooth SIG has reserved 16-bit UUIDs for `standardised services `_. 128-bit UUIDs should be preferred to avoid conflicts and confusion. + +BLE uses 128-bit Universally Unique Identifiers (UUIDs) to determine what each service, characteristic and descriptor refers to in addition to the type of every attribute. To minimize the amount of information that needs to be transmitted the Bluetooth SIG selected a base UUID of ``0000XXXX-0000-1000-8000-00805F9B34FB``. This allows a 16-bit number to be transmitted in place of the full 128-bit value in some cases. In bluez_peripheral 16-bit UUIDs are represented by the :py:class:`~bluez_peripheral.uuid16.UUID16` class whilst 128-bit values are represented by :py:class:`uuid.UUID`. In bluez_peripheral all user provided UUIDs are are parsed using :py:func:`UUID16.parse_uuid()` meaning you can use these types interchangeably, UUID16s will automatically be used where possible. + +Adding Attributes +----------------- +The :py:class:`@characteristic` and :py:class:`@descriptor` decorators are designed to work identically to the built-in :py:class:`@property` decorator. Attributes can be added to a service either manually or using decorators: + +.. warning:: + Attributes exceeding 48 bytes in length may take place across multiple accesses, using the :ref:`options.offset` parameter to select portions of the data. This is dependent upon the :ref:`options.mtu`. + +.. tab:: Decorators + + .. testcode:: + + from bluez_peripheral.gatt import Service + from bluez_peripheral.gatt import characteristic, CharacteristicFlags as CharFlags + from bluez_peripheral.gatt import descriptor, DescriptorFlags as DescFlags + + class MyService(Service): + def __init__(self): + # You must call the super class constructor to register any decorated attributes. + super().__init__(uuid="BEED") + + @characteristic("BEEE", CharFlags.READ | CharFlags.WRITE) + def my_characteristic(self, options): + # This is the getter for my_characteristic. + # All attribute functions must return bytes. + return bytes("Hello World!", "utf-8") + + @my_characteristic.setter + def my_characteristic(self, value, options): + # This is the setter for my_characteristic. + # Value consists of some bytes. + self._my_char_value = value + + # Descriptors work exactly the same way. + @descriptor("BEEF", my_characteristic, DescFlags.WRITE) + def my_writeonly_descriptor(self, options): + # This function is a manditory placeholder. + # In Python 3.9+ you don't need this function (See PEP 614). + pass + + my_writeonly_descriptor.setter + def my_writeonly_descriptor(self, value, options): + self._my_desc_value = value + + # Characteristic and Descriptor getters/ setters may also be asynchronous. + @characteristic("BEEB", CharFlags.READ) + async def my_async_characteristic(self, options): + return await my_awaitable() + +.. tab:: Manually (Not Recommended) + + .. testcode:: + + from bluez_peripheral.gatt import Service + from bluez_peripheral.gatt import characteristic, CharacteristicFlags as CharFlags + from bluez_peripheral.gatt import descriptor, DescriptorFlags as DescFlags + + # Create my_characteristic + my_char_value = None + def my_characteristic_getter(service, options): + return bytes("Hello World!", "utf-8") + def my_characteristic_setter(service, value, options): + my_char_value = value + # See characteristic.__call__() + my_characteristic = characteristic("BEEE", CharFlags.READ | CharFlags.WRITE)( + my_characteristic_getter, my_characteristic_setter + ) + + # Create my_descriptor + my_desc_value = None + def my_readonly_descriptor_setter(service, value, options): + my_desc_value = value + # See descriptor.__call__() + my_descriptor = descriptor("BEEF", my_characteristic, DescFlags.WRITE)( + None, my_readonly_descriptor_setter + ) + + async def my_async_characteristic_getter(self, options): + return await my_awaitable() + my_async_characteristic = characteristic("BEEE", CharFlags.READ)( + my_async_characteristic_getter + ) + + # Register my_descriptor with its parent characteristic and my_characteristic + # with its parent service. + my_service = Service("BEED") + my_characteristic.add_descriptor(my_descriptor) + my_service.add_characteristic(my_characteristic) + my_service.add_characteristic(my_async_characteristic) + +Error Handling +^^^^^^^^^^^^^^ +Attribute getters/ setters may raise one of a set of :ref:`legal exceptions` to signal specific conditions to bluez. Avoid throwing custom exceptions in attribute assessors, since these will not be presented to a user and bluez will not know how to interpret them. Additionally any exceptions thrown **must** derive from :py:class:`dbus_fast.DBusError`. + +.. _legal-errors: + +Legal Errors +^^^^^^^^^^^^ + ++-------------------------------------------------------------+----------------------------------------------------------+----------------------------------------------------------+ +| Error | Characteristic | Descriptor | +| +----------------------------+-----------------------------+----------------------------+-----------------------------+ +| | :abbr:`Getter (ReadValue)` | :abbr:`Setter (WriteValue)` | :abbr:`Getter (ReadValue)` | :abbr:`Setter (WriteValue)` | ++=============================================================+============================+=============================+============================+=============================+ +| :py:class:`~bluez_peripheral.error.FailedError` | ✓ | ✓ | ✓ | ✓ | ++-------------------------------------------------------------+----------------------------+-----------------------------+----------------------------+-----------------------------+ +| :py:class:`~bluez_peripheral.error.InProgressError` | ✓ | ✓ | ✓ | ✓ | ++-------------------------------------------------------------+----------------------------+-----------------------------+----------------------------+-----------------------------+ +| :py:class:`~bluez_peripheral.error.InvalidOffsetError` | ✓ | | | | ++-------------------------------------------------------------+----------------------------+-----------------------------+----------------------------+-----------------------------+ +| :py:class:`~bluez_peripheral.error.InvalidValueLengthError` | | ✓ | | ✓ | ++-------------------------------------------------------------+----------------------------+-----------------------------+----------------------------+-----------------------------+ +| :py:class:`~bluez_peripheral.error.NotAuthorizedError` | ✓ | ✓ | ✓ | ✓ | ++-------------------------------------------------------------+----------------------------+-----------------------------+----------------------------+-----------------------------+ +| :py:class:`~bluez_peripheral.error.NotPermittedError` | ✓ | ✓ | ✓ | ✓ | ++-------------------------------------------------------------+----------------------------+-----------------------------+----------------------------+-----------------------------+ +| :py:class:`~bluez_peripheral.error.NotSupportedError` | ✓ | ✓ | ✓ | ✓ | ++-------------------------------------------------------------+----------------------------+-----------------------------+----------------------------+-----------------------------+ + +Registering a Service +----------------------- +.. warning:: + Ensure that the thread used to register your service yields regularly. Client requests will not be served otherwise. + +.. hint:: + The "message bus" referred to here is a :py:class:`dbus_fast.aio.MessageBus`. + +Services can either be registered individually using a :py:class:`~bluez_peripheral.gatt.service.Service` or as part of a :py:class:`~bluez_peripheral.gatt.service.ServiceCollection`. For example following on from the earlier code: + +.. tab:: Service + + .. testcode:: + + from bluez_peripheral import get_message_bus + + async def main(): + my_service = Service() + + bus = await get_message_bus() + # Register the service for bluez to access. + await my_service.register(bus) + + # Yeild so that the service can handle requests. + await bus.wait_for_disconnect() + + if __name__ == "__main__": + asyncio.run(main()) + +.. tab:: ServiceCollection + + .. testcode:: + + from bluez_peripheral import get_message_bus + from bluez_peripheral.gatt import ServiceCollection + + async def main(): + my_service_collection = ServiceCollection() + my_service_collection.add_service(my_service) + #my_service_collection.add_service(my_other_service) + + bus = await get_message_bus() + # Register the service for bluez to access. + await my_service_collection.register(bus) + + # Yeild so that the services can handle requests. + await bus.wait_for_disconnect() + + if __name__ == "__main__": + asyncio.run(main()) + +Notification +^^^^^^^^^^^^ +Characteristics with the :py:attr:`~bluez_peripheral.gatt.characteristic.CharacteristicFlags.NOTIFY` or :py:attr:`~bluez_peripheral.gatt.characteristic.CharacteristicFlags.INDICATE` flags can update clients when their value changes. Indicate requires acknowledgment from the client whilst notify does not. For this to work the client must first call subscribe to the notification. The client can then be notified by calling :py:func:`characteristic.changed()`. + +.. warning:: + The :py:func:`characteristic.changed()` function may only be called in the same thread that registered the service. + +.. testcode:: + + from bluez_peripheral import get_message_bus + from bluez_peripheral.gatt import Service + from bluez_peripheral.gatt import characteristic, CharacteristicFlags as CharFlags + + class MyService(Service): + def __init__(self): + super().__init__(uuid="DEED") + + @characteristic("DEEE", CharFlags.NOTIFY) + def my_notify_characteristic(self, options): + pass + + async def main(): + my_service = MyService() + + bus = await get_message_bus() + await my_service.register(bus) + + # Signal that the value of the characteristic has changed. + service.my_notify_characteristic.changed(bytes("My new value", "utf-8")) + + # Yeild so that the service can handle requests and signal the change. + await bus.wait_for_disconnect() + + if __name__ == "__main__": + asyncio.run(main()) + + +.. seealso:: + + Bluez Documentation + `Service API `_ + `Characteristic API `_ + `Descriptor API `_ + + .. _attribute-options: + + Attribute Access Options + :py:class:`~bluez_peripheral.gatt.characteristic.CharacteristicReadOptions` + :py:class:`~bluez_peripheral.gatt.characteristic.CharacteristicWriteOptions` + :py:class:`~bluez_peripheral.gatt.descriptor.DescriptorReadOptions` + :py:class:`~bluez_peripheral.gatt.descriptor.DescriptorWriteOptions` + diff --git a/docs/source/spelling_wordlist.txt b/docs/source/spelling_wordlist.txt index 9cce343..172bbb7 100644 --- a/docs/source/spelling_wordlist.txt +++ b/docs/source/spelling_wordlist.txt @@ -6,12 +6,29 @@ getter getters discoverable bluetoothctl +bluetoothd +manpages +pairable +subclasses cli passcode indicatable eg +ie unregister hostname dbus gatt -util \ No newline at end of file +util +asyncio +multithreading +linux +subpackages +submodules +enums +responder +unexport +unexported +LEAdvertisingManager +GattManager +unpairs \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 58a4a54..2fb5a4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,4 +6,32 @@ requires = [ ] build-backend = "setuptools.build_meta" -[tool.setuptools_scm] \ No newline at end of file +[tool.setuptools_scm] + +[tool.pylint] +disable = [ + "missing-module-docstring", + "line-too-long", + "too-many-instance-attributes", + "too-many-return-statements", + "too-many-branches", + "too-many-arguments", + "too-many-locals", + "too-few-public-methods", + "too-many-public-methods", + "duplicate-code", +] + +[tool.mypy] +python_version = "3.11" +strict = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +warn_unused_ignores = true +warn_return_any = true +warn_unused_configs = true + +[tool.coverage.run] +omit = [ + "setup.py", +] \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index a339b17..2dc8b30 100644 --- a/setup.cfg +++ b/setup.cfg @@ -5,10 +5,9 @@ description = A library for building BLE peripherals using GATT and bluez long_description = file: README.md long_description_content_type = text/markdown url = https://github.com/spacecheese/bluez_peripheral -license = MIT License +license = MIT classifiers = Programming Language :: Python :: 3 - License :: OSI Approved :: MIT License Typing :: Typed [options] diff --git a/setup.py b/setup.py index 5e614f3..7f1a176 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,4 @@ from setuptools import setup -if __name__ == '__main__': - setup() \ No newline at end of file +if __name__ == "__main__": + setup() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b26241b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,10 @@ +import pytest_asyncio + +from bluez_peripheral.util import get_message_bus, MessageBus + + +@pytest_asyncio.fixture +async def message_bus(): + bus = await get_message_bus() + yield bus + bus.disconnect() diff --git a/tests/gatt/test_characteristic.py b/tests/gatt/test_characteristic.py deleted file mode 100644 index a1cdf81..0000000 --- a/tests/gatt/test_characteristic.py +++ /dev/null @@ -1,355 +0,0 @@ -from bluez_peripheral.gatt.descriptor import descriptor -from unittest import IsolatedAsyncioTestCase -from threading import Event -from tests.util import * -import re - -from bluez_peripheral.util import get_message_bus -from bluez_peripheral.gatt.characteristic import ( - CharacteristicFlags, - CharacteristicWriteType, - characteristic, -) -from bluez_peripheral.gatt.service import Service - -last_opts = None -write_notify_char_val = None -write_only_char_val = None - - -class TestService(Service): - def __init__(self): - super().__init__("180A") - - @characteristic("2A37", CharacteristicFlags.READ) - def read_only_char(self, opts): - global last_opts - last_opts = opts - return bytes("Test Message", "utf-8") - - @characteristic("3A37", CharacteristicFlags.READ) - async def async_read_only_char(self, opts): - global last_opts - last_opts = opts - await asyncio.sleep(0.05) - return bytes("Test Message", "utf-8") - - # Not testing other characteristic flags since their functionality is handled by bluez. - @characteristic("2A38", CharacteristicFlags.NOTIFY | CharacteristicFlags.WRITE) - def write_notify_char(self, _): - pass - - @write_notify_char.setter - def write_notify_char(self, val, opts): - global last_opts - last_opts = opts - global write_notify_char_val - write_notify_char_val = val - - @characteristic("3A38", CharacteristicFlags.WRITE) - async def aysnc_write_only_char(self, _): - pass - - @aysnc_write_only_char.setter - async def aysnc_write_only_char(self, val, opts): - global last_opts - last_opts = opts - global write_only_char_val - write_only_char_val = val - await asyncio.sleep(0.05) - - -class TestCharacteristic(IsolatedAsyncioTestCase): - async def asyncSetUp(self): - self._client_bus = await get_message_bus() - self._bus_manager = BusManager() - self._path = "/com/spacecheese/bluez_peripheral/test_characteristic" - - async def asyncTearDown(self): - self._client_bus.disconnect() - self._bus_manager.close() - - async def test_structure(self): - async def inspector(path): - service = await get_attrib( - self._client_bus, self._bus_manager.name, path, "180A" - ) - - child_names = [path.split("/")[-1] for path in service.child_paths] - child_names = sorted(child_names) - - i = 0 - # Characteristic numbering can't have gaps. - for name in child_names: - assert re.match(r"^char0{0,3}" + str(i) + "$", name) - i += 1 - - service = TestService() - adapter = MockAdapter(inspector) - - await service.register(self._bus_manager.bus, self._path, adapter) - - async def test_read(self): - async def inspector(path): - global last_opts - opts = { - "offset": Variant("q", 0), - "mtu": Variant("q", 128), - "device": Variant("s", "blablabla/.hmm"), - } - interface = ( - await get_attrib( - self._client_bus, - self._bus_manager.name, - path, - "180A", - char_uuid="2A37", - ) - ).get_interface("org.bluez.GattCharacteristic1") - resp = await interface.call_read_value(opts) - cache = await interface.get_value() - - assert resp.decode("utf-8") == "Test Message" - assert last_opts.offset == 0 - assert last_opts.mtu == 128 - assert last_opts.device == "blablabla/.hmm" - assert cache == resp - - interface = ( - await get_attrib( - self._client_bus, - self._bus_manager.name, - path, - "180A", - char_uuid="3A37", - ) - ).get_interface("org.bluez.GattCharacteristic1") - resp = await interface.call_read_value(opts) - cache = await interface.get_value() - - assert resp.decode("utf-8") == "Test Message" - assert cache == resp - - service = TestService() - adapter = MockAdapter(inspector) - - await service.register(self._bus_manager.bus, self._path, adapter) - - async def test_write(self): - async def inspector(path): - global last_opts - opts = { - "offset": Variant("q", 10), - "type": Variant("s", "request"), - "mtu": Variant("q", 128), - "device": Variant("s", "blablabla/.hmm"), - "link": Variant("s", "yuyuyuy"), - "prepare-authorize": Variant("b", False), - } - interface = ( - await get_attrib( - self._client_bus, - self._bus_manager.name, - path, - "180A", - char_uuid="2A38", - ) - ).get_interface("org.bluez.GattCharacteristic1") - await interface.call_write_value(bytes("Test Write Value", "utf-8"), opts) - - assert last_opts.offset == 10 - assert last_opts.type == CharacteristicWriteType.REQUEST - assert last_opts.mtu == 128 - assert last_opts.device == "blablabla/.hmm" - assert last_opts.link == "yuyuyuy" - assert last_opts.prepare_authorize == False - - assert write_notify_char_val.decode("utf-8") == "Test Write Value" - - interface = ( - await get_attrib( - self._client_bus, - self._bus_manager.name, - path, - "180A", - char_uuid="3A38", - ) - ).get_interface("org.bluez.GattCharacteristic1") - await interface.call_write_value(bytes("Test Write Value", "utf-8"), opts) - - assert write_only_char_val.decode("utf-8") == "Test Write Value" - - service = TestService() - adapter = MockAdapter(inspector) - - await service.register(self._bus_manager.bus, self._path, adapter) - - async def test_notify_no_start(self): - property_changed = Event() - - async def inspector(path): - interface = ( - await get_attrib( - self._client_bus, - self._bus_manager.name, - path, - "180A", - char_uuid="2A38", - ) - ).get_interface("org.freedesktop.DBus.Properties") - - def on_properties_changed(_0, _1, _2): - property_changed.set() - - interface.on_properties_changed(on_properties_changed) - - service = TestService() - adapter = MockAdapter(inspector) - - await service.register(self._bus_manager.bus, self._path, adapter) - service.write_notify_char.changed(bytes("Test Notify Value", "utf-8")) - - # Expect a timeout since start notify has not been called. - if property_changed.wait(timeout=0.1): - raise Exception( - "The characteristic signalled a notification before StartNotify() was called." - ) - - async def test_notify_start(self): - property_changed = Event() - - async def inspector(path): - proxy = await get_attrib( - self._client_bus, - self._bus_manager.name, - path, - "180A", - char_uuid="2A38", - ) - properties_interface = proxy.get_interface( - "org.freedesktop.DBus.Properties" - ) - char_interface = proxy.get_interface("org.bluez.GattCharacteristic1") - - def on_properties_changed(interface, values, invalid_props): - assert interface == "org.bluez.GattCharacteristic1" - assert len(values) == 1 - assert values["Value"].value.decode("utf-8") == "Test Notify Value" - property_changed.set() - - - properties_interface.on_properties_changed(on_properties_changed) - await char_interface.call_start_notify() - - service = TestService() - adapter = MockAdapter(inspector) - - await service.register(self._bus_manager.bus, self._path, adapter) - service.write_notify_char.changed(bytes("Test Notify Value", "utf-8")) - - await asyncio.sleep(0.01) - - # Block until the properties changed notification propagates. - if not property_changed.wait(timeout=0.1): - raise TimeoutError( - "The characteristic did not send a notification in time." - ) - - async def test_notify_stop(self): - property_changed = Event() - - async def inspector(path): - proxy = await get_attrib( - self._client_bus, - self._bus_manager.name, - path, - "180A", - char_uuid="2A38", - ) - property_interface = proxy.get_interface("org.freedesktop.DBus.Properties") - char_interface = proxy.get_interface("org.bluez.GattCharacteristic1") - - def on_properties_changed(_0, _1, _2): - property_changed.set() - - property_interface.on_properties_changed(on_properties_changed) - - await char_interface.call_start_notify() - await char_interface.call_stop_notify() - - service = TestService() - adapter = MockAdapter(inspector) - - await service.register(self._bus_manager.bus, self._path, adapter) - service.write_notify_char.changed(bytes("Test Notify Value", "utf-8")) - - # Expect a timeout since start notify has not been called. - if property_changed.wait(timeout=0.01): - raise Exception( - "The characteristic signalled a notification before after StopNotify() was called." - ) - - async def test_modify(self): - service = TestService() - - @descriptor("2D56", service.write_notify_char) - def some_desc(service, opts): - return bytes("Some Test Value", "utf-8") - - global expect_descriptor - expect_descriptor = True - - async def inspector(path): - global expect_descriptor - - opts = { - "offset": Variant("q", 0), - "mtu": Variant("q", 128), - "device": Variant("s", "blablabla/.hmm"), - } - - if expect_descriptor: - proxy = await get_attrib( - self._client_bus, - self._bus_manager.name, - path, - "180A", - "2A38", - "2D56", - ) - desc = proxy.get_interface("org.bluez.GattDescriptor1") - assert (await desc.call_read_value(opts)).decode( - "utf-8" - ) == "Some Test Value" - else: - try: - await get_attrib( - self._client_bus, - self._bus_manager.name, - path, - "180A", - "2A38", - "2D56", - ) - except ValueError: - pass - else: - self.fail("The descriptor was not properly removed.") - - adapter = MockAdapter(inspector) - - await service.register(self._bus_manager.bus, self._path, adapter=adapter) - self.assertRaises( - ValueError, service.write_notify_char.remove_descriptor, some_desc - ) - - await service.unregister() - service.write_notify_char.remove_descriptor(some_desc) - expect_descriptor = False - - await service.register(self._bus_manager.bus, self._path, adapter=adapter) - self.assertRaises( - ValueError, service.write_notify_char.add_descriptor, some_desc - ) - await service.unregister() - await service.register(self._bus_manager.bus, self._path, adapter=adapter) diff --git a/tests/gatt/test_descriptor.py b/tests/gatt/test_descriptor.py deleted file mode 100644 index 2949a2b..0000000 --- a/tests/gatt/test_descriptor.py +++ /dev/null @@ -1,189 +0,0 @@ -from dbus_fast import introspection -from unittest import IsolatedAsyncioTestCase -import re - -from dbus_fast.signature import Variant - -from tests.util import * - -from bluez_peripheral.util import get_message_bus -from bluez_peripheral.gatt.characteristic import CharacteristicFlags, characteristic -from bluez_peripheral.gatt.descriptor import DescriptorFlags, descriptor -from bluez_peripheral.gatt.service import Service - -last_opts = None -write_desc_val = None -async_write_desc_val = None - -class TestService(Service): - def __init__(self): - super().__init__("180A") - - @characteristic("2A37", CharacteristicFlags.RELIABLE_WRITE) - def some_char(self, _): - return bytes("Some Other Test Message", "utf-8") - - @some_char.descriptor("2A38") - def read_only_desc(self, opts): - global last_opts - last_opts = opts - return bytes("Test Message", "utf-8") - - @some_char.descriptor("3A38") - async def async_read_only_desc(self, opts): - global last_opts - last_opts = opts - await asyncio.sleep(0.05) - return bytes("Test Message", "utf-8") - - @descriptor("2A39", some_char, DescriptorFlags.WRITE) - def write_desc(self, _): - pass - - @write_desc.setter - def write_desc(self, val, opts): - global last_opts - last_opts = opts - global write_desc_val - write_desc_val = val - - @descriptor("3A39", some_char, DescriptorFlags.WRITE) - async def async_write_desc(self, _): - pass - - @async_write_desc.setter - async def async_write_desc(self, val, opts): - global last_opts - last_opts = opts - await asyncio.sleep(0.05) - global async_write_desc_val - async_write_desc_val = val - - -class TestDescriptor(IsolatedAsyncioTestCase): - async def asyncSetUp(self): - self._client_bus = await get_message_bus() - self._bus_manager = BusManager() - self._path = "/com/spacecheese/bluez_peripheral/test_descriptor" - - async def asyncTearDown(self): - self._client_bus.disconnect() - self._bus_manager.close() - - async def test_structure(self): - async def inspector(path): - char = await get_attrib( - self._client_bus, self._bus_manager.name, path, "180A", char_uuid="2A37" - ) - - child_names = [path.split("/")[-1] for path in char.child_paths] - child_names = sorted(child_names) - - i = 0 - for name in child_names: - assert re.match(r"^desc0{0,2}" + str(i) + "$", name) - i += 1 - - service = TestService() - adapter = MockAdapter(inspector) - - await service.register(self._bus_manager.bus, self._path, adapter) - - async def test_read(self): - async def inspector(path): - global last_opts - opts = { - "offset": Variant("q", 0), - "link": Variant("s", "dododo"), - "device": Variant("s", "bebealbl/.afal"), - } - interface = ( - await get_attrib( - self._client_bus, - self._bus_manager.name, - path, - "180A", - char_uuid="2A37", - desc_uuid="2A38", - ) - ).get_interface("org.bluez.GattDescriptor1") - resp = await interface.call_read_value(opts) - - assert resp.decode("utf-8") == "Test Message" - assert last_opts.offset == 0 - assert last_opts.link == "dododo" - assert last_opts.device == "bebealbl/.afal" - - interface = ( - await get_attrib( - self._client_bus, - self._bus_manager.name, - path, - "180A", - char_uuid="2A37", - desc_uuid="3A38", - ) - ).get_interface("org.bluez.GattDescriptor1") - resp = await interface.call_read_value(opts) - - assert resp.decode("utf-8") == "Test Message" - - service = TestService() - adapter = MockAdapter(inspector) - - await service.register(self._bus_manager.bus, self._path, adapter) - - async def test_write(self): - async def inspector(path): - global last_opts - global write_desc_val - opts = { - "offset": Variant("q", 1), - "device": Variant("s", "bebealbl/.afal"), - "link": Variant("s", "gogog"), - "prepare-authorize": Variant("b", True), - } - interface = ( - await get_attrib( - self._client_bus, - self._bus_manager.name, - path, - "180A", - char_uuid="2A37", - desc_uuid="2A39", - ) - ).get_interface("org.bluez.GattDescriptor1") - await interface.call_write_value(bytes("Test Write Value", "utf-8"), opts) - - assert last_opts.offset == 1 - assert last_opts.device == "bebealbl/.afal" - assert last_opts.link == "gogog" - assert last_opts.prepare_authorize == True - - assert write_desc_val.decode("utf-8") == "Test Write Value" - - interface = ( - await get_attrib( - self._client_bus, - self._bus_manager.name, - path, - "180A", - char_uuid="2A37", - desc_uuid="3A39", - ) - ).get_interface("org.bluez.GattDescriptor1") - await interface.call_write_value(bytes("Test Write Value", "utf-8"), opts) - - assert write_desc_val.decode("utf-8") == "Test Write Value" - - service = TestService() - adapter = MockAdapter(inspector) - - await service.register(self._bus_manager.bus, self._path, adapter) - - async def test_bluez(self): - await bluez_available_or_skip(self._client_bus) - await get_first_adapter_or_skip(self._client_bus) - - service = TestService() - await service.register(self._client_bus, self._path) diff --git a/tests/gatt/test_service.py b/tests/gatt/test_service.py deleted file mode 100644 index beba0b4..0000000 --- a/tests/gatt/test_service.py +++ /dev/null @@ -1,100 +0,0 @@ -from tests.util import BusManager, MockAdapter, get_attrib - -import re -from typing import Collection -from unittest import IsolatedAsyncioTestCase - -from bluez_peripheral.util import get_message_bus -from bluez_peripheral.gatt.service import Service, ServiceCollection - - -class TestService1(Service): - def __init__(self, includes: Collection[Service]): - super().__init__("180A", primary=False, includes=includes) - - -class TestService2(Service): - def __init__(self): - super().__init__("180B") - - -class TestService3(Service): - def __init__(self): - super().__init__("180C") - - -class TestService(IsolatedAsyncioTestCase): - async def asyncSetUp(self): - self._client_bus = await get_message_bus() - self._bus_manager = BusManager() - self._path = "/com/spacecheese/bluez_peripheral/test_service" - - async def asyncTearDown(self): - self._client_bus.disconnect() - self._bus_manager.close() - - async def test_structure(self): - async def inspector(path): - introspection = await self._client_bus.introspect( - self._bus_manager.name, path - ) - - child_names = [node.name for node in introspection.nodes] - child_names = sorted(child_names) - - i = 0 - for name in child_names: - assert re.match(r"^service0?" + str(i) + "$", name) - i += 1 - - service1 = TestService1([]) - service2 = TestService2() - service3 = TestService3() - collection = ServiceCollection([service1, service2, service3]) - - adapter = MockAdapter(inspector) - - await collection.register(self._bus_manager.bus, self._path, adapter) - await collection.unregister() - - async def test_include_modify(self): - service3 = TestService3() - service2 = TestService2() - service1 = TestService1([service2, service3]) - collection = ServiceCollection([service1, service2]) - - expect_service3 = False - - async def inspector(path): - service1 = await get_attrib( - self._client_bus, self._bus_manager.name, path, "180A" - ) - service = service1.get_interface("org.bluez.GattService1") - includes = await service.get_includes() - - service2 = await get_attrib( - self._client_bus, self._bus_manager.name, path, "180B" - ) - # Services must include themselves. - assert service1.path in includes - assert service2.path in includes - - if expect_service3: - service3 = await get_attrib( - self._client_bus, self._bus_manager.name, path, "180C" - ) - assert service3.path in includes - - adapter = MockAdapter(inspector) - await collection.register(self._bus_manager.bus, self._path, adapter=adapter) - await collection.unregister() - - collection.add_service(service3) - expect_service3 = True - await collection.register(self._bus_manager.bus, self._path, adapter=adapter) - await collection.unregister() - - collection.remove_service(service3) - expect_service3 = False - await collection.register(self._bus_manager.bus, self._path, adapter=adapter) - diff --git a/tests/gatt/__init__.py b/tests/loopback/README.md similarity index 100% rename from tests/gatt/__init__.py rename to tests/loopback/README.md diff --git a/tests/loopback/__init__.py b/tests/loopback/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/loopback/conftest.py b/tests/loopback/conftest.py new file mode 100644 index 0000000..84763bb --- /dev/null +++ b/tests/loopback/conftest.py @@ -0,0 +1,83 @@ +import pytest +import pytest_asyncio + +from dbus_fast.service import dbus_method + +from bluez_peripheral.adapter import Adapter +from bluez_peripheral.agent import AgentCapability, BaseAgent + + +class TrivialAgent(BaseAgent): + @dbus_method() + def Cancel(): # type: ignore + return + + @dbus_method() + def Release(): # type: ignore + return + + @dbus_method() + def RequestPinCode(self, device: "o") -> "s": # type: ignore + return "0000" + + @dbus_method() + def DisplayPinCode(self, device: "o", pincode: "s"): # type: ignore + return + + @dbus_method() + def RequestPasskey(self, device: "o") -> "u": # type: ignore + return 0 + + @dbus_method() + def DisplayPasskey(self, device: "o", passkey: "u", entered: "q"): # type: ignore + return + + @dbus_method() + def RequestConfirmation(self, device: "o", passkey: "u"): # type: ignore + return + + @dbus_method() + def RequestAuthorization(self, device: "o"): # type: ignore + return + + @dbus_method() + def AuthorizeService(self, device: "o", uuid: "s"): # type: ignore + return + + +@pytest_asyncio.fixture +async def unpaired_adapters(message_bus): + adapters = await Adapter.get_all(message_bus) + # TODO: Ideally we don't just take the first 2 since an end user may have some real adapters. + + assert len(adapters) >= 2 + + for device in await adapters[1].get_devices(): + if await device.get_paired(): + await device.remove(adapters[1]) + + yield adapters[0:2] + + +@pytest_asyncio.fixture +async def paired_adapters(message_bus, unpaired_adapters): + adapters = unpaired_adapters + + agent = TrivialAgent(AgentCapability.KEYBOARD_DISPLAY) + await agent.register(message_bus) + + await adapters[0].set_powered(True) + await adapters[0].set_discoverable(True) + await adapters[0].set_pairable(True) + + await adapters[1].set_powered(True) + await adapters[1].start_discovery() + devices = await adapters[1].get_devices() + assert len(devices) == 1 + devices[0].pair() + + yield adapters + + devices[0].remove() + + await agent.unregister(message_bus) diff --git a/tests/loopback/debug.sh b/tests/loopback/debug.sh new file mode 100755 index 0000000..9caf768 --- /dev/null +++ b/tests/loopback/debug.sh @@ -0,0 +1,4 @@ +PYTHON_ARGS="-m pytest tests/unit" +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 && python3 -m debugpy --listen 0.0.0.0:5678 --wait-for-client $PYTHON_ARGS +" \ No newline at end of file diff --git a/tests/loopback/gatt/__init__.py b/tests/loopback/gatt/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/loopback/test.sh b/tests/loopback/test.sh new file mode 100755 index 0000000..92d30f1 --- /dev/null +++ b/tests/loopback/test.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +set -euo pipefail + +IMAGE="${1}" +KEY_FILE="${2}" +PROJ_ROOT="${3}" +shift 3 + +INTERACTIVE=0 +while getopts ":i" opt; do + case $opt in + i) INTERACTIVE=1 ;; + \?) echo "Invalid option: -$OPTARG" >&2; exit 1 ;; + esac +done + +SSH="ssh -i $KEY_FILE -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" + +echo "[*] Starting QEMU session" +qemu-system-x86_64 \ + -m 2048 \ + -smp 2 \ + -nographic \ + -drive file="$IMAGE",format=qcow2 \ + -netdev user,id=net0,hostfwd=tcp::2244-:22 \ + -device virtio-net-pci,netdev=net0 \ + -no-reboot \ + -monitor none \ + -display none \ + -serial file:"serial.log" & + +QEMU_PID=$! +kill_qemu() { + if kill -0 $QEMU_PID 2>/dev/null; then + echo "[!] Killing QEMU" + kill $QEMU_PID 2>/dev/null || true + fi +} +trap kill_qemu EXIT + +SSH_UP=0 +for i in {1..30}; do + echo "[*] Waiting for SSH..." + if $SSH -p 2244 tester@localhost 'true'; then + echo "[✓] SSH Connected" + SSH_UP=1 + break + fi + sleep 5 +done + +if [[ $SSH_UP -ne 1 ]]; then + echo "[✗] SSH Connection Timed Out" + exit 1 +fi + +echo "[*] Copying bluez_peripheral" +rsync -a --progress --rsync-path="sudo rsync" \ + -e "$SSH -p 2244" --delete \ + --exclude tests/loopback/assets \ + --exclude docs \ + --exclude serial.log \ + $PROJ_ROOT tester@localhost:/bluez_peripheral + +$SSH -p 2244 tester@localhost " + set -euo pipefail + + echo '[*] Setting Up Dependencies' + python3 -m venv ~/venv + source ~/venv/bin/activate + python3 -m pip install -r /bluez_peripheral/tests/requirements.txt + + sudo nohup btvirt -L -l2 >/dev/null 2>&1 & + sudo service bluetooth start + + sudo cp /bluez_peripheral/tests/unit/com.spacecheese.test.conf /etc/dbus-1/system.d +" + +if (( INTERACTIVE )); then + $SSH -p 2244 tester@localhost +else + $SSH -p 2244 tester@localhost " + set -euo pipefail + + source ~/venv/bin/activate + cd /bluez_peripheral + echo '[*] Running Unit Tests' + pytest tests/unit -s + echo '[*] Running Loopback Tests' + pytest tests/loopback -s + sudo shutdown -h now + " + wait $QEMU_PID +fi diff --git a/tests/loopback/test_advert.py b/tests/loopback/test_advert.py new file mode 100644 index 0000000..5ada841 --- /dev/null +++ b/tests/loopback/test_advert.py @@ -0,0 +1,37 @@ +import asyncio +import pytest +import pytest_asyncio + +from bluez_peripheral.advert import Advertisement +from bluez_peripheral.uuid16 import UUID16 + + +@pytest.mark.asyncio +async def test_advertisement(message_bus, unpaired_adapters): + adapters = unpaired_adapters + + await adapters[0].set_powered(True) + await adapters[0].set_discoverable(True) + await adapters[0].set_pairable(True) + + await adapters[1].set_powered(True) + + advert = Advertisement( + "Heart Monitor", + ["180D", "1234"], + appearance=0x0340, + timeout=300, + duration=5, + ) + await advert.register(message_bus, adapter=adapters[0]) + + await adapters[1].start_discovery() + devices = await adapters[1].get_devices() + + assert len(devices) == 1 + assert await devices[0].get_alias() == "Heart Monitor" + assert await devices[0].get_appearance() == 0x0340 + uuids = set(await devices[0].get_uuids()) + assert uuids == set([UUID16("180D"), UUID16("1234")]) + + await adapters[1].stop_discovery() diff --git a/tests/requirements.txt b/tests/requirements.txt index 1026f99..8a0eddd 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1 +1,4 @@ -dbus_fast \ No newline at end of file +dbus_fast + +pytest +pytest-asyncio \ No newline at end of file diff --git a/tests/test_advert.py b/tests/test_advert.py deleted file mode 100644 index 999733a..0000000 --- a/tests/test_advert.py +++ /dev/null @@ -1,114 +0,0 @@ -from unittest import IsolatedAsyncioTestCase -from unittest.case import SkipTest - -from tests.util import * -from bluez_peripheral.util import get_message_bus -from bluez_peripheral.advert import Advertisement, PacketType, AdvertisingIncludes - -from uuid import UUID - - -class TestAdvert(IsolatedAsyncioTestCase): - async def asyncSetUp(self): - self._bus_manager = BusManager() - self._client_bus = await get_message_bus() - - async def asyncTearDown(self): - self._client_bus.disconnect() - self._bus_manager.close() - - async def test_basic(self): - advert = Advertisement( - "Testing Device Name", - ["180A", "180D"], - 0x0340, - 2, - packetType=PacketType.PERIPHERAL, - includes=AdvertisingIncludes.TX_POWER, - ) - - async def inspector(path): - introspection = await self._client_bus.introspect( - self._bus_manager.name, path - ) - proxy_object = self._client_bus.get_proxy_object( - self._bus_manager.name, path, introspection - ) - interface = proxy_object.get_interface("org.bluez.LEAdvertisement1") - - assert await interface.get_type() == "peripheral" - # Case of UUIDs is not important. - assert [id.lower() for id in await interface.get_service_uui_ds()] == [ - "180a", - "180d", - ] - assert await interface.get_local_name() == "Testing Device Name" - assert await interface.get_appearance() == 0x0340 - assert await interface.get_timeout() == 2 - assert await interface.get_includes() == ["tx-power"] - - path = "/com/spacecheese/bluez_peripheral/test_advert/advert0" - adapter = MockAdapter(inspector) - await advert.register(self._bus_manager.bus, adapter, path) - - async def test_includes_empty(self): - advert = Advertisement( - "Testing Device Name", - ["180A", "180D"], - 0x0340, - 2, - packetType=PacketType.PERIPHERAL, - includes=AdvertisingIncludes.NONE, - ) - - async def inspector(path): - introspection = await self._client_bus.introspect( - self._bus_manager.name, path - ) - proxy_object = self._client_bus.get_proxy_object( - self._bus_manager.name, path, introspection - ) - interface = proxy_object.get_interface("org.bluez.LEAdvertisement1") - - assert await interface.get_includes() == [] - - adapter = MockAdapter(inspector) - await advert.register(self._bus_manager.bus, adapter) - - async def test_uuid128(self): - advert = Advertisement( - "Improv Test", - [UUID("00467768-6228-2272-4663-277478268000")], - 0x0340, - 2, - ) - - async def inspector(path): - introspection = await self._client_bus.introspect( - self._bus_manager.name, path - ) - proxy_object = self._client_bus.get_proxy_object( - self._bus_manager.name, path, introspection - ) - interface = proxy_object.get_interface("org.bluez.LEAdvertisement1") - - assert [id.lower() for id in await interface.get_service_uui_ds()] == [ - "00467768-6228-2272-4663-277478268000", - ] - print(await interface.get_service_uui_ds()) - - adapter = MockAdapter(inspector) - await advert.register(self._bus_manager.bus, adapter) - - async def test_real(self): - await bluez_available_or_skip(self._client_bus) - adapter = await get_first_adapter_or_skip(self._client_bus) - - advert = Advertisement( - "Testing Device Name", - ["180A", "180D"], - 0x0340, - 2, - ) - - await advert.register(self._client_bus, adapter) diff --git a/tests/test_agent.py b/tests/test_agent.py deleted file mode 100644 index 94db5cd..0000000 --- a/tests/test_agent.py +++ /dev/null @@ -1,50 +0,0 @@ -from unittest import IsolatedAsyncioTestCase - -from tests.util import * - -from bluez_peripheral.util import get_message_bus -from bluez_peripheral.agent import AgentCapability, BaseAgent - - -class MockBus: - async def introspect(self, name, path): - return self - - def get_proxy_object(self, object, path, intro): - return self - - def get_interface(self, int): - return self - - def export(self, path, obj): - self._path = path - return self - - async def call_register_agent(self, path, capability): - assert path == path - self._capability = capability - return self - - async def call_request_default_agent(self, path): - assert path == path - - -class TestAgent(IsolatedAsyncioTestCase): - async def asyncSetUp(self): - self._bus_manager = BusManager() - self._client_bus = await get_message_bus() - - async def asyncTearDown(self): - self._client_bus.disconnect() - self._bus_manager.close() - - async def test_base_agent_capability(self): - agent = BaseAgent(AgentCapability.KEYBOARD_DISPLAY) - bus = MockBus() - await agent.register(bus) - assert bus._capability == "KeyboardDisplay" - - agent = BaseAgent(AgentCapability.NO_INPUT_NO_OUTPUT) - bus = MockBus() - await agent.register(bus) - assert bus._capability == "NoInputNoOutput" diff --git a/tests/test_util.py b/tests/test_util.py deleted file mode 100644 index 3195b57..0000000 --- a/tests/test_util.py +++ /dev/null @@ -1,76 +0,0 @@ -from unittest import IsolatedAsyncioTestCase - -from tests.util import * - -from bluez_peripheral.util import * - - -class TestUtil(IsolatedAsyncioTestCase): - async def asyncSetUp(self) -> None: - self._bus = await get_message_bus() - await bluez_available_or_skip(self._bus) - - self._adapter = await get_first_adapter_or_skip(self._bus) - - async def asyncTearDown(self) -> None: - self._bus.disconnect() - - async def test_get_first(self): - if not len(await Adapter.get_all(self._bus)) > 0: - with self.assertRaises(ValueError): - Adapter.get_first(self._bus) - else: - assert type(await Adapter.get_first(self._bus)) == Adapter - - async def test_alias_set(self): - await self._adapter.set_alias("Some test name") - assert await self._adapter.get_alias() == "Some test name" - - async def test_alias_clear(self): - await self._adapter.set_alias("") - assert await self._adapter.get_alias() == await self._adapter.get_name() - - async def test_powered(self): - initial_powered = await self._adapter.get_powered() - - await self._adapter.set_powered(False) - assert await self._adapter.get_powered() == False - await self._adapter.set_powered(True) - assert await self._adapter.get_powered() == True - await self._adapter.set_powered(initial_powered) - - async def test_discoverable(self): - initial_discoverable = await self._adapter.get_discoverable() - - await self._adapter.set_discoverable(False) - assert await self._adapter.get_discoverable() == False - await self._adapter.set_discoverable(True) - assert await self._adapter.get_discoverable() == True - await self._adapter.set_discoverable(initial_discoverable) - - async def test_pairable(self): - initial_pairable = await self._adapter.get_pairable() - - await self._adapter.set_pairable(False) - assert await self._adapter.get_pairable() == False - await self._adapter.set_pairable(True) - assert await self._adapter.get_pairable() == True - await self._adapter.set_pairable(initial_pairable) - - async def test_pairable_timeout(self): - initial_pairable_timeout = await self._adapter.get_pairable_timeout() - - await self._adapter.set_pairable_timeout(30) - assert await self._adapter.get_pairable_timeout() == 30 - await self._adapter.set_pairable_timeout(0) - assert await self._adapter.get_pairable_timeout() == 0 - await self._adapter.set_pairable_timeout(initial_pairable_timeout) - - async def test_discoverable_timeout(self): - initial_discoverable_timeout = await self._adapter.get_discoverable_timeout() - - await self._adapter.set_discoverable_timeout(30) - assert await self._adapter.get_discoverable_timeout() == 30 - await self._adapter.set_discoverable_timeout(0) - assert await self._adapter.get_discoverable_timeout() == 0 - await self._adapter.set_discoverable_timeout(initial_discoverable_timeout) diff --git a/tests/test_uuid16.py b/tests/test_uuid16.py deleted file mode 100644 index 27c292c..0000000 --- a/tests/test_uuid16.py +++ /dev/null @@ -1,79 +0,0 @@ -from multiprocessing.sharedctypes import Value -import unittest - -from bluez_peripheral.uuid16 import UUID16 -from uuid import UUID, uuid1 - - -class TestUUID16(unittest.TestCase): - def test_from_hex(self): - with self.assertRaises(ValueError): - uuid = UUID16("123") - - with self.assertRaises(ValueError): - uuid = UUID16("12345") - - uuid = UUID16("1234") - assert uuid.uuid == UUID("00001234-0000-1000-8000-00805F9B34FB") - - uuid = UUID16("00001234-0000-1000-8000-00805F9B34FB") - assert uuid.uuid == UUID("00001234-0000-1000-8000-00805F9B34FB") - - def test_from_bytes(self): - with self.assertRaises(ValueError): - uuid = UUID16(bytes=b'\x12') - - with self.assertRaises(ValueError): - uuid = UUID16(bytes=b'\x12\x34\x56') - - uuid = UUID16(bytes=b'\x12\x34') - assert uuid.uuid == UUID("00001234-0000-1000-8000-00805F9B34FB") - - uuid = UUID16(bytes=UUID("00001234-0000-1000-8000-00805F9B34FB").bytes) - assert uuid.uuid == UUID("00001234-0000-1000-8000-00805F9B34FB") - - def test_from_int(self): - with self.assertRaises(ValueError): - uuid = UUID16(int=0x12345) - - uuid = UUID16(int=0x1234) - assert uuid.uuid == UUID("00001234-0000-1000-8000-00805F9B34FB") - - uuid = UUID16(int=0x0000123400001000800000805F9B34FB) - assert uuid.uuid == UUID("00001234-0000-1000-8000-00805F9B34FB") - - def test_parse_uuid(self): - uuid = UUID("00001234-0000-1000-8000-00805F9B34FB") - assert type(UUID16.parse_uuid(uuid)) is UUID16 - - uuid = UUID("00011234-0000-1000-8000-00805F9B34FB") - assert type(UUID16.parse_uuid(uuid)) is UUID - - def test_from_uuid(self): - with self.assertRaises(ValueError): - uuid = UUID16(uuid=uuid1()) - - uuid = UUID16(uuid=UUID("00001234-0000-1000-8000-00805F9B34FB")) - assert uuid.uuid == UUID("00001234-0000-1000-8000-00805F9B34FB") - - def test_is_in_range(self): - uuid = UUID("00001234-0000-1000-8000-00805F9B34FB") - assert UUID16.is_in_range(uuid) == True - - uuid = UUID("00011234-0000-1000-8000-00805F9B34FB") - assert UUID16.is_in_range(uuid) == False - - uuid = UUID("00001234-0000-1000-8000-00805F9B34FC") - assert UUID16.is_in_range(uuid) == True - - def test_int(self): - uuid = UUID16(uuid=UUID("00001234-0000-1000-8000-00805F9B34FB")) - assert uuid.int == 0x1234 - - def test_bytes(self): - uuid = UUID16(uuid=UUID("00001234-0000-1000-8000-00805F9B34FB")) - assert uuid.bytes == b'\x12\x34' - - def test_hex(self): - uuid = UUID16(uuid=UUID("00001234-0000-1000-8000-00805F9B34FB")) - assert uuid.hex == '1234' \ No newline at end of file diff --git a/tests/README.md b/tests/unit/README.md similarity index 73% rename from tests/README.md rename to tests/unit/README.md index e0479bc..60ced07 100644 --- a/tests/README.md +++ b/tests/unit/README.md @@ -13,7 +13,8 @@ Add the dbus config to allow the test process access to the bluetooth daemon. ```bash sudo cp com.spacecheese.test.conf /etc/dbus-1/system.d ``` - -To run the tests, you can execute the following command: - -`python -m unittest discover -s tests -p "test_*.py"` +# Run the Tests +Run tests from the root project directory (eg bluez_peripheral). +```bash +python3 -m pytest tests/unit +``` diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/com.spacecheese.test.conf b/tests/unit/com.spacecheese.test.conf similarity index 100% rename from tests/com.spacecheese.test.conf rename to tests/unit/com.spacecheese.test.conf diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 0000000..9c4955b --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,15 @@ +import pytest_asyncio + +from .util import BackgroundServiceManager + + +@pytest_asyncio.fixture +async def background_service(services, bus_name, bus_path): + manager = BackgroundServiceManager() + await manager.start(bus_name) + manager.register(services, bus_path) + + yield manager + + manager.unregister() + await manager.stop() diff --git a/tests/unit/gatt/__init__.py b/tests/unit/gatt/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/gatt/test_characteristic.py b/tests/unit/gatt/test_characteristic.py new file mode 100644 index 0000000..795d885 --- /dev/null +++ b/tests/unit/gatt/test_characteristic.py @@ -0,0 +1,279 @@ +import asyncio +import re + +import pytest +import pytest_asyncio + +from dbus_fast import Variant + +from bluez_peripheral.gatt.characteristic import ( + CharacteristicFlags, + CharacteristicWriteType, + characteristic, +) +from bluez_peripheral.gatt.descriptor import descriptor +from bluez_peripheral.gatt.service import Service, ServiceCollection + +from ..util import ( + ServiceNode, + get_first_adapter_or_skip, + bluez_available_or_skip, +) + + +class MockService(Service): + def __init__(self): + super().__init__("180A") + + @characteristic("2A37", CharacteristicFlags.READ) + def read_only_char(self, opts): + self.last_opts = opts + return bytes("Test Message", "utf-8") + + @characteristic("3A37", CharacteristicFlags.READ) + async def async_read_only_char(self, opts): + self.last_opts = opts + return bytes("Test Message", "utf-8") + + # Not testing other characteristic flags since their functionality is handled by bluez. + @characteristic("2A38", CharacteristicFlags.NOTIFY | CharacteristicFlags.WRITE) + def write_notify_char(self, _): + raise NotImplementedError() + + @write_notify_char.setter + def write_notify_char(self, val, opts): + self.last_opts = opts + self.val = val + + @characteristic("3A38", CharacteristicFlags.WRITE) + async def aysnc_write_only_char(self, _): + raise NotImplementedError() + + @aysnc_write_only_char.setter + async def aysnc_write_only_char(self, val, opts): + self.last_opts = opts + self.val = val + + @characteristic("3A33", CharacteristicFlags.WRITE | CharacteristicFlags.READ) + def read_write_char(self, opts): + self.last_opts = opts + return self.val + + @read_write_char.setter + def read_write_char(self, val, opts): + self.last_opts = opts + self.val = val + + +@pytest.fixture +def service(): + return MockService() + + +@pytest.fixture +def services(service): + return ServiceCollection([service]) + + +@pytest.fixture +def bus_name(): + return "com.spacecheese.test" + + +@pytest.fixture +def bus_path(): + return "/com/spacecheese/bluez_peripheral/test" + + +@pytest.mark.asyncio +async def test_structure(message_bus, background_service, bus_name, bus_path): + service_collection = await ServiceNode.from_service_collection( + message_bus, bus_name, bus_path + ) + service = await service_collection.get_child("180A") + char = await service.get_children() + + child_names = [c.bus_path.split("/")[-1] for c in char.values()] + child_names.sort() + + assert len(child_names) == 5 + + i = 0 + # Numbering may not have gaps. + for name in child_names: + assert re.match(r"^char0{0,3}" + str(i) + "$", name) + i += 1 + + +@pytest.mark.asyncio +async def test_read(message_bus, service, background_service, bus_name, bus_path): + opts = { + "offset": Variant("q", 0), + "mtu": Variant("q", 128), + "device": Variant("s", "blablabla/.hmm"), + } + + service_collection = await ServiceNode.from_service_collection( + message_bus, bus_name, bus_path + ) + char = await service_collection.get_child("180A", "2A37") + resp = await char.attr_interface.call_read_value(opts) + cache = await char.attr_interface.get_value() + + assert resp.decode("utf-8") == "Test Message" + assert service.last_opts.offset == 0 + assert service.last_opts.mtu == 128 + assert service.last_opts.device == "blablabla/.hmm" + assert cache == resp + + char = await service_collection.get_child("180A", "3A37") + resp = await char.attr_interface.call_read_value(opts) + cache = await char.attr_interface.get_value() + + assert resp.decode("utf-8") == "Test Message" + assert cache == resp + + +@pytest.mark.asyncio +async def test_write(message_bus, service, background_service, bus_name, bus_path): + opts = { + "offset": Variant("q", 10), + "type": Variant("s", "request"), + "mtu": Variant("q", 128), + "device": Variant("s", "blablabla/.hmm"), + "link": Variant("s", "yuyuyuy"), + "prepare-authorize": Variant("b", False), + } + + service_collection = await ServiceNode.from_service_collection( + message_bus, bus_name, bus_path + ) + char = await service_collection.get_child("180A", "2A38") + await char.attr_interface.call_write_value(bytes("Test Write Value", "utf-8"), opts) + + assert service.last_opts.offset == 10 + assert service.last_opts.type == CharacteristicWriteType.REQUEST + assert service.last_opts.mtu == 128 + assert service.last_opts.device == "blablabla/.hmm" + assert service.last_opts.link == "yuyuyuy" + assert service.last_opts.prepare_authorize == False + + assert service.val.decode("utf-8") == "Test Write Value" + + char = await service_collection.get_child("180A", "3A38") + await char.attr_interface.call_write_value(bytes("Test Write Value", "utf-8"), opts) + + assert service.val.decode("utf-8") == "Test Write Value" + + +@pytest.mark.asyncio +async def test_notify_no_start( + message_bus, service, background_service, bus_name, bus_path +): + service_collection = await ServiceNode.from_service_collection( + message_bus, bus_name, bus_path + ) + char = await service_collection.get_child("180A", "2A38") + prop_interface = char.proxy.get_interface("org.freedesktop.DBus.Properties") + + def on_properties_changed(_0, _1, _2): + property_changed.set() + + prop_interface.on_properties_changed(on_properties_changed) + + +@pytest.mark.asyncio +async def test_notify_start_stop( + message_bus, service, background_service, bus_name, bus_path +): + service_collection = await ServiceNode.from_service_collection( + message_bus, bus_name, bus_path + ) + char = await service_collection.get_child("180A", "2A38") + properties_interface = char.proxy.get_interface("org.freedesktop.DBus.Properties") + + foreground_loop = asyncio.get_running_loop() + properties_changed = foreground_loop.create_future() + + def _good_on_properties_changed(interface, values, invalid_props): + assert interface == "org.bluez.GattCharacteristic1" + assert len(values) == 1 + assert values["Value"].value.decode("utf-8") == "Test Notify Value" + foreground_loop.call_soon_threadsafe(properties_changed.set_result, ()) + + properties_interface.on_properties_changed(_good_on_properties_changed) + await char.attr_interface.call_start_notify() + + service.write_notify_char.changed(bytes("Test Notify Value", "utf-8")) + await asyncio.wait_for(properties_changed, timeout=0.1) + + properties_changed = foreground_loop.create_future() + + def _bad_on_properties_changed(interface, values, invalid_props): + ex = AssertionError( + "on_properties_changed triggered after call_stop_notify called" + ) + foreground_loop.call_soon_threadsafe(properties_changed.set_exception, (ex)) + + properties_interface.on_properties_changed(_bad_on_properties_changed) + await char.attr_interface.call_stop_notify() + + service.write_notify_char.changed(bytes("Test Notify Value", "utf-8")) + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(properties_changed, timeout=0.1) + + +@pytest.mark.asyncio +async def test_modify( + message_bus, service, services, background_service, bus_name, bus_path +): + opts = { + "offset": Variant("q", 0), + "mtu": Variant("q", 128), + "device": Variant("s", "blablabla/.hmm"), + } + + service_collection = await ServiceNode.from_service_collection( + message_bus, bus_name, bus_path + ) + + with pytest.raises(KeyError): + await service_collection.get_child("180A", "2A38", "2D56") + + background_service.unregister() + + @descriptor("2D56", service.write_notify_char) + def some_desc(service, opts): + return bytes("Some Test Value", "utf-8") + + background_service.register(services, bus_path) + desc = await service_collection.get_child("180A", "2A38", "2D56") + resp = await desc.attr_interface.call_read_value(opts) + assert resp.decode("utf-8") == "Some Test Value" + + background_service.unregister() + service.write_notify_char.remove_child(some_desc) + + background_service.register(services, bus_path) + with pytest.raises(KeyError): + await service_collection.get_child("180A", "2A38", "2D56") + + +@pytest.mark.asyncio +async def test_bluez(message_bus, services): + await bluez_available_or_skip(message_bus) + adapter = await get_first_adapter_or_skip(message_bus) + + initial_powered = await adapter.get_powered() + initial_discoverable = await adapter.get_discoverable() + + await adapter.set_powered(True) + await adapter.set_discoverable(True) + + try: + await services.register(message_bus, adapter=adapter) + finally: + await services.unregister() + + await adapter.set_discoverable(initial_discoverable) + await adapter.set_powered(initial_powered) diff --git a/tests/unit/gatt/test_descriptor.py b/tests/unit/gatt/test_descriptor.py new file mode 100644 index 0000000..80c7de2 --- /dev/null +++ b/tests/unit/gatt/test_descriptor.py @@ -0,0 +1,172 @@ +import asyncio +import re + +import pytest +import pytest_asyncio + +from dbus_fast import Variant + +from bluez_peripheral.gatt.characteristic import ( + CharacteristicFlags, + characteristic, +) +from bluez_peripheral.gatt.descriptor import descriptor, DescriptorFlags +from bluez_peripheral.gatt.service import Service, ServiceCollection + +from ..util import ServiceNode + + +class MockService(Service): + def __init__(self): + super().__init__("180A") + + read_write_val = b"\x05" + + @characteristic("2A37", CharacteristicFlags.RELIABLE_WRITE) + def some_char(self, _): + return bytes("Some Other Test Message", "utf-8") + + @some_char.descriptor("2A38") + def read_only_desc(self, opts): + self.last_opts = opts + return bytes("Test Message", "utf-8") + + @some_char.descriptor("3A38") + async def async_read_only_desc(self, opts): + self.last_opts = opts + await asyncio.sleep(0.05) + return bytes("Test Message", "utf-8") + + @descriptor("2A39", some_char, DescriptorFlags.WRITE) + def write_desc(self, _): + return bytes() + + @write_desc.setter + def write_desc_set(self, val, opts): + self.last_opts = opts + self.write_desc_val = val + + @descriptor("3A39", some_char, DescriptorFlags.WRITE) + async def async_write_desc(self, _): + return bytes() + + @async_write_desc.setter + async def async_write_desc_set(self, val, opts): + self.last_opts = opts + await asyncio.sleep(0.05) + self.async_write_desc_val = val + + @descriptor("3A33", some_char, DescriptorFlags.WRITE | DescriptorFlags.READ) + def read_write_desc(self, opts): + return self.read_write_val + + @read_write_desc.setter + def read_write_desc(self, val, opts): + self.read_write_val = val + + +@pytest.fixture +def service(): + return MockService() + + +@pytest.fixture +def services(service): + return ServiceCollection([service]) + + +@pytest.fixture +def bus_name(): + return "com.spacecheese.test" + + +@pytest.fixture +def bus_path(): + return "/com/spacecheese/bluez_peripheral/test" + + +@pytest.mark.asyncio +async def test_structure(message_bus, background_service, bus_name, bus_path): + service_collection = await ServiceNode.from_service_collection( + message_bus, bus_name, bus_path + ) + service = await service_collection.get_child("180A") + char = await service.get_child("2A37") + descs = await char.get_children() + + child_names = [c.bus_path.split("/")[-1] for c in descs.values()] + child_names.sort() + + assert len(child_names) == 5 + + i = 0 + # Numbering may not have gaps. + for name in child_names: + assert re.match(r"^desc0{0,2}" + str(i) + "$", name) + i += 1 + + +@pytest.mark.asyncio +async def test_read(message_bus, service, background_service, bus_name, bus_path): + opts = { + "offset": Variant("q", 0), + "link": Variant("s", "dododo"), + "device": Variant("s", "bebealbl/.afal"), + } + + service_collection = await ServiceNode.from_service_collection( + message_bus, bus_name, bus_path + ) + desc = await service_collection.get_child("180A", "2A37", "2A38") + resp = await desc.attr_interface.call_read_value(opts) + + assert resp.decode("utf-8") == "Test Message" + assert service.last_opts.offset == 0 + assert service.last_opts.link == "dododo" + assert service.last_opts.device == "bebealbl/.afal" + + desc = await service_collection.get_child("180A", "2A37", "3A38") + resp = await desc.attr_interface.call_read_value(opts) + + assert resp.decode("utf-8") == "Test Message" + + +@pytest.mark.asyncio +async def test_write(message_bus, service, background_service, bus_name, bus_path): + opts = { + "offset": Variant("q", 1), + "device": Variant("s", "bebealbl/.afal"), + "link": Variant("s", "gogog"), + "prepare-authorize": Variant("b", True), + } + + service_collection = await ServiceNode.from_service_collection( + message_bus, bus_name, bus_path + ) + desc = await service_collection.get_child("180A", "2A37", "2A39") + await desc.attr_interface.call_write_value(bytes("Test Write Value", "utf-8"), opts) + + assert service.last_opts.offset == 1 + assert service.last_opts.device == "bebealbl/.afal" + assert service.last_opts.link == "gogog" + assert service.last_opts.prepare_authorize == True + + assert service.write_desc_val.decode("utf-8") == "Test Write Value" + + desc = await service_collection.get_child("180A", "2A37", "3A39") + await desc.attr_interface.call_write_value(bytes("Test Write Value", "utf-8"), opts) + + assert service.async_write_desc_val.decode("utf-8") == "Test Write Value" + + +@pytest.mark.asyncio +async def test_empty_opts(message_bus, service, background_service, bus_name, bus_path): + service_collection = await ServiceNode.from_service_collection( + message_bus, bus_name, bus_path + ) + desc = await service_collection.get_child("180A", "2A37", "3A33") + resp = await desc.attr_interface.call_read_value({}) + assert resp == b"\x05" + await desc.attr_interface.call_write_value(bytes("Test Write Value", "utf-8"), {}) + resp = await desc.attr_interface.call_read_value({}) + assert resp == bytes("Test Write Value", "utf-8") diff --git a/tests/unit/gatt/test_service.py b/tests/unit/gatt/test_service.py new file mode 100644 index 0000000..60c8aaa --- /dev/null +++ b/tests/unit/gatt/test_service.py @@ -0,0 +1,127 @@ +import re +from typing import Collection + +import pytest +import pytest_asyncio + +from bluez_peripheral.gatt.service import Service, ServiceCollection + +from ..util import ServiceNode + + +class MockService1(Service): + def __init__(self, includes: Collection[Service]): + super().__init__("180A", primary=False, includes=includes) + + +class MockService2(Service): + def __init__(self): + super().__init__("180B") + + +class MockService3(Service): + def __init__(self): + super().__init__("180C") + + +@pytest.fixture +def service1(service2, service3): + return MockService1([service2, service3]) + + +@pytest.fixture +def service2(): + return MockService2() + + +@pytest.fixture +def service3(): + return MockService3() + + +@pytest.fixture +def services(service1, service2, service3): + return ServiceCollection([service1, service2, service3]) + + +@pytest.fixture +def bus_name(): + return "com.spacecheese.test" + + +@pytest.fixture +def bus_path(): + return "/com/spacecheese/bluez_peripheral/test" + + +@pytest.mark.asyncio +async def test_structure(message_bus, background_service, bus_name, bus_path): + service_collection = await ServiceNode.from_service_collection( + message_bus, bus_name, bus_path + ) + + children = await service_collection.get_children() + child_names = [c.bus_path.split("/")[-1] for c in children.values()] + child_names = sorted(child_names) + + assert len(child_names) == 3 + + # Numbering may not have gaps. + i = 0 + for name in child_names: + assert re.match(r"^service0?" + str(i) + "$", name) + i += 1 + + +@pytest.mark.asyncio +async def test_include_modify( + message_bus, + service3, + services, + bus_name, + bus_path, + background_service, +): + service_collection = await ServiceNode.from_service_collection( + message_bus, bus_name, bus_path + ) + service1_node = await service_collection.get_child("180A") + service2_node = await service_collection.get_child("180B") + service3_node = await service_collection.get_child("180C") + + includes = await service1_node.attr_interface.get_includes() + assert set(includes) == set( + [service1_node.bus_path, service2_node.bus_path, service3_node.bus_path] + ) + + background_service.unregister() + services.remove_child(service3) + background_service.register(services, bus_path) + + service_collection = await ServiceNode.from_service_collection( + message_bus, bus_name, bus_path + ) + service1_node = await service_collection.get_child("180A") + service2_node = await service_collection.get_child("180B") + + includes = await service1_node.attr_interface.get_includes() + assert set(includes) == set([service1_node.bus_path, service2_node.bus_path]) + + with pytest.raises(KeyError): + await service_collection.get_child("180C") + + background_service.unregister() + services.add_child(service3) + background_service.register(services, bus_path) + + service_collection = await ServiceNode.from_service_collection( + message_bus, bus_name, bus_path + ) + service1_node = await service_collection.get_child("180A") + service2_node = await service_collection.get_child("180B") + service3_node = await service_collection.get_child("180C") + + includes = await service1_node.attr_interface.get_includes() + assert set(includes) == set( + [service1_node.bus_path, service2_node.bus_path, service3_node.bus_path] + ) diff --git a/tests/unit/test_advert.py b/tests/unit/test_advert.py new file mode 100644 index 0000000..3b97603 --- /dev/null +++ b/tests/unit/test_advert.py @@ -0,0 +1,129 @@ +from uuid import UUID + +import asyncio +import pytest + +from bluez_peripheral.advert import Advertisement, AdvertisingIncludes +from bluez_peripheral.flags import AdvertisingPacketType + +from .util import ( + get_first_adapter_or_skip, + bluez_available_or_skip, + BackgroundAdvertManager, +) + + +@pytest.fixture +def bus_name(): + return "com.spacecheese.test" + + +@pytest.fixture +def bus_path(): + return "/com/spacecheese/bluez_peripheral/test" + + +@pytest.mark.asyncio +async def test_basic(message_bus, bus_name, bus_path): + advert = Advertisement( + "Testing Device Name", + ["180A", "180D"], + appearance=0x0340, + timeout=2, + packet_type=AdvertisingPacketType.PERIPHERAL, + includes=AdvertisingIncludes.TX_POWER, + ) + manager = BackgroundAdvertManager() + await manager.start(bus_name) + manager.register(advert, 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") + + assert await interface.get_type() == "peripheral" + # Case of UUIDs is not important. + assert [id.lower() for id in await interface.get_service_uui_ds()] == [ + "180a", + "180d", + ] + assert await interface.get_local_name() == "Testing Device Name" + assert await interface.get_appearance() == 0x0340 + assert await interface.get_timeout() == 2 + assert await interface.get_includes() == ["tx-power"] + + manager.unregister() + await manager.stop() + + +@pytest.mark.asyncio +async def test_includes_empty(message_bus, bus_name, bus_path): + advert = Advertisement( + "Testing Device Name", + ["180A", "180D"], + appearance=0x0340, + timeout=2, + packet_type=AdvertisingPacketType.PERIPHERAL, + includes=AdvertisingIncludes.NONE, + ) + manager = BackgroundAdvertManager() + await manager.start(bus_name) + manager.register(advert, 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") + assert await interface.get_includes() == [] + + manager.unregister() + await manager.stop() + + +@pytest.mark.asyncio +async def test_uuid128(message_bus, bus_name, bus_path): + advert = Advertisement( + "Improv Test", + [UUID("00467768-6228-2272-4663-277478268000")], + appearance=0x0340, + timeout=2, + ) + manager = BackgroundAdvertManager() + await manager.start(bus_name) + manager.register(advert, 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") + assert [id.lower() for id in await interface.get_service_uui_ds()] == [ + "00467768-6228-2272-4663-277478268000", + ] + + manager.unregister() + await manager.stop() + + +@pytest.mark.asyncio +async def test_bluez(message_bus): + await bluez_available_or_skip(message_bus) + adapter = await get_first_adapter_or_skip(message_bus) + + initial_powered = await adapter.get_powered() + initial_discoverable = await adapter.get_discoverable() + + await adapter.set_powered(True) + await adapter.set_discoverable(True) + + advert = Advertisement( + "Testing Device Name", + ["180A", "180D"], + appearance=0x0340, + timeout=2, + ) + + try: + await advert.register(message_bus, adapter=adapter) + finally: + await advert.unregister() + + await adapter.set_discoverable(initial_discoverable) + await adapter.set_powered(initial_powered) diff --git a/tests/unit/test_agent.py b/tests/unit/test_agent.py new file mode 100644 index 0000000..a631503 --- /dev/null +++ b/tests/unit/test_agent.py @@ -0,0 +1,35 @@ +import pytest +import asyncio +from unittest.mock import MagicMock, AsyncMock + +from bluez_peripheral.agent import AgentCapability, BaseAgent + +from .util import make_message_bus_mock + + +@pytest.mark.asyncio +async def test_base_agent_capability(): + mock_bus = make_message_bus_mock() + mock_proxy = mock_bus.get_proxy_object.return_value + mock_interface = mock_proxy.get_interface.return_value + bus_path = "/com/spacecheese/bluez_peripheral/agent0" + + agent = BaseAgent(AgentCapability.KEYBOARD_DISPLAY) + + await agent.register(mock_bus, path=bus_path) + mock_interface.call_register_agent.assert_awaited_once_with( + bus_path, "KeyboardDisplay" + ) + await agent.unregister() + + mock_bus.reset_mock() + agent = BaseAgent(AgentCapability.NO_INPUT_NO_OUTPUT) + + await agent.register(mock_bus, path=bus_path, default=True) + mock_interface.call_register_agent.assert_awaited_once_with( + bus_path, "NoInputNoOutput" + ) + mock_interface.call_request_default_agent.assert_awaited_once_with(bus_path) + await agent.unregister() + + mock_interface.call_unregister_agent.assert_awaited_once_with(bus_path) diff --git a/tests/unit/test_util.py b/tests/unit/test_util.py new file mode 100644 index 0000000..c89b0ad --- /dev/null +++ b/tests/unit/test_util.py @@ -0,0 +1,141 @@ +import pytest + +from bluez_peripheral.adapter import Adapter +from bluez_peripheral.util import get_message_bus +from ..unit.util import get_first_adapter_or_skip, bluez_available_or_skip + + +@pytest.mark.asyncio +async def test_get_first(): + bus = await get_message_bus() + await bluez_available_or_skip(bus) + + if not len(await Adapter.get_all(bus)) > 0: + with pytest.raises(ValueError): + Adapter.get_first(bus) + else: + assert isinstance(await Adapter.get_first(bus), Adapter) + + bus.disconnect() + + +@pytest.mark.asyncio +async def test_alias_set(): + bus = await get_message_bus() + await bluez_available_or_skip(bus) + + adapter = await get_first_adapter_or_skip(bus) + + await adapter.set_alias("Some test name") + assert await adapter.get_alias() == "Some test name" + + bus.disconnect() + + +@pytest.mark.asyncio +async def test_alias_clear(): + bus = await get_message_bus() + await bluez_available_or_skip(bus) + + adapter = await get_first_adapter_or_skip(bus) + + await adapter.set_alias("") + assert await adapter.get_alias() == await adapter.get_name() + + bus.disconnect() + + +@pytest.mark.asyncio +async def test_powered(): + bus = await get_message_bus() + await bluez_available_or_skip(bus) + + adapter = await get_first_adapter_or_skip(bus) + + initial_powered = await adapter.get_powered() + + await adapter.set_powered(False) + assert not await adapter.get_powered() + await adapter.set_powered(True) + assert await adapter.get_powered() + await adapter.set_powered(initial_powered) + + bus.disconnect() + + +@pytest.mark.asyncio +async def test_discoverable(): + bus = await get_message_bus() + await bluez_available_or_skip(bus) + + adapter = await get_first_adapter_or_skip(bus) + + initial_discoverable = await adapter.get_discoverable() + initial_powered = await adapter.get_powered() + + await adapter.set_powered(True) + + await adapter.set_discoverable(False) + assert not await adapter.get_discoverable() + await adapter.set_discoverable(True) + assert await adapter.get_discoverable() + await adapter.set_discoverable(initial_discoverable) + await adapter.set_powered(initial_powered) + + bus.disconnect() + + +@pytest.mark.asyncio +async def test_pairable(): + bus = await get_message_bus() + await bluez_available_or_skip(bus) + + adapter = await get_first_adapter_or_skip(bus) + + initial_pairable = await adapter.get_pairable() + initial_powered = await adapter.get_powered() + + await adapter.set_pairable(False) + assert not await adapter.get_pairable() + await adapter.set_pairable(True) + assert await adapter.get_pairable() + await adapter.set_pairable(initial_pairable) + await adapter.set_powered(initial_powered) + + bus.disconnect() + + +@pytest.mark.asyncio +async def test_pairable_timeout(): + bus = await get_message_bus() + await bluez_available_or_skip(bus) + + adapter = await get_first_adapter_or_skip(bus) + + initial_pairable_timeout = await adapter.get_pairable_timeout() + + await adapter.set_pairable_timeout(30) + assert await adapter.get_pairable_timeout() == 30 + await adapter.set_pairable_timeout(0) + assert await adapter.get_pairable_timeout() == 0 + await adapter.set_pairable_timeout(initial_pairable_timeout) + + bus.disconnect() + + +@pytest.mark.asyncio +async def test_discoverable_timeout(): + bus = await get_message_bus() + await bluez_available_or_skip(bus) + + adapter = await get_first_adapter_or_skip(bus) + + initial_discoverable_timeout = await adapter.get_discoverable_timeout() + + await adapter.set_discoverable_timeout(30) + assert await adapter.get_discoverable_timeout() == 30 + await adapter.set_discoverable_timeout(0) + assert await adapter.get_discoverable_timeout() == 0 + await adapter.set_discoverable_timeout(initial_discoverable_timeout) + + bus.disconnect() diff --git a/tests/unit/test_uuid16.py b/tests/unit/test_uuid16.py new file mode 100644 index 0000000..0f703cc --- /dev/null +++ b/tests/unit/test_uuid16.py @@ -0,0 +1,137 @@ +from uuid import UUID, uuid1 + +import pytest + +from bluez_peripheral.uuid16 import UUID16 + + +def test_from_hex(): + with pytest.raises(ValueError): + uuid = UUID16(hex="123") + + with pytest.raises(ValueError): + uuid = UUID16(hex="12345") + + uuid = UUID16(hex="1234") + assert uuid.uuid == UUID("00001234-0000-1000-8000-00805F9B34FB") + + uuid = UUID16(hex="00001234-0000-1000-8000-00805F9B34FB") + assert uuid.uuid == UUID("00001234-0000-1000-8000-00805F9B34FB") + + +def test_from_bytes(): + with pytest.raises(ValueError): + uuid = UUID16(bytes=b"\x12") + + with pytest.raises(ValueError): + uuid = UUID16(bytes=b"\x12\x34\x56") + + uuid = UUID16(bytes=b"\x12\x34") + assert uuid.uuid == UUID("00001234-0000-1000-8000-00805F9B34FB") + + uuid = UUID16(bytes=UUID("00001234-0000-1000-8000-00805F9B34FB").bytes) + assert uuid.uuid == UUID("00001234-0000-1000-8000-00805F9B34FB") + + +def test_from_int(): + with pytest.raises(ValueError): + uuid = UUID16(int=0x12345) + + uuid = UUID16(int=0x1234) + assert uuid.uuid == UUID("00001234-0000-1000-8000-00805F9B34FB") + + uuid = UUID16(int=0x0000123400001000800000805F9B34FB) + assert uuid.uuid == UUID("00001234-0000-1000-8000-00805F9B34FB") + + +def test_parse_uuid(): + uuid = UUID("00001234-0000-1000-8000-00805F9B34FB") + assert isinstance(UUID16.parse_uuid(uuid), UUID16) + + uuid = "00001234-0000-1000-8000-00805F9B34FB" + assert isinstance(UUID16.parse_uuid(uuid), UUID16) + + uuid = b"\x00\x01\x12\x34\x00\x00\x10\x00\x80\x00\x00\x80\x5f\x9b\x34\xfb" + assert isinstance(UUID16.parse_uuid(uuid), UUID) + + uuid = 0x0000123400001000800000805F9B34FB + assert isinstance(UUID16.parse_uuid(uuid), UUID16) + + uuid = UUID("00011234-0000-1000-8000-00805F9B34FB") + assert isinstance(UUID16.parse_uuid(uuid), UUID) + + uuid = "00011234-0000-1000-8000-00805F9B34FB" + assert isinstance(UUID16.parse_uuid(uuid), UUID) + + uuid = b"\x00\x00\x12\x34\x00\x00\x10\x00\x80\x00\x00\x80\x5f\x9b\x34\xfb" + assert isinstance(UUID16.parse_uuid(uuid), UUID16) + + uuid = 0x0001123400001000800000805F9B34FB + assert isinstance(UUID16.parse_uuid(uuid), UUID) + + with pytest.raises(ValueError): + uuid = UUID16.parse_uuid(object()) + + +def test_from_uuid(): + with pytest.raises(ValueError): + uuid = UUID16(uuid=uuid1()) + + uuid = UUID16(uuid=UUID("00001234-0000-1000-8000-00805F9B34FB")) + assert uuid.uuid == UUID("00001234-0000-1000-8000-00805F9B34FB") + + +def test_is_in_range(): + uuid = UUID("00001234-0000-1000-8000-00805F9B34FB") + assert UUID16.is_in_range(uuid) + + uuid = UUID("00011234-0000-1000-8000-00805F9B34FB") + assert not UUID16.is_in_range(uuid) + + uuid = UUID("00001234-0000-1000-8000-00805F9B34FC") + assert UUID16.is_in_range(uuid) + + +def test_int(): + uuid = UUID16(uuid=UUID("00001234-0000-1000-8000-00805F9B34FB")) + assert uuid.int == 0x1234 + + +def test_bytes(): + uuid = UUID16(uuid=UUID("00001234-0000-1000-8000-00805F9B34FB")) + assert uuid.bytes == b"\x12\x34" + + +def test_hex(): + uuid = UUID16(uuid=UUID("00001234-0000-1000-8000-00805F9B34FB")) + assert uuid.hex == "1234" + + +def test_init(): + with pytest.raises(TypeError): + uuid = UUID16() + + +def test_eq(): + uuid_a = UUID("00001234-0000-1000-8000-00805F9B34FB") + uuid16_a = UUID16(uuid=uuid_a) + + uuid_b = UUID("00001236-0000-1000-8000-00805F9B34FB") + uuid16_b = UUID16(uuid=uuid_b) + + assert uuid16_a != uuid16_b + assert uuid16_b != uuid16_a + assert uuid16_a == uuid_a + assert uuid16_b == uuid_b + assert uuid16_a != uuid_b + assert not uuid16_a == object() + assert uuid16_a != object() + + +def test_hash(): + uuid_a = UUID16(uuid=UUID("00001234-0000-1000-8000-00805F9B34FB")) + uuid_b = UUID16(uuid=UUID("00001234-0000-1000-8000-00805F9B34FB")) + uuid_c = UUID16(uuid=UUID("00001236-0000-1000-8000-00805F9B34FB")) + + assert hash(uuid_a) == hash(uuid_b) + assert hash(uuid_a) != hash(uuid_c) diff --git a/tests/unit/util.py b/tests/unit/util.py new file mode 100644 index 0000000..9c1b743 --- /dev/null +++ b/tests/unit/util.py @@ -0,0 +1,269 @@ +import asyncio +from typing import Dict, Union, Optional +from unittest.mock import MagicMock, AsyncMock, create_autospec +from uuid import UUID +import threading + +import pytest + +from dbus_fast.introspection import Node +from dbus_fast.aio.proxy_object import ProxyInterface, ProxyObject + +from bluez_peripheral.util import ( + get_message_bus, + is_bluez_available, + MessageBus, +) +from bluez_peripheral.gatt.service import ServiceCollection +from bluez_peripheral.adapter import Adapter +from bluez_peripheral.uuid16 import UUID16, UUIDLike +from bluez_peripheral.advert import Advertisement + + +def make_adapter_mock() -> MagicMock: + adapter = MagicMock() + + advertising_manager = MagicMock() + advertising_manager.call_register_advertisement = AsyncMock() + advertising_manager.call_unregister_advertisement = AsyncMock() + adapter.get_advertising_manager.return_value = advertising_manager + + gatt_manager = MagicMock() + gatt_manager.call_register_application = AsyncMock() + gatt_manager.call_unregister_application = AsyncMock() + adapter.get_gatt_manager.return_value = gatt_manager + + return adapter + + +def make_message_bus_mock() -> MagicMock: + bus = create_autospec(MessageBus, instance=True) + + proxy = create_autospec(ProxyObject, instance=True) + interface = MagicMock() + + interface.call_register_agent = AsyncMock() + interface.call_request_default_agent = AsyncMock() + interface.call_unregister_agent = AsyncMock() + + proxy.get_interface.return_value = interface + bus.get_proxy_object.return_value = proxy + + return bus + + +class BackgroundLoopWrapper: + event_loop: asyncio.AbstractEventLoop + thread: threading.Thread + + def __init__(self): + self.event_loop = asyncio.new_event_loop() + + def _func(): + self.event_loop.run_forever() + self.event_loop.close() + + self.thread = threading.Thread(target=_func, daemon=True) + + @property + def running(self): + return self.thread.is_alive() + + def start(self): + self.thread.start() + + def stop(self): + if self.thread is None or not self.thread.is_alive(): + return + + def _func(): + if self.event_loop is not None and self.event_loop.is_running(): + self.event_loop.stop() + + self.event_loop.call_soon_threadsafe(_func) + self.thread.join() + + +async def get_first_adapter_or_skip(bus: MessageBus) -> Adapter: + adapters = await Adapter.get_all(bus) + if not len(adapters) > 0: + pytest.skip("No adapters detected for testing.") + else: + return adapters[0] + + +async def bluez_available_or_skip(bus: MessageBus): + if await is_bluez_available(bus): + return + else: + pytest.skip("bluez is not available for testing.") + + +class ServiceNode: + bus_name: str + bus_path: str + attr_interface: Optional[ProxyInterface] + attr_type: Optional[str] + + _intf_hierarchy = [ + None, + "org.bluez.GattService1", + "org.bluez.GattCharacteristic1", + "org.bluez.GattDescriptor1", + ] + + def __init__( + self, + node: Node, + *, + bus: MessageBus, + bus_name: str, + bus_path: str, + proxy: Optional[ProxyObject] = None, + attr_interface: Optional[ProxyInterface] = None, + attr_type: Optional[str] = None, + ): + self.node = node + self.bus = bus + self.bus_name = bus_name + self.bus_path = bus_path + self.proxy = proxy + self.attr_interface = attr_interface + self.attr_type = attr_type + + @staticmethod + async def from_service_collection( + bus: MessageBus, bus_name: str, bus_path: str + ) -> "ServiceNode": + node = await bus.introspect(bus_name, bus_path) + return ServiceNode(node, bus=bus, bus_name=bus_name, bus_path=bus_path) + + @staticmethod + def _node_has_intf(node: Node, intf: str): + return any(i.name == intf for i in node.interfaces) + + async def get_children(self) -> Dict[Union[UUID16, UUID], "ServiceNode"]: + children = [] + for node in self.node.nodes: + assert node.name is not None + path = self.bus_path + "/" + node.name + + attr_idx = self._intf_hierarchy.index(self.attr_type) + attr_type = self._intf_hierarchy[attr_idx + 1] + + expanded_node = await self.bus.introspect(self.bus_name, path) + + proxy = None + attr_interface = None + if attr_type is not None and self._node_has_intf(expanded_node, attr_type): + proxy = self.bus.get_proxy_object(self.bus_name, path, expanded_node) + attr_interface = proxy.get_interface(attr_type) + + children.append( + ServiceNode( + expanded_node, + bus=self.bus, + bus_name=self.bus_name, + bus_path=path, + proxy=proxy, + attr_type=attr_type, + attr_interface=attr_interface, + ) + ) + + res = {} + for c in children: + uuid = UUID16.parse_uuid(await c.attr_interface.get_uuid()) + res[uuid] = c + return res + + async def get_child(self, *uuid: UUIDLike): + child = self + for u in uuid: + children = await child.get_children() + child = children[UUID16.parse_uuid(u)] + + return child + + +class BackgroundBusManager: + _background_bus: Optional[MessageBus] + + def __init__(self): + self._background_wrapper = BackgroundLoopWrapper() + self._foreground_loop = None + + @property + def foreground_loop(self): + return self._foreground_loop + + @property + def background_loop(self): + return self._background_wrapper.event_loop + + async def start(self, bus_name: str): + self._foreground_loop = asyncio.get_running_loop() + + async def _serve(): + await self._background_bus.wait_for_disconnect() + + self._background_wrapper.start() + + async def _start(): + self._background_bus = await get_message_bus() + await self._background_bus.request_name(bus_name) + + asyncio.run_coroutine_threadsafe(_start(), self.background_loop).result() + self._idle_task = asyncio.run_coroutine_threadsafe( + _serve(), self.background_loop + ) + + async def stop(self): + async def _stop(): + self._background_bus.disconnect() + + asyncio.run_coroutine_threadsafe(_stop(), self.background_loop).result() + self._idle_task.result() + self._background_wrapper.stop() + + @property + def background_bus(self): + return self._background_bus + + +class BackgroundServiceManager(BackgroundBusManager): + def __init__(self): + self.adapter = make_adapter_mock() + super().__init__() + + def register(self, services: ServiceCollection, bus_path: str): + self._services = services + asyncio.run_coroutine_threadsafe( + services.register(self.background_bus, path=bus_path, adapter=self.adapter), + self.background_loop, + ).result() + + def unregister(self): + asyncio.run_coroutine_threadsafe( + self._services.unregister(), self.background_loop + ).result() + self._services = None + + +class BackgroundAdvertManager(BackgroundBusManager): + def __init__(self): + self.adapter = make_adapter_mock() + super().__init__() + + def register(self, advert: Advertisement, bus_path: str): + self._advert = advert + asyncio.run_coroutine_threadsafe( + advert.register(self.background_bus, path=bus_path, adapter=self.adapter), + self.background_loop, + ).result() + + def unregister(self): + asyncio.run_coroutine_threadsafe( + self._advert.unregister(), self.background_loop + ).result() + self._advert = None diff --git a/tests/util.py b/tests/util.py deleted file mode 100644 index db65842..0000000 --- a/tests/util.py +++ /dev/null @@ -1,113 +0,0 @@ -from typing import Tuple -import asyncio -from threading import Thread, Event -from unittest.case import SkipTest - -from dbus_fast.introspection import Node - -from bluez_peripheral.util import * -from bluez_peripheral.uuid16 import UUID16 - - -class BusManager: - def __init__(self, name="com.spacecheese.test"): - bus_ready = Event() - self.name = name - - async def operate_bus_async(): - # Setup the bus. - self.bus = await get_message_bus() - await self.bus.request_name(name) - - bus_ready.set() - - await self.bus.wait_for_disconnect() - - def operate_bus(): - asyncio.run(operate_bus_async()) - - self._thread = Thread(target=operate_bus) - self._thread.start() - - bus_ready.wait() - - def close(self): - self.bus.disconnect() - - -async def get_first_adapter_or_skip(bus: MessageBus) -> Adapter: - adapters = await Adapter.get_all(bus) - if not len(adapters) > 0: - raise SkipTest("No adapters detected for testing.") - else: - return adapters[0] - - -async def bluez_available_or_skip(bus: MessageBus): - if await is_bluez_available(bus): - return - else: - raise SkipTest("bluez is not available for testing.") - - -class MockAdapter(Adapter): - def __init__(self, inspector): - self._inspector = inspector - self._proxy = self - - def get_interface(self, name): - return self - - async def call_register_advertisement(self, path, obj): - await self._inspector(path) - - async def call_register_application(self, path, obj): - await self._inspector(path) - - async def call_unregister_application(self, path): - pass - - -async def find_attrib(bus, bus_name, path, nodes, target_uuid) -> Tuple[Node, str]: - for node in nodes: - node_path = path + "/" + node.name - - introspection = await bus.introspect(bus_name, node_path) - proxy = bus.get_proxy_object(bus_name, node_path, introspection) - - uuid = None - interface_names = [interface.name for interface in introspection.interfaces] - if "org.bluez.GattService1" in interface_names: - uuid = await proxy.get_interface("org.bluez.GattService1").get_uuid() - elif "org.bluez.GattCharacteristic1" in interface_names: - uuid = await proxy.get_interface("org.bluez.GattCharacteristic1").get_uuid() - elif "org.bluez.GattDescriptor1" in interface_names: - uuid = await proxy.get_interface("org.bluez.GattDescriptor1").get_uuid() - - if UUID16(uuid) == UUID16(target_uuid): - return introspection, node_path - - raise ValueError( - "The attribute with uuid '" + str(target_uuid) + "' could not be found." - ) - - -async def get_attrib(bus, bus_name, path, service_uuid, char_uuid=None, desc_uuid=None): - introspection = await bus.introspect(bus_name, path) - - nodes = introspection.nodes - introspection, path = await find_attrib(bus, bus_name, path, nodes, service_uuid) - - if char_uuid is None: - return bus.get_proxy_object(bus_name, path, introspection) - - nodes = introspection.nodes - introspection, path = await find_attrib(bus, bus_name, path, nodes, char_uuid) - - if desc_uuid is None: - return bus.get_proxy_object(bus_name, path, introspection) - - nodes = introspection.nodes - introspection, path = await find_attrib(bus, bus_name, path, nodes, desc_uuid) - - return bus.get_proxy_object(bus_name, path, introspection)