diff --git a/README.rst b/README.rst index 4a5533e..ac2f09c 100644 --- a/README.rst +++ b/README.rst @@ -31,12 +31,12 @@ Usage Example .. code-block:: python - from adafruit_ble import SmartAdapter + from adafruit_ble import BLERadio - adapter = SmartAdapter() + radio = BLERadio() print("scanning") found = set() - for entry in adapter.start_scan(timeout=60, minimum_rssi=-80): + for entry in radio.start_scan(timeout=60, minimum_rssi=-80): addr = entry.address if addr not in found: print(entry) diff --git a/adafruit_ble/__init__.py b/adafruit_ble/__init__.py index beeb49f..2cde100 100755 --- a/adafruit_ble/__init__.py +++ b/adafruit_ble/__init__.py @@ -35,6 +35,7 @@ **Hardware:** Adafruit Feather nRF52840 Express + Adafruit Circuit Playground Bluefruit **Software and Dependencies:** @@ -53,46 +54,61 @@ __repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_BLE.git" class BLEConnection: - """This represents a connection to a peer BLE device. - - It acts as a map from a Service type to a Service instance for the connection. """ - def __init__(self, connection): - self._connection = connection - self._discovered_services = {} - """These are the bare remote services from _bleio.""" + Represents a connection to a peer BLE device. + It acts as a map from a `Service` type to a `Service` instance for the connection. + + :param bleio_connection _bleio.Connection: the native `_bleio.Connection` object to wrap + """ + def __init__(self, bleio_connection): + self._bleio_connection = bleio_connection + # _bleio.Service objects representing services found during discovery. + self._discovered_bleio_services = {} + # Service objects that wrap remote services. self._constructed_services = {} - """These are the Service instances from the library that wrap the remote services.""" def _discover_remote(self, uuid): remote_service = None - if uuid in self._discovered_services: - remote_service = self._discovered_services[uuid] + if uuid in self._discovered_bleio_services: + remote_service = self._discovered_bleio_services[uuid] else: - results = self._connection.discover_remote_services((uuid.bleio_uuid,)) + results = self._bleio_connection.discover_remote_services((uuid.bleio_uuid,)) if results: remote_service = results[0] - self._discovered_services[uuid] = remote_service + self._discovered_bleio_services[uuid] = remote_service return remote_service def __contains__(self, key): + """ + Allows easy testing for a particular Service class or a particular UUID + associated with this connection. + + Example:: + + if UARTService in connection: + # do something + + if StandardUUID(0x1234) in connection: + # do something + """ uuid = key if hasattr(key, "uuid"): uuid = key.uuid return self._discover_remote(uuid) is not None def __getitem__(self, key): + """Return the Service for the given Service class or uuid, if any.""" uuid = key maybe_service = False if hasattr(key, "uuid"): uuid = key.uuid maybe_service = True - remote_service = self._discover_remote(uuid) - if uuid in self._constructed_services: return self._constructed_services[uuid] + + remote_service = self._discover_remote(uuid) if remote_service: constructed_service = None if maybe_service: @@ -105,16 +121,19 @@ def __getitem__(self, key): @property def connected(self): """True if the connection to the peer is still active.""" - return self._connection.connected + return self._bleio_connection.connected def disconnect(self): """Disconnect from peer.""" - self._connection.disconnect() + self._bleio_connection.disconnect() class BLERadio: - """The BLERadio class enhances the normal `_bleio.Adapter`. + """ + BLERadio provides the interfaces for BLE advertising, + scanning for advertisements, and connecting to peers. There may be + multiple connections active at once. - It uses the library's `Advertisement` classes and the `BLEConnection` class.""" + It uses this library's `Advertisement` classes and the `BLEConnection` class.""" def __init__(self, adapter=None): if not adapter: @@ -123,34 +142,63 @@ def __init__(self, adapter=None): self._current_advertisement = None self._connection_cache = {} - def start_advertising(self, advertisement, scan_response=None, **kwargs): - """Starts advertising the given advertisement. + def start_advertising(self, advertisement, scan_response=None, interval=0.1): + """ + Starts advertising the given advertisement. - It takes most kwargs of `_bleio.Adapter.start_advertising`.""" + :param buf scan_response: scan response data packet bytes. + ``None`` if no scan response is needed. + :param float interval: advertising interval, in seconds + """ scan_response_data = None if scan_response: scan_response_data = bytes(scan_response) self._adapter.start_advertising(bytes(advertisement), scan_response=scan_response_data, connectable=advertisement.connectable, - **kwargs) + interval=interval) def stop_advertising(self): """Stops advertising.""" self._adapter.stop_advertising() - def start_scan(self, *advertisement_types, **kwargs): - """Starts scanning. Returns an iterator of advertisement objects of the types given in - advertisement_types. The iterator will block until an advertisement is heard or the scan - times out. - - If any ``advertisement_types`` are given, only Advertisements of those types are produced - by the returned iterator. If none are given then `Advertisement` objects will be - returned.""" + def start_scan(self, *advertisement_types, buffer_size=512, extended=False, timeout=None, + interval=0.1, window=0.1, minimum_rssi=-80, active=True): + """ + Starts scanning. Returns an iterator of advertisement objects of the types given in + advertisement_types. The iterator will block until an advertisement is heard or the scan + times out. + + If any ``advertisement_types`` are given, only Advertisements of those types are produced + by the returned iterator. If none are given then `Advertisement` objects will be + returned. + + Advertisements and scan responses are filtered and returned separately. + + :param int buffer_size: the maximum number of advertising bytes to buffer. + :param bool extended: When True, support extended advertising packets. + Increasing buffer_size is recommended when this is set. + :param float timeout: the scan timeout in seconds. + If None, will scan until `stop_scan` is called. + :param float interval: the interval (in seconds) between the start + of two consecutive scan windows + Must be in the range 0.0025 - 40.959375 seconds. + :param float window: the duration (in seconds) to scan a single BLE channel. + window must be <= interval. + :param int minimum_rssi: the minimum rssi of entries to return. + :param bool active: request and retrieve scan responses for scannable advertisements. + :return: If any ``advertisement_types`` are given, + only Advertisements of those types are produced by the returned iterator. + If none are given then `Advertisement` objects will be returned. + :rtype: iterable + """ prefixes = b"" if advertisement_types: prefixes = b"".join(adv.prefix for adv in advertisement_types) - for entry in self._adapter.start_scan(prefixes=prefixes, **kwargs): + for entry in self._adapter.start_scan(prefixes=prefixes, buffer_size=buffer_size, + extended=extended, timeout=timeout, + interval=interval, window=window, + minimum_rssi=minimum_rssi, active=active): adv_type = Advertisement for possible_type in advertisement_types: if possible_type.matches(entry) and issubclass(possible_type, adv_type): @@ -167,7 +215,14 @@ def stop_scan(self): self._adapter.stop_scan() def connect(self, advertisement, *, timeout=4): - """Initiates a `BLEConnection` to the peer that advertised the given advertisement.""" + """ + Initiates a `BLEConnection` to the peer that advertised the given advertisement. + + :param advertisement Advertisement: An `Advertisement` or a subclass of `Advertisement` + :param timeout float: how long to wait for a connection + :return: the connection to the peer + :rtype: BLEConnection + """ connection = self._adapter.connect(advertisement.address, timeout=timeout) self._connection_cache[connection] = BLEConnection(connection) return self._connection_cache[connection] diff --git a/adafruit_ble/advertising/__init__.py b/adafruit_ble/advertising/__init__.py index 8125a25..093d708 100644 --- a/adafruit_ble/advertising/__init__.py +++ b/adafruit_ble/advertising/__init__.py @@ -25,15 +25,13 @@ import struct -def to_hex(b): +def to_hex(seq): """Pretty prints a byte sequence as hex values.""" - # pylint: disable=invalid-name - return " ".join(["{:02x}".format(v) for v in b]) + return " ".join("{:02x}".format(v) for v in seq) -def to_bytes_literal(b): +def to_bytes_literal(seq): """Prints a byte sequence as a Python bytes literal that only uses hex encoding.""" - # pylint: disable=invalid-name - return "b\"" + "".join(["\\x{:02x}".format(v) for v in b]) + "\"" + return "b\"" + "".join("\\x{:02x}".format(v) for v in seq) + "\"" def decode_data(data, *, key_encoding="B"): """Helper which decodes length encoded structures into a dictionary with the given key diff --git a/adafruit_ble/advertising/standard.py b/adafruit_ble/advertising/standard.py index 0d75c90..4abae54 100644 --- a/adafruit_ble/advertising/standard.py +++ b/adafruit_ble/advertising/standard.py @@ -64,7 +64,8 @@ def __contains__(self, key): return uuid in self._vendor_services or uuid in self._standard_services def _update(self, adt, uuids): - if len(uuids) == 0: + if not uuids: + # uuids is empty del self._advertisement.data_dict[adt] uuid_length = uuids[0].size // 8 b = bytearray(len(uuids) * uuid_length) @@ -131,12 +132,12 @@ def _present(self, obj): def __get__(self, obj, cls): if not self._present(obj) and not obj.mutable: return None - if not hasattr(obj, "_service_lists"): - obj._service_lists = {} + if not hasattr(obj, "adv_service_lists"): + obj.adv_service_lists = {} first_adt = self.standard_services[0] - if first_adt not in obj._service_lists: - obj._service_lists[first_adt] = BoundServiceList(obj, **self.__dict__) - return obj._service_lists[first_adt] + if first_adt not in obj.adv_service_lists: + obj.adv_service_lists[first_adt] = BoundServiceList(obj, **self.__dict__) + return obj.adv_service_lists[first_adt] class ProvideServicesAdvertisement(Advertisement): """Advertise what services that the device makes available upon connection.""" diff --git a/adafruit_ble/attributes/__init__.py b/adafruit_ble/attributes/__init__.py new file mode 100644 index 0000000..b0b80d4 --- /dev/null +++ b/adafruit_ble/attributes/__init__.py @@ -0,0 +1,72 @@ +# The MIT License (MIT) +# +# Copyright (c) 2019 Dan Halbert for Adafruit Industries +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. +""" +:py:mod:`~adafruit_ble.attributes` +==================================================== + +This module provides definitions common to all kinds of BLE attributes, +specifically characteristics and descriptors. + +""" +import _bleio + +__version__ = "0.0.0-auto.0" +__repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_BLE.git" + +class Attribute: + """Constants describing security levels. + + .. data:: NO_ACCESS + + security mode: access not allowed + + .. data:: OPEN + + security_mode: no security (link is not encrypted) + + .. data:: ENCRYPT_NO_MITM + + security_mode: unauthenticated encryption, without man-in-the-middle protection + + .. data:: ENCRYPT_WITH_MITM + + security_mode: authenticated encryption, with man-in-the-middle protection + + .. data:: LESC_ENCRYPT_WITH_MITM + + security_mode: LESC encryption, with man-in-the-middle protection + + .. data:: SIGNED_NO_MITM + + security_mode: unauthenticated data signing, without man-in-the-middle protection + + .. data:: SIGNED_WITH_MITM + + security_mode: authenticated data signing, without man-in-the-middle protection +""" + NO_ACCESS = _bleio.Attribute.NO_ACCESS + OPEN = _bleio.Attribute.OPEN + ENCRYPT_NO_MITM = _bleio.Attribute.ENCRYPT_NO_MITM + ENCRYPT_WITH_MITM = _bleio.Attribute.ENCRYPT_WITH_MITM + LESC_ENCRYPT_WITH_MITM = _bleio.Attribute.LESC_ENCRYPT_WITH_MITM + SIGNED_NO_MITM = _bleio.Attribute.SIGNED_NO_MITM + SIGNED_WITH_MITM = _bleio.Attribute.SIGNED_NO_MITM diff --git a/adafruit_ble/characteristics/__init__.py b/adafruit_ble/characteristics/__init__.py index 78daf3e..883709d 100644 --- a/adafruit_ble/characteristics/__init__.py +++ b/adafruit_ble/characteristics/__init__.py @@ -30,32 +30,90 @@ import struct import _bleio +from ..attributes import Attribute + __version__ = "0.0.0-auto.0" __repo__ = "https://github.com/adafruit/Adafruit_CircuitPython_BLE.git" class Characteristic: - """Top level Characteristic class that does basic binding.""" - def __init__(self, *, uuid=None, initial_value=None, max_length=None, **kwargs): + """ + Top level Characteristic class that does basic binding. + + :param UUID uuid: The uuid of the characteristic + :param int properties: The properties of the characteristic, + specified as a bitmask of these values bitwise-or'd together: + `BROADCAST`, `INDICATE`, `NOTIFY`, `READ`, `WRITE`, `WRITE_NO_RESPONSE`. + :param int read_perm: Specifies whether the characteristic can be read by a client, + and if so, which security mode is required. + Must be one of the integer values `Attribute.NO_ACCESS`, `Attribute.OPEN`, + `Attribute.ENCRYPT_NO_MITM`, `Attribute.ENCRYPT_WITH_MITM`, + `Attribute.LESC_ENCRYPT_WITH_MITM`, + `Attribute.SIGNED_NO_MITM`, or `Attribute.SIGNED_WITH_MITM`. + :param int write_perm: Specifies whether the characteristic can be written by a client, + and if so, which security mode is required. Values allowed are the same as ``read_perm``. + :param int max_length: Maximum length in bytes of the characteristic value. The maximum allowed + is 512, or possibly 510 if ``fixed_length`` is False. The default, 20, is the maximum + number of data bytes that fit in a single BLE 4.x ATT packet. + :param bool fixed_length: True if the characteristic value is of fixed length. + :param buf initial_value: The initial value for this characteristic. If not given, will be + filled with zeros. + + .. data:: BROADCAST + + property: allowed in advertising packets + + .. data:: INDICATE + + property: server will indicate to the client when the value is set and wait for a response + + .. data:: NOTIFY + + property: server will notify the client when the value is set + + .. data:: READ + + property: clients may read this characteristic + + .. data:: WRITE + + property: clients may write this characteristic; a response will be sent back + + .. data:: WRITE_NO_RESPONSE + + property: clients may write this characteristic; no response will be sent back +""" + BROADCAST = _bleio.Characteristic.BROADCAST + INDICATE = _bleio.Characteristic.INDICATE + NOTIFY = _bleio.Characteristic.NOTIFY + READ = _bleio.Characteristic.READ + WRITE = _bleio.Characteristic.WRITE + WRITE_NO_RESPONSE = _bleio.Characteristic.WRITE_NO_RESPONSE + + def __init__(self, *, uuid=None, properties=0, + read_perm=Attribute.OPEN, write_perm=Attribute.OPEN, + max_length=20, fixed_length=False, initial_value=None): + self.field_name = None # Set by Service during basic binding + if uuid: self.uuid = uuid - self.kwargs = kwargs - self.initial_value = initial_value + self.properties = properties + self.read_perm = read_perm + self.write_perm = write_perm self.max_length = max_length - self.field_name = None # Set by Service during basic binding + self.fixed_length = fixed_length + self.initial_value = initial_value def _ensure_bound(self, service, initial_value=None): """Binds the characteristic to the local Service or remote Characteristic object given.""" if self.field_name in service.bleio_characteristics: return if service.remote: - bleio_characteristic = None - remote_characteristics = service.bleio_service.characteristics - for characteristic in remote_characteristics: + for characteristic in service.bleio_service.characteristics: if characteristic.uuid == self.uuid.bleio_uuid: bleio_characteristic = characteristic break - if not bleio_characteristic: + else: raise AttributeError("Characteristic not available on remote service") else: bleio_characteristic = self.__bind_locally(service, initial_value) @@ -74,18 +132,14 @@ def __bind_locally(self, service, initial_value): if max_length is None: max_length = len(initial_value) return _bleio.Characteristic.add_to_service( - service.bleio_service, - self.uuid.bleio_uuid, - initial_value=initial_value, - max_length=max_length, - **self.kwargs - ) + service.bleio_service, self.uuid.bleio_uuid, initial_value=initial_value, + max_length=max_length, fixed_length=self.fixed_length, + properties=self.properties, read_perm=self.read_perm, write_perm=self.write_perm) def __get__(self, service, cls=None): self._ensure_bound(service) bleio_characteristic = service.bleio_characteristics[self.field_name] - raw_data = bleio_characteristic.value - return raw_data + return bleio_characteristic.value def __set__(self, service, value): self._ensure_bound(service, value) @@ -93,28 +147,36 @@ def __set__(self, service, value): bleio_characteristic.value = value class ComplexCharacteristic: - """Characteristic class that does complex binding where the subclass returns a full object for - interacting with the characteristic data. The Characteristic itself will be shadowed once it - has been bound to the corresponding instance attribute.""" - def __init__(self, *, uuid=None, **kwargs): + """ + Characteristic class that does complex binding where the subclass returns a full object for + interacting with the characteristic data. The Characteristic itself will be shadowed once it + has been bound to the corresponding instance attribute. + """ + def __init__(self, *, uuid=None, properties=0, + read_perm=Attribute.OPEN, write_perm=Attribute.OPEN, + max_length=20, fixed_length=False, initial_value=None): + self.field_name = None # Set by Service during basic binding + if uuid: self.uuid = uuid - self.kwargs = kwargs - self.field_name = None # Set by Service + self.properties = properties + self.read_perm = read_perm + self.write_perm = write_perm + self.max_length = max_length + self.fixed_length = fixed_length + self.initial_value = initial_value def bind(self, service): """Binds the characteristic to the local Service or remote Characteristic object given.""" if service.remote: - remote_characteristics = service.bleio_service.characteristics - for characteristic in remote_characteristics: + for characteristic in service.bleio_service.characteristics: if characteristic.uuid == self.uuid.bleio_uuid: return characteristic raise AttributeError("Characteristic not available on remote service") return _bleio.Characteristic.add_to_service( - service.bleio_service, - self.uuid.bleio_uuid, - **self.kwargs - ) + service.bleio_service, self.uuid.bleio_uuid, + initial_value=self.initial_value, max_length=self.max_length, + properties=self.properties, read_perm=self.read_perm, write_perm=self.write_perm) def __get__(self, service, cls=None): bound_object = self.bind(service) @@ -122,13 +184,27 @@ def __get__(self, service, cls=None): return bound_object class StructCharacteristic(Characteristic): - """Data descriptor for a structure with a fixed format.""" - def __init__(self, struct_format, **kwargs): + """ + Data descriptor for a structure with a fixed format. + + :param struct_format: a `struct` format string describing how to pack multiple values + into the characteristic bytestring + :param UUID uuid: The uuid of the characteristic + :param int properties: see `Characteristic` + :param int read_perm: see `Characteristic` + :param int write_perm: see `Characteristic` + :param buf initial_value: see `Characteristic` + """ + def __init__(self, struct_format, *, uuid=None, properties=0, + read_perm=Attribute.OPEN, write_perm=Attribute.OPEN, + initial_value=None): self._struct_format = struct_format self._expected_size = struct.calcsize(struct_format) - if "initial_value" in kwargs: - kwargs["initial_value"] = struct.pack(self._struct_format, *kwargs["initial_value"]) - super().__init__(**kwargs, max_length=self._expected_size, fixed_length=True) + if initial_value: + initial_value = struct.pack(self._struct_format, initial_value) + super().__init__(uuid=uuid, initial_value=initial_value, + max_length=self._expected_size, fixed_length=True, + properties=properties, read_perm=read_perm, write_perm=write_perm) def __get__(self, obj, cls=None): raw_data = super().__get__(obj, cls) diff --git a/adafruit_ble/characteristics/float.py b/adafruit_ble/characteristics/float.py index d33903e..eced8c3 100644 --- a/adafruit_ble/characteristics/float.py +++ b/adafruit_ble/characteristics/float.py @@ -27,6 +27,7 @@ """ +from . import Attribute from . import StructCharacteristic __version__ = "0.0.0-auto.0" @@ -34,11 +35,14 @@ class FloatCharacteristic(StructCharacteristic): """32-bit float""" - # TODO: Valid set values as within range. - def __init__(self, **kwargs): - if "initial_value" in kwargs: - kwargs["initial_value"] = (kwargs["initial_value"],) - super().__init__("