diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f2d32aa2..74490c73 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -18,6 +18,7 @@ Added * Added ``BleakScanner.find_device_by_name()`` class method. * Added optional command line argument to use debug log level to all applicable examples. * Make sure the disconnect monitor task is properly cancelled on the BlueZ client. +* Added new backend, for Silicon Labs NCP devices implementing BGAPI Changed ------- diff --git a/bleak/assigned_numbers.py b/bleak/assigned_numbers.py index 0fe2ce85..4a492dfa 100644 --- a/bleak/assigned_numbers.py +++ b/bleak/assigned_numbers.py @@ -37,3 +37,58 @@ class AdvertisementDataType(IntEnum): SERVICE_DATA_UUID128 = 0x21 MANUFACTURER_SPECIFIC_DATA = 0xFF + + +AppearanceCategories = { + 0x000: "Unknown", + 0x001: "Phone", + 0x002: "Computer", + 0x003: "Watch", + 0x004: "Clock", + 0x005: "Display", + 0x006: "Remote Control", + 0x007: "Eye-glasses", + 0x008: "Tag", + 0x009: "Keyring", + 0x00A: "Media Player", + 0x00B: "Barcode Scanner", + 0x00C: "Thermometer", + 0x00D: "Heart Rate Sensor", + 0x00E: "Blood Pressure", + 0x00F: "Human Interface Device", + 0x010: "Glucose Meter", + 0x011: "Running Walking Sensor", + 0x012: "Cycling", + 0x013: "Control Device", + 0x014: "Network Device", + 0x015: "Sensor", + 0x016: "Light Fixtures", + 0x017: "Fan", + 0x018: "HVAC", + 0x019: "Air Conditioning", + 0x01A: "Humidifier", + 0x01B: "Heating", + 0x01C: "Access Control", + 0x01D: "Motorized Device", + 0x01E: "Power Device", + 0x01F: "Light Source", + 0x020: "Window Covering", + 0x021: "Audio Sink", + 0x022: "Audio Source", + 0x023: "Motorized Vehicle", + 0x024: "Domestic Appliance", + 0x025: "Wearable Audio Device", + 0x026: "Aircraft", + 0x027: "AV Equipment", + 0x028: "Display Equipment", + 0x029: "Hearing aid", + 0x02A: "Gaming", + 0x02B: "Signage", + 0x031: "Pulse Oximeter", + 0x032: "Weight Scale", + 0x033: "Personal Mobility Device", + 0x034: "Continuous Glucose Monitor", + 0x035: "Insulin Pump", + 0x036: "Medication Delivery", + 0x051: "Outdoor Sports Activity", +} diff --git a/bleak/backends/bgapi/__init__.py b/bleak/backends/bgapi/__init__.py new file mode 100644 index 00000000..4053cf06 --- /dev/null +++ b/bleak/backends/bgapi/__init__.py @@ -0,0 +1,129 @@ +""" +Backend targetting Silicon Labs devices running "NCP" firmware, using the BGAPI via pyBGAPI +See: https://pypi.org/project/pybgapi/ +""" +import asyncio +import logging +import typing + +import bgapi + +class BgapiHandler(): + def __init__(self, adapter, xapi): + self.log = logging.getLogger(f"BgapiHandler-{adapter}") + self.log.info("Creating an explicit handler") + self._loop = asyncio.get_running_loop() + + self.lib = bgapi.BGLib( + bgapi.SerialConnector(adapter, baudrate=115200), + xapi, + self.bgapi_evt_handler, + ) + self._scan_handlers: typing.List[typing.Callable] = list() + self._conn_handlers: typing.Dict[int, typing.Callable] = {} + self._is_scanning = False + self.scan_phy = None + self.scan_parameters = None + self.scan_discover_mode = None + # We should make _this_ layer do a .lib.open() straight away, so it can call reset...? + # then it can manage start_scan + self.lib.open() + self.is_booted = asyncio.Event() + self.lib.bt.system.reset(0) + # block other actions here til we get the booted message? + + def bgapi_evt_handler(self, evt): + """ + THIS RUNS IN THE BGLIB THREAD! + and because of this, we can't call commands from here ourself, we'd have to + recall them back onto the other thread? + """ + if evt == "bt_evt_system_boot": + self.log.debug( + "NCP booted: %d.%d.%db%d hw:%d hash: %x", + evt.major, + evt.minor, + evt.patch, + evt.build, + evt.hw, + evt.hash, + ) + # TODO - save boot info? any purpose? + self._loop.call_soon_threadsafe(self.is_booted.set) + + #self.log.debug("Internal event received, sending to %d subs: %s", len(self._scan_handlers), evt) + if hasattr(evt, "connection"): + handler = self._conn_handlers.get(evt.connection, None) + if handler: + handler(evt) + else: + if evt.reason == 4118: + # disconnected at local request, and our client has gone away + # without waiting. waiting for this would be possible, but seems unnecessary + pass + else: + self.log.warning("Connection event with no matching handler!: %s", evt) + else: + # CAUTION: is this too restrictive? (only ending events without connection details) + for x in self._scan_handlers: + x(evt) + + #self.log.debug("int event finished") + + async def start_scan(self, phy, scanning_mode, discover_mode, handler: typing.Callable): + """ + :return: + """ + await self.is_booted.wait() + self._scan_handlers.append(handler) + if self._is_scanning: + # TODO - If params are the same, return, if params are different.... + # reinitialize with new ones? we're still by definition one app, + # we must assume cooperative. + self.log.debug("scanning already in process, skipping") + return + self._is_scanning = True + self.log.debug("requesting bgapi to start scanning") + self.scan_phy = phy + self.scan_parameters = scanning_mode + self.scan_discover_mode = discover_mode + self.lib.bt.scanner.set_parameters(self.scan_parameters, 0x10, 0x10) + self.lib.bt.scanner.start(self.scan_phy, self.scan_discover_mode) + + async def stop_scan(self, handler: typing.Callable): + self._scan_handlers.remove(handler) + if len(self._scan_handlers) == 0: + self.log.info("Stopping scanners, all listeners have exited") + self.lib.bt.scanner.stop() + self._is_scanning = False + + async def connect(self, address, address_type, phy, handler: typing.Callable): + await self.is_booted.wait() + _, ch = self.lib.bt.connection.open(address, address_type, phy) + self._conn_handlers[ch] = handler + self.log.debug("Attempting connection to addr: %s, assigned ch: %d", address, ch) + return ch + + async def disconnect(self, ch): + self.log.debug("attempting to disconnect ch: %d", ch) + self.lib.bt.connection.close(ch) + # the user won't get a final event, but they're exiting anyway, they don't care + self._conn_handlers.pop(ch) + + +class BgapiRegistry: + """ + Holds lib/connector instances based on the adapter address. + Only allows one, so you can have multiple higher level objects... + """ + registry = {} + + + @classmethod + def get(cls, adapter, xapi): + x = cls.registry.get(adapter) + if x: + return x + x = BgapiHandler(adapter, xapi) + cls.registry[adapter] = x + return x diff --git a/bleak/backends/bgapi/characteristic.py b/bleak/backends/bgapi/characteristic.py new file mode 100644 index 00000000..826db49e --- /dev/null +++ b/bleak/backends/bgapi/characteristic.py @@ -0,0 +1,94 @@ +import collections +from typing import List, Union +from uuid import UUID + +from ..characteristic import BleakGATTCharacteristic, GattCharacteristicsFlags +from ..descriptor import BleakGATTDescriptor + +PartialCharacteristic = collections.namedtuple( + "PartialCharacteristic", ["uuid", "handle", "properties"] +) + + +class BleakGATTCharacteristicBGAPI(BleakGATTCharacteristic): + """GATT Characteristic implementation for the Silicon Labs BGAPI backend""" + + def __init__( + self, + obj: PartialCharacteristic, + service_uuid: str, + service_handle: int, + max_write_without_response_size: int, + ): + super(BleakGATTCharacteristicBGAPI, self).__init__( + obj, max_write_without_response_size + ) + self.__uuid = self.obj.uuid + self.__handle = self.obj.handle + self.__service_uuid = service_uuid + self.__service_handle = service_handle + self.__descriptors = [] + self.__notification_descriptor = None + + self.__properties = [ + x.name for x in GattCharacteristicsFlags if x.value & obj.properties > 0 + ] + + @property + def service_uuid(self) -> str: + """The uuid of the Service containing this characteristic""" + return self.__service_uuid + + @property + def service_handle(self) -> int: + """The integer handle of the Service containing this characteristic""" + return int(self.__service_handle) + + @property + def handle(self) -> int: + """The handle of this characteristic""" + return self.__handle + + @property + def uuid(self) -> str: + """The uuid of this characteristic""" + return self.__uuid + + @property + def properties(self) -> List[str]: + """Properties of this characteristic""" + return self.__properties + + @property + def descriptors(self) -> List[BleakGATTDescriptor]: + """List of descriptors for this service""" + return self.__descriptors + + def get_descriptor( + self, specifier: Union[str, UUID] + ) -> Union[BleakGATTDescriptor, None]: + """Get a descriptor by UUID (str or uuid.UUID)""" + + matches = [ + descriptor + for descriptor in self.descriptors + if descriptor.uuid == str(specifier) + ] + if len(matches) == 0: + return None + return matches[0] + + def add_descriptor(self, descriptor: BleakGATTDescriptor): + """Add a :py:class:`~BleakGATTDescriptor` to the characteristic. + + Should not be used by end user, but rather by `bleak` itself. + """ + self.__descriptors.append(descriptor) + # FIXME - you probably still need this! + # if descriptor.uuid == defs.CLIENT_CHARACTERISTIC_CONFIGURATION_UUID: + # self.__notification_descriptor = descriptor + + @property + def notification_descriptor(self) -> BleakGATTDescriptor: + """The notification descriptor. Mostly needed by `bleak`, not by end user""" + return self.__notification_descriptor diff --git a/bleak/backends/bgapi/client.py b/bleak/backends/bgapi/client.py new file mode 100644 index 00000000..c7a99dbe --- /dev/null +++ b/bleak/backends/bgapi/client.py @@ -0,0 +1,360 @@ +import os + +import asyncio +import logging +import struct +from typing import Optional, Union +import uuid +import warnings + + +from ...exc import BleakError +from ..characteristic import BleakGATTCharacteristic, GattCharacteristicsFlags +from ..client import BaseBleakClient, NotifyCallback +from ..device import BLEDevice +from ..service import BleakGATTServiceCollection + +from .characteristic import BleakGATTCharacteristicBGAPI, PartialCharacteristic +from .descriptor import BleakGATTDescriptorBGAPI, PartialDescriptor +from .service import BleakGATTServiceBGAPI + +from . import BgapiRegistry + + +def _bgapi_uuid_to_str(uuid_bytes): + """ + Converts a 16/32/128bit bytes uuid in BGAPI result format into + the "normal" bleak string form. + """ + if len(uuid_bytes) == 2: + (uu,) = struct.unpack(" bool: + # TODO both of these should be ... layered differentyly? + # XXX: some people _may_ wish to specify this. (can't use PHY_ANY!) + phy = self._bgh.lib.bt.gap.PHY_PHY_1M + atype = self._bgh.lib.bt.gap.ADDRESS_TYPE_PUBLIC_ADDRESS + if self._device: + # FIXME - we have the address type information in the scanner, make sure it gets here? + pass + self._ch = await self._bgh.connect(self.address, atype, phy, self._bgapi_evt_handler) + self._ev_disconnected.clear() # This should allow people to re-use client objects. + + try: + # with the invoke or not?! + await asyncio.wait_for(self._ev_connect.wait(), timeout=self._timeout) + + except asyncio.exceptions.TimeoutError: + self.log.warning("Timed out attempting connection to %s", self.address) + # According to the NCP API docs, we "should" request to close the connection here! + await self.disconnect() + # FIXME - what's the "correct" exception to raise here? + raise + + # nominally, you don't need to do this, but it's how bleak behaves, so just do it, + # even though it's "wasteful" to enumerate everything. It's predictable behaviour + # enumerating services/characteristics does a series of nested waits, + # make sure we can handle being disconnected during that process + d, p = await asyncio.wait([asyncio.Task(self.get_services(), name="get_services"), + asyncio.Task(self._ev_disconnected.wait(), name="disconn") + ], + return_when=asyncio.FIRST_COMPLETED) + [t.cancel() for t in p] # cleanup and avoid "Task was destroyed but it is pending" + if self._ev_disconnected.is_set(): + return False + + return True + + async def disconnect(self) -> bool: + if self._ch is not None: + await self._bgh.disconnect(self._ch) + else: + # This happens when the remote side disconnects us + pass + self._ch = None + return True + + def _bgapi_evt_handler(self, evt): + """ + THIS RUNS IN THE BGLIB THREAD! + and because of this, we can't call commands from here ourself, + remember to use _loop.call_soon_threadsafe.... + """ + if evt == "bt_evt_connection_opened": + # The internal handler should ensure this + assert self._ch == evt.connection + # do this on the right thread! + self._loop.call_soon_threadsafe(self._ev_connect.set) + elif evt == "bt_evt_connection_closed": + self.log.info( + "Disconnected connection: %d: reason: %d (%#x)", + evt.connection, + evt.reason, + evt.reason, + ) + if self._disconnected_callback: + self._loop.call_soon_threadsafe(self._disconnected_callback, self) + self._loop.call_soon_threadsafe(self._ev_disconnected.set) + # the conn handle is _dead_ now. if you try and keep using this, you'll get command errors + self._ch = None + elif ( + evt == "bt_evt_connection_parameters" + or evt == "bt_evt_connection_phy_status" + or evt == "bt_evt_connection_remote_used_features" + or evt == "bt_evt_connection_tx_power" + ): + #self.log.debug("ignoring 'extra' info in: %s", evt) + # We don't need anything else here? just confirmations, and avoid "unhandled" warnings? + pass + elif evt == "bt_evt_gatt_mtu_exchanged": + self._mtu_size = evt.mtu + elif evt == "bt_evt_gatt_service": + uus = _bgapi_uuid_to_str(evt.uuid) + service = BleakGATTServiceBGAPI(dict(uuid=uus, handle=evt.service)) + self._loop.call_soon_threadsafe(self.services.add_service, service) + elif evt == "bt_evt_gatt_characteristic": + uus = _bgapi_uuid_to_str(evt.uuid) + # Unlike with services, we don't have enough information to directly create the BleakCharacteristic here. + self._loop.call_soon_threadsafe( + self._buffer_characteristics.append, + PartialCharacteristic( + uuid=uus, handle=evt.characteristic, properties=evt.properties + ), + ) + elif evt == "bt_evt_gatt_characteristic_value": + # This handles reads, long reads, and notifications/indications + if self._cbs_notify.get(evt.characteristic, False): + self._loop.call_soon_threadsafe( + self._cbs_notify[evt.characteristic], evt.value + ) + else: + # because long reads are autohandled, we must keep adding data until the operation completes. + self._loop.call_soon_threadsafe(self._buffer_data.extend, evt.value) + elif evt == "bt_evt_gatt_descriptor": + uus = _bgapi_uuid_to_str(evt.uuid) + # Unlike with services, we don't have enough information to directly create the BleakDescriptor here. + self._loop.call_soon_threadsafe( + self._buffer_descriptors.append, + PartialDescriptor(uuid=uus, handle=evt.descriptor), + ) + elif evt == "bt_evt_gatt_procedure_completed": + self._loop.call_soon_threadsafe(self._ev_gatt_op.set) + else: + # Loudly show all the places we're not handling things yet! + self.log.warning(f"unhandled bgapi evt! {evt}") + + @property + def mtu_size(self) -> int: + """Get ATT MTU size for active connection""" + if self._mtu_size is None: + # You normally get this event straight after connecting, + warnings.warn( + "bt_evt_gatt_mtu_exchanged not yet received! assuming default!" + ) + return 23 + + return self._mtu_size + + async def pair(self, *args, **kwargs) -> bool: + raise NotImplementedError + + async def unpair(self) -> bool: + raise NotImplementedError + + @property + def is_connected(self) -> bool: + # TODO - could also look at ev_disconnected.is_set()? + return self._ch is not None + + async def get_services(self, **kwargs) -> BleakGATTServiceCollection: + if self._services_resolved: + return self.services + + self._ev_gatt_op.clear() + self._bgh.lib.bt.gatt.discover_primary_services(self._ch) + await self._ev_gatt_op.wait() + + for s in self.services: + self._ev_gatt_op.clear() + self._buffer_characteristics.clear() + self._bgh.lib.bt.gatt.discover_characteristics(self._ch, s.handle) + await self._ev_gatt_op.wait() + + # ok, we've now got a stack of partial characteristics + for pc in self._buffer_characteristics: + bc = BleakGATTCharacteristicBGAPI( + pc, s.uuid, s.handle, self.mtu_size - 3 + ) + self.services.add_characteristic(bc) # Add to the root collection! + + # Now also get the descriptors + self._ev_gatt_op.clear() + self._buffer_descriptors.clear() + self._bgh.lib.bt.gatt.discover_descriptors(self._ch, bc.handle) + await self._ev_gatt_op.wait() + for pd in self._buffer_descriptors: + bd = BleakGATTDescriptorBGAPI(pd, bc.uuid, bc.handle) + self.services.add_descriptor(bd) # Add to the root collection! + + self._services_resolved = True + return self.services + + async def read_gatt_char( + self, + char_specifier: Union[BleakGATTCharacteristic, int, str, uuid.UUID], + **kwargs, + ) -> bytearray: + if not isinstance(char_specifier, BleakGATTCharacteristic): + characteristic = self.services.get_characteristic(char_specifier) + else: + characteristic = char_specifier + if not characteristic: + raise BleakError("Characteristic {} was not found!".format(char_specifier)) + + # this will automatically use long reads if needed, so need to make sure that we bunch up data + self._ev_gatt_op.clear() + self._buffer_data.clear() + self._bgh.lib.bt.gatt.read_characteristic_value(self._ch, characteristic.handle) + d, p = await asyncio.wait([asyncio.Task(self._ev_gatt_op.wait(), name="gattop"), asyncio.Task(self._ev_disconnected.wait(), name="disconn")], + return_when=asyncio.FIRST_COMPLETED) + [t.cancel() for t in p] + if self._ev_disconnected.is_set(): + pass # nothing we can do differently here. + return bytearray(self._buffer_data) + + async def read_gatt_descriptor(self, handle: int, **kwargs) -> bytearray: + raise NotImplementedError + + async def write_gatt_char( + self, + char_specifier: Union[BleakGATTCharacteristic, int, str, uuid.UUID], + data: Union[bytes, bytearray, memoryview], + response: bool = False, + ) -> None: + if not isinstance(char_specifier, BleakGATTCharacteristic): + characteristic = self.services.get_characteristic(char_specifier) + else: + characteristic = char_specifier + if not characteristic: + raise BleakError("Characteristic {} was not found!".format(char_specifier)) + + if ( + GattCharacteristicsFlags.write.name not in characteristic.properties + and GattCharacteristicsFlags.write_without_response.name + not in characteristic.properties + ): + raise BleakError( + f"Characteristic {characteristic} does not support write operations!" + ) + if ( + not response + and GattCharacteristicsFlags.write_without_response.name + not in characteristic.properties + ): + # Warning seems harsh, this is just magically "fixing" things, but it's what the bluez backend does. + self.log.warning( + f"Characteristic {characteristic} does not support write without response, auto-trying as write" + ) + response = True + # bgapi needs "bytes" or a string that it will encode as latin1. + # All of the bleak types can be cast to bytes, and that's easier than modifying pybgapi + odata = bytes(data) + if response: + self._ev_gatt_op.clear() + self._bgh.lib.bt.gatt.write_characteristic_value( + self._ch, characteristic.handle, odata + ) + d, p = await asyncio.wait([asyncio.Task(self._ev_gatt_op.wait(), name="gattop"), asyncio.Task(self._ev_disconnected.wait(), name="disconn")], + return_when=asyncio.FIRST_COMPLETED) + [t.cancel() for t in p] + if self._ev_disconnected.is_set(): + pass # Nothing special we can do anyway. + else: + self._bgh.lib.bt.gatt.write_characteristic_value_without_response( + self._ch, characteristic.handle, odata + ) + + async def write_gatt_descriptor( + self, handle: int, data: Union[bytes, bytearray, memoryview] + ) -> None: + raise NotImplementedError + + async def start_notify( + self, + characteristic: BleakGATTCharacteristic, + callback: NotifyCallback, + **kwargs, + ) -> None: + self._cbs_notify[characteristic.handle] = callback + enable = self._bgh.lib.bt.gatt.CLIENT_CONFIG_FLAG_NOTIFICATION + force_indic = kwargs.get("force_indicate", False) + if force_indic: + enable = self._bgh.lib.bt.gatt.CLIENT_CONFIG_FLAG_INDICATION + self._bgh.lib.bt.gatt.set_characteristic_notification( + self._ch, characteristic.handle, enable + ) + + async def stop_notify( + self, char_specifier: Union[BleakGATTCharacteristic, int, str, uuid.UUID] + ) -> None: + if not isinstance(char_specifier, BleakGATTCharacteristic): + characteristic = self.services.get_characteristic(char_specifier) + else: + characteristic = char_specifier + if not characteristic: + raise BleakError("Characteristic {} was not found!".format(char_specifier)) + self._cbs_notify.pop(characteristic.handle, None) # hard remove callback + cancel = self._bgh.lib.bt.gatt.CLIENT_CONFIG_FLAG_DISABLE + self._bgh.lib.bt.gatt.set_characteristic_notification( + self._ch, characteristic.handle, cancel + ) diff --git a/bleak/backends/bgapi/descriptor.py b/bleak/backends/bgapi/descriptor.py new file mode 100644 index 00000000..d3da94dc --- /dev/null +++ b/bleak/backends/bgapi/descriptor.py @@ -0,0 +1,38 @@ +import collections +from ..descriptor import BleakGATTDescriptor + +PartialDescriptor = collections.namedtuple("PartialDescriptor", ["uuid", "handle"]) + + +class BleakGATTDescriptorBGAPI(BleakGATTDescriptor): + """GATT Descriptor implementation for Silicon Labs BGAPI backend""" + + def __init__( + self, + obj: PartialDescriptor, + characteristic_uuid: str, + characteristic_handle: int, + ): + super(BleakGATTDescriptorBGAPI, self).__init__(obj) + self.__characteristic_uuid = characteristic_uuid + self.__characteristic_handle = characteristic_handle + + @property + def characteristic_handle(self) -> int: + """Handle for the characteristic that this descriptor belongs to""" + return self.__characteristic_handle + + @property + def characteristic_uuid(self) -> str: + """UUID for the characteristic that this descriptor belongs to""" + return self.__characteristic_uuid + + @property + def uuid(self) -> str: + """UUID for this descriptor""" + return self.obj.uuid + + @property + def handle(self) -> int: + """Integer handle for this descriptor""" + return self.obj.handle diff --git a/bleak/backends/bgapi/scanner.py b/bleak/backends/bgapi/scanner.py new file mode 100644 index 00000000..b7fec928 --- /dev/null +++ b/bleak/backends/bgapi/scanner.py @@ -0,0 +1,221 @@ +import asyncio +import logging +import os +import struct +import sys +from typing import List, Optional +import uuid + +import bgapi + +if sys.version_info[:2] < (3, 8): + from typing_extensions import Literal +else: + from typing import Literal + +from ...exc import BleakError +from ..scanner import AdvertisementData, AdvertisementDataCallback, BaseBleakScanner +from . import BgapiRegistry +from ... import assigned_numbers + +class BleakScannerBGAPI(BaseBleakScanner): + """ + A scanner built to talk to a Silabs "BGAPI" NCP device. + """ + + def __init__( + self, + detection_callback: Optional[AdvertisementDataCallback], + service_uuids: Optional[List[str]], + scanning_mode: Literal["active", "passive"], + **kwargs, + ): + + super(BleakScannerBGAPI, self).__init__(detection_callback, service_uuids) + self._adapter: Optional[str] = kwargs.get("adapter", kwargs.get("ncp")) + tag = kwargs.get("logtag", hex(id(self))) + self.log = logging.getLogger(f"bleak.backends.bgapi.scanner.{tag}") + + # Env vars have priority + self._bgapi = os.environ.get("BLEAK_BGAPI_XAPI", kwargs.get("bgapi", None)) + if not self._bgapi: + raise BleakError( + "BGAPI file for your target (sl_bt.xapi) is required, normally this is in your SDK tree" + ) + self._adapter = os.environ.get( + "BLEAK_BGAPI_ADAPTER", kwargs.get("adapter", "/dev/ttyACM0") + ) + + self._loop = asyncio.get_running_loop() + self._bgh = BgapiRegistry.get(self._adapter, self._bgapi) + + scan_modes = { + "passive": self._bgh.lib.bt.scanner.SCAN_MODE_SCAN_MODE_PASSIVE, + "active": self._bgh.lib.bt.scanner.SCAN_MODE_SCAN_MODE_ACTIVE, + } + # TODO - might make this a "backend option"? + # self._phy = self._lib.bt.scanner.SCAN_PHY_SCAN_PHY_1M_AND_CODED + self._phy = self._bgh.lib.bt.scanner.SCAN_PHY_SCAN_PHY_1M + # TODO - might make this a "backend option"? + # Discover mode seems to be an internal filter on what it sees? + # maybe use the "filters" blob for this? + # I definitely need OBSERVATION for my own stuff at least. + # self._discover_mode = self._lib.bt.scanner.DISCOVER_MODE_DISCOVER_GENERIC + self._discover_mode = self._bgh.lib.bt.scanner.DISCOVER_MODE_DISCOVER_OBSERVATION + self._scanning_mode = scan_modes.get(scanning_mode, scan_modes["passive"]) + if scanning_mode == "passive" and service_uuids: + self.log.warning( + "service uuid filtering with passive scanning is super unreliable..." + ) + + # Don't bother supporting the deprecated set_scanning_filter in new work. + self._scanning_filters = {} + filters = kwargs.get("filters") + if filters: + self._scanning_filters = filters + + def _bgapi_evt_handler(self, evt): + """ + THIS RUNS IN THE BGLIB THREAD! + and because of this, we can't call commands from here ourself, we'd have to + recall them back onto the other thread? + """ + if ( + evt == "bt_evt_scanner_legacy_advertisement_report" + or evt == "bt_evt_scanner_extended_advertisement_report" + ): + rssif = self._scanning_filters.get("rssi", -255) + addr_match = self._scanning_filters.get("address", False) + addr_matches = True + if addr_match and addr_match != evt.address: + addr_matches = False + if evt.rssi > rssif and addr_matches: + self._loop.call_soon_threadsafe( + self._handle_advertising_data, evt, evt.data + ) + else: + self.log.warning(f"unhandled bgapi evt! {evt}") + + async def start(self): + await self._bgh.start_scan(self._phy, self._scanning_mode, self._discover_mode, self._bgapi_evt_handler) + + async def stop(self): + await self._bgh.stop_scan(self._bgapi_evt_handler) + + def set_scanning_filter(self, **kwargs): + # BGAPI doesn't do any itself, but doing it bleak can still be very userfriendly. + self._scanning_filters = kwargs + # raise NotImplementedError("BGAPI doesn't provide NCP level filters") + + def _handle_advertising_data(self, evt, raw): + """ + Make a bleak AdvertisementData() from our raw data, we'll fill in what we can. + :param data: + :return: + """ + + items = {} + index = 0 + # TODO make this smarter/faster/simpler + while index < len(raw): + remaining = raw[index:] + flen = remaining[0] + index = index + flen + 1 # account for length byte too! + if flen == 0: + continue + chunk = remaining[1 : 1 + flen] + type = chunk[0] + dat = chunk[1:] + items[type] = (type, dat) + + flags = None + local_name = None + service_uuids = [] + manufacturer_data = {} + tx_power = None + service_data = {} + + for type, dat in items.values(): + # Ok, do a little extra magic? + # Assigned numbers sec 2.3 + if type == 1: + assert len(dat) == 1 + flags = dat[0] + elif type in [0x2, 0x3]: + num = len(dat) // 2 + uuids16 = [struct.unpack_from("> 6 + subcat = app & 0x3f + + # self.log.debug("Appearance: %#x: %s-%s", app, + # assigned_numbers.AppearanceCategories[cat], + # "TODO" + # ) + elif type == 0x20: + (uuid32,) = struct.unpack(" str: + """The UUID to this service""" + return self.__uuid + + @property + def handle(self) -> int: + """A unique identifier for this service""" + return self.__handle + + @property + def characteristics(self) -> List[BleakGATTCharacteristicBGAPI]: + """List of characteristics for this service""" + return self.__characteristics + + def add_characteristic(self, characteristic: BleakGATTCharacteristicBGAPI): + """Add a :py:class:`~BleakGATTCharacteristicBGAPI` to the service. + + Should not be used by end user, but rather by `bleak` itself. + """ + self.__characteristics.append(characteristic) diff --git a/bleak/backends/client.py b/bleak/backends/client.py index d2bf6323..333cefb1 100644 --- a/bleak/backends/client.py +++ b/bleak/backends/client.py @@ -42,7 +42,7 @@ def __init__(self, address_or_ble_device: Union[BLEDevice, str], **kwargs): else: self.address = address_or_ble_device - self.services: Optional[BleakGATTServiceCollection] = None + self.services = BleakGATTServiceCollection() self._timeout = kwargs.get("timeout", 10.0) self._disconnected_callback = kwargs.get("disconnected_callback") @@ -257,6 +257,11 @@ def get_platform_client_backend_type() -> Type[BaseBleakClient]: """ Gets the platform-specific :class:`BaseBleakClient` type. """ + if os.environ.get("BLEAK_BGAPI_XAPI") is not None: + from bleak.backends.bgapi.client import BleakClientBGAPI + + return BleakClientBGAPI + if os.environ.get("P4A_BOOTSTRAP") is not None: from bleak.backends.p4android.client import BleakClientP4Android diff --git a/bleak/backends/scanner.py b/bleak/backends/scanner.py index 55636923..3027b638 100644 --- a/bleak/backends/scanner.py +++ b/bleak/backends/scanner.py @@ -237,6 +237,11 @@ def get_platform_scanner_backend_type() -> Type[BaseBleakScanner]: """ Gets the platform-specific :class:`BaseBleakScanner` type. """ + if os.environ.get("BLEAK_BGAPI_XAPI") is not None: + from bleak.backends.bgapi.scanner import BleakScannerBGAPI + + return BleakScannerBGAPI + if os.environ.get("P4A_BOOTSTRAP") is not None: from bleak.backends.p4android.scanner import BleakScannerP4Android diff --git a/docs/backends/bgapi.rst b/docs/backends/bgapi.rst new file mode 100644 index 00000000..b48f62db --- /dev/null +++ b/docs/backends/bgapi.rst @@ -0,0 +1,71 @@ +.. _bgapi-backend: + +Silicon Labs BGAPI backend +============= + +The BGAPI backend of Bleak communicates with any device that implements Silicon Labs "BGAPI" +Protocol. Classically, this is a Silicon Labs microcontroller, attached via a serial port, +which has been programmed with some variant of "NCP" (Network Co-Processor) firmware. + +This does `not` apply to devices using "RCP" (Radio Co-Processor) firmware, as those only +expose the much lower level HCI interface. + +References: + * `AN1259: Using the v3.x Silicon Labs Bluetooth Stack in Network Co-Processor Mode `_ + * https://docs.silabs.com/bluetooth/5.0/index + +Requirements +------ +This backend uses `pyBGAPI `_ to handle the protocol layers. + + +Usage +----- +This backend can either be explicitly selected via the ``backend`` kwarg when creating a BleakClient, +or, environment variables can be used. + +Environment variables understood: + * BLEAK_BGAPI_XAPI Must be a path to the ``sl_bt.xapi`` file. + If this env var exists, the BGAPI backend will be automatically loaded + * BLEAK_BGAPI_ADAPTER The serial port to use, eg ``/dev/ttyACM1`` + * BLEAK_BGAPI_BAUDRATE The serial baudrate to use when opening the port, if required. + +Alternatively, these can all be provided directly as kwargs, as show below: + +.. code-block:: python + + async with bleak.BleakClient( + "11:aa:bb:cc:22:33", + backend=bleak.backends.bgapi.client.BleakClientBGAPI, + bgapi="/home/.../SimplicityStudio/SDKs/gecko-4.2.0/protocol/bluetooth/api/sl_bt.xapi", + adapter="/dev/ttyACM1", + baudrate=921600, + ) as client: + logging.info("Connected to %s", client) + +Pay attention that the ``bgapi`` file must be provided, corresponding to the firmware used on your device. +These files can be found in the `Silicon Labs Gecko SDK `_ in +the ``protocol/bluetooth/api`` directory. + +Likewise, the ``adapter`` kwarg should be used to specify where the device is attached. + +At the time of writing, support for sockets or the Silicon Labs CPC daemon is not tested. + + + +API +--- + +Scanner +~~~~~~~ + +.. automodule:: bleak.backends.bluezdbus.scanner + :members: + +Client +~~~~~~ + +.. automodule:: bleak.backends.bluezdbus.client + :members: + +.. _`asyncio event loop`: https://docs.python.org/3/library/asyncio-eventloop.html diff --git a/docs/index.rst b/docs/index.rst index 5afd1ae4..c22bacec 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -39,6 +39,7 @@ Features * Supports Windows 10, version 16299 (Fall Creators Update) or greater * Supports Linux distributions with BlueZ >= 5.43 (See :ref:`linux-backend` for more details) * OS X/macOS support via Core Bluetooth API, from at least OS X version 10.11 +* Supports any os via Silicon Labs NCP devices. (See :ref:`bgapi-backend` for more details) Bleak supports reading, writing and getting notifications from GATT servers, as well as a function for discovering BLE devices.