From 1db05a0a4b5491b930cee2883da8f16bcc4d0a2d Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 4 Sep 2022 12:46:01 +0000 Subject: [PATCH 001/158] Refactor BTUUID class --- bluez_peripheral/uuid.py | 82 ------------------------- bluez_peripheral/uuid16.py | 119 +++++++++++++++++++++++++++++++++++++ tests/test_uuid16.py | 72 ++++++++++++++++++++++ 3 files changed, 191 insertions(+), 82 deletions(-) delete mode 100644 bluez_peripheral/uuid.py create mode 100644 bluez_peripheral/uuid16.py create mode 100644 tests/test_uuid16.py diff --git a/bluez_peripheral/uuid.py b/bluez_peripheral/uuid.py deleted file mode 100644 index 5512391..0000000 --- a/bluez_peripheral/uuid.py +++ /dev/null @@ -1,82 +0,0 @@ -import re - -from uuid import UUID -from typing import Union - - -class BTUUID(UUID): - """An extension of the built-in UUID class with some utility functions for converting Bluetooth UUID16s to and from UUID128s.""" - - _UUID16_UUID128_FMT = "0000{0}-0000-1000-8000-00805F9B34FB" - _UUID16_UUID128_RE = re.compile( - "^0000([0-9A-F]{4})-0000-1000-8000-00805F9B34FB$", re.IGNORECASE - ) - _UUID16_RE = re.compile("^(?:0x)?([0-9A-F]{4})$", re.IGNORECASE) - - @classmethod - def from_uuid16(cls, id: Union[str, int]) -> "BTUUID": - """Converts an integer or 4 digit hex string to a Bluetooth compatible UUID16. - - Args: - id: The UUID representation to convert. - - Raises: - ValueError: Raised if the supplied UUID16 is not valid. - - Returns: - The resulting UUID. - """ - hex = "0000" - - if type(id) is str: - match = cls._UUID16_RE.search(id) - - if not match: - raise ValueError("id is not a valid UUID16") - - hex = match.group(1) - - elif type(id) is int: - if id > 65535 or id < 0: - raise ValueError("id is out of range") - - hex = "{:04X}".format(id) - - return cls(cls._UUID16_UUID128_FMT.format(hex)) - - @classmethod - def from_uuid16_128(cls, id: str) -> "BTUUID": - """Converts a 4 or 32 digit hex string to a bluetooth compatible UUID16. - - Raises: - ValueError: Raised if the supplied string is not a valid UUID. - - Returns: - The resulting UUID. - """ - if len(id) == 4: - return cls.from_uuid16(id) - else: - uuid = cls(id) - - try: - # If the result wont convert to a uuid16 then it must be invalid. - _ = uuid.uuid16 - except ValueError: - raise ValueError("id is not a valid uuid16") - - return uuid - - @property - def uuid16(self) -> Union[str, None]: - """Converts the UUID16 to a 4 digit string representation. - - Returns: - The UUID representation or None if self is not a valid UUID16. - """ - match = self._UUID16_UUID128_RE.search(str(self)) - - if not match: - return None - - return match.group(1) diff --git a/bluez_peripheral/uuid16.py b/bluez_peripheral/uuid16.py new file mode 100644 index 0000000..a15d077 --- /dev/null +++ b/bluez_peripheral/uuid16.py @@ -0,0 +1,119 @@ +import builtins + +from uuid import UUID +from typing import Optional + + +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. + """ + + # 0000****--0000-1000-8000-00805F9B34FB + _FIELDS = (0x00000000, 0x0000, 0x1000, 0x80, 0x00, 0x00805F9B34FB) + _uuid: UUID = None + + def __init__( + self, + hex: Optional[str] = None, + bytes: Optional[bytes] = None, + int: Optional[int] = None, + uuid: Optional[UUID] = None, + ): + if [hex, bytes, int, uuid].count(None) != 3: + raise TypeError("one of the hex, bytes or int arguments must be given") + + time_low = None + + if hex is not None: + hex.strip("0x") + if len(hex) == 4: + time_low = builtins.int(hex, 16) + else: + uuid = UUID(hex) + + if 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: + 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) + else: + if UUID16.is_uuid_valid(uuid): + self._uuid = uuid + else: + raise ValueError( + "the supplied uuid128 was out of range" + ) + + @classmethod + def is_uuid_valid(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. + """ + 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 + + @property + def uuid(self) -> UUID: + """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. + """ + return self._uuid.time_low & 0xFFFF + + @property + def bytes(self) -> bytes: + """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. + """ + return self.bytes.hex() + + def __eq__(self, __o: object) -> bool: + if type(__o) is UUID16: + return self._uuid == __o._uuid + elif type(__o) is UUID: + return self._uuid == __o + else: + return False + + def __ne__(self, __o: object) -> bool: + return not self.__eq__(__o) + + def __str__(self): + return self.hex diff --git a/tests/test_uuid16.py b/tests/test_uuid16.py new file mode 100644 index 0000000..1215a7a --- /dev/null +++ b/tests/test_uuid16.py @@ -0,0 +1,72 @@ +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_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_uuid_valid(self): + uuid = UUID("00001234-0000-1000-8000-00805F9B34FB") + assert UUID16.is_uuid_valid(uuid) == True + + uuid = UUID("00011234-0000-1000-8000-00805F9B34FB") + assert UUID16.is_uuid_valid(uuid) == False + + uuid = UUID("00001234-0000-1000-8000-00805F9B34FC") + assert UUID16.is_uuid_valid(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 From 34f3bff792fb102f7062ec5c50c9397cc424ac0f Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 4 Sep 2022 14:02:34 +0000 Subject: [PATCH 002/158] Replace old BTUUID parsing --- bluez_peripheral/advert.py | 21 ++++++++--------- bluez_peripheral/gatt/characteristic.py | 15 ++++++------ bluez_peripheral/gatt/descriptor.py | 10 ++++---- bluez_peripheral/gatt/service.py | 7 +++--- bluez_peripheral/uuid16.py | 31 ++++++++++++++++++++++--- tests/test_uuid16.py | 15 ++++++++---- 6 files changed, 63 insertions(+), 36 deletions(-) diff --git a/bluez_peripheral/advert.py b/bluez_peripheral/advert.py index e994e27..8b01ff9 100644 --- a/bluez_peripheral/advert.py +++ b/bluez_peripheral/advert.py @@ -6,8 +6,9 @@ from enum import Enum, Flag, auto from typing import Collection, Dict, Union import struct +from uuid import UUID -from .uuid import BTUUID +from .uuid16 import UUID16 from .util import * @@ -59,22 +60,20 @@ class Advertisement(ServiceInterface): def __init__( self, localName: str, - serviceUUIDs: Collection[Union[BTUUID, str]], + serviceUUIDs: Collection[Union[str, bytes, UUID, UUID16, int]], appearance: Union[int, bytes], timeout: int, discoverable: bool = True, packet_type: PacketType = PacketType.PERIPHERAL, manufacturerData: Dict[int, bytes] = {}, - solicitUUIDs: Collection[BTUUID] = [], + solicitUUIDs: Collection[Union[str, bytes, UUID, UUID16, int]] = [], serviceData: Dict[str, bytes] = {}, includes: AdvertisingIncludes = AdvertisingIncludes.NONE, duration: int = 2, ): self._type = packet_type - # Convert any string uuids to uuid16. self._serviceUUIDs = [ - uuid if type(uuid) is BTUUID else BTUUID.from_uuid16_128(uuid) - for uuid in serviceUUIDs + UUID16.parse_uuid(uuid) for uuid in serviceUUIDs ] self._localName = localName # Convert the appearance to a uint16 if it isn't already an int. @@ -87,7 +86,9 @@ def __init__( for key, value in manufacturerData.items(): self._manufacturerData[key] = Variant("ay", value) - self._solicitUUIDs = solicitUUIDs + self._solicitUUIDs = [ + UUID16.parse_uuid(uuid) for uuid in solicitUUIDs + ] self._serviceData = serviceData self._discoverable = discoverable self._includes = includes @@ -141,9 +142,7 @@ def Type(self) -> "s": # type: ignore @dbus_property(PropertyAccess.READ) def ServiceUUIDs(self) -> "as": # type: ignore - return [ - id.uuid16 if not id.uuid16 is None else str(id) for id in self._serviceUUIDs - ] + return [str(id) for id in self._serviceUUIDs] @dbus_property(PropertyAccess.READ) def LocalName(self) -> "s": # type: ignore @@ -163,7 +162,7 @@ def ManufacturerData(self) -> "a{qv}": # type: ignore @dbus_property(PropertyAccess.READ) def SolicitUUIDs(self) -> "as": # type: ignore - return [id.uuid16 for id in self._solicitUUIDs] + return [str(id) for id in self._solicitUUIDs] @dbus_property(PropertyAccess.READ) def ServiceData(self) -> "a{say}": # type: ignore diff --git a/bluez_peripheral/gatt/characteristic.py b/bluez_peripheral/gatt/characteristic.py index 8fea3e3..90adca2 100644 --- a/bluez_peripheral/gatt/characteristic.py +++ b/bluez_peripheral/gatt/characteristic.py @@ -3,11 +3,12 @@ from dbus_next.service import ServiceInterface, method, dbus_property from dbus_next.aio import MessageBus +from uuid import UUID from enum import Enum, Flag, auto -from typing import Callable, Union +from typing import Callable, Optional, Union from .descriptor import descriptor, DescriptorFlags -from ..uuid import BTUUID +from ..uuid16 import UUID16 from ..util import * @@ -30,7 +31,7 @@ def offset(self) -> int: return self._offset @property - def mtu(self) -> Union[int, None]: + def mtu(self) -> Optional[int]: """The exchanged Maximum Transfer Unit of the connection with the remote device or 0.""" return self._mtu @@ -181,12 +182,10 @@ class characteristic(ServiceInterface): def __init__( self, - uuid: Union[BTUUID, str], + uuid: Union[str, bytes, UUID, UUID16, int], flags: CharacteristicFlags = CharacteristicFlags.READ, ): - if type(uuid) is str: - uuid = BTUUID.from_uuid16_128(uuid) - self.uuid = uuid + self.uuid = UUID16.parse_uuid(uuid) self.getter_func = None self.setter_func = None self.flags = flags @@ -238,7 +237,7 @@ def __call__( return self def descriptor( - self, uuid: Union[BTUUID, str], flags: DescriptorFlags = DescriptorFlags.READ + self, uuid: Union[str, bytes, UUID, UUID16, int], flags: DescriptorFlags = DescriptorFlags.READ ) -> "descriptor": """Create a new descriptor with the specified UUID and Flags. diff --git a/bluez_peripheral/gatt/descriptor.py b/bluez_peripheral/gatt/descriptor.py index 898a1b9..6563908 100644 --- a/bluez_peripheral/gatt/descriptor.py +++ b/bluez_peripheral/gatt/descriptor.py @@ -4,8 +4,8 @@ from dbus_next.constants import PropertyAccess from enum import Flag, auto -from typing import Union, Callable -from ..uuid import BTUUID as UUID +from typing import Callable, Union +from ..uuid16 import UUID16 from ..util import * @@ -114,13 +114,11 @@ class descriptor(ServiceInterface): def __init__( self, - uuid: Union[UUID, str], + uuid: Union[str, bytes, UUID, UUID16, int], characteristic: "characteristic", # type: ignore flags: DescriptorFlags = DescriptorFlags.READ, ): - if type(uuid) is str: - uuid = UUID.from_uuid16_128(uuid) - self.uuid = uuid + self.uuid = UUID16.parse_uuid(uuid) self.getter_func = None self.setter_func = None self.characteristic = characteristic diff --git a/bluez_peripheral/gatt/service.py b/bluez_peripheral/gatt/service.py index a2bab91..f5cc0fd 100644 --- a/bluez_peripheral/gatt/service.py +++ b/bluez_peripheral/gatt/service.py @@ -4,10 +4,9 @@ from dbus_next.aio import MessageBus from .characteristic import characteristic -from ..uuid import BTUUID as UUID +from ..uuid16 import UUID16 from ..util import * -from typing import Union import inspect # See https://git.kernel.org/pub/scm/bluetooth/bluez.git/tree/doc/gatt-api.txt @@ -36,12 +35,12 @@ def _populate(self): def __init__( self, - uuid: Union[UUID, str], + uuid: str | bytes | UUID | UUID16 | int, primary: bool = True, includes: Collection["Service"] = [], ): # Make sure uuid is a uuid16. - self._uuid = uuid if type(uuid) is UUID else UUID.from_uuid16_128(uuid) + self._uuid = UUID16.parse_uuid(uuid) self._primary = primary self._characteristics = [] self._path = None diff --git a/bluez_peripheral/uuid16.py b/bluez_peripheral/uuid16.py index a15d077..9459cb9 100644 --- a/bluez_peripheral/uuid16.py +++ b/bluez_peripheral/uuid16.py @@ -1,7 +1,7 @@ import builtins from uuid import UUID -from typing import Optional +from typing import Optional, Union class UUID16: @@ -57,7 +57,7 @@ def __init__( fields[0] = time_low self._uuid = UUID(fields=fields) else: - if UUID16.is_uuid_valid(uuid): + if UUID16.is_in_range(uuid): self._uuid = uuid else: raise ValueError( @@ -65,7 +65,7 @@ def __init__( ) @classmethod - def is_uuid_valid(cls, uuid: UUID) -> bool: + def is_in_range(cls, uuid: UUID) -> bool: """Determines if a supplied uuid128 is in the allowed uuid16 range. Returns: @@ -80,6 +80,31 @@ def is_uuid_valid(cls, uuid: UUID) -> bool: return True + @classmethod + def parse_uuid(cls, uuid: Union[str, bytes, int, UUID]) -> Union[UUID, "UUID16"]: + if type(uuid) is UUID: + if cls.is_in_range(uuid): + return UUID16(uuid=uuid) + return uuid + + if type(uuid) is str: + try: + return UUID16(hex=uuid) + except: + return UUID(hex=uuid) + + if type(uuid) is bytes: + try: + return UUID16(bytes=uuid) + except: + return UUID(bytes=uuid) + + if type(uuid) is int: + try: + return UUID16(int=uuid) + except: + return UUID(int=uuid) + @property def uuid(self) -> UUID: """Returns the full uuid128 corresponding to this uuid16. diff --git a/tests/test_uuid16.py b/tests/test_uuid16.py index 1215a7a..27c292c 100644 --- a/tests/test_uuid16.py +++ b/tests/test_uuid16.py @@ -42,6 +42,13 @@ def test_from_int(self): 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()) @@ -49,15 +56,15 @@ def test_from_uuid(self): uuid = UUID16(uuid=UUID("00001234-0000-1000-8000-00805F9B34FB")) assert uuid.uuid == UUID("00001234-0000-1000-8000-00805F9B34FB") - def test_is_uuid_valid(self): + def test_is_in_range(self): uuid = UUID("00001234-0000-1000-8000-00805F9B34FB") - assert UUID16.is_uuid_valid(uuid) == True + assert UUID16.is_in_range(uuid) == True uuid = UUID("00011234-0000-1000-8000-00805F9B34FB") - assert UUID16.is_uuid_valid(uuid) == False + assert UUID16.is_in_range(uuid) == False uuid = UUID("00001234-0000-1000-8000-00805F9B34FC") - assert UUID16.is_uuid_valid(uuid) == True + assert UUID16.is_in_range(uuid) == True def test_int(self): uuid = UUID16(uuid=UUID("00001234-0000-1000-8000-00805F9B34FB")) From 4e189bff63d0b181f9adb2655bbf196d1e7e7cd1 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sat, 31 Dec 2022 19:41:37 +0000 Subject: [PATCH 003/158] Fix tests --- bluez_peripheral/gatt/descriptor.py | 2 ++ bluez_peripheral/gatt/service.py | 1 + tests/util.py | 6 +++--- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/bluez_peripheral/gatt/descriptor.py b/bluez_peripheral/gatt/descriptor.py index 6563908..13ed128 100644 --- a/bluez_peripheral/gatt/descriptor.py +++ b/bluez_peripheral/gatt/descriptor.py @@ -3,8 +3,10 @@ from dbus_next.service import ServiceInterface, method, dbus_property from dbus_next.constants import PropertyAccess +from uuid import UUID from enum import Flag, auto from typing import Callable, Union + from ..uuid16 import UUID16 from ..util import * diff --git a/bluez_peripheral/gatt/service.py b/bluez_peripheral/gatt/service.py index f5cc0fd..1cfdf99 100644 --- a/bluez_peripheral/gatt/service.py +++ b/bluez_peripheral/gatt/service.py @@ -7,6 +7,7 @@ from ..uuid16 import UUID16 from ..util import * +from uuid import UUID import inspect # See https://git.kernel.org/pub/scm/bluetooth/bluez.git/tree/doc/gatt-api.txt diff --git a/tests/util.py b/tests/util.py index 578ae2a..c7d1079 100644 --- a/tests/util.py +++ b/tests/util.py @@ -6,7 +6,7 @@ from dbus_next.introspection import Node from bluez_peripheral.util import * -from bluez_peripheral.uuid import BTUUID +from bluez_peripheral.uuid16 import UUID16 class BusManager: @@ -84,11 +84,11 @@ async def find_attrib(bus, bus_name, path, nodes, target_uuid) -> Tuple[Node, st elif "org.bluez.GattDescriptor1" in interface_names: uuid = await proxy.get_interface("org.bluez.GattDescriptor1").get_uuid() - if BTUUID.from_uuid16_128(uuid) == BTUUID.from_uuid16(target_uuid): + if UUID16(uuid) == UUID16(target_uuid): return introspection, node_path raise ValueError( - "The attribute with uuid '" + target_uuid + "' could not be found." + "The attribute with uuid '" + str(target_uuid) + "' could not be found." ) From 44bb7017fd4874ac31af2c8097d93e72f52327c6 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sat, 31 Dec 2022 19:49:30 +0000 Subject: [PATCH 004/158] Replace PEP604 union --- bluez_peripheral/gatt/service.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bluez_peripheral/gatt/service.py b/bluez_peripheral/gatt/service.py index 1cfdf99..ecb322f 100644 --- a/bluez_peripheral/gatt/service.py +++ b/bluez_peripheral/gatt/service.py @@ -8,6 +8,7 @@ from ..util import * 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 @@ -36,7 +37,7 @@ def _populate(self): def __init__( self, - uuid: str | bytes | UUID | UUID16 | int, + uuid: Union[str, bytes, UUID, UUID16, int], primary: bool = True, includes: Collection["Service"] = [], ): From eb29e6975b7c3d6d55e1b942a9bcc7a618a6d289 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sat, 31 Dec 2022 19:49:42 +0000 Subject: [PATCH 005/158] Fix uuid128 test --- tests/test_advert.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_advert.py b/tests/test_advert.py index c1ecbdd..de5cd22 100644 --- a/tests/test_advert.py +++ b/tests/test_advert.py @@ -2,10 +2,11 @@ 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): @@ -77,7 +78,7 @@ async def inspector(path): async def test_uuid128(self): advert = Advertisement( "Improv Test", - [BTUUID("00467768-6228-2272-4663-277478268000")], + [UUID("00467768-6228-2272-4663-277478268000")], 0x0340, 2, ) From 5a3bb73a726dc36e35c5bdaf4cc51883c772a5a3 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Thu, 29 Dec 2022 19:30:12 +0000 Subject: [PATCH 006/158] Fix references to BTUUID --- bluez_peripheral/gatt/service.py | 7 +++---- tests/gatt/test_characteristic.py | 33 ++++++++++++++++--------------- tests/gatt/test_descriptor.py | 18 ++++++++++------- tests/util.py | 2 +- 4 files changed, 32 insertions(+), 28 deletions(-) diff --git a/bluez_peripheral/gatt/service.py b/bluez_peripheral/gatt/service.py index ecb322f..0b65269 100644 --- a/bluez_peripheral/gatt/service.py +++ b/bluez_peripheral/gatt/service.py @@ -3,14 +3,13 @@ from dbus_next.service import ServiceInterface, dbus_property from dbus_next.aio import MessageBus +import inspect +from uuid import UUID + from .characteristic import characteristic from ..uuid16 import UUID16 from ..util import * -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): """Create a bluetooth service with the specified uuid. diff --git a/tests/gatt/test_characteristic.py b/tests/gatt/test_characteristic.py index 7ac01a4..5c09714 100644 --- a/tests/gatt/test_characteristic.py +++ b/tests/gatt/test_characteristic.py @@ -4,6 +4,7 @@ from tests.util import * import re +from bluez_peripheral.uuid16 import UUID16 from bluez_peripheral.util import get_message_bus from bluez_peripheral.gatt.characteristic import ( CharacteristicFlags, @@ -82,8 +83,8 @@ async def inspector(path): self._client_bus, self._bus_manager.name, path, - "180A", - char_uuid="2A37", + UUID16("180A"), + UUID16("2A37"), ) ).get_interface("org.bluez.GattCharacteristic1") resp = await interface.call_read_value(opts) @@ -116,8 +117,8 @@ async def inspector(path): self._client_bus, self._bus_manager.name, path, - "180A", - char_uuid="2A38", + UUID16("180A"), + UUID16("2A38"), ) ).get_interface("org.bluez.GattCharacteristic1") await interface.call_write_value(bytes("Test Write Value", "utf-8"), opts) @@ -145,8 +146,8 @@ async def inspector(path): self._client_bus, self._bus_manager.name, path, - "180A", - char_uuid="2A38", + UUID16("180A"), + UUID16("2A38"), ) ).get_interface("org.freedesktop.DBus.Properties") @@ -175,8 +176,8 @@ async def inspector(path): self._client_bus, self._bus_manager.name, path, - "180A", - char_uuid="2A38", + UUID16("180A"), + UUID16("2A38"), ) properties_interface = proxy.get_interface( "org.freedesktop.DBus.Properties" @@ -215,8 +216,8 @@ async def inspector(path): self._client_bus, self._bus_manager.name, path, - "180A", - char_uuid="2A38", + UUID16("180A"), + UUID16("2A38"), ) property_interface = proxy.get_interface("org.freedesktop.DBus.Properties") char_interface = proxy.get_interface("org.bluez.GattCharacteristic1") @@ -265,9 +266,9 @@ async def inspector(path): self._client_bus, self._bus_manager.name, path, - "180A", - "2A38", - "2D56", + UUID16("180A"), + UUID16("2A38"), + UUID16("2D56"), ) desc = proxy.get_interface("org.bluez.GattDescriptor1") assert (await desc.call_read_value(opts)).decode( @@ -279,9 +280,9 @@ async def inspector(path): self._client_bus, self._bus_manager.name, path, - "180A", - "2A38", - "2D56", + UUID16("180A"), + UUID16("2A38"), + UUID16("2D56"), ) except ValueError: pass diff --git a/tests/gatt/test_descriptor.py b/tests/gatt/test_descriptor.py index 62d0a8a..56c151c 100644 --- a/tests/gatt/test_descriptor.py +++ b/tests/gatt/test_descriptor.py @@ -54,7 +54,11 @@ async def asyncTearDown(self): 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" + self._client_bus, + self._bus_manager.name, + path, + UUID16("180A"), + UUID16("2A37") ) child_names = [path.split("/")[-1] for path in char.child_paths] @@ -83,9 +87,9 @@ async def inspector(path): self._client_bus, self._bus_manager.name, path, - "180A", - char_uuid="2A37", - desc_uuid="2A38", + UUID16("180A"), + UUID16("2A37"), + UUID16("2A38"), ) ).get_interface("org.bluez.GattDescriptor1") resp = await interface.call_read_value(opts) @@ -115,9 +119,9 @@ async def inspector(path): self._client_bus, self._bus_manager.name, path, - "180A", - char_uuid="2A37", - desc_uuid="2A39", + UUID16("180A"), + UUID16("2A37"), + UUID16("2A39"), ) ).get_interface("org.bluez.GattDescriptor1") await interface.call_write_value(bytes("Test Write Value", "utf-8"), opts) diff --git a/tests/util.py b/tests/util.py index c7d1079..f65b618 100644 --- a/tests/util.py +++ b/tests/util.py @@ -84,7 +84,7 @@ async def find_attrib(bus, bus_name, path, nodes, target_uuid) -> Tuple[Node, st elif "org.bluez.GattDescriptor1" in interface_names: uuid = await proxy.get_interface("org.bluez.GattDescriptor1").get_uuid() - if UUID16(uuid) == UUID16(target_uuid): + if UUID16.from_uuid16_128(uuid) == target_uuid: return introspection, node_path raise ValueError( From 88ec102f5717bfd5e771b03cb753d2428698d193 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sat, 31 Dec 2022 19:00:02 +0000 Subject: [PATCH 007/158] Regenerate api docs --- ...advert.rst => bluez_peripheral.advert.rst} | 4 +++- .../{agent.rst => bluez_peripheral.agent.rst} | 4 +++- docs/source/ref/bluez_peripheral.errors.rst | 7 ++++++ ... bluez_peripheral.gatt.characteristic.rst} | 3 ++- ...t => bluez_peripheral.gatt.descriptor.rst} | 3 ++- docs/source/ref/bluez_peripheral.gatt.rst | 12 ++++++++++ ....rst => bluez_peripheral.gatt.service.rst} | 2 ++ docs/source/ref/bluez_peripheral.rst | 22 ++++++++++++++----- .../{util.rst => bluez_peripheral.util.rst} | 1 + docs/source/ref/bluez_peripheral.uuid16.rst | 7 ++++++ docs/source/ref/gatt/gatt.rst | 10 --------- docs/source/ref/uuid.rst | 5 ----- 12 files changed, 55 insertions(+), 25 deletions(-) rename docs/source/ref/{advert.rst => bluez_peripheral.advert.rst} (64%) rename docs/source/ref/{agent.rst => bluez_peripheral.agent.rst} (64%) create mode 100644 docs/source/ref/bluez_peripheral.errors.rst rename docs/source/ref/{gatt/characteristic.rst => bluez_peripheral.gatt.characteristic.rst} (78%) rename docs/source/ref/{gatt/descriptor.rst => bluez_peripheral.gatt.descriptor.rst} (76%) create mode 100644 docs/source/ref/bluez_peripheral.gatt.rst rename docs/source/ref/{gatt/service.rst => bluez_peripheral.gatt.service.rst} (75%) rename docs/source/ref/{util.rst => bluez_peripheral.util.rst} (87%) create mode 100644 docs/source/ref/bluez_peripheral.uuid16.rst delete mode 100644 docs/source/ref/gatt/gatt.rst delete mode 100644 docs/source/ref/uuid.rst diff --git a/docs/source/ref/advert.rst b/docs/source/ref/bluez_peripheral.advert.rst similarity index 64% rename from docs/source/ref/advert.rst rename to docs/source/ref/bluez_peripheral.advert.rst index 7c3e463..111ee97 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: + :no-undoc-members: + :show-inheritance: diff --git a/docs/source/ref/agent.rst b/docs/source/ref/bluez_peripheral.agent.rst similarity index 64% rename from docs/source/ref/agent.rst rename to docs/source/ref/bluez_peripheral.agent.rst index 7cd67e1..20bc362 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: + :no-undoc-members: + :show-inheritance: diff --git a/docs/source/ref/bluez_peripheral.errors.rst b/docs/source/ref/bluez_peripheral.errors.rst new file mode 100644 index 0000000..ca7534a --- /dev/null +++ b/docs/source/ref/bluez_peripheral.errors.rst @@ -0,0 +1,7 @@ +bluez\_peripheral.errors module +=============================== + +.. automodule:: bluez_peripheral.errors + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/ref/gatt/characteristic.rst b/docs/source/ref/bluez_peripheral.gatt.characteristic.rst similarity index 78% rename from docs/source/ref/gatt/characteristic.rst rename to docs/source/ref/bluez_peripheral.gatt.characteristic.rst index 2866ebe..7c6222c 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 + :no-undoc-members: + :show-inheritance: diff --git a/docs/source/ref/gatt/descriptor.rst b/docs/source/ref/bluez_peripheral.gatt.descriptor.rst similarity index 76% rename from docs/source/ref/gatt/descriptor.rst rename to docs/source/ref/bluez_peripheral.gatt.descriptor.rst index d252cf5..08add29 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 + :no-undoc-members: + :show-inheritance: diff --git a/docs/source/ref/bluez_peripheral.gatt.rst b/docs/source/ref/bluez_peripheral.gatt.rst new file mode 100644 index 0000000..bc289e0 --- /dev/null +++ b/docs/source/ref/bluez_peripheral.gatt.rst @@ -0,0 +1,12 @@ +bluez\_peripheral.gatt package +============================== + +Submodules +---------- + +.. toctree:: + :maxdepth: 4 + + bluez_peripheral.gatt.characteristic + bluez_peripheral.gatt.descriptor + bluez_peripheral.gatt.service \ No newline at end of file diff --git a/docs/source/ref/gatt/service.rst b/docs/source/ref/bluez_peripheral.gatt.service.rst similarity index 75% rename from docs/source/ref/gatt/service.rst rename to docs/source/ref/bluez_peripheral.gatt.service.rst index 3ea5b7e..67a6986 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: + :no-undoc-members: + :show-inheritance: diff --git a/docs/source/ref/bluez_peripheral.rst b/docs/source/ref/bluez_peripheral.rst index 15e37aa..6edc17f 100644 --- a/docs/source/ref/bluez_peripheral.rst +++ b/docs/source/ref/bluez_peripheral.rst @@ -1,12 +1,22 @@ 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.advert + bluez_peripheral.agent + bluez_peripheral.errors + bluez_peripheral.util + bluez_peripheral.uuid16 \ No newline at end of file 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..a990b93 100644 --- a/docs/source/ref/util.rst +++ b/docs/source/ref/bluez_peripheral.util.rst @@ -3,4 +3,5 @@ bluez\_peripheral.util module .. automodule:: bluez_peripheral.util :members: + :undoc-members: :show-inheritance: diff --git a/docs/source/ref/bluez_peripheral.uuid16.rst b/docs/source/ref/bluez_peripheral.uuid16.rst new file mode 100644 index 0000000..ebcada0 --- /dev/null +++ b/docs/source/ref/bluez_peripheral.uuid16.rst @@ -0,0 +1,7 @@ +bluez\_peripheral.uuid16 module +=============================== + +.. automodule:: bluez_peripheral.uuid16 + :members: + :undoc-members: + :show-inheritance: 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 88791be..0000000 --- a/docs/source/ref/uuid.rst +++ /dev/null @@ -1,5 +0,0 @@ -bluez\_peripheral.uuid module -============================= - -.. automodule:: bluez_peripheral.uuid - :members: \ No newline at end of file From 8a0f6e72e5ec58a0161054c5aaf682ef9e170171 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sat, 31 Dec 2022 19:21:11 +0000 Subject: [PATCH 008/158] Private internal util functions --- bluez_peripheral/advert.py | 7 ++++--- bluez_peripheral/agent.py | 4 ++-- bluez_peripheral/gatt/characteristic.py | 22 +++++++++++----------- bluez_peripheral/gatt/descriptor.py | 18 +++++++++--------- bluez_peripheral/util.py | 8 ++++---- 5 files changed, 30 insertions(+), 29 deletions(-) diff --git a/bluez_peripheral/advert.py b/bluez_peripheral/advert.py index 8b01ff9..4c9d62d 100644 --- a/bluez_peripheral/advert.py +++ b/bluez_peripheral/advert.py @@ -1,3 +1,4 @@ +from dbus_next import Variant from dbus_next.aio import MessageBus from dbus_next.aio.proxy_object import ProxyInterface from dbus_next.constants import PropertyAccess @@ -9,7 +10,7 @@ from uuid import UUID from .uuid16 import UUID16 -from .util import * +from .util import _snake_to_kebab, _kebab_to_shouting_snake, Adapter class PacketType(Enum): @@ -127,7 +128,7 @@ async def GetSupportedIncludes(cls, adapter: Adapter) -> AdvertisingIncludes: includes = await interface.get_supported_includes() flags = AdvertisingIncludes.NONE for inc in includes: - inc = AdvertisingIncludes[kebab_to_shouting_snake(inc)] + inc = AdvertisingIncludes[_kebab_to_shouting_snake(inc)] # Combine all the included flags. flags |= inc return flags @@ -175,7 +176,7 @@ def Discoverable(self) -> "b": # type: ignore @dbus_property(PropertyAccess.READ) def Includes(self) -> "as": # type: ignore return [ - snake_to_kebab(inc.name) + _snake_to_kebab(inc.name) for inc in AdvertisingIncludes if self._includes & inc ] diff --git a/bluez_peripheral/agent.py b/bluez_peripheral/agent.py index 5249d6e..ee50c2c 100644 --- a/bluez_peripheral/agent.py +++ b/bluez_peripheral/agent.py @@ -5,7 +5,7 @@ from typing import Awaitable, Callable from enum import Enum -from .util import * +from .util import _snake_to_pascal class AgentCapability(Enum): @@ -59,7 +59,7 @@ def Cancel(self): # type: ignore pass def _get_capability(self): - return snake_to_pascal(self._capability.name) + return _snake_to_pascal(self._capability.name) async def _get_manager_interface(self, bus: MessageBus): introspection = await bus.introspect("org.bluez", "/org/bluez") diff --git a/bluez_peripheral/gatt/characteristic.py b/bluez_peripheral/gatt/characteristic.py index 90adca2..075a844 100644 --- a/bluez_peripheral/gatt/characteristic.py +++ b/bluez_peripheral/gatt/characteristic.py @@ -9,7 +9,7 @@ from .descriptor import descriptor, DescriptorFlags from ..uuid16 import UUID16 -from ..util import * +from ..util import _snake_to_kebab, _getattr_variant class CharacteristicReadOptions: @@ -21,9 +21,9 @@ 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) + self._offset = int(_getattr_variant(options, "offset", 0)) + self._mtu = int(_getattr_variant(options, "mtu", 0)) + self._device = _getattr_variant(options, "device", None) @property def offset(self) -> int: @@ -64,15 +64,15 @@ def __init__(self): self.__init__({}) def __init__(self, options): - self._offset = int(getattr_variant(options, "offset", 0)) - type = getattr_variant(options, "type", None) + 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._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) @property def offset(self): @@ -381,7 +381,7 @@ def Flags(self) -> "as": # type: ignore # 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 ] diff --git a/bluez_peripheral/gatt/descriptor.py b/bluez_peripheral/gatt/descriptor.py index 13ed128..3c6a68b 100644 --- a/bluez_peripheral/gatt/descriptor.py +++ b/bluez_peripheral/gatt/descriptor.py @@ -8,7 +8,7 @@ from typing import Callable, Union from ..uuid16 import UUID16 -from ..util import * +from ..util import _snake_to_kebab, _getattr_variant class DescriptorReadOptions: @@ -17,9 +17,9 @@ class DescriptorReadOptions: """ 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) + 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): @@ -43,10 +43,10 @@ class DescriptorWriteOptions: """ 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) + 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): @@ -213,5 +213,5 @@ def Characteristic(self) -> "o": # type: ignore def 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 ] diff --git a/bluez_peripheral/util.py b/bluez_peripheral/util.py index 204d0d6..06e2019 100644 --- a/bluez_peripheral/util.py +++ b/bluez_peripheral/util.py @@ -7,22 +7,22 @@ from dbus_next.errors import DBusError -def getattr_variant(object: Dict[str, Variant], key: str, default: Any): +def _getattr_variant(object: Dict[str, Variant], key: str, default: Any): if key in object: return object[key].value else: 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 = "" From 7e3ec883bcd05a8e3c5b04b97e83ff703b4d104a Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sat, 31 Dec 2022 20:03:10 +0000 Subject: [PATCH 009/158] Fix missing service import --- bluez_peripheral/gatt/service.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bluez_peripheral/gatt/service.py b/bluez_peripheral/gatt/service.py index 0b65269..ed832ed 100644 --- a/bluez_peripheral/gatt/service.py +++ b/bluez_peripheral/gatt/service.py @@ -5,6 +5,7 @@ import inspect from uuid import UUID +from typing import Union from .characteristic import characteristic from ..uuid16 import UUID16 From f981fa5caf0f72f3d329461889bb6c1fdfd8302a Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sat, 31 Dec 2022 20:03:59 +0000 Subject: [PATCH 010/158] Add pipfile --- Pipfile | 17 +++ Pipfile.lock | 314 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 331 insertions(+) create mode 100644 Pipfile create mode 100644 Pipfile.lock diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..f57f9e8 --- /dev/null +++ b/Pipfile @@ -0,0 +1,17 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +dbus-next = "*" + +[dev-packages] +sphinx = "*" +sphinx-inline-tabs = "*" +sphinxcontrib-spelling = "*" +furo = "*" +m2r2 = "*" + +[requires] +python_version = "3.10" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..c784449 --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,314 @@ +{ + "_meta": { + "hash": { + "sha256": "841debc30ad37a49fd03433dc03ef7a3e652754bb88aa509b02ed8af8747603a" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.10" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "dbus-next": { + "hashes": [ + "sha256:58948f9aff9db08316734c0be2a120f6dc502124d9642f55e90ac82ffb16a18b", + "sha256:f4eae26909332ada528c0a3549dda8d4f088f9b365153952a408e28023a626a5" + ], + "index": "pypi", + "version": "==0.2.3" + } + }, + "develop": { + "alabaster": { + "hashes": [ + "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359", + "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02" + ], + "version": "==0.7.12" + }, + "babel": { + "hashes": [ + "sha256:1ad3eca1c885218f6dce2ab67291178944f810a10a9b5f3cb8382a5a232b64fe", + "sha256:5ef4b3226b0180dedded4229651c8b0e1a3a6a2837d45a073272f313e4cf97f6" + ], + "markers": "python_version >= '3.6'", + "version": "==2.11.0" + }, + "beautifulsoup4": { + "hashes": [ + "sha256:58d5c3d29f5a36ffeb94f02f0d786cd53014cf9b3b3951d42e0080d8a9498d30", + "sha256:ad9aa55b65ef2808eb405f46cf74df7fcb7044d5cbc26487f96eb2ef2e436693" + ], + "markers": "python_full_version >= '3.6.0'", + "version": "==4.11.1" + }, + "certifi": { + "hashes": [ + "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3", + "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18" + ], + "markers": "python_version >= '3.6'", + "version": "==2022.12.7" + }, + "charset-normalizer": { + "hashes": [ + "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845", + "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f" + ], + "markers": "python_full_version >= '3.6.0'", + "version": "==2.1.1" + }, + "docutils": { + "hashes": [ + "sha256:33995a6753c30b7f577febfc2c50411fec6aac7f7ffeb7c4cfe5991072dcf9e6", + "sha256:5e1de4d849fee02c63b040a4a3fd567f4ab104defd8a5511fbbc24a8a017efbc" + ], + "markers": "python_version >= '3.7'", + "version": "==0.19" + }, + "furo": { + "hashes": [ + "sha256:7cb76c12a25ef65db85ab0743df907573d03027a33631f17d267e598ebb191f7", + "sha256:d8008f8efbe7587a97ba533c8b2df1f9c21ee9b3e5cad0d27f61193d38b1a986" + ], + "index": "pypi", + "version": "==2022.12.7" + }, + "idna": { + "hashes": [ + "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4", + "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2" + ], + "markers": "python_version >= '3.5'", + "version": "==3.4" + }, + "imagesize": { + "hashes": [ + "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", + "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.4.1" + }, + "jinja2": { + "hashes": [ + "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852", + "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61" + ], + "markers": "python_version >= '3.7'", + "version": "==3.1.2" + }, + "m2r2": { + "hashes": [ + "sha256:2ee32a5928c3598b67c70e6d22981ec936c03d5bfd2f64229e77678731952f16", + "sha256:f9b6e9efbc2b6987dbd43d2fd15a6d115ba837d8158ae73295542635b4086e75" + ], + "index": "pypi", + "version": "==0.3.3" + }, + "markupsafe": { + "hashes": [ + "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003", + "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88", + "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5", + "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7", + "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a", + "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603", + "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1", + "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135", + "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247", + "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6", + "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601", + "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77", + "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02", + "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e", + "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63", + "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f", + "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980", + "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b", + "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812", + "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff", + "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96", + "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1", + "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925", + "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a", + "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6", + "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e", + "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f", + "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4", + "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f", + "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3", + "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c", + "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a", + "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417", + "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a", + "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a", + "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37", + "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452", + "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933", + "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a", + "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7" + ], + "markers": "python_version >= '3.7'", + "version": "==2.1.1" + }, + "mistune": { + "hashes": [ + "sha256:59a3429db53c50b5c6bcc8a07f8848cb00d7dc8bdb431a4ab41920d201d4756e", + "sha256:88a1051873018da288eee8538d476dffe1262495144b33ecb586c4ab266bb8d4" + ], + "version": "==0.8.4" + }, + "packaging": { + "hashes": [ + "sha256:2198ec20bd4c017b8f9717e00f0c8714076fc2fd93816750ab48e2c41de2cfd3", + "sha256:957e2148ba0e1a3b282772e791ef1d8083648bc131c8ab0c1feba110ce1146c3" + ], + "markers": "python_version >= '3.7'", + "version": "==22.0" + }, + "pyenchant": { + "hashes": [ + "sha256:1cf830c6614362a78aab78d50eaf7c6c93831369c52e1bb64ffae1df0341e637", + "sha256:5a636832987eaf26efe971968f4d1b78e81f62bca2bde0a9da210c7de43c3bce", + "sha256:5facc821ece957208a81423af7d6ec7810dad29697cb0d77aae81e4e11c8e5a6", + "sha256:6153f521852e23a5add923dbacfbf4bebbb8d70c4e4bad609a8e0f9faeb915d1" + ], + "markers": "python_version >= '3.5'", + "version": "==3.2.2" + }, + "pygments": { + "hashes": [ + "sha256:56a8508ae95f98e2b9bdf93a6be5ae3f7d8af858b43e02c5a2ff083726be40c1", + "sha256:f643f331ab57ba3c9d89212ee4a2dabc6e94f117cf4eefde99a0574720d14c42" + ], + "markers": "python_version >= '3.6'", + "version": "==2.13.0" + }, + "pytz": { + "hashes": [ + "sha256:7ccfae7b4b2c067464a6733c6261673fdb8fd1be905460396b97a073e9fa683a", + "sha256:93007def75ae22f7cd991c84e02d434876818661f8df9ad5df9e950ff4e52cfd" + ], + "version": "==2022.7" + }, + "requests": { + "hashes": [ + "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983", + "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349" + ], + "markers": "python_version >= '3.7' and python_version < '4'", + "version": "==2.28.1" + }, + "snowballstemmer": { + "hashes": [ + "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1", + "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a" + ], + "version": "==2.2.0" + }, + "soupsieve": { + "hashes": [ + "sha256:3b2503d3c7084a42b1ebd08116e5f81aadfaea95863628c80a3b774a11b7c759", + "sha256:fc53893b3da2c33de295667a0e19f078c14bf86544af307354de5fcf12a3f30d" + ], + "markers": "python_version >= '3.6'", + "version": "==2.3.2.post1" + }, + "sphinx": { + "hashes": [ + "sha256:58c140ecd9aa0abbc8ff6da48a266648eac9e5bfc8e49576efd2979bf46f5961", + "sha256:c2aeebfcb0e7474f5a820eac6177c7492b1d3c9c535aa21d5ae77cab2f3600e4" + ], + "index": "pypi", + "version": "==6.0.0" + }, + "sphinx-basic-ng": { + "hashes": [ + "sha256:89374bd3ccd9452a301786781e28c8718e99960f2d4f411845ea75fc7bb5a9b0", + "sha256:ade597a3029c7865b24ad0eda88318766bcc2f9f4cef60df7e28126fde94db2a" + ], + "markers": "python_version >= '3.7'", + "version": "==1.0.0b1" + }, + "sphinx-inline-tabs": { + "hashes": [ + "sha256:afb9142772ec05ccb07f05d8181b518188fc55631b26ee803c694e812b3fdd73", + "sha256:bb4e807769ef52301a186d0678da719120b978a1af4fd62a1e9453684e962dbc" + ], + "index": "pypi", + "version": "==2022.1.2b11" + }, + "sphinxcontrib-applehelp": { + "hashes": [ + "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a", + "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58" + ], + "markers": "python_version >= '3.5'", + "version": "==1.0.2" + }, + "sphinxcontrib-devhelp": { + "hashes": [ + "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e", + "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4" + ], + "markers": "python_version >= '3.5'", + "version": "==1.0.2" + }, + "sphinxcontrib-htmlhelp": { + "hashes": [ + "sha256:d412243dfb797ae3ec2b59eca0e52dac12e75a241bf0e4eb861e450d06c6ed07", + "sha256:f5f8bb2d0d629f398bf47d0d69c07bc13b65f75a81ad9e2f71a63d4b7a2f6db2" + ], + "markers": "python_version >= '3.6'", + "version": "==2.0.0" + }, + "sphinxcontrib-jsmath": { + "hashes": [ + "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", + "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8" + ], + "markers": "python_version >= '3.5'", + "version": "==1.0.1" + }, + "sphinxcontrib-qthelp": { + "hashes": [ + "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72", + "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6" + ], + "markers": "python_version >= '3.5'", + "version": "==1.0.3" + }, + "sphinxcontrib-serializinghtml": { + "hashes": [ + "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd", + "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952" + ], + "markers": "python_version >= '3.5'", + "version": "==1.1.5" + }, + "sphinxcontrib-spelling": { + "hashes": [ + "sha256:56561c3f6a155b0946914e4de988729859315729dc181b5e4dc8a68fe78de35a", + "sha256:95a0defef8ffec6526f9e83b20cc24b08c9179298729d87976891840e3aa3064" + ], + "index": "pypi", + "version": "==7.7.0" + }, + "urllib3": { + "hashes": [ + "sha256:47cc05d99aaa09c9e72ed5809b60e7ba354e64b59c9c173ac3018642d8bb41fc", + "sha256:c083dd0dce68dbfbe1129d5271cb90f9447dea7d52097c6e0126120c521ddea8" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", + "version": "==1.26.13" + } + } +} From c52a2c894158e89d2d11bb8f7bc29574d43221d2 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sat, 31 Dec 2022 20:05:21 +0000 Subject: [PATCH 011/158] Remove uuid16 docstring typing --- bluez_peripheral/uuid16.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bluez_peripheral/uuid16.py b/bluez_peripheral/uuid16.py index 9459cb9..febbf4d 100644 --- a/bluez_peripheral/uuid16.py +++ b/bluez_peripheral/uuid16.py @@ -8,10 +8,10 @@ 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 @@ -68,8 +68,8 @@ def __init__( 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. + Returns: + True if the uuid is in range, False otherwise. """ if uuid.fields[0] & 0xFFFF0000 != cls._FIELDS[0]: return False From 7b9bb85b73c82a9a133f74f98ce055fa8a01930f Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sat, 31 Dec 2022 20:05:39 +0000 Subject: [PATCH 012/158] Update docs requirements.txt --- docs/requirements.txt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 4f5205a..3e95511 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,5 +1,7 @@ dbus_next + sphinx -sphinx_rtd_theme +sphinx_tabs sphinxcontrib-spelling +furo m2r2 \ No newline at end of file From 68e3cb3674d32f77c1f1cefa97c8a9db293f6959 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sat, 31 Dec 2022 20:06:07 +0000 Subject: [PATCH 013/158] Fix broken test imports --- tests/gatt/test_characteristic.py | 9 +++++++-- tests/gatt/test_service.py | 16 +++++++++++++--- tests/util.py | 7 ++++--- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/tests/gatt/test_characteristic.py b/tests/gatt/test_characteristic.py index 5c09714..d906558 100644 --- a/tests/gatt/test_characteristic.py +++ b/tests/gatt/test_characteristic.py @@ -1,4 +1,3 @@ -from bluez_peripheral.gatt.descriptor import descriptor from unittest import IsolatedAsyncioTestCase from threading import Event from tests.util import * @@ -11,8 +10,11 @@ CharacteristicWriteType, characteristic, ) +from bluez_peripheral.gatt.descriptor import descriptor from bluez_peripheral.gatt.service import Service +from dbus_next import Variant + last_opts = None write_notify_char_val = None @@ -53,7 +55,10 @@ async def asyncTearDown(self): async def test_structure(self): async def inspector(path): service = await get_attrib( - self._client_bus, self._bus_manager.name, path, "180A" + self._client_bus, + self._bus_manager.name, + path, + UUID16("180A") ) child_names = [path.split("/")[-1] for path in service.child_paths] diff --git a/tests/gatt/test_service.py b/tests/gatt/test_service.py index beba0b4..ac8a61b 100644 --- a/tests/gatt/test_service.py +++ b/tests/gatt/test_service.py @@ -4,6 +4,7 @@ from typing import Collection from unittest import IsolatedAsyncioTestCase +from bluez_peripheral.uuid16 import UUID16 from bluez_peripheral.util import get_message_bus from bluez_peripheral.gatt.service import Service, ServiceCollection @@ -67,13 +68,19 @@ async def test_include_modify(self): async def inspector(path): service1 = await get_attrib( - self._client_bus, self._bus_manager.name, path, "180A" + self._client_bus, + self._bus_manager.name, + path, + UUID16("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" + self._client_bus, + self._bus_manager.name, + path, + UUID16("180B") ) # Services must include themselves. assert service1.path in includes @@ -81,7 +88,10 @@ async def inspector(path): if expect_service3: service3 = await get_attrib( - self._client_bus, self._bus_manager.name, path, "180C" + self._client_bus, + self._bus_manager.name, + path, + UUID16("180C") ) assert service3.path in includes diff --git a/tests/util.py b/tests/util.py index f65b618..ecb350d 100644 --- a/tests/util.py +++ b/tests/util.py @@ -1,11 +1,12 @@ -from typing import Tuple import asyncio +from uuid import UUID +from typing import Tuple from threading import Thread, Event from unittest.case import SkipTest from dbus_next.introspection import Node -from bluez_peripheral.util import * +from bluez_peripheral.util import get_message_bus, is_bluez_available, MessageBus, Adapter from bluez_peripheral.uuid16 import UUID16 @@ -84,7 +85,7 @@ async def find_attrib(bus, bus_name, path, nodes, target_uuid) -> Tuple[Node, st elif "org.bluez.GattDescriptor1" in interface_names: uuid = await proxy.get_interface("org.bluez.GattDescriptor1").get_uuid() - if UUID16.from_uuid16_128(uuid) == target_uuid: + if UUID16(uuid) == target_uuid: return introspection, node_path raise ValueError( From 284f273e23fcabb196ecf0790a9a9187b7f5525a Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sat, 31 Dec 2022 20:07:51 +0000 Subject: [PATCH 014/158] Update sphinx config --- docs/source/conf.py | 40 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index ed397cf..00cc2bc 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -16,6 +16,22 @@ sys.path.insert(0, os.path.abspath(os.path.dirname(__file__) + '/../../')) +# See https://github.com/sphinx-doc/sphinx/issues/5603 +def add_intersphinx_aliases_to_inv(app): + from sphinx.ext.intersphinx import InventoryAdapter + inventories = InventoryAdapter(app.builder.env) + + for alias, target in app.config.intersphinx_aliases.items(): + alias_domain, alias_name = alias + target_domain, target_name = target + try: + found = inventories.main_inventory[target_domain][target_name] + try: + inventories.main_inventory[alias_domain][alias_name] = found + except KeyError: + continue + except KeyError: + continue # -- Project information ----------------------------------------------------- @@ -28,7 +44,7 @@ # 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_inline_tabs", "m2r2"] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] @@ -38,6 +54,8 @@ # This pattern also affects html_static_path and html_extra_path. exclude_patterns = [] +nitpicky = True + # -- Napoleon ---------------------------------------------------------------- napoleon_numpy_docstring = False @@ -45,17 +63,31 @@ intersphinx_mapping = { "python": ("https://docs.python.org/3", None), "dbus_next": ("https://python-dbus-next.readthedocs.io/en/latest/", None), - # Add a backup inv to fix mapping of dbus_next.aio.proxy_object.ProxyObject and dbus_next.aio.message_bus.MessageBus - "dbus_next_alias": (os.path.abspath(os.path.dirname(__file__)), "dbus_next.inv") } +# Fix resolution of MessageBus class to where docs actually are. +intersphinx_aliases = { + ('py:class', 'dbus_next.aio.message_bus.MessageBus'): + ('py:class', 'dbus_next.aio.MessageBus'), + ('py:class', 'dbus_next.aio.proxy_object.ProxyObject'): + ('py:class', 'dbus_next.aio.ProxyObject'), + ('py:class', 'dbus_next.errors.DBusError'): + ('py:class', 'dbus_next.DBusError'), + ('py:class', 'dbus_next.signature.Variant'): + ('py:class', 'dbus_next.Variant'), +} + +def setup(app): + app.add_config_value('intersphinx_aliases', {}, 'env') + app.connect('builder-inited', add_intersphinx_aliases_to_inv) + # -- Options for HTML output ------------------------------------------------- # 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, From 191042eb4a4cdd5cc901fc1a01c76d3017f263c3 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sat, 31 Dec 2022 20:08:29 +0000 Subject: [PATCH 015/158] Add imports to __init__s --- bluez_peripheral/__init__.py | 3 +++ bluez_peripheral/gatt/__init__.py | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/bluez_peripheral/__init__.py b/bluez_peripheral/__init__.py index e69de29..f579899 100644 --- a/bluez_peripheral/__init__.py +++ b/bluez_peripheral/__init__.py @@ -0,0 +1,3 @@ +from .advert import Advertisement + +from .uuid16 import UUID16 \ No newline at end of file diff --git a/bluez_peripheral/gatt/__init__.py b/bluez_peripheral/gatt/__init__.py index e69de29..c4ccfc1 100644 --- a/bluez_peripheral/gatt/__init__.py +++ b/bluez_peripheral/gatt/__init__.py @@ -0,0 +1,6 @@ +from .characteristic import characteristic + +from .descriptor import descriptor + +from .service import Service +from .service import ServiceCollection \ No newline at end of file From 15787fac425b0d0ee3e8d9f7f9e7e7bf311882c9 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sat, 31 Dec 2022 21:26:56 +0000 Subject: [PATCH 016/158] Remove inventory hack --- docs/source/dbus_next.inv | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 docs/source/dbus_next.inv diff --git a/docs/source/dbus_next.inv b/docs/source/dbus_next.inv deleted file mode 100644 index 448077c..0000000 --- a/docs/source/dbus_next.inv +++ /dev/null @@ -1,9 +0,0 @@ -# Sphinx inventory version 2 -# Project: dbus-next -# Version: 0.2.3 -# The remainder of this file is compressed using zlib. -xÚKI*-ŽÏK­(ÑKÌÌ×ËM-.NLO -êùBØN¥Å -•VÉ9‰ÅÅ -† -Pº@ú@ºH|½Œ’Üe]®C Šò+*ãó“²R“Kô@0ÙØŒÌô ݜԲÔÝäœÌÔ¼°á`ºPpÓRÜA \ No newline at end of file From e961ea2f0df642c73044f072c99554038775f91c Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sat, 31 Dec 2022 23:50:56 +0000 Subject: [PATCH 017/158] Initial error classes --- bluez_peripheral/agent.py | 5 +-- bluez_peripheral/error.py | 38 +++++++++++++++++++ bluez_peripheral/gatt/characteristic.py | 11 ++---- ....errors.rst => bluez_peripheral.error.rst} | 4 +- docs/source/ref/bluez_peripheral.rst | 2 +- 5 files changed, 46 insertions(+), 14 deletions(-) create mode 100644 bluez_peripheral/error.py rename docs/source/ref/{bluez_peripheral.errors.rst => bluez_peripheral.error.rst} (54%) diff --git a/bluez_peripheral/agent.py b/bluez_peripheral/agent.py index ee50c2c..7884592 100644 --- a/bluez_peripheral/agent.py +++ b/bluez_peripheral/agent.py @@ -6,6 +6,7 @@ from enum import Enum from .util import _snake_to_pascal +from .error import RejectedError class AgentCapability(Enum): @@ -185,9 +186,7 @@ def __init__( @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." - ) + raise RejectedError("The supplied passkey was rejected.") @method() def Cancel(self): # type: ignore diff --git a/bluez_peripheral/error.py b/bluez_peripheral/error.py new file mode 100644 index 0000000..07459af --- /dev/null +++ b/bluez_peripheral/error.py @@ -0,0 +1,38 @@ +from dbus_next import DBusError + +class FailedError(DBusError): + def __init__(message): + super.__init__("org.bluez.Error.Failed", message) + +class InProgressError(DBusError): + def __init__(message): + super.__init__("org.bluez.Error.InProgress", message) + +class NotPermittedError(DBusError): + def __init__(message): + super.__init__("org.bluez.Error.NotPermitted", message) + +class InvalidValueLengthError(DBusError): + def __init__(message): + super.__init__("org.bluez.Error.InvalidValueLength", message) + +class InvalidOffsetError(DBusError): + def __init__(message): + super.__init__("org.bluez.Error.InvalidOffset", message) + +class NotAuthorizedError(DBusError): + def __init__(message): + super.__init__("org.bluez.Error.NotAuthorized", message) + +class NotConnectedError(DBusError): + def __init__(message): + super.__init__("org.bluez.Error.NotConnected", message) + +class NotSupportedError(DBusError): + def __init__(message): + super.__init__("org.bluez.Error.NotSupported", message) + +class RejectedError(DBusError): + def __init__(message): + super.__init__("org.bluez.Error.Rejected", message) + diff --git a/bluez_peripheral/gatt/characteristic.py b/bluez_peripheral/gatt/characteristic.py index 075a844..5965df7 100644 --- a/bluez_peripheral/gatt/characteristic.py +++ b/bluez_peripheral/gatt/characteristic.py @@ -10,6 +10,7 @@ from .descriptor import descriptor, DescriptorFlags from ..uuid16 import UUID16 from ..util import _snake_to_kebab, _getattr_variant +from ..error import NotSupportedError class CharacteristicReadOptions: @@ -349,20 +350,14 @@ def WriteValue(self, data: "ay", options: "a{sv}"): # type: ignore @method() def StartNotify(self): 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): 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 diff --git a/docs/source/ref/bluez_peripheral.errors.rst b/docs/source/ref/bluez_peripheral.error.rst similarity index 54% rename from docs/source/ref/bluez_peripheral.errors.rst rename to docs/source/ref/bluez_peripheral.error.rst index ca7534a..0072581 100644 --- a/docs/source/ref/bluez_peripheral.errors.rst +++ b/docs/source/ref/bluez_peripheral.error.rst @@ -1,7 +1,7 @@ -bluez\_peripheral.errors module +bluez\_peripheral.error module =============================== -.. automodule:: bluez_peripheral.errors +.. automodule:: bluez_peripheral.error :members: :undoc-members: :show-inheritance: diff --git a/docs/source/ref/bluez_peripheral.rst b/docs/source/ref/bluez_peripheral.rst index 6edc17f..5b50fbf 100644 --- a/docs/source/ref/bluez_peripheral.rst +++ b/docs/source/ref/bluez_peripheral.rst @@ -17,6 +17,6 @@ Submodules bluez_peripheral.advert bluez_peripheral.agent - bluez_peripheral.errors + bluez_peripheral.error bluez_peripheral.util bluez_peripheral.uuid16 \ No newline at end of file From 48d802931db256e042c96bff24de15743c88873d Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 3 Jan 2023 16:53:18 +0000 Subject: [PATCH 018/158] Add get_message_bus to bluez_peripheral namespace --- bluez_peripheral/__init__.py | 4 +++- tests/test_advert.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/bluez_peripheral/__init__.py b/bluez_peripheral/__init__.py index f579899..b9d9d73 100644 --- a/bluez_peripheral/__init__.py +++ b/bluez_peripheral/__init__.py @@ -1,3 +1,5 @@ from .advert import Advertisement -from .uuid16 import UUID16 \ No newline at end of file +from .uuid16 import UUID16 + +from .util import get_message_bus \ No newline at end of file diff --git a/tests/test_advert.py b/tests/test_advert.py index de5cd22..ffadf4f 100644 --- a/tests/test_advert.py +++ b/tests/test_advert.py @@ -2,7 +2,7 @@ from unittest.case import SkipTest from tests.util import * -from bluez_peripheral.util import get_message_bus +from bluez_peripheral import get_message_bus from bluez_peripheral.advert import Advertisement, PacketType, AdvertisingIncludes from uuid import UUID From 25bb23ddbf784542bb5abf7b7497d6697ed18760 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 3 Jan 2023 16:55:09 +0000 Subject: [PATCH 019/158] Add format checking workflow --- .github/workflows/python-test.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index b049dca..105c856 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -42,4 +42,22 @@ jobs: - name: Run Tests run: | python -m unittest + format-check: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.9.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 From feb59340301866a0ea09048ea1960e68e998ca1c Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 3 Jan 2023 16:57:43 +0000 Subject: [PATCH 020/158] README fixes --- README.md | 31 +++++++++++-------------------- 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 4a1b5b4..a470f92 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,8 @@ 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 standardised 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) @@ -31,19 +31,15 @@ You can find a list of standardised services on the [Bluetooth SIG website](http 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/). -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 yeild 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 @@ -56,11 +52,7 @@ class HeartRateService(Service): @characteristic("2A37", CharFlags.NOTIFY | CharFlags.READ) def heart_rate_measurement(self, options): # This function is called when the characteristic is read. - # Since this characteristic is notify only this function is a placeholder. - # You don't need this function Python 3.9+ (See PEP 614). - # You can generally ignore the options argument - # (see Advanced Characteristics and Descriptors Documentation). - pass + return struct.pack(" Date: Tue, 3 Jan 2023 16:58:50 +0000 Subject: [PATCH 021/158] Source formatting and docstring fixes --- bluez_peripheral/__init__.py | 2 +- bluez_peripheral/advert.py | 8 ++--- bluez_peripheral/agent.py | 4 +-- bluez_peripheral/error.py | 42 +++++++++++++++---------- bluez_peripheral/gatt/__init__.py | 9 +++++- bluez_peripheral/gatt/characteristic.py | 11 ++++--- bluez_peripheral/gatt/descriptor.py | 1 + bluez_peripheral/uuid16.py | 20 +++++------- tests/gatt/test_characteristic.py | 6 +--- tests/gatt/test_descriptor.py | 22 +++++++------ tests/gatt/test_service.py | 20 +++--------- tests/test_uuid16.py | 10 +++--- tests/util.py | 7 ++++- 13 files changed, 83 insertions(+), 79 deletions(-) diff --git a/bluez_peripheral/__init__.py b/bluez_peripheral/__init__.py index b9d9d73..6e09aa6 100644 --- a/bluez_peripheral/__init__.py +++ b/bluez_peripheral/__init__.py @@ -2,4 +2,4 @@ from .uuid16 import UUID16 -from .util import get_message_bus \ No newline at end of file +from .util import get_message_bus diff --git a/bluez_peripheral/advert.py b/bluez_peripheral/advert.py index 4c9d62d..2293f83 100644 --- a/bluez_peripheral/advert.py +++ b/bluez_peripheral/advert.py @@ -73,9 +73,7 @@ def __init__( duration: int = 2, ): self._type = packet_type - self._serviceUUIDs = [ - UUID16.parse_uuid(uuid) for uuid in serviceUUIDs - ] + self._serviceUUIDs = [UUID16.parse_uuid(uuid) for uuid in serviceUUIDs] self._localName = localName # Convert the appearance to a uint16 if it isn't already an int. self._appearance = ( @@ -87,9 +85,7 @@ def __init__( for key, value in manufacturerData.items(): self._manufacturerData[key] = Variant("ay", value) - self._solicitUUIDs = [ - UUID16.parse_uuid(uuid) for uuid in solicitUUIDs - ] + self._solicitUUIDs = [UUID16.parse_uuid(uuid) for uuid in solicitUUIDs] self._serviceData = serviceData self._discoverable = discoverable self._includes = includes diff --git a/bluez_peripheral/agent.py b/bluez_peripheral/agent.py index 7884592..5ed7dd4 100644 --- a/bluez_peripheral/agent.py +++ b/bluez_peripheral/agent.py @@ -18,10 +18,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. diff --git a/bluez_peripheral/error.py b/bluez_peripheral/error.py index 07459af..52c53ae 100644 --- a/bluez_peripheral/error.py +++ b/bluez_peripheral/error.py @@ -1,38 +1,46 @@ from dbus_next import DBusError + class FailedError(DBusError): - def __init__(message): - super.__init__("org.bluez.Error.Failed", message) + def __init__(message): + super.__init__("org.bluez.Error.Failed", message) + class InProgressError(DBusError): - def __init__(message): - super.__init__("org.bluez.Error.InProgress", message) + def __init__(message): + super.__init__("org.bluez.Error.InProgress", message) + class NotPermittedError(DBusError): - def __init__(message): - super.__init__("org.bluez.Error.NotPermitted", message) + def __init__(message): + super.__init__("org.bluez.Error.NotPermitted", message) + class InvalidValueLengthError(DBusError): - def __init__(message): - super.__init__("org.bluez.Error.InvalidValueLength", message) + def __init__(message): + super.__init__("org.bluez.Error.InvalidValueLength", message) + class InvalidOffsetError(DBusError): - def __init__(message): - super.__init__("org.bluez.Error.InvalidOffset", message) + def __init__(message): + super.__init__("org.bluez.Error.InvalidOffset", message) + class NotAuthorizedError(DBusError): - def __init__(message): - super.__init__("org.bluez.Error.NotAuthorized", message) + def __init__(message): + super.__init__("org.bluez.Error.NotAuthorized", message) + class NotConnectedError(DBusError): - def __init__(message): - super.__init__("org.bluez.Error.NotConnected", message) + def __init__(message): + super.__init__("org.bluez.Error.NotConnected", message) + class NotSupportedError(DBusError): - def __init__(message): - super.__init__("org.bluez.Error.NotSupported", message) + def __init__(message): + super.__init__("org.bluez.Error.NotSupported", message) + class RejectedError(DBusError): def __init__(message): super.__init__("org.bluez.Error.Rejected", message) - diff --git a/bluez_peripheral/gatt/__init__.py b/bluez_peripheral/gatt/__init__.py index c4ccfc1..ee660f7 100644 --- a/bluez_peripheral/gatt/__init__.py +++ b/bluez_peripheral/gatt/__init__.py @@ -1,6 +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 \ No newline at end of file +from .service import ServiceCollection diff --git a/bluez_peripheral/gatt/characteristic.py b/bluez_peripheral/gatt/characteristic.py index 5965df7..f2ebfda 100644 --- a/bluez_peripheral/gatt/characteristic.py +++ b/bluez_peripheral/gatt/characteristic.py @@ -13,6 +13,7 @@ from ..error import NotSupportedError +# TODO: Add type annotations to these classes. class CharacteristicReadOptions: """Options supplied to characteristic read functions. Generally you can ignore these unless you have a long characteristic (eg > 48 bytes) or you have some specific authorization requirements. @@ -23,7 +24,7 @@ def __init__(self): def __init__(self, options): self._offset = int(_getattr_variant(options, "offset", 0)) - self._mtu = int(_getattr_variant(options, "mtu", 0)) + self._mtu = int(_getattr_variant(options, "mtu", None)) self._device = _getattr_variant(options, "device", None) @property @@ -33,11 +34,11 @@ def offset(self) -> int: @property def mtu(self) -> Optional[int]: - """The exchanged Maximum Transfer Unit of the connection with the remote device or 0.""" + """The exchanged Maximum Transfer Unit of the connection with the remote device or None.""" 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 @@ -238,7 +239,9 @@ def __call__( return self def descriptor( - self, uuid: Union[str, bytes, UUID, UUID16, int], flags: DescriptorFlags = DescriptorFlags.READ + self, + uuid: Union[str, bytes, UUID, UUID16, int], + flags: DescriptorFlags = DescriptorFlags.READ, ) -> "descriptor": """Create a new descriptor with the specified UUID and Flags. diff --git a/bluez_peripheral/gatt/descriptor.py b/bluez_peripheral/gatt/descriptor.py index 3c6a68b..86c60bc 100644 --- a/bluez_peripheral/gatt/descriptor.py +++ b/bluez_peripheral/gatt/descriptor.py @@ -11,6 +11,7 @@ from ..util import _snake_to_kebab, _getattr_variant +# TODO: Add type annotations to these classes. class DescriptorReadOptions: """Options supplied to descriptor read functions. Generally you can ignore these unless you have a long descriptor (eg > 48 bytes) or you have some specific authorization requirements. diff --git a/bluez_peripheral/uuid16.py b/bluez_peripheral/uuid16.py index febbf4d..653b590 100644 --- a/bluez_peripheral/uuid16.py +++ b/bluez_peripheral/uuid16.py @@ -50,7 +50,6 @@ def __init__( time_low = int else: uuid = UUID(int=int) - if time_low is not None: fields = [f for f in self._FIELDS] @@ -60,15 +59,13 @@ def __init__( 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: + Returns: True if the uuid is in range, False otherwise. """ if uuid.fields[0] & 0xFFFF0000 != cls._FIELDS[0]: @@ -82,6 +79,7 @@ def is_in_range(cls, uuid: UUID) -> bool: @classmethod def parse_uuid(cls, uuid: Union[str, bytes, int, UUID]) -> Union[UUID, "UUID16"]: + """Attempts to convert a provided value to a UUID16. If this fails, conversion falls back to a 128-bit UUID.""" if type(uuid) is UUID: if cls.is_in_range(uuid): return UUID16(uuid=uuid) @@ -107,26 +105,22 @@ def parse_uuid(cls, uuid: Union[str, bytes, int, UUID]) -> Union[UUID, "UUID16"] @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. - """ + """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: diff --git a/tests/gatt/test_characteristic.py b/tests/gatt/test_characteristic.py index d906558..3c6028e 100644 --- a/tests/gatt/test_characteristic.py +++ b/tests/gatt/test_characteristic.py @@ -55,10 +55,7 @@ async def asyncTearDown(self): async def test_structure(self): async def inspector(path): service = await get_attrib( - self._client_bus, - self._bus_manager.name, - path, - UUID16("180A") + self._client_bus, self._bus_manager.name, path, UUID16("180A") ) child_names = [path.split("/")[-1] for path in service.child_paths] @@ -194,7 +191,6 @@ def on_properties_changed(interface, values, invalid_props): 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() diff --git a/tests/gatt/test_descriptor.py b/tests/gatt/test_descriptor.py index 56c151c..dd7af91 100644 --- a/tests/gatt/test_descriptor.py +++ b/tests/gatt/test_descriptor.py @@ -6,10 +6,14 @@ 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 +from bluez_peripheral import get_message_bus +from bluez_peripheral.gatt import ( + CharacteristicFlags, + characteristic, + DescriptorFlags, + descriptor, + Service, +) last_opts = None write_desc_val = None @@ -54,11 +58,11 @@ async def asyncTearDown(self): async def test_structure(self): async def inspector(path): char = await get_attrib( - self._client_bus, - self._bus_manager.name, - path, - UUID16("180A"), - UUID16("2A37") + self._client_bus, + self._bus_manager.name, + path, + UUID16("180A"), + UUID16("2A37"), ) child_names = [path.split("/")[-1] for path in char.child_paths] diff --git a/tests/gatt/test_service.py b/tests/gatt/test_service.py index ac8a61b..c27dc9f 100644 --- a/tests/gatt/test_service.py +++ b/tests/gatt/test_service.py @@ -5,8 +5,8 @@ from unittest import IsolatedAsyncioTestCase from bluez_peripheral.uuid16 import UUID16 -from bluez_peripheral.util import get_message_bus -from bluez_peripheral.gatt.service import Service, ServiceCollection +from bluez_peripheral import get_message_bus +from bluez_peripheral.gatt import Service, ServiceCollection class TestService1(Service): @@ -68,19 +68,13 @@ async def test_include_modify(self): async def inspector(path): service1 = await get_attrib( - self._client_bus, - self._bus_manager.name, - path, - UUID16("180A") + self._client_bus, self._bus_manager.name, path, UUID16("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, - UUID16("180B") + self._client_bus, self._bus_manager.name, path, UUID16("180B") ) # Services must include themselves. assert service1.path in includes @@ -88,10 +82,7 @@ async def inspector(path): if expect_service3: service3 = await get_attrib( - self._client_bus, - self._bus_manager.name, - path, - UUID16("180C") + self._client_bus, self._bus_manager.name, path, UUID16("180C") ) assert service3.path in includes @@ -107,4 +98,3 @@ async def inspector(path): collection.remove_service(service3) expect_service3 = False await collection.register(self._bus_manager.bus, self._path, adapter=adapter) - diff --git a/tests/test_uuid16.py b/tests/test_uuid16.py index 27c292c..93d048c 100644 --- a/tests/test_uuid16.py +++ b/tests/test_uuid16.py @@ -21,12 +21,12 @@ def test_from_hex(self): def test_from_bytes(self): with self.assertRaises(ValueError): - uuid = UUID16(bytes=b'\x12') + uuid = UUID16(bytes=b"\x12") with self.assertRaises(ValueError): - uuid = UUID16(bytes=b'\x12\x34\x56') + uuid = UUID16(bytes=b"\x12\x34\x56") - uuid = UUID16(bytes=b'\x12\x34') + 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) @@ -72,8 +72,8 @@ def test_int(self): def test_bytes(self): uuid = UUID16(uuid=UUID("00001234-0000-1000-8000-00805F9B34FB")) - assert uuid.bytes == b'\x12\x34' + 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 + assert uuid.hex == "1234" diff --git a/tests/util.py b/tests/util.py index ecb350d..4a8edd7 100644 --- a/tests/util.py +++ b/tests/util.py @@ -6,7 +6,12 @@ from dbus_next.introspection import Node -from bluez_peripheral.util import get_message_bus, is_bluez_available, MessageBus, Adapter +from bluez_peripheral.util import ( + get_message_bus, + is_bluez_available, + MessageBus, + Adapter, +) from bluez_peripheral.uuid16 import UUID16 From 6d672cb0e3ae3c246fe06e2b0f498ecd50b52d8d Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 3 Jan 2023 16:59:16 +0000 Subject: [PATCH 022/158] Add more doc makefile rules --- docs/Makefile | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/Makefile b/docs/Makefile index a15b7e2..d2b6274 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -3,16 +3,27 @@ # You can set these variables from the command line, and also # from the environment for the first two. -SPHINXOPTS ?= -W -b spelling -n -j auto +SPHINXOPTS ?= -j auto 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: + @$(SPHINXDOC) -o "$(APIDOCDIR)" "$(MODPATH)" $(SPHINXDOCOPTS) + +.PHONY: help Makefile apidoc + +spelling: + @$(SPHINXBUILD) -b spelling "$(SOURCEDIR)" "$(BUILDDIR)" # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). From 9a5562bbb052e001810198652c76cf75fea9e347 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 3 Jan 2023 17:00:05 +0000 Subject: [PATCH 023/158] Re-add spelling checking to test workflow --- .github/workflows/python-test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 105c856..5cf1a45 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -22,6 +22,7 @@ jobs: cd docs make clean make html + make spelling make linkcheck test: runs-on: ubuntu-22.04 From 58091cdf8cfd36396b9d095a1a862d06d540fb57 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 3 Jan 2023 18:16:47 +0000 Subject: [PATCH 024/158] Rewrite static docs --- README.md | 4 +- docs/source/advertising.rst | 38 ++++ docs/source/characteristics_descriptors.rst | 48 ----- docs/source/index.rst | 120 ++--------- docs/source/pairing.rst | 152 ++++++++++---- docs/source/services.rst | 209 ++++++++++++++++++++ 6 files changed, 374 insertions(+), 197 deletions(-) create mode 100644 docs/source/advertising.rst delete mode 100644 docs/source/characteristics_descriptors.rst create mode 100644 docs/source/services.rst diff --git a/README.md b/README.md index a470f92..ccba3ac 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ class HeartRateService(Service): For your service to work you need to register it with bluez. In this example we also register an agent to secure pairing attempts (more on this later). Finally you also need to advertise the service to nearby devices. ```python from bluez_peripheral import get_message_bus, Advertisement +from bluez_peripheral.util import Adapter from bluez_peripheral.agent import NoIoAgent import asyncio @@ -99,5 +100,4 @@ async def main(): if __name__ == "__main__": asyncio.run(main()) ``` -To communicate with bluez the default dbus configuration requires that you be in the bluetooth user group (eg. `sudo useradd -aG bluetooth spacecheese`). -Further [documentation](https://bluez-peripheral.readthedocs.io/en/latest/) is available. +To connect to and test your service the [nRF Connect for Mobile](https://www.nordicsemi.com/Products/Development-tools/nrf-connect-for-mobile) app is an excellent tool. To communicate with bluez the default dbus configuration requires that you be in the bluetooth user group (eg. `sudo useradd -aG bluetooth spacecheese`). diff --git a/docs/source/advertising.rst b/docs/source/advertising.rst new file mode 100644 index 0000000..03c77f1 --- /dev/null +++ b/docs/source/advertising.rst @@ -0,0 +1,38 @@ +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_next.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 values `). +* 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). + +.. code-block:: python + + from bluez_peripheral import get_message_bus, Advertisement + from bluez_peripheral.util import Adapter + + adapter = await Adapter.get_first(bus) + + # This name will appear to the user. + # | Any service UUIDs (in this case the heart rate service). + # | | This is the appearance code for a generic heart rate sensor. + # | | | How long until the advert should stop (in seconds). + # | | | | + # \/ \/ \/ \/ + advert = Advertisement("Heart Monitor", ["180D"], 0x0340, 60) + await advert.register(bus, adapter) + +.. seealso:: + + Bluez Documentation + `Advertising API `_ + + Bluetooth SIG + `Assigned Appearance Values `_ \ No newline at end of file diff --git a/docs/source/characteristics_descriptors.rst b/docs/source/characteristics_descriptors.rst deleted file mode 100644 index a243a5c..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_next `_ 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_next.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_next.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/index.rst b/docs/source/index.rst index e428ac5..126a6ef 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,108 +1,18 @@ -.. _quickstart: +.. mdinclude:: ../../README.md -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_readonly_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.gatt.service import ServiceCollection - from ble.util import * - - # 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 Advertisment - - 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 = Advertisment("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 +TODO: Make sure types are linked in all these pages when the reference is done. +TODO: Make sure True/ False is linked in pages. +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..fe611a2 100644 --- a/docs/source/pairing.rst +++ b/docs/source/pairing.rst @@ -1,71 +1,139 @@ 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_next.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) + .. code-block:: python -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: + 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. + agent = YesNoAgent(accept_pairing, cancel_pairing) + await agent.register(bus) + +.. 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: + + .. code-block:: python + + from bluez_peripheral import get_message_bus + from bluez_peripheral.agent import TestAgent + + bus = await get_message_bus() + + agent = TestAgent() + await agent.register(bus) + + 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_next 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 ``. Aditionally the linux bluetooth daemon stores a large amount of seemingly undocumented metadata in the ``/var/lib/bluetooth/`` directory and, it may be useful to delete this data between attempts. + +.. _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 `_ \ No newline at end of file diff --git a/docs/source/services.rst b/docs/source/services.rst new file mode 100644 index 0000000..99d362b --- /dev/null +++ b/docs/source/services.rst @@ -0,0 +1,209 @@ +Creating a Service +================== +Attribute Flags +--------------- +The behaviour 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 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 minimise 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 interchangably, 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 + + .. code-block:: python + + # TODO: Code examples need automated testing. + 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 + +.. tab:: Manually (Not Recommended) + + .. code-block:: python + + 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 + ) + + # Register my_descriptor with its parent characteristic and my_characteristic + # with its parent service. + my_service = Service() + my_characteristic.add_descriptor(my_descriptor) + my_service.add_characteristic(my_characteristic) + +Error Handling +^^^^^^^^^^^^^^ +Attribute getters/ setters may raise one of a set of :ref:`legal exceptions` to signal specific conditions to bluez. Avoid thowing custom exceptions in attribute accessors, since these will not be presented to a user and bluez will not know how to interpret them. Aditionally any exceptions thrown **must** derive from :py:class:`dbus_next.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 yeilds regularly. Client requests will not be served otherwise. + +.. hint:: + The "message bus" referred to here is a :py:class:`dbus_next.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 + + .. code-block:: python + + from bluez_peripheral import get_message_bus + + 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() + +.. tab:: ServiceCollection + + .. code-block:: python + + from bluez_peripheral import get_message_bus + from bluez_peripheral.gatt import ServiceCollection + + 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() + +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 acknowledgement 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. + +.. code-block:: python + + 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 + + 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() + + +.. seealso:: + + Bluez Documentation + `GATT 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` + From c4fbf3b321b4ea9842480f3635a4f82ad38e38fd Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 3 Jan 2023 18:17:09 +0000 Subject: [PATCH 025/158] Add __call__ to characteristic/ descriptor ref --- docs/source/ref/bluez_peripheral.gatt.characteristic.rst | 1 + docs/source/ref/bluez_peripheral.gatt.descriptor.rst | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/source/ref/bluez_peripheral.gatt.characteristic.rst b/docs/source/ref/bluez_peripheral.gatt.characteristic.rst index 7c6222c..ca8204b 100644 --- a/docs/source/ref/bluez_peripheral.gatt.characteristic.rst +++ b/docs/source/ref/bluez_peripheral.gatt.characteristic.rst @@ -5,3 +5,4 @@ bluez\_peripheral.gatt.characteristic module :members: :no-undoc-members: :show-inheritance: + :special-members: __call__ diff --git a/docs/source/ref/bluez_peripheral.gatt.descriptor.rst b/docs/source/ref/bluez_peripheral.gatt.descriptor.rst index 08add29..32ee68a 100644 --- a/docs/source/ref/bluez_peripheral.gatt.descriptor.rst +++ b/docs/source/ref/bluez_peripheral.gatt.descriptor.rst @@ -5,3 +5,4 @@ bluez\_peripheral.gatt.descriptor module :members: :no-undoc-members: :show-inheritance: + :special-members: __call__ From ecf428ab29ba85880dc728edfe6a882cf1ce561e Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 3 Jan 2023 18:17:30 +0000 Subject: [PATCH 026/158] Rename Advertisement.__init__.packet_type --- bluez_peripheral/advert.py | 8 ++++---- tests/test_advert.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/bluez_peripheral/advert.py b/bluez_peripheral/advert.py index 2293f83..1445514 100644 --- a/bluez_peripheral/advert.py +++ b/bluez_peripheral/advert.py @@ -46,13 +46,13 @@ class Advertisement(ServiceInterface): `See the Bluetooth SIG recognised values. `_ timeout: The time from registration until this advert is removed. 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. - duration: Duration of the advert when multiple adverts are ongoing. + duration: Duration of the advert when only this advert is ongoing. """ _INTERFACE = "org.bluez.LEAdvertisement1" @@ -65,14 +65,14 @@ def __init__( appearance: Union[int, bytes], timeout: int, discoverable: bool = True, - packet_type: PacketType = PacketType.PERIPHERAL, + packetType: PacketType = PacketType.PERIPHERAL, manufacturerData: Dict[int, bytes] = {}, solicitUUIDs: Collection[Union[str, bytes, UUID, UUID16, int]] = [], serviceData: Dict[str, bytes] = {}, includes: AdvertisingIncludes = AdvertisingIncludes.NONE, duration: int = 2, ): - self._type = packet_type + self._type = packetType self._serviceUUIDs = [UUID16.parse_uuid(uuid) for uuid in serviceUUIDs] self._localName = localName # Convert the appearance to a uint16 if it isn't already an int. diff --git a/tests/test_advert.py b/tests/test_advert.py index ffadf4f..3eb5ea1 100644 --- a/tests/test_advert.py +++ b/tests/test_advert.py @@ -23,7 +23,7 @@ async def test_basic(self): ["180A", "180D"], 0x0340, 2, - packet_type=PacketType.PERIPHERAL, + packetType=PacketType.PERIPHERAL, includes=AdvertisingIncludes.TX_POWER, ) @@ -57,7 +57,7 @@ async def test_includes_empty(self): ["180A", "180D"], 0x0340, 2, - packet_type=PacketType.PERIPHERAL, + packetType=PacketType.PERIPHERAL, includes=AdvertisingIncludes.NONE, ) From 87c0fb0679f20d69f99172d8d25b1299f29cefd3 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 3 Jan 2023 18:49:45 +0000 Subject: [PATCH 027/158] Remove requirements and migrate workflows --- .github/workflows/python-publish.yml | 11 +++++------ .github/workflows/python-test.yml | 19 +++++++++---------- docs/requirements.txt | 7 ------- requirements.txt | 1 - 4 files changed, 14 insertions(+), 24 deletions(-) delete mode 100644 docs/requirements.txt delete mode 100644 requirements.txt diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 205cf75..faa7832 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -6,19 +6,18 @@ on: jobs: deploy: - - runs-on: ubuntu-latest - + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: - python-version: '3.x' - cache: 'pip' + python-version: '3.9.x' + cache: 'pipenv' - name: Install dependencies run: | - python -m pip install --upgrade pip + curl https://raw.githubusercontent.com/pypa/pipenv/master/get-pipenv.py | python + pipenv install pip install build - name: Build package run: python -m build diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 5cf1a45..b5148c1 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -11,12 +11,12 @@ jobs: uses: actions/setup-python@v4 with: python-version: '3.9.x' - cache: 'pip' + cache: 'pipenv' - 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 + curl https://raw.githubusercontent.com/pypa/pipenv/master/get-pipenv.py | python + pipenv install - name: Build Docs run: | cd docs @@ -32,11 +32,11 @@ jobs: uses: actions/setup-python@v4 with: python-version: '3.9.x' - cache: 'pip' + cache: 'pipenv' - name: Install dependencies run: | - python -m pip install --upgrade pip - pip install -r tests/requirements.txt + curl https://raw.githubusercontent.com/pypa/pipenv/master/get-pipenv.py | python + pipenv install - name: Add DBus Config run: | sudo cp tests/com.spacecheese.test.conf /etc/dbus-1/system.d @@ -51,12 +51,11 @@ jobs: uses: actions/setup-python@v4 with: python-version: '3.9.x' - cache: 'pip' + cache: 'pipenv' - name: Install dependencies run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install black + curl https://raw.githubusercontent.com/pypa/pipenv/master/get-pipenv.py | python + pipenv install - name: Check Formatting run: | python -m black --check bluez_peripheral diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index 3e95511..0000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -dbus_next - -sphinx -sphinx_tabs -sphinxcontrib-spelling -furo -m2r2 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 60f767a..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -dbus_next \ No newline at end of file From 0fb06cc8c961b38b4d3c8cec425dc128f00b3452 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 3 Jan 2023 18:51:45 +0000 Subject: [PATCH 028/158] Add black install to test workflow --- .github/workflows/python-test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index b5148c1..96492d9 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -56,6 +56,7 @@ jobs: run: | curl https://raw.githubusercontent.com/pypa/pipenv/master/get-pipenv.py | python pipenv install + pip install black - name: Check Formatting run: | python -m black --check bluez_peripheral From 7aae565cf1817aeb25e3b625160eec0337e6217a Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 3 Jan 2023 18:59:47 +0000 Subject: [PATCH 029/158] Run workflows in pipenv shell --- .github/workflows/python-publish.yml | 2 +- .github/workflows/python-test.yml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index faa7832..f3ab528 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -17,7 +17,7 @@ jobs: - name: Install dependencies run: | curl https://raw.githubusercontent.com/pypa/pipenv/master/get-pipenv.py | python - pipenv install + pipenv install && pipenv shell pip install build - name: Build package run: python -m build diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 96492d9..b6fc250 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -16,7 +16,7 @@ jobs: run: | sudo apt-get update && sudo apt-get install -y enchant-2 aspell-en curl https://raw.githubusercontent.com/pypa/pipenv/master/get-pipenv.py | python - pipenv install + pipenv install && pipenv shell - name: Build Docs run: | cd docs @@ -36,7 +36,7 @@ jobs: - name: Install dependencies run: | curl https://raw.githubusercontent.com/pypa/pipenv/master/get-pipenv.py | python - pipenv install + pipenv install && pipenv shell - name: Add DBus Config run: | sudo cp tests/com.spacecheese.test.conf /etc/dbus-1/system.d @@ -55,7 +55,7 @@ jobs: - name: Install dependencies run: | curl https://raw.githubusercontent.com/pypa/pipenv/master/get-pipenv.py | python - pipenv install + pipenv install && pipenv shell pip install black - name: Check Formatting run: | From 0ec9c6b11f8acbe027dfe9bc1d10031fcad5a761 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 3 Jan 2023 19:23:21 +0000 Subject: [PATCH 030/158] Attempt pipenv workflow fix --- .github/workflows/python-publish.yml | 9 ++++--- .github/workflows/python-test.yml | 35 ++++++++++++++-------------- 2 files changed, 21 insertions(+), 23 deletions(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index f3ab528..4b363da 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -12,15 +12,14 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: '3.9.x' + python-version: '3.10.x' cache: 'pipenv' - name: Install dependencies run: | - curl https://raw.githubusercontent.com/pypa/pipenv/master/get-pipenv.py | python - pipenv install && pipenv shell - pip install build + sudo apt-get update && sudo apt-get install -y pipenv python3.10-venv + pipenv install build - name: Build package - run: python -m build + run: pipenv run python -m build - name: Publish package uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 with: diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index b6fc250..2ab2280 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -10,20 +10,19 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: '3.9.x' + python-version: '3.10.x' cache: 'pipenv' - name: Install dependencies run: | - sudo apt-get update && sudo apt-get install -y enchant-2 aspell-en - curl https://raw.githubusercontent.com/pypa/pipenv/master/get-pipenv.py | python - pipenv install && pipenv shell + sudo apt-get update && sudo apt-get install -y enchant-2 aspell-en pipenv python3.10-venv + pipenv install --dev - name: Build Docs run: | cd docs - make clean - make html - make spelling - make linkcheck + pipenv run make clean + pipenv run make html + pipenv run make spelling + pipenv run make linkcheck test: runs-on: ubuntu-22.04 steps: @@ -31,18 +30,18 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: '3.9.x' + python-version: '3.10.x' cache: 'pipenv' - name: Install dependencies run: | - curl https://raw.githubusercontent.com/pypa/pipenv/master/get-pipenv.py | python - pipenv install && pipenv shell + sudo apt-get update && sudo apt-get install -y pipenv python3.10-venv + pipenv install --dev - name: Add DBus Config run: | sudo cp tests/com.spacecheese.test.conf /etc/dbus-1/system.d - name: Run Tests run: | - python -m unittest + pipenv run python -m unittest format-check: runs-on: ubuntu-22.04 steps: @@ -50,15 +49,15 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: '3.9.x' + python-version: '3.10.x' cache: 'pipenv' - name: Install dependencies run: | - curl https://raw.githubusercontent.com/pypa/pipenv/master/get-pipenv.py | python - pipenv install && pipenv shell - pip install black + sudo apt-get update && sudo apt-get install -y pipenv virtualenv + pipenv install --dev + pipenv install black - name: Check Formatting run: | - python -m black --check bluez_peripheral - python -m black --check tests + pipenv run python -m black --check bluez_peripheral + pipenv run python -m black --check tests From 5d149e9570608d31a44559a9c8e00d408a7b78f6 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 3 Jan 2023 19:27:17 +0000 Subject: [PATCH 031/158] Revert "Attempt pipenv workflow fix" This reverts commit 0ec9c6b11f8acbe027dfe9bc1d10031fcad5a761. --- .github/workflows/python-publish.yml | 9 +++---- .github/workflows/python-test.yml | 35 ++++++++++++++-------------- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 4b363da..f3ab528 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -12,14 +12,15 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: '3.10.x' + python-version: '3.9.x' cache: 'pipenv' - name: Install dependencies run: | - sudo apt-get update && sudo apt-get install -y pipenv python3.10-venv - pipenv install build + curl https://raw.githubusercontent.com/pypa/pipenv/master/get-pipenv.py | python + pipenv install && pipenv shell + pip install build - name: Build package - run: pipenv run python -m build + run: python -m build - name: Publish package uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 with: diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 2ab2280..b6fc250 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -10,19 +10,20 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: '3.10.x' + python-version: '3.9.x' cache: 'pipenv' - name: Install dependencies run: | - sudo apt-get update && sudo apt-get install -y enchant-2 aspell-en pipenv python3.10-venv - pipenv install --dev + sudo apt-get update && sudo apt-get install -y enchant-2 aspell-en + curl https://raw.githubusercontent.com/pypa/pipenv/master/get-pipenv.py | python + pipenv install && pipenv shell - name: Build Docs run: | cd docs - pipenv run make clean - pipenv run make html - pipenv run make spelling - pipenv run make linkcheck + make clean + make html + make spelling + make linkcheck test: runs-on: ubuntu-22.04 steps: @@ -30,18 +31,18 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: '3.10.x' + python-version: '3.9.x' cache: 'pipenv' - name: Install dependencies run: | - sudo apt-get update && sudo apt-get install -y pipenv python3.10-venv - pipenv install --dev + curl https://raw.githubusercontent.com/pypa/pipenv/master/get-pipenv.py | python + pipenv install && pipenv shell - name: Add DBus Config run: | sudo cp tests/com.spacecheese.test.conf /etc/dbus-1/system.d - name: Run Tests run: | - pipenv run python -m unittest + python -m unittest format-check: runs-on: ubuntu-22.04 steps: @@ -49,15 +50,15 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: '3.10.x' + python-version: '3.9.x' cache: 'pipenv' - name: Install dependencies run: | - sudo apt-get update && sudo apt-get install -y pipenv virtualenv - pipenv install --dev - pipenv install black + curl https://raw.githubusercontent.com/pypa/pipenv/master/get-pipenv.py | python + pipenv install && pipenv shell + pip install black - name: Check Formatting run: | - pipenv run python -m black --check bluez_peripheral - pipenv run python -m black --check tests + python -m black --check bluez_peripheral + python -m black --check tests From c365f8439269af8140997e9d10295717392e9e13 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 3 Jan 2023 19:27:20 +0000 Subject: [PATCH 032/158] Revert "Run workflows in pipenv shell" This reverts commit 7aae565cf1817aeb25e3b625160eec0337e6217a. --- .github/workflows/python-publish.yml | 2 +- .github/workflows/python-test.yml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index f3ab528..faa7832 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -17,7 +17,7 @@ jobs: - name: Install dependencies run: | curl https://raw.githubusercontent.com/pypa/pipenv/master/get-pipenv.py | python - pipenv install && pipenv shell + pipenv install pip install build - name: Build package run: python -m build diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index b6fc250..96492d9 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -16,7 +16,7 @@ jobs: run: | sudo apt-get update && sudo apt-get install -y enchant-2 aspell-en curl https://raw.githubusercontent.com/pypa/pipenv/master/get-pipenv.py | python - pipenv install && pipenv shell + pipenv install - name: Build Docs run: | cd docs @@ -36,7 +36,7 @@ jobs: - name: Install dependencies run: | curl https://raw.githubusercontent.com/pypa/pipenv/master/get-pipenv.py | python - pipenv install && pipenv shell + pipenv install - name: Add DBus Config run: | sudo cp tests/com.spacecheese.test.conf /etc/dbus-1/system.d @@ -55,7 +55,7 @@ jobs: - name: Install dependencies run: | curl https://raw.githubusercontent.com/pypa/pipenv/master/get-pipenv.py | python - pipenv install && pipenv shell + pipenv install pip install black - name: Check Formatting run: | From 576b473cf1af96663c85fc85a1beaacb8045005e Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 3 Jan 2023 19:27:21 +0000 Subject: [PATCH 033/158] Revert "Add black install to test workflow" This reverts commit 0fb06cc8c961b38b4d3c8cec425dc128f00b3452. --- .github/workflows/python-test.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 96492d9..b5148c1 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -56,7 +56,6 @@ jobs: run: | curl https://raw.githubusercontent.com/pypa/pipenv/master/get-pipenv.py | python pipenv install - pip install black - name: Check Formatting run: | python -m black --check bluez_peripheral From 042d7448675f32b97d3579bd13454eb00d038f1b Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 3 Jan 2023 19:27:22 +0000 Subject: [PATCH 034/158] Revert "Remove requirements and migrate workflows" This reverts commit 87c0fb0679f20d69f99172d8d25b1299f29cefd3. --- .github/workflows/python-publish.yml | 11 ++++++----- .github/workflows/python-test.yml | 19 ++++++++++--------- docs/requirements.txt | 7 +++++++ requirements.txt | 1 + 4 files changed, 24 insertions(+), 14 deletions(-) create mode 100644 docs/requirements.txt create mode 100644 requirements.txt diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index faa7832..205cf75 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -6,18 +6,19 @@ on: jobs: deploy: - runs-on: ubuntu-22.04 + + runs-on: ubuntu-latest + steps: - uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: - python-version: '3.9.x' - cache: 'pipenv' + python-version: '3.x' + cache: 'pip' - name: Install dependencies run: | - curl https://raw.githubusercontent.com/pypa/pipenv/master/get-pipenv.py | python - pipenv install + python -m pip install --upgrade pip pip install build - name: Build package run: python -m build diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index b5148c1..5cf1a45 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -11,12 +11,12 @@ jobs: uses: actions/setup-python@v4 with: python-version: '3.9.x' - cache: 'pipenv' + 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 - curl https://raw.githubusercontent.com/pypa/pipenv/master/get-pipenv.py | python - pipenv install - name: Build Docs run: | cd docs @@ -32,11 +32,11 @@ jobs: uses: actions/setup-python@v4 with: python-version: '3.9.x' - cache: 'pipenv' + cache: 'pip' - name: Install dependencies run: | - curl https://raw.githubusercontent.com/pypa/pipenv/master/get-pipenv.py | python - pipenv install + python -m pip install --upgrade pip + pip install -r tests/requirements.txt - name: Add DBus Config run: | sudo cp tests/com.spacecheese.test.conf /etc/dbus-1/system.d @@ -51,11 +51,12 @@ jobs: uses: actions/setup-python@v4 with: python-version: '3.9.x' - cache: 'pipenv' + cache: 'pip' - name: Install dependencies run: | - curl https://raw.githubusercontent.com/pypa/pipenv/master/get-pipenv.py | python - pipenv install + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install black - name: Check Formatting run: | python -m black --check bluez_peripheral diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..3e95511 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,7 @@ +dbus_next + +sphinx +sphinx_tabs +sphinxcontrib-spelling +furo +m2r2 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..60f767a --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +dbus_next \ No newline at end of file From 755435f882c95d26193e4228610684cfa755ae03 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 3 Jan 2023 19:28:07 +0000 Subject: [PATCH 035/158] Update docs requirements.txt --- docs/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 3e95511..a3a36bb 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,7 +1,7 @@ dbus_next sphinx -sphinx_tabs +sphinx-inline-tabs sphinxcontrib-spelling furo m2r2 \ No newline at end of file From e072135a7f2d4318180054a204fdc7384c7723a0 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 3 Jan 2023 19:28:15 +0000 Subject: [PATCH 036/158] Remove pipfile --- Pipfile | 17 --- Pipfile.lock | 314 --------------------------------------------------- 2 files changed, 331 deletions(-) delete mode 100644 Pipfile delete mode 100644 Pipfile.lock diff --git a/Pipfile b/Pipfile deleted file mode 100644 index f57f9e8..0000000 --- a/Pipfile +++ /dev/null @@ -1,17 +0,0 @@ -[[source]] -url = "https://pypi.org/simple" -verify_ssl = true -name = "pypi" - -[packages] -dbus-next = "*" - -[dev-packages] -sphinx = "*" -sphinx-inline-tabs = "*" -sphinxcontrib-spelling = "*" -furo = "*" -m2r2 = "*" - -[requires] -python_version = "3.10" diff --git a/Pipfile.lock b/Pipfile.lock deleted file mode 100644 index c784449..0000000 --- a/Pipfile.lock +++ /dev/null @@ -1,314 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "841debc30ad37a49fd03433dc03ef7a3e652754bb88aa509b02ed8af8747603a" - }, - "pipfile-spec": 6, - "requires": { - "python_version": "3.10" - }, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "dbus-next": { - "hashes": [ - "sha256:58948f9aff9db08316734c0be2a120f6dc502124d9642f55e90ac82ffb16a18b", - "sha256:f4eae26909332ada528c0a3549dda8d4f088f9b365153952a408e28023a626a5" - ], - "index": "pypi", - "version": "==0.2.3" - } - }, - "develop": { - "alabaster": { - "hashes": [ - "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359", - "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02" - ], - "version": "==0.7.12" - }, - "babel": { - "hashes": [ - "sha256:1ad3eca1c885218f6dce2ab67291178944f810a10a9b5f3cb8382a5a232b64fe", - "sha256:5ef4b3226b0180dedded4229651c8b0e1a3a6a2837d45a073272f313e4cf97f6" - ], - "markers": "python_version >= '3.6'", - "version": "==2.11.0" - }, - "beautifulsoup4": { - "hashes": [ - "sha256:58d5c3d29f5a36ffeb94f02f0d786cd53014cf9b3b3951d42e0080d8a9498d30", - "sha256:ad9aa55b65ef2808eb405f46cf74df7fcb7044d5cbc26487f96eb2ef2e436693" - ], - "markers": "python_full_version >= '3.6.0'", - "version": "==4.11.1" - }, - "certifi": { - "hashes": [ - "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3", - "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18" - ], - "markers": "python_version >= '3.6'", - "version": "==2022.12.7" - }, - "charset-normalizer": { - "hashes": [ - "sha256:5a3d016c7c547f69d6f81fb0db9449ce888b418b5b9952cc5e6e66843e9dd845", - "sha256:83e9a75d1911279afd89352c68b45348559d1fc0506b054b346651b5e7fee29f" - ], - "markers": "python_full_version >= '3.6.0'", - "version": "==2.1.1" - }, - "docutils": { - "hashes": [ - "sha256:33995a6753c30b7f577febfc2c50411fec6aac7f7ffeb7c4cfe5991072dcf9e6", - "sha256:5e1de4d849fee02c63b040a4a3fd567f4ab104defd8a5511fbbc24a8a017efbc" - ], - "markers": "python_version >= '3.7'", - "version": "==0.19" - }, - "furo": { - "hashes": [ - "sha256:7cb76c12a25ef65db85ab0743df907573d03027a33631f17d267e598ebb191f7", - "sha256:d8008f8efbe7587a97ba533c8b2df1f9c21ee9b3e5cad0d27f61193d38b1a986" - ], - "index": "pypi", - "version": "==2022.12.7" - }, - "idna": { - "hashes": [ - "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4", - "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2" - ], - "markers": "python_version >= '3.5'", - "version": "==3.4" - }, - "imagesize": { - "hashes": [ - "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b", - "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.4.1" - }, - "jinja2": { - "hashes": [ - "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852", - "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61" - ], - "markers": "python_version >= '3.7'", - "version": "==3.1.2" - }, - "m2r2": { - "hashes": [ - "sha256:2ee32a5928c3598b67c70e6d22981ec936c03d5bfd2f64229e77678731952f16", - "sha256:f9b6e9efbc2b6987dbd43d2fd15a6d115ba837d8158ae73295542635b4086e75" - ], - "index": "pypi", - "version": "==0.3.3" - }, - "markupsafe": { - "hashes": [ - "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003", - "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88", - "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5", - "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7", - "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a", - "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603", - "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1", - "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135", - "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247", - "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6", - "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601", - "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77", - "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02", - "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e", - "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63", - "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f", - "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980", - "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b", - "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812", - "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff", - "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96", - "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1", - "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925", - "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a", - "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6", - "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e", - "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f", - "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4", - "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f", - "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3", - "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c", - "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a", - "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417", - "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a", - "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a", - "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37", - "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452", - "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933", - "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a", - "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7" - ], - "markers": "python_version >= '3.7'", - "version": "==2.1.1" - }, - "mistune": { - "hashes": [ - "sha256:59a3429db53c50b5c6bcc8a07f8848cb00d7dc8bdb431a4ab41920d201d4756e", - "sha256:88a1051873018da288eee8538d476dffe1262495144b33ecb586c4ab266bb8d4" - ], - "version": "==0.8.4" - }, - "packaging": { - "hashes": [ - "sha256:2198ec20bd4c017b8f9717e00f0c8714076fc2fd93816750ab48e2c41de2cfd3", - "sha256:957e2148ba0e1a3b282772e791ef1d8083648bc131c8ab0c1feba110ce1146c3" - ], - "markers": "python_version >= '3.7'", - "version": "==22.0" - }, - "pyenchant": { - "hashes": [ - "sha256:1cf830c6614362a78aab78d50eaf7c6c93831369c52e1bb64ffae1df0341e637", - "sha256:5a636832987eaf26efe971968f4d1b78e81f62bca2bde0a9da210c7de43c3bce", - "sha256:5facc821ece957208a81423af7d6ec7810dad29697cb0d77aae81e4e11c8e5a6", - "sha256:6153f521852e23a5add923dbacfbf4bebbb8d70c4e4bad609a8e0f9faeb915d1" - ], - "markers": "python_version >= '3.5'", - "version": "==3.2.2" - }, - "pygments": { - "hashes": [ - "sha256:56a8508ae95f98e2b9bdf93a6be5ae3f7d8af858b43e02c5a2ff083726be40c1", - "sha256:f643f331ab57ba3c9d89212ee4a2dabc6e94f117cf4eefde99a0574720d14c42" - ], - "markers": "python_version >= '3.6'", - "version": "==2.13.0" - }, - "pytz": { - "hashes": [ - "sha256:7ccfae7b4b2c067464a6733c6261673fdb8fd1be905460396b97a073e9fa683a", - "sha256:93007def75ae22f7cd991c84e02d434876818661f8df9ad5df9e950ff4e52cfd" - ], - "version": "==2022.7" - }, - "requests": { - "hashes": [ - "sha256:7c5599b102feddaa661c826c56ab4fee28bfd17f5abca1ebbe3e7f19d7c97983", - "sha256:8fefa2a1a1365bf5520aac41836fbee479da67864514bdb821f31ce07ce65349" - ], - "markers": "python_version >= '3.7' and python_version < '4'", - "version": "==2.28.1" - }, - "snowballstemmer": { - "hashes": [ - "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1", - "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a" - ], - "version": "==2.2.0" - }, - "soupsieve": { - "hashes": [ - "sha256:3b2503d3c7084a42b1ebd08116e5f81aadfaea95863628c80a3b774a11b7c759", - "sha256:fc53893b3da2c33de295667a0e19f078c14bf86544af307354de5fcf12a3f30d" - ], - "markers": "python_version >= '3.6'", - "version": "==2.3.2.post1" - }, - "sphinx": { - "hashes": [ - "sha256:58c140ecd9aa0abbc8ff6da48a266648eac9e5bfc8e49576efd2979bf46f5961", - "sha256:c2aeebfcb0e7474f5a820eac6177c7492b1d3c9c535aa21d5ae77cab2f3600e4" - ], - "index": "pypi", - "version": "==6.0.0" - }, - "sphinx-basic-ng": { - "hashes": [ - "sha256:89374bd3ccd9452a301786781e28c8718e99960f2d4f411845ea75fc7bb5a9b0", - "sha256:ade597a3029c7865b24ad0eda88318766bcc2f9f4cef60df7e28126fde94db2a" - ], - "markers": "python_version >= '3.7'", - "version": "==1.0.0b1" - }, - "sphinx-inline-tabs": { - "hashes": [ - "sha256:afb9142772ec05ccb07f05d8181b518188fc55631b26ee803c694e812b3fdd73", - "sha256:bb4e807769ef52301a186d0678da719120b978a1af4fd62a1e9453684e962dbc" - ], - "index": "pypi", - "version": "==2022.1.2b11" - }, - "sphinxcontrib-applehelp": { - "hashes": [ - "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a", - "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58" - ], - "markers": "python_version >= '3.5'", - "version": "==1.0.2" - }, - "sphinxcontrib-devhelp": { - "hashes": [ - "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e", - "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4" - ], - "markers": "python_version >= '3.5'", - "version": "==1.0.2" - }, - "sphinxcontrib-htmlhelp": { - "hashes": [ - "sha256:d412243dfb797ae3ec2b59eca0e52dac12e75a241bf0e4eb861e450d06c6ed07", - "sha256:f5f8bb2d0d629f398bf47d0d69c07bc13b65f75a81ad9e2f71a63d4b7a2f6db2" - ], - "markers": "python_version >= '3.6'", - "version": "==2.0.0" - }, - "sphinxcontrib-jsmath": { - "hashes": [ - "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", - "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8" - ], - "markers": "python_version >= '3.5'", - "version": "==1.0.1" - }, - "sphinxcontrib-qthelp": { - "hashes": [ - "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72", - "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6" - ], - "markers": "python_version >= '3.5'", - "version": "==1.0.3" - }, - "sphinxcontrib-serializinghtml": { - "hashes": [ - "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd", - "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952" - ], - "markers": "python_version >= '3.5'", - "version": "==1.1.5" - }, - "sphinxcontrib-spelling": { - "hashes": [ - "sha256:56561c3f6a155b0946914e4de988729859315729dc181b5e4dc8a68fe78de35a", - "sha256:95a0defef8ffec6526f9e83b20cc24b08c9179298729d87976891840e3aa3064" - ], - "index": "pypi", - "version": "==7.7.0" - }, - "urllib3": { - "hashes": [ - "sha256:47cc05d99aaa09c9e72ed5809b60e7ba354e64b59c9c173ac3018642d8bb41fc", - "sha256:c083dd0dce68dbfbe1129d5271cb90f9447dea7d52097c6e0126120c521ddea8" - ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", - "version": "==1.26.13" - } - } -} From b924e779d58f04af717455f26d5421ca74b5beac Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 3 Jan 2023 19:39:00 +0000 Subject: [PATCH 037/158] Promote sphinx warnings --- docs/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Makefile b/docs/Makefile index d2b6274..b164d3d 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -3,7 +3,7 @@ # You can set these variables from the command line, and also # from the environment for the first two. -SPHINXOPTS ?= -j auto +SPHINXOPTS ?= -j auto -W SPHINXBUILD ?= sphinx-build SOURCEDIR = source BUILDDIR = build From 0b6d506bc08e9c12963550c44c707cc468db6072 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 3 Jan 2023 19:41:17 +0000 Subject: [PATCH 038/158] Always run spelling and linkcheck --- .github/workflows/python-test.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 5cf1a45..76f5b34 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -22,7 +22,13 @@ jobs: cd docs make clean make html + - name: Spelling Check + if: always() + run: | make spelling + - name: Link + if: always() + run: | make linkcheck test: runs-on: ubuntu-22.04 From 9ff9f96a15b580b01a45e0dfef1fd4c761d2acbb Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 3 Jan 2023 19:42:10 +0000 Subject: [PATCH 039/158] Rename link check step --- .github/workflows/python-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 76f5b34..0c95541 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -26,7 +26,7 @@ jobs: if: always() run: | make spelling - - name: Link + - name: Link Check if: always() run: | make linkcheck From 3e93478e01f10c5410d9bae27849f9e19339136f Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 3 Jan 2023 20:57:32 +0000 Subject: [PATCH 040/158] Fix spelling and linkcheck tests --- .github/workflows/python-test.yml | 2 ++ docs/Makefile | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 0c95541..48969e3 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -25,10 +25,12 @@ jobs: - name: Spelling Check if: always() run: | + cd docs make spelling - name: Link Check if: always() run: | + cd docs make linkcheck test: runs-on: ubuntu-22.04 diff --git a/docs/Makefile b/docs/Makefile index b164d3d..3d80d42 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -20,7 +20,7 @@ help: apidoc: @$(SPHINXDOC) -o "$(APIDOCDIR)" "$(MODPATH)" $(SPHINXDOCOPTS) -.PHONY: help Makefile apidoc +.PHONY: help Makefile apidoc spelling spelling: @$(SPHINXBUILD) -b spelling "$(SOURCEDIR)" "$(BUILDDIR)" From b232677771655931168d82ed82184e5c0c0b1766 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 3 Jan 2023 21:16:48 +0000 Subject: [PATCH 041/158] Remove user facing TODOs --- docs/source/index.rst | 3 --- docs/source/services.rst | 4 ++-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/source/index.rst b/docs/source/index.rst index 126a6ef..35711c0 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,8 +1,5 @@ .. mdinclude:: ../../README.md -TODO: Make sure types are linked in all these pages when the reference is done. -TODO: Make sure True/ False is linked in pages. - Contents ======== .. toctree:: diff --git a/docs/source/services.rst b/docs/source/services.rst index 99d362b..49034f8 100644 --- a/docs/source/services.rst +++ b/docs/source/services.rst @@ -18,11 +18,11 @@ The :py:class:`@characteristic` parameter to select portions of the data. This is dependent upon the :ref:`options.mtu`. +.. TODO: Code examples need automated testing. .. tab:: Decorators - + .. code-block:: python - # TODO: Code examples need automated testing. from bluez_peripheral.gatt import Service from bluez_peripheral.gatt import characteristic, CharacteristicFlags as CharFlags from bluez_peripheral.gatt import descriptor, DescriptorFlags as DescFlags From 2c5fa2445fb358964c691b5dc34df1d00ca04455 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 3 Jan 2023 21:34:49 +0000 Subject: [PATCH 042/158] Fix advertising page --- docs/source/advertising.rst | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/source/advertising.rst b/docs/source/advertising.rst index 03c77f1..93a7c43 100644 --- a/docs/source/advertising.rst +++ b/docs/source/advertising.rst @@ -7,9 +7,10 @@ In BLE advertising is required for other devices to discover services that surro The "message bus" referred to here is a :py:class:`dbus_next.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 values `). +* An appearance describing how the device should appear to a user (see `Bluetooth SIG Assigned values `_). * 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). @@ -35,4 +36,4 @@ A minimal :py:class:`advert` requires: `Advertising API `_ Bluetooth SIG - `Assigned Appearance Values `_ \ No newline at end of file + `Assigned Appearance Values `_ From 1019d71f1cb06682f700e09513c00b2b9becfaa8 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 3 Jan 2023 21:35:14 +0000 Subject: [PATCH 043/158] Shorten advertising example comments --- docs/source/advertising.rst | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/source/advertising.rst b/docs/source/advertising.rst index 93a7c43..0050e62 100644 --- a/docs/source/advertising.rst +++ b/docs/source/advertising.rst @@ -21,12 +21,10 @@ A minimal :py:class:`advert` requires: adapter = await Adapter.get_first(bus) - # This name will appear to the user. - # | Any service UUIDs (in this case the heart rate service). - # | | This is the appearance code for a generic heart rate sensor. - # | | | How long until the advert should stop (in seconds). - # | | | | - # \/ \/ \/ \/ + # "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"], 0x0340, 60) await advert.register(bus, adapter) From 41784437d2f3242923170160e9b15400904b77a7 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 3 Jan 2023 21:35:29 +0000 Subject: [PATCH 044/158] Add some advertising TODO notes --- docs/source/advertising.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/source/advertising.rst b/docs/source/advertising.rst index 0050e62..a468a5c 100644 --- a/docs/source/advertising.rst +++ b/docs/source/advertising.rst @@ -28,6 +28,9 @@ A minimal :py:class:`advert` requires: advert = Advertisement("Heart Monitor", ["180D"], 0x0340, 60) await advert.register(bus, adapter) +.. TODO: Advertising includes +.. TODO: Advertisable characteristics + .. seealso:: Bluez Documentation From ba579c15dd7f7aabddc2feee1b322307eaf3b5d8 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 3 Jan 2023 21:38:58 +0000 Subject: [PATCH 045/158] Fix broken characteristic/ descriptor links --- bluez_peripheral/gatt/characteristic.py | 4 +--- bluez_peripheral/gatt/descriptor.py | 4 +--- docs/source/services.rst | 2 ++ 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/bluez_peripheral/gatt/characteristic.py b/bluez_peripheral/gatt/characteristic.py index f2ebfda..ecfb5a5 100644 --- a/bluez_peripheral/gatt/characteristic.py +++ b/bluez_peripheral/gatt/characteristic.py @@ -175,9 +175,7 @@ class characteristic(ServiceInterface): flags: Flags defining the possible read/ write behavior of the attribute. See Also: - :ref:`quickstart` - - :ref:`characteristics_descriptors` + :ref:`services` """ _INTERFACE = "org.bluez.GattCharacteristic1" diff --git a/bluez_peripheral/gatt/descriptor.py b/bluez_peripheral/gatt/descriptor.py index 86c60bc..dcca381 100644 --- a/bluez_peripheral/gatt/descriptor.py +++ b/bluez_peripheral/gatt/descriptor.py @@ -108,9 +108,7 @@ class descriptor(ServiceInterface): flags: Flags defining the possible read/ write behavior of the attribute. See Also: - :ref:`quickstart` - - :ref:`characteristics_descriptors` + :ref:`services` """ _INTERFACE = "org.bluez.GattDescriptor1" diff --git a/docs/source/services.rst b/docs/source/services.rst index 49034f8..612e7bb 100644 --- a/docs/source/services.rst +++ b/docs/source/services.rst @@ -1,3 +1,5 @@ +.. _services: + Creating a Service ================== Attribute Flags From 5d00d4e5acb5ac19732b0f498132e78d5222a4f1 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 3 Jan 2023 21:55:44 +0000 Subject: [PATCH 046/158] Escalate spelling warnings --- docs/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Makefile b/docs/Makefile index 3d80d42..ab9cbdc 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -23,7 +23,7 @@ apidoc: .PHONY: help Makefile apidoc spelling spelling: - @$(SPHINXBUILD) -b spelling "$(SOURCEDIR)" "$(BUILDDIR)" + @$(SPHINXBUILD) -b spelling -W "$(SOURCEDIR)" "$(BUILDDIR)" # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). From c801df7b4a557ff291e7f807164d4e884382750a Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 3 Jan 2023 22:12:36 +0000 Subject: [PATCH 047/158] Spelling corrections --- README.md | 6 +++--- docs/source/pairing.rst | 2 +- docs/source/services.rst | 10 +++++----- docs/source/spelling_wordlist.txt | 10 +++++++++- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index ccba3ac..57772ef 100644 --- a/README.md +++ b/README.md @@ -23,19 +23,19 @@ Install bluez (eg. `sudo apt-get install bluez`) ## GATT Overview GATT is a 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 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) *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 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 -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 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 diff --git a/docs/source/pairing.rst b/docs/source/pairing.rst index fe611a2..703963b 100644 --- a/docs/source/pairing.rst +++ b/docs/source/pairing.rst @@ -85,7 +85,7 @@ There are three potential sources of agents: 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 ``. Aditionally the linux bluetooth daemon stores a large amount of seemingly undocumented metadata in the ``/var/lib/bluetooth/`` directory and, it may be useful to delete this data between attempts. +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 a large amount of seemingly undocumented metadata in the ``/var/lib/bluetooth/`` directory and, it may be useful to delete this data between attempts. .. _pairing-io: diff --git a/docs/source/services.rst b/docs/source/services.rst index 612e7bb..ac37230 100644 --- a/docs/source/services.rst +++ b/docs/source/services.rst @@ -4,14 +4,14 @@ Creating a Service ================== Attribute Flags --------------- -The behaviour 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``). +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 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 minimise 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 interchangably, UUID16s will automatically be used where possible. +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 ----------------- @@ -93,7 +93,7 @@ The :py:class:`@characteristic` to signal specific conditions to bluez. Avoid thowing custom exceptions in attribute accessors, since these will not be presented to a user and bluez will not know how to interpret them. Aditionally any exceptions thrown **must** derive from :py:class:`dbus_next.DBusError`. +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_next.DBusError`. .. _legal-errors: @@ -123,7 +123,7 @@ Legal Errors Registering a Service ----------------------- .. warning:: - Ensure that the thread used to register your service yeilds regularly. Client requests will not be served otherwise. + 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_next.aio.MessageBus`. @@ -165,7 +165,7 @@ Services can either be registered individually using a :py:class:`~bluez_periphe 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 acknowledgement 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()`. +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. diff --git a/docs/source/spelling_wordlist.txt b/docs/source/spelling_wordlist.txt index 9cce343..00932ab 100644 --- a/docs/source/spelling_wordlist.txt +++ b/docs/source/spelling_wordlist.txt @@ -10,8 +10,16 @@ cli passcode indicatable eg +ie unregister hostname dbus gatt -util \ No newline at end of file +util +asyncio +multithreading +linux +subpackages +submodules +enums +responder \ No newline at end of file From c1dfd739309b9b61f95fb48b2278f0c9a1278246 Mon Sep 17 00:00:00 2001 From: Space Cheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 9 May 2023 22:21:19 +0100 Subject: [PATCH 048/158] Reword services.rst --- docs/source/services.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/services.rst b/docs/source/services.rst index ac37230..4b2c601 100644 --- a/docs/source/services.rst +++ b/docs/source/services.rst @@ -9,7 +9,7 @@ The behavior of a particular attribute is described by a set of flags. These fla UUIDs ----- .. hint:: - The Bluetooth SIG has reserved 16-bit UUIDs for `standardised services `_. 128-bit UUIDs should be preferred to avoid confusion. + 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. From 806fb4ee510fd219bdbd063034ce1fe5f7087482 Mon Sep 17 00:00:00 2001 From: Space Cheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 9 May 2023 22:28:03 +0100 Subject: [PATCH 049/158] Fix characteristic assigned numbers link --- bluez_peripheral/gatt/characteristic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bluez_peripheral/gatt/characteristic.py b/bluez_peripheral/gatt/characteristic.py index ecfb5a5..b8ba0e7 100644 --- a/bluez_peripheral/gatt/characteristic.py +++ b/bluez_peripheral/gatt/characteristic.py @@ -171,7 +171,7 @@ class characteristic(ServiceInterface): """Create a new characteristic with a specified UUID and flags. 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: From 9be1a704a3ea0d13ec1397cb2ef4202f80842145 Mon Sep 17 00:00:00 2001 From: Space Cheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 9 May 2023 22:33:35 +0100 Subject: [PATCH 050/158] Create .readthedocs.yaml --- .readthedocs.yaml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .readthedocs.yaml diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..4b74802 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,6 @@ +version: 2 + +build: + os: ubuntu-22.04 + tools: + python: "3.11" From 20c42fffee81a82fc7ff38fd1d2ea3907b78c138 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 9 May 2023 22:45:35 +0100 Subject: [PATCH 051/158] Make python CI versions consistent --- .github/workflows/python-publish.yml | 2 +- .github/workflows/python-test.yml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 205cf75..e9f5fd8 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -14,7 +14,7 @@ jobs: - 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 48969e3..08e0254 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -10,7 +10,7 @@ jobs: - 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: | @@ -39,7 +39,7 @@ jobs: - 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: | @@ -58,7 +58,7 @@ jobs: - 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: | From dd17c2ca5019eddab1c5cf5081b25a4baa9a7ce2 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 9 May 2023 22:46:52 +0100 Subject: [PATCH 052/158] Fix service.py formatting error --- bluez_peripheral/gatt/service.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bluez_peripheral/gatt/service.py b/bluez_peripheral/gatt/service.py index ed832ed..66821cc 100644 --- a/bluez_peripheral/gatt/service.py +++ b/bluez_peripheral/gatt/service.py @@ -11,6 +11,7 @@ from ..uuid16 import UUID16 from ..util import * + # See https://git.kernel.org/pub/scm/bluetooth/bluez.git/tree/doc/gatt-api.txt class Service(ServiceInterface): """Create a bluetooth service with the specified uuid. From 38b06e51ee69342ee6f89459d5956c1364392b70 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 9 May 2023 22:56:39 +0100 Subject: [PATCH 053/158] Specify readthedocs config and requirements paths --- .readthedocs.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 4b74802..85f07ba 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -4,3 +4,10 @@ build: os: ubuntu-22.04 tools: python: "3.11" + +sphinx: + configuration: docs/source/conf.py + +python: + install: + - requirements: docs/requirements.txt \ No newline at end of file From 3309ae8d1c63149cea217c5f3cd432683e4e5823 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 9 May 2023 22:56:49 +0100 Subject: [PATCH 054/158] Fix broken links --- bluez_peripheral/advert.py | 2 +- bluez_peripheral/gatt/descriptor.py | 2 +- bluez_peripheral/gatt/service.py | 2 +- docs/source/advertising.rst | 5 +---- 4 files changed, 4 insertions(+), 7 deletions(-) diff --git a/bluez_peripheral/advert.py b/bluez_peripheral/advert.py index 1445514..63f0f27 100644 --- a/bluez_peripheral/advert.py +++ b/bluez_peripheral/advert.py @@ -43,7 +43,7 @@ class Advertisement(ServiceInterface): 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. discoverable: Whether or not the device this advert should be generally discoverable. packetType: The type of advertising packet requested. diff --git a/bluez_peripheral/gatt/descriptor.py b/bluez_peripheral/gatt/descriptor.py index dcca381..a8efcfe 100644 --- a/bluez_peripheral/gatt/descriptor.py +++ b/bluez_peripheral/gatt/descriptor.py @@ -103,7 +103,7 @@ class descriptor(ServiceInterface): """Create a new descriptor with a specified UUID and flags associated with the specified parent characteristic. 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. diff --git a/bluez_peripheral/gatt/service.py b/bluez_peripheral/gatt/service.py index 66821cc..57ab88e 100644 --- a/bluez_peripheral/gatt/service.py +++ b/bluez_peripheral/gatt/service.py @@ -17,7 +17,7 @@ class Service(ServiceInterface): """Create a bluetooth service with the specified uuid. 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. diff --git a/docs/source/advertising.rst b/docs/source/advertising.rst index a468a5c..23afc42 100644 --- a/docs/source/advertising.rst +++ b/docs/source/advertising.rst @@ -10,7 +10,7 @@ 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 values `_). +* 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). @@ -35,6 +35,3 @@ A minimal :py:class:`advert` requires: Bluez Documentation `Advertising API `_ - - Bluetooth SIG - `Assigned Appearance Values `_ From c5e8a4386064d10f17e0d7b752b853bbb153177d Mon Sep 17 00:00:00 2001 From: Space Cheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 9 May 2023 23:15:43 +0100 Subject: [PATCH 055/158] Fix typo in README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 57772ef..3d856b3 100644 --- a/README.md +++ b/README.md @@ -100,4 +100,4 @@ async def main(): if __name__ == "__main__": asyncio.run(main()) ``` -To connect to and test your service the [nRF Connect for Mobile](https://www.nordicsemi.com/Products/Development-tools/nrf-connect-for-mobile) app is an excellent tool. To communicate with bluez the default dbus configuration requires that you be in the bluetooth user group (eg. `sudo useradd -aG bluetooth spacecheese`). +To connect to and test your service the [nRF Connect for Mobile](https://www.nordicsemi.com/Products/Development-tools/nrf-connect-for-mobile) app is an excellent tool. To communicate with bluez the default dbus configuration requires that you be in the bluetooth user group (eg. `sudo usermod -aG bluetooth spacecheese`). From 28876ddbabae1f8459de6de84bcbdc6af0191388 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Mon, 22 May 2023 22:46:06 +0100 Subject: [PATCH 056/158] Fix some test issues --- bluez_peripheral/gatt/descriptor.py | 1 + bluez_peripheral/uuid16.py | 32 ++++++++++++++--------------- tests/README.md | 5 +++++ 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/bluez_peripheral/gatt/descriptor.py b/bluez_peripheral/gatt/descriptor.py index b787edd..92cbe4d 100644 --- a/bluez_peripheral/gatt/descriptor.py +++ b/bluez_peripheral/gatt/descriptor.py @@ -3,6 +3,7 @@ from dbus_next.service import ServiceInterface, method, dbus_property from dbus_next.constants import PropertyAccess +import inspect from uuid import UUID from enum import Flag, auto from typing import Callable, Union, Awaitable diff --git a/bluez_peripheral/uuid16.py b/bluez_peripheral/uuid16.py index eb06858..bbfb6a7 100644 --- a/bluez_peripheral/uuid16.py +++ b/bluez_peripheral/uuid16.py @@ -16,7 +16,7 @@ class UUID16: # 0000****--0000-1000-8000-00805F9B34FB _FIELDS = (0x00000000, 0x0000, 0x1000, 0x80, 0x00, 0x00805F9B34FB) - _uuid: UUID = None + _uuid: Optional[UUID] = None def __init__( self, @@ -26,9 +26,10 @@ def __init__( uuid: Optional[UUID] = None, ): 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 + # All representations are converted to either a UUID128 or a 16-bit integer. + time_low : int = None if hex is not None: hex.strip("0x") @@ -43,7 +44,7 @@ def __init__( elif len(bytes) == 16: uuid = UUID(bytes=bytes) else: - raise ValueError("bytes must be either 2 or 16-bytes long") + raise ValueError("uuid bytes must be exactly either 2 or 16 bytes long") if int is not None: if int < 2**16 and int >= 0: @@ -52,14 +53,13 @@ def __init__( 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: if UUID16.is_in_range(uuid): self._uuid = uuid else: raise ValueError("the supplied uuid128 was out of range") + @classmethod def is_in_range(cls, uuid: UUID) -> bool: @@ -78,25 +78,25 @@ def is_in_range(cls, uuid: UUID) -> bool: return True @classmethod - def parse_uuid(cls, uuid: Union[str, bytes, int, UUID]) -> Union[UUID, "UUID16"]: - if type(uuid) is UUID: + def parse_uuid(cls, uuid: Union[str, bytes, int, UUID, "UUID16"]) -> Union[UUID, "UUID16"]: + if type(uuid) is UUID16: + return uuid + elif type(uuid) is UUID: if cls.is_in_range(uuid): return UUID16(uuid=uuid) - return uuid - - if type(uuid) is str: + else: + return uuid + elif type(uuid) is str: try: return UUID16(hex=uuid) except: return UUID(hex=uuid) - - if type(uuid) is bytes: + elif type(uuid) is bytes: try: return UUID16(bytes=uuid) except: return UUID(bytes=uuid) - - if type(uuid) is int: + else: # Must be int. try: return UUID16(int=uuid) except: diff --git a/tests/README.md b/tests/README.md index 97b6049..8632a3a 100644 --- a/tests/README.md +++ b/tests/README.md @@ -7,4 +7,9 @@ Add the dbus config to allow the test process access to the bluetooth daemon. > This has serious security implications so only do this if you know what you are doing. ```bash sudo cp com.spacecheese.test.conf /etc/dbus-1/system.d +``` +# Run the Tests +Run tests from the root project directory (eg bluez_peripheral). +```bash +python3 -m unittest ``` \ No newline at end of file From 5189a8b859fc3e0b354643c6b4d5069ffa8eebf6 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Mon, 22 May 2023 22:47:40 +0100 Subject: [PATCH 057/158] Fix formatting check --- docs/source/conf.py | 37 +++++++++++++++++++++++------------ setup.py | 4 ++-- tests/gatt/test_descriptor.py | 1 + 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 00cc2bc..b7cdccc 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -14,11 +14,13 @@ import sys 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__) + "/../../")) + # See https://github.com/sphinx-doc/sphinx/issues/5603 def add_intersphinx_aliases_to_inv(app): from sphinx.ext.intersphinx import InventoryAdapter + inventories = InventoryAdapter(app.builder.env) for alias, target in app.config.intersphinx_aliases.items(): @@ -33,6 +35,7 @@ def add_intersphinx_aliases_to_inv(app): except KeyError: continue + # -- Project information ----------------------------------------------------- project = "bluez-peripheral" @@ -44,7 +47,14 @@ def add_intersphinx_aliases_to_inv(app): # 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", "sphinx_inline_tabs", "m2r2"] +extensions = [ + "sphinxcontrib.spelling", + "sphinx.ext.autodoc", + "sphinx.ext.intersphinx", + "sphinx.ext.napoleon", + "sphinx_inline_tabs", + "m2r2", +] # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] @@ -67,19 +77,22 @@ def add_intersphinx_aliases_to_inv(app): # Fix resolution of MessageBus class to where docs actually are. intersphinx_aliases = { - ('py:class', 'dbus_next.aio.message_bus.MessageBus'): - ('py:class', 'dbus_next.aio.MessageBus'), - ('py:class', 'dbus_next.aio.proxy_object.ProxyObject'): - ('py:class', 'dbus_next.aio.ProxyObject'), - ('py:class', 'dbus_next.errors.DBusError'): - ('py:class', 'dbus_next.DBusError'), - ('py:class', 'dbus_next.signature.Variant'): - ('py:class', 'dbus_next.Variant'), + ("py:class", "dbus_next.aio.message_bus.MessageBus"): ( + "py:class", + "dbus_next.aio.MessageBus", + ), + ("py:class", "dbus_next.aio.proxy_object.ProxyObject"): ( + "py:class", + "dbus_next.aio.ProxyObject", + ), + ("py:class", "dbus_next.errors.DBusError"): ("py:class", "dbus_next.DBusError"), + ("py:class", "dbus_next.signature.Variant"): ("py:class", "dbus_next.Variant"), } + def setup(app): - app.add_config_value('intersphinx_aliases', {}, 'env') - app.connect('builder-inited', add_intersphinx_aliases_to_inv) + app.add_config_value("intersphinx_aliases", {}, "env") + app.connect("builder-inited", add_intersphinx_aliases_to_inv) # -- Options for HTML output ------------------------------------------------- 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/gatt/test_descriptor.py b/tests/gatt/test_descriptor.py index 5f55917..52a72d5 100644 --- a/tests/gatt/test_descriptor.py +++ b/tests/gatt/test_descriptor.py @@ -19,6 +19,7 @@ write_desc_val = None async_write_desc_val = None + class TestService(Service): def __init__(self): super().__init__("180A") From 3b3146be362e14d4a7336eaad9f30411e35e210d Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Mon, 22 May 2023 22:48:49 +0100 Subject: [PATCH 058/158] Fix uui16.py formatting --- bluez_peripheral/uuid16.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/bluez_peripheral/uuid16.py b/bluez_peripheral/uuid16.py index bbfb6a7..fff587b 100644 --- a/bluez_peripheral/uuid16.py +++ b/bluez_peripheral/uuid16.py @@ -26,10 +26,12 @@ def __init__( uuid: Optional[UUID] = None, ): if [hex, bytes, int, uuid].count(None) != 3: - raise TypeError("exactly 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" + ) # All representations are converted to either a UUID128 or a 16-bit integer. - time_low : int = None + time_low: int = None if hex is not None: hex.strip("0x") @@ -59,7 +61,6 @@ def __init__( self._uuid = uuid else: raise ValueError("the supplied uuid128 was out of range") - @classmethod def is_in_range(cls, uuid: UUID) -> bool: @@ -78,7 +79,9 @@ def is_in_range(cls, uuid: UUID) -> bool: return True @classmethod - def parse_uuid(cls, uuid: Union[str, bytes, int, UUID, "UUID16"]) -> Union[UUID, "UUID16"]: + def parse_uuid( + cls, uuid: Union[str, bytes, int, UUID, "UUID16"] + ) -> Union[UUID, "UUID16"]: if type(uuid) is UUID16: return uuid elif type(uuid) is UUID: @@ -96,7 +99,7 @@ def parse_uuid(cls, uuid: Union[str, bytes, int, UUID, "UUID16"]) -> Union[UUID, return UUID16(bytes=uuid) except: return UUID(bytes=uuid) - else: # Must be int. + else: # Must be int. try: return UUID16(int=uuid) except: From 727e52f58b9cdaa3bcca0ed976f675df677bae63 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Mon, 22 May 2023 22:56:51 +0100 Subject: [PATCH 059/158] Remove UUID16 docstring types --- bluez_peripheral/uuid16.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/bluez_peripheral/uuid16.py b/bluez_peripheral/uuid16.py index fff587b..2f07553 100644 --- a/bluez_peripheral/uuid16.py +++ b/bluez_peripheral/uuid16.py @@ -8,10 +8,10 @@ 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 @@ -67,7 +67,7 @@ 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 @@ -82,6 +82,8 @@ def is_in_range(cls, uuid: UUID) -> bool: def parse_uuid( cls, uuid: Union[str, bytes, int, UUID, "UUID16"] ) -> Union[UUID, "UUID16"]: + """Attempts to parse a supplied UUID representation to a UUID16. + If the resulting value is out of range a UUI128 will be returned instead.""" if type(uuid) is UUID16: return uuid elif type(uuid) is UUID: From 5cd2498704406dca763c7196ac12d533700ccdd7 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 23 May 2023 01:59:03 +0100 Subject: [PATCH 060/158] Add type hinting --- .github/workflows/python-test.yml | 17 +++++ bluez_peripheral/advert.py | 15 ++-- bluez_peripheral/agent.py | 11 +-- bluez_peripheral/error.py | 18 ++--- bluez_peripheral/gatt/characteristic.py | 94 +++++++++++++++---------- bluez_peripheral/gatt/descriptor.py | 81 ++++++++++++++------- bluez_peripheral/gatt/service.py | 28 ++++---- bluez_peripheral/util.py | 14 ++-- bluez_peripheral/uuid16.py | 10 +-- 9 files changed, 181 insertions(+), 107 deletions(-) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 08e0254..1ce2466 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -69,4 +69,21 @@ jobs: run: | python -m black --check bluez_peripheral python -m black --check tests + type-hint-check: + runs-on: ubuntu-22.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 diff --git a/bluez_peripheral/advert.py b/bluez_peripheral/advert.py index d6a1068..4458704 100644 --- a/bluez_peripheral/advert.py +++ b/bluez_peripheral/advert.py @@ -81,9 +81,10 @@ def __init__( self._serviceUUIDs = [UUID16.parse_uuid(uuid) for uuid in serviceUUIDs] self._localName = localName # 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 type(appearance) is bytes: + self._appearance = struct.unpack("H", appearance)[0] + else: + self._appearance = appearance self._timeout = timeout self._manufacturerData = {} @@ -102,7 +103,7 @@ def __init__( async def register( self, bus: MessageBus, - adapter: Adapter = None, + adapter: Optional[Adapter] = None, path: Optional[str] = None, ): """Register this advert with bluez to start advertising. @@ -130,12 +131,12 @@ async def register( # Get the LEAdvertisingManager1 interface for the target adapter. interface = adapter._proxy.get_interface(self._MANAGER_INTERFACE) - await interface.call_register_advertisement(path, {}) + await interface.call_register_advertisement(path, {}) # type: ignore @classmethod async def GetSupportedIncludes(cls, adapter: Adapter) -> AdvertisingIncludes: interface = adapter._proxy.get_interface(cls._MANAGER_INTERFACE) - includes = await interface.get_supported_includes() + includes = await interface.get_supported_includes() # type: ignore flags = AdvertisingIncludes.NONE for inc in includes: inc = AdvertisingIncludes[_kebab_to_shouting_snake(inc)] @@ -191,7 +192,7 @@ def Includes(self) -> "as": # type: ignore return [ _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) diff --git a/bluez_peripheral/agent.py b/bluez_peripheral/agent.py index 5ed7dd4..3b7d56a 100644 --- a/bluez_peripheral/agent.py +++ b/bluez_peripheral/agent.py @@ -2,7 +2,7 @@ from dbus_next.aio import MessageBus from dbus_next import DBusError -from typing import Awaitable, Callable +from typing import Awaitable, Callable, Optional from enum import Enum from .util import _snake_to_pascal @@ -46,9 +46,9 @@ def __init__( self, capability: AgentCapability, ): - self._capability = capability + self._capability: AgentCapability = capability + self._path: Optional[str] = None - self._path = None super().__init__(self._INTERFACE) @method() @@ -76,7 +76,7 @@ async def register( 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 @@ -89,6 +89,9 @@ async def register( await interface.call_request_default_agent(self._path) async def unregister(self, bus: MessageBus): + if self._path is None: + return + interface = await self._get_manager_interface(bus) await interface.call_unregister_agent(self._path) diff --git a/bluez_peripheral/error.py b/bluez_peripheral/error.py index 52c53ae..0835a93 100644 --- a/bluez_peripheral/error.py +++ b/bluez_peripheral/error.py @@ -2,45 +2,45 @@ class FailedError(DBusError): - def __init__(message): + def __init__(self, message): super.__init__("org.bluez.Error.Failed", message) class InProgressError(DBusError): - def __init__(message): + def __init__(self, message): super.__init__("org.bluez.Error.InProgress", message) class NotPermittedError(DBusError): - def __init__(message): + def __init__(self, message): super.__init__("org.bluez.Error.NotPermitted", message) class InvalidValueLengthError(DBusError): - def __init__(message): + def __init__(self, message): super.__init__("org.bluez.Error.InvalidValueLength", message) class InvalidOffsetError(DBusError): - def __init__(message): + def __init__(self, message): super.__init__("org.bluez.Error.InvalidOffset", message) class NotAuthorizedError(DBusError): - def __init__(message): + def __init__(self, message): super.__init__("org.bluez.Error.NotAuthorized", message) class NotConnectedError(DBusError): - def __init__(message): + def __init__(self, message): super.__init__("org.bluez.Error.NotConnected", message) class NotSupportedError(DBusError): - def __init__(message): + def __init__(self, message): super.__init__("org.bluez.Error.NotSupported", message) class RejectedError(DBusError): - def __init__(message): + def __init__(self, message): super.__init__("org.bluez.Error.Rejected", message) diff --git a/bluez_peripheral/gatt/characteristic.py b/bluez_peripheral/gatt/characteristic.py index eee5193..f9e25a1 100644 --- a/bluez_peripheral/gatt/characteristic.py +++ b/bluez_peripheral/gatt/characteristic.py @@ -6,25 +6,27 @@ import inspect from uuid import UUID from enum import Enum, Flag, auto -from typing import Callable, Optional, Union, Awaitable +from typing import Callable, Optional, Union, Awaitable, List, TYPE_CHECKING, cast -from .descriptor import descriptor, DescriptorFlags +if TYPE_CHECKING: + from .service import Service + +from .descriptor import descriptor as Descriptor, DescriptorFlags from ..uuid16 import UUID16 from ..util import * from ..util import _snake_to_kebab, _getattr_variant -from ..error import NotSupportedError +from ..error import NotSupportedError, FailedError -# TODO: Add type annotations to these classes. class CharacteristicReadOptions: """Options supplied to characteristic read functions. 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: Optional[Dict[str, Variant]] = None): + if options is None: + return - def __init__(self, options): self._offset = int(_getattr_variant(options, "offset", 0)) self._mtu = int(_getattr_variant(options, "mtu", None)) self._device = _getattr_variant(options, "device", None) @@ -64,10 +66,10 @@ 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: Optional[Dict[str, Variant]] = None): + if options is None: + return - def __init__(self, options): self._offset = int(_getattr_variant(options, "offset", 0)) type = _getattr_variant(options, "type", None) if not type is None: @@ -169,6 +171,16 @@ class CharacteristicFlags(Flag): """""" +GetterType = Union[ + Callable[["Service", CharacteristicReadOptions], bytes], + Callable[["Service", CharacteristicReadOptions], Awaitable[bytes]], +] +SetterType = Union[ + Callable[["Service", bytes, CharacteristicWriteOptions], None], + Callable[["Service", bytes, CharacteristicWriteOptions], Awaitable[None]], +] + + class characteristic(ServiceInterface): """Create a new characteristic with a specified UUID and flags. @@ -188,14 +200,14 @@ def __init__( flags: CharacteristicFlags = CharacteristicFlags.READ, ): self.uuid = UUID16.parse_uuid(uuid) - self.getter_func = None - self.setter_func = None + self.getter_func: Optional[GetterType] = None + self.setter_func: Optional[SetterType] = None self.flags = flags self._notify = False - self._service_path = None - self._descriptors = [] - self._service = None + self._service_path: Optional[str] = None + self._descriptors: List[Descriptor] = [] + self._service: Optional["Service"] = None self._value = bytearray() super().__init__(self._INTERFACE) @@ -210,27 +222,15 @@ def changed(self, new_value: bytes): 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": + def setter(self, setter_func: SetterType) -> "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, + getter_func: Optional[GetterType] = None, + setter_func: Optional[SetterType] = None, ) -> "characteristic": """A decorator for characteristic value getters. @@ -249,7 +249,7 @@ def descriptor( self, uuid: Union[str, bytes, UUID, UUID16, int], flags: DescriptorFlags = DescriptorFlags.READ, - ) -> "descriptor": + ) -> Descriptor: """Create a new descriptor with the specified UUID and Flags. Args: @@ -257,7 +257,7 @@ def descriptor( flags: Any descriptor access flags to use. """ # Use as a decorator for descriptors that need a getter. - return descriptor(uuid, self, flags) + return Descriptor(uuid, self, flags) def _is_registered(self): return not self._service_path is None @@ -268,7 +268,7 @@ def _set_service(self, service: "Service"): for desc in self._descriptors: desc._set_service(service) - def add_descriptor(self, desc: "descriptor"): + def add_descriptor(self, desc: Descriptor): """Associate the specified descriptor with this characteristic. Args: @@ -286,7 +286,7 @@ def add_descriptor(self, desc: "descriptor"): # Make sure that any descriptors have the correct service set at all times. desc._set_service(self._service) - def remove_descriptor(self, desc: "descriptor"): + def remove_descriptor(self, desc: Descriptor): """Remove the specified descriptor from this characteristic. Args: @@ -305,6 +305,9 @@ def remove_descriptor(self, desc: "descriptor"): desc._set_service(None) def _get_path(self) -> str: + if self._service_path is None: + raise ValueError() + return self._service_path + "/char{:d}".format(self._num) def _export(self, bus: MessageBus, service_path: str, num: int): @@ -328,19 +331,26 @@ def _unexport(self, bus: MessageBus): @method() async def ReadValue(self, options: "a{sv}") -> "ay": # type: ignore + if self.getter_func is None: + raise FailedError("No getter implemented") + + if self._service is None: + raise ValueError() + try: - res = [] + res: bytes if inspect.iscoroutinefunction(self.getter_func): res = await self.getter_func( self._service, CharacteristicReadOptions(options) ) else: - res = self.getter_func( - self._service, CharacteristicReadOptions(options) + res = cast( + bytes, + self.getter_func(self._service, CharacteristicReadOptions(options)), ) self._value = bytearray(res) - return bytes(self._value) + return res except DBusError as e: # Allow DBusErrors to bubble up normally. raise e @@ -353,6 +363,12 @@ async def ReadValue(self, options: "a{sv}") -> "ay": # type: ignore @method() async def WriteValue(self, data: "ay", options: "a{sv}"): # type: ignore + if self.setter_func is None: + raise FailedError("No setter implemented") + + if self._service is None: + raise ValueError() + opts = CharacteristicWriteOptions(options) try: if inspect.iscoroutinefunction(self.setter_func): @@ -399,7 +415,7 @@ def Flags(self) -> "as": # type: ignore return [ _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) diff --git a/bluez_peripheral/gatt/descriptor.py b/bluez_peripheral/gatt/descriptor.py index 92cbe4d..7e348bf 100644 --- a/bluez_peripheral/gatt/descriptor.py +++ b/bluez_peripheral/gatt/descriptor.py @@ -1,4 +1,4 @@ -from dbus_next import DBusError +from dbus_next import DBusError, Variant from dbus_next.aio import MessageBus from dbus_next.service import ServiceInterface, method, dbus_property from dbus_next.constants import PropertyAccess @@ -6,19 +6,25 @@ import inspect from uuid import UUID from enum import Flag, auto -from typing import Callable, Union, Awaitable +from typing import Callable, Union, Awaitable, Optional, Dict, TYPE_CHECKING, cast + +if TYPE_CHECKING: + from .service import Service from ..uuid16 import UUID16 from ..util import _snake_to_kebab, _getattr_variant +from ..error import FailedError -# TODO: Add type annotations to these classes. class DescriptorReadOptions: """Options supplied to descriptor read functions. 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): + def __init__(self, options: Optional[Dict[str, Variant]] = None): + if options is None: + return + self._offset = _getattr_variant(options, "offset", 0) self._link = _getattr_variant(options, "link", None) self._device = _getattr_variant(options, "device", None) @@ -44,7 +50,10 @@ 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): + def __init__(self, options: Optional[Dict[str, Variant]] = None): + if options is None: + return + self._offset = _getattr_variant(options, "offset", 0) self._device = _getattr_variant(options, "device", None) self._link = _getattr_variant(options, "link", None) @@ -99,6 +108,16 @@ 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): """Create a new descriptor with a specified UUID and flags associated with the specified parent characteristic. @@ -121,13 +140,13 @@ def __init__( flags: DescriptorFlags = DescriptorFlags.READ, ): self.uuid = UUID16.parse_uuid(uuid) - self.getter_func = None - self.setter_func = None + self.getter_func: Optional[GetterType] = None + self.setter_func: Optional[SetterType] = None self.characteristic = characteristic self.flags = flags self._service = None - self._characteristic_path = None + self._characteristic_path: Optional[str] = None super().__init__(self._INTERFACE) characteristic.add_descriptor(self) @@ -135,31 +154,22 @@ def __init__( # Decorators def setter( self, - setter_func: Union[ - Callable[["Service", bytes, DescriptorWriteOptions], None], - Callable[["Service", bytes, DescriptorWriteOptions], Awaitable[None]], - ], + setter_func: SetterType, ) -> "descriptor": """A decorator for descriptor value setters.""" self.setter_func = setter_func - return setter_func + return self 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, + getter_func: Optional[GetterType] = None, + setter_func: Optional[SetterType] = 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. + setter_func: The setter function for this descriptor. Returns: This descriptor @@ -173,6 +183,9 @@ def _set_service(self, service): # DBus def _get_path(self) -> str: + if self._characteristic_path is None: + raise ValueError() + return self._characteristic_path + "/desc{:d}".format(self._num) def _export(self, bus: MessageBus, characteristic_path: str, num: int): @@ -181,18 +194,30 @@ def _export(self, bus: MessageBus, characteristic_path: str, num: int): bus.export(self._get_path(), self) def _unexport(self, bus: MessageBus): + if self._characteristic_path is None: + return + bus.unexport(self._get_path(), self._INTERFACE) self._characteristic_path = None @method() async def ReadValue(self, options: "a{sv}") -> "ay": # type: ignore + if self.getter_func is None: + raise FailedError("No getter implemented") + + if self._service is None: + raise ValueError() + 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)) + return cast( + bytes, + self.getter_func(self._service, DescriptorReadOptions(options)), + ) except DBusError as e: # Allow DBusErrors to bubble up normally. raise e @@ -205,6 +230,12 @@ async def ReadValue(self, options: "a{sv}") -> "ay": # type: ignore @method() async def WriteValue(self, data: "ay", options: "a{sv}"): # type: ignore + if self.setter_func is None: + raise FailedError("No setter implemented") + + if self._service is None: + raise ValueError() + try: if inspect.iscoroutinefunction(self.setter_func): await self.setter_func( @@ -232,5 +263,7 @@ def Characteristic(self) -> "o": # type: ignore def 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 57ab88e..6cce6ab 100644 --- a/bluez_peripheral/gatt/service.py +++ b/bluez_peripheral/gatt/service.py @@ -5,7 +5,7 @@ import inspect from uuid import UUID -from typing import Union +from typing import Union, List, Optional from .characteristic import characteristic from ..uuid16 import UUID16 @@ -45,8 +45,8 @@ def __init__( # Make sure uuid is a uuid16. self._uuid = UUID16.parse_uuid(uuid) self._primary = primary - self._characteristics = [] - self._path = None + self._characteristics: List[characteristic] = [] + self._path: Optional[str] = None self._includes = includes self._populate() @@ -103,6 +103,9 @@ def _export(self, bus: MessageBus, path: str): i += 1 def _unexport(self, bus: MessageBus): + if self._path is None: + return + # Unexport this and every child characteristic. bus.unexport(self._path, self._INTERFACE) for char in self._characteristics: @@ -114,7 +117,7 @@ async def register( self, bus: MessageBus, path: str = "/com/spacecheese/bluez_peripheral", - adapter: Adapter = None, + adapter: Optional[Adapter] = None, ): """Register this service as a standalone service. Using this multiple times will cause path conflicts. @@ -150,6 +153,10 @@ def Primary(self) -> "b": # type: ignore def Includes(self) -> "ao": # type: ignore paths = [] + # Shouldn't be possible to call this before export. + if self._path is None: + raise ValueError() + for service in self._includes: if not service._path is None: paths.append(service._path) @@ -163,19 +170,14 @@ class ServiceCollection: _MANAGER_INTERFACE = "org.bluez.GattManager1" - 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: List[Service] = []): """Create a service collection populated with the specified list of services. Args: services: The services to provide. """ - self._path = None - self._adapter = None + self._path: Optional[str] = None + self._adapter: Optional[Adapter] = None self._services = services def add_service(self, service: Service): @@ -219,7 +221,7 @@ async def register( self, bus: MessageBus, path: str = "/com/spacecheese/bluez_peripheral", - adapter: Adapter = None, + adapter: Optional[Adapter] = 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. diff --git a/bluez_peripheral/util.py b/bluez_peripheral/util.py index 06e2019..ac4c9b3 100644 --- a/bluez_peripheral/util.py +++ b/bluez_peripheral/util.py @@ -65,30 +65,30 @@ def __init__(self, proxy: ProxyObject): async def get_address(self) -> str: """Read the bluetooth address of this device.""" - return await self._adapter_interface.get_address() + 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() + 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() + return await self._adapter_interface.get_alias() # type: ignore 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) + 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() + return await self._adapter_interface.get_powered() # type: ignore async def set_powered(self, val: bool): """Turn this adapter on or off.""" - await self._adapter_interface.set_powered(val) + await self._adapter_interface.set_powered(val) # type: ignore @classmethod async def get_all(cls, bus: MessageBus) -> Collection["Adapter"]: @@ -127,6 +127,6 @@ async def get_first(cls, bus: MessageBus) -> "Adapter": """ adapters = await cls.get_all(bus) if len(adapters) > 0: - return adapters[0] + return next(iter(adapters)) else: raise ValueError("No bluetooth adapters could be found.") diff --git a/bluez_peripheral/uuid16.py b/bluez_peripheral/uuid16.py index 2f07553..937f417 100644 --- a/bluez_peripheral/uuid16.py +++ b/bluez_peripheral/uuid16.py @@ -16,7 +16,7 @@ class UUID16: # 0000****--0000-1000-8000-00805F9B34FB _FIELDS = (0x00000000, 0x0000, 0x1000, 0x80, 0x00, 0x00805F9B34FB) - _uuid: Optional[UUID] = None + _uuid: UUID def __init__( self, @@ -31,7 +31,7 @@ def __init__( ) # All representations are converted to either a UUID128 or a 16-bit integer. - time_low: int = None + time_low = None if hex is not None: hex.strip("0x") @@ -56,7 +56,7 @@ def __init__( if time_low is not None: self._uuid = UUID(fields=(time_low,) + self._FIELDS[1:]) - else: + elif uuid is not None: if UUID16.is_in_range(uuid): self._uuid = uuid else: @@ -101,12 +101,14 @@ def parse_uuid( return UUID16(bytes=uuid) except: return UUID(bytes=uuid) - else: # Must be int. + elif type(uuid) is int: try: return UUID16(int=uuid) except: 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.""" From 6e9c1c92912c552396c1f6b5dee645bbb5336378 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 23 May 2023 02:00:50 +0100 Subject: [PATCH 061/158] Add Interrogate docstring check --- .github/workflows/python-test.yml | 17 +++++++++++++++++ pyproject.toml | 10 +++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 1ce2466..0d61d92 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -86,4 +86,21 @@ jobs: - name: Check type hints run: | python -m mypy bluez_peripheral + interrotate-check: + runs-on: ubuntu-22.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 interrogate + - name: Check type hints + run: | + python -m interrogate -vv diff --git a/pyproject.toml b/pyproject.toml index 58a4a54..d3f755d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,4 +6,12 @@ requires = [ ] build-backend = "setuptools.build_meta" -[tool.setuptools_scm] \ No newline at end of file +[tool.setuptools_scm] + +[tool.interrogate] +# Documenting these plays badly with sphinx +ignore-init-module = true +ignore-init-method = true + +ignore-private = true +ignore-semiprivate = true \ No newline at end of file From 2f80ff3490b4a230a358dd40bce80d22cc96837d Mon Sep 17 00:00:00 2001 From: Space Cheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 27 Jun 2023 22:32:52 +0100 Subject: [PATCH 062/158] Rework /var/lib/bluetooth reference --- docs/source/pairing.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/source/pairing.rst b/docs/source/pairing.rst index 703963b..c2bfa95 100644 --- a/docs/source/pairing.rst +++ b/docs/source/pairing.rst @@ -85,7 +85,7 @@ There are three potential sources of agents: 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 a large amount of seemingly undocumented metadata in the ``/var/lib/bluetooth/`` directory and, it may be useful to delete this data between attempts. +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 adapter metadata in the ``/var/lib/bluetooth/`` directory which can be deleted (see the bluetoothd manpages) although a bluetoothd restart may be required. .. _pairing-io: @@ -136,4 +136,4 @@ Note that IO Capability is not the only factor in selecting a pairing algorithm. Vol 3, Part H, Table 2.8 (source of :ref:`pairing-io`) Bluez Documentation - `Agent API `_ \ No newline at end of file + `Agent API `_ From 75d8f49aa69535e0b8935b91e70e81a420fd723a Mon Sep 17 00:00:00 2001 From: Space Cheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 27 Jun 2023 22:52:21 +0100 Subject: [PATCH 063/158] Update pairing.rst --- docs/source/pairing.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/pairing.rst b/docs/source/pairing.rst index c2bfa95..36b870a 100644 --- a/docs/source/pairing.rst +++ b/docs/source/pairing.rst @@ -85,7 +85,7 @@ There are three potential sources of agents: 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 adapter metadata in the ``/var/lib/bluetooth/`` directory which can be deleted (see the bluetoothd manpages) although a bluetoothd restart may be required. +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 adapter metadata in the ``/var/lib/bluetooth/`` directory which can be deleted (see the bluetoothd manpages). .. _pairing-io: From 31cfcf5f2d5c5e96bfa6c0ae997bd11075e7e801 Mon Sep 17 00:00:00 2001 From: Space Cheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 27 Jun 2023 22:56:15 +0100 Subject: [PATCH 064/158] Don't suggest deleting /var/lib/blueooth --- docs/source/pairing.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/pairing.rst b/docs/source/pairing.rst index 36b870a..3fe5d81 100644 --- a/docs/source/pairing.rst +++ b/docs/source/pairing.rst @@ -85,7 +85,7 @@ There are three potential sources of agents: 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 adapter metadata in the ``/var/lib/bluetooth/`` directory which can be deleted (see the bluetoothd manpages). +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: From eb76b94ba549c4211751fac681de28dce1abf353 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 24 Jun 2025 00:27:00 +0100 Subject: [PATCH 065/158] Refactor adapter --- bluez_peripheral/adapter.py | 163 +++++++++++++++++++++++++++++++ bluez_peripheral/advert.py | 1 + bluez_peripheral/gatt/service.py | 1 + bluez_peripheral/util.py | 123 +---------------------- 4 files changed, 166 insertions(+), 122 deletions(-) create mode 100644 bluez_peripheral/adapter.py diff --git a/bluez_peripheral/adapter.py b/bluez_peripheral/adapter.py new file mode 100644 index 0000000..0f55a29 --- /dev/null +++ b/bluez_peripheral/adapter.py @@ -0,0 +1,163 @@ +from dbus_fast.aio import MessageBus +from dbus_fast.errors import InvalidIntrospectionError +from dbus_fast.aio.proxy_object import ProxyObject + +from typing import Collection + +class Device: + """A bluetooth device discovered by an adapter.""" + + _INTERFACE = "org.bluez.Device1" + _device_interface = None + + def __init__(self, proxy: ProxyObject): + self._proxy = proxy + self._device_interface = proxy.get_interface(self._INTERFACE) + + async def get_paired(self): + return await self._device_interface.get_paired() + + async def pair(self): + await self._device_interface.call_pair() + + +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) + + async def start_discovery(self): + """Start searching for other bluetooth devices.""" + await self._adapter_interface.call_start_discovery() + + async def stop_discovery(self): + """Stop searching for other blutooth devices.""" + await self._adapter_interface.call_stop_discovery() + + async def get_devices(self, bus: MessageBus) -> Collection[Device]: + path = self._adapter_interface.path + device_nodes = (await bus.introspect("org.bluez", path)).nodes + + devices = [] + for node in device_nodes: + 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) -> 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: + 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.") \ No newline at end of file diff --git a/bluez_peripheral/advert.py b/bluez_peripheral/advert.py index d526c88..0ea3056 100644 --- a/bluez_peripheral/advert.py +++ b/bluez_peripheral/advert.py @@ -9,6 +9,7 @@ from uuid import UUID from .uuid16 import UUID16 +from .adapter import Adapter from .util import * diff --git a/bluez_peripheral/gatt/service.py b/bluez_peripheral/gatt/service.py index 789cd9d..a1f4cb7 100644 --- a/bluez_peripheral/gatt/service.py +++ b/bluez_peripheral/gatt/service.py @@ -5,6 +5,7 @@ from .characteristic import characteristic from ..uuid16 import UUID16 +from ..adapter import Adapter from ..util import * from uuid import UUID diff --git a/bluez_peripheral/util.py b/bluez_peripheral/util.py index d0b3e6a..e7c4d33 100644 --- a/bluez_peripheral/util.py +++ b/bluez_peripheral/util.py @@ -1,12 +1,8 @@ from dbus_fast import Variant, BusType from dbus_fast.aio import MessageBus -from dbus_fast.errors import InvalidIntrospectionError - -from typing import Any, Collection, Dict - -from dbus_fast.aio.proxy_object import ProxyObject from dbus_fast.errors import DBusError +from typing import Any, Dict def getattr_variant(object: Dict[str, Variant], key: str, default: Any): if key in object: @@ -52,120 +48,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: - 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.") From 9bb33e39dd40077f288c821bf29e934b44041799 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 24 Jun 2025 00:27:25 +0100 Subject: [PATCH 066/158] Initial test implementation --- tests/test.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100755 tests/test.py diff --git a/tests/test.py b/tests/test.py new file mode 100755 index 0000000..80060d8 --- /dev/null +++ b/tests/test.py @@ -0,0 +1,42 @@ +import asyncio +from dbus_fast.aio import MessageBus +from dbus_fast.constants import BusType + +from bluez_peripheral.advert import Advertisement +from bluez_peripheral.adapter import Adapter +from bluez_peripheral.util import get_message_bus +from bluez_peripheral.agent import TestAgent, AgentCapability + +async def main(): + bus = await get_message_bus() + + adapters = await Adapter.get_all(bus) + + # Enable adapter settings + 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].set_discoverable(True) + await adapters[1].set_pairable(True) + + advert = Advertisement("Heart Monitor", ["180D"], 0x0340, 60) + await advert.register(bus, adapters[0]) + + await adapters[1].start_discovery() + + await asyncio.sleep(5) + + agent = TestAgent(AgentCapability.KEYBOARD_DISPLAY) + await agent.register(bus) + + devices = await adapters[1].get_devices(bus) + for d in devices: + if not await d.get_paired(): + await d.pair() + + while 1: + await asyncio.sleep(5) + +asyncio.run(main()) \ No newline at end of file From d7c14ab264bb820b579e7b071aa8cf0c26732037 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Wed, 25 Jun 2025 00:12:15 +0100 Subject: [PATCH 067/158] Implement Adapter.remove_device --- bluez_peripheral/adapter.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bluez_peripheral/adapter.py b/bluez_peripheral/adapter.py index 0f55a29..f3863ab 100644 --- a/bluez_peripheral/adapter.py +++ b/bluez_peripheral/adapter.py @@ -117,6 +117,10 @@ async def get_devices(self, bus: MessageBus) -> Collection[Device]: pass return devices + + async def remove_device(self, device: Device): + path = device._device_interface.path + await self._adapter_interface.call_remove_device(path) @classmethod async def get_all(cls, bus: MessageBus) -> Collection["Adapter"]: From b824e2ea9df4eb9ace861b3ebb3477735d872125 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Wed, 25 Jun 2025 00:12:29 +0100 Subject: [PATCH 068/158] Remove Adapter.get_devices bus argument --- bluez_peripheral/adapter.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bluez_peripheral/adapter.py b/bluez_peripheral/adapter.py index f3863ab..fd9abbb 100644 --- a/bluez_peripheral/adapter.py +++ b/bluez_peripheral/adapter.py @@ -101,8 +101,9 @@ async def stop_discovery(self): """Stop searching for other blutooth devices.""" await self._adapter_interface.call_stop_discovery() - async def get_devices(self, bus: MessageBus) -> Collection[Device]: + async def get_devices(self) -> Collection[Device]: path = self._adapter_interface.path + bus = self._adapter_interface.bus device_nodes = (await bus.introspect("org.bluez", path)).nodes devices = [] From 454293b24702b41d2a985fa72491b949926fac04 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Wed, 25 Jun 2025 00:12:38 +0100 Subject: [PATCH 069/158] Add Device.get_name --- bluez_peripheral/adapter.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bluez_peripheral/adapter.py b/bluez_peripheral/adapter.py index fd9abbb..a2932fa 100644 --- a/bluez_peripheral/adapter.py +++ b/bluez_peripheral/adapter.py @@ -20,6 +20,9 @@ async def get_paired(self): async def pair(self): await self._device_interface.call_pair() + async def get_name(self): + return await self._device_interface.get_name() + class Adapter: """A bluetooth adapter.""" From e9203b25bb420cbb16ac1979b21df2402d3e11ff Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Wed, 25 Jun 2025 00:12:53 +0100 Subject: [PATCH 070/158] Fix broken module imports --- bluez_peripheral/gatt/service.py | 2 +- tests/util.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/bluez_peripheral/gatt/service.py b/bluez_peripheral/gatt/service.py index a1f4cb7..95bcf1a 100644 --- a/bluez_peripheral/gatt/service.py +++ b/bluez_peripheral/gatt/service.py @@ -9,7 +9,7 @@ from ..util import * from uuid import UUID -from typing import Union +from typing import Union, Collection import inspect # See https://git.kernel.org/pub/scm/bluetooth/bluez.git/tree/doc/gatt-api.txt diff --git a/tests/util.py b/tests/util.py index db65842..2620b25 100644 --- a/tests/util.py +++ b/tests/util.py @@ -5,6 +5,7 @@ from dbus_fast.introspection import Node +from bluez_peripheral.adapter import Adapter from bluez_peripheral.util import * from bluez_peripheral.uuid16 import UUID16 From 022ca1b82364b078815e1c9036310bc5d33c758f Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Wed, 25 Jun 2025 00:13:04 +0100 Subject: [PATCH 071/158] Test proof of concept updates --- tests/test.py | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/tests/test.py b/tests/test.py index 80060d8..be2c98b 100755 --- a/tests/test.py +++ b/tests/test.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python3 + import asyncio from dbus_fast.aio import MessageBus from dbus_fast.constants import BusType @@ -21,22 +23,39 @@ async def main(): await adapters[1].set_discoverable(True) await adapters[1].set_pairable(True) - advert = Advertisement("Heart Monitor", ["180D"], 0x0340, 60) + print(f"Advertising on {await adapters[0].get_name()}") + advert = Advertisement("Heart Monitor", ["180D", "1234"], 0x0340, 60 * 5, duration=5) await advert.register(bus, adapters[0]) + print(f"Starting scan on {await adapters[1].get_name()}") await adapters[1].start_discovery() - await asyncio.sleep(5) - agent = TestAgent(AgentCapability.KEYBOARD_DISPLAY) await agent.register(bus) - devices = await adapters[1].get_devices(bus) + devices = [] + print("Waiting for devices", end="") + while len(devices) == 0: + await asyncio.sleep(1) + print(".", end="") + devices = await adapters[1].get_devices() + print("") + for d in devices: + print(f"Found '{await d.get_name()}'") if not await d.get_paired(): + print(" Pairing") await d.pair() - while 1: - await asyncio.sleep(5) + print("Sleeping", end="") + for _ in range(0,10): + print(".", end="") + print("") + + for d in devices: + print(f"Device '{await d.get_name()}'") + if await d.get_paired(): + print(" Removing") + await adapters[1].remove_device(d) asyncio.run(main()) \ No newline at end of file From e10b97585bbd34a19e363b62e62ecdb012689dfd Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Thu, 14 Aug 2025 20:16:54 +0100 Subject: [PATCH 072/158] bluez_images integration prototype --- bluez_images/test.sh | 64 ++++++++++++++++++++++++++++++++++++ bluez_images/test_wrapper.sh | 6 ++++ tests/test.py | 44 +++++++++++++++++++++++-- 3 files changed, 112 insertions(+), 2 deletions(-) create mode 100755 bluez_images/test.sh create mode 100755 bluez_images/test_wrapper.sh diff --git a/bluez_images/test.sh b/bluez_images/test.sh new file mode 100755 index 0000000..31dc908 --- /dev/null +++ b/bluez_images/test.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +set -euo pipefail + +IMAGE="${1}" + +SSH="ssh -i id_ed25519 -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" --exclude bluez_images \ + "../" tester@localhost:/bluez_peripheral + +echo "[*] Testing adapter" +$SSH -p 2244 tester@localhost " + python3 -m venv ~/venv + source ~/venv/bin/activate + pip install -r /bluez_peripheral/tests/requirements.txt + + sudo nohup btvirt -L -l2 >/dev/null 2>&1 & + sudo service bluetooth start + + cd /bluez_peripheral + python3 -m tests.test + sudo shutdown -h now +" +wait $QEMU_PID diff --git a/bluez_images/test_wrapper.sh b/bluez_images/test_wrapper.sh new file mode 100755 index 0000000..b7cf97c --- /dev/null +++ b/bluez_images/test_wrapper.sh @@ -0,0 +1,6 @@ +wget -O id_ed25519 https://github.com/spacecheese/bluez_images/releases/download/0.0.13/id_ed25519 +wget -O image.qcow2 https://github.com/spacecheese/bluez_images/releases/download/0.0.13/ubuntu-24.04-bluez-5.66.qcow2 + + +chmod 600 id_ed25519 +./test.sh image.qcow2 \ No newline at end of file diff --git a/tests/test.py b/tests/test.py index be2c98b..f72f98b 100755 --- a/tests/test.py +++ b/tests/test.py @@ -1,13 +1,53 @@ #!/usr/bin/env python3 import asyncio +from dbus_fast.service import ServiceInterface, method from dbus_fast.aio import MessageBus from dbus_fast.constants import BusType from bluez_peripheral.advert import Advertisement from bluez_peripheral.adapter import Adapter from bluez_peripheral.util import get_message_bus -from bluez_peripheral.agent import TestAgent, AgentCapability +from bluez_peripheral.agent import BaseAgent, TestAgent, AgentCapability + +class TrivialAgent(BaseAgent): + @method() + def Cancel(): # type: ignore + return + + @method() + def Release(): # type: ignore + return + + @method() + def RequestPinCode(self, device: "o") -> "s": # type: ignore + breakpoint() + pass + + @method() + def DisplayPinCode(self, device: "o", pincode: "s"): # type: ignore + return + + @method() + def RequestPasskey(self, device: "o") -> "u": # type: ignore + return + + @method() + def DisplayPasskey(self, device: "o", passkey: "u", entered: "q"): # type: ignore + return + + @method() + def RequestConfirmation(self, device: "o", passkey: "u"): # type: ignore + return + + @method() + def RequestAuthorization(self, device: "o"): # type: ignore + return + + @method() + def AuthorizeService(self, device: "o", uuid: "s"): # type: ignore + return + async def main(): bus = await get_message_bus() @@ -30,7 +70,7 @@ async def main(): print(f"Starting scan on {await adapters[1].get_name()}") await adapters[1].start_discovery() - agent = TestAgent(AgentCapability.KEYBOARD_DISPLAY) + agent = TrivialAgent(AgentCapability.KEYBOARD_DISPLAY) await agent.register(bus) devices = [] From 0154e35999786adba2e9cfe736dfa108953fdfd5 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Mon, 18 Aug 2025 23:48:39 +0100 Subject: [PATCH 073/158] Minor characteristics/ descriptors tidy up --- docs/source/characteristics_descriptors.rst | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/docs/source/characteristics_descriptors.rst b/docs/source/characteristics_descriptors.rst index 9972a14..9624886 100644 --- a/docs/source/characteristics_descriptors.rst +++ b/docs/source/characteristics_descriptors.rst @@ -14,10 +14,7 @@ These are recommended over creating a custom characteristic where possible since 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:: +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 one of the following types:: org.bluez.Error.Failed org.bluez.Error.InProgress @@ -25,6 +22,7 @@ of each access operation (the characteristic getters and setters map to ReadValu org.bluez.Error.InvalidValueLength org.bluez.Error.NotAuthorized org.bluez.Error.NotSupported + org.bluez.Error.ImproperlyConfigured 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. @@ -35,7 +33,7 @@ bluez_peripheral does not check the validity of these options and only assigns t 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). +most devices have a larger default MTU. 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. From 9f6a343bf4399fc8e08a0bcd35bea8c81401d3c2 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Mon, 18 Aug 2025 23:54:27 +0100 Subject: [PATCH 074/158] Update bluez links --- bluez_peripheral/gatt/characteristic.py | 2 +- bluez_peripheral/gatt/descriptor.py | 2 +- bluez_peripheral/gatt/service.py | 2 +- docs/source/advertising.rst | 2 +- docs/source/pairing.rst | 2 +- docs/source/services.rst | 4 +++- 6 files changed, 8 insertions(+), 6 deletions(-) diff --git a/bluez_peripheral/gatt/characteristic.py b/bluez_peripheral/gatt/characteristic.py index f9e25a1..b17dbd6 100644 --- a/bluez_peripheral/gatt/characteristic.py +++ b/bluez_peripheral/gatt/characteristic.py @@ -113,7 +113,7 @@ def prepare_authorize(self): 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 diff --git a/bluez_peripheral/gatt/descriptor.py b/bluez_peripheral/gatt/descriptor.py index 7e348bf..942a57d 100644 --- a/bluez_peripheral/gatt/descriptor.py +++ b/bluez_peripheral/gatt/descriptor.py @@ -82,7 +82,7 @@ def prepare_authorize(self): 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 diff --git a/bluez_peripheral/gatt/service.py b/bluez_peripheral/gatt/service.py index 6cce6ab..28074f3 100644 --- a/bluez_peripheral/gatt/service.py +++ b/bluez_peripheral/gatt/service.py @@ -12,7 +12,7 @@ from ..util import * -# See https://git.kernel.org/pub/scm/bluetooth/bluez.git/tree/doc/gatt-api.txt +# See https://github.com/bluez/bluez/blob/master/doc/org.bluez.GattService.rst class Service(ServiceInterface): """Create a bluetooth service with the specified uuid. diff --git a/docs/source/advertising.rst b/docs/source/advertising.rst index 23afc42..b57d5bc 100644 --- a/docs/source/advertising.rst +++ b/docs/source/advertising.rst @@ -34,4 +34,4 @@ A minimal :py:class:`advert` requires: .. seealso:: Bluez Documentation - `Advertising API `_ + `Advertising API `_ diff --git a/docs/source/pairing.rst b/docs/source/pairing.rst index 3fe5d81..8d955f5 100644 --- a/docs/source/pairing.rst +++ b/docs/source/pairing.rst @@ -136,4 +136,4 @@ Note that IO Capability is not the only factor in selecting a pairing algorithm. Vol 3, Part H, Table 2.8 (source of :ref:`pairing-io`) Bluez Documentation - `Agent API `_ + `Agent API `_ diff --git a/docs/source/services.rst b/docs/source/services.rst index 4b2c601..7cbbd1c 100644 --- a/docs/source/services.rst +++ b/docs/source/services.rst @@ -199,7 +199,9 @@ Characteristics with the :py:attr:`~bluez_peripheral.gatt.characteristic.Charact .. seealso:: Bluez Documentation - `GATT API `_ + `Service API `_ + `Characteristic API `_ + `Descriptor API `_ .. _attribute-options: From b306c41b12b7982b58af9e14d2bbd920ce4efc96 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 19 Aug 2025 00:25:51 +0100 Subject: [PATCH 075/158] Add sphinx-autobuild recipe --- docs/Makefile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/Makefile b/docs/Makefile index ab9cbdc..4264fe6 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -25,6 +25,9 @@ apidoc: 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). %: Makefile From 79b7acf68a4055e96a271a3e0caf95d541c98f91 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 19 Aug 2025 00:26:04 +0100 Subject: [PATCH 076/158] Add sphinx.ext.linkcode --- docs/source/conf.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/docs/source/conf.py b/docs/source/conf.py index b7cdccc..4d806da 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -12,6 +12,9 @@ # import os import sys +import inspect +import importlib +from pathlib import Path from datetime import datetime sys.path.insert(0, os.path.abspath(os.path.dirname(__file__) + "/../../")) @@ -52,6 +55,7 @@ def add_intersphinx_aliases_to_inv(app): "sphinx.ext.autodoc", "sphinx.ext.intersphinx", "sphinx.ext.napoleon", + "sphinx.ext.linkcode", "sphinx_inline_tabs", "m2r2", ] @@ -66,6 +70,36 @@ def add_intersphinx_aliases_to_inv(app): nitpicky = True +# -- Linkcode ---------------------------------------------------------------- +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 + + src = Path(src).relative_to(Path(__file__).parents[2]) + + return ( + f"https://github.com/spacecheese/bluez_peripheral/" + f"blob/master/{src.as_posix()}#L{lineno}-L{lineno+len(lines)-1}" + ) + # -- Napoleon ---------------------------------------------------------------- napoleon_numpy_docstring = False From 6b8ece6d205e79de5a3e31b6606b5417338d9964 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Wed, 20 Aug 2025 01:03:51 +0100 Subject: [PATCH 077/158] Initial pylint and mypy clean pass --- .github/workflows/python-test.yml | 4 +- bluez_peripheral/__init__.py | 2 - bluez_peripheral/adapter.py | 138 ++++++++++++++++++ bluez_peripheral/advert.py | 180 +++++++++++------------- bluez_peripheral/agent.py | 131 +++++++++-------- bluez_peripheral/error.py | 56 +++++--- bluez_peripheral/flags.py | 27 ++++ bluez_peripheral/gatt/characteristic.py | 126 +++++++++-------- bluez_peripheral/gatt/descriptor.py | 96 +++++++------ bluez_peripheral/gatt/service.py | 91 ++++++------ bluez_peripheral/util.py | 137 ++---------------- bluez_peripheral/uuid16.py | 62 ++++---- docs/requirements.txt | 3 +- docs/source/advertising.rst | 2 +- docs/source/conf.py | 15 +- docs/source/pairing.rst | 4 +- docs/source/services.rst | 4 +- pyproject.toml | 26 +++- tests/gatt/test_characteristic.py | 2 +- tests/test_advert.py | 19 +-- tests/util.py | 2 +- 21 files changed, 599 insertions(+), 528 deletions(-) create mode 100644 bluez_peripheral/adapter.py create mode 100644 bluez_peripheral/flags.py diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 93c15e9..daaf5ef 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -86,7 +86,7 @@ jobs: - name: Check type hints run: | python -m mypy bluez_peripheral - interrotate-check: + lint-check: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v3 @@ -99,7 +99,7 @@ jobs: run: | python -m pip install --upgrade pip pip install -r requirements.txt - pip install interrogate + python -m pylint bluez_peripheral - name: Check type hints run: | python -m interrogate -vv diff --git a/bluez_peripheral/__init__.py b/bluez_peripheral/__init__.py index 6e09aa6..99c1381 100644 --- a/bluez_peripheral/__init__.py +++ b/bluez_peripheral/__init__.py @@ -1,5 +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..82c2eaf --- /dev/null +++ b/bluez_peripheral/adapter.py @@ -0,0 +1,138 @@ +from typing import Collection + +from dbus_fast.aio.proxy_object import ProxyObject +from dbus_fast.aio import MessageBus +from dbus_fast.errors import InvalidIntrospectionError, InterfaceNotFoundError + +from .util import _kebab_to_shouting_snake +from .flags import AdvertisingIncludes + + +class Adapter: + """A bluetooth adapter.""" + + _ADAPTER_INTERFACE = "org.bluez.Adapter1" + _ADVERTISING_MANAGER_INTERFACE = "org.bluez.LEAdvertisingManager1" + _adapter_interface = None + + def __init__(self, proxy: ProxyObject): + self._proxy = proxy + self._adapter_interface = proxy.get_interface(self._ADAPTER_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._proxy.get_interface(self._ADVERTISING_MANAGER_INTERFACE) + 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 + + @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 next(iter(adapters)) + raise ValueError("No bluetooth adapters could be found.") diff --git a/bluez_peripheral/advert.py b/bluez_peripheral/advert.py index 96c7e84..a4a594b 100644 --- a/bluez_peripheral/advert.py +++ b/bluez_peripheral/advert.py @@ -1,38 +1,17 @@ -from dbus_next import Variant -from dbus_next.aio import MessageBus -from dbus_next.aio.proxy_object import ProxyInterface -from dbus_next.constants import PropertyAccess -from dbus_next.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 _snake_to_kebab, _kebab_to_shouting_snake, Adapter +from dbus_fast import Variant +from dbus_fast.constants import PropertyAccess +from dbus_fast.service import ServiceInterface, method, dbus_property +from dbus_fast.aio.message_bus import MessageBus - -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. - """ - - -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, UUIDCompatible +from .util import _snake_to_kebab +from .adapter import Adapter +from .flags import AdvertisingIncludes +from .flags import AdvertisingPacketType class Advertisement(ServiceInterface): @@ -63,46 +42,54 @@ class Advertisement(ServiceInterface): def __init__( self, - localName: str, - serviceUUIDs: Collection[Union[str, bytes, UUID, UUID16, int]], + local_name: str, + service_uuids: Collection[UUIDCompatible], + *, 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[UUIDCompatible]] = None, + service_data: Optional[List[Tuple[UUIDCompatible, 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. - if type(appearance) is bytes: + 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._export_bus: Optional[MessageBus] = None + self._export_path: Optional[str] = None super().__init__(self._INTERFACE) @@ -111,7 +98,7 @@ async def register( bus: MessageBus, adapter: Optional[Adapter] = None, path: Optional[str] = None, - ): + ) -> None: """Register this advert with bluez to start advertising. Args: @@ -126,8 +113,8 @@ async def register( ) Advertisement._defaultPathAdvertCount += 1 - self._exportBus = bus - self._exportPath = path + self._export_bus = bus + self._export_path = path # Export this advert to the dbus. bus.export(path, self) @@ -139,68 +126,59 @@ async def register( interface = adapter._proxy.get_interface(self._MANAGER_INTERFACE) await interface.call_register_advertisement(path, {}) # type: ignore - @classmethod - async def GetSupportedIncludes(cls, adapter: Adapter) -> AdvertisingIncludes: - interface = adapter._proxy.get_interface(cls._MANAGER_INTERFACE) - 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 - - @method() - def Release(self): # type: ignore - self._exportBus.unexport(self._exportPath, self._INTERFACE) - - if self.releaseCallback is not None: - self.releaseCallback() - - @dbus_property(PropertyAccess.READ) - def Type(self) -> "s": # type: ignore + @method("Release") + def _release(self): # type: ignore + assert self._export_bus is not None + assert self._export_path is not None + self._export_bus.unexport(self._export_path, self._INTERFACE) + + if self._release_callback is not None: + self._release_callback() + + @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) for inc in AdvertisingIncludes 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 3edf651..2814b10 100644 --- a/bluez_peripheral/agent.py +++ b/bluez_peripheral/agent.py @@ -1,10 +1,10 @@ -from dbus_fast.service import ServiceInterface, method -from dbus_fast.aio import MessageBus -from dbus_fast import DBusError - from typing import Awaitable, Callable, Optional from enum import Enum +from dbus_fast.service import ServiceInterface, 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 @@ -51,25 +51,25 @@ def __init__( super().__init__(self._INTERFACE) - @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): + 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" - ): + ) -> None: """Expose this agent on the specified message bus and register it with the bluez agent manager. Args: @@ -83,17 +83,22 @@ async def register( bus.export(path, self) 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._path) # type: ignore - async def unregister(self, bus: MessageBus): + async def unregister(self, bus: MessageBus) -> 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 self._path is None: return interface = await self._get_manager_interface(bus) - await interface.call_unregister_agent(self._path) + await interface.call_unregister_agent(self._path) # type: ignore bus.unexport(self._path, self._INTERFACE) @@ -105,67 +110,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 @@ -179,18 +172,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): + @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/error.py b/bluez_peripheral/error.py index 0835a93..53944b2 100644 --- a/bluez_peripheral/error.py +++ b/bluez_peripheral/error.py @@ -1,46 +1,64 @@ -from dbus_next import DBusError +from dbus_fast.errors import DBusError class FailedError(DBusError): - def __init__(self, message): - super.__init__("org.bluez.Error.Failed", message) + """Raised when an operation failed.""" + + def __init__(self, message: str): + super().__init__("org.bluez.Error.Failed", message) class InProgressError(DBusError): - def __init__(self, message): - super.__init__("org.bluez.Error.InProgress", message) + """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): - def __init__(self, message): - super.__init__("org.bluez.Error.NotPermitted", message) + """Raised when a requested operation is not permitted.""" + + def __init__(self, message: str): + super().__init__("org.bluez.Error.NotPermitted", message) class InvalidValueLengthError(DBusError): - def __init__(self, message): - super.__init__("org.bluez.Error.InvalidValueLength", message) + """Raised when a written value was an illegal length.""" + + def __init__(self, message: str): + super().__init__("org.bluez.Error.InvalidValueLength", message) class InvalidOffsetError(DBusError): - def __init__(self, message): - super.__init__("org.bluez.Error.InvalidOffset", message) + """Raised when an illegal offset is provided.""" + + def __init__(self, message: str): + super().__init__("org.bluez.Error.InvalidOffset", message) class NotAuthorizedError(DBusError): - def __init__(self, message): - super.__init__("org.bluez.Error.NotAuthorized", message) + """Raised when a requester is not authorized to perform the requsted operation.""" + + def __init__(self, message: str): + super().__init__("org.bluez.Error.NotAuthorized", message) class NotConnectedError(DBusError): - def __init__(self, message): - super.__init__("org.bluez.Error.NotConnected", message) + """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): - def __init__(self, message): - super.__init__("org.bluez.Error.NotSupported", message) + """Raised when the requested operation is not supported.""" + + def __init__(self, message: str): + super().__init__("org.bluez.Error.NotSupported", message) class RejectedError(DBusError): - def __init__(self, message): - super.__init__("org.bluez.Error.Rejected", message) + """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/characteristic.py b/bluez_peripheral/gatt/characteristic.py index 65323e0..fadebfa 100644 --- a/bluez_peripheral/gatt/characteristic.py +++ b/bluez_peripheral/gatt/characteristic.py @@ -1,22 +1,21 @@ -from dbus_fast import DBusError -from dbus_fast.constants import PropertyAccess -from dbus_fast.service import ServiceInterface, method, dbus_property -from dbus_fast.aio import MessageBus - import inspect -from uuid import UUID from enum import Enum, Flag, auto -from typing import Callable, Optional, Union, Awaitable, List, TYPE_CHECKING, cast +from typing import Callable, Optional, Union, Awaitable, List, cast, Dict, TYPE_CHECKING -if TYPE_CHECKING: - from .service import Service +from dbus_fast.errors import DBusError +from dbus_fast.constants import PropertyAccess +from dbus_fast.service import ServiceInterface, method, dbus_property +from dbus_fast.aio import MessageBus +from dbus_fast import Variant from .descriptor import descriptor as Descriptor, DescriptorFlags -from ..uuid16 import UUID16 -from ..util import * +from ..uuid16 import UUID16, UUIDCompatible from ..util import _snake_to_kebab, _getattr_variant from ..error import NotSupportedError, FailedError +if TYPE_CHECKING: + from .service import Service + class CharacteristicReadOptions: """Options supplied to characteristic read functions. @@ -27,9 +26,9 @@ def __init__(self, options: Optional[Dict[str, Variant]] = None): if options is None: return - self._offset = int(_getattr_variant(options, "offset", 0)) - self._mtu = int(_getattr_variant(options, "mtu", None)) - self._device = _getattr_variant(options, "device", None) + 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: @@ -70,43 +69,46 @@ def __init__(self, options: Optional[Dict[str, Variant]] = None): if options is None: return - 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) + t = _getattr_variant(options, "type", None) + self._type: Optional[CharacteristicWriteType] = None + if not t is None: + self._type = CharacteristicWriteType[t.upper()] + + 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 @@ -181,7 +183,7 @@ class CharacteristicFlags(Flag): ] -class characteristic(ServiceInterface): +class characteristic(ServiceInterface): # pylint: disable=invalid-name """Create a new characteristic with a specified UUID and flags. Args: @@ -196,7 +198,7 @@ class characteristic(ServiceInterface): def __init__( self, - uuid: Union[str, bytes, UUID, UUID16, int], + uuid: UUIDCompatible, flags: CharacteristicFlags = CharacteristicFlags.READ, ): self.uuid = UUID16.parse_uuid(uuid) @@ -209,10 +211,11 @@ def __init__( self._descriptors: List[Descriptor] = [] self._service: Optional["Service"] = None self._value = bytearray() + self._num: Optional[int] = None super().__init__(self._INTERFACE) - 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: @@ -247,7 +250,7 @@ def __call__( def descriptor( self, - uuid: Union[str, bytes, UUID, UUID16, int], + uuid: UUIDCompatible, flags: DescriptorFlags = DescriptorFlags.READ, ) -> Descriptor: """Create a new descriptor with the specified UUID and Flags. @@ -259,16 +262,21 @@ def descriptor( # Use as a decorator for descriptors that need a getter. return Descriptor(uuid, self, flags) - def _is_registered(self): + def _is_registered(self) -> bool: return not self._service_path is None - def _set_service(self, service: "Service"): + def set_service(self, service: Optional["Service"]) -> None: + """Attaches this characteristic to the specified service. + .. warning:: + + Do not call this directly. Subclasses of the Service class will handle this automatically. + """ self._service = service for desc in self._descriptors: - desc._set_service(service) + desc.set_service(service) - def add_descriptor(self, desc: Descriptor): + def add_descriptor(self, desc: Descriptor) -> None: """Associate the specified descriptor with this characteristic. Args: @@ -284,9 +292,9 @@ def add_descriptor(self, desc: Descriptor): self._descriptors.append(desc) # Make sure that any descriptors have the correct service set at all times. - desc._set_service(self._service) + desc.set_service(self._service) - def remove_descriptor(self, desc: Descriptor): + def remove_descriptor(self, desc: Descriptor) -> None: """Remove the specified descriptor from this characteristic. Args: @@ -302,15 +310,15 @@ def remove_descriptor(self, desc: Descriptor): self._descriptors.remove(desc) # Clear the parent service from any old descriptors. - desc._set_service(None) + desc.set_service(None) def _get_path(self) -> str: if self._service_path is None: raise ValueError() - return self._service_path + "/char{:d}".format(self._num) + return f"{self._service_path}/char{self._num}" - def _export(self, bus: MessageBus, service_path: str, num: int): + def _export(self, bus: MessageBus, service_path: str, num: int) -> None: self._service_path = service_path self._num = num bus.export(self._get_path(), self) @@ -321,7 +329,7 @@ def _export(self, bus: MessageBus, service_path: str, num: int): desc._export(bus, self._get_path(), i) i += 1 - def _unexport(self, bus: MessageBus): + def _unexport(self, bus: MessageBus) -> None: # Unexport this and each of the child descriptors. bus.unexport(self._get_path(), self._INTERFACE) for desc in self._descriptors: @@ -329,8 +337,8 @@ def _unexport(self, bus: MessageBus): self._service_path = None - @method() - async def ReadValue(self, options: "a{sv}") -> "ay": # type: ignore + @method("ReadValue") + async def _read_value(self, options: "a{sv}") -> "ay": # type: ignore if self.getter_func is None: raise FailedError("No getter implemented") @@ -361,8 +369,8 @@ async def ReadValue(self, options: "a{sv}") -> "ay": # type: ignore ) raise e - @method() - async def WriteValue(self, data: "ay", options: "a{sv}"): # type: ignore + @method("WriteValue") + async def _write_value(self, data: "ay", options: "a{sv}"): # type: ignore if self.setter_func is None: raise FailedError("No setter implemented") @@ -384,30 +392,30 @@ async def WriteValue(self, data: "ay", options: "a{sv}"): # type: ignore 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 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 NotSupportedError("The characteristic does not support notification.") self._notify = False - @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 Service(self) -> "o": # type: ignore + @dbus_property(PropertyAccess.READ, "Service") + def _get_service(self) -> "o": # type: ignore return self._service_path - @dbus_property(PropertyAccess.READ) - def Flags(self) -> "as": # type: ignore + @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 @@ -418,6 +426,6 @@ def Flags(self) -> "as": # type: ignore if self.flags & flag and flag.name is not None ] - @dbus_property(PropertyAccess.READ) - def Value(self) -> "ay": # type: ignore + @dbus_property(PropertyAccess.READ, "Value") + def _get_value(self) -> "ay": # type: ignore return bytes(self._value) diff --git a/bluez_peripheral/gatt/descriptor.py b/bluez_peripheral/gatt/descriptor.py index 942a57d..dec11f7 100644 --- a/bluez_peripheral/gatt/descriptor.py +++ b/bluez_peripheral/gatt/descriptor.py @@ -1,20 +1,20 @@ -from dbus_next import DBusError, Variant -from dbus_next.aio import MessageBus -from dbus_next.service import ServiceInterface, method, dbus_property -from dbus_next.constants import PropertyAccess - import inspect -from uuid import UUID from enum import Flag, auto from typing import Callable, Union, Awaitable, Optional, Dict, TYPE_CHECKING, cast -if TYPE_CHECKING: - from .service import Service +from dbus_fast.errors import DBusError +from dbus_fast import Variant +from dbus_fast.aio.message_bus import MessageBus +from dbus_fast.service import ServiceInterface, method, dbus_property +from dbus_fast.constants import PropertyAccess -from ..uuid16 import UUID16 +from ..uuid16 import UUID16, UUIDCompatible from ..util import _snake_to_kebab, _getattr_variant from ..error import FailedError +if TYPE_CHECKING: + from .service import Service + class DescriptorReadOptions: """Options supplied to descriptor read functions. @@ -30,19 +30,19 @@ def __init__(self, options: Optional[Dict[str, Variant]] = 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: @@ -60,24 +60,24 @@ def __init__(self, options: Optional[Dict[str, Variant]] = 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): @@ -119,7 +119,7 @@ class DescriptorFlags(Flag): # Decorator for descriptor getters/ setters. -class descriptor(ServiceInterface): +class descriptor(ServiceInterface): # pylint: disable=invalid-name """Create a new descriptor with a specified UUID and flags associated with the specified parent characteristic. Args: @@ -135,7 +135,7 @@ class descriptor(ServiceInterface): def __init__( self, - uuid: Union[str, bytes, UUID, UUID16, int], + uuid: UUIDCompatible, characteristic: "characteristic", # type: ignore flags: DescriptorFlags = DescriptorFlags.READ, ): @@ -144,7 +144,8 @@ def __init__( self.setter_func: Optional[SetterType] = None self.characteristic = characteristic self.flags = flags - self._service = None + self._service: Optional[Service] = None + self._num: Optional[int] = None self._characteristic_path: Optional[str] = None super().__init__(self._INTERFACE) @@ -178,7 +179,12 @@ def __call__( self.setter_func = setter_func return self - def _set_service(self, service): + def set_service(self, service: Optional["Service"]) -> None: + """Attaches this descriptor to the specified service. + .. warning:: + + Do not call this directly. Subclasses of the Service class will handle this automatically. + """ self._service = service # DBus @@ -186,22 +192,22 @@ def _get_path(self) -> str: if self._characteristic_path is None: raise ValueError() - return self._characteristic_path + "/desc{:d}".format(self._num) + return f"{self._characteristic_path}/desc{self._num}" - def _export(self, bus: MessageBus, characteristic_path: str, num: int): + def _export(self, bus: MessageBus, characteristic_path: str, num: int) -> None: self._characteristic_path = characteristic_path self._num = num bus.export(self._get_path(), self) - def _unexport(self, bus: MessageBus): + def _unexport(self, bus: MessageBus) -> None: if self._characteristic_path is None: return bus.unexport(self._get_path(), self._INTERFACE) self._characteristic_path = None - @method() - async def ReadValue(self, options: "a{sv}") -> "ay": # type: ignore + @method("ReadValue") + async def _read_value(self, options: "a{sv}") -> "ay": # type: ignore if self.getter_func is None: raise FailedError("No getter implemented") @@ -213,11 +219,11 @@ async def ReadValue(self, options: "a{sv}") -> "ay": # type: ignore return await self.getter_func( self._service, DescriptorReadOptions(options) ) - else: - return cast( - bytes, - self.getter_func(self._service, DescriptorReadOptions(options)), - ) + + return cast( + bytes, + self.getter_func(self._service, DescriptorReadOptions(options)), + ) except DBusError as e: # Allow DBusErrors to bubble up normally. raise e @@ -228,8 +234,8 @@ async def ReadValue(self, options: "a{sv}") -> "ay": # type: ignore ) raise e - @method() - async def WriteValue(self, data: "ay", options: "a{sv}"): # type: ignore + @method("WriteValue") + async def _write_value(self, data: "ay", options: "a{sv}"): # type: ignore if self.setter_func is None: raise FailedError("No setter implemented") @@ -251,16 +257,16 @@ async def WriteValue(self, data: "ay", options: "a{sv}"): # type: ignore ) raise e - @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 Characteristic(self) -> "o": # type: ignore + @dbus_property(PropertyAccess.READ, "Characteristic") + def _get_characteristic(self) -> "o": # type: ignore return self._characteristic_path - @dbus_property(PropertyAccess.READ) - def Flags(self) -> "as": # type: ignore + @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) diff --git a/bluez_peripheral/gatt/service.py b/bluez_peripheral/gatt/service.py index 2d461bd..c59b6a5 100644 --- a/bluez_peripheral/gatt/service.py +++ b/bluez_peripheral/gatt/service.py @@ -1,15 +1,14 @@ -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 - -import inspect -from uuid import UUID -from typing import Union, List, Optional +from dbus_fast.aio.message_bus import MessageBus +from dbus_fast.aio.proxy_object import ProxyInterface from .characteristic import characteristic -from ..uuid16 import UUID16 -from ..util import * +from ..uuid16 import UUID16, UUIDCompatible +from ..adapter import Adapter # See https://github.com/bluez/bluez/blob/master/doc/org.bluez.GattService.rst @@ -25,12 +24,14 @@ class Service(ServiceInterface): _INTERFACE = "org.bluez.GattService1" - 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) + member.set_service(self) # Some characteristics will occur multiple times due to different decorators. if not member in self._characteristics: @@ -38,17 +39,20 @@ def _populate(self): def __init__( self, - uuid: Union[str, bytes, UUID, UUID16, int], + uuid: UUIDCompatible, primary: bool = True, - includes: Collection["Service"] = [], + includes: Optional[Collection["Service"]] = None, ): # Make sure uuid is a uuid16. self._uuid = UUID16.parse_uuid(uuid) self._primary = primary self._characteristics: List[characteristic] = [] self._path: Optional[str] = None + if includes is None: + includes = [] self._includes = includes self._populate() + self._collection: Optional[ServiceCollection] = None super().__init__(self._INTERFACE) @@ -60,7 +64,7 @@ def is_registered(self) -> bool: """ return not self._path is None - def add_characteristic(self, char: characteristic): + def add_characteristic(self, char: characteristic) -> None: """Add the specified characteristic to this service declaration. Args: @@ -76,7 +80,7 @@ def add_characteristic(self, char: characteristic): self._characteristics.append(char) - def remove_characteristic(self, char: characteristic): + def remove_characteristic(self, char: characteristic) -> None: """Remove the specified characteristic from this service declaration. Args: @@ -92,7 +96,7 @@ def remove_characteristic(self, char: characteristic): self._characteristics.remove(char) - def _export(self, bus: MessageBus, path: str): + def _export(self, bus: MessageBus, path: str) -> None: self._path = path # Export this and number each child characteristic. @@ -102,7 +106,7 @@ def _export(self, bus: MessageBus, path: str): char._export(bus, path, i) i += 1 - def _unexport(self, bus: MessageBus): + def _unexport(self, bus: MessageBus) -> None: if self._path is None: return @@ -118,7 +122,7 @@ async def register( bus: MessageBus, path: str = "/com/spacecheese/bluez_peripheral", adapter: Optional[Adapter] = None, - ): + ) -> None: """Register this service as a standalone service. Using this multiple times will cause path conflicts. @@ -130,27 +134,25 @@ async def register( self._collection = ServiceCollection([self]) await self._collection.register(bus, path, adapter) - 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() - @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 = [] # Shouldn't be possible to call this before export. @@ -170,17 +172,20 @@ class ServiceCollection: _MANAGER_INTERFACE = "org.bluez.GattManager1" - def __init__(self, services: List[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._bus: Optional[MessageBus] self._path: Optional[str] = None self._adapter: Optional[Adapter] = None + if services is None: + services = [] self._services = services - def add_service(self, service: Service): + def add_service(self, service: Service) -> None: """Add the specified service to this service collection. Args: @@ -193,7 +198,7 @@ def add_service(self, service: Service): self._services.append(service) - def remove_service(self, service: Service): + def remove_service(self, service: Service) -> None: """Remove the specified service from this collection. Args: @@ -206,7 +211,11 @@ def remove_service(self, service: Service): self._services.remove(service) - async def _get_manager_interface(self): + async def _get_manager_interface(self) -> ProxyInterface: + if not self.is_registered(): + raise ValueError("Service is not registered to an adapter.") + assert self._adapter is not None + return self._adapter._proxy.get_interface(self._MANAGER_INTERFACE) def is_registered(self) -> bool: @@ -222,7 +231,7 @@ async def register( bus: MessageBus, path: str = "/com/spacecheese/bluez_peripheral", 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. @@ -244,24 +253,26 @@ async def register( # Number and export each service. i = 0 for service in self._services: - service._export(bus, self._path + "/service{:d}".format(i)) + service._export(bus, f"{self._path}/service{i}") i += 1 - class EmptyServiceInterface(ServiceInterface): + class _EmptyServiceInterface(ServiceInterface): pass # 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, {}) + bus.export(self._path, _EmptyServiceInterface(self._path.replace("/", ".")[1:])) + await manager.call_register_application(self._path, {}) # type: ignore - async def unregister(self): + async def unregister(self) -> None: """Unregister this service using the bluez service manager.""" if not self.is_registered(): return + assert self._bus is not None + assert self._path is not None manager = await self._get_manager_interface() - await manager.call_unregister_application(self._path) + await manager.call_unregister_application(self._path) # type: ignore for service in self._services: service._unexport(self._bus) diff --git a/bluez_peripheral/util.py b/bluez_peripheral/util.py index 829d824..9240783 100644 --- a/bluez_peripheral/util.py +++ b/bluez_peripheral/util.py @@ -1,18 +1,16 @@ -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.aio.proxy_object import ProxyObject +from dbus_fast import Variant +from dbus_fast.constants import BusType from dbus_fast.errors import DBusError +from dbus_fast.aio.message_bus import MessageBus + +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: @@ -52,120 +50,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() # 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): - """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): - """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() - - 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 next(iter(adapters)) - else: - raise ValueError("No bluetooth adapters could be found.") diff --git a/bluez_peripheral/uuid16.py b/bluez_peripheral/uuid16.py index 67858e3..bb0f461 100644 --- a/bluez_peripheral/uuid16.py +++ b/bluez_peripheral/uuid16.py @@ -1,7 +1,8 @@ import builtins - +from typing import Union, Optional from uuid import UUID -from typing import Optional, Union + +UUIDCompatible = Union[str, bytes, UUID, "UUID16", int] class UUID16: @@ -24,13 +25,12 @@ 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( "exactly one of the hex, bytes or int arguments must be given" ) - # All representations are converted to either a UUID128 or a 16-bit integer. time_low = None if hex is not None: @@ -39,24 +39,23 @@ 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("uuid bytes must be exactly either 2 or 16 bytes long") - - if int is not None: - if int < 2**16 and int >= 0: + elif int is not None: + if 0 <= int < 2**16: time_low = int else: uuid = UUID(int=int) if time_low is not None: self._uuid = UUID(fields=(time_low,) + self._FIELDS[1:]) - elif uuid is not None: + else: + assert uuid is not None if UUID16.is_in_range(uuid): self._uuid = uuid else: @@ -72,39 +71,32 @@ def is_in_range(cls, uuid: UUID) -> bool: 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, "UUID16"] - ) -> Union[UUID, "UUID16"]: + def parse_uuid(cls, uuid: UUIDCompatible) -> Union[UUID, "UUID16"]: """Attempts to parse a supplied UUID representation to a UUID16. If the resulting value is out of range a UUI128 will be returned instead.""" - if type(uuid) is UUID16: + if isinstance(uuid, UUID16): return uuid - elif type(uuid) is UUID: + if isinstance(uuid, UUID): if cls.is_in_range(uuid): return UUID16(uuid=uuid) - else: - return uuid - elif type(uuid) is str: + return uuid + if isinstance(uuid, str): try: return UUID16(hex=uuid) - except: + except ValueError: return UUID(hex=uuid) - elif type(uuid) is bytes: + if isinstance(uuid, builtins.bytes): try: return UUID16(bytes=uuid) - except: + except ValueError: return UUID(bytes=uuid) - elif 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") @@ -115,7 +107,7 @@ def uuid(self) -> UUID: return self._uuid @property - def int(self) -> int: + def int(self) -> builtins.int: """Returns the 16-bit integer value corresponding to this uuid16.""" return self._uuid.time_low & 0xFFFF @@ -130,18 +122,18 @@ def hex(self) -> str: 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/requirements.txt b/docs/requirements.txt index 3157cf5..9652cbd 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -3,4 +3,5 @@ sphinx sphinx-inline-tabs sphinxcontrib-spelling furo -m2r2 \ No newline at end of file +m2r2 +interrogate \ No newline at end of file diff --git a/docs/source/advertising.rst b/docs/source/advertising.rst index b57d5bc..8f844a3 100644 --- a/docs/source/advertising.rst +++ b/docs/source/advertising.rst @@ -4,7 +4,7 @@ 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_next.aio.MessageBus`. + The "message bus" referred to here is a :py:class:`dbus_fast.aio.MessageBus`. A minimal :py:class:`advert` requires: diff --git a/docs/source/conf.py b/docs/source/conf.py index b3b34e9..f639f79 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -92,6 +92,9 @@ def linkcode_resolve(domain, info): lines, lineno = inspect.getsourcelines(obj) except Exception: return None + + if src is None: + return None src = Path(src).relative_to(Path(__file__).parents[2]) @@ -111,16 +114,16 @@ def linkcode_resolve(domain, info): # Fix resolution of MessageBus class to where docs actually are. intersphinx_aliases = { - ("py:class", "dbus_next.aio.message_bus.MessageBus"): ( + ("py:class", "dbus_fast.aio.message_bus.MessageBus"): ( "py:class", - "dbus_next.aio.MessageBus", + "dbus_fast.aio.MessageBus", ), - ("py:class", "dbus_next.aio.proxy_object.ProxyObject"): ( + ("py:class", "dbus_fast.aio.proxy_object.ProxyObject"): ( "py:class", - "dbus_next.aio.ProxyObject", + "dbus_fast.aio.ProxyObject", ), - ("py:class", "dbus_next.errors.DBusError"): ("py:class", "dbus_next.DBusError"), - ("py:class", "dbus_next.signature.Variant"): ("py:class", "dbus_next.Variant"), + ("py:class", "dbus_fast.errors.DBusError"): ("py:class", "dbus_fast.DBusError"), + ("py:class", "dbus_fast.signature.Variant"): ("py:class", "dbus_fast.Variant"), } diff --git a/docs/source/pairing.rst b/docs/source/pairing.rst index 8d955f5..4109df3 100644 --- a/docs/source/pairing.rst +++ b/docs/source/pairing.rst @@ -20,7 +20,7 @@ Using an Agent -------------- .. hint:: - The "message bus" referred to here is a :py:class:`dbus_next.aio.MessageBus`. + The "message bus" referred to here is a :py:class:`dbus_fast.aio.MessageBus`. There are three potential sources of agents: @@ -81,7 +81,7 @@ There are three potential sources of agents: agent = TestAgent() await agent.register(bus) - 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_next services`). + 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 --------- diff --git a/docs/source/services.rst b/docs/source/services.rst index 7cbbd1c..5f7f89b 100644 --- a/docs/source/services.rst +++ b/docs/source/services.rst @@ -93,7 +93,7 @@ The :py:class:`@characteristic` 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_next.DBusError`. +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: @@ -126,7 +126,7 @@ Registering a Service 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_next.aio.MessageBus`. + 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: diff --git a/pyproject.toml b/pyproject.toml index d3f755d..ded4f36 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,10 +8,24 @@ build-backend = "setuptools.build_meta" [tool.setuptools_scm] -[tool.interrogate] -# Documenting these plays badly with sphinx -ignore-init-module = true -ignore-init-method = true +[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", + "duplicate-code", +] -ignore-private = true -ignore-semiprivate = true \ No newline at end of file +[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 \ No newline at end of file diff --git a/tests/gatt/test_characteristic.py b/tests/gatt/test_characteristic.py index 7fc1d16..21fe3d8 100644 --- a/tests/gatt/test_characteristic.py +++ b/tests/gatt/test_characteristic.py @@ -13,7 +13,7 @@ from bluez_peripheral.gatt.descriptor import descriptor from bluez_peripheral.gatt.service import Service -from dbus_next import Variant +from dbus_fast import Variant last_opts = None write_notify_char_val = None diff --git a/tests/test_advert.py b/tests/test_advert.py index 7cddbc5..ec49e3e 100644 --- a/tests/test_advert.py +++ b/tests/test_advert.py @@ -3,7 +3,8 @@ from tests.util import * from bluez_peripheral import get_message_bus -from bluez_peripheral.advert import Advertisement, PacketType, AdvertisingIncludes +from bluez_peripheral.advert import Advertisement, AdvertisingIncludes +from bluez_peripheral.flags import AdvertisingPacketType from uuid import UUID @@ -21,9 +22,9 @@ async def test_basic(self): advert = Advertisement( "Testing Device Name", ["180A", "180D"], - 0x0340, - 2, - packetType=PacketType.PERIPHERAL, + appearance=0x0340, + timeout=2, + packet_type=AdvertisingPacketType.PERIPHERAL, includes=AdvertisingIncludes.TX_POWER, ) @@ -55,9 +56,9 @@ async def test_includes_empty(self): advert = Advertisement( "Testing Device Name", ["180A", "180D"], - 0x0340, - 2, - packetType=PacketType.PERIPHERAL, + appearance=0x0340, + timeout=2, + packet_type=AdvertisingPacketType.PERIPHERAL, includes=AdvertisingIncludes.NONE, ) @@ -79,8 +80,8 @@ async def test_uuid128(self): advert = Advertisement( "Improv Test", [UUID("00467768-6228-2272-4663-277478268000")], - 0x0340, - 2, + appearance=0x0340, + timeout=2, ) async def inspector(path): diff --git a/tests/util.py b/tests/util.py index f402d68..7300453 100644 --- a/tests/util.py +++ b/tests/util.py @@ -10,8 +10,8 @@ get_message_bus, is_bluez_available, MessageBus, - Adapter, ) +from bluez_peripheral.adapter import Adapter from bluez_peripheral.uuid16 import UUID16 From 36a2707abee38ab2c0a15da3f33bfd21219daee7 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Wed, 20 Aug 2025 01:05:58 +0100 Subject: [PATCH 078/158] Fix pylint action --- .github/workflows/python-test.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index daaf5ef..461d926 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -99,8 +99,8 @@ jobs: run: | python -m pip install --upgrade pip pip install -r requirements.txt - python -m pylint bluez_peripheral - - name: Check type hints + pip install pylint + - name: Run lint checks run: | - python -m interrogate -vv + python -m pylint bluez_peripheral From 3aba16c26eddbe3ecd7b96117d731fac70862cbe Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Wed, 20 Aug 2025 01:20:19 +0100 Subject: [PATCH 079/158] Fix docstring warnings --- bluez_peripheral/gatt/characteristic.py | 4 ++-- bluez_peripheral/gatt/descriptor.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bluez_peripheral/gatt/characteristic.py b/bluez_peripheral/gatt/characteristic.py index fadebfa..a2eeab3 100644 --- a/bluez_peripheral/gatt/characteristic.py +++ b/bluez_peripheral/gatt/characteristic.py @@ -267,8 +267,8 @@ def _is_registered(self) -> bool: def set_service(self, service: Optional["Service"]) -> None: """Attaches this characteristic to the specified service. - .. warning:: - + + Warnings: Do not call this directly. Subclasses of the Service class will handle this automatically. """ self._service = service diff --git a/bluez_peripheral/gatt/descriptor.py b/bluez_peripheral/gatt/descriptor.py index dec11f7..0879204 100644 --- a/bluez_peripheral/gatt/descriptor.py +++ b/bluez_peripheral/gatt/descriptor.py @@ -181,8 +181,8 @@ def __call__( def set_service(self, service: Optional["Service"]) -> None: """Attaches this descriptor to the specified service. - .. warning:: + Warnings: Do not call this directly. Subclasses of the Service class will handle this automatically. """ self._service = service From 449f576a8e20524bbb92309bd7a14131e41b29f9 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Wed, 20 Aug 2025 01:23:06 +0100 Subject: [PATCH 080/158] Update apidoc listings --- bluez_peripheral/advert.py | 2 +- docs/Makefile | 2 ++ docs/source/advertising.rst | 2 +- docs/source/ref/bluez_peripheral.adapter.rst | 7 +++++++ docs/source/ref/bluez_peripheral.advert.rst | 2 +- docs/source/ref/bluez_peripheral.agent.rst | 2 +- docs/source/ref/bluez_peripheral.error.rst | 4 ++-- docs/source/ref/bluez_peripheral.flags.rst | 7 +++++++ .../ref/bluez_peripheral.gatt.characteristic.rst | 2 +- docs/source/ref/bluez_peripheral.gatt.descriptor.rst | 2 +- docs/source/ref/bluez_peripheral.gatt.rst | 10 +++++++++- docs/source/ref/bluez_peripheral.gatt.service.rst | 2 +- docs/source/ref/bluez_peripheral.rst | 12 +++++++++++- docs/source/ref/bluez_peripheral.util.rst | 2 +- docs/source/ref/bluez_peripheral.uuid16.rst | 2 +- 15 files changed, 47 insertions(+), 13 deletions(-) create mode 100644 docs/source/ref/bluez_peripheral.adapter.rst create mode 100644 docs/source/ref/bluez_peripheral.flags.rst diff --git a/bluez_peripheral/advert.py b/bluez_peripheral/advert.py index a4a594b..5494212 100644 --- a/bluez_peripheral/advert.py +++ b/bluez_peripheral/advert.py @@ -30,7 +30,7 @@ class Advertisement(ServiceInterface): 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. """ diff --git a/docs/Makefile b/docs/Makefile index 4264fe6..fe1383d 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -18,7 +18,9 @@ help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) apidoc: + rm -r "$(APIDOCDIR)" @$(SPHINXDOC) -o "$(APIDOCDIR)" "$(MODPATH)" $(SPHINXDOCOPTS) + rm "$(APIDOCDIR)/modules.rst" .PHONY: help Makefile apidoc spelling diff --git a/docs/source/advertising.rst b/docs/source/advertising.rst index 8f844a3..873e922 100644 --- a/docs/source/advertising.rst +++ b/docs/source/advertising.rst @@ -12,7 +12,7 @@ A minimal :py:class:`advert` requires: * 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). +* A reference to a specific bluetooth :py:class:`adapter` (since unlike with services, adverts are per-adapter). .. code-block:: python 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/bluez_peripheral.advert.rst b/docs/source/ref/bluez_peripheral.advert.rst index 111ee97..bbb8e2e 100644 --- a/docs/source/ref/bluez_peripheral.advert.rst +++ b/docs/source/ref/bluez_peripheral.advert.rst @@ -3,5 +3,5 @@ bluez\_peripheral.advert module .. automodule:: bluez_peripheral.advert :members: - :no-undoc-members: :show-inheritance: + :undoc-members: diff --git a/docs/source/ref/bluez_peripheral.agent.rst b/docs/source/ref/bluez_peripheral.agent.rst index 20bc362..47b9c25 100644 --- a/docs/source/ref/bluez_peripheral.agent.rst +++ b/docs/source/ref/bluez_peripheral.agent.rst @@ -3,5 +3,5 @@ bluez\_peripheral.agent module .. automodule:: bluez_peripheral.agent :members: - :no-undoc-members: :show-inheritance: + :undoc-members: diff --git a/docs/source/ref/bluez_peripheral.error.rst b/docs/source/ref/bluez_peripheral.error.rst index 0072581..d494221 100644 --- a/docs/source/ref/bluez_peripheral.error.rst +++ b/docs/source/ref/bluez_peripheral.error.rst @@ -1,7 +1,7 @@ bluez\_peripheral.error module -=============================== +============================== .. automodule:: bluez_peripheral.error :members: - :undoc-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.characteristic.rst b/docs/source/ref/bluez_peripheral.gatt.characteristic.rst index ca8204b..c707bff 100644 --- a/docs/source/ref/bluez_peripheral.gatt.characteristic.rst +++ b/docs/source/ref/bluez_peripheral.gatt.characteristic.rst @@ -3,6 +3,6 @@ bluez\_peripheral.gatt.characteristic module .. automodule:: bluez_peripheral.gatt.characteristic :members: - :no-undoc-members: :show-inheritance: + :undoc-members: :special-members: __call__ diff --git a/docs/source/ref/bluez_peripheral.gatt.descriptor.rst b/docs/source/ref/bluez_peripheral.gatt.descriptor.rst index 32ee68a..7772eb7 100644 --- a/docs/source/ref/bluez_peripheral.gatt.descriptor.rst +++ b/docs/source/ref/bluez_peripheral.gatt.descriptor.rst @@ -3,6 +3,6 @@ bluez\_peripheral.gatt.descriptor module .. automodule:: bluez_peripheral.gatt.descriptor :members: - :no-undoc-members: :show-inheritance: + :undoc-members: :special-members: __call__ diff --git a/docs/source/ref/bluez_peripheral.gatt.rst b/docs/source/ref/bluez_peripheral.gatt.rst index bc289e0..031838d 100644 --- a/docs/source/ref/bluez_peripheral.gatt.rst +++ b/docs/source/ref/bluez_peripheral.gatt.rst @@ -9,4 +9,12 @@ Submodules bluez_peripheral.gatt.characteristic bluez_peripheral.gatt.descriptor - bluez_peripheral.gatt.service \ No newline at end of file + bluez_peripheral.gatt.service + +Module contents +--------------- + +.. automodule:: bluez_peripheral.gatt + :members: + :show-inheritance: + :undoc-members: diff --git a/docs/source/ref/bluez_peripheral.gatt.service.rst b/docs/source/ref/bluez_peripheral.gatt.service.rst index 67a6986..11985c0 100644 --- a/docs/source/ref/bluez_peripheral.gatt.service.rst +++ b/docs/source/ref/bluez_peripheral.gatt.service.rst @@ -3,5 +3,5 @@ bluez\_peripheral.gatt.service module .. automodule:: bluez_peripheral.gatt.service :members: - :no-undoc-members: :show-inheritance: + :undoc-members: diff --git a/docs/source/ref/bluez_peripheral.rst b/docs/source/ref/bluez_peripheral.rst index 5b50fbf..fe111f1 100644 --- a/docs/source/ref/bluez_peripheral.rst +++ b/docs/source/ref/bluez_peripheral.rst @@ -15,8 +15,18 @@ Submodules .. toctree:: :maxdepth: 4 + bluez_peripheral.adapter bluez_peripheral.advert bluez_peripheral.agent bluez_peripheral.error + bluez_peripheral.flags bluez_peripheral.util - bluez_peripheral.uuid16 \ No newline at end of file + bluez_peripheral.uuid16 + +Module contents +--------------- + +.. automodule:: bluez_peripheral + :members: + :show-inheritance: + :undoc-members: diff --git a/docs/source/ref/bluez_peripheral.util.rst b/docs/source/ref/bluez_peripheral.util.rst index a990b93..3b5d7f3 100644 --- a/docs/source/ref/bluez_peripheral.util.rst +++ b/docs/source/ref/bluez_peripheral.util.rst @@ -3,5 +3,5 @@ bluez\_peripheral.util module .. automodule:: bluez_peripheral.util :members: - :undoc-members: :show-inheritance: + :undoc-members: diff --git a/docs/source/ref/bluez_peripheral.uuid16.rst b/docs/source/ref/bluez_peripheral.uuid16.rst index ebcada0..775aa20 100644 --- a/docs/source/ref/bluez_peripheral.uuid16.rst +++ b/docs/source/ref/bluez_peripheral.uuid16.rst @@ -3,5 +3,5 @@ bluez\_peripheral.uuid16 module .. automodule:: bluez_peripheral.uuid16 :members: - :undoc-members: :show-inheritance: + :undoc-members: From 67dca15579efc2ca4aa63667d1b731ad4cfff558 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Wed, 20 Aug 2025 01:24:53 +0100 Subject: [PATCH 081/158] Fix formatting --- bluez_peripheral/gatt/characteristic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bluez_peripheral/gatt/characteristic.py b/bluez_peripheral/gatt/characteristic.py index a2eeab3..b52bac4 100644 --- a/bluez_peripheral/gatt/characteristic.py +++ b/bluez_peripheral/gatt/characteristic.py @@ -267,7 +267,7 @@ def _is_registered(self) -> bool: def set_service(self, service: Optional["Service"]) -> None: """Attaches this characteristic to the specified service. - + Warnings: Do not call this directly. Subclasses of the Service class will handle this automatically. """ From 6d4fc32f4344e3f0856aa6165ab4fd83a771edb6 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Fri, 22 Aug 2025 22:38:23 +0100 Subject: [PATCH 082/158] Fix spell checking --- bluez_peripheral/error.py | 2 +- docs/source/spelling_wordlist.txt | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/bluez_peripheral/error.py b/bluez_peripheral/error.py index 53944b2..e77bfa8 100644 --- a/bluez_peripheral/error.py +++ b/bluez_peripheral/error.py @@ -37,7 +37,7 @@ def __init__(self, message: str): class NotAuthorizedError(DBusError): - """Raised when a requester is not authorized to perform the requsted operation.""" + """Raised when a requester is not authorized to perform the requested operation.""" def __init__(self, message: str): super().__init__("org.bluez.Error.NotAuthorized", message) diff --git a/docs/source/spelling_wordlist.txt b/docs/source/spelling_wordlist.txt index 00932ab..604f55d 100644 --- a/docs/source/spelling_wordlist.txt +++ b/docs/source/spelling_wordlist.txt @@ -6,6 +6,10 @@ getter getters discoverable bluetoothctl +bluetoothd +manpages +pairable +subclasses cli passcode indicatable From 7731b0995d4c8b29a62b6c76f27442fee6296871 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Fri, 22 Aug 2025 22:48:31 +0100 Subject: [PATCH 083/158] Fix typo is comment --- bluez_peripheral/uuid16.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bluez_peripheral/uuid16.py b/bluez_peripheral/uuid16.py index bb0f461..1a67ca4 100644 --- a/bluez_peripheral/uuid16.py +++ b/bluez_peripheral/uuid16.py @@ -76,7 +76,7 @@ def is_in_range(cls, uuid: UUID) -> bool: @classmethod def parse_uuid(cls, uuid: UUIDCompatible) -> Union[UUID, "UUID16"]: """Attempts to parse a supplied UUID representation to a UUID16. - If the resulting value is out of range a UUI128 will be returned instead.""" + If the resulting value is out of range a UUID128 will be returned instead.""" if isinstance(uuid, UUID16): return uuid if isinstance(uuid, UUID): From 5e7a83bbdcb0ddf6d65103a06fc59fcebd9ccf0e Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Fri, 22 Aug 2025 22:49:07 +0100 Subject: [PATCH 084/158] Remove workarounds in intersphinx config --- docs/source/conf.py | 39 --------------------------------------- 1 file changed, 39 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index f639f79..736d4e5 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -19,26 +19,6 @@ sys.path.insert(0, os.path.abspath(os.path.dirname(__file__) + "/../../")) - -# See https://github.com/sphinx-doc/sphinx/issues/5603 -def add_intersphinx_aliases_to_inv(app): - from sphinx.ext.intersphinx import InventoryAdapter - - inventories = InventoryAdapter(app.builder.env) - - for alias, target in app.config.intersphinx_aliases.items(): - alias_domain, alias_name = alias - target_domain, target_name = target - try: - found = inventories.main_inventory[target_domain][target_name] - try: - inventories.main_inventory[alias_domain][alias_name] = found - except KeyError: - continue - except KeyError: - continue - - # -- Project information ----------------------------------------------------- project = "bluez-peripheral" @@ -112,25 +92,6 @@ def linkcode_resolve(domain, info): "dbus_fast": ("https://dbus-fast.readthedocs.io/en/latest/", None), } -# Fix resolution of MessageBus class to where docs actually are. -intersphinx_aliases = { - ("py:class", "dbus_fast.aio.message_bus.MessageBus"): ( - "py:class", - "dbus_fast.aio.MessageBus", - ), - ("py:class", "dbus_fast.aio.proxy_object.ProxyObject"): ( - "py:class", - "dbus_fast.aio.ProxyObject", - ), - ("py:class", "dbus_fast.errors.DBusError"): ("py:class", "dbus_fast.DBusError"), - ("py:class", "dbus_fast.signature.Variant"): ("py:class", "dbus_fast.Variant"), -} - - -def setup(app): - app.add_config_value("intersphinx_aliases", {}, "env") - app.connect("builder-inited", add_intersphinx_aliases_to_inv) - # -- Options for HTML output ------------------------------------------------- From 7463e699a3e945deee474beaf8dd7e8d60978fe8 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Fri, 22 Aug 2025 23:08:34 +0100 Subject: [PATCH 085/158] Link to current ref source --- docs/source/conf.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 736d4e5..5b1690b 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -14,6 +14,7 @@ import sys import inspect import importlib +import subprocess from pathlib import Path from datetime import datetime @@ -51,6 +52,23 @@ nitpicky = True # -- 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 @@ -80,7 +98,7 @@ def linkcode_resolve(domain, info): return ( f"https://github.com/spacecheese/bluez_peripheral/" - f"blob/master/{src.as_posix()}#L{lineno}-L{lineno+len(lines)-1}" + f"blob/{GIT_REF}/{src.as_posix()}#L{lineno}-L{lineno+len(lines)-1}" ) # -- Napoleon ---------------------------------------------------------------- From f58837481e6e2d59d9fd2a886b2c688b239fad49 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Fri, 22 Aug 2025 23:21:19 +0100 Subject: [PATCH 086/158] Move remaining flags to common types module --- bluez_peripheral/adapter.py | 2 +- bluez_peripheral/advert.py | 6 +- bluez_peripheral/flags.py | 27 -- bluez_peripheral/gatt/__init__.py | 7 - bluez_peripheral/gatt/characteristic.py | 163 +--------- bluez_peripheral/gatt/descriptor.py | 97 +----- bluez_peripheral/types.py | 280 ++++++++++++++++++ docs/source/ref/bluez_peripheral.rst | 2 +- ...l.flags.rst => bluez_peripheral.types.rst} | 4 +- tests/test_advert.py | 2 +- 10 files changed, 293 insertions(+), 297 deletions(-) delete mode 100644 bluez_peripheral/flags.py create mode 100644 bluez_peripheral/types.py rename docs/source/ref/{bluez_peripheral.flags.rst => bluez_peripheral.types.rst} (55%) diff --git a/bluez_peripheral/adapter.py b/bluez_peripheral/adapter.py index 82c2eaf..41ecbe7 100644 --- a/bluez_peripheral/adapter.py +++ b/bluez_peripheral/adapter.py @@ -5,7 +5,7 @@ from dbus_fast.errors import InvalidIntrospectionError, InterfaceNotFoundError from .util import _kebab_to_shouting_snake -from .flags import AdvertisingIncludes +from .types import AdvertisingIncludes class Adapter: diff --git a/bluez_peripheral/advert.py b/bluez_peripheral/advert.py index 317ba19..10c43d3 100644 --- a/bluez_peripheral/advert.py +++ b/bluez_peripheral/advert.py @@ -10,8 +10,8 @@ from .uuid16 import UUID16, UUIDCompatible from .util import _snake_to_kebab from .adapter import Adapter -from .flags import AdvertisingIncludes -from .flags import AdvertisingPacketType +from .types import AdvertisingIncludes +from .types import AdvertisingPacketType class Advertisement(ServiceInterface): @@ -137,7 +137,7 @@ def _release(self): # type: ignore assert self._export_bus is not None assert self._export_path is not None self._export_bus.unexport(self._export_path, self._INTERFACE) - + async def unregister(self): """ Unregister this advertisement from bluez to stop advertising. diff --git a/bluez_peripheral/flags.py b/bluez_peripheral/flags.py deleted file mode 100644 index 626be11..0000000 --- a/bluez_peripheral/flags.py +++ /dev/null @@ -1,27 +0,0 @@ -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 ee660f7..06c75da 100644 --- a/bluez_peripheral/gatt/__init__.py +++ b/bluez_peripheral/gatt/__init__.py @@ -1,13 +1,6 @@ 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/characteristic.py b/bluez_peripheral/gatt/characteristic.py index b52bac4..4c8704e 100644 --- a/bluez_peripheral/gatt/characteristic.py +++ b/bluez_peripheral/gatt/characteristic.py @@ -1,178 +1,21 @@ import inspect -from enum import Enum, Flag, auto -from typing import Callable, Optional, Union, Awaitable, List, cast, Dict, TYPE_CHECKING +from typing import Callable, Optional, Union, Awaitable, List, cast, TYPE_CHECKING from dbus_fast.errors import DBusError from dbus_fast.constants import PropertyAccess from dbus_fast.service import ServiceInterface, method, dbus_property from dbus_fast.aio import MessageBus -from dbus_fast import Variant +from ..types import CharacteristicFlags, CharacteristicReadOptions, CharacteristicWriteOptions from .descriptor import descriptor as Descriptor, DescriptorFlags from ..uuid16 import UUID16, UUIDCompatible -from ..util import _snake_to_kebab, _getattr_variant +from ..util import _snake_to_kebab from ..error import NotSupportedError, FailedError if TYPE_CHECKING: from .service import Service -class CharacteristicReadOptions: - """Options supplied to characteristic read functions. - Generally you can ignore these unless you have a long characteristic (eg > 48 bytes) or you have some specific authorization requirements. - """ - - def __init__(self, options: Optional[Dict[str, Variant]] = None): - if options is None: - return - - 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: - """A byte offset to read the characteristic from until the end.""" - return self._offset - - @property - def mtu(self) -> Optional[int]: - """The exchanged Maximum Transfer Unit of the connection with the remote device or 0.""" - return self._mtu - - @property - def device(self) -> Optional[str]: - """The path of the remote device on the system dbus or None.""" - return self._device - - -class CharacteristicWriteType(Enum): - """Possible value of the :class:`CharacteristicWriteOptions`.type field""" - - COMMAND = 0 - """Write without response - """ - REQUEST = 1 - """Write with response - """ - RELIABLE = 2 - """Reliable Write - """ - - -class CharacteristicWriteOptions: - """Options supplied to characteristic write functions. - Generally you can ignore these unless you have a long characteristic (eg > 48 bytes) or you have some specific authorization requirements. - """ - - def __init__(self, options: Optional[Dict[str, Variant]] = None): - if options is None: - return - - t = _getattr_variant(options, "type", None) - self._type: Optional[CharacteristicWriteType] = None - if not t is None: - self._type = CharacteristicWriteType[t.upper()] - - 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) -> int: - """A byte offset to use when writing to this characteristic.""" - return self._offset - - @property - def type(self) -> Optional[CharacteristicWriteType]: - """The type of write operation requested or None.""" - return self._type - - @property - 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) -> str: - """The path of the remote device on the system dbus or None.""" - return self._device - - @property - def link(self) -> str: - """The link type.""" - return self._link - - @property - 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 `_. - """ - - INVALID = 0 - BROADCAST = auto() - """Characteristic value may be broadcast as a part of advertisements. - """ - READ = auto() - """Characteristic value may be read. - """ - WRITE_WITHOUT_RESPONSE = auto() - """Characteristic value may be written to with no confirmation required. - """ - WRITE = auto() - """Characteristic value may be written to and confirmation is required. - """ - NOTIFY = auto() - """Characteristic may be subscribed to in order to provide notification when its value changes. - Notification does not require acknowledgment. - """ - INDICATE = auto() - """Characteristic may be subscribed to in order to provide indication when its value changes. - Indication requires acknowledgment. - """ - AUTHENTICATED_SIGNED_WRITES = auto() - """Characteristic requires secure bonding. Values are authenticated using a client signature. - """ - EXTENDED_PROPERTIES = auto() - """The Characteristic Extended Properties Descriptor exists and contains the values of any extended properties. - Do not manually set this flag or attempt to define the Characteristic Extended Properties Descriptor. These are automatically - handled when a :class:`CharacteristicFlags.RELIABLE_WRITE` or :class:`CharacteristicFlags.WRITABLE_AUXILIARIES` flag is used. - """ - RELIABLE_WRITE = auto() - """The value to be written to the characteristic is verified by transmission back to the client before writing occurs. - """ - WRITABLE_AUXILIARIES = auto() - """The Characteristic User Description Descriptor exists and is writable by the client. - """ - ENCRYPT_READ = auto() - """The communicating devices have to be paired for the client to be able to read the characteristic. - After pairing the devices share a bond and the communication is encrypted. - """ - ENCRYPT_WRITE = auto() - """The communicating devices have to be paired for the client to be able to write the characteristic. - After pairing the devices share a bond and the communication is encrypted. - """ - ENCRYPT_AUTHENTICATED_READ = auto() - """""" - ENCRYPT_AUTHENTICATED_WRITE = auto() - """""" - SECURE_READ = auto() - """""" - SECURE_WRITE = auto() - """""" - AUTHORIZE = auto() - """""" - - GetterType = Union[ Callable[["Service", CharacteristicReadOptions], bytes], Callable[["Service", CharacteristicReadOptions], Awaitable[bytes]], diff --git a/bluez_peripheral/gatt/descriptor.py b/bluez_peripheral/gatt/descriptor.py index 0879204..28c76fd 100644 --- a/bluez_peripheral/gatt/descriptor.py +++ b/bluez_peripheral/gatt/descriptor.py @@ -1,113 +1,20 @@ import inspect -from enum import Flag, auto from typing import Callable, Union, Awaitable, Optional, Dict, TYPE_CHECKING, cast from dbus_fast.errors import DBusError -from dbus_fast import Variant from dbus_fast.aio.message_bus import MessageBus from dbus_fast.service import ServiceInterface, method, dbus_property from dbus_fast.constants import PropertyAccess +from ..types import DescriptorFlags, DescriptorReadOptions, DescriptorWriteOptions from ..uuid16 import UUID16, UUIDCompatible -from ..util import _snake_to_kebab, _getattr_variant +from ..util import _snake_to_kebab from ..error import FailedError if TYPE_CHECKING: from .service import Service -class DescriptorReadOptions: - """Options supplied to descriptor read functions. - 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: Optional[Dict[str, Variant]] = None): - if options is None: - return - - 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) -> int: - """A byte offset to use when writing to this descriptor.""" - return cast(int, self._offset) - - @property - def link(self) -> str: - """The link type.""" - return cast(str, self._link) - - @property - def device(self) -> str: - """The path of the remote device on the system dbus or None.""" - return cast(str, self._device) - - -class DescriptorWriteOptions: - """Options supplied to descriptor write functions. - 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: Optional[Dict[str, Variant]] = None): - if options is None: - return - - 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) -> int: - """A byte offset to use when writing to this descriptor.""" - return cast(int, self._offset) - - @property - def device(self) -> str: - """The path of the remote device on the system dbus or None.""" - return cast(str, self._device) - - @property - def link(self) -> str: - """The link type.""" - return cast(str, self._link) - - @property - def prepare_authorize(self) -> bool: - """True if prepare authorization request. False otherwise.""" - 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 `_. - """ - - INVALID = 0 - READ = auto() - """Descriptor may be read. - """ - WRITE = auto() - """Descriptor may be written to. - """ - ENCRYPT_READ = auto() - """""" - ENCRYPT_WRITE = auto() - """""" - ENCRYPT_AUTHENTICATED_READ = auto() - """""" - ENCRYPT_AUTHENTICATED_WRITE = auto() - """""" - SECURE_READ = auto() - """""" - SECURE_WRITE = auto() - """""" - AUTHORIZE = auto() - """""" - - GetterType = Union[ Callable[["Service", DescriptorReadOptions], bytes], Callable[["Service", DescriptorReadOptions], Awaitable[bytes]], diff --git a/bluez_peripheral/types.py b/bluez_peripheral/types.py new file mode 100644 index 0000000..022afe9 --- /dev/null +++ b/bluez_peripheral/types.py @@ -0,0 +1,280 @@ +from enum import Enum, Flag, auto +from typing import Optional, cast, Dict + +from dbus_fast import Variant + +from .util import _getattr_variant + + +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. + """ + + +class CharacteristicReadOptions: + """Options supplied to characteristic read functions. + Generally you can ignore these unless you have a long characteristic (eg > 48 bytes) or you have some specific authorization requirements. + """ + + def __init__(self, options: Optional[Dict[str, Variant]] = None): + if options is None: + return + + 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: + """A byte offset to read the characteristic from until the end.""" + return self._offset + + @property + def mtu(self) -> Optional[int]: + """The exchanged Maximum Transfer Unit of the connection with the remote device or 0.""" + return self._mtu + + @property + def device(self) -> Optional[str]: + """The path of the remote device on the system dbus or None.""" + return self._device + + +class CharacteristicWriteType(Enum): + """Possible value of the :class:`CharacteristicWriteOptions`.type field""" + + COMMAND = 0 + """Write without response + """ + REQUEST = 1 + """Write with response + """ + RELIABLE = 2 + """Reliable Write + """ + + +class CharacteristicWriteOptions: + """Options supplied to characteristic write functions. + Generally you can ignore these unless you have a long characteristic (eg > 48 bytes) or you have some specific authorization requirements. + """ + + def __init__(self, options: Optional[Dict[str, Variant]] = None): + if options is None: + return + + t = _getattr_variant(options, "type", None) + self._type: Optional[CharacteristicWriteType] = None + if not t is None: + self._type = CharacteristicWriteType[t.upper()] + + 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) -> int: + """A byte offset to use when writing to this characteristic.""" + return self._offset + + @property + def type(self) -> Optional[CharacteristicWriteType]: + """The type of write operation requested or None.""" + return self._type + + @property + 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) -> str: + """The path of the remote device on the system dbus or None.""" + return self._device + + @property + def link(self) -> str: + """The link type.""" + return self._link + + @property + 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 `_. + """ + + INVALID = 0 + BROADCAST = auto() + """Characteristic value may be broadcast as a part of advertisements. + """ + READ = auto() + """Characteristic value may be read. + """ + WRITE_WITHOUT_RESPONSE = auto() + """Characteristic value may be written to with no confirmation required. + """ + WRITE = auto() + """Characteristic value may be written to and confirmation is required. + """ + NOTIFY = auto() + """Characteristic may be subscribed to in order to provide notification when its value changes. + Notification does not require acknowledgment. + """ + INDICATE = auto() + """Characteristic may be subscribed to in order to provide indication when its value changes. + Indication requires acknowledgment. + """ + AUTHENTICATED_SIGNED_WRITES = auto() + """Characteristic requires secure bonding. Values are authenticated using a client signature. + """ + EXTENDED_PROPERTIES = auto() + """The Characteristic Extended Properties Descriptor exists and contains the values of any extended properties. + Do not manually set this flag or attempt to define the Characteristic Extended Properties Descriptor. These are automatically + handled when a :class:`CharacteristicFlags.RELIABLE_WRITE` or :class:`CharacteristicFlags.WRITABLE_AUXILIARIES` flag is used. + """ + RELIABLE_WRITE = auto() + """The value to be written to the characteristic is verified by transmission back to the client before writing occurs. + """ + WRITABLE_AUXILIARIES = auto() + """The Characteristic User Description Descriptor exists and is writable by the client. + """ + ENCRYPT_READ = auto() + """The communicating devices have to be paired for the client to be able to read the characteristic. + After pairing the devices share a bond and the communication is encrypted. + """ + ENCRYPT_WRITE = auto() + """The communicating devices have to be paired for the client to be able to write the characteristic. + After pairing the devices share a bond and the communication is encrypted. + """ + ENCRYPT_AUTHENTICATED_READ = auto() + """""" + ENCRYPT_AUTHENTICATED_WRITE = auto() + """""" + SECURE_READ = auto() + """""" + SECURE_WRITE = auto() + """""" + AUTHORIZE = auto() + """""" + + +class DescriptorReadOptions: + """Options supplied to descriptor read functions. + 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: Optional[Dict[str, Variant]] = None): + if options is None: + return + + 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) -> int: + """A byte offset to use when writing to this descriptor.""" + return cast(int, self._offset) + + @property + def link(self) -> str: + """The link type.""" + return cast(str, self._link) + + @property + def device(self) -> str: + """The path of the remote device on the system dbus or None.""" + return cast(str, self._device) + + +class DescriptorWriteOptions: + """Options supplied to descriptor write functions. + 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: Optional[Dict[str, Variant]] = None): + if options is None: + return + + 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) -> int: + """A byte offset to use when writing to this descriptor.""" + return cast(int, self._offset) + + @property + def device(self) -> str: + """The path of the remote device on the system dbus or None.""" + return cast(str, self._device) + + @property + def link(self) -> str: + """The link type.""" + return cast(str, self._link) + + @property + def prepare_authorize(self) -> bool: + """True if prepare authorization request. False otherwise.""" + 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 `_. + """ + + INVALID = 0 + READ = auto() + """Descriptor may be read. + """ + WRITE = auto() + """Descriptor may be written to. + """ + ENCRYPT_READ = auto() + """""" + ENCRYPT_WRITE = auto() + """""" + ENCRYPT_AUTHENTICATED_READ = auto() + """""" + ENCRYPT_AUTHENTICATED_WRITE = auto() + """""" + SECURE_READ = auto() + """""" + SECURE_WRITE = auto() + """""" + AUTHORIZE = auto() + """""" diff --git a/docs/source/ref/bluez_peripheral.rst b/docs/source/ref/bluez_peripheral.rst index fe111f1..c66ee5e 100644 --- a/docs/source/ref/bluez_peripheral.rst +++ b/docs/source/ref/bluez_peripheral.rst @@ -19,7 +19,7 @@ Submodules bluez_peripheral.advert bluez_peripheral.agent bluez_peripheral.error - bluez_peripheral.flags + bluez_peripheral.types bluez_peripheral.util bluez_peripheral.uuid16 diff --git a/docs/source/ref/bluez_peripheral.flags.rst b/docs/source/ref/bluez_peripheral.types.rst similarity index 55% rename from docs/source/ref/bluez_peripheral.flags.rst rename to docs/source/ref/bluez_peripheral.types.rst index a5796ef..463f226 100644 --- a/docs/source/ref/bluez_peripheral.flags.rst +++ b/docs/source/ref/bluez_peripheral.types.rst @@ -1,7 +1,7 @@ -bluez\_peripheral.flags module +bluez\_peripheral.types module ============================== -.. automodule:: bluez_peripheral.flags +.. automodule:: bluez_peripheral.types :members: :show-inheritance: :undoc-members: diff --git a/tests/test_advert.py b/tests/test_advert.py index ec49e3e..73c7fcb 100644 --- a/tests/test_advert.py +++ b/tests/test_advert.py @@ -4,7 +4,7 @@ from tests.util import * from bluez_peripheral import get_message_bus from bluez_peripheral.advert import Advertisement, AdvertisingIncludes -from bluez_peripheral.flags import AdvertisingPacketType +from bluez_peripheral.types import AdvertisingPacketType from uuid import UUID From 6be79f9ad0eb011c5d7ba7df50e4081f3e97cfad Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Fri, 22 Aug 2025 23:25:16 +0100 Subject: [PATCH 087/158] Revert "Move remaining flags to common types module" This reverts commit f58837481e6e2d59d9fd2a886b2c688b239fad49. --- bluez_peripheral/adapter.py | 2 +- bluez_peripheral/advert.py | 6 +- bluez_peripheral/flags.py | 27 ++ bluez_peripheral/gatt/__init__.py | 7 + bluez_peripheral/gatt/characteristic.py | 163 +++++++++- bluez_peripheral/gatt/descriptor.py | 97 +++++- bluez_peripheral/types.py | 280 ------------------ ...l.types.rst => bluez_peripheral.flags.rst} | 4 +- docs/source/ref/bluez_peripheral.rst | 2 +- tests/test_advert.py | 2 +- 10 files changed, 297 insertions(+), 293 deletions(-) create mode 100644 bluez_peripheral/flags.py delete mode 100644 bluez_peripheral/types.py rename docs/source/ref/{bluez_peripheral.types.rst => bluez_peripheral.flags.rst} (55%) diff --git a/bluez_peripheral/adapter.py b/bluez_peripheral/adapter.py index 41ecbe7..82c2eaf 100644 --- a/bluez_peripheral/adapter.py +++ b/bluez_peripheral/adapter.py @@ -5,7 +5,7 @@ from dbus_fast.errors import InvalidIntrospectionError, InterfaceNotFoundError from .util import _kebab_to_shouting_snake -from .types import AdvertisingIncludes +from .flags import AdvertisingIncludes class Adapter: diff --git a/bluez_peripheral/advert.py b/bluez_peripheral/advert.py index 10c43d3..317ba19 100644 --- a/bluez_peripheral/advert.py +++ b/bluez_peripheral/advert.py @@ -10,8 +10,8 @@ from .uuid16 import UUID16, UUIDCompatible from .util import _snake_to_kebab from .adapter import Adapter -from .types import AdvertisingIncludes -from .types import AdvertisingPacketType +from .flags import AdvertisingIncludes +from .flags import AdvertisingPacketType class Advertisement(ServiceInterface): @@ -137,7 +137,7 @@ def _release(self): # type: ignore assert self._export_bus is not None assert self._export_path is not None self._export_bus.unexport(self._export_path, self._INTERFACE) - + async def unregister(self): """ Unregister this advertisement from bluez to stop advertising. 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 06c75da..ee660f7 100644 --- a/bluez_peripheral/gatt/__init__.py +++ b/bluez_peripheral/gatt/__init__.py @@ -1,6 +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/characteristic.py b/bluez_peripheral/gatt/characteristic.py index 4c8704e..b52bac4 100644 --- a/bluez_peripheral/gatt/characteristic.py +++ b/bluez_peripheral/gatt/characteristic.py @@ -1,21 +1,178 @@ import inspect -from typing import Callable, Optional, Union, Awaitable, List, cast, TYPE_CHECKING +from enum import Enum, Flag, auto +from typing import Callable, Optional, Union, Awaitable, List, cast, Dict, TYPE_CHECKING from dbus_fast.errors import DBusError from dbus_fast.constants import PropertyAccess from dbus_fast.service import ServiceInterface, method, dbus_property from dbus_fast.aio import MessageBus +from dbus_fast import Variant -from ..types import CharacteristicFlags, CharacteristicReadOptions, CharacteristicWriteOptions from .descriptor import descriptor as Descriptor, DescriptorFlags from ..uuid16 import UUID16, UUIDCompatible -from ..util import _snake_to_kebab +from ..util import _snake_to_kebab, _getattr_variant from ..error import NotSupportedError, FailedError if TYPE_CHECKING: from .service import Service +class CharacteristicReadOptions: + """Options supplied to characteristic read functions. + Generally you can ignore these unless you have a long characteristic (eg > 48 bytes) or you have some specific authorization requirements. + """ + + def __init__(self, options: Optional[Dict[str, Variant]] = None): + if options is None: + return + + 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: + """A byte offset to read the characteristic from until the end.""" + return self._offset + + @property + def mtu(self) -> Optional[int]: + """The exchanged Maximum Transfer Unit of the connection with the remote device or 0.""" + return self._mtu + + @property + def device(self) -> Optional[str]: + """The path of the remote device on the system dbus or None.""" + return self._device + + +class CharacteristicWriteType(Enum): + """Possible value of the :class:`CharacteristicWriteOptions`.type field""" + + COMMAND = 0 + """Write without response + """ + REQUEST = 1 + """Write with response + """ + RELIABLE = 2 + """Reliable Write + """ + + +class CharacteristicWriteOptions: + """Options supplied to characteristic write functions. + Generally you can ignore these unless you have a long characteristic (eg > 48 bytes) or you have some specific authorization requirements. + """ + + def __init__(self, options: Optional[Dict[str, Variant]] = None): + if options is None: + return + + t = _getattr_variant(options, "type", None) + self._type: Optional[CharacteristicWriteType] = None + if not t is None: + self._type = CharacteristicWriteType[t.upper()] + + 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) -> int: + """A byte offset to use when writing to this characteristic.""" + return self._offset + + @property + def type(self) -> Optional[CharacteristicWriteType]: + """The type of write operation requested or None.""" + return self._type + + @property + 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) -> str: + """The path of the remote device on the system dbus or None.""" + return self._device + + @property + def link(self) -> str: + """The link type.""" + return self._link + + @property + 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 `_. + """ + + INVALID = 0 + BROADCAST = auto() + """Characteristic value may be broadcast as a part of advertisements. + """ + READ = auto() + """Characteristic value may be read. + """ + WRITE_WITHOUT_RESPONSE = auto() + """Characteristic value may be written to with no confirmation required. + """ + WRITE = auto() + """Characteristic value may be written to and confirmation is required. + """ + NOTIFY = auto() + """Characteristic may be subscribed to in order to provide notification when its value changes. + Notification does not require acknowledgment. + """ + INDICATE = auto() + """Characteristic may be subscribed to in order to provide indication when its value changes. + Indication requires acknowledgment. + """ + AUTHENTICATED_SIGNED_WRITES = auto() + """Characteristic requires secure bonding. Values are authenticated using a client signature. + """ + EXTENDED_PROPERTIES = auto() + """The Characteristic Extended Properties Descriptor exists and contains the values of any extended properties. + Do not manually set this flag or attempt to define the Characteristic Extended Properties Descriptor. These are automatically + handled when a :class:`CharacteristicFlags.RELIABLE_WRITE` or :class:`CharacteristicFlags.WRITABLE_AUXILIARIES` flag is used. + """ + RELIABLE_WRITE = auto() + """The value to be written to the characteristic is verified by transmission back to the client before writing occurs. + """ + WRITABLE_AUXILIARIES = auto() + """The Characteristic User Description Descriptor exists and is writable by the client. + """ + ENCRYPT_READ = auto() + """The communicating devices have to be paired for the client to be able to read the characteristic. + After pairing the devices share a bond and the communication is encrypted. + """ + ENCRYPT_WRITE = auto() + """The communicating devices have to be paired for the client to be able to write the characteristic. + After pairing the devices share a bond and the communication is encrypted. + """ + ENCRYPT_AUTHENTICATED_READ = auto() + """""" + ENCRYPT_AUTHENTICATED_WRITE = auto() + """""" + SECURE_READ = auto() + """""" + SECURE_WRITE = auto() + """""" + AUTHORIZE = auto() + """""" + + GetterType = Union[ Callable[["Service", CharacteristicReadOptions], bytes], Callable[["Service", CharacteristicReadOptions], Awaitable[bytes]], diff --git a/bluez_peripheral/gatt/descriptor.py b/bluez_peripheral/gatt/descriptor.py index 28c76fd..0879204 100644 --- a/bluez_peripheral/gatt/descriptor.py +++ b/bluez_peripheral/gatt/descriptor.py @@ -1,20 +1,113 @@ import inspect +from enum import Flag, auto from typing import Callable, Union, Awaitable, Optional, Dict, TYPE_CHECKING, cast from dbus_fast.errors import DBusError +from dbus_fast import Variant from dbus_fast.aio.message_bus import MessageBus from dbus_fast.service import ServiceInterface, method, dbus_property from dbus_fast.constants import PropertyAccess -from ..types import DescriptorFlags, DescriptorReadOptions, DescriptorWriteOptions from ..uuid16 import UUID16, UUIDCompatible -from ..util import _snake_to_kebab +from ..util import _snake_to_kebab, _getattr_variant from ..error import FailedError if TYPE_CHECKING: from .service import Service +class DescriptorReadOptions: + """Options supplied to descriptor read functions. + 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: Optional[Dict[str, Variant]] = None): + if options is None: + return + + 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) -> int: + """A byte offset to use when writing to this descriptor.""" + return cast(int, self._offset) + + @property + def link(self) -> str: + """The link type.""" + return cast(str, self._link) + + @property + def device(self) -> str: + """The path of the remote device on the system dbus or None.""" + return cast(str, self._device) + + +class DescriptorWriteOptions: + """Options supplied to descriptor write functions. + 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: Optional[Dict[str, Variant]] = None): + if options is None: + return + + 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) -> int: + """A byte offset to use when writing to this descriptor.""" + return cast(int, self._offset) + + @property + def device(self) -> str: + """The path of the remote device on the system dbus or None.""" + return cast(str, self._device) + + @property + def link(self) -> str: + """The link type.""" + return cast(str, self._link) + + @property + def prepare_authorize(self) -> bool: + """True if prepare authorization request. False otherwise.""" + 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 `_. + """ + + INVALID = 0 + READ = auto() + """Descriptor may be read. + """ + WRITE = auto() + """Descriptor may be written to. + """ + ENCRYPT_READ = auto() + """""" + ENCRYPT_WRITE = auto() + """""" + ENCRYPT_AUTHENTICATED_READ = auto() + """""" + ENCRYPT_AUTHENTICATED_WRITE = auto() + """""" + SECURE_READ = auto() + """""" + SECURE_WRITE = auto() + """""" + AUTHORIZE = auto() + """""" + + GetterType = Union[ Callable[["Service", DescriptorReadOptions], bytes], Callable[["Service", DescriptorReadOptions], Awaitable[bytes]], diff --git a/bluez_peripheral/types.py b/bluez_peripheral/types.py deleted file mode 100644 index 022afe9..0000000 --- a/bluez_peripheral/types.py +++ /dev/null @@ -1,280 +0,0 @@ -from enum import Enum, Flag, auto -from typing import Optional, cast, Dict - -from dbus_fast import Variant - -from .util import _getattr_variant - - -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. - """ - - -class CharacteristicReadOptions: - """Options supplied to characteristic read functions. - Generally you can ignore these unless you have a long characteristic (eg > 48 bytes) or you have some specific authorization requirements. - """ - - def __init__(self, options: Optional[Dict[str, Variant]] = None): - if options is None: - return - - 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: - """A byte offset to read the characteristic from until the end.""" - return self._offset - - @property - def mtu(self) -> Optional[int]: - """The exchanged Maximum Transfer Unit of the connection with the remote device or 0.""" - return self._mtu - - @property - def device(self) -> Optional[str]: - """The path of the remote device on the system dbus or None.""" - return self._device - - -class CharacteristicWriteType(Enum): - """Possible value of the :class:`CharacteristicWriteOptions`.type field""" - - COMMAND = 0 - """Write without response - """ - REQUEST = 1 - """Write with response - """ - RELIABLE = 2 - """Reliable Write - """ - - -class CharacteristicWriteOptions: - """Options supplied to characteristic write functions. - Generally you can ignore these unless you have a long characteristic (eg > 48 bytes) or you have some specific authorization requirements. - """ - - def __init__(self, options: Optional[Dict[str, Variant]] = None): - if options is None: - return - - t = _getattr_variant(options, "type", None) - self._type: Optional[CharacteristicWriteType] = None - if not t is None: - self._type = CharacteristicWriteType[t.upper()] - - 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) -> int: - """A byte offset to use when writing to this characteristic.""" - return self._offset - - @property - def type(self) -> Optional[CharacteristicWriteType]: - """The type of write operation requested or None.""" - return self._type - - @property - 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) -> str: - """The path of the remote device on the system dbus or None.""" - return self._device - - @property - def link(self) -> str: - """The link type.""" - return self._link - - @property - 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 `_. - """ - - INVALID = 0 - BROADCAST = auto() - """Characteristic value may be broadcast as a part of advertisements. - """ - READ = auto() - """Characteristic value may be read. - """ - WRITE_WITHOUT_RESPONSE = auto() - """Characteristic value may be written to with no confirmation required. - """ - WRITE = auto() - """Characteristic value may be written to and confirmation is required. - """ - NOTIFY = auto() - """Characteristic may be subscribed to in order to provide notification when its value changes. - Notification does not require acknowledgment. - """ - INDICATE = auto() - """Characteristic may be subscribed to in order to provide indication when its value changes. - Indication requires acknowledgment. - """ - AUTHENTICATED_SIGNED_WRITES = auto() - """Characteristic requires secure bonding. Values are authenticated using a client signature. - """ - EXTENDED_PROPERTIES = auto() - """The Characteristic Extended Properties Descriptor exists and contains the values of any extended properties. - Do not manually set this flag or attempt to define the Characteristic Extended Properties Descriptor. These are automatically - handled when a :class:`CharacteristicFlags.RELIABLE_WRITE` or :class:`CharacteristicFlags.WRITABLE_AUXILIARIES` flag is used. - """ - RELIABLE_WRITE = auto() - """The value to be written to the characteristic is verified by transmission back to the client before writing occurs. - """ - WRITABLE_AUXILIARIES = auto() - """The Characteristic User Description Descriptor exists and is writable by the client. - """ - ENCRYPT_READ = auto() - """The communicating devices have to be paired for the client to be able to read the characteristic. - After pairing the devices share a bond and the communication is encrypted. - """ - ENCRYPT_WRITE = auto() - """The communicating devices have to be paired for the client to be able to write the characteristic. - After pairing the devices share a bond and the communication is encrypted. - """ - ENCRYPT_AUTHENTICATED_READ = auto() - """""" - ENCRYPT_AUTHENTICATED_WRITE = auto() - """""" - SECURE_READ = auto() - """""" - SECURE_WRITE = auto() - """""" - AUTHORIZE = auto() - """""" - - -class DescriptorReadOptions: - """Options supplied to descriptor read functions. - 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: Optional[Dict[str, Variant]] = None): - if options is None: - return - - 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) -> int: - """A byte offset to use when writing to this descriptor.""" - return cast(int, self._offset) - - @property - def link(self) -> str: - """The link type.""" - return cast(str, self._link) - - @property - def device(self) -> str: - """The path of the remote device on the system dbus or None.""" - return cast(str, self._device) - - -class DescriptorWriteOptions: - """Options supplied to descriptor write functions. - 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: Optional[Dict[str, Variant]] = None): - if options is None: - return - - 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) -> int: - """A byte offset to use when writing to this descriptor.""" - return cast(int, self._offset) - - @property - def device(self) -> str: - """The path of the remote device on the system dbus or None.""" - return cast(str, self._device) - - @property - def link(self) -> str: - """The link type.""" - return cast(str, self._link) - - @property - def prepare_authorize(self) -> bool: - """True if prepare authorization request. False otherwise.""" - 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 `_. - """ - - INVALID = 0 - READ = auto() - """Descriptor may be read. - """ - WRITE = auto() - """Descriptor may be written to. - """ - ENCRYPT_READ = auto() - """""" - ENCRYPT_WRITE = auto() - """""" - ENCRYPT_AUTHENTICATED_READ = auto() - """""" - ENCRYPT_AUTHENTICATED_WRITE = auto() - """""" - SECURE_READ = auto() - """""" - SECURE_WRITE = auto() - """""" - AUTHORIZE = auto() - """""" diff --git a/docs/source/ref/bluez_peripheral.types.rst b/docs/source/ref/bluez_peripheral.flags.rst similarity index 55% rename from docs/source/ref/bluez_peripheral.types.rst rename to docs/source/ref/bluez_peripheral.flags.rst index 463f226..a5796ef 100644 --- a/docs/source/ref/bluez_peripheral.types.rst +++ b/docs/source/ref/bluez_peripheral.flags.rst @@ -1,7 +1,7 @@ -bluez\_peripheral.types module +bluez\_peripheral.flags module ============================== -.. automodule:: bluez_peripheral.types +.. automodule:: bluez_peripheral.flags :members: :show-inheritance: :undoc-members: diff --git a/docs/source/ref/bluez_peripheral.rst b/docs/source/ref/bluez_peripheral.rst index c66ee5e..fe111f1 100644 --- a/docs/source/ref/bluez_peripheral.rst +++ b/docs/source/ref/bluez_peripheral.rst @@ -19,7 +19,7 @@ Submodules bluez_peripheral.advert bluez_peripheral.agent bluez_peripheral.error - bluez_peripheral.types + bluez_peripheral.flags bluez_peripheral.util bluez_peripheral.uuid16 diff --git a/tests/test_advert.py b/tests/test_advert.py index 73c7fcb..ec49e3e 100644 --- a/tests/test_advert.py +++ b/tests/test_advert.py @@ -4,7 +4,7 @@ from tests.util import * from bluez_peripheral import get_message_bus from bluez_peripheral.advert import Advertisement, AdvertisingIncludes -from bluez_peripheral.types import AdvertisingPacketType +from bluez_peripheral.flags import AdvertisingPacketType from uuid import UUID From e20c60320a2570f44d31afd6e514ba44b5c6db6a Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Fri, 22 Aug 2025 23:41:22 +0100 Subject: [PATCH 088/158] Fix lint issues --- bluez_peripheral/advert.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bluez_peripheral/advert.py b/bluez_peripheral/advert.py index 317ba19..be98865 100644 --- a/bluez_peripheral/advert.py +++ b/bluez_peripheral/advert.py @@ -32,7 +32,7 @@ class Advertisement(ServiceInterface): includes: Fields that can be optionally included in the advertising packet. Only the :class:`bluez_peripheral.flags.AdvertisingIncludes.TX_POWER` flag seems to work correctly with bluez. duration: Duration of the advert when multiple adverts are ongoing. - 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" @@ -137,8 +137,8 @@ def _release(self): # type: ignore assert self._export_bus is not None assert self._export_path is not None self._export_bus.unexport(self._export_path, self._INTERFACE) - - async def unregister(self): + + async def unregister(self) -> None: """ Unregister this advertisement from bluez to stop advertising. """ @@ -147,13 +147,13 @@ async def unregister(self): interface = self._adapter._proxy.get_interface(self._MANAGER_INTERFACE) - await interface.call_unregister_advertisement(self._exportPath) + await interface.call_unregister_advertisement(self._exportPath) # type: ignore self._exportBus = None 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, "Type") def _get_type(self) -> "s": # type: ignore From 0005e29d8c9c94f328a4fb20b622ad936e23027a Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Fri, 22 Aug 2025 23:59:05 +0100 Subject: [PATCH 089/158] Fix typo in characteristic docstring --- bluez_peripheral/gatt/characteristic.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bluez_peripheral/gatt/characteristic.py b/bluez_peripheral/gatt/characteristic.py index b52bac4..53850a4 100644 --- a/bluez_peripheral/gatt/characteristic.py +++ b/bluez_peripheral/gatt/characteristic.py @@ -238,8 +238,8 @@ def __call__( """A decorator for characteristic value getters. Args: - get: The getter function for this characteristic. - set: The setter function for this characteristic. + getter_func: The getter function for this characteristic. + setter_func: The setter function for this characteristic. Returns: This characteristic. From 5b7ad8804405f693fdf03d85272e687dc97229cd Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Fri, 22 Aug 2025 23:59:24 +0100 Subject: [PATCH 090/158] Fix typo in Advertisement __init__ docstring --- bluez_peripheral/advert.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bluez_peripheral/advert.py b/bluez_peripheral/advert.py index be98865..e93ce58 100644 --- a/bluez_peripheral/advert.py +++ b/bluez_peripheral/advert.py @@ -32,7 +32,7 @@ class Advertisement(ServiceInterface): includes: Fields that can be optionally included in the advertising packet. Only the :class:`bluez_peripheral.flags.AdvertisingIncludes.TX_POWER` flag seems to work correctly with bluez. duration: Duration of the advert when multiple adverts are ongoing. - _release_callback: A function to call when the advert release function is called. + release_callback: A function to call when the advert release function is called. """ _INTERFACE = "org.bluez.LEAdvertisement1" From 099ced9384c9951fdad620b5f99cd9167c20d693 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Fri, 22 Aug 2025 23:59:37 +0100 Subject: [PATCH 091/158] Use autodoc_typehints description --- docs/source/conf.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/source/conf.py b/docs/source/conf.py index 5b1690b..a27101a 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -51,6 +51,9 @@ nitpicky = True +autodoc_typehints = "description" + + # -- Linkcode ---------------------------------------------------------------- def _get_git_ref(): try: From 2af0815575c4124420a0b5afd1f0a102c62f4694 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sat, 23 Aug 2025 00:09:25 +0100 Subject: [PATCH 092/158] Fix bad merge --- bluez_peripheral/advert.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/bluez_peripheral/advert.py b/bluez_peripheral/advert.py index e93ce58..6e894e0 100644 --- a/bluez_peripheral/advert.py +++ b/bluez_peripheral/advert.py @@ -90,9 +90,6 @@ def __init__( self._export_bus: Optional[MessageBus] = None self._export_path: Optional[str] = None - - self._exportBus: Optional[MessageBus] = None - self._exportPath: Optional[str] = None self._adapter: Optional[Adapter] = None super().__init__(self._INTERFACE) @@ -142,15 +139,15 @@ 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: + if not self._export_bus or not self._adapter or not self._export_path: return interface = self._adapter._proxy.get_interface(self._MANAGER_INTERFACE) - await interface.call_unregister_advertisement(self._exportPath) # type: ignore - self._exportBus = None + await interface.call_unregister_advertisement(self._export_path) # type: ignore + self._export_bus = None self._adapter = None - self._exportPath = None + self._export_path = None if self._release_callback is not None: self._release_callback() From a3cb16e57f0c7cbda043ed6796c62694d665767e Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sat, 30 Aug 2025 12:39:38 +0100 Subject: [PATCH 093/158] Add python test version matrix --- .github/workflows/python-test.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 461d926..102ac35 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -34,12 +34,15 @@ jobs: make linkcheck test: runs-on: ubuntu-22.04 + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12"] steps: - uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: - python-version: '3.11.x' + python-version: ${{ matrix.python-version }} cache: 'pip' - name: Install dependencies run: | From 5ead741da224fceab49e75fdf4df17965a46ffa8 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sat, 30 Aug 2025 12:39:48 +0100 Subject: [PATCH 094/158] Remove always() conditions --- .github/workflows/python-test.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 102ac35..442ccde 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -23,12 +23,10 @@ jobs: make clean make html - name: Spelling Check - if: always() run: | cd docs make spelling - name: Link Check - if: always() run: | cd docs make linkcheck From a1c8fb0197ffea6f33189f2b212ec73105e0ef43 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 14 Sep 2025 14:05:05 +0100 Subject: [PATCH 095/158] Refactor service interface --- bluez_peripheral/adapter.py | 21 +- bluez_peripheral/advert.py | 5 +- bluez_peripheral/gatt/base.py | 260 ++++++++++++++++++ bluez_peripheral/gatt/characteristic.py | 239 ++++------------ bluez_peripheral/gatt/descriptor.py | 150 ++-------- bluez_peripheral/gatt/service.py | 183 +++--------- .../source/ref/bluez_peripheral.gatt.base.rst | 7 + .../bluez_peripheral.gatt.characteristic.rst | 1 - .../ref/bluez_peripheral.gatt.descriptor.rst | 1 - docs/source/ref/bluez_peripheral.gatt.rst | 1 + tests/gatt/test_characteristic.py | 19 +- tests/gatt/test_descriptor.py | 14 +- tests/gatt/test_service.py | 9 +- tests/test_advert.py | 2 +- tests/test_agent.py | 2 +- tests/util.py | 10 +- 16 files changed, 429 insertions(+), 495 deletions(-) create mode 100644 bluez_peripheral/gatt/base.py create mode 100644 docs/source/ref/bluez_peripheral.gatt.base.rst diff --git a/bluez_peripheral/adapter.py b/bluez_peripheral/adapter.py index 82c2eaf..114da47 100644 --- a/bluez_peripheral/adapter.py +++ b/bluez_peripheral/adapter.py @@ -1,7 +1,7 @@ -from typing import Collection +from typing import Sequence +from dbus_fast.aio import MessageBus, ProxyInterface from dbus_fast.aio.proxy_object import ProxyObject -from dbus_fast.aio import MessageBus from dbus_fast.errors import InvalidIntrospectionError, InterfaceNotFoundError from .util import _kebab_to_shouting_snake @@ -11,13 +11,22 @@ class Adapter: """A bluetooth adapter.""" - _ADAPTER_INTERFACE = "org.bluez.Adapter1" + BUS_INTERFACE = "org.bluez.Adapter1" + _GATT_MANAGER_INTERFACE = "org.bluez.GattManager1" _ADVERTISING_MANAGER_INTERFACE = "org.bluez.LEAdvertisingManager1" _adapter_interface = None def __init__(self, proxy: ProxyObject): self._proxy = proxy - self._adapter_interface = proxy.get_interface(self._ADAPTER_INTERFACE) + self._adapter_interface = proxy.get_interface(self.BUS_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.""" @@ -83,7 +92,7 @@ async def set_discoverable_timeout(self, val: int) -> None: async def get_supported_advertising_includes(self) -> AdvertisingIncludes: """Returns a flag set of the advertising includes supported by this adapter.""" - interface = self._proxy.get_interface(self._ADVERTISING_MANAGER_INTERFACE) + interface = self.get_advertising_manager() includes = await interface.get_supported_includes() # type: ignore flags = AdvertisingIncludes.NONE for inc in includes: @@ -93,7 +102,7 @@ async def get_supported_advertising_includes(self) -> AdvertisingIncludes: return flags @classmethod - async def get_all(cls, bus: MessageBus) -> Collection["Adapter"]: + async def get_all(cls, bus: MessageBus) -> Sequence["Adapter"]: """Get a list of available Bluetooth adapters. Args: diff --git a/bluez_peripheral/advert.py b/bluez_peripheral/advert.py index 6e894e0..f96917b 100644 --- a/bluez_peripheral/advert.py +++ b/bluez_peripheral/advert.py @@ -36,7 +36,6 @@ class Advertisement(ServiceInterface): """ _INTERFACE = "org.bluez.LEAdvertisement1" - _MANAGER_INTERFACE = "org.bluez.LEAdvertisingManager1" _defaultPathAdvertCount = 0 @@ -126,7 +125,7 @@ async def register( self._adapter = adapter # Get the LEAdvertisingManager1 interface for the target adapter. - interface = adapter._proxy.get_interface(self._MANAGER_INTERFACE) + interface = adapter.get_advertising_manager() await interface.call_register_advertisement(path, {}) # type: ignore @method("Release") @@ -142,7 +141,7 @@ async def unregister(self) -> None: if not self._export_bus or not self._adapter or not self._export_path: return - interface = self._adapter._proxy.get_interface(self._MANAGER_INTERFACE) + interface = self._adapter.get_advertising_manager() await interface.call_unregister_advertisement(self._export_path) # type: ignore self._export_bus = None diff --git a/bluez_peripheral/gatt/base.py b/bluez_peripheral/gatt/base.py new file mode 100644 index 0000000..f1b0d39 --- /dev/null +++ b/bluez_peripheral/gatt/base.py @@ -0,0 +1,260 @@ +import inspect +from abc import ABC, abstractmethod +from typing import ( + Any, + Optional, + TypeVar, + Generic, + Union, + Callable, + Awaitable, + TypeAlias, + Dict, + cast, + TYPE_CHECKING, +) + +from dbus_fast import Variant +from dbus_fast.errors import DBusError +from dbus_fast.constants import PropertyAccess +from dbus_fast.service import method, ServiceInterface, dbus_property +from dbus_fast.aio.message_bus import MessageBus + +from ..error import FailedError, NotSupportedError + +if TYPE_CHECKING: + from .service import Service + + +class HierarchicalServiceInterface(ServiceInterface): + """ + 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. + """ + + BUS_INTERFACE = "" + """ + The DBus interface name implemented by this component. + """ + + def __init__(self) -> None: + super().__init__(name=self.BUS_INTERFACE) + + self._export_path: Optional[str] = None + 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 + + @property + def export_path(self) -> Optional[str]: + """ + The path on which this service is exported (or None). + """ + return self._export_path + + @property + def is_exported(self) -> bool: + """ + Indicates whether this service is exported or not. + """ + return self._export_path is not None + + 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 self.is_exported: + raise ValueError("Cannot export an already exported component") + + 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") + + bus.export(path, self) + self._export_path = path + + for i, c in enumerate(self._children): + c.export(bus, num=i) + + def unexport(self, bus: MessageBus) -> 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") + assert self._export_path is not None + + for c in self._children: + c.unexport(bus) + + bus.unexport(self._export_path, self.BUS_INTERFACE) + self._export_path = None + + +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: TypeAlias = Union[ + Callable[[Any, ReadOptionsT], bytes], + Callable[[Any, ReadOptionsT], Awaitable[bytes]], +] +SetterType: TypeAlias = 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: Optional[Dict[str, Variant]]) -> ReadOptionsT: + pass + + @staticmethod + @abstractmethod + def _parse_write_options(options: Optional[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 53850a4..233c088 100644 --- a/bluez_peripheral/gatt/characteristic.py +++ b/bluez_peripheral/gatt/characteristic.py @@ -1,17 +1,15 @@ -import inspect from enum import Enum, Flag, auto -from typing import Callable, Optional, Union, Awaitable, List, cast, Dict, TYPE_CHECKING +from typing import Optional, cast, Dict, TYPE_CHECKING -from dbus_fast.errors import DBusError -from dbus_fast.constants import PropertyAccess -from dbus_fast.service import ServiceInterface, method, dbus_property -from dbus_fast.aio import MessageBus from dbus_fast import Variant +from dbus_fast.constants import PropertyAccess +from dbus_fast.service import method, dbus_property -from .descriptor import descriptor as Descriptor, DescriptorFlags -from ..uuid16 import UUID16, UUIDCompatible +from .base import HierarchicalServiceInterface, ServiceAttribute +from ..uuid16 import UUIDCompatible, UUID16 from ..util import _snake_to_kebab, _getattr_variant -from ..error import NotSupportedError, FailedError +from ..error import NotSupportedError +from .descriptor import DescriptorFlags, descriptor if TYPE_CHECKING: from .service import Service @@ -173,17 +171,10 @@ class CharacteristicFlags(Flag): """""" -GetterType = Union[ - Callable[["Service", CharacteristicReadOptions], bytes], - Callable[["Service", CharacteristicReadOptions], Awaitable[bytes]], -] -SetterType = Union[ - Callable[["Service", bytes, CharacteristicWriteOptions], None], - Callable[["Service", bytes, CharacteristicWriteOptions], Awaitable[None]], -] - - -class characteristic(ServiceInterface): # pylint: disable=invalid-name +class characteristic( + ServiceAttribute[CharacteristicReadOptions, CharacteristicWriteOptions], + HierarchicalServiceInterface, +): # pylint: disable=invalid-name """Create a new characteristic with a specified UUID and flags. Args: @@ -194,26 +185,45 @@ class characteristic(ServiceInterface): # pylint: disable=invalid-name :ref:`services` """ - _INTERFACE = "org.bluez.GattCharacteristic1" + BUS_PREFIX = "char" + BUS_INTERFACE = "org.bluez.GattCharacteristic1" def __init__( self, uuid: UUIDCompatible, flags: CharacteristicFlags = CharacteristicFlags.READ, ): - self.uuid = UUID16.parse_uuid(uuid) - self.getter_func: Optional[GetterType] = None - self.setter_func: Optional[SetterType] = None - self.flags = flags + super().__init__() + self.flags = flags + self._uuid = UUID16.parse_uuid(uuid) self._notify = False - self._service_path: Optional[str] = None - self._descriptors: List[Descriptor] = [] - self._service: Optional["Service"] = None self._value = bytearray() - self._num: Optional[int] = None - super().__init__(self._INTERFACE) + @staticmethod + def _parse_read_options( + options: Optional[Dict[str, Variant]], + ) -> CharacteristicReadOptions: + return CharacteristicReadOptions(options) + + @staticmethod + def _parse_write_options( + options: Optional[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) + + @ServiceAttribute.service.setter # type: ignore[misc, attr-defined] + def service(self, service: Optional["Service"]) -> None: + ServiceAttribute.service.fset(self, service) # type: ignore[attr-defined] + for c in self._children: + assert isinstance(c, descriptor) + c.service = self.service def changed(self, new_value: bytes) -> None: """Call this function when the value of a notifiable or indicatable property changes to alert any subscribers. @@ -221,38 +231,15 @@ def changed(self, new_value: bytes) -> None: 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: SetterType) -> "characteristic": - """A decorator for characteristic value setters.""" - self.setter_func = setter_func - return self - - def __call__( - self, - getter_func: Optional[GetterType] = None, - setter_func: Optional[SetterType] = None, - ) -> "characteristic": - """A decorator for characteristic value getters. - - Args: - getter_func: The getter function for this characteristic. - setter_func: 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: UUIDCompatible, flags: DescriptorFlags = DescriptorFlags.READ, - ) -> Descriptor: + ) -> descriptor: """Create a new descriptor with the specified UUID and Flags. Args: @@ -260,137 +247,7 @@ def descriptor( flags: Any descriptor access flags to use. """ # Use as a decorator for descriptors that need a getter. - return Descriptor(uuid, self, flags) - - def _is_registered(self) -> bool: - return not self._service_path is None - - def set_service(self, service: Optional["Service"]) -> None: - """Attaches this characteristic to the specified service. - - Warnings: - Do not call this directly. Subclasses of the Service class will handle this automatically. - """ - self._service = service - - for desc in self._descriptors: - desc.set_service(service) - - def add_descriptor(self, desc: Descriptor) -> None: - """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) -> None: - """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: - if self._service_path is None: - raise ValueError() - - return f"{self._service_path}/char{self._num}" - - def _export(self, bus: MessageBus, service_path: str, num: int) -> None: - 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) -> None: - # 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("ReadValue") - async def _read_value(self, options: "a{sv}") -> "ay": # type: ignore - if self.getter_func is None: - raise FailedError("No getter implemented") - - if self._service is None: - raise ValueError() - - try: - res: bytes - if inspect.iscoroutinefunction(self.getter_func): - res = await self.getter_func( - self._service, CharacteristicReadOptions(options) - ) - else: - res = cast( - bytes, - self.getter_func(self._service, CharacteristicReadOptions(options)), - ) - - self._value = 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 FailedError("No setter implemented") - - if self._service is None: - raise ValueError() - - 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) + return descriptor(uuid, self, flags) @method("StartNotify") def _start_notify(self) -> None: @@ -408,11 +265,13 @@ def _stop_notify(self) -> None: @dbus_property(PropertyAccess.READ, "UUID") def _get_uuid(self) -> "s": # type: ignore - return str(self.uuid) + return str(self._uuid) @dbus_property(PropertyAccess.READ, "Service") def _get_service(self) -> "o": # type: ignore - return self._service_path + assert self._service is not None + + return self._service.export_path @dbus_property(PropertyAccess.READ, "Flags") def _get_flags(self) -> "as": # type: ignore @@ -425,7 +284,3 @@ def _get_flags(self) -> "as": # type: ignore for flag in CharacteristicFlags if self.flags & flag and flag.name is not None ] - - @dbus_property(PropertyAccess.READ, "Value") - def _get_value(self) -> "ay": # type: ignore - return bytes(self._value) diff --git a/bluez_peripheral/gatt/descriptor.py b/bluez_peripheral/gatt/descriptor.py index 0879204..d106c53 100644 --- a/bluez_peripheral/gatt/descriptor.py +++ b/bluez_peripheral/gatt/descriptor.py @@ -1,19 +1,17 @@ -import inspect from enum import Flag, auto from typing import Callable, Union, Awaitable, Optional, Dict, TYPE_CHECKING, cast -from dbus_fast.errors import DBusError from dbus_fast import Variant -from dbus_fast.aio.message_bus import MessageBus -from dbus_fast.service import ServiceInterface, method, dbus_property +from dbus_fast.service import dbus_property from dbus_fast.constants import PropertyAccess +from .base import HierarchicalServiceInterface, ServiceAttribute from ..uuid16 import UUID16, UUIDCompatible from ..util import _snake_to_kebab, _getattr_variant -from ..error import FailedError if TYPE_CHECKING: from .service import Service + from .characteristic import characteristic class DescriptorReadOptions: @@ -119,7 +117,10 @@ class DescriptorFlags(Flag): # Decorator for descriptor getters/ setters. -class descriptor(ServiceInterface): # pylint: disable=invalid-name +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. Args: @@ -131,139 +132,42 @@ class descriptor(ServiceInterface): # pylint: disable=invalid-name :ref:`services` """ - _INTERFACE = "org.bluez.GattDescriptor1" + BUS_PREFIX = "desc" + BUS_INTERFACE = "org.bluez.GattDescriptor1" def __init__( self, uuid: UUIDCompatible, - characteristic: "characteristic", # type: ignore + characteristic: "characteristic", flags: DescriptorFlags = DescriptorFlags.READ, ): - self.uuid = UUID16.parse_uuid(uuid) - self.getter_func: Optional[GetterType] = None - self.setter_func: Optional[SetterType] = None - self.characteristic = characteristic - self.flags = flags - self._service: Optional[Service] = None - self._num: Optional[int] = None - - self._characteristic_path: Optional[str] = None - super().__init__(self._INTERFACE) + super().__init__() - characteristic.add_descriptor(self) + self.flags = flags + self._uuid = UUID16.parse_uuid(uuid) + self._characteristic = characteristic - # Decorators - def setter( - self, - setter_func: SetterType, - ) -> "descriptor": - """A decorator for descriptor value setters.""" - self.setter_func = setter_func - return self + characteristic.add_child(self) - def __call__( - self, - getter_func: Optional[GetterType] = None, - setter_func: Optional[SetterType] = 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. - - Returns: - This descriptor - """ - self.getter_func = getter_func - self.setter_func = setter_func - return self - - def set_service(self, service: Optional["Service"]) -> None: - """Attaches this descriptor to the specified service. - - Warnings: - Do not call this directly. Subclasses of the Service class will handle this automatically. - """ - self._service = service - - # DBus - def _get_path(self) -> str: - if self._characteristic_path is None: - raise ValueError() - - return f"{self._characteristic_path}/desc{self._num}" - - def _export(self, bus: MessageBus, characteristic_path: str, num: int) -> None: - self._characteristic_path = characteristic_path - self._num = num - bus.export(self._get_path(), self) - - def _unexport(self, bus: MessageBus) -> None: - if self._characteristic_path is None: - return + @staticmethod + def _parse_read_options( + options: Optional[Dict[str, Variant]], + ) -> DescriptorReadOptions: + return DescriptorReadOptions(options) - bus.unexport(self._get_path(), self._INTERFACE) - self._characteristic_path = None - - @method("ReadValue") - async def _read_value(self, options: "a{sv}") -> "ay": # type: ignore - if self.getter_func is None: - raise FailedError("No getter implemented") - - if self._service is None: - raise ValueError() - - try: - if inspect.iscoroutinefunction(self.getter_func): - return await self.getter_func( - self._service, DescriptorReadOptions(options) - ) - - return cast( - bytes, - 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("WriteValue") - async def _write_value(self, data: "ay", options: "a{sv}"): # type: ignore - if self.setter_func is None: - raise FailedError("No setter implemented") - - if self._service is None: - raise ValueError() - - 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 + @staticmethod + def _parse_write_options( + options: Optional[Dict[str, Variant]], + ) -> DescriptorWriteOptions: + return DescriptorWriteOptions(options) @dbus_property(PropertyAccess.READ, "UUID") def _get_uuid(self) -> "s": # type: ignore - return str(self.uuid) + return str(self._uuid) @dbus_property(PropertyAccess.READ, "Characteristic") def _get_characteristic(self) -> "o": # type: ignore - return self._characteristic_path + return self._characteristic.export_path @dbus_property(PropertyAccess.READ, "Flags") def _get_flags(self) -> "as": # type: ignore diff --git a/bluez_peripheral/gatt/service.py b/bluez_peripheral/gatt/service.py index c59b6a5..395443f 100644 --- a/bluez_peripheral/gatt/service.py +++ b/bluez_peripheral/gatt/service.py @@ -2,17 +2,17 @@ from typing import List, Optional, Collection from dbus_fast.constants import PropertyAccess -from dbus_fast.service import ServiceInterface, dbus_property +from dbus_fast.service import dbus_property from dbus_fast.aio.message_bus import MessageBus -from dbus_fast.aio.proxy_object import ProxyInterface +from .base import HierarchicalServiceInterface from .characteristic import characteristic from ..uuid16 import UUID16, UUIDCompatible from ..adapter import Adapter # See https://github.com/bluez/bluez/blob/master/doc/org.bluez.GattService.rst -class Service(ServiceInterface): +class Service(HierarchicalServiceInterface): """Create a bluetooth service with the specified uuid. Args: @@ -22,7 +22,8 @@ class Service(ServiceInterface): Services must be registered at the time Includes is read to be included. """ - _INTERFACE = "org.bluez.GattService1" + BUS_INTERFACE = "org.bluez.GattService1" + BUS_PREFIX = "service" def _populate(self) -> None: # Only interested in characteristic members. @@ -31,11 +32,9 @@ def _populate(self) -> None: ) 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, @@ -43,10 +42,10 @@ def __init__( primary: bool = True, includes: Optional[Collection["Service"]] = None, ): - # Make sure uuid is a uuid16. + super().__init__() + self._uuid = UUID16.parse_uuid(uuid) self._primary = primary - self._characteristics: List[characteristic] = [] self._path: Optional[str] = None if includes is None: includes = [] @@ -54,68 +53,11 @@ def __init__( 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. - - Returns: - bool: True if the service is registered. False otherwise. - """ - return not self._path is None - - def add_characteristic(self, char: characteristic) -> None: - """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) -> None: - """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. - """ - 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) -> None: - 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) -> None: - if self._path is None: - return - - # Unexport this and every child characteristic. - bus.unexport(self._path, self._INTERFACE) - for char in self._characteristics: - char._unexport(bus) - - self._path = None + 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) async def register( self, @@ -155,22 +97,19 @@ def _get_primary(self) -> "b": # type: ignore def _get_includes(self) -> "ao": # type: ignore paths = [] - # Shouldn't be possible to call this before export. - if self._path is None: - raise ValueError() - 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" + BUS_INTERFACE = "org.spacecheese.ServiceCollection1" def __init__(self, services: Optional[List[Service]] = None): """Create a service collection populated with the specified list of services. @@ -178,53 +117,14 @@ def __init__(self, services: Optional[List[Service]] = None): Args: services: The services to provide. """ - self._bus: Optional[MessageBus] + super().__init__() + if services is not None: + for s in services: + self.add_child(s) + self._path: Optional[str] = None + self._bus: Optional[MessageBus] = None self._adapter: Optional[Adapter] = None - if services is None: - services = [] - self._services = services - - def add_service(self, service: Service) -> None: - """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) -> None: - """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." - ) - - self._services.remove(service) - - async def _get_manager_interface(self) -> ProxyInterface: - if not self.is_registered(): - raise ValueError("Service is not registered to an adapter.") - assert self._adapter is not None - - 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 async def register( self, @@ -241,43 +141,28 @@ 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._bus = bus 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, f"{self._path}/service{i}") - i += 1 - - class _EmptyServiceInterface(ServiceInterface): - pass + self.export(self._bus, path=path) - # 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:])) + manager = self._adapter.get_gatt_manager() await manager.call_register_application(self._path, {}) # type: ignore 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._path is not None + assert self._bus is not None + assert self._adapter is not None - manager = await self._get_manager_interface() + manager = self._adapter.get_gatt_manager() await manager.call_unregister_application(self._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._bus) self._path = None self._adapter = None 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/bluez_peripheral.gatt.characteristic.rst b/docs/source/ref/bluez_peripheral.gatt.characteristic.rst index c707bff..db5c48b 100644 --- a/docs/source/ref/bluez_peripheral.gatt.characteristic.rst +++ b/docs/source/ref/bluez_peripheral.gatt.characteristic.rst @@ -5,4 +5,3 @@ bluez\_peripheral.gatt.characteristic module :members: :show-inheritance: :undoc-members: - :special-members: __call__ diff --git a/docs/source/ref/bluez_peripheral.gatt.descriptor.rst b/docs/source/ref/bluez_peripheral.gatt.descriptor.rst index 7772eb7..815386c 100644 --- a/docs/source/ref/bluez_peripheral.gatt.descriptor.rst +++ b/docs/source/ref/bluez_peripheral.gatt.descriptor.rst @@ -5,4 +5,3 @@ bluez\_peripheral.gatt.descriptor module :members: :show-inheritance: :undoc-members: - :special-members: __call__ diff --git a/docs/source/ref/bluez_peripheral.gatt.rst b/docs/source/ref/bluez_peripheral.gatt.rst index 031838d..f54f518 100644 --- a/docs/source/ref/bluez_peripheral.gatt.rst +++ b/docs/source/ref/bluez_peripheral.gatt.rst @@ -7,6 +7,7 @@ Submodules .. toctree:: :maxdepth: 4 + bluez_peripheral.gatt.base bluez_peripheral.gatt.characteristic bluez_peripheral.gatt.descriptor bluez_peripheral.gatt.service diff --git a/tests/gatt/test_characteristic.py b/tests/gatt/test_characteristic.py index 21fe3d8..7d80160 100644 --- a/tests/gatt/test_characteristic.py +++ b/tests/gatt/test_characteristic.py @@ -40,7 +40,7 @@ async def async_read_only_char(self, opts): # Not testing other characteristic flags since their functionality is handled by bluez. @characteristic("2A38", CharacteristicFlags.NOTIFY | CharacteristicFlags.WRITE) def write_notify_char(self, _): - pass + raise NotImplementedError() @write_notify_char.setter def write_notify_char(self, val, opts): @@ -51,7 +51,7 @@ def write_notify_char(self, val, opts): @characteristic("3A38", CharacteristicFlags.WRITE) async def aysnc_write_only_char(self, _): - pass + raise NotImplementedError() @aysnc_write_only_char.setter async def aysnc_write_only_char(self, val, opts): @@ -65,7 +65,7 @@ async def aysnc_write_only_char(self, val, opts): class TestCharacteristic(IsolatedAsyncioTestCase): async def asyncSetUp(self): self._client_bus = await get_message_bus() - self._bus_manager = BusManager() + self._bus_manager = ParallelBus() self._path = "/com/spacecheese/bluez_peripheral/test_characteristic" async def asyncTearDown(self): @@ -91,6 +91,7 @@ async def inspector(path): adapter = MockAdapter(inspector) await service.register(self._bus_manager.bus, self._path, adapter) + await service.unregister() async def test_read(self): async def inspector(path): @@ -137,6 +138,7 @@ async def inspector(path): adapter = MockAdapter(inspector) await service.register(self._bus_manager.bus, self._path, adapter) + await service.unregister() async def test_write(self): async def inspector(path): @@ -186,6 +188,7 @@ async def inspector(path): adapter = MockAdapter(inspector) await service.register(self._bus_manager.bus, self._path, adapter) + await service.unregister() async def test_notify_no_start(self): property_changed = Event() @@ -217,6 +220,7 @@ def on_properties_changed(_0, _1, _2): raise Exception( "The characteristic signalled a notification before StartNotify() was called." ) + await service.unregister() async def test_notify_start(self): property_changed = Event() @@ -256,6 +260,7 @@ def on_properties_changed(interface, values, invalid_props): raise TimeoutError( "The characteristic did not send a notification in time." ) + await service.unregister() async def test_notify_stop(self): property_changed = Event() @@ -290,6 +295,7 @@ def on_properties_changed(_0, _1, _2): raise Exception( "The characteristic signalled a notification before after StopNotify() was called." ) + await service.unregister() async def test_modify(self): service = TestService() @@ -342,16 +348,17 @@ async def inspector(path): await service.register(self._bus_manager.bus, self._path, adapter=adapter) self.assertRaises( - ValueError, service.write_notify_char.remove_descriptor, some_desc + ValueError, service.write_notify_char.remove_child, some_desc ) await service.unregister() - service.write_notify_char.remove_descriptor(some_desc) + service.write_notify_char.remove_child(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 + ValueError, service.write_notify_char.add_child, some_desc ) await service.unregister() await service.register(self._bus_manager.bus, self._path, adapter=adapter) + await service.unregister() diff --git a/tests/gatt/test_descriptor.py b/tests/gatt/test_descriptor.py index ee57180..f92be99 100644 --- a/tests/gatt/test_descriptor.py +++ b/tests/gatt/test_descriptor.py @@ -43,10 +43,10 @@ async def async_read_only_desc(self, opts): @descriptor("2A39", some_char, DescriptorFlags.WRITE) def write_desc(self, _): - pass + return bytes() @write_desc.setter - def write_desc(self, val, opts): + def write_desc_set(self, val, opts): global last_opts last_opts = opts global write_desc_val @@ -54,10 +54,10 @@ def write_desc(self, val, opts): @descriptor("3A39", some_char, DescriptorFlags.WRITE) async def async_write_desc(self, _): - pass + return bytes() @async_write_desc.setter - async def async_write_desc(self, val, opts): + async def async_write_desc_set(self, val, opts): global last_opts last_opts = opts await asyncio.sleep(0.05) @@ -68,7 +68,7 @@ async def async_write_desc(self, val, opts): class TestDescriptor(IsolatedAsyncioTestCase): async def asyncSetUp(self): self._client_bus = await get_message_bus() - self._bus_manager = BusManager() + self._bus_manager = ParallelBus() self._path = "/com/spacecheese/bluez_peripheral/test_descriptor" async def asyncTearDown(self): @@ -97,6 +97,7 @@ async def inspector(path): adapter = MockAdapter(inspector) await service.register(self._bus_manager.bus, self._path, adapter) + await service.unregister() async def test_read(self): async def inspector(path): @@ -141,6 +142,7 @@ async def inspector(path): adapter = MockAdapter(inspector) await service.register(self._bus_manager.bus, self._path, adapter) + await service.unregister() async def test_write(self): async def inspector(path): @@ -189,6 +191,7 @@ async def inspector(path): adapter = MockAdapter(inspector) await service.register(self._bus_manager.bus, self._path, adapter) + await service.unregister() async def test_bluez(self): await bluez_available_or_skip(self._client_bus) @@ -196,3 +199,4 @@ async def test_bluez(self): service = TestService() await service.register(self._client_bus, self._path) + await service.unregister() diff --git a/tests/gatt/test_service.py b/tests/gatt/test_service.py index c27dc9f..7c0e525 100644 --- a/tests/gatt/test_service.py +++ b/tests/gatt/test_service.py @@ -1,4 +1,4 @@ -from tests.util import BusManager, MockAdapter, get_attrib +from tests.util import ParallelBus, MockAdapter, get_attrib import re from typing import Collection @@ -27,7 +27,7 @@ def __init__(self): class TestService(IsolatedAsyncioTestCase): async def asyncSetUp(self): self._client_bus = await get_message_bus() - self._bus_manager = BusManager() + self._bus_manager = ParallelBus() self._path = "/com/spacecheese/bluez_peripheral/test_service" async def asyncTearDown(self): @@ -90,11 +90,12 @@ async def inspector(path): await collection.register(self._bus_manager.bus, self._path, adapter=adapter) await collection.unregister() - collection.add_service(service3) + collection.add_child(service3) expect_service3 = True await collection.register(self._bus_manager.bus, self._path, adapter=adapter) await collection.unregister() - collection.remove_service(service3) + collection.remove_child(service3) expect_service3 = False await collection.register(self._bus_manager.bus, self._path, adapter=adapter) + await collection.unregister() diff --git a/tests/test_advert.py b/tests/test_advert.py index ec49e3e..d416a4a 100644 --- a/tests/test_advert.py +++ b/tests/test_advert.py @@ -11,7 +11,7 @@ class TestAdvert(IsolatedAsyncioTestCase): async def asyncSetUp(self): - self._bus_manager = BusManager() + self._bus_manager = ParallelBus() self._client_bus = await get_message_bus() async def asyncTearDown(self): diff --git a/tests/test_agent.py b/tests/test_agent.py index 94db5cd..c196e3b 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -31,7 +31,7 @@ async def call_request_default_agent(self, path): class TestAgent(IsolatedAsyncioTestCase): async def asyncSetUp(self): - self._bus_manager = BusManager() + self._bus_manager = ParallelBus() self._client_bus = await get_message_bus() async def asyncTearDown(self): diff --git a/tests/util.py b/tests/util.py index 7300453..b7b2ad1 100644 --- a/tests/util.py +++ b/tests/util.py @@ -1,6 +1,5 @@ import asyncio -from uuid import UUID -from typing import Tuple +from typing import Tuple, Optional from threading import Thread, Event from unittest.case import SkipTest @@ -15,10 +14,11 @@ from bluez_peripheral.uuid16 import UUID16 -class BusManager: +class ParallelBus: def __init__(self, name="com.spacecheese.test"): bus_ready = Event() self.name = name + self.bus: MessageBus async def operate_bus_async(): # Setup the bus. @@ -36,8 +36,10 @@ def operate_bus(): self._thread.start() bus_ready.wait() + assert self.bus is not None def close(self): + assert self.bus is not None self.bus.disconnect() @@ -89,6 +91,8 @@ async def find_attrib(bus, bus_name, path, nodes, target_uuid) -> Tuple[Node, st 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() + else: + raise ValueError("No supported interfaces found") if UUID16.parse_uuid(uuid) == UUID16.parse_uuid(target_uuid): return introspection, node_path From 5eeceed9056deb3d1890095d82606005f9e55027 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 14 Sep 2025 14:31:12 +0100 Subject: [PATCH 096/158] Attempt automated coverage reporting --- .github/workflows/python-test.yml | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 442ccde..2276756 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -46,12 +46,33 @@ jobs: run: | python -m pip install --upgrade pip pip install -r tests/requirements.txt + pip install coverage - name: Add DBus Config run: | sudo cp tests/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 --branch -m unittest discover -s tests -p "test_*.py" -v + - name: Report Coverage + run: | + coverage html + coverage report + - name: Upload Coverage Artifact + id: upload + uses: actions/upload-pages-artifact@v3 + with: + path: htmlcov/ + deploy-coverage: + environment: + name: github-pages + url: ${{ steps.upload.outputs.page_url }} + runs-on: ubuntu-latest + needs: test + steps: + - name: Deploy to GitHub Pages + uses: actions/deploy-pages@v4 + + format-check: runs-on: ubuntu-22.04 steps: From 86e835ce77eec0f0f9e7312ca5e628fb24bc07fd Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 14 Sep 2025 14:33:27 +0100 Subject: [PATCH 097/158] Disable fail-fast --- .github/workflows/python-test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 2276756..81a49ce 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -35,6 +35,7 @@ jobs: strategy: matrix: python-version: ["3.9", "3.10", "3.11", "3.12"] + fail-fast: false steps: - uses: actions/checkout@v3 - name: Set up Python From bb525e55de60a73fd965a13194da012a25bcba01 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 14 Sep 2025 14:34:28 +0100 Subject: [PATCH 098/158] Fix test formatting --- tests/gatt/test_characteristic.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/gatt/test_characteristic.py b/tests/gatt/test_characteristic.py index 7d80160..9453201 100644 --- a/tests/gatt/test_characteristic.py +++ b/tests/gatt/test_characteristic.py @@ -347,18 +347,14 @@ async def inspector(path): adapter = MockAdapter(inspector) await service.register(self._bus_manager.bus, self._path, adapter=adapter) - self.assertRaises( - ValueError, service.write_notify_char.remove_child, some_desc - ) + self.assertRaises(ValueError, service.write_notify_char.remove_child, some_desc) await service.unregister() service.write_notify_char.remove_child(some_desc) expect_descriptor = False await service.register(self._bus_manager.bus, self._path, adapter=adapter) - self.assertRaises( - ValueError, service.write_notify_char.add_child, some_desc - ) + self.assertRaises(ValueError, service.write_notify_char.add_child, some_desc) await service.unregister() await service.register(self._bus_manager.bus, self._path, adapter=adapter) await service.unregister() From b957fe279c940952772c96d1e96cfa555939366f Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 14 Sep 2025 14:37:15 +0100 Subject: [PATCH 099/158] Remove TypeAlias references --- bluez_peripheral/gatt/base.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bluez_peripheral/gatt/base.py b/bluez_peripheral/gatt/base.py index f1b0d39..981acf2 100644 --- a/bluez_peripheral/gatt/base.py +++ b/bluez_peripheral/gatt/base.py @@ -8,7 +8,6 @@ Union, Callable, Awaitable, - TypeAlias, Dict, cast, TYPE_CHECKING, @@ -132,11 +131,11 @@ def unexport(self, bus: MessageBus) -> None: """ The type of options supplied by a DBus WriteValue access. """ -GetterType: TypeAlias = Union[ +GetterType = Union[ Callable[[Any, ReadOptionsT], bytes], Callable[[Any, ReadOptionsT], Awaitable[bytes]], ] -SetterType: TypeAlias = Union[ +SetterType = Union[ Callable[[Any, bytes, WriteOptionsT], None], Callable[[Any, bytes, WriteOptionsT], Awaitable[None]], ] From 3fa526415d29c0a5689ab2fad8db823f37d0b57b Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 14 Sep 2025 14:42:25 +0100 Subject: [PATCH 100/158] Try uniquifying coverage reports --- .github/workflows/python-test.yml | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 81a49ce..840876e 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -62,17 +62,12 @@ jobs: id: upload uses: actions/upload-pages-artifact@v3 with: + artifact-name: coverage-${{ matrix.python-version }} path: htmlcov/ - deploy-coverage: - environment: - name: github-pages - url: ${{ steps.upload.outputs.page_url }} - runs-on: ubuntu-latest - needs: test - steps: - name: Deploy to GitHub Pages uses: actions/deploy-pages@v4 - + with: + artifact-name: coverage-${{ needs.test.outputs.python-version }} format-check: runs-on: ubuntu-22.04 From a2176d404e43068906a16f33862899da92553ca7 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 14 Sep 2025 14:44:47 +0100 Subject: [PATCH 101/158] Fix key names --- .github/workflows/python-test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 840876e..ecaab0c 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -62,12 +62,12 @@ jobs: id: upload uses: actions/upload-pages-artifact@v3 with: - artifact-name: coverage-${{ matrix.python-version }} + name: coverage-${{ matrix.python-version }} path: htmlcov/ - name: Deploy to GitHub Pages uses: actions/deploy-pages@v4 with: - artifact-name: coverage-${{ needs.test.outputs.python-version }} + artifact_name: coverage-${{ needs.test.outputs.python-version }} format-check: runs-on: ubuntu-22.04 From a39ff1d4ba516d120f78f4038209c98968352522 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 14 Sep 2025 14:46:22 +0100 Subject: [PATCH 102/158] Add token permissions --- .github/workflows/python-test.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index ecaab0c..7dade79 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -36,6 +36,9 @@ jobs: 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 From 55a81cffec1c8088b26bf6ad985068facb5a80f3 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 14 Sep 2025 14:47:18 +0100 Subject: [PATCH 103/158] Fix typo --- .github/workflows/python-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 7dade79..dc208f1 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -70,7 +70,7 @@ jobs: - name: Deploy to GitHub Pages uses: actions/deploy-pages@v4 with: - artifact_name: coverage-${{ needs.test.outputs.python-version }} + artifact_name: coverage-${{ matrix.python-version }} format-check: runs-on: ubuntu-22.04 From 70985966a37c18f9785432002c47a75fc3fb7c02 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 14 Sep 2025 14:55:41 +0100 Subject: [PATCH 104/158] Remove pages deployment --- .github/workflows/python-test.yml | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index dc208f1..26b605e 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -62,15 +62,11 @@ jobs: coverage html coverage report - name: Upload Coverage Artifact - id: upload - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-artifact@v3 with: name: coverage-${{ matrix.python-version }} path: htmlcov/ - - name: Deploy to GitHub Pages - uses: actions/deploy-pages@v4 - with: - artifact_name: coverage-${{ matrix.python-version }} + format-check: runs-on: ubuntu-22.04 From f2e4372abe703cbf332fbbe38bcc79e8b6026600 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 14 Sep 2025 14:56:58 +0100 Subject: [PATCH 105/158] Fix upload-artifact version --- .github/workflows/python-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 26b605e..545ab2e 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -62,7 +62,7 @@ jobs: coverage html coverage report - name: Upload Coverage Artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: coverage-${{ matrix.python-version }} path: htmlcov/ From ee5215766944e2f9e6fc60fe532fe4077c28878f Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 14 Sep 2025 15:00:17 +0100 Subject: [PATCH 106/158] Improve coverage report presentation --- .github/workflows/python-test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 545ab2e..e8e2b6f 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -56,11 +56,11 @@ jobs: sudo cp tests/com.spacecheese.test.conf /etc/dbus-1/system.d - name: Run Tests run: | - coverage run --branch -m unittest discover -s tests -p "test_*.py" -v + coverage run --source=. --branch -m unittest discover -s tests -p "test_*.py" -v - name: Report Coverage run: | coverage html - coverage report + coverage report -m - name: Upload Coverage Artifact uses: actions/upload-artifact@v4 with: From b4bad21e77968c35433a4234f2136c9d07fc759c Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 14 Sep 2025 15:03:06 +0100 Subject: [PATCH 107/158] Fix spelling issues --- bluez_peripheral/gatt/base.py | 10 +++++----- docs/source/spelling_wordlist.txt | 5 ++++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/bluez_peripheral/gatt/base.py b/bluez_peripheral/gatt/base.py index 981acf2..290458c 100644 --- a/bluez_peripheral/gatt/base.py +++ b/bluez_peripheral/gatt/base.py @@ -37,7 +37,7 @@ class HierarchicalServiceInterface(ServiceInterface): BUS_INTERFACE = "" """ - The DBus interface name implemented by this component. + The dbus interface name implemented by this component. """ def __init__(self) -> None: @@ -125,11 +125,11 @@ def unexport(self, bus: MessageBus) -> None: ReadOptionsT = TypeVar("ReadOptionsT") """ -The type of options supplied by a DBus ReadValue access. +The type of options supplied by a dbus ReadValue access. """ WriteOptionsT = TypeVar("WriteOptionsT") """ -The type of options supplied by a DBus WriteValue access. +The type of options supplied by a dbus WriteValue access. """ GetterType = Union[ Callable[[Any, ReadOptionsT], bytes], @@ -143,7 +143,7 @@ def unexport(self, bus: MessageBus) -> None: class ServiceAttribute(Generic[ReadOptionsT, WriteOptionsT], ABC): """ - Base class for service components with a ReadValue and WriteValue DBus interface. + Base class for service components with a ReadValue and WriteValue dbus interface. """ def __init__(self, *args: Any, **kwargs: Any): @@ -202,7 +202,7 @@ def __call__( return self - # DBus Interface + # dbus Interface @method("ReadValue") async def _read_value(self, options: "a{sv}") -> "ay": # type: ignore if self._getter_func is None: diff --git a/docs/source/spelling_wordlist.txt b/docs/source/spelling_wordlist.txt index 604f55d..564dc4d 100644 --- a/docs/source/spelling_wordlist.txt +++ b/docs/source/spelling_wordlist.txt @@ -26,4 +26,7 @@ linux subpackages submodules enums -responder \ No newline at end of file +responder +unexport +LEAdvertisingManager +GattManager \ No newline at end of file From 37f6b77dda0ece29846c7cb0fa2c9e459865119b Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 14 Sep 2025 15:05:04 +0100 Subject: [PATCH 108/158] Add "unexported" to spelling wordlist --- docs/source/spelling_wordlist.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/source/spelling_wordlist.txt b/docs/source/spelling_wordlist.txt index 564dc4d..b515220 100644 --- a/docs/source/spelling_wordlist.txt +++ b/docs/source/spelling_wordlist.txt @@ -28,5 +28,6 @@ submodules enums responder unexport +unexported LEAdvertisingManager GattManager \ No newline at end of file From f28ea1638225cf4189dee31debe615409b8064af Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 14 Sep 2025 15:22:01 +0100 Subject: [PATCH 109/158] Reduce linkcheck workers --- docs/source/conf.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/source/conf.py b/docs/source/conf.py index a27101a..91ea684 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -53,6 +53,8 @@ autodoc_typehints = "description" +linkcheck_timeout = 10 +linkcheck_workers = 1 # -- Linkcode ---------------------------------------------------------------- def _get_git_ref(): From 5c955360ab3c32e83c05ab5da1824287bdfecfe8 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 14 Sep 2025 16:28:54 +0100 Subject: [PATCH 110/158] Exclude setup.py from coverage --- pyproject.toml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index ded4f36..a74222e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,4 +28,9 @@ disallow_untyped_defs = true disallow_incomplete_defs = true warn_unused_ignores = true warn_return_any = true -warn_unused_configs = true \ No newline at end of file +warn_unused_configs = true + +[tool.coverage.run] +omit = [ + "setup.py", +] \ No newline at end of file From 45ed51fd7d7d7c77631f55bf5319a71d9ee8e458 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 14 Sep 2025 16:29:05 +0100 Subject: [PATCH 111/158] Improve uuid16 tests --- tests/test_uuid16.py | 57 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 53 insertions(+), 4 deletions(-) diff --git a/tests/test_uuid16.py b/tests/test_uuid16.py index 93d048c..e8f9ef6 100644 --- a/tests/test_uuid16.py +++ b/tests/test_uuid16.py @@ -8,15 +8,15 @@ class TestUUID16(unittest.TestCase): def test_from_hex(self): with self.assertRaises(ValueError): - uuid = UUID16("123") + uuid = UUID16(hex="123") with self.assertRaises(ValueError): - uuid = UUID16("12345") + uuid = UUID16(hex="12345") - uuid = UUID16("1234") + uuid = UUID16(hex="1234") assert uuid.uuid == UUID("00001234-0000-1000-8000-00805F9B34FB") - uuid = UUID16("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(self): @@ -46,9 +46,30 @@ def test_parse_uuid(self): uuid = UUID("00001234-0000-1000-8000-00805F9B34FB") assert type(UUID16.parse_uuid(uuid)) is UUID16 + uuid = "00001234-0000-1000-8000-00805F9B34FB" + assert type(UUID16.parse_uuid(uuid) is UUID16) + + uuid = b"\x00\x01\x12\x34\x00\x00\x10\x00\x80\x00\x00\x80\x5f\x9b\x34\xfb" + assert type(UUID16.parse_uuid(uuid) is UUID16) + + uuid = 0x0000123400001000800000805F9B34FB + assert type(UUID16.parse_uuid(uuid) is UUID16) + uuid = UUID("00011234-0000-1000-8000-00805F9B34FB") assert type(UUID16.parse_uuid(uuid)) is UUID + uuid = "00011234-0000-1000-8000-00805F9B34FB" + assert type(UUID16.parse_uuid(uuid) is UUID) + + uuid = b"\x00\x00\x12\x34\x00\x00\x10\x00\x80\x00\x00\x80\x5f\x9b\x34\xfb" + assert type(UUID16.parse_uuid(uuid) is UUID) + + uuid = 0x0001123400001000800000805F9B34FB + assert type(UUID16.parse_uuid(uuid) is UUID) + + with self.assertRaises(ValueError): + uuid = UUID16.parse_uuid(object()) + def test_from_uuid(self): with self.assertRaises(ValueError): uuid = UUID16(uuid=uuid1()) @@ -77,3 +98,31 @@ def test_bytes(self): def test_hex(self): uuid = UUID16(uuid=UUID("00001234-0000-1000-8000-00805F9B34FB")) assert uuid.hex == "1234" + + def test_init(self): + with self.assertRaises(TypeError): + uuid = UUID16() + + def test_eq(self): + 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_a + 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(self): + 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) From f926028f2d3c5223d7656a3aad84475fe9132c47 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 14 Sep 2025 17:03:11 +0100 Subject: [PATCH 112/158] Ensure service unregister is called in tests --- tests/gatt/test_characteristic.py | 100 +++++++++++++++++------------- tests/gatt/test_descriptor.py | 25 +++++--- tests/gatt/test_service.py | 24 ++++--- 3 files changed, 90 insertions(+), 59 deletions(-) diff --git a/tests/gatt/test_characteristic.py b/tests/gatt/test_characteristic.py index 9453201..9c7b211 100644 --- a/tests/gatt/test_characteristic.py +++ b/tests/gatt/test_characteristic.py @@ -137,8 +137,10 @@ async def inspector(path): service = TestService() adapter = MockAdapter(inspector) - await service.register(self._bus_manager.bus, self._path, adapter) - await service.unregister() + try: + await service.register(self._bus_manager.bus, self._path, adapter) + finally: + await service.unregister() async def test_write(self): async def inspector(path): @@ -187,8 +189,10 @@ async def inspector(path): service = TestService() adapter = MockAdapter(inspector) - await service.register(self._bus_manager.bus, self._path, adapter) - await service.unregister() + try: + await service.register(self._bus_manager.bus, self._path, adapter) + finally: + await service.unregister() async def test_notify_no_start(self): property_changed = Event() @@ -212,15 +216,17 @@ def on_properties_changed(_0, _1, _2): 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")) + try: + 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." - ) - await service.unregister() + # 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." + ) + finally: + await service.unregister() async def test_notify_start(self): property_changed = Event() @@ -250,17 +256,19 @@ def on_properties_changed(interface, values, invalid_props): 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")) + try: + 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) + 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." - ) - await service.unregister() + # 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." + ) + finally: + await service.unregister() async def test_notify_stop(self): property_changed = Event() @@ -287,15 +295,17 @@ def on_properties_changed(_0, _1, _2): 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")) + try: + 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." - ) - await service.unregister() + # 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." + ) + finally: + await service.unregister() async def test_modify(self): service = TestService() @@ -330,7 +340,7 @@ async def inspector(path): "utf-8" ) == "Some Test Value" else: - try: + with self.assertRaises(ValueError): await get_attrib( self._client_bus, self._bus_manager.name, @@ -339,22 +349,26 @@ async def inspector(path): UUID16("2A38"), UUID16("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_child, some_desc) - - await service.unregister() + try: + await service.register(self._bus_manager.bus, self._path, adapter=adapter) + with self.assertRaises(ValueError): + service.write_notify_char.remove_child(some_desc) + finally: + await service.unregister() service.write_notify_char.remove_child(some_desc) expect_descriptor = False - await service.register(self._bus_manager.bus, self._path, adapter=adapter) - self.assertRaises(ValueError, service.write_notify_char.add_child, some_desc) - await service.unregister() - await service.register(self._bus_manager.bus, self._path, adapter=adapter) - await service.unregister() + try: + await service.register(self._bus_manager.bus, self._path, adapter=adapter) + with self.assertRaises(ValueError): + service.write_notify_char.add_child(some_desc) + finally: + await service.unregister() + + try: + await service.register(self._bus_manager.bus, self._path, adapter=adapter) + finally: + await service.unregister() diff --git a/tests/gatt/test_descriptor.py b/tests/gatt/test_descriptor.py index f92be99..047087e 100644 --- a/tests/gatt/test_descriptor.py +++ b/tests/gatt/test_descriptor.py @@ -96,8 +96,10 @@ async def inspector(path): service = TestService() adapter = MockAdapter(inspector) - await service.register(self._bus_manager.bus, self._path, adapter) - await service.unregister() + try: + await service.register(self._bus_manager.bus, self._path, adapter) + finally: + await service.unregister() async def test_read(self): async def inspector(path): @@ -141,8 +143,10 @@ async def inspector(path): service = TestService() adapter = MockAdapter(inspector) - await service.register(self._bus_manager.bus, self._path, adapter) - await service.unregister() + try: + await service.register(self._bus_manager.bus, self._path, adapter) + finally: + await service.unregister() async def test_write(self): async def inspector(path): @@ -190,13 +194,18 @@ async def inspector(path): service = TestService() adapter = MockAdapter(inspector) - await service.register(self._bus_manager.bus, self._path, adapter) - await service.unregister() + try: + await service.register(self._bus_manager.bus, self._path, adapter) + finally: + await service.unregister() 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) - await service.unregister() + try: + await service.register(self._client_bus, self._path) + finally: + await service.unregister() + diff --git a/tests/gatt/test_service.py b/tests/gatt/test_service.py index 7c0e525..8cb53b9 100644 --- a/tests/gatt/test_service.py +++ b/tests/gatt/test_service.py @@ -55,8 +55,10 @@ async def inspector(path): adapter = MockAdapter(inspector) - await collection.register(self._bus_manager.bus, self._path, adapter) - await collection.unregister() + try: + await collection.register(self._bus_manager.bus, self._path, adapter) + finally: + await collection.unregister() async def test_include_modify(self): service3 = TestService3() @@ -87,15 +89,21 @@ async def inspector(path): assert service3.path in includes adapter = MockAdapter(inspector) - await collection.register(self._bus_manager.bus, self._path, adapter=adapter) - await collection.unregister() + try: + await collection.register(self._bus_manager.bus, self._path, adapter=adapter) + finally: + await collection.unregister() collection.add_child(service3) expect_service3 = True - await collection.register(self._bus_manager.bus, self._path, adapter=adapter) - await collection.unregister() + try: + await collection.register(self._bus_manager.bus, self._path, adapter=adapter) + finally: + await collection.unregister() collection.remove_child(service3) expect_service3 = False - await collection.register(self._bus_manager.bus, self._path, adapter=adapter) - await collection.unregister() + try: + await collection.register(self._bus_manager.bus, self._path, adapter=adapter) + finally: + await collection.unregister() From 37abe3bbc0c993ba66ca1aef4010309998baab19 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 14 Sep 2025 17:03:34 +0100 Subject: [PATCH 113/158] Remove optional qualifier on read/write options --- bluez_peripheral/gatt/base.py | 4 ++-- bluez_peripheral/gatt/characteristic.py | 9 +++------ bluez_peripheral/gatt/descriptor.py | 9 +++------ 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/bluez_peripheral/gatt/base.py b/bluez_peripheral/gatt/base.py index 290458c..022185c 100644 --- a/bluez_peripheral/gatt/base.py +++ b/bluez_peripheral/gatt/base.py @@ -157,12 +157,12 @@ def __init__(self, *args: Any, **kwargs: Any): @staticmethod @abstractmethod - def _parse_read_options(options: Optional[Dict[str, Variant]]) -> ReadOptionsT: + def _parse_read_options(options: Dict[str, Variant]) -> ReadOptionsT: pass @staticmethod @abstractmethod - def _parse_write_options(options: Optional[Dict[str, Variant]]) -> WriteOptionsT: + def _parse_write_options(options: Dict[str, Variant]) -> WriteOptionsT: pass @property diff --git a/bluez_peripheral/gatt/characteristic.py b/bluez_peripheral/gatt/characteristic.py index 233c088..89c34f3 100644 --- a/bluez_peripheral/gatt/characteristic.py +++ b/bluez_peripheral/gatt/characteristic.py @@ -20,10 +20,7 @@ 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, options: Optional[Dict[str, Variant]] = None): - if options is None: - return - + 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)) @@ -202,13 +199,13 @@ def __init__( @staticmethod def _parse_read_options( - options: Optional[Dict[str, Variant]], + options: Dict[str, Variant], ) -> CharacteristicReadOptions: return CharacteristicReadOptions(options) @staticmethod def _parse_write_options( - options: Optional[Dict[str, Variant]], + options: Dict[str, Variant], ) -> CharacteristicWriteOptions: return CharacteristicWriteOptions(options) diff --git a/bluez_peripheral/gatt/descriptor.py b/bluez_peripheral/gatt/descriptor.py index d106c53..27e50c9 100644 --- a/bluez_peripheral/gatt/descriptor.py +++ b/bluez_peripheral/gatt/descriptor.py @@ -19,10 +19,7 @@ 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: Optional[Dict[str, Variant]] = None): - if options is None: - return - + 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) @@ -151,13 +148,13 @@ def __init__( @staticmethod def _parse_read_options( - options: Optional[Dict[str, Variant]], + options: Dict[str, Variant], ) -> DescriptorReadOptions: return DescriptorReadOptions(options) @staticmethod def _parse_write_options( - options: Optional[Dict[str, Variant]], + options: Dict[str, Variant], ) -> DescriptorWriteOptions: return DescriptorWriteOptions(options) From 5c35d9768a5d1253a802429a24be6ca9fc4d7bf0 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 14 Sep 2025 17:03:55 +0100 Subject: [PATCH 114/158] Add empty read/write options test --- tests/gatt/test_characteristic.py | 33 +++++++++++++++++++++++++++++++ tests/gatt/test_descriptor.py | 33 +++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/tests/gatt/test_characteristic.py b/tests/gatt/test_characteristic.py index 9c7b211..9fb5051 100644 --- a/tests/gatt/test_characteristic.py +++ b/tests/gatt/test_characteristic.py @@ -24,6 +24,8 @@ class TestService(Service): def __init__(self): super().__init__("180A") + read_write_val = b'\x05' + @characteristic("2A37", CharacteristicFlags.READ) def read_only_char(self, opts): global last_opts @@ -61,6 +63,14 @@ async def aysnc_write_only_char(self, val, opts): write_only_char_val = val await asyncio.sleep(0.05) + @characteristic("3A33", CharacteristicFlags.WRITE | CharacteristicFlags.READ) + def read_write_char(self, opts): + return self.read_write_val + + @read_write_char.setter + def read_write_char(self, val, opts): + self.read_write_val = val + class TestCharacteristic(IsolatedAsyncioTestCase): async def asyncSetUp(self): @@ -372,3 +382,26 @@ async def inspector(path): await service.register(self._bus_manager.bus, self._path, adapter=adapter) finally: await service.unregister() + + async def test_empty_opts(self): + async def inspector(path): + interface = ( + await get_attrib( + self._client_bus, + self._bus_manager.name, + path, + UUID16("180A"), + UUID16("3A33"), + ) + ).get_interface("org.bluez.GattCharacteristic1") + assert await interface.call_read_value({}) == b'\x05' + await interface.call_write_value(bytes("Test Write Value", "utf-8"), {}) + assert await interface.call_read_value({}) == bytes("Test Write Value", "utf-8") + + service = TestService() + adapter = MockAdapter(inspector) + + try: + await service.register(self._bus_manager.bus, self._path, adapter=adapter) + finally: + await service.unregister() \ No newline at end of file diff --git a/tests/gatt/test_descriptor.py b/tests/gatt/test_descriptor.py index 047087e..e026331 100644 --- a/tests/gatt/test_descriptor.py +++ b/tests/gatt/test_descriptor.py @@ -24,6 +24,8 @@ class TestService(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") @@ -64,6 +66,14 @@ async def async_write_desc_set(self, val, opts): global async_write_desc_val 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 + class TestDescriptor(IsolatedAsyncioTestCase): async def asyncSetUp(self): @@ -209,3 +219,26 @@ async def test_bluez(self): finally: await service.unregister() + async def test_empty_opts(self): + async def inspector(path): + interface = ( + await get_attrib( + self._client_bus, + self._bus_manager.name, + path, + UUID16("180A"), + UUID16("2A37"), + UUID16("3A33"), + ) + ).get_interface("org.bluez.GattDescriptor1") + assert await interface.call_read_value({}) == b'\x05' + await interface.call_write_value(bytes("Test Write Value", "utf-8"), {}) + assert await interface.call_read_value({}) == bytes("Test Write Value", "utf-8") + + service = TestService() + adapter = MockAdapter(inspector) + + try: + await service.register(self._bus_manager.bus, self._path, adapter=adapter) + finally: + await service.unregister() \ No newline at end of file From 545b62c993845c4ae50dbdc11cc255be67c33dea Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 14 Sep 2025 17:06:17 +0100 Subject: [PATCH 115/158] Remove optional write options qualifier --- bluez_peripheral/gatt/characteristic.py | 5 +---- bluez_peripheral/gatt/descriptor.py | 7 ++----- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/bluez_peripheral/gatt/characteristic.py b/bluez_peripheral/gatt/characteristic.py index 89c34f3..52d1e66 100644 --- a/bluez_peripheral/gatt/characteristic.py +++ b/bluez_peripheral/gatt/characteristic.py @@ -60,10 +60,7 @@ 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, options: Optional[Dict[str, Variant]] = None): - if options is None: - return - + def __init__(self, options: Dict[str, Variant]): t = _getattr_variant(options, "type", None) self._type: Optional[CharacteristicWriteType] = None if not t is None: diff --git a/bluez_peripheral/gatt/descriptor.py b/bluez_peripheral/gatt/descriptor.py index 27e50c9..706408c 100644 --- a/bluez_peripheral/gatt/descriptor.py +++ b/bluez_peripheral/gatt/descriptor.py @@ -1,5 +1,5 @@ from enum import Flag, auto -from typing import Callable, Union, Awaitable, Optional, Dict, TYPE_CHECKING, cast +from typing import Callable, Union, Awaitable, Dict, TYPE_CHECKING, cast from dbus_fast import Variant from dbus_fast.service import dbus_property @@ -45,10 +45,7 @@ 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: Optional[Dict[str, Variant]] = None): - if options is None: - return - + 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) From c05493b18963dc3cb87c9baac6999702d2137281 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 14 Sep 2025 17:08:03 +0100 Subject: [PATCH 116/158] Fix test formatting --- tests/gatt/test_characteristic.py | 14 ++++++++------ tests/gatt/test_descriptor.py | 14 ++++++++------ tests/gatt/test_service.py | 12 +++++++++--- tests/test_uuid16.py | 4 ++-- 4 files changed, 27 insertions(+), 17 deletions(-) diff --git a/tests/gatt/test_characteristic.py b/tests/gatt/test_characteristic.py index 9fb5051..10a8eeb 100644 --- a/tests/gatt/test_characteristic.py +++ b/tests/gatt/test_characteristic.py @@ -24,7 +24,7 @@ class TestService(Service): def __init__(self): super().__init__("180A") - read_write_val = b'\x05' + read_write_val = b"\x05" @characteristic("2A37", CharacteristicFlags.READ) def read_only_char(self, opts): @@ -66,7 +66,7 @@ async def aysnc_write_only_char(self, val, opts): @characteristic("3A33", CharacteristicFlags.WRITE | CharacteristicFlags.READ) def read_write_char(self, opts): return self.read_write_val - + @read_write_char.setter def read_write_char(self, val, opts): self.read_write_val = val @@ -377,7 +377,7 @@ async def inspector(path): service.write_notify_char.add_child(some_desc) finally: await service.unregister() - + try: await service.register(self._bus_manager.bus, self._path, adapter=adapter) finally: @@ -394,9 +394,11 @@ async def inspector(path): UUID16("3A33"), ) ).get_interface("org.bluez.GattCharacteristic1") - assert await interface.call_read_value({}) == b'\x05' + assert await interface.call_read_value({}) == b"\x05" await interface.call_write_value(bytes("Test Write Value", "utf-8"), {}) - assert await interface.call_read_value({}) == bytes("Test Write Value", "utf-8") + assert await interface.call_read_value({}) == bytes( + "Test Write Value", "utf-8" + ) service = TestService() adapter = MockAdapter(inspector) @@ -404,4 +406,4 @@ async def inspector(path): try: await service.register(self._bus_manager.bus, self._path, adapter=adapter) finally: - await service.unregister() \ No newline at end of file + await service.unregister() diff --git a/tests/gatt/test_descriptor.py b/tests/gatt/test_descriptor.py index e026331..f80b215 100644 --- a/tests/gatt/test_descriptor.py +++ b/tests/gatt/test_descriptor.py @@ -24,7 +24,7 @@ class TestService(Service): def __init__(self): super().__init__("180A") - read_write_val = b'\x05' + read_write_val = b"\x05" @characteristic("2A37", CharacteristicFlags.RELIABLE_WRITE) def some_char(self, _): @@ -69,7 +69,7 @@ async def async_write_desc_set(self, val, opts): @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 @@ -218,7 +218,7 @@ async def test_bluez(self): await service.register(self._client_bus, self._path) finally: await service.unregister() - + async def test_empty_opts(self): async def inspector(path): interface = ( @@ -231,9 +231,11 @@ async def inspector(path): UUID16("3A33"), ) ).get_interface("org.bluez.GattDescriptor1") - assert await interface.call_read_value({}) == b'\x05' + assert await interface.call_read_value({}) == b"\x05" await interface.call_write_value(bytes("Test Write Value", "utf-8"), {}) - assert await interface.call_read_value({}) == bytes("Test Write Value", "utf-8") + assert await interface.call_read_value({}) == bytes( + "Test Write Value", "utf-8" + ) service = TestService() adapter = MockAdapter(inspector) @@ -241,4 +243,4 @@ async def inspector(path): try: await service.register(self._bus_manager.bus, self._path, adapter=adapter) finally: - await service.unregister() \ No newline at end of file + await service.unregister() diff --git a/tests/gatt/test_service.py b/tests/gatt/test_service.py index 8cb53b9..a75119c 100644 --- a/tests/gatt/test_service.py +++ b/tests/gatt/test_service.py @@ -90,20 +90,26 @@ async def inspector(path): adapter = MockAdapter(inspector) try: - await collection.register(self._bus_manager.bus, self._path, adapter=adapter) + await collection.register( + self._bus_manager.bus, self._path, adapter=adapter + ) finally: await collection.unregister() collection.add_child(service3) expect_service3 = True try: - await collection.register(self._bus_manager.bus, self._path, adapter=adapter) + await collection.register( + self._bus_manager.bus, self._path, adapter=adapter + ) finally: await collection.unregister() collection.remove_child(service3) expect_service3 = False try: - await collection.register(self._bus_manager.bus, self._path, adapter=adapter) + await collection.register( + self._bus_manager.bus, self._path, adapter=adapter + ) finally: await collection.unregister() diff --git a/tests/test_uuid16.py b/tests/test_uuid16.py index e8f9ef6..f982db5 100644 --- a/tests/test_uuid16.py +++ b/tests/test_uuid16.py @@ -106,10 +106,10 @@ def test_init(self): def test_eq(self): 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_a assert uuid16_a != uuid16_b assert uuid16_b != uuid16_a From 2a33cbd4bc382ccdb7b9e4127855bac912c54caa Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 14 Sep 2025 17:09:42 +0100 Subject: [PATCH 117/158] Add toml support dependency --- .github/workflows/python-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index e8e2b6f..53edbff 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -50,7 +50,7 @@ jobs: run: | python -m pip install --upgrade pip pip install -r tests/requirements.txt - pip install coverage + pip install coverage[toml] - name: Add DBus Config run: | sudo cp tests/com.spacecheese.test.conf /etc/dbus-1/system.d From 64872f7daad5e5f582b1847dc40074c3dce8e6cd Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 14 Sep 2025 18:45:51 +0100 Subject: [PATCH 118/158] Unregister adverisements in tests --- tests/test_advert.py | 20 ++++++++++++++++---- tests/util.py | 3 +++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/tests/test_advert.py b/tests/test_advert.py index d416a4a..115ab66 100644 --- a/tests/test_advert.py +++ b/tests/test_advert.py @@ -50,7 +50,10 @@ async def inspector(path): path = "/com/spacecheese/bluez_peripheral/test_advert/advert0" adapter = MockAdapter(inspector) - await advert.register(self._bus_manager.bus, adapter, path) + try: + await advert.register(self._bus_manager.bus, adapter, path) + finally: + await advert.unregister() async def test_includes_empty(self): advert = Advertisement( @@ -74,7 +77,10 @@ async def inspector(path): assert await interface.get_includes() == [] adapter = MockAdapter(inspector) - await advert.register(self._bus_manager.bus, adapter) + try: + await advert.register(self._bus_manager.bus, adapter) + finally: + await advert.unregister() async def test_uuid128(self): advert = Advertisement( @@ -98,7 +104,10 @@ async def inspector(path): ] adapter = MockAdapter(inspector) - await advert.register(self._bus_manager.bus, adapter) + try: + await advert.register(self._bus_manager.bus, adapter) + finally: + await advert.unregister() async def test_real(self): await bluez_available_or_skip(self._client_bus) @@ -111,4 +120,7 @@ async def test_real(self): 2, ) - await advert.register(self._client_bus, adapter) + try: + await advert.register(self._client_bus, adapter) + finally: + await advert.unregister() diff --git a/tests/util.py b/tests/util.py index b7b2ad1..f615097 100644 --- a/tests/util.py +++ b/tests/util.py @@ -75,6 +75,9 @@ async def call_register_application(self, path, obj): async def call_unregister_application(self, path): pass + async def call_unregister_advertisement(self, path): + pass + async def find_attrib(bus, bus_name, path, nodes, target_uuid) -> Tuple[Node, str]: for node in nodes: From d22add017649e093a2182d7589d90b132a7b94e2 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 14 Sep 2025 18:49:06 +0100 Subject: [PATCH 119/158] Document async attribute/ descriptors --- docs/source/services.rst | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/source/services.rst b/docs/source/services.rst index 5f7f89b..c37c003 100644 --- a/docs/source/services.rst +++ b/docs/source/services.rst @@ -57,6 +57,11 @@ The :py:class:`@characteristic Date: Sun, 14 Sep 2025 19:22:52 +0100 Subject: [PATCH 120/158] Implement doctests --- .github/workflows/python-test.yml | 4 ++ bluez_peripheral/gatt/characteristic.py | 6 ++ bluez_peripheral/gatt/service.py | 3 + docs/source/advertising.rst | 22 ++++--- docs/source/conf.py | 1 + docs/source/pairing.rst | 32 +++++---- docs/source/services.rst | 88 +++++++++++++++---------- 7 files changed, 101 insertions(+), 55 deletions(-) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 53edbff..727dfe4 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -26,6 +26,10 @@ jobs: run: | cd docs make spelling + - name: Test Code Snippets + run: | + cd docs + make doctest - name: Link Check run: | cd docs diff --git a/bluez_peripheral/gatt/characteristic.py b/bluez_peripheral/gatt/characteristic.py index 52d1e66..912ce3f 100644 --- a/bluez_peripheral/gatt/characteristic.py +++ b/bluez_peripheral/gatt/characteristic.py @@ -212,6 +212,12 @@ def add_child(self, child: HierarchicalServiceInterface) -> None: child.service = self.service super().add_child(child) + def add_descriptor(self, descriptor: descriptor) -> None: + """ + Associated a descriptor with this characteristic. + """ + self.add_child(descriptor) + @ServiceAttribute.service.setter # type: ignore[misc, attr-defined] def service(self, service: Optional["Service"]) -> None: ServiceAttribute.service.fset(self, service) # type: ignore[attr-defined] diff --git a/bluez_peripheral/gatt/service.py b/bluez_peripheral/gatt/service.py index 395443f..418572b 100644 --- a/bluez_peripheral/gatt/service.py +++ b/bluez_peripheral/gatt/service.py @@ -59,6 +59,9 @@ def add_child(self, child: HierarchicalServiceInterface) -> None: child.service = self super().add_child(child) + def add_characteristic(self, characteristic: characteristic) -> None: + self.add_child(characteristic) + async def register( self, bus: MessageBus, diff --git a/docs/source/advertising.rst b/docs/source/advertising.rst index 873e922..6dc583e 100644 --- a/docs/source/advertising.rst +++ b/docs/source/advertising.rst @@ -14,19 +14,23 @@ A minimal :py:class:`advert` requires: * 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). -.. code-block:: python +.. testcode:: from bluez_peripheral import get_message_bus, Advertisement - from bluez_peripheral.util import Adapter + from bluez_peripheral.adapter import Adapter - adapter = await Adapter.get_first(bus) + 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"], 0x0340, 60) - await advert.register(bus, adapter) + # "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"], 0x0340, 60) + await advert.register(bus, adapter) + + if __name__ == "__main__": + asyncio.run(main()) .. TODO: Advertising includes .. TODO: Advertisable characteristics diff --git a/docs/source/conf.py b/docs/source/conf.py index 91ea684..e5c0552 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -37,6 +37,7 @@ "sphinx.ext.intersphinx", "sphinx.ext.napoleon", "sphinx.ext.linkcode", + "sphinx.ext.doctest", "sphinx_inline_tabs", "m2r2", ] diff --git a/docs/source/pairing.rst b/docs/source/pairing.rst index 4109df3..4bdf051 100644 --- a/docs/source/pairing.rst +++ b/docs/source/pairing.rst @@ -38,16 +38,17 @@ There are three potential sources of agents: 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: - .. code-block:: python + .. testcode:: from bluez_peripheral import get_message_bus from bluez_peripheral.agent import NoIoAgent - bus = await get_message_bus() + async def agent_builtin(): + bus = await get_message_bus() - agent = NoIoAgent() - # By default agents are registered as default. - await agent.register(bus, default=True) + agent = NoIoAgent() + # By default agents are registered as default. + await agent.register(bus, default=True) # OR @@ -64,22 +65,31 @@ There are three potential sources of agents: # TODO: Notify the user that pairing was cancelled by the other device. pass - agent = YesNoAgent(accept_pairing, cancel_pairing) - await agent.register(bus) + 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: - .. code-block:: python + .. testcode:: from bluez_peripheral import get_message_bus from bluez_peripheral.agent import TestAgent - bus = await get_message_bus() + async def main(): + bus = await get_message_bus() + + agent = TestAgent() + await agent.register(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`). diff --git a/docs/source/services.rst b/docs/source/services.rst index c37c003..44a2f06 100644 --- a/docs/source/services.rst +++ b/docs/source/services.rst @@ -20,10 +20,9 @@ The :py:class:`@characteristic` parameter to select portions of the data. This is dependent upon the :ref:`options.mtu`. -.. TODO: Code examples need automated testing. .. tab:: Decorators - .. code-block:: python + .. testcode:: from bluez_peripheral.gatt import Service from bluez_peripheral.gatt import characteristic, CharacteristicFlags as CharFlags @@ -58,13 +57,13 @@ The :py:class:`@characteristic` function may only be called in the same thread that registered the service. -.. code-block:: python +.. 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") + def __init__(self): + super().__init__(uuid="DEED") - @characteristic("DEEE", CharFlags.NOTIFY) - def my_notify_characteristic(self, options): - pass + @characteristic("DEEE", CharFlags.NOTIFY) + def my_notify_characteristic(self, options): + pass - my_service = MyService() + async def main(): + my_service = MyService() - bus = await get_message_bus() - await my_service.register(bus) + 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")) - # 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() - # Yeild so that the service can handle requests and signal the change. - await bus.wait_for_disconnect() + if __name__ == "__main__": + asyncio.run(main()) .. seealso:: From 70e8b2c14456a73a4d5fa569800f01268a7a4582 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 14 Sep 2025 19:25:52 +0100 Subject: [PATCH 121/158] Add test readme.rst --- README.rst | 118 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 README.rst diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..77c5c8a --- /dev/null +++ b/README.rst @@ -0,0 +1,118 @@ +bluez-peripheral +================ + +`Documentation `_ + +`PyPi `_ + +`GitHub `_ + +A bluez-peripheral is a library for building Bluetooth Low Energy (BLE) peripherals/servers using the Bluez (Linux) GATT API. + +Who this Library is For +----------------------- + +- Developers using Python and Linux (and Bluez). +- Wishing to develop a bluetooth compatible peripheral (ie. something that other devices connect to). +- With low bandwidth requirements (ie. not streaming audio). + +Installation +------------ + +Install bluez (eg. ``sudo apt-get install bluez``) + +.. testcode:: + + pip install bluez-peripheral + +GATT Overview +------------- + +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 `_. + +.. image:: https://doc.qt.io/qt-5/images/peripheral-structure.png + :alt: Peripheral Hierarchy Diagram + +_Courtesy 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, Characteristics and Descriptors) are identified by a 16-bit number `assigned by the Bluetooth SIG `_. + +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 `_. `This video `_ gives a more in-depth overview of BLE. + +Usage +----- + +When using this library please remember that services are not implicitly threaded. **The thread used to register your service must regularly yield otherwise your service will not work** (particularly notifications). Therefore you must frequently yield to the asyncio event loop (for example using asyncio.sleep) and ideally use multithreading. + +The easiest way to use the library is to create a class describing the service that you wish to provide. + +.. testcode:: + + from bluez_peripheral.gatt import Service, characteristic, CharacteristicFlags as CharFlags + import struct + + class HeartRateService(Service): + def __init__(self): + # Base 16 service UUID, This should be a primary service. + super().__init__("180D", True) + + # Characteristics and Descriptors can have multiple flags set at once. + @characteristic("2A37", CharFlags.NOTIFY | CharFlags.READ) + def heart_rate_measurement(self, options): + # This function is called when the characteristic is read. + # Since this characteristic is notify only this function is a placeholder. + # You don't need this function Python 3.9+ (See PEP 614). + # You can generally ignore the options argument + # (see Advanced Characteristics and Descriptors Documentation). + pass + + def update_heart_rate(self, new_rate): + # Call this when you get a new heartrate reading. + # Note that notification is asynchronous (you must await something at some point after calling this). + flags = 0 + + # Bluetooth data is little endian. + rate = struct.pack("`_. From cc4a1ddb1f205cd0ab8f7d7079b1c29c2c6fd356 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 14 Sep 2025 19:27:22 +0100 Subject: [PATCH 122/158] Revert "Add test readme.rst" This reverts commit 70e8b2c14456a73a4d5fa569800f01268a7a4582. --- README.rst | 118 ----------------------------------------------------- 1 file changed, 118 deletions(-) delete mode 100644 README.rst diff --git a/README.rst b/README.rst deleted file mode 100644 index 77c5c8a..0000000 --- a/README.rst +++ /dev/null @@ -1,118 +0,0 @@ -bluez-peripheral -================ - -`Documentation `_ - -`PyPi `_ - -`GitHub `_ - -A bluez-peripheral is a library for building Bluetooth Low Energy (BLE) peripherals/servers using the Bluez (Linux) GATT API. - -Who this Library is For ------------------------ - -- Developers using Python and Linux (and Bluez). -- Wishing to develop a bluetooth compatible peripheral (ie. something that other devices connect to). -- With low bandwidth requirements (ie. not streaming audio). - -Installation ------------- - -Install bluez (eg. ``sudo apt-get install bluez``) - -.. testcode:: - - pip install bluez-peripheral - -GATT Overview -------------- - -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 `_. - -.. image:: https://doc.qt.io/qt-5/images/peripheral-structure.png - :alt: Peripheral Hierarchy Diagram - -_Courtesy 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, Characteristics and Descriptors) are identified by a 16-bit number `assigned by the Bluetooth SIG `_. - -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 `_. `This video `_ gives a more in-depth overview of BLE. - -Usage ------ - -When using this library please remember that services are not implicitly threaded. **The thread used to register your service must regularly yield otherwise your service will not work** (particularly notifications). Therefore you must frequently yield to the asyncio event loop (for example using asyncio.sleep) and ideally use multithreading. - -The easiest way to use the library is to create a class describing the service that you wish to provide. - -.. testcode:: - - from bluez_peripheral.gatt import Service, characteristic, CharacteristicFlags as CharFlags - import struct - - class HeartRateService(Service): - def __init__(self): - # Base 16 service UUID, This should be a primary service. - super().__init__("180D", True) - - # Characteristics and Descriptors can have multiple flags set at once. - @characteristic("2A37", CharFlags.NOTIFY | CharFlags.READ) - def heart_rate_measurement(self, options): - # This function is called when the characteristic is read. - # Since this characteristic is notify only this function is a placeholder. - # You don't need this function Python 3.9+ (See PEP 614). - # You can generally ignore the options argument - # (see Advanced Characteristics and Descriptors Documentation). - pass - - def update_heart_rate(self, new_rate): - # Call this when you get a new heartrate reading. - # Note that notification is asynchronous (you must await something at some point after calling this). - flags = 0 - - # Bluetooth data is little endian. - rate = struct.pack("`_. From 15d5344f3795bc38087347bcbdafd108425ce736 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 14 Sep 2025 19:29:18 +0100 Subject: [PATCH 123/158] Fix lint issues --- bluez_peripheral/gatt/characteristic.py | 4 ++-- bluez_peripheral/gatt/service.py | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/bluez_peripheral/gatt/characteristic.py b/bluez_peripheral/gatt/characteristic.py index 912ce3f..ce604a8 100644 --- a/bluez_peripheral/gatt/characteristic.py +++ b/bluez_peripheral/gatt/characteristic.py @@ -212,11 +212,11 @@ def add_child(self, child: HierarchicalServiceInterface) -> None: child.service = self.service super().add_child(child) - def add_descriptor(self, descriptor: descriptor) -> None: + def add_descriptor(self, desc: "descriptor") -> None: """ Associated a descriptor with this characteristic. """ - self.add_child(descriptor) + self.add_child(desc) @ServiceAttribute.service.setter # type: ignore[misc, attr-defined] def service(self, service: Optional["Service"]) -> None: diff --git a/bluez_peripheral/gatt/service.py b/bluez_peripheral/gatt/service.py index 418572b..0b488ce 100644 --- a/bluez_peripheral/gatt/service.py +++ b/bluez_peripheral/gatt/service.py @@ -59,8 +59,11 @@ def add_child(self, child: HierarchicalServiceInterface) -> None: child.service = self super().add_child(child) - def add_characteristic(self, characteristic: characteristic) -> None: - self.add_child(characteristic) + def add_characteristic(self, char: characteristic) -> None: + """ + Associated a characteristic with this service. + """ + self.add_child(char) async def register( self, From eb4033ab22d9a8612b046af5940178c4d8c8218e Mon Sep 17 00:00:00 2001 From: Space Cheese <12695808+spacecheese@users.noreply.github.com> Date: Mon, 29 Dec 2025 16:01:13 +0000 Subject: [PATCH 124/158] Fix ci issues --- bluez_peripheral/adapter.py | 6 +++--- bluez_peripheral/gatt/characteristic.py | 2 +- docs/source/spelling_wordlist.txt | 3 ++- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/bluez_peripheral/adapter.py b/bluez_peripheral/adapter.py index c45f6bf..501c126 100644 --- a/bluez_peripheral/adapter.py +++ b/bluez_peripheral/adapter.py @@ -23,7 +23,7 @@ async def get_paired(self) -> bool: return await self._device_interface.get_paired() # type: ignore async def pair(self) -> None: - """Attemps to pair the parent adapter with this device.""" + """Attempts to pair the parent adapter with this device.""" await self._device_interface.call_pair() # type: ignore async def get_name(self) -> str: @@ -33,7 +33,7 @@ async def get_name(self) -> str: 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 + await interface.call_remove_device(self._device_interface._path) # type: ignore # pylint: disable=protected-access class Adapter: @@ -139,7 +139,7 @@ async def start_discovery(self) -> None: await self._adapter_interface.call_start_discovery() # type: ignore async def stop_discovery(self) -> None: - """Stop searching for other blutooth devices.""" + """Stop searching for other bluetooth devices.""" await self._adapter_interface.call_stop_discovery() # type: ignore async def get_devices(self) -> Sequence[Device]: diff --git a/bluez_peripheral/gatt/characteristic.py b/bluez_peripheral/gatt/characteristic.py index 8de8406..0c0eb31 100644 --- a/bluez_peripheral/gatt/characteristic.py +++ b/bluez_peripheral/gatt/characteristic.py @@ -220,7 +220,7 @@ def add_descriptor(self, desc: "descriptor") -> None: @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 + 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 diff --git a/docs/source/spelling_wordlist.txt b/docs/source/spelling_wordlist.txt index b515220..172bbb7 100644 --- a/docs/source/spelling_wordlist.txt +++ b/docs/source/spelling_wordlist.txt @@ -30,4 +30,5 @@ responder unexport unexported LEAdvertisingManager -GattManager \ No newline at end of file +GattManager +unpairs \ No newline at end of file From 8fe2659e7c024b773b499b5d024f363b109760a1 Mon Sep 17 00:00:00 2001 From: Space Cheese <12695808+spacecheese@users.noreply.github.com> Date: Mon, 29 Dec 2025 16:09:44 +0000 Subject: [PATCH 125/158] Move unit tests to separate module --- .github/workflows/python-test.yml | 10 +++++++--- .gitignore | 6 +++++- tests/{ => loopback}/__init__.py | 0 {bluez_images => tests/loopback}/test.sh | 0 {bluez_images => tests/loopback}/test_wrapper.sh | 0 tests/{ => unit}/README.md | 0 tests/{gatt => unit}/__init__.py | 0 tests/{ => unit}/com.spacecheese.test.conf | 0 tests/unit/gatt/__init__.py | 0 tests/{ => unit}/gatt/test_characteristic.py | 2 +- tests/{ => unit}/gatt/test_descriptor.py | 2 +- tests/{ => unit}/gatt/test_service.py | 2 +- tests/{ => unit}/requirements.txt | 0 tests/{ => unit}/test.py | 0 tests/{ => unit}/test_advert.py | 2 +- tests/{ => unit}/test_agent.py | 2 +- tests/{ => unit}/test_util.py | 2 +- tests/{ => unit}/test_uuid16.py | 0 tests/{ => unit}/util.py | 0 19 files changed, 18 insertions(+), 10 deletions(-) rename tests/{ => loopback}/__init__.py (100%) rename {bluez_images => tests/loopback}/test.sh (100%) rename {bluez_images => tests/loopback}/test_wrapper.sh (100%) rename tests/{ => unit}/README.md (100%) rename tests/{gatt => unit}/__init__.py (100%) rename tests/{ => unit}/com.spacecheese.test.conf (100%) create mode 100644 tests/unit/gatt/__init__.py rename tests/{ => unit}/gatt/test_characteristic.py (99%) rename tests/{ => unit}/gatt/test_descriptor.py (99%) rename tests/{ => unit}/gatt/test_service.py (98%) rename tests/{ => unit}/requirements.txt (100%) rename tests/{ => unit}/test.py (100%) rename tests/{ => unit}/test_advert.py (99%) rename tests/{ => unit}/test_agent.py (98%) rename tests/{ => unit}/test_util.py (99%) rename tests/{ => unit}/test_uuid16.py (100%) rename tests/{ => unit}/util.py (100%) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index f57ef5b..cd881c6 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -34,7 +34,8 @@ jobs: run: | cd docs make linkcheck - test: + + test-unit: runs-on: ubuntu-22.04 strategy: matrix: @@ -57,10 +58,10 @@ jobs: 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: | - coverage run --source=. --branch -m unittest discover -s tests -p "test_*.py" -v + coverage run --source=. --branch -m unittest discover -s tests/unit -p "test_*.py" -v - name: Report Coverage run: | coverage html @@ -70,6 +71,7 @@ jobs: with: name: coverage-${{ matrix.python-version }} path: htmlcov/ + format-check: runs-on: ubuntu-22.04 steps: @@ -88,6 +90,7 @@ jobs: run: | python -m black --check bluez_peripheral python -m black --check tests + type-hint-check: runs-on: ubuntu-22.04 steps: @@ -105,6 +108,7 @@ jobs: - name: Check type hints run: | python -m mypy bluez_peripheral + lint-check: runs-on: ubuntu-22.04 steps: diff --git a/.gitignore b/.gitignore index 1eecd93..ca6a204 100644 --- a/.gitignore +++ b/.gitignore @@ -138,4 +138,8 @@ dmypy.json cython_debug/ # VSCode Config Files -.vscode/ \ No newline at end of file +.vscode/ + +# Testing images +tests/loopback/*.qcow2 +tests/loopback/id_* \ No newline at end of file diff --git a/tests/__init__.py b/tests/loopback/__init__.py similarity index 100% rename from tests/__init__.py rename to tests/loopback/__init__.py diff --git a/bluez_images/test.sh b/tests/loopback/test.sh similarity index 100% rename from bluez_images/test.sh rename to tests/loopback/test.sh diff --git a/bluez_images/test_wrapper.sh b/tests/loopback/test_wrapper.sh similarity index 100% rename from bluez_images/test_wrapper.sh rename to tests/loopback/test_wrapper.sh diff --git a/tests/README.md b/tests/unit/README.md similarity index 100% rename from tests/README.md rename to tests/unit/README.md diff --git a/tests/gatt/__init__.py b/tests/unit/__init__.py similarity index 100% rename from tests/gatt/__init__.py rename to tests/unit/__init__.py 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/gatt/__init__.py b/tests/unit/gatt/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/gatt/test_characteristic.py b/tests/unit/gatt/test_characteristic.py similarity index 99% rename from tests/gatt/test_characteristic.py rename to tests/unit/gatt/test_characteristic.py index 10a8eeb..7fcc0d8 100644 --- a/tests/gatt/test_characteristic.py +++ b/tests/unit/gatt/test_characteristic.py @@ -1,6 +1,6 @@ from unittest import IsolatedAsyncioTestCase from threading import Event -from tests.util import * +from unit.util import * import re from bluez_peripheral.uuid16 import UUID16 diff --git a/tests/gatt/test_descriptor.py b/tests/unit/gatt/test_descriptor.py similarity index 99% rename from tests/gatt/test_descriptor.py rename to tests/unit/gatt/test_descriptor.py index f80b215..c87d09e 100644 --- a/tests/gatt/test_descriptor.py +++ b/tests/unit/gatt/test_descriptor.py @@ -4,7 +4,7 @@ from dbus_fast.signature import Variant -from tests.util import * +from unit.util import * from bluez_peripheral import get_message_bus from bluez_peripheral.gatt import ( diff --git a/tests/gatt/test_service.py b/tests/unit/gatt/test_service.py similarity index 98% rename from tests/gatt/test_service.py rename to tests/unit/gatt/test_service.py index a75119c..b85a905 100644 --- a/tests/gatt/test_service.py +++ b/tests/unit/gatt/test_service.py @@ -1,4 +1,4 @@ -from tests.util import ParallelBus, MockAdapter, get_attrib +from unit.util import ParallelBus, MockAdapter, get_attrib import re from typing import Collection diff --git a/tests/requirements.txt b/tests/unit/requirements.txt similarity index 100% rename from tests/requirements.txt rename to tests/unit/requirements.txt diff --git a/tests/test.py b/tests/unit/test.py similarity index 100% rename from tests/test.py rename to tests/unit/test.py diff --git a/tests/test_advert.py b/tests/unit/test_advert.py similarity index 99% rename from tests/test_advert.py rename to tests/unit/test_advert.py index 115ab66..422ad32 100644 --- a/tests/test_advert.py +++ b/tests/unit/test_advert.py @@ -1,7 +1,7 @@ from unittest import IsolatedAsyncioTestCase from unittest.case import SkipTest -from tests.util import * +from unit.util import * from bluez_peripheral import get_message_bus from bluez_peripheral.advert import Advertisement, AdvertisingIncludes from bluez_peripheral.flags import AdvertisingPacketType diff --git a/tests/test_agent.py b/tests/unit/test_agent.py similarity index 98% rename from tests/test_agent.py rename to tests/unit/test_agent.py index c196e3b..98a2566 100644 --- a/tests/test_agent.py +++ b/tests/unit/test_agent.py @@ -1,6 +1,6 @@ from unittest import IsolatedAsyncioTestCase -from tests.util import * +from util import * from bluez_peripheral.util import get_message_bus from bluez_peripheral.agent import AgentCapability, BaseAgent diff --git a/tests/test_util.py b/tests/unit/test_util.py similarity index 99% rename from tests/test_util.py rename to tests/unit/test_util.py index 3195b57..595dbfb 100644 --- a/tests/test_util.py +++ b/tests/unit/test_util.py @@ -1,6 +1,6 @@ from unittest import IsolatedAsyncioTestCase -from tests.util import * +from util import * from bluez_peripheral.util import * diff --git a/tests/test_uuid16.py b/tests/unit/test_uuid16.py similarity index 100% rename from tests/test_uuid16.py rename to tests/unit/test_uuid16.py diff --git a/tests/util.py b/tests/unit/util.py similarity index 100% rename from tests/util.py rename to tests/unit/util.py From a344375d309bbc6f86a8c1991faa92254f6211c1 Mon Sep 17 00:00:00 2001 From: Space Cheese <12695808+spacecheese@users.noreply.github.com> Date: Mon, 29 Dec 2025 16:10:52 +0000 Subject: [PATCH 126/158] Fix "Install dependencies" --- .github/workflows/python-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index cd881c6..85ed492 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -54,7 +54,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r tests/requirements.txt + pip install -r tests/unit/requirements.txt pip install "coverage[toml]" - name: Add DBus Config run: | From 8878341e2e497b253793bfbf35814761996d4cce Mon Sep 17 00:00:00 2001 From: Space Cheese <12695808+spacecheese@users.noreply.github.com> Date: Mon, 29 Dec 2025 16:13:37 +0000 Subject: [PATCH 127/158] Fix unit test imports --- tests/__init__.py | 0 tests/unit/gatt/test_characteristic.py | 2 +- tests/unit/gatt/test_descriptor.py | 2 +- tests/unit/gatt/test_service.py | 2 +- tests/unit/test_advert.py | 2 +- 5 files changed, 4 insertions(+), 4 deletions(-) create mode 100644 tests/__init__.py diff --git a/tests/__init__.py b/tests/__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 index 7fcc0d8..fa4be09 100644 --- a/tests/unit/gatt/test_characteristic.py +++ b/tests/unit/gatt/test_characteristic.py @@ -1,6 +1,6 @@ from unittest import IsolatedAsyncioTestCase from threading import Event -from unit.util import * +from tests.unit.util import * import re from bluez_peripheral.uuid16 import UUID16 diff --git a/tests/unit/gatt/test_descriptor.py b/tests/unit/gatt/test_descriptor.py index c87d09e..55aa00f 100644 --- a/tests/unit/gatt/test_descriptor.py +++ b/tests/unit/gatt/test_descriptor.py @@ -4,7 +4,7 @@ from dbus_fast.signature import Variant -from unit.util import * +from tests.unit.util import * from bluez_peripheral import get_message_bus from bluez_peripheral.gatt import ( diff --git a/tests/unit/gatt/test_service.py b/tests/unit/gatt/test_service.py index b85a905..bfea773 100644 --- a/tests/unit/gatt/test_service.py +++ b/tests/unit/gatt/test_service.py @@ -1,4 +1,4 @@ -from unit.util import ParallelBus, MockAdapter, get_attrib +from tests.unit.util import ParallelBus, MockAdapter, get_attrib import re from typing import Collection diff --git a/tests/unit/test_advert.py b/tests/unit/test_advert.py index 422ad32..dbd4875 100644 --- a/tests/unit/test_advert.py +++ b/tests/unit/test_advert.py @@ -1,7 +1,7 @@ from unittest import IsolatedAsyncioTestCase from unittest.case import SkipTest -from unit.util import * +from tests.unit.util import * from bluez_peripheral import get_message_bus from bluez_peripheral.advert import Advertisement, AdvertisingIncludes from bluez_peripheral.flags import AdvertisingPacketType From f6064a5da023715d5730d5b0f162b90185ff411c Mon Sep 17 00:00:00 2001 From: Space Cheese <12695808+spacecheese@users.noreply.github.com> Date: Mon, 29 Dec 2025 20:05:37 +0000 Subject: [PATCH 128/158] Fix some state issues during exceptions --- bluez_peripheral/adapter.py | 2 +- bluez_peripheral/advert.py | 6 +++--- bluez_peripheral/agent.py | 6 ++++-- bluez_peripheral/error.py | 2 +- bluez_peripheral/gatt/base.py | 3 +-- bluez_peripheral/gatt/service.py | 15 +++++++++------ bluez_peripheral/util.py | 3 +-- tests/unit/test_advert.py | 13 +++++++++++-- tests/unit/test_util.py | 6 ++++++ 9 files changed, 37 insertions(+), 19 deletions(-) diff --git a/bluez_peripheral/adapter.py b/bluez_peripheral/adapter.py index 501c126..351d9b7 100644 --- a/bluez_peripheral/adapter.py +++ b/bluez_peripheral/adapter.py @@ -2,7 +2,7 @@ from dbus_fast.aio import MessageBus, ProxyInterface from dbus_fast.aio.proxy_object import ProxyObject -from dbus_fast.errors import InvalidIntrospectionError, InterfaceNotFoundError +from dbus_fast import InvalidIntrospectionError, InterfaceNotFoundError from .util import _kebab_to_shouting_snake from .flags import AdvertisingIncludes diff --git a/bluez_peripheral/advert.py b/bluez_peripheral/advert.py index f96917b..4a3e227 100644 --- a/bluez_peripheral/advert.py +++ b/bluez_peripheral/advert.py @@ -113,9 +113,6 @@ async def register( ) Advertisement._defaultPathAdvertCount += 1 - self._export_bus = bus - self._export_path = path - # Export this advert to the dbus. bus.export(path, self) @@ -128,6 +125,9 @@ async def register( interface = adapter.get_advertising_manager() await interface.call_register_advertisement(path, {}) # type: ignore + self._export_bus = bus + self._export_path = path + @method("Release") def _release(self): # type: ignore assert self._export_bus is not None diff --git a/bluez_peripheral/agent.py b/bluez_peripheral/agent.py index 2814b10..4b8d572 100644 --- a/bluez_peripheral/agent.py +++ b/bluez_peripheral/agent.py @@ -79,14 +79,15 @@ async def register( 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) interface = await self._get_manager_interface(bus) await interface.call_register_agent(path, self._get_capability()) # type: ignore + self._path = path + if default: - await interface.call_request_default_agent(self._path) # type: ignore + await interface.call_request_default_agent(path) # type: ignore async def unregister(self, bus: MessageBus) -> None: """Unregister this agent with bluez and remove it from the specified message bus. @@ -101,6 +102,7 @@ async def unregister(self, bus: MessageBus) -> None: await interface.call_unregister_agent(self._path) # type: ignore bus.unexport(self._path, self._INTERFACE) + self._path = None class TestAgent(BaseAgent): diff --git a/bluez_peripheral/error.py b/bluez_peripheral/error.py index e77bfa8..adb94b8 100644 --- a/bluez_peripheral/error.py +++ b/bluez_peripheral/error.py @@ -1,4 +1,4 @@ -from dbus_fast.errors import DBusError +from dbus_fast import DBusError class FailedError(DBusError): diff --git a/bluez_peripheral/gatt/base.py b/bluez_peripheral/gatt/base.py index 022185c..d338ea3 100644 --- a/bluez_peripheral/gatt/base.py +++ b/bluez_peripheral/gatt/base.py @@ -13,8 +13,7 @@ TYPE_CHECKING, ) -from dbus_fast import Variant -from dbus_fast.errors import DBusError +from dbus_fast import Variant, DBusError from dbus_fast.constants import PropertyAccess from dbus_fast.service import method, ServiceInterface, dbus_property from dbus_fast.aio.message_bus import MessageBus diff --git a/bluez_peripheral/gatt/service.py b/bluez_peripheral/gatt/service.py index 0b488ce..b6722f9 100644 --- a/bluez_peripheral/gatt/service.py +++ b/bluez_peripheral/gatt/service.py @@ -79,8 +79,9 @@ 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, adapter) + self._collection = collection async def unregister(self) -> None: """Unregister this service. @@ -90,6 +91,7 @@ async def unregister(self) -> None: return await self._collection.unregister() + self._collection = None @dbus_property(PropertyAccess.READ, "UUID") def _get_uuid(self) -> "s": # type: ignore @@ -147,14 +149,15 @@ 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. """ - self._path = path - self._bus = bus self._adapter = await Adapter.get_first(bus) if adapter is None else adapter - self.export(self._bus, path=path) + self.export(bus, path=path) manager = self._adapter.get_gatt_manager() - await manager.call_register_application(self._path, {}) # type: ignore + await manager.call_register_application(path, {}) # type: ignore + + self._path = path + self._bus = bus async def unregister(self) -> None: """Unregister this service using the bluez service manager.""" diff --git a/bluez_peripheral/util.py b/bluez_peripheral/util.py index 9240783..a19b5cc 100644 --- a/bluez_peripheral/util.py +++ b/bluez_peripheral/util.py @@ -1,8 +1,7 @@ from typing import Any, Dict -from dbus_fast import Variant +from dbus_fast import Variant, DBusError from dbus_fast.constants import BusType -from dbus_fast.errors import DBusError from dbus_fast.aio.message_bus import MessageBus diff --git a/tests/unit/test_advert.py b/tests/unit/test_advert.py index dbd4875..9efdb41 100644 --- a/tests/unit/test_advert.py +++ b/tests/unit/test_advert.py @@ -113,14 +113,23 @@ async def test_real(self): await bluez_available_or_skip(self._client_bus) adapter = await get_first_adapter_or_skip(self._client_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"], - 0x0340, - 2, + appearance=0x0340, + timeout=2, ) try: await advert.register(self._client_bus, adapter) finally: await advert.unregister() + + await adapter.set_discoverable(initial_discoverable) + await adapter.set_powered(initial_powered) diff --git a/tests/unit/test_util.py b/tests/unit/test_util.py index 595dbfb..28beacf 100644 --- a/tests/unit/test_util.py +++ b/tests/unit/test_util.py @@ -41,21 +41,27 @@ async def test_powered(self): async def test_discoverable(self): initial_discoverable = await self._adapter.get_discoverable() + initial_powered = await self._adapter.get_powered() + + await self._adapter.set_powered(True) 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) + await self._adapter.set_powered(initial_powered) async def test_pairable(self): initial_pairable = await self._adapter.get_pairable() + initial_powered = await self._adapter.get_powered() 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) + await self._adapter.set_powered(initial_powered) async def test_pairable_timeout(self): initial_pairable_timeout = await self._adapter.get_pairable_timeout() From 65187e6b26bbf271a5746cd8f8deb9a88ab2cc97 Mon Sep 17 00:00:00 2001 From: Space Cheese <12695808+spacecheese@users.noreply.github.com> Date: Mon, 29 Dec 2025 20:06:27 +0000 Subject: [PATCH 129/158] Bump ubuntu versions --- .github/workflows/python-publish.yml | 3 +-- .github/workflows/python-test.yml | 10 +++++----- .readthedocs.yaml | 2 +- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index e9f5fd8..6827736 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -6,8 +6,7 @@ on: jobs: deploy: - - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v3 diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 85ed492..237248c 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -4,7 +4,7 @@ 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 @@ -36,7 +36,7 @@ jobs: make linkcheck test-unit: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 strategy: matrix: python-version: ["3.9", "3.10", "3.11", "3.12"] @@ -73,7 +73,7 @@ jobs: path: htmlcov/ format-check: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v3 - name: Set up Python @@ -92,7 +92,7 @@ jobs: python -m black --check tests type-hint-check: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v3 - name: Set up Python @@ -110,7 +110,7 @@ jobs: python -m mypy bluez_peripheral lint-check: - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v3 - name: Set up Python diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 85f07ba..b079729 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -1,7 +1,7 @@ version: 2 build: - os: ubuntu-22.04 + os: ubuntu-24.04 tools: python: "3.11" From 5ff58ba510de6bf348c57f986b30b143f8a75aee Mon Sep 17 00:00:00 2001 From: Space Cheese <12695808+spacecheese@users.noreply.github.com> Date: Mon, 29 Dec 2025 20:08:00 +0000 Subject: [PATCH 130/158] Implement test-qemu job --- .github/workflows/python-test.yml | 24 +++++++++++++++++++++++- .gitignore | 3 +-- tests/{unit => loopback}/test.py | 2 +- tests/loopback/test.sh | 23 +++++++++++++++++------ tests/loopback/test_wrapper.sh | 6 ------ tests/{unit => }/requirements.txt | 0 6 files changed, 42 insertions(+), 16 deletions(-) rename tests/{unit => loopback}/test.py (96%) delete mode 100755 tests/loopback/test_wrapper.sh rename tests/{unit => }/requirements.txt (100%) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 237248c..554fd0b 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -35,7 +35,7 @@ jobs: cd docs make linkcheck - test-unit: + test-container: runs-on: ubuntu-24.04 strategy: matrix: @@ -71,6 +71,28 @@ jobs: 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: + - 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 diff --git a/.gitignore b/.gitignore index ca6a204..cef3daf 100644 --- a/.gitignore +++ b/.gitignore @@ -141,5 +141,4 @@ cython_debug/ .vscode/ # Testing images -tests/loopback/*.qcow2 -tests/loopback/id_* \ No newline at end of file +tests/loopback/assets \ No newline at end of file diff --git a/tests/unit/test.py b/tests/loopback/test.py similarity index 96% rename from tests/unit/test.py rename to tests/loopback/test.py index 8b1fb86..24772cb 100755 --- a/tests/unit/test.py +++ b/tests/loopback/test.py @@ -66,7 +66,7 @@ async def main(): print(f"Advertising on {await adapters[0].get_name()}") advert = Advertisement( - "Heart Monitor", ["180D", "1234"], 0x0340, 60 * 5, duration=5 + "Heart Monitor", ["180D", "1234"], appearance=0x0340, timeout=60 * 5, duration=5 ) await advert.register(bus, adapters[0]) diff --git a/tests/loopback/test.sh b/tests/loopback/test.sh index 31dc908..113c4d6 100755 --- a/tests/loopback/test.sh +++ b/tests/loopback/test.sh @@ -2,8 +2,10 @@ set -euo pipefail IMAGE="${1}" +KEY_FILE="${2}" +PROJ_ROOT="${3}" -SSH="ssh -i id_ed25519 -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" +SSH="ssh -i $KEY_FILE -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" echo "[*] Starting QEMU session" qemu-system-x86_64 \ @@ -45,20 +47,29 @@ fi echo "[*] Copying bluez_peripheral" rsync -a --progress --rsync-path="sudo rsync" \ - -e "$SSH -p 2244" --exclude bluez_images \ - "../" tester@localhost:/bluez_peripheral + -e "$SSH -p 2244" --delete \ + --exclude $IMAGE \ + --exclude $KEY_FILE \ + --exclude .git \ + --exclude serial.log \ + $PROJ_ROOT tester@localhost:/bluez_peripheral -echo "[*] Testing adapter" $SSH -p 2244 tester@localhost " + set -euo pipefail + + echo '[*] Setting Up Dependencies' python3 -m venv ~/venv source ~/venv/bin/activate - pip install -r /bluez_peripheral/tests/requirements.txt + python3 -m pip install -r /bluez_peripheral/tests/requirements.txt sudo nohup btvirt -L -l2 >/dev/null 2>&1 & sudo service bluetooth start + echo '[*] Running Tests' cd /bluez_peripheral - python3 -m tests.test + sudo cp tests/unit/com.spacecheese.test.conf /etc/dbus-1/system.d + python3 -m unittest discover -s tests/unit -p 'test_*.py' -v + python3 -m unittest discover -s tests/loopback -p 'test_*.py' -v sudo shutdown -h now " wait $QEMU_PID diff --git a/tests/loopback/test_wrapper.sh b/tests/loopback/test_wrapper.sh deleted file mode 100755 index b7cf97c..0000000 --- a/tests/loopback/test_wrapper.sh +++ /dev/null @@ -1,6 +0,0 @@ -wget -O id_ed25519 https://github.com/spacecheese/bluez_images/releases/download/0.0.13/id_ed25519 -wget -O image.qcow2 https://github.com/spacecheese/bluez_images/releases/download/0.0.13/ubuntu-24.04-bluez-5.66.qcow2 - - -chmod 600 id_ed25519 -./test.sh image.qcow2 \ No newline at end of file diff --git a/tests/unit/requirements.txt b/tests/requirements.txt similarity index 100% rename from tests/unit/requirements.txt rename to tests/requirements.txt From 4169f874dc9b9ce0c71625934f941952971d44f8 Mon Sep 17 00:00:00 2001 From: Space Cheese <12695808+spacecheese@users.noreply.github.com> Date: Mon, 29 Dec 2025 20:08:27 +0000 Subject: [PATCH 131/158] Fix doc references to Advertisement.__init__ args --- README.md | 3 ++- docs/source/advertising.rst | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4ee569f..42d41dd 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,8 @@ async def main(): 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/docs/source/advertising.rst b/docs/source/advertising.rst index 6dc583e..73c99d6 100644 --- a/docs/source/advertising.rst +++ b/docs/source/advertising.rst @@ -26,7 +26,7 @@ A minimal :py:class:`advert` requires: # "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"], 0x0340, 60) + advert = Advertisement("Heart Monitor", ["180D"], appearance=0x0340, timeout=60) await advert.register(bus, adapter) if __name__ == "__main__": From 5160ae492fdc21b92ae679f7d946a5a7c245bd77 Mon Sep 17 00:00:00 2001 From: Space Cheese <12695808+spacecheese@users.noreply.github.com> Date: Mon, 29 Dec 2025 20:14:50 +0000 Subject: [PATCH 132/158] Workflow fixes --- .github/workflows/python-test.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 554fd0b..ae1216a 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -54,7 +54,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r tests/unit/requirements.txt + pip install -r tests/requirements.txt pip install "coverage[toml]" - name: Add DBus Config run: | @@ -79,6 +79,7 @@ jobs: 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: Download qemu images env: image_repo: "spacecheese/bluez_images" From 04a9be627248795ef565d0f8fefcc15f5bb08a00 Mon Sep 17 00:00:00 2001 From: Space Cheese <12695808+spacecheese@users.noreply.github.com> Date: Mon, 29 Dec 2025 20:18:59 +0000 Subject: [PATCH 133/158] Install qemu runtime dependencies --- .github/workflows/python-test.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index ae1216a..591a6c7 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -80,6 +80,10 @@ jobs: 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" From 3a5d809a49637560075b0e4a0d40d3e98ce04f28 Mon Sep 17 00:00:00 2001 From: Space Cheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 30 Dec 2025 16:33:24 +0000 Subject: [PATCH 134/158] Add cross references to org.bluez pages --- bluez_peripheral/adapter.py | 8 ++++++-- bluez_peripheral/advert.py | 1 + bluez_peripheral/agent.py | 1 + bluez_peripheral/gatt/characteristic.py | 1 + bluez_peripheral/gatt/descriptor.py | 1 + bluez_peripheral/gatt/service.py | 2 +- 6 files changed, 11 insertions(+), 3 deletions(-) diff --git a/bluez_peripheral/adapter.py b/bluez_peripheral/adapter.py index 351d9b7..1ef55d2 100644 --- a/bluez_peripheral/adapter.py +++ b/bluez_peripheral/adapter.py @@ -9,7 +9,9 @@ class Device: - """A bluetooth device discovered by an adapter.""" + """A bluetooth device discovered by an adapter. + Represents an `org.bluez.Device1 `_ instance. + """ _INTERFACE = "org.bluez.Device1" _device_interface: ProxyInterface @@ -37,7 +39,9 @@ async def remove(self, adapter: "Adapter") -> None: class Adapter: - """A bluetooth adapter.""" + """A bluetooth adapter. + Represents an `org.bluez.Adapter1 `_ instance. + """ BUS_INTERFACE = "org.bluez.Adapter1" _GATT_MANAGER_INTERFACE = "org.bluez.GattManager1" diff --git a/bluez_peripheral/advert.py b/bluez_peripheral/advert.py index 4a3e227..b5c730b 100644 --- a/bluez_peripheral/advert.py +++ b/bluez_peripheral/advert.py @@ -17,6 +17,7 @@ class Advertisement(ServiceInterface): """ 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. diff --git a/bluez_peripheral/agent.py b/bluez_peripheral/agent.py index 4b8d572..175d9ba 100644 --- a/bluez_peripheral/agent.py +++ b/bluez_peripheral/agent.py @@ -34,6 +34,7 @@ class AgentCapability(Enum): class BaseAgent(ServiceInterface): """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. diff --git a/bluez_peripheral/gatt/characteristic.py b/bluez_peripheral/gatt/characteristic.py index 0c0eb31..860a476 100644 --- a/bluez_peripheral/gatt/characteristic.py +++ b/bluez_peripheral/gatt/characteristic.py @@ -170,6 +170,7 @@ class characteristic( 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 `_ diff --git a/bluez_peripheral/gatt/descriptor.py b/bluez_peripheral/gatt/descriptor.py index 706408c..b69fcc4 100644 --- a/bluez_peripheral/gatt/descriptor.py +++ b/bluez_peripheral/gatt/descriptor.py @@ -116,6 +116,7 @@ class descriptor( 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 Assigned Numbers `_ diff --git a/bluez_peripheral/gatt/service.py b/bluez_peripheral/gatt/service.py index b6722f9..0466c3a 100644 --- a/bluez_peripheral/gatt/service.py +++ b/bluez_peripheral/gatt/service.py @@ -11,9 +11,9 @@ from ..adapter import Adapter -# See https://github.com/bluez/bluez/blob/master/doc/org.bluez.GattService.rst 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 Assigned Numbers `_ From ee25f9419fafa6ab232914dfc46d8ab80f61f6a3 Mon Sep 17 00:00:00 2001 From: Space Cheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 30 Dec 2025 17:52:40 +0000 Subject: [PATCH 135/158] Replace m2r2 with sphinx_include --- docs/requirements.txt | 2 +- docs/source/conf.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 9652cbd..22b4b6c 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -3,5 +3,5 @@ sphinx sphinx-inline-tabs sphinxcontrib-spelling furo -m2r2 +sphinx_mdinclude interrogate \ No newline at end of file diff --git a/docs/source/conf.py b/docs/source/conf.py index e5c0552..0cb1b01 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -39,7 +39,7 @@ "sphinx.ext.linkcode", "sphinx.ext.doctest", "sphinx_inline_tabs", - "m2r2", + "sphinx_mdinclude", ] # Add any paths that contain templates here, relative to this directory. From 61912d2c1b58fb5155f0b410c84ac33d0276a53b Mon Sep 17 00:00:00 2001 From: Space Cheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 30 Dec 2025 17:54:33 +0000 Subject: [PATCH 136/158] Link to raw rst to avoid upsetting github --- bluez_peripheral/adapter.py | 4 ++-- bluez_peripheral/advert.py | 2 +- bluez_peripheral/agent.py | 2 +- bluez_peripheral/gatt/characteristic.py | 4 ++-- bluez_peripheral/gatt/descriptor.py | 4 ++-- bluez_peripheral/gatt/service.py | 2 +- docs/source/advertising.rst | 2 +- docs/source/conf.py | 1 - docs/source/pairing.rst | 2 +- docs/source/services.rst | 6 +++--- 10 files changed, 14 insertions(+), 15 deletions(-) diff --git a/bluez_peripheral/adapter.py b/bluez_peripheral/adapter.py index 1ef55d2..a6a924f 100644 --- a/bluez_peripheral/adapter.py +++ b/bluez_peripheral/adapter.py @@ -10,7 +10,7 @@ class Device: """A bluetooth device discovered by an adapter. - Represents an `org.bluez.Device1 `_ instance. + Represents an `org.bluez.Device1 `_ instance. """ _INTERFACE = "org.bluez.Device1" @@ -40,7 +40,7 @@ async def remove(self, adapter: "Adapter") -> None: class Adapter: """A bluetooth adapter. - Represents an `org.bluez.Adapter1 `_ instance. + Represents an `org.bluez.Adapter1 https://raw.githubusercontent.com/bluez/bluez/refs/heads/master/doc/org.bluez.Adapter.rst>`_ instance. """ BUS_INTERFACE = "org.bluez.Adapter1" diff --git a/bluez_peripheral/advert.py b/bluez_peripheral/advert.py index b5c730b..9f3aaa1 100644 --- a/bluez_peripheral/advert.py +++ b/bluez_peripheral/advert.py @@ -17,7 +17,7 @@ class Advertisement(ServiceInterface): """ 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. + Represents an `org.bluez.LEAdvertisement1 `_ instance. Args: localName: The device name to advertise. diff --git a/bluez_peripheral/agent.py b/bluez_peripheral/agent.py index 175d9ba..72922b8 100644 --- a/bluez_peripheral/agent.py +++ b/bluez_peripheral/agent.py @@ -34,7 +34,7 @@ class AgentCapability(Enum): class BaseAgent(ServiceInterface): """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. + Represents an `org.bluez.Agent1 `_ instance. Args: capability: The IO capabilities of the agent. diff --git a/bluez_peripheral/gatt/characteristic.py b/bluez_peripheral/gatt/characteristic.py index 860a476..1cbc1c5 100644 --- a/bluez_peripheral/gatt/characteristic.py +++ b/bluez_peripheral/gatt/characteristic.py @@ -107,7 +107,7 @@ def prepare_authorize(self) -> bool: 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 `_. + These are converted to `bluez flags `_. """ INVALID = 0 @@ -170,7 +170,7 @@ class characteristic( HierarchicalServiceInterface, ): # pylint: disable=invalid-name """Create a new characteristic with a specified UUID and flags. - Represents an `org.bluez.GattCharacteristic1 `_ instance. + 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 `_ diff --git a/bluez_peripheral/gatt/descriptor.py b/bluez_peripheral/gatt/descriptor.py index b69fcc4..6db4094 100644 --- a/bluez_peripheral/gatt/descriptor.py +++ b/bluez_peripheral/gatt/descriptor.py @@ -74,7 +74,7 @@ def prepare_authorize(self) -> bool: 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 `_. + These are converted to `bluez flags `_. """ INVALID = 0 @@ -116,7 +116,7 @@ class descriptor( 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. + 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 Assigned Numbers `_ diff --git a/bluez_peripheral/gatt/service.py b/bluez_peripheral/gatt/service.py index 0466c3a..42087b8 100644 --- a/bluez_peripheral/gatt/service.py +++ b/bluez_peripheral/gatt/service.py @@ -13,7 +13,7 @@ class Service(HierarchicalServiceInterface): """Create a bluetooth service with the specified uuid. - Represents an `org.bluez.GattService1 `_ instance. + 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 Assigned Numbers `_ diff --git a/docs/source/advertising.rst b/docs/source/advertising.rst index 73c99d6..3cd221a 100644 --- a/docs/source/advertising.rst +++ b/docs/source/advertising.rst @@ -38,4 +38,4 @@ A minimal :py:class:`advert` requires: .. seealso:: Bluez Documentation - `Advertising API `_ + `Advertising API `_ diff --git a/docs/source/conf.py b/docs/source/conf.py index 0cb1b01..eb44876 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -55,7 +55,6 @@ autodoc_typehints = "description" linkcheck_timeout = 10 -linkcheck_workers = 1 # -- Linkcode ---------------------------------------------------------------- def _get_git_ref(): diff --git a/docs/source/pairing.rst b/docs/source/pairing.rst index 4bdf051..b80c5e2 100644 --- a/docs/source/pairing.rst +++ b/docs/source/pairing.rst @@ -146,4 +146,4 @@ Note that IO Capability is not the only factor in selecting a pairing algorithm. Vol 3, Part H, Table 2.8 (source of :ref:`pairing-io`) Bluez Documentation - `Agent API `_ + `Agent API `_ diff --git a/docs/source/services.rst b/docs/source/services.rst index 44a2f06..162c8af 100644 --- a/docs/source/services.rst +++ b/docs/source/services.rst @@ -222,9 +222,9 @@ Characteristics with the :py:attr:`~bluez_peripheral.gatt.characteristic.Charact .. seealso:: Bluez Documentation - `Service API `_ - `Characteristic API `_ - `Descriptor API `_ + `Service API `_ + `Characteristic API `_ + `Descriptor API `_ .. _attribute-options: From 657b56138fbcb1abea6199023f603f984e2c4b00 Mon Sep 17 00:00:00 2001 From: Space Cheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 30 Dec 2025 17:55:39 +0000 Subject: [PATCH 137/158] Format docs conf.py --- docs/source/conf.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index eb44876..7901a31 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -60,20 +60,27 @@ def _get_git_ref(): try: ref = ( - subprocess.check_output(["git", "describe", "--tags", "--exact-match"], stderr=subprocess.DEVNULL) + 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) + 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 @@ -95,7 +102,7 @@ def linkcode_resolve(domain, info): lines, lineno = inspect.getsourcelines(obj) except Exception: return None - + if src is None: return None From 87bf66de25a2d6fff22c77b056a7e39076f986b5 Mon Sep 17 00:00:00 2001 From: Space Cheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 30 Dec 2025 17:55:52 +0000 Subject: [PATCH 138/158] Update gatt structure diagram link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 42d41dd..2d5da58 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Install bluez (eg. `sudo apt-get install bluez`) 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)_ From f3f6a400cc5da0241572b2af1f81486927eaa090 Mon Sep 17 00:00:00 2001 From: Space Cheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 30 Dec 2025 17:59:55 +0000 Subject: [PATCH 139/158] Add contributing guide --- .pre-commit-config.yaml | 56 +++++++++++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 27 ++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 .pre-commit-config.yaml create mode 100644 CONTRIBUTING.md diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..f6b69f3 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,56 @@ +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/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 From 819125190cf61c600d4cd049ee09ff9263f47926 Mon Sep 17 00:00:00 2001 From: Space Cheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 30 Dec 2025 18:01:37 +0000 Subject: [PATCH 140/158] Remove aspell dependency --- .github/workflows/python-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 591a6c7..ff76eb5 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -16,7 +16,7 @@ jobs: 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 From cdf30f12c4d70d486594e8d446ac2a54d178e59b Mon Sep 17 00:00:00 2001 From: Space Cheese <12695808+spacecheese@users.noreply.github.com> Date: Tue, 30 Dec 2025 18:39:44 +0000 Subject: [PATCH 141/158] Remove deprecated license tags --- setup.cfg | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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] From db147e3b2c00ca63a1dbbec02911c324926f1e3f Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sat, 3 Jan 2026 20:39:48 +0000 Subject: [PATCH 142/158] Port tests to pytest --- bluez_peripheral/adapter.py | 45 +- bluez_peripheral/advert.py | 47 +- bluez_peripheral/agent.py | 23 +- bluez_peripheral/base.py | 76 +++ bluez_peripheral/gatt/base.py | 48 +- bluez_peripheral/gatt/characteristic.py | 10 +- bluez_peripheral/gatt/descriptor.py | 8 +- bluez_peripheral/gatt/service.py | 25 +- bluez_peripheral/uuid16.py | 4 +- docs/source/ref/bluez_peripheral.base.rst | 7 + docs/source/ref/bluez_peripheral.rst | 1 + tests/conftest.py | 10 + tests/loopback/README.md | 0 tests/loopback/conftest.py | 83 +++ tests/loopback/gatt/__init__.py | 0 tests/loopback/test.py | 105 ---- tests/loopback/test.sh | 8 +- tests/loopback/test_advert.py | 37 ++ tests/requirements.txt | 5 +- tests/unit/README.md | 2 +- tests/unit/conftest.py | 15 + tests/unit/gatt/test_characteristic.py | 586 ++++++++-------------- tests/unit/gatt/test_descriptor.py | 320 +++++------- tests/unit/gatt/test_service.py | 204 ++++---- tests/unit/test_advert.py | 257 +++++----- tests/unit/test_agent.py | 64 +-- tests/unit/test_util.py | 183 ++++--- tests/unit/test_uuid16.py | 191 +++---- tests/unit/util.py | 329 ++++++++---- 29 files changed, 1393 insertions(+), 1300 deletions(-) create mode 100644 bluez_peripheral/base.py create mode 100644 docs/source/ref/bluez_peripheral.base.rst create mode 100644 tests/conftest.py create mode 100644 tests/loopback/README.md create mode 100644 tests/loopback/conftest.py create mode 100644 tests/loopback/gatt/__init__.py delete mode 100755 tests/loopback/test.py create mode 100644 tests/loopback/test_advert.py create mode 100644 tests/unit/conftest.py diff --git a/bluez_peripheral/adapter.py b/bluez_peripheral/adapter.py index a6a924f..9293777 100644 --- a/bluez_peripheral/adapter.py +++ b/bluez_peripheral/adapter.py @@ -1,4 +1,4 @@ -from typing import Sequence +from typing import Collection, Dict, Tuple, List from dbus_fast.aio import MessageBus, ProxyInterface from dbus_fast.aio.proxy_object import ProxyObject @@ -6,6 +6,7 @@ from .util import _kebab_to_shouting_snake from .flags import AdvertisingIncludes +from .uuid16 import UUID16, UUIDLike class Device: @@ -28,22 +29,44 @@ async def pair(self) -> None: """Attempts to pair the parent adapter with this device.""" await self._device_interface.call_pair() # type: ignore - async def get_name(self) -> str: - """Returns the display name of this device.""" - return await self._device_interface.get_name() # 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 https://raw.githubusercontent.com/bluez/bluez/refs/heads/master/doc/org.bluez.Adapter.rst>`_ instance. + Represents an `org.bluez.Adapter1 `_ instance. """ - BUS_INTERFACE = "org.bluez.Adapter1" + _INTERFACE = "org.bluez.Adapter1" _GATT_MANAGER_INTERFACE = "org.bluez.GattManager1" _ADVERTISING_MANAGER_INTERFACE = "org.bluez.LEAdvertisingManager1" _proxy: ProxyObject @@ -51,7 +74,7 @@ class Adapter: def __init__(self, proxy: ProxyObject): self._proxy = proxy - self._adapter_interface = proxy.get_interface(self.BUS_INTERFACE) + self._adapter_interface = proxy.get_interface(self._INTERFACE) def get_adapter_interface(self) -> ProxyInterface: """Returns the org.bluez.Adapter associated with this adapter.""" @@ -146,8 +169,8 @@ 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) -> Sequence[Device]: - """Returns a sequence of devices which have been discovered by this adapter.""" + 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 @@ -173,7 +196,7 @@ async def get_devices(self) -> Sequence[Device]: return devices @classmethod - async def get_all(cls, bus: MessageBus) -> Sequence["Adapter"]: + async def get_all(cls, bus: MessageBus) -> List["Adapter"]: """Get a list of available Bluetooth adapters. Args: diff --git a/bluez_peripheral/advert.py b/bluez_peripheral/advert.py index 9f3aaa1..d52d23d 100644 --- a/bluez_peripheral/advert.py +++ b/bluez_peripheral/advert.py @@ -4,17 +4,18 @@ from dbus_fast import Variant from dbus_fast.constants import PropertyAccess -from dbus_fast.service import ServiceInterface, method, dbus_property +from dbus_fast.service import method, dbus_property from dbus_fast.aio.message_bus import MessageBus -from .uuid16 import UUID16, UUIDCompatible +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. @@ -37,21 +38,20 @@ class Advertisement(ServiceInterface): """ _INTERFACE = "org.bluez.LEAdvertisement1" - - _defaultPathAdvertCount = 0 + _DEFAULT_PATH_PREFIX = "/com/spacecheese/bluez_peripheral/advert" def __init__( self, local_name: str, - service_uuids: Collection[UUIDCompatible], + service_uuids: Collection[UUIDLike], *, appearance: Union[int, bytes], timeout: int = 0, discoverable: bool = True, packet_type: AdvertisingPacketType = AdvertisingPacketType.PERIPHERAL, manufacturer_data: Optional[Dict[int, bytes]] = None, - solicit_uuids: Optional[Collection[UUIDCompatible]] = None, - service_data: Optional[List[Tuple[UUIDCompatible, bytes]]] = None, + solicit_uuids: Optional[Collection[UUIDLike]] = None, + service_data: Optional[List[Tuple[UUIDLike, bytes]]] = None, includes: AdvertisingIncludes = AdvertisingIncludes.NONE, duration: int = 2, release_callback: Optional[Callable[[], None]] = None, @@ -88,17 +88,16 @@ def __init__( self._duration = duration self._release_callback = release_callback - self._export_bus: Optional[MessageBus] = None - self._export_path: Optional[str] = None self._adapter: Optional[Adapter] = None - super().__init__(self._INTERFACE) + super().__init__() async def register( self, bus: MessageBus, - adapter: Optional[Adapter] = None, + *, path: Optional[str] = None, + adapter: Optional[Adapter] = None, ) -> None: """Register this advert with bluez to start advertising. @@ -107,47 +106,33 @@ 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 - # 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) - self._adapter = adapter - # Get the LEAdvertisingManager1 interface for the target adapter. interface = adapter.get_advertising_manager() await interface.call_register_advertisement(path, {}) # type: ignore - self._export_bus = bus - self._export_path = path + self._adapter = adapter @method("Release") def _release(self): # type: ignore - assert self._export_bus is not None - assert self._export_path is not None - self._export_bus.unexport(self._export_path, self._INTERFACE) + self.unexport() async def unregister(self) -> None: """ Unregister this advertisement from bluez to stop advertising. """ - if not self._export_bus or not self._adapter or not self._export_path: - return + if not self._adapter: + raise ValueError("This advertisement is not registered") interface = self._adapter.get_advertising_manager() await interface.call_unregister_advertisement(self._export_path) # type: ignore - self._export_bus = None self._adapter = None - self._export_path = None if self._release_callback is not None: self._release_callback() diff --git a/bluez_peripheral/agent.py b/bluez_peripheral/agent.py index 72922b8..8688e9e 100644 --- a/bluez_peripheral/agent.py +++ b/bluez_peripheral/agent.py @@ -1,12 +1,13 @@ from typing import Awaitable, Callable, Optional from enum import Enum -from dbus_fast.service import ServiceInterface, method +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): @@ -31,7 +32,7 @@ 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. @@ -42,6 +43,7 @@ class BaseAgent(ServiceInterface): _INTERFACE = "org.bluez.Agent1" _MANAGER_INTERFACE = "org.bluez.AgentManager1" + _DEFAULT_PATH_PREFIX = "/com/spacecheese/bluez_peripheral/agent" def __init__( self, @@ -50,7 +52,7 @@ def __init__( self._capability: AgentCapability = capability self._path: Optional[str] = None - super().__init__(self._INTERFACE) + super().__init__() @method("Release") def _release(self): # type: ignore @@ -69,7 +71,7 @@ async def _get_manager_interface(self, bus: MessageBus) -> ProxyInterface: 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. @@ -80,7 +82,7 @@ async def register( The invoking process requires superuser if this is true. path: The path to expose this message bus on. """ - 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()) # type: ignore @@ -90,19 +92,20 @@ async def register( if default: await interface.call_request_default_agent(path) # type: ignore - async def unregister(self, bus: MessageBus) -> None: + 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 self._path is None: - return + 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(bus) + interface = await self._get_manager_interface(self._export_bus) await interface.call_unregister_agent(self._path) # type: ignore - bus.unexport(self._path, self._INTERFACE) + self.unexport() self._path = None 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/gatt/base.py b/bluez_peripheral/gatt/base.py index d338ea3..31a4027 100644 --- a/bluez_peripheral/gatt/base.py +++ b/bluez_peripheral/gatt/base.py @@ -15,34 +15,29 @@ from dbus_fast import Variant, DBusError from dbus_fast.constants import PropertyAccess -from dbus_fast.service import method, ServiceInterface, dbus_property +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(ServiceInterface): +class HierarchicalServiceInterface(BaseServiceInterface): """ Base class for a member of a hierarchy of ServiceInterfaces which should be exported and unexported as a group. """ - BUS_PREFIX = "" + _BUS_PREFIX = "" """ - The prefix used by default when exporting this ServiceInterface as a child of another component. - """ - - BUS_INTERFACE = "" - """ - The dbus interface name implemented by this component. + The prefix used by default when exporting this ServiceInterface as a child of another component. """ def __init__(self) -> None: - super().__init__(name=self.BUS_INTERFACE) + super().__init__() - self._export_path: Optional[str] = None self._parent: Optional["HierarchicalServiceInterface"] = None self._children: list["HierarchicalServiceInterface"] = [] @@ -66,20 +61,6 @@ def remove_child(self, child: "HierarchicalServiceInterface") -> None: self._children.remove(child) child._parent = None # pylint: disable=protected-access - @property - def export_path(self) -> Optional[str]: - """ - The path on which this service is exported (or None). - """ - return self._export_path - - @property - def is_exported(self) -> bool: - """ - Indicates whether this service is exported or not. - """ - return self._export_path is not None - def export( self, bus: MessageBus, *, num: Optional[int] = 0, path: Optional[str] = None ) -> None: @@ -92,34 +73,27 @@ def export( 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 self.is_exported: - raise ValueError("Cannot export an already exported component") - if path is None: if self._parent is not None: - path = f"{self._parent.export_path}/{self.BUS_PREFIX}{num}" + path = f"{self._parent.export_path}/{self._BUS_PREFIX}{num}" else: raise ValueError("path or parent must be specified") - bus.export(path, self) - self._export_path = path - + super().export(bus, path=path) for i, c in enumerate(self._children): c.export(bus, num=i) - def unexport(self, bus: MessageBus) -> None: + 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") - assert self._export_path is not None for c in self._children: - c.unexport(bus) + c.unexport() - bus.unexport(self._export_path, self.BUS_INTERFACE) - self._export_path = None + super().unexport() ReadOptionsT = TypeVar("ReadOptionsT") diff --git a/bluez_peripheral/gatt/characteristic.py b/bluez_peripheral/gatt/characteristic.py index 1cbc1c5..2466f54 100644 --- a/bluez_peripheral/gatt/characteristic.py +++ b/bluez_peripheral/gatt/characteristic.py @@ -6,7 +6,7 @@ from dbus_fast.service import method, dbus_property from .base import HierarchicalServiceInterface, ServiceAttribute -from ..uuid16 import UUIDCompatible, UUID16 +from ..uuid16 import UUIDLike, UUID16 from ..util import _snake_to_kebab, _getattr_variant from ..error import NotSupportedError from .descriptor import DescriptorFlags, descriptor @@ -180,12 +180,12 @@ class characteristic( :ref:`services` """ - BUS_PREFIX = "char" - BUS_INTERFACE = "org.bluez.GattCharacteristic1" + _INTERFACE = "org.bluez.GattCharacteristic1" + _BUS_PREFIX = "char" def __init__( self, - uuid: UUIDCompatible, + uuid: UUIDLike, flags: CharacteristicFlags = CharacteristicFlags.READ, ): super().__init__() @@ -238,7 +238,7 @@ def changed(self, new_value: bytes) -> None: def descriptor( self, - uuid: UUIDCompatible, + uuid: UUIDLike, flags: DescriptorFlags = DescriptorFlags.READ, ) -> descriptor: """Create a new descriptor with the specified UUID and Flags. diff --git a/bluez_peripheral/gatt/descriptor.py b/bluez_peripheral/gatt/descriptor.py index 6db4094..6d8e625 100644 --- a/bluez_peripheral/gatt/descriptor.py +++ b/bluez_peripheral/gatt/descriptor.py @@ -6,7 +6,7 @@ from dbus_fast.constants import PropertyAccess from .base import HierarchicalServiceInterface, ServiceAttribute -from ..uuid16 import UUID16, UUIDCompatible +from ..uuid16 import UUID16, UUIDLike from ..util import _snake_to_kebab, _getattr_variant if TYPE_CHECKING: @@ -127,12 +127,12 @@ class descriptor( :ref:`services` """ - BUS_PREFIX = "desc" - BUS_INTERFACE = "org.bluez.GattDescriptor1" + _INTERFACE = "org.bluez.GattDescriptor1" + _BUS_PREFIX = "desc" def __init__( self, - uuid: UUIDCompatible, + uuid: UUIDLike, characteristic: "characteristic", flags: DescriptorFlags = DescriptorFlags.READ, ): diff --git a/bluez_peripheral/gatt/service.py b/bluez_peripheral/gatt/service.py index 42087b8..98fab70 100644 --- a/bluez_peripheral/gatt/service.py +++ b/bluez_peripheral/gatt/service.py @@ -7,7 +7,7 @@ from .base import HierarchicalServiceInterface from .characteristic import characteristic -from ..uuid16 import UUID16, UUIDCompatible +from ..uuid16 import UUID16, UUIDLike from ..adapter import Adapter @@ -22,8 +22,8 @@ class Service(HierarchicalServiceInterface): Services must be registered at the time Includes is read to be included. """ - BUS_INTERFACE = "org.bluez.GattService1" - BUS_PREFIX = "service" + _INTERFACE = "org.bluez.GattService1" + _BUS_PREFIX = "service" def _populate(self) -> None: # Only interested in characteristic members. @@ -38,7 +38,7 @@ def _populate(self) -> None: def __init__( self, - uuid: UUIDCompatible, + uuid: UUIDLike, primary: bool = True, includes: Optional[Collection["Service"]] = None, ): @@ -68,7 +68,8 @@ def add_characteristic(self, char: characteristic) -> None: async def register( self, bus: MessageBus, - path: str = "/com/spacecheese/bluez_peripheral", + *, + path: str, adapter: Optional[Adapter] = None, ) -> None: """Register this service as a standalone service. @@ -80,7 +81,7 @@ async def register( adapter: The adapter that will provide this service or None to select the first adapter. """ collection = ServiceCollection([self]) - await collection.register(bus, path, adapter) + await collection.register(bus, path=path, adapter=adapter) self._collection = collection async def unregister(self) -> None: @@ -117,7 +118,7 @@ def _get_includes(self) -> "ao": # type: ignore class ServiceCollection(HierarchicalServiceInterface): """A collection of services that are registered with the bluez GATT manager as a group.""" - BUS_INTERFACE = "org.spacecheese.ServiceCollection1" + _INTERFACE = "org.spacecheese.ServiceCollection1" def __init__(self, services: Optional[List[Service]] = None): """Create a service collection populated with the specified list of services. @@ -130,13 +131,13 @@ def __init__(self, services: Optional[List[Service]] = None): for s in services: self.add_child(s) - self._path: Optional[str] = None self._bus: Optional[MessageBus] = None self._adapter: Optional[Adapter] = None async def register( self, bus: MessageBus, + *, path: str = "/com/spacecheese/bluez_peripheral", adapter: Optional[Adapter] = None, ) -> None: @@ -156,23 +157,19 @@ async def register( manager = self._adapter.get_gatt_manager() await manager.call_register_application(path, {}) # type: ignore - self._path = path self._bus = bus async def unregister(self) -> None: """Unregister this service using the bluez service manager.""" if not self.is_exported: return - assert self._path is not None assert self._bus is not None assert self._adapter is not None manager = self._adapter.get_gatt_manager() + await manager.call_unregister_application(self.export_path) # type: ignore - await manager.call_unregister_application(self._path) # type: ignore - - self.unexport(self._bus) + self.unexport() - self._path = None self._adapter = None self._bus = None diff --git a/bluez_peripheral/uuid16.py b/bluez_peripheral/uuid16.py index 1a67ca4..20700fd 100644 --- a/bluez_peripheral/uuid16.py +++ b/bluez_peripheral/uuid16.py @@ -2,7 +2,7 @@ from typing import Union, Optional from uuid import UUID -UUIDCompatible = Union[str, bytes, UUID, "UUID16", int] +UUIDLike = Union[str, bytes, UUID, "UUID16", int] class UUID16: @@ -74,7 +74,7 @@ def is_in_range(cls, uuid: UUID) -> bool: return uuid.fields[1:5] == cls._FIELDS[1:5] @classmethod - def parse_uuid(cls, uuid: UUIDCompatible) -> Union[UUID, "UUID16"]: + 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): 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.rst b/docs/source/ref/bluez_peripheral.rst index fe111f1..12bb3e7 100644 --- a/docs/source/ref/bluez_peripheral.rst +++ b/docs/source/ref/bluez_peripheral.rst @@ -18,6 +18,7 @@ Submodules bluez_peripheral.adapter bluez_peripheral.advert bluez_peripheral.agent + bluez_peripheral.base bluez_peripheral.error bluez_peripheral.flags bluez_peripheral.util diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..95ea201 --- /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() \ No newline at end of file diff --git a/tests/loopback/README.md b/tests/loopback/README.md 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..3a1dbc7 --- /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/gatt/__init__.py b/tests/loopback/gatt/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/loopback/test.py b/tests/loopback/test.py deleted file mode 100755 index 24772cb..0000000 --- a/tests/loopback/test.py +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env python3 - -import asyncio -from dbus_fast.service import ServiceInterface, method -from dbus_fast.aio import MessageBus -from dbus_fast.constants import BusType - -from bluez_peripheral.advert import Advertisement -from bluez_peripheral.adapter import Adapter -from bluez_peripheral.util import get_message_bus -from bluez_peripheral.agent import BaseAgent, TestAgent, AgentCapability - - -class TrivialAgent(BaseAgent): - @method() - def Cancel(): # type: ignore - return - - @method() - def Release(): # type: ignore - return - - @method() - def RequestPinCode(self, device: "o") -> "s": # type: ignore - breakpoint() - pass - - @method() - def DisplayPinCode(self, device: "o", pincode: "s"): # type: ignore - return - - @method() - def RequestPasskey(self, device: "o") -> "u": # type: ignore - return - - @method() - def DisplayPasskey(self, device: "o", passkey: "u", entered: "q"): # type: ignore - return - - @method() - def RequestConfirmation(self, device: "o", passkey: "u"): # type: ignore - return - - @method() - def RequestAuthorization(self, device: "o"): # type: ignore - return - - @method() - def AuthorizeService(self, device: "o", uuid: "s"): # type: ignore - return - - -async def main(): - bus = await get_message_bus() - - adapters = await Adapter.get_all(bus) - - # Enable adapter settings - 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].set_discoverable(True) - await adapters[1].set_pairable(True) - - print(f"Advertising on {await adapters[0].get_name()}") - advert = Advertisement( - "Heart Monitor", ["180D", "1234"], appearance=0x0340, timeout=60 * 5, duration=5 - ) - await advert.register(bus, adapters[0]) - - print(f"Starting scan on {await adapters[1].get_name()}") - await adapters[1].start_discovery() - - agent = TrivialAgent(AgentCapability.KEYBOARD_DISPLAY) - await agent.register(bus) - - devices = [] - print("Waiting for devices", end="") - while len(devices) == 0: - await asyncio.sleep(1) - print(".", end="") - devices = await adapters[1].get_devices() - print("") - - for d in devices: - print(f"Found '{await d.get_name()}'") - if not await d.get_paired(): - print(" Pairing") - await d.pair() - - print("Sleeping", end="") - for _ in range(0, 10): - print(".", end="") - print("") - - for d in devices: - print(f"Device '{await d.get_name()}'") - if await d.get_paired(): - print(" Removing") - d.remove(adapters[1]) - - -asyncio.run(main()) diff --git a/tests/loopback/test.sh b/tests/loopback/test.sh index 113c4d6..4914568 100755 --- a/tests/loopback/test.sh +++ b/tests/loopback/test.sh @@ -50,7 +50,7 @@ rsync -a --progress --rsync-path="sudo rsync" \ -e "$SSH -p 2244" --delete \ --exclude $IMAGE \ --exclude $KEY_FILE \ - --exclude .git \ + --exclude docs \ --exclude serial.log \ $PROJ_ROOT tester@localhost:/bluez_peripheral @@ -61,15 +61,17 @@ $SSH -p 2244 tester@localhost " python3 -m venv ~/venv source ~/venv/bin/activate python3 -m pip install -r /bluez_peripheral/tests/requirements.txt + python3 -m pip install -e /bluez_peripheral sudo nohup btvirt -L -l2 >/dev/null 2>&1 & sudo service bluetooth start - echo '[*] Running Tests' cd /bluez_peripheral sudo cp tests/unit/com.spacecheese.test.conf /etc/dbus-1/system.d + + echo '[*] Running Tests' python3 -m unittest discover -s tests/unit -p 'test_*.py' -v - python3 -m unittest discover -s tests/loopback -p 'test_*.py' -v + pytest tests/loopback -s sudo shutdown -h now " wait $QEMU_PID diff --git a/tests/loopback/test_advert.py b/tests/loopback/test_advert.py new file mode 100644 index 0000000..8b71a36 --- /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, 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/unit/README.md b/tests/unit/README.md index 97e3aea..60ced07 100644 --- a/tests/unit/README.md +++ b/tests/unit/README.md @@ -16,5 +16,5 @@ sudo cp com.spacecheese.test.conf /etc/dbus-1/system.d # Run the Tests Run tests from the root project directory (eg bluez_peripheral). ```bash -python -m unittest discover -s tests -p "test_*.py" +python3 -m pytest tests/unit ``` diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 0000000..371cf8c --- /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() \ No newline at end of file diff --git a/tests/unit/gatt/test_characteristic.py b/tests/unit/gatt/test_characteristic.py index fa4be09..fa7920c 100644 --- a/tests/unit/gatt/test_characteristic.py +++ b/tests/unit/gatt/test_characteristic.py @@ -1,42 +1,34 @@ -from unittest import IsolatedAsyncioTestCase -from threading import Event -from tests.unit.util import * +import asyncio import re -from bluez_peripheral.uuid16 import UUID16 -from bluez_peripheral.util import get_message_bus +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 - -from dbus_fast import Variant +from bluez_peripheral.gatt.service import Service, ServiceCollection -last_opts = None -write_notify_char_val = None -write_only_char_val = None +from ..util import ServiceNode -class TestService(Service): +class MockService(Service): def __init__(self): super().__init__("180A") - read_write_val = b"\x05" - @characteristic("2A37", CharacteristicFlags.READ) def read_only_char(self, opts): - global last_opts - last_opts = opts + self.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) + self.last_opts = opts return bytes("Test Message", "utf-8") # Not testing other characteristic flags since their functionality is handled by bluez. @@ -46,10 +38,8 @@ def write_notify_char(self, _): @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 + self.last_opts = opts + self.val = val @characteristic("3A38", CharacteristicFlags.WRITE) async def aysnc_write_only_char(self, _): @@ -57,353 +47,215 @@ async def aysnc_write_only_char(self, _): @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) + self.last_opts = opts + self.val = val @characteristic("3A33", CharacteristicFlags.WRITE | CharacteristicFlags.READ) def read_write_char(self, opts): - return self.read_write_val + self.last_opts = opts + return self.val @read_write_char.setter def read_write_char(self, val, opts): - self.read_write_val = val - - -class TestCharacteristic(IsolatedAsyncioTestCase): - async def asyncSetUp(self): - self._client_bus = await get_message_bus() - self._bus_manager = ParallelBus() - 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, UUID16("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) - await service.unregister() - - 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, - UUID16("180A"), - UUID16("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) - - try: - await service.register(self._bus_manager.bus, self._path, adapter) - finally: - await service.unregister() - - 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, - UUID16("180A"), - UUID16("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) - - try: - await service.register(self._bus_manager.bus, self._path, adapter) - finally: - await service.unregister() - - 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, - UUID16("180A"), - UUID16("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) - - try: - 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." - ) - finally: - await service.unregister() - - 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, - UUID16("180A"), - UUID16("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) - - try: - 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." - ) - finally: - await service.unregister() - - 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, - UUID16("180A"), - UUID16("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) - - try: - 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." - ) - finally: - await service.unregister() - - 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, - UUID16("180A"), - UUID16("2A38"), - UUID16("2D56"), - ) - desc = proxy.get_interface("org.bluez.GattDescriptor1") - assert (await desc.call_read_value(opts)).decode( - "utf-8" - ) == "Some Test Value" - else: - with self.assertRaises(ValueError): - await get_attrib( - self._client_bus, - self._bus_manager.name, - path, - UUID16("180A"), - UUID16("2A38"), - UUID16("2D56"), - ) - - adapter = MockAdapter(inspector) - - try: - await service.register(self._bus_manager.bus, self._path, adapter=adapter) - with self.assertRaises(ValueError): - service.write_notify_char.remove_child(some_desc) - finally: - await service.unregister() - service.write_notify_char.remove_child(some_desc) - expect_descriptor = False - - try: - await service.register(self._bus_manager.bus, self._path, adapter=adapter) - with self.assertRaises(ValueError): - service.write_notify_char.add_child(some_desc) - finally: - await service.unregister() - - try: - await service.register(self._bus_manager.bus, self._path, adapter=adapter) - finally: - await service.unregister() - - async def test_empty_opts(self): - async def inspector(path): - interface = ( - await get_attrib( - self._client_bus, - self._bus_manager.name, - path, - UUID16("180A"), - UUID16("3A33"), - ) - ).get_interface("org.bluez.GattCharacteristic1") - assert await interface.call_read_value({}) == b"\x05" - await interface.call_write_value(bytes("Test Write Value", "utf-8"), {}) - assert await interface.call_read_value({}) == bytes( - "Test Write Value", "utf-8" - ) - - service = TestService() - adapter = MockAdapter(inspector) - - try: - await service.register(self._bus_manager.bus, self._path, adapter=adapter) - finally: - await service.unregister() + 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")) + async with asyncio.timeout(0.1): + await properties_changed + + 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(TimeoutError): + async with asyncio.timeout(0.1): + await properties_changed + + +@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") diff --git a/tests/unit/gatt/test_descriptor.py b/tests/unit/gatt/test_descriptor.py index 55aa00f..24c8775 100644 --- a/tests/unit/gatt/test_descriptor.py +++ b/tests/unit/gatt/test_descriptor.py @@ -1,26 +1,22 @@ -from dbus_fast import introspection -from unittest import IsolatedAsyncioTestCase +import asyncio import re -from dbus_fast.signature import Variant +import pytest +import pytest_asyncio -from tests.unit.util import * +from dbus_fast import Variant -from bluez_peripheral import get_message_bus -from bluez_peripheral.gatt import ( +from bluez_peripheral.gatt.characteristic import ( CharacteristicFlags, characteristic, - DescriptorFlags, - descriptor, - Service, ) +from bluez_peripheral.gatt.descriptor import descriptor, DescriptorFlags +from bluez_peripheral.gatt.service import Service, ServiceCollection -last_opts = None -write_desc_val = None -async_write_desc_val = None +from ..util import ServiceNode -class TestService(Service): +class MockService(Service): def __init__(self): super().__init__("180A") @@ -32,14 +28,12 @@ def some_char(self, _): @some_char.descriptor("2A38") def read_only_desc(self, opts): - global last_opts - last_opts = opts + self.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 + self.last_opts = opts await asyncio.sleep(0.05) return bytes("Test Message", "utf-8") @@ -49,10 +43,8 @@ def write_desc(self, _): @write_desc.setter def write_desc_set(self, val, opts): - global last_opts - last_opts = opts - global write_desc_val - write_desc_val = val + self.last_opts = opts + self.write_desc_val = val @descriptor("3A39", some_char, DescriptorFlags.WRITE) async def async_write_desc(self, _): @@ -60,11 +52,9 @@ async def async_write_desc(self, _): @async_write_desc.setter async def async_write_desc_set(self, val, opts): - global last_opts - last_opts = opts + self.last_opts = opts await asyncio.sleep(0.05) - global async_write_desc_val - async_write_desc_val = val + self.async_write_desc_val = val @descriptor("3A33", some_char, DescriptorFlags.WRITE | DescriptorFlags.READ) def read_write_desc(self, opts): @@ -75,172 +65,114 @@ def read_write_desc(self, val, opts): self.read_write_val = val -class TestDescriptor(IsolatedAsyncioTestCase): - async def asyncSetUp(self): - self._client_bus = await get_message_bus() - self._bus_manager = ParallelBus() - 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, - UUID16("180A"), - UUID16("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) - - try: - await service.register(self._bus_manager.bus, self._path, adapter) - finally: - await service.unregister() - - 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, - UUID16("180A"), - UUID16("2A37"), - UUID16("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) - - try: - await service.register(self._bus_manager.bus, self._path, adapter) - finally: - await service.unregister() - - 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, - UUID16("180A"), - UUID16("2A37"), - UUID16("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) - - try: - await service.register(self._bus_manager.bus, self._path, adapter) - finally: - await service.unregister() - - async def test_bluez(self): - await bluez_available_or_skip(self._client_bus) - await get_first_adapter_or_skip(self._client_bus) - - service = TestService() - try: - await service.register(self._client_bus, self._path) - finally: - await service.unregister() - - async def test_empty_opts(self): - async def inspector(path): - interface = ( - await get_attrib( - self._client_bus, - self._bus_manager.name, - path, - UUID16("180A"), - UUID16("2A37"), - UUID16("3A33"), - ) - ).get_interface("org.bluez.GattDescriptor1") - assert await interface.call_read_value({}) == b"\x05" - await interface.call_write_value(bytes("Test Write Value", "utf-8"), {}) - assert await interface.call_read_value({}) == bytes( - "Test Write Value", "utf-8" - ) - - service = TestService() - adapter = MockAdapter(inspector) - - try: - await service.register(self._bus_manager.bus, self._path, adapter=adapter) - finally: - await service.unregister() +@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 index bfea773..375ac1a 100644 --- a/tests/unit/gatt/test_service.py +++ b/tests/unit/gatt/test_service.py @@ -1,115 +1,123 @@ -from tests.unit.util import ParallelBus, MockAdapter, get_attrib - import re from typing import Collection -from unittest import IsolatedAsyncioTestCase -from bluez_peripheral.uuid16 import UUID16 -from bluez_peripheral import get_message_bus -from bluez_peripheral.gatt import Service, ServiceCollection +import pytest +import pytest_asyncio + +from bluez_peripheral.gatt.service import Service, ServiceCollection +from ..util import ServiceNode -class TestService1(Service): + +class MockService1(Service): def __init__(self, includes: Collection[Service]): super().__init__("180A", primary=False, includes=includes) -class TestService2(Service): +class MockService2(Service): def __init__(self): super().__init__("180B") -class TestService3(Service): +class MockService3(Service): def __init__(self): super().__init__("180C") -class TestService(IsolatedAsyncioTestCase): - async def asyncSetUp(self): - self._client_bus = await get_message_bus() - self._bus_manager = ParallelBus() - 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) - - try: - await collection.register(self._bus_manager.bus, self._path, adapter) - finally: - 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, UUID16("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, UUID16("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, UUID16("180C") - ) - assert service3.path in includes - - adapter = MockAdapter(inspector) - try: - await collection.register( - self._bus_manager.bus, self._path, adapter=adapter - ) - finally: - await collection.unregister() - - collection.add_child(service3) - expect_service3 = True - try: - await collection.register( - self._bus_manager.bus, self._path, adapter=adapter - ) - finally: - await collection.unregister() - - collection.remove_child(service3) - expect_service3 = False - try: - await collection.register( - self._bus_manager.bus, self._path, adapter=adapter - ) - finally: - await collection.unregister() +@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 index 9efdb41..0da5f05 100644 --- a/tests/unit/test_advert.py +++ b/tests/unit/test_advert.py @@ -1,135 +1,130 @@ -from unittest import IsolatedAsyncioTestCase -from unittest.case import SkipTest +from uuid import UUID + +import asyncio +import pytest -from tests.unit.util import * -from bluez_peripheral import get_message_bus from bluez_peripheral.advert import Advertisement, AdvertisingIncludes from bluez_peripheral.flags import AdvertisingPacketType -from uuid import UUID - - -class TestAdvert(IsolatedAsyncioTestCase): - async def asyncSetUp(self): - self._bus_manager = ParallelBus() - 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"], - appearance=0x0340, - timeout=2, - packet_type=AdvertisingPacketType.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) - try: - await advert.register(self._bus_manager.bus, adapter, path) - finally: - await advert.unregister() - - async def test_includes_empty(self): - advert = Advertisement( - "Testing Device Name", - ["180A", "180D"], - appearance=0x0340, - timeout=2, - packet_type=AdvertisingPacketType.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) - try: - await advert.register(self._bus_manager.bus, adapter) - finally: - await advert.unregister() - - async def test_uuid128(self): - advert = Advertisement( - "Improv Test", - [UUID("00467768-6228-2272-4663-277478268000")], - appearance=0x0340, - timeout=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", - ] - - adapter = MockAdapter(inspector) - try: - await advert.register(self._bus_manager.bus, adapter) - finally: - await advert.unregister() - - async def test_real(self): - await bluez_available_or_skip(self._client_bus) - adapter = await get_first_adapter_or_skip(self._client_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(self._client_bus, adapter) - finally: - await advert.unregister() - - await adapter.set_discoverable(initial_discoverable) - await adapter.set_powered(initial_powered) +from .util import get_first_adapter_or_skip, bluez_available_or_skip, make_adapter_mock, 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_real(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 index 98a2566..a147e54 100644 --- a/tests/unit/test_agent.py +++ b/tests/unit/test_agent.py @@ -1,50 +1,30 @@ -from unittest import IsolatedAsyncioTestCase +import pytest +import asyncio +from unittest.mock import MagicMock, AsyncMock -from util import * - -from bluez_peripheral.util import get_message_bus from bluez_peripheral.agent import AgentCapability, BaseAgent +from .util import make_message_bus_mock -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 - +@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" -class TestAgent(IsolatedAsyncioTestCase): - async def asyncSetUp(self): - self._bus_manager = ParallelBus() - self._client_bus = await get_message_bus() + 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() - async def asyncTearDown(self): - self._client_bus.disconnect() - self._bus_manager.close() + mock_bus.reset_mock() + agent = BaseAgent(AgentCapability.NO_INPUT_NO_OUTPUT) - async def test_base_agent_capability(self): - agent = BaseAgent(AgentCapability.KEYBOARD_DISPLAY) - bus = MockBus() - await agent.register(bus) - assert bus._capability == "KeyboardDisplay" + 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() - agent = BaseAgent(AgentCapability.NO_INPUT_NO_OUTPUT) - bus = MockBus() - await agent.register(bus) - assert bus._capability == "NoInputNoOutput" + 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 index 28beacf..c89b0ad 100644 --- a/tests/unit/test_util.py +++ b/tests/unit/test_util.py @@ -1,82 +1,141 @@ -from unittest import IsolatedAsyncioTestCase +import pytest -from util import * +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 -from bluez_peripheral.util import * +@pytest.mark.asyncio +async def test_get_first(): + bus = await get_message_bus() + await bluez_available_or_skip(bus) -class TestUtil(IsolatedAsyncioTestCase): - async def asyncSetUp(self) -> None: - self._bus = await get_message_bus() - await bluez_available_or_skip(self._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) - self._adapter = await get_first_adapter_or_skip(self._bus) + bus.disconnect() - 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 +@pytest.mark.asyncio +async def test_alias_set(): + bus = await get_message_bus() + await bluez_available_or_skip(bus) - async def test_alias_set(self): - await self._adapter.set_alias("Some test name") - assert await self._adapter.get_alias() == "Some test name" + adapter = await get_first_adapter_or_skip(bus) - async def test_alias_clear(self): - await self._adapter.set_alias("") - assert await self._adapter.get_alias() == await self._adapter.get_name() + await adapter.set_alias("Some test name") + assert await adapter.get_alias() == "Some test name" - async def test_powered(self): - initial_powered = await self._adapter.get_powered() + bus.disconnect() - 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() - initial_powered = await self._adapter.get_powered() +@pytest.mark.asyncio +async def test_alias_clear(): + bus = await get_message_bus() + await bluez_available_or_skip(bus) - await self._adapter.set_powered(True) + adapter = await get_first_adapter_or_skip(bus) - 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) - await self._adapter.set_powered(initial_powered) + await adapter.set_alias("") + assert await adapter.get_alias() == await adapter.get_name() - async def test_pairable(self): - initial_pairable = await self._adapter.get_pairable() - initial_powered = await self._adapter.get_powered() + bus.disconnect() - 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) - await self._adapter.set_powered(initial_powered) - async def test_pairable_timeout(self): - initial_pairable_timeout = await self._adapter.get_pairable_timeout() +@pytest.mark.asyncio +async def test_powered(): + bus = await get_message_bus() + await bluez_available_or_skip(bus) - 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) + adapter = await get_first_adapter_or_skip(bus) - async def test_discoverable_timeout(self): - initial_discoverable_timeout = await self._adapter.get_discoverable_timeout() + initial_powered = await adapter.get_powered() - 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) + 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 index f982db5..0f703cc 100644 --- a/tests/unit/test_uuid16.py +++ b/tests/unit/test_uuid16.py @@ -1,128 +1,137 @@ -from multiprocessing.sharedctypes import Value -import unittest +from uuid import UUID, uuid1 + +import pytest 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(hex="123") +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") - with self.assertRaises(ValueError): - uuid = UUID16(hex="12345") + uuid = UUID16(int=0x0000123400001000800000805F9B34FB) + assert uuid.uuid == UUID("00001234-0000-1000-8000-00805F9B34FB") - 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_parse_uuid(): + uuid = UUID("00001234-0000-1000-8000-00805F9B34FB") + assert isinstance(UUID16.parse_uuid(uuid), UUID16) - def test_from_bytes(self): - with self.assertRaises(ValueError): - uuid = UUID16(bytes=b"\x12") + uuid = "00001234-0000-1000-8000-00805F9B34FB" + assert isinstance(UUID16.parse_uuid(uuid), UUID16) - with self.assertRaises(ValueError): - uuid = UUID16(bytes=b"\x12\x34\x56") + 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 = UUID16(bytes=b"\x12\x34") - assert uuid.uuid == UUID("00001234-0000-1000-8000-00805F9B34FB") + uuid = 0x0000123400001000800000805F9B34FB + assert isinstance(UUID16.parse_uuid(uuid), UUID16) - uuid = UUID16(bytes=UUID("00001234-0000-1000-8000-00805F9B34FB").bytes) - assert uuid.uuid == UUID("00001234-0000-1000-8000-00805F9B34FB") + uuid = UUID("00011234-0000-1000-8000-00805F9B34FB") + assert isinstance(UUID16.parse_uuid(uuid), UUID) - def test_from_int(self): - with self.assertRaises(ValueError): - uuid = UUID16(int=0x12345) + uuid = "00011234-0000-1000-8000-00805F9B34FB" + assert isinstance(UUID16.parse_uuid(uuid), UUID) - uuid = UUID16(int=0x1234) - assert uuid.uuid == UUID("00001234-0000-1000-8000-00805F9B34FB") + 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 = UUID16(int=0x0000123400001000800000805F9B34FB) - assert uuid.uuid == UUID("00001234-0000-1000-8000-00805F9B34FB") + uuid = 0x0001123400001000800000805F9B34FB + assert isinstance(UUID16.parse_uuid(uuid), UUID) - def test_parse_uuid(self): - uuid = UUID("00001234-0000-1000-8000-00805F9B34FB") - assert type(UUID16.parse_uuid(uuid)) is UUID16 + with pytest.raises(ValueError): + uuid = UUID16.parse_uuid(object()) - uuid = "00001234-0000-1000-8000-00805F9B34FB" - assert type(UUID16.parse_uuid(uuid) is UUID16) - uuid = b"\x00\x01\x12\x34\x00\x00\x10\x00\x80\x00\x00\x80\x5f\x9b\x34\xfb" - assert type(UUID16.parse_uuid(uuid) is UUID16) +def test_from_uuid(): + with pytest.raises(ValueError): + uuid = UUID16(uuid=uuid1()) - uuid = 0x0000123400001000800000805F9B34FB - assert type(UUID16.parse_uuid(uuid) is UUID16) + uuid = UUID16(uuid=UUID("00001234-0000-1000-8000-00805F9B34FB")) + assert uuid.uuid == UUID("00001234-0000-1000-8000-00805F9B34FB") - uuid = UUID("00011234-0000-1000-8000-00805F9B34FB") - assert type(UUID16.parse_uuid(uuid)) is UUID - uuid = "00011234-0000-1000-8000-00805F9B34FB" - assert type(UUID16.parse_uuid(uuid) is UUID) +def test_is_in_range(): + uuid = UUID("00001234-0000-1000-8000-00805F9B34FB") + assert UUID16.is_in_range(uuid) - uuid = b"\x00\x00\x12\x34\x00\x00\x10\x00\x80\x00\x00\x80\x5f\x9b\x34\xfb" - assert type(UUID16.parse_uuid(uuid) is UUID) + uuid = UUID("00011234-0000-1000-8000-00805F9B34FB") + assert not UUID16.is_in_range(uuid) - uuid = 0x0001123400001000800000805F9B34FB - assert type(UUID16.parse_uuid(uuid) is UUID) + uuid = UUID("00001234-0000-1000-8000-00805F9B34FC") + assert UUID16.is_in_range(uuid) - with self.assertRaises(ValueError): - uuid = UUID16.parse_uuid(object()) - def test_from_uuid(self): - with self.assertRaises(ValueError): - uuid = UUID16(uuid=uuid1()) +def test_int(): + uuid = UUID16(uuid=UUID("00001234-0000-1000-8000-00805F9B34FB")) + assert uuid.int == 0x1234 - 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 +def test_bytes(): + uuid = UUID16(uuid=UUID("00001234-0000-1000-8000-00805F9B34FB")) + assert uuid.bytes == b"\x12\x34" - 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_hex(): + uuid = UUID16(uuid=UUID("00001234-0000-1000-8000-00805F9B34FB")) + assert uuid.hex == "1234" - 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_init(): + with pytest.raises(TypeError): + uuid = UUID16() - def test_hex(self): - uuid = UUID16(uuid=UUID("00001234-0000-1000-8000-00805F9B34FB")) - assert uuid.hex == "1234" - def test_init(self): - with self.assertRaises(TypeError): - uuid = UUID16() +def test_eq(): + uuid_a = UUID("00001234-0000-1000-8000-00805F9B34FB") + uuid16_a = UUID16(uuid=uuid_a) - def test_eq(self): - 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) - 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() - assert uuid16_a == uuid16_a - 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(self): - 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")) +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) + 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 index f615097..6ed0225 100644 --- a/tests/unit/util.py +++ b/tests/unit/util.py @@ -1,126 +1,273 @@ import asyncio -from typing import Tuple, Optional -from threading import Thread, Event -from unittest.case import SkipTest +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 - - -class ParallelBus: - def __init__(self, name="com.spacecheese.test"): - bus_ready = Event() - self.name = name - self.bus: MessageBus - - 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()) +from bluez_peripheral.uuid16 import UUID16, UUIDLike +from bluez_peripheral.advert import Advertisement - self._thread = Thread(target=operate_bus) - self._thread.start() - bus_ready.wait() - assert self.bus is not None - - def close(self): - assert self.bus is not None - 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] +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 -async def bluez_available_or_skip(bus: MessageBus): - if await is_bluez_available(bus): - return - else: - raise SkipTest("bluez is not available for testing.") + 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 -class MockAdapter(Adapter): - def __init__(self, inspector): - self._inspector = inspector - self._proxy = self - def get_interface(self, name): - return self +def make_message_bus_mock() -> MagicMock: + bus = create_autospec(MessageBus, instance=True) - async def call_register_advertisement(self, path, obj): - await self._inspector(path) + proxy = create_autospec(ProxyObject, instance=True) + interface = MagicMock() - async def call_register_application(self, path, obj): - await self._inspector(path) + interface.call_register_agent = AsyncMock() + interface.call_request_default_agent = AsyncMock() + interface.call_unregister_agent = AsyncMock() - async def call_unregister_application(self, path): - pass + proxy.get_interface.return_value = interface + bus.get_proxy_object.return_value = proxy - async def call_unregister_advertisement(self, path): - pass + return bus -async def find_attrib(bus, bus_name, path, nodes, target_uuid) -> Tuple[Node, str]: - for node in nodes: - node_path = path + "/" + node.name +class BackgroundLoopWrapper: + event_loop: asyncio.AbstractEventLoop + thread: threading.Thread - introspection = await bus.introspect(bus_name, node_path) - proxy = bus.get_proxy_object(bus_name, node_path, introspection) + def __init__(self): + self.event_loop = asyncio.new_event_loop() - 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() - else: - raise ValueError("No supported interfaces found") + def _func(): + self.event_loop.run_forever() + self.event_loop.close() - if UUID16.parse_uuid(uuid) == UUID16.parse_uuid(target_uuid): - return introspection, node_path + self.thread = threading.Thread( + target=_func, + daemon=True + ) - raise ValueError( - "The attribute with uuid '" + str(target_uuid) + "' could not be found." - ) + @property + def running(self): + return self.thread.is_alive() + def start(self): + self.thread.start() -async def get_attrib(bus, bus_name, path, service_uuid, char_uuid=None, desc_uuid=None): - introspection = await bus.introspect(bus_name, path) + def stop(self): + if self.thread is None or not self.thread.is_alive(): + return - nodes = introspection.nodes - introspection, path = await find_attrib(bus, bus_name, path, nodes, service_uuid) + def _func(): + if self.event_loop is not None and self.event_loop.is_running(): + self.event_loop.stop() - if char_uuid is None: - return bus.get_proxy_object(bus_name, path, introspection) + self.event_loop.call_soon_threadsafe(_func) + self.thread.join() - 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) +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] - nodes = introspection.nodes - introspection, path = await find_attrib(bus, bus_name, path, nodes, desc_uuid) - return bus.get_proxy_object(bus_name, path, introspection) +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 \ No newline at end of file From d0687a35bb2180b81d44180580db5c31d28c61f1 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sat, 3 Jan 2026 20:45:00 +0000 Subject: [PATCH 143/158] Fix pre-commit black file spec --- .pre-commit-config.yaml | 4 +--- tests/conftest.py | 2 +- tests/loopback/conftest.py | 2 +- tests/loopback/test_advert.py | 2 +- tests/unit/conftest.py | 6 +++--- tests/unit/gatt/test_characteristic.py | 8 ++----- tests/unit/gatt/test_descriptor.py | 12 +++-------- tests/unit/gatt/test_service.py | 8 +++++-- tests/unit/test_advert.py | 20 ++++++++--------- tests/unit/test_agent.py | 11 +++++++--- tests/unit/util.py | 30 +++++++++++--------------- 11 files changed, 49 insertions(+), 56 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f6b69f3..1d74f44 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,9 +4,7 @@ repos: hooks: - id: black name: black (code formatting) - files: | - ^bluez_peripheral/| - ^tests/ + files: ^(bluez_peripheral/|tests/) - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.19.1 diff --git a/tests/conftest.py b/tests/conftest.py index 95ea201..b26241b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,4 +7,4 @@ async def message_bus(): bus = await get_message_bus() yield bus - bus.disconnect() \ No newline at end of file + bus.disconnect() diff --git a/tests/loopback/conftest.py b/tests/loopback/conftest.py index 3a1dbc7..84763bb 100644 --- a/tests/loopback/conftest.py +++ b/tests/loopback/conftest.py @@ -6,6 +6,7 @@ from bluez_peripheral.adapter import Adapter from bluez_peripheral.agent import AgentCapability, BaseAgent + class TrivialAgent(BaseAgent): @dbus_method() def Cancel(): # type: ignore @@ -80,4 +81,3 @@ async def paired_adapters(message_bus, unpaired_adapters): devices[0].remove() await agent.unregister(message_bus) - diff --git a/tests/loopback/test_advert.py b/tests/loopback/test_advert.py index 8b71a36..99c3b2d 100644 --- a/tests/loopback/test_advert.py +++ b/tests/loopback/test_advert.py @@ -27,7 +27,7 @@ async def test_advertisement(message_bus, unpaired_adapters): 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 diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 371cf8c..9c4955b 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -8,8 +8,8 @@ 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() \ No newline at end of file + await manager.stop() diff --git a/tests/unit/gatt/test_characteristic.py b/tests/unit/gatt/test_characteristic.py index fa7920c..1491501 100644 --- a/tests/unit/gatt/test_characteristic.py +++ b/tests/unit/gatt/test_characteristic.py @@ -102,9 +102,7 @@ async def test_structure(message_bus, background_service, bus_name, bus_path): @pytest.mark.asyncio -async def test_read( - message_bus, service, background_service, bus_name, bus_path -): +async def test_read(message_bus, service, background_service, bus_name, bus_path): opts = { "offset": Variant("q", 0), "mtu": Variant("q", 128), @@ -133,9 +131,7 @@ async def test_read( @pytest.mark.asyncio -async def test_write( - message_bus, service, background_service, bus_name, bus_path -): +async def test_write(message_bus, service, background_service, bus_name, bus_path): opts = { "offset": Variant("q", 10), "type": Variant("s", "request"), diff --git a/tests/unit/gatt/test_descriptor.py b/tests/unit/gatt/test_descriptor.py index 24c8775..80c7de2 100644 --- a/tests/unit/gatt/test_descriptor.py +++ b/tests/unit/gatt/test_descriptor.py @@ -107,9 +107,7 @@ async def test_structure(message_bus, background_service, bus_name, bus_path): @pytest.mark.asyncio -async def test_read( - message_bus, service, background_service, bus_name, bus_path -): +async def test_read(message_bus, service, background_service, bus_name, bus_path): opts = { "offset": Variant("q", 0), "link": Variant("s", "dododo"), @@ -134,9 +132,7 @@ async def test_read( @pytest.mark.asyncio -async def test_write( - message_bus, service, background_service, bus_name, bus_path -): +async def test_write(message_bus, service, background_service, bus_name, bus_path): opts = { "offset": Variant("q", 1), "device": Variant("s", "bebealbl/.afal"), @@ -164,9 +160,7 @@ async def test_write( @pytest.mark.asyncio -async def test_empty_opts( - message_bus, service, background_service, bus_name, bus_path -): +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 ) diff --git a/tests/unit/gatt/test_service.py b/tests/unit/gatt/test_service.py index 375ac1a..60c8aaa 100644 --- a/tests/unit/gatt/test_service.py +++ b/tests/unit/gatt/test_service.py @@ -90,7 +90,9 @@ async def test_include_modify( 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]) + assert set(includes) == set( + [service1_node.bus_path, service2_node.bus_path, service3_node.bus_path] + ) background_service.unregister() services.remove_child(service3) @@ -120,4 +122,6 @@ async def test_include_modify( 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]) + 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 index 0da5f05..15434e4 100644 --- a/tests/unit/test_advert.py +++ b/tests/unit/test_advert.py @@ -6,7 +6,12 @@ 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, make_adapter_mock, BackgroundAdvertManager +from .util import ( + get_first_adapter_or_skip, + bluez_available_or_skip, + make_adapter_mock, + BackgroundAdvertManager, +) @pytest.fixture @@ -67,15 +72,14 @@ async def test_includes_empty(message_bus, bus_name, bus_path): 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 - ) + 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( @@ -89,13 +93,9 @@ async def test_uuid128(message_bus, bus_name, bus_path): 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 - ) + 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() - ] == [ + assert [id.lower() for id in await interface.get_service_uui_ds()] == [ "00467768-6228-2272-4663-277478268000", ] diff --git a/tests/unit/test_agent.py b/tests/unit/test_agent.py index a147e54..a631503 100644 --- a/tests/unit/test_agent.py +++ b/tests/unit/test_agent.py @@ -6,6 +6,7 @@ from .util import make_message_bus_mock + @pytest.mark.asyncio async def test_base_agent_capability(): mock_bus = make_message_bus_mock() @@ -14,16 +15,20 @@ async def test_base_agent_capability(): 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") + 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_register_agent.assert_awaited_once_with( + bus_path, "NoInputNoOutput" + ) mock_interface.call_request_default_agent.assert_awaited_once_with(bus_path) await agent.unregister() diff --git a/tests/unit/util.py b/tests/unit/util.py index 6ed0225..9c1b743 100644 --- a/tests/unit/util.py +++ b/tests/unit/util.py @@ -63,10 +63,7 @@ def _func(): self.event_loop.run_forever() self.event_loop.close() - self.thread = threading.Thread( - target=_func, - daemon=True - ) + self.thread = threading.Thread(target=_func, daemon=True) @property def running(self): @@ -211,18 +208,18 @@ 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(): + async def _stop(): self._background_bus.disconnect() asyncio.run_coroutine_threadsafe(_stop(), self.background_loop).result() @@ -233,6 +230,7 @@ async def _stop(): def background_bus(self): return self._background_bus + class BackgroundServiceManager(BackgroundBusManager): def __init__(self): self.adapter = make_adapter_mock() @@ -241,18 +239,17 @@ def __init__(self): 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 + 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 + self._services.unregister(), self.background_loop ).result() self._services = None - + class BackgroundAdvertManager(BackgroundBusManager): def __init__(self): self.adapter = make_adapter_mock() @@ -261,13 +258,12 @@ def __init__(self): 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 + 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 + self._advert.unregister(), self.background_loop ).result() - self._advert = None \ No newline at end of file + self._advert = None From 0cb28ce11ebe83e616071161d31dec1c4726ec83 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sat, 3 Jan 2026 20:49:40 +0000 Subject: [PATCH 144/158] Fix ci commands --- .github/workflows/python-test.yml | 2 +- tests/loopback/test.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index ff76eb5..4ec9537 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -61,7 +61,7 @@ jobs: sudo cp tests/unit/com.spacecheese.test.conf /etc/dbus-1/system.d - name: Run Tests run: | - coverage run --source=. --branch -m unittest discover -s tests/unit -p "test_*.py" -v + coverage run --source=. --branch -m pytest tests/unit -v - name: Report Coverage run: | coverage html diff --git a/tests/loopback/test.sh b/tests/loopback/test.sh index 4914568..e234c85 100755 --- a/tests/loopback/test.sh +++ b/tests/loopback/test.sh @@ -70,7 +70,7 @@ $SSH -p 2244 tester@localhost " sudo cp tests/unit/com.spacecheese.test.conf /etc/dbus-1/system.d echo '[*] Running Tests' - python3 -m unittest discover -s tests/unit -p 'test_*.py' -v + pytest tests/unit -s pytest tests/loopback -s sudo shutdown -h now " From 4c4a0d4afb03b7d9db0dabb4272541ba1c64dfb4 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sat, 3 Jan 2026 20:58:19 +0000 Subject: [PATCH 145/158] Replace asyncio.timeout with wait_for --- tests/unit/gatt/test_characteristic.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/unit/gatt/test_characteristic.py b/tests/unit/gatt/test_characteristic.py index 1491501..a45beb5 100644 --- a/tests/unit/gatt/test_characteristic.py +++ b/tests/unit/gatt/test_characteristic.py @@ -201,8 +201,7 @@ def _good_on_properties_changed(interface, values, invalid_props): await char.attr_interface.call_start_notify() service.write_notify_char.changed(bytes("Test Notify Value", "utf-8")) - async with asyncio.timeout(0.1): - await properties_changed + await asyncio.wait_for(properties_changed, timeout=0.1) properties_changed = foreground_loop.create_future() @@ -217,8 +216,7 @@ def _bad_on_properties_changed(interface, values, invalid_props): service.write_notify_char.changed(bytes("Test Notify Value", "utf-8")) with pytest.raises(TimeoutError): - async with asyncio.timeout(0.1): - await properties_changed + await asyncio.wait_for(properties_changed, timeout=0.1) @pytest.mark.asyncio From 71efaba8d6abe582e0090d9e8c1a8dffd6f42a7c Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sat, 3 Jan 2026 21:18:56 +0000 Subject: [PATCH 146/158] Explicitly specify asyncio.TimeoutError --- tests/unit/gatt/test_characteristic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/gatt/test_characteristic.py b/tests/unit/gatt/test_characteristic.py index a45beb5..a706633 100644 --- a/tests/unit/gatt/test_characteristic.py +++ b/tests/unit/gatt/test_characteristic.py @@ -215,7 +215,7 @@ def _bad_on_properties_changed(interface, values, invalid_props): await char.attr_interface.call_stop_notify() service.write_notify_char.changed(bytes("Test Notify Value", "utf-8")) - with pytest.raises(TimeoutError): + with pytest.raises(asyncio.TimeoutError): await asyncio.wait_for(properties_changed, timeout=0.1) From eb238232b3772dbe73f3f79b2cf32901ac589132 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sat, 3 Jan 2026 21:19:57 +0000 Subject: [PATCH 147/158] Remove local install for loopback test --- tests/loopback/test.sh | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/loopback/test.sh b/tests/loopback/test.sh index e234c85..7a632c9 100755 --- a/tests/loopback/test.sh +++ b/tests/loopback/test.sh @@ -61,7 +61,6 @@ $SSH -p 2244 tester@localhost " python3 -m venv ~/venv source ~/venv/bin/activate python3 -m pip install -r /bluez_peripheral/tests/requirements.txt - python3 -m pip install -e /bluez_peripheral sudo nohup btvirt -L -l2 >/dev/null 2>&1 & sudo service bluetooth start From 01335abe717088e3ca7caf7acbda95b8d3dbdb9d Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 4 Jan 2026 00:04:25 +0000 Subject: [PATCH 148/158] Add interactive loopback test launch option --- tests/loopback/test.sh | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/tests/loopback/test.sh b/tests/loopback/test.sh index 7a632c9..2d4fff2 100755 --- a/tests/loopback/test.sh +++ b/tests/loopback/test.sh @@ -4,6 +4,15 @@ 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" @@ -54,6 +63,8 @@ rsync -a --progress --rsync-path="sudo rsync" \ --exclude serial.log \ $PROJ_ROOT tester@localhost:/bluez_peripheral +TEST_STEPS="" + $SSH -p 2244 tester@localhost " set -euo pipefail @@ -67,10 +78,16 @@ $SSH -p 2244 tester@localhost " cd /bluez_peripheral sudo cp 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 " echo '[*] Running Tests' pytest tests/unit -s pytest tests/loopback -s sudo shutdown -h now -" -wait $QEMU_PID + " + wait $QEMU_PID +fi From 048434076076c6fef1922192d63c04fd0f256bf5 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 4 Jan 2026 01:27:30 +0000 Subject: [PATCH 149/158] Explicitly exclude loopback test assets is rsync --- tests/loopback/test.sh | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/loopback/test.sh b/tests/loopback/test.sh index 2d4fff2..1623f80 100755 --- a/tests/loopback/test.sh +++ b/tests/loopback/test.sh @@ -57,14 +57,11 @@ fi echo "[*] Copying bluez_peripheral" rsync -a --progress --rsync-path="sudo rsync" \ -e "$SSH -p 2244" --delete \ - --exclude $IMAGE \ - --exclude $KEY_FILE \ + --exclude tests/loopback/assets \ --exclude docs \ --exclude serial.log \ $PROJ_ROOT tester@localhost:/bluez_peripheral -TEST_STEPS="" - $SSH -p 2244 tester@localhost " set -euo pipefail From 40754be7b0cc04cc0045cb05ff477498296307aa Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 4 Jan 2026 01:28:40 +0000 Subject: [PATCH 150/158] Fix registration logic to use updated BaseServiceInterface export mechanism --- bluez_peripheral/advert.py | 2 +- bluez_peripheral/agent.py | 8 ++------ bluez_peripheral/gatt/service.py | 2 +- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/bluez_peripheral/advert.py b/bluez_peripheral/advert.py index d52d23d..385bf8e 100644 --- a/bluez_peripheral/advert.py +++ b/bluez_peripheral/advert.py @@ -114,7 +114,7 @@ async def register( # Get the LEAdvertisingManager1 interface for the target adapter. interface = adapter.get_advertising_manager() - await interface.call_register_advertisement(path, {}) # type: ignore + await interface.call_register_advertisement(self.export_path, {}) # type: ignore self._adapter = adapter diff --git a/bluez_peripheral/agent.py b/bluez_peripheral/agent.py index 8688e9e..e3d0cf1 100644 --- a/bluez_peripheral/agent.py +++ b/bluez_peripheral/agent.py @@ -50,7 +50,6 @@ def __init__( capability: AgentCapability, ): self._capability: AgentCapability = capability - self._path: Optional[str] = None super().__init__() @@ -87,10 +86,8 @@ async def register( interface = await self._get_manager_interface(bus) await interface.call_register_agent(path, self._get_capability()) # type: ignore - self._path = path - if default: - await interface.call_request_default_agent(path) # type: ignore + await interface.call_request_default_agent(self.export_path) # type: ignore async def unregister(self) -> None: """Unregister this agent with bluez and remove it from the specified message bus. @@ -103,10 +100,9 @@ async def unregister(self) -> None: assert self._export_bus is not None interface = await self._get_manager_interface(self._export_bus) - await interface.call_unregister_agent(self._path) # type: ignore + await interface.call_unregister_agent(self.export_path) # type: ignore self.unexport() - self._path = None class TestAgent(BaseAgent): diff --git a/bluez_peripheral/gatt/service.py b/bluez_peripheral/gatt/service.py index 98fab70..735280e 100644 --- a/bluez_peripheral/gatt/service.py +++ b/bluez_peripheral/gatt/service.py @@ -155,7 +155,7 @@ async def register( self.export(bus, path=path) manager = self._adapter.get_gatt_manager() - await manager.call_register_application(path, {}) # type: ignore + await manager.call_register_application(self.export_path, {}) # type: ignore self._bus = bus From 655119f022209614926620f66fdce6ff11b25ee9 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 4 Jan 2026 01:28:54 +0000 Subject: [PATCH 151/158] Add debug script --- tests/loopback/debug.sh | 4 ++++ 1 file changed, 4 insertions(+) create mode 100755 tests/loopback/debug.sh 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 From aab5aadc40a10228b2aa314b121b4fd29e580150 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 4 Jan 2026 01:34:27 +0000 Subject: [PATCH 152/158] Source venv before running pytest --- tests/loopback/test.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/loopback/test.sh b/tests/loopback/test.sh index 1623f80..c0420cb 100755 --- a/tests/loopback/test.sh +++ b/tests/loopback/test.sh @@ -82,6 +82,7 @@ if (( INTERACTIVE )); then else $SSH -p 2244 tester@localhost " echo '[*] Running Tests' + source ~/venv/bin/activate pytest tests/unit -s pytest tests/loopback -s sudo shutdown -h now From 8eb451728253d5d2e5ae7d93283edcab9ae97911 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 4 Jan 2026 01:37:25 +0000 Subject: [PATCH 153/158] Fix error detection in test run --- tests/loopback/test.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/loopback/test.sh b/tests/loopback/test.sh index c0420cb..903e1e8 100755 --- a/tests/loopback/test.sh +++ b/tests/loopback/test.sh @@ -81,6 +81,8 @@ if (( INTERACTIVE )); then $SSH -p 2244 tester@localhost else $SSH -p 2244 tester@localhost " + set -euo pipefail + echo '[*] Running Tests' source ~/venv/bin/activate pytest tests/unit -s From 6e3caabfa2d0293181732f7be4a2ad6036d82953 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 4 Jan 2026 01:39:07 +0000 Subject: [PATCH 154/158] Fix test running pwd --- tests/loopback/test.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/loopback/test.sh b/tests/loopback/test.sh index 903e1e8..a8544e8 100755 --- a/tests/loopback/test.sh +++ b/tests/loopback/test.sh @@ -73,8 +73,7 @@ $SSH -p 2244 tester@localhost " sudo nohup btvirt -L -l2 >/dev/null 2>&1 & sudo service bluetooth start - cd /bluez_peripheral - sudo cp tests/unit/com.spacecheese.test.conf /etc/dbus-1/system.d + sudo cp /bluez_peripheral/tests/unit/com.spacecheese.test.conf /etc/dbus-1/system.d " if (( INTERACTIVE )); then @@ -85,6 +84,7 @@ else echo '[*] Running Tests' source ~/venv/bin/activate + cd /bluez_peripheral pytest tests/unit -s pytest tests/loopback -s sudo shutdown -h now From ff5bff7e2c6c12654c13582ebec3d9f149380d33 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 4 Jan 2026 01:44:26 +0000 Subject: [PATCH 155/158] Print separate messages for unit and loopback tests --- tests/loopback/test.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/loopback/test.sh b/tests/loopback/test.sh index a8544e8..92d30f1 100755 --- a/tests/loopback/test.sh +++ b/tests/loopback/test.sh @@ -82,10 +82,11 @@ else $SSH -p 2244 tester@localhost " set -euo pipefail - echo '[*] Running Tests' 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 " From 07baa2824769c199ba827c3ded84d5f177eaae7d Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 4 Jan 2026 02:02:37 +0000 Subject: [PATCH 156/158] Fix loopback test_advert --- tests/loopback/test_advert.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/loopback/test_advert.py b/tests/loopback/test_advert.py index 99c3b2d..5ada841 100644 --- a/tests/loopback/test_advert.py +++ b/tests/loopback/test_advert.py @@ -23,7 +23,7 @@ async def test_advertisement(message_bus, unpaired_adapters): timeout=300, duration=5, ) - await advert.register(message_bus, adapters[0]) + await advert.register(message_bus, adapter=adapters[0]) await adapters[1].start_discovery() devices = await adapters[1].get_devices() From e4936863c33ab6ba2a2464f1591a9fc142367bc3 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 4 Jan 2026 02:03:29 +0000 Subject: [PATCH 157/158] Rename test_real to test_bluez --- tests/unit/test_advert.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/unit/test_advert.py b/tests/unit/test_advert.py index 15434e4..3b97603 100644 --- a/tests/unit/test_advert.py +++ b/tests/unit/test_advert.py @@ -9,7 +9,6 @@ from .util import ( get_first_adapter_or_skip, bluez_available_or_skip, - make_adapter_mock, BackgroundAdvertManager, ) @@ -104,7 +103,7 @@ async def test_uuid128(message_bus, bus_name, bus_path): @pytest.mark.asyncio -async def test_real(message_bus): +async def test_bluez(message_bus): await bluez_available_or_skip(message_bus) adapter = await get_first_adapter_or_skip(message_bus) From 97aa1eb2b55f8bde12e04d092eee6506ccc14531 Mon Sep 17 00:00:00 2001 From: spacecheese <12695808+spacecheese@users.noreply.github.com> Date: Sun, 4 Jan 2026 02:03:44 +0000 Subject: [PATCH 158/158] Add test_characteristic.test_bluez --- tests/unit/gatt/test_characteristic.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/tests/unit/gatt/test_characteristic.py b/tests/unit/gatt/test_characteristic.py index a706633..795d885 100644 --- a/tests/unit/gatt/test_characteristic.py +++ b/tests/unit/gatt/test_characteristic.py @@ -14,7 +14,11 @@ from bluez_peripheral.gatt.descriptor import descriptor from bluez_peripheral.gatt.service import Service, ServiceCollection -from ..util import ServiceNode +from ..util import ( + ServiceNode, + get_first_adapter_or_skip, + bluez_available_or_skip, +) class MockService(Service): @@ -253,3 +257,23 @@ def some_desc(service, opts): 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)