Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions .github/workflows/python-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,8 @@ jobs:
cache: 'pip'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install --upgrade pip black
pip install -r requirements.txt
pip install black
- name: Check Formatting
run: |
python -m black --check bluez_peripheral
Expand All @@ -129,9 +128,8 @@ jobs:
cache: 'pip'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install --upgrade pip mypy
pip install -r requirements.txt
pip install mypy
- name: Check type hints
run: |
python -m mypy bluez_peripheral
Expand All @@ -147,10 +145,15 @@ jobs:
cache: 'pip'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install --upgrade pip pylint
pip install -r requirements.txt
pip install pylint
- name: Run lint checks
- name: Run package lint checks
run: |
python -m pylint bluez_peripheral
- name: Install testing dependencies
run: |
pip install -r tests/requirements.txt
- name: Run package lint checks
run: |
python -m pylint --rcfile=tests/.pylintrc tests

11 changes: 11 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,21 @@ repos:
rev: v4.0.4
hooks:
- id: pylint
name: pylint-bluez
additional_dependencies:
- dbus_fast
files: ^bluez_peripheral/

- id: pylint
name: pylint-tests
additional_dependencies:
- dbus_fast
- pytest
- pytest-asyncio
files: ^tests/unit
args:
- --rcfile=tests/.pylintrc

- repo: local
hooks:
- id: docs-build
Expand Down
125 changes: 109 additions & 16 deletions bluez_peripheral/adapter.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
from typing import Collection, Dict, Tuple, List
import asyncio
from typing import Collection, Dict, Tuple, List, AsyncIterator

from dbus_fast import Variant
from dbus_fast.aio import MessageBus, ProxyInterface
from dbus_fast.aio.proxy_object import ProxyObject
from dbus_fast import InvalidIntrospectionError, InterfaceNotFoundError
from dbus_fast.errors import DBusError

from .util import _kebab_to_shouting_snake
from .flags import AdvertisingIncludes
from .uuid16 import UUID16, UUIDLike
from .error import BluezNotAvailableError, bluez_error_wrapper


class Device:
Expand All @@ -27,12 +31,14 @@ async def get_paired(self) -> bool:

async def pair(self) -> None:
"""Attempts to pair the parent adapter with this device."""
await self._device_interface.call_pair() # type: ignore
async with bluez_error_wrapper():
await self._device_interface.call_pair() # type: ignore

async def remove(self, adapter: "Adapter") -> None:
"""Disconnects and unpairs from this device."""
interface = adapter.get_adapter_interface()
await interface.call_remove_device(self._device_interface._path) # type: ignore # pylint: disable=protected-access
async with bluez_error_wrapper():
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)."""
Expand Down Expand Up @@ -75,6 +81,7 @@ class Adapter:
def __init__(self, proxy: ProxyObject):
self._proxy = proxy
self._adapter_interface = proxy.get_interface(self._INTERFACE)
self._discovery_stopped = asyncio.Event()

def get_adapter_interface(self) -> ProxyInterface:
"""Returns the org.bluez.Adapter associated with this adapter."""
Expand Down Expand Up @@ -161,13 +168,105 @@ async def get_supported_advertising_includes(self) -> AdvertisingIncludes:
flags |= inc
return flags

async def get_discovering(self) -> bool:
"""Returns true if the adapter is discovering. False otherwise."""
return await self._adapter_interface.get_discovering() # type: ignore

async def start_discovery(self) -> None:
"""Start searching for other bluetooth devices."""
await self._adapter_interface.call_start_discovery() # type: ignore
async with bluez_error_wrapper():
await self._adapter_interface.call_start_discovery() # type: ignore
self._discovery_stopped.clear()

async def stop_discovery(self) -> None:
"""Stop searching for other bluetooth devices."""
await self._adapter_interface.call_stop_discovery() # type: ignore
async with bluez_error_wrapper():
await self._adapter_interface.call_stop_discovery() # type: ignore
self._discovery_stopped.set()

async def _get_device(self, path: str) -> Device:
bus = self._adapter_interface.bus

introspection = await bus.introspect("org.bluez", path)
proxy = bus.get_proxy_object("org.bluez", path, introspection)
proxy.get_interface("org.bluez.Device1")
return Device(proxy)

async def discover_devices(self, duration: float = 10.0) -> AsyncIterator[Device]:
"""
Asynchronously search for other bluetooth devices.

Args:
duration: The number of seconds to perform the discovery scan. Defaults to 10.0 seconds.
"""
queue: asyncio.Queue[Tuple[str, Dict[str, Dict[str, Variant]]]] = (
asyncio.Queue()
)

def _interface_added(path: str, intfs_and_props: Dict[str, Dict[str, Variant]]): # type: ignore
queue.put_nowait((path, intfs_and_props))

introspection = await self._adapter_interface.bus.introspect("org.bluez", "/")
proxy = self._adapter_interface.bus.get_proxy_object(
"org.bluez", "/", introspection
)
object_manager_interface = proxy.get_interface(
"org.freedesktop.DBus.ObjectManager"
)
object_manager_interface.on_interfaces_added(_interface_added) # type: ignore

yielded_paths = set()

async def _stop_discovery() -> None:
await asyncio.sleep(duration)
await self.stop_discovery()

await self.start_discovery()
stop_task = None
if duration > 0:
stop_task = asyncio.create_task(_stop_discovery())

adapter_path = self._adapter_interface.path
while not self._discovery_stopped.is_set():
if stop_task is not None:
queue_task = asyncio.create_task(queue.get())

done, _ = await asyncio.wait(
[queue_task, stop_task],
return_when=asyncio.FIRST_COMPLETED,
)

if stop_task in done and queue_task not in done:
queue_task.cancel()
try:
await queue_task
except asyncio.CancelledError:
pass
break

path, intfs_and_props = queue_task.result()
else:
path, intfs_and_props = await queue.get()

if (
path.startswith(adapter_path)
and path in yielded_paths
or "org.bluez.Device1" not in intfs_and_props
):
continue

yield await self._get_device(path)
yielded_paths.add(path)

if stop_task is not None and stop_task.done():
stop_task.cancel()
try:
await stop_task
except asyncio.CancelledError:
pass

object_manager_interface.off_interfaces_added(_interface_added) # type: ignore
return

async def get_devices(self) -> List[Device]:
"""Returns a list of devices which have been discovered by this adapter."""
Expand All @@ -182,17 +281,8 @@ async def get_devices(self) -> List[Device]:
for node in device_nodes:
if node.name is None:
continue
try:
introspection = await bus.introspect(
"org.bluez", path + "/" + node.name
)
proxy = bus.get_proxy_object(
"org.bluez", path + "/" + node.name, introspection
)
devices.append(Device(proxy))
except InvalidIntrospectionError:
pass

devices.append(await self._get_device(path + "/" + node.name))
return devices

@classmethod
Expand All @@ -205,7 +295,10 @@ async def get_all(cls, bus: MessageBus) -> List["Adapter"]:
Returns:
A list of available bluetooth adapters.
"""
adapter_nodes = (await bus.introspect("org.bluez", "/org/bluez")).nodes
try:
adapter_nodes = (await bus.introspect("org.bluez", "/org/bluez")).nodes
except DBusError as e:
raise BluezNotAvailableError("org.bluez could not be introspected") from e

adapters = []
for node in adapter_nodes:
Expand Down
57 changes: 36 additions & 21 deletions bluez_peripheral/advert.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import Collection, Dict, Callable, Optional, Union, List, Tuple
import inspect
from typing import Collection, Dict, Callable, Optional, Union, Awaitable
import struct
from uuid import UUID

from dbus_fast import Variant
from dbus_fast.constants import PropertyAccess
Expand All @@ -13,6 +13,7 @@
from .flags import AdvertisingIncludes
from .flags import AdvertisingPacketType
from .base import BaseServiceInterface
from .error import bluez_error_wrapper


class Advertisement(BaseServiceInterface):
Expand All @@ -34,7 +35,7 @@ class Advertisement(BaseServiceInterface):
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. The default release callback will unexport the advert.
"""

_INTERFACE = "org.bluez.LEAdvertisement1"
Expand All @@ -51,10 +52,12 @@ def __init__(
packet_type: AdvertisingPacketType = AdvertisingPacketType.PERIPHERAL,
manufacturer_data: Optional[Dict[int, bytes]] = None,
solicit_uuids: Optional[Collection[UUIDLike]] = None,
service_data: Optional[List[Tuple[UUIDLike, bytes]]] = None,
service_data: Optional[Dict[UUIDLike, bytes]] = None,
includes: AdvertisingIncludes = AdvertisingIncludes.NONE,
duration: int = 2,
release_callback: Optional[Callable[[], None]] = None,
release_callback: Optional[
Union[Callable[[], None], Callable[[], Awaitable[None]]]
] = None,
):
self._type = packet_type
# Convert any string uuids to uuid16.
Expand All @@ -69,24 +72,32 @@ def __init__(

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._manufacturer_data = {
k: Variant("ay", v) for k, v in manufacturer_data.items()
}

if solicit_uuids is None:
solicit_uuids = []
self._solicit_uuids = [UUID16.parse_uuid(uuid) for uuid in solicit_uuids]

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)))
service_data = {}
self._service_data = {
UUID16.parse_uuid(k): Variant("ay", v) for k, v in service_data.items()
}

self._discoverable = discoverable
self._includes = includes
self._duration = duration
self._release_callback = release_callback

def _default_release_callback() -> None:
self.unexport()

self._release_callback: Union[Callable[[], None], Callable[[], Awaitable[None]]]
if release_callback is None:
self._release_callback = _default_release_callback
else:
self._release_callback = release_callback

self._adapter: Optional[Adapter] = None

Expand Down Expand Up @@ -114,28 +125,32 @@ async def register(

# Get the LEAdvertisingManager1 interface for the target adapter.
interface = adapter.get_advertising_manager()
await interface.call_register_advertisement(self.export_path, {}) # type: ignore
async with bluez_error_wrapper():
await interface.call_register_advertisement(self.export_path, {}) # type: ignore

self._adapter = adapter

@method("Release")
def _release(self): # type: ignore
self.unexport()
async def _release(self): # type: ignore
if inspect.iscoroutinefunction(self._release_callback):
await self._release_callback()
else:
self._release_callback()

async def unregister(self) -> None:
"""
Unregister this advertisement from bluez to stop advertising.
"""
if not self._adapter:
if not self._adapter or not self.is_exported:
raise ValueError("This advertisement is not registered")

interface = self._adapter.get_advertising_manager()

await interface.call_unregister_advertisement(self._export_path) # type: ignore
async with bluez_error_wrapper():
await interface.call_unregister_advertisement(self.export_path) # type: ignore
self._adapter = None

if self._release_callback is not None:
self._release_callback()
self.unexport()

@dbus_property(PropertyAccess.READ, "Type")
def _get_type(self) -> "s": # type: ignore
Expand Down Expand Up @@ -167,7 +182,7 @@ def _get_solicit_uuids(self) -> "as": # type: ignore

@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)
return {str(key): val for key, val in self._service_data.items()}

@dbus_property(PropertyAccess.READ, "Discoverable")
def _get_discoverable(self) -> "b": # type: ignore
Expand Down
Loading