diff options
author | Gilles Boccon-Gibod <bok@bok.net> | 2022-07-22 10:21:39 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2022-07-22 10:21:39 -0700 |
commit | c66b357de6908cf3680d83a73c6744451e2d0fa0 (patch) | |
tree | e4a6644f35255b3e2cdd729431e3cff4d9ca4095 | |
parent | 0ffed3defff40e6622107de322053a2b05a1d26c (diff) | |
parent | e156ed375854a838ea1a3d7d13ed3070480da17f (diff) | |
download | bumble-c66b357de6908cf3680d83a73c6744451e2d0fa0.tar.gz |
Merge pull request #13 from google/gbg/standard-profiles
support for type adapters and framework for standard GATT profiles
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | apps/gatt_dump.py | 6 | ||||
-rw-r--r-- | apps/gg_bridge.py | 2 | ||||
-rw-r--r-- | bumble/att.py | 28 | ||||
-rw-r--r-- | bumble/device.py | 11 | ||||
-rw-r--r-- | bumble/gatt.py | 250 | ||||
-rw-r--r-- | bumble/gatt_client.py | 117 | ||||
-rw-r--r-- | bumble/profiles/__init__.py | 13 | ||||
-rw-r--r-- | bumble/profiles/battery_service.py | 61 | ||||
-rw-r--r-- | bumble/profiles/device_information_service.py | 135 | ||||
-rw-r--r-- | examples/battery_client.py | 72 | ||||
-rw-r--r-- | examples/battery_server.py (renamed from examples/battery_service.py) | 46 | ||||
-rw-r--r-- | examples/device_information_client.py | 80 | ||||
-rw-r--r-- | examples/device_information_server.py | 66 | ||||
-rw-r--r-- | examples/get_peer_device_info.py | 96 | ||||
-rw-r--r-- | examples/run_controller.py | 3 | ||||
-rw-r--r-- | examples/run_gatt_client.py | 4 | ||||
-rw-r--r-- | examples/run_gatt_client_and_server.py | 70 | ||||
-rw-r--r-- | tests/gatt_test.py | 150 |
19 files changed, 937 insertions, 274 deletions
@@ -8,3 +8,4 @@ docs/mkdocs/site tests/__pycache__ test-results.xml bumble/transport/__pycache__ +bumble/profiles/__pycache__ diff --git a/apps/gatt_dump.py b/apps/gatt_dump.py index c055626..02c3641 100644 --- a/apps/gatt_dump.py +++ b/apps/gatt_dump.py @@ -32,10 +32,10 @@ async def dump_gatt_db(peer, done): # Discover all services print(color('### Discovering Services and Characteristics', 'magenta')) await peer.discover_services() - await peer.discover_characteristics() for service in peer.services: + await service.discover_characteristics() for characteristic in service.characteristics: - await peer.discover_descriptors(characteristic) + await characteristic.discover_descriptors() print(color('=== Services ===', 'yellow')) show_services(peer.services) @@ -47,7 +47,7 @@ async def dump_gatt_db(peer, done): for attribute in attributes: print(attribute) try: - value = await peer.read_value(attribute) + value = await attribute.read_value() print(color(f'{value.hex()}', 'green')) except ProtocolError as error: print(color(error, 'red')) diff --git a/apps/gg_bridge.py b/apps/gg_bridge.py index ac4ae5a..d524913 100644 --- a/apps/gg_bridge.py +++ b/apps/gg_bridge.py @@ -73,7 +73,7 @@ class GattlinkHubBridge(Device.Listener): gattlink_service = services[0] # Discover all the characteristics for the service - characteristics = await self.peer.discover_characteristics(service = gattlink_service) + characteristics = await gattlink_service.discover_characteristics() print(color('=== Characteristics discovered', 'yellow')) for characteristic in characteristics: if characteristic.uuid == GG_GATTLINK_RX_CHARACTERISTIC_UUID: diff --git a/bumble/att.py b/bumble/att.py index 9f82424..22b683e 100644 --- a/bumble/att.py +++ b/bumble/att.py @@ -682,11 +682,14 @@ class Attribute(EventEmitter): def __init__(self, attribute_type, permissions, value = b''): EventEmitter.__init__(self) - self.handle = 0 - self.permissions = permissions - - # Convert the type to a UUID - if type(attribute_type) is bytes: + self.handle = 0 + self.end_group_handle = 0 + self.permissions = permissions + + # Convert the type to a UUID object if it isn't already + if type(attribute_type) is str: + self.type = UUID(attribute_type) + elif type(attribute_type) is bytes: self.type = UUID.from_bytes(attribute_type) else: self.type = attribute_type @@ -698,16 +701,13 @@ class Attribute(EventEmitter): self.value = value def read_value(self, connection): - if type(self.value) is bytes: - return self.value + if read := getattr(self.value, 'read', None): + try: + return read(connection) + except ATT_Error as error: + raise ATT_Error(error_code=error.error_code, att_handle=self.handle) else: - if read := getattr(self.value, 'read', None): - try: - return read(connection) - except ATT_Error as error: - raise ATT_Error(error_code=error.error_code, att_handle=self.handle) - else: - return bytes(self.value) + return self.value def write_value(self, connection, value): if write := getattr(self.value, 'write', None): diff --git a/bumble/device.py b/bumble/device.py index f834f9e..97ead5e 100644 --- a/bumble/device.py +++ b/bumble/device.py @@ -137,6 +137,17 @@ class Peer: def get_characteristics_by_uuid(self, uuid, service = None): return self.gatt_client.get_characteristics_by_uuid(uuid, service) + def create_service_proxy(self, proxy_class): + return proxy_class.from_client(self.gatt_client) + + async def discover_service_and_create_proxy(self, proxy_class): + # Discover the first matching service and its characteristics + services = await self.discover_service(proxy_class.SERVICE_CLASS.UUID) + if services: + service = services[0] + await service.discover_characteristics() + return self.create_service_proxy(proxy_class) + # [Classic only] async def request_name(self): return await self.connection.request_remote_name() diff --git a/bumble/gatt.py b/bumble/gatt.py index 31f5625..df760c3 100644 --- a/bumble/gatt.py +++ b/bumble/gatt.py @@ -22,6 +22,8 @@ # ----------------------------------------------------------------------------- # Imports # ----------------------------------------------------------------------------- +import asyncio +import types import logging from colors import color @@ -53,13 +55,13 @@ GATT_NEXT_DST_CHANGE_SERVICE = UUID.from_16_bits(0x1807, 'Next DS GATT_GLUCOSE_SERVICE = UUID.from_16_bits(0x1808, 'Glucose') GATT_HEALTH_THERMOMETER_SERVICE = UUID.from_16_bits(0x1809, 'Health Thermometer') GATT_DEVICE_INFORMATION_SERVICE = UUID.from_16_bits(0x180A, 'Device Information') -GATT_DEVICE_HEART_RATE_SERVICE = UUID.from_16_bits(0x180D, 'Heart Rate') -GATT_PHONE_ALTERT_STATUS_SERVICE = UUID.from_16_bits(0x180E, 'Phone Alert Status') -GATT_DEVICE_BATTERY_SERVICE = UUID.from_16_bits(0x180F, 'Battery') +GATT_HEART_RATE_SERVICE = UUID.from_16_bits(0x180D, 'Heart Rate') +GATT_PHONE_ALERT_STATUS_SERVICE = UUID.from_16_bits(0x180E, 'Phone Alert Status') +GATT_BATTERY_SERVICE = UUID.from_16_bits(0x180F, 'Battery') GATT_BLOOD_PRESSURE_SERVICE = UUID.from_16_bits(0x1810, 'Blood Pressure') -GATT_ALTERT_NOTIFICATION_SERVICE = UUID.from_16_bits(0x1811, 'Alert Notification') -GATT_DEVICE_HUMAN_INTERFACE_DEVICE_SERVICE = UUID.from_16_bits(0x1812, 'Human Interface Device') -GATT_DEVICE_SCAN_PARAMETERS_SERVICE = UUID.from_16_bits(0x1813, 'Scan Parameters') +GATT_ALERT_NOTIFICATION_SERVICE = UUID.from_16_bits(0x1811, 'Alert Notification') +GATT_HUMAN_INTERFACE_DEVICE_SERVICE = UUID.from_16_bits(0x1812, 'Human Interface Device') +GATT_SCAN_PARAMETERS_SERVICE = UUID.from_16_bits(0x1813, 'Scan Parameters') GATT_RUNNING_SPEED_AND_CADENCE_SERVICE = UUID.from_16_bits(0x1814, 'Running Speed and Cadence') GATT_AUTOMATION_IO_SERVICE = UUID.from_16_bits(0x1815, 'Automation IO') GATT_CYCLING_SPEED_AND_CADENCE_SERVICE = UUID.from_16_bits(0x1816, 'Cycling Speed and Cadence') @@ -119,7 +121,7 @@ GATT_ENVIRONMENTAL_SENSING_CONFIGURATION_DESCRIPTOR = UUID.from_16_bits(0x290B, GATT_ENVIRONMENTAL_SENSING_MEASUREMENT_DESCRIPTOR = UUID.from_16_bits(0x290C, 'Environmental Sensing Measurement') GATT_ENVIRONMENTAL_SENSING_TRIGGER_DESCRIPTOR = UUID.from_16_bits(0x290D, 'Environmental Sensing Trigger Setting') GATT_TIME_TRIGGER_DESCRIPTOR = UUID.from_16_bits(0x290E, 'Time Trigger Setting') -GATT_COMPLETE_BE_EDR_TRANSPORT_BLOCK_DATA_DESCRIPTOR = UUID.from_16_bits(0x290F, 'Complete BR-EDR Transport Block Data') +GATT_COMPLETE_BR_EDR_TRANSPORT_BLOCK_DATA_DESCRIPTOR = UUID.from_16_bits(0x290F, 'Complete BR-EDR Transport Block Data') # Device Information Service GATT_SYSTEM_ID_CHARACTERISTIC = UUID.from_16_bits(0x2A23, 'System ID') @@ -140,19 +142,19 @@ GATT_REPORT_CHARACTERISTIC = UUID.from_16_bits(0x2A4D, 'Report') GATT_PROTOCOL_MODE_CHARACTERISTIC = UUID.from_16_bits(0x2A4E, 'Protocol Mode') # Misc -GATT_DEVICE_NAME_CHARACTERISTIC = UUID.from_16_bits(0x2A00, 'Device Name') -GATT_APPEARANCE_CHARACTERISTIC = UUID.from_16_bits(0x2A01, 'Appearance') -GATT_PERIPHERAL_PRIVACY_FLAG_CHARACTERISTIC = UUID.from_16_bits(0x2A02, 'Peripheral Privacy Flag') -GATT_RECONNECTION_ADDRESS_CHARACTERISTIC = UUID.from_16_bits(0x2A03, 'Reconnection Address') -GATT_PERIPHERAL_PREFERRREED_CONNECTION_PARAMETERS_CHARACTERISTIC = UUID.from_16_bits(0x2A04, 'Peripheral Preferred Connection Parameters') -GATT_SERVICE_CHANGED_CHARACTERISTIC = UUID.from_16_bits(0x2A05, 'Service Changed') -GATT_ALERT_LEVEL_CHARACTERISTIC = UUID.from_16_bits(0x2A06, 'Alert Level') -GATT_TX_POWER_LEVEL_CHARACTERISTIC = UUID.from_16_bits(0x2A07, 'Tx Power Level') -GATT_BATTERY_LEVEL_CHARACTERISTIC = UUID.from_16_bits(0x2A19, 'Battery Level') -GATT_BOOT_KEYBOARD_INPUT_REPORT_CHARACTERISTIC = UUID.from_16_bits(0x2A22, 'Boot Keyboard Input Report') -GATT_CURRENT_TIME_CHARACTERISTIC = UUID.from_16_bits(0x2A2B, 'Current Time') -GATT_BOOT_KEYBOARD_OUTPUT_REPORT_CHARACTERISTIC = UUID.from_16_bits(0x2A32, 'Boot Keyboard Output Report') -GATT_CENTRAL_ADDRESS_RESOLUTION__CHARACTERISTIC = UUID.from_16_bits(0x2AA6, 'Central Address Resolution') +GATT_DEVICE_NAME_CHARACTERISTIC = UUID.from_16_bits(0x2A00, 'Device Name') +GATT_APPEARANCE_CHARACTERISTIC = UUID.from_16_bits(0x2A01, 'Appearance') +GATT_PERIPHERAL_PRIVACY_FLAG_CHARACTERISTIC = UUID.from_16_bits(0x2A02, 'Peripheral Privacy Flag') +GATT_RECONNECTION_ADDRESS_CHARACTERISTIC = UUID.from_16_bits(0x2A03, 'Reconnection Address') +GATT_PERIPHERAL_PREFERRED_CONNECTION_PARAMETERS_CHARACTERISTIC = UUID.from_16_bits(0x2A04, 'Peripheral Preferred Connection Parameters') +GATT_SERVICE_CHANGED_CHARACTERISTIC = UUID.from_16_bits(0x2A05, 'Service Changed') +GATT_ALERT_LEVEL_CHARACTERISTIC = UUID.from_16_bits(0x2A06, 'Alert Level') +GATT_TX_POWER_LEVEL_CHARACTERISTIC = UUID.from_16_bits(0x2A07, 'Tx Power Level') +GATT_BATTERY_LEVEL_CHARACTERISTIC = UUID.from_16_bits(0x2A19, 'Battery Level') +GATT_BOOT_KEYBOARD_INPUT_REPORT_CHARACTERISTIC = UUID.from_16_bits(0x2A22, 'Boot Keyboard Input Report') +GATT_CURRENT_TIME_CHARACTERISTIC = UUID.from_16_bits(0x2A2B, 'Current Time') +GATT_BOOT_KEYBOARD_OUTPUT_REPORT_CHARACTERISTIC = UUID.from_16_bits(0x2A32, 'Boot Keyboard Output Report') +GATT_CENTRAL_ADDRESS_RESOLUTION__CHARACTERISTIC = UUID.from_16_bits(0x2AA6, 'Central Address Resolution') # ----------------------------------------------------------------------------- @@ -189,7 +191,6 @@ class Service(Attribute): self.uuid = uuid self.included_services = [] self.characteristics = characteristics[:] - self.end_group_handle = 0 self.primary = primary def __str__(self): @@ -197,6 +198,18 @@ class Service(Attribute): # ----------------------------------------------------------------------------- +class TemplateService(Service): + ''' + Convenience abstract class that can be used by profile-specific subclasses that want + to expose their UUID as a class property + ''' + UUID = None + + def __init__(self, characteristics, primary=True): + super().__init__(self.UUID, characteristics, primary) + + +# ----------------------------------------------------------------------------- class Characteristic(Attribute): ''' See Vol 3, Part G - 3.3 CHARACTERISTIC DEFINITION @@ -227,56 +240,34 @@ class Characteristic(Attribute): def property_name(property): return Characteristic.PROPERTY_NAMES.get(property, '') - def __init__(self, uuid, properties, permissions, value = b'', descriptors = []): - # Convert the uuid to a UUID object if it isn't already - if type(uuid) is str: - uuid = UUID(uuid) + @staticmethod + def properties_as_string(properties): + return ','.join([ + Characteristic.property_name(p) for p in Characteristic.PROPERTY_NAMES.keys() + if properties & p + ]) + def __init__(self, uuid, properties, permissions, value = b'', descriptors = []): super().__init__(uuid, permissions, value) - self.uuid = uuid - self.properties = properties - self._descriptors = descriptors - self._descriptors_discovered = False - self.end_group_handle = 0 - self.attach_descriptors() - - def attach_descriptors(self): - """ Let all the descriptors know they are attached to this characteristic """ - for descriptor in self._descriptors: - descriptor.characteristic = self - - def add_descriptor(self, descriptor): - descriptor.characteristic = self - self.descriptors.append(descriptor) + self.uuid = self.type + self.properties = properties + self.descriptors = descriptors def get_descriptor(self, descriptor_type): for descriptor in self.descriptors: if descriptor.uuid == descriptor_type: return descriptor - @property - def descriptors(self): - return self._descriptors - - @descriptors.setter - def descriptors(self, value): - self._descriptors = value - self._descriptors_discovered = True - self.attach_descriptors() - - @property - def descriptors_discovered(self): - return self._descriptors_discovered - - def get_properties_as_string(self): - return ','.join([self.property_name(p) for p in self.PROPERTY_NAMES.keys() if self.properties & p]) - def __str__(self): - return f'Characteristic(handle=0x{self.handle:04X}, end=0x{self.end_group_handle:04X}, uuid={self.uuid}, properties={self.get_properties_as_string()})' + return f'Characteristic(handle=0x{self.handle:04X}, end=0x{self.end_group_handle:04X}, uuid={self.uuid}, properties={Characteristic.properties_as_string(self.properties)})' # ----------------------------------------------------------------------------- class CharacteristicValue: + ''' + Characteristic value where reading and/or writing is delegated to functions + passed as arguments to the constructor. + ''' def __init__(self, read=None, write=None): self._read = read self._write = write @@ -290,19 +281,144 @@ class CharacteristicValue: # ----------------------------------------------------------------------------- +class CharacteristicAdapter: + ''' + An adapter that can adapt any object with `read_value` and `write_value` + methods (like Characteristic and CharacteristicProxy objects) by wrapping + those methods with ones that return/accept encoded/decoded values. + Objects with async methods are considered proxies, so the adaptation is one + where the return value of `read_value` is decoded and the value passed to + `write_value` is encoded. Other objects are considered local characteristics + so the adaptation is one where the return value of `read_value` is encoded + and the value passed to `write_value` is decoded. + If the characteristic has a `subscribe` method, it is wrapped with one where + the values are decoded before being passed to the subscriber. + ''' + def __init__(self, characteristic): + self.wrapped_characteristic = characteristic + + if ( + asyncio.iscoroutinefunction(characteristic.read_value) and + asyncio.iscoroutinefunction(characteristic.write_value) + ): + self.read_value = self.read_decoded_value + self.write_value = self.write_decoded_value + else: + self.read_value = self.read_encoded_value + self.write_value = self.write_encoded_value + + if hasattr(self.wrapped_characteristic, 'subscribe'): + self.subscribe = self.wrapped_subscribe + + def __getattr__(self, name): + return getattr(self.wrapped_characteristic, name) + + def read_encoded_value(self, connection): + return self.encode_value(self.wrapped_characteristic.read_value(connection)) + + def write_encoded_value(self, connection, value): + return self.wrapped_characteristic.write_value(connection, self.decode_value(value)) + + async def read_decoded_value(self): + return self.decode_value(await self.wrapped_characteristic.read_value()) + + async def write_decoded_value(self, value): + return await self.wrapped_characteristic.write_value(self.encode_value(value)) + + def encode_value(self, value): + return value + + def decode_value(self, value): + return value + + def wrapped_subscribe(self, subscriber=None): + return self.wrapped_characteristic.subscribe( + None if subscriber is None else lambda value: subscriber(self.decode_value(value)) + ) + + +# ----------------------------------------------------------------------------- +class DelegatedCharacteristicAdapter(CharacteristicAdapter): + def __init__(self, characteristic, encode, decode): + super().__init__(characteristic) + self.encode = encode + self.decode = decode + + def encode_value(self, value): + return self.encode(value) + + def decode_value(self, value): + return self.decode(value) + + +# ----------------------------------------------------------------------------- +class PackedCharacteristicAdapter(CharacteristicAdapter): + ''' + Adapter that packs/unpacks characteristic values according to a standard + Python `struct` format. + For formats with a single value, the adapted `read_value` and `write_value` + methods return/accept single values. For formats with multiple values, + they return/accept a tuple with the same number of elements as is required for + the format. + ''' + def __init__(self, characteristic, format): + super().__init__(characteristic) + self.struct = struct.Struct(format) + + def pack(self, *values): + return self.struct.pack(*values) + + def unpack(self, buffer): + return self.struct.unpack(buffer) + + def encode_value(self, value): + return self.pack(*value if type(value) is tuple else (value,)) + + def decode_value(self, value): + unpacked = self.unpack(value) + return unpacked[0] if len(unpacked) == 1 else unpacked + + +# ----------------------------------------------------------------------------- +class MappedCharacteristicAdapter(PackedCharacteristicAdapter): + ''' + Adapter that packs/unpacks characteristic values according to a standard + Python `struct` format. + The adapted `read_value` and `write_value` methods return/accept aa dictionary which + is packed/unpacked according to format, with the arguments extracted from the dictionary + by key, in the same order as they occur in the `keys` parameter. + ''' + def __init__(self, characteristic, format, keys): + super().__init__(characteristic, format) + self.keys = keys + + def pack(self, values): + return super().pack(*(values[key] for key in self.keys)) + + def unpack(self, buffer): + return dict(zip(self.keys, super().unpack(buffer))) + + +# ----------------------------------------------------------------------------- +class UTF8CharacteristicAdapter(CharacteristicAdapter): + ''' + Adapter that converts strings to/from bytes using UTF-8 encoding + ''' + def encode_value(self, value): + return value.encode('utf-8') + + def decode_value(self, value): + return value.decode('utf-8') + + +# ----------------------------------------------------------------------------- class Descriptor(Attribute): ''' See Vol 3, Part G - 3.3.3 Characteristic Descriptor Declarations ''' - def __init__(self, uuid, permissions, value = b''): - # Convert the uuid to a UUID object if it isn't already - if type(uuid) is str: - uuid = UUID(uuid) - - super().__init__(uuid, permissions, value) - self.uuid = uuid - self.characteristic = None + def __init__(self, descriptor_type, permissions, value = b''): + super().__init__(descriptor_type, permissions, value) def __str__(self): - return f'Descriptor(handle=0x{self.handle:04X}, uuid={self.uuid}, value={self.read_value(None).hex()})' + return f'Descriptor(handle=0x{self.handle:04X}, type={self.type}, value={self.read_value(None).hex()})' diff --git a/bumble/gatt_client.py b/bumble/gatt_client.py index 729f74e..e817e2e 100644 --- a/bumble/gatt_client.py +++ b/bumble/gatt_client.py @@ -35,10 +35,9 @@ from .gatt import ( GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR, GATT_REQUEST_TIMEOUT, GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE, + GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE, GATT_CHARACTERISTIC_ATTRIBUTE_TYPE, - Service, - Characteristic, - Descriptor + Characteristic ) # ----------------------------------------------------------------------------- @@ -48,6 +47,91 @@ logger = logging.getLogger(__name__) # ----------------------------------------------------------------------------- +# Proxies +# ----------------------------------------------------------------------------- +class AttributeProxy(EventEmitter): + def __init__(self, client, handle, end_group_handle, attribute_type): + EventEmitter.__init__(self) + self.client = client + self.handle = handle + self.end_group_handle = end_group_handle + self.type = attribute_type + + async def read_value(self, no_long_read=False): + return await self.client.read_value(self.handle, no_long_read) + + async def write_value(self, value, with_response=False): + return await self.client.write_value(self.handle, value, with_response) + + def __str__(self): + return f'Attribute(handle=0x{self.handle:04X}, type={self.uuid})' + + +class ServiceProxy(AttributeProxy): + @staticmethod + def from_client(cls, client, service_uuid): + # The service and its characteristics are considered to have already been discovered + services = client.get_services_by_uuid(service_uuid) + service = services[0] if services else None + return cls(service) if service else None + + def __init__(self, client, handle, end_group_handle, uuid, primary=True): + attribute_type = GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE if primary else GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE + super().__init__(client, handle, end_group_handle, attribute_type) + self.uuid = uuid + self.characteristics = [] + + async def discover_characteristics(self, uuids=[]): + return await self.client.discover_characteristics(uuids, self) + + def get_characteristics_by_uuid(self, uuid): + return self.client.get_characteristics_by_uuid(uuid, self) + + def __str__(self): + return f'Service(handle=0x{self.handle:04X}, uuid={self.uuid})' + + +class CharacteristicProxy(AttributeProxy): + def __init__(self, client, handle, end_group_handle, uuid, properties): + super().__init__(client, handle, end_group_handle, uuid) + self.uuid = uuid + self.properties = properties + self.descriptors = [] + self.descriptors_discovered = False + + def get_descriptor(self, descriptor_type): + for descriptor in self.descriptors: + if descriptor.type == descriptor_type: + return descriptor + + async def discover_descriptors(self): + return await self.client.discover_descriptors(self) + + async def subscribe(self, subscriber=None): + return await self.client.subscribe(self, subscriber) + + def __str__(self): + return f'Characteristic(handle=0x{self.handle:04X}, uuid={self.uuid}, properties={Characteristic.properties_as_string(self.properties)})' + + +class DescriptorProxy(AttributeProxy): + def __init__(self, client, handle, descriptor_type): + super().__init__(client, handle, 0, descriptor_type) + + def __str__(self): + return f'Descriptor(handle=0x{self.handle:04X}, type={self.type})' + + +class ProfileServiceProxy: + ''' + Base class for profile-specific service proxies + ''' + @classmethod + def from_client(cls, client): + return ServiceProxy.from_client(cls, client, cls.SERVICE_CLASS.UUID) + + +# ----------------------------------------------------------------------------- # GATT Client # ----------------------------------------------------------------------------- class Client: @@ -173,10 +257,14 @@ class Client: logger.warning(f'bogus handle values: {attribute_handle} {end_group_handle}') return - # Create a primary service object - service = Service(UUID.from_bytes(attribute_value), [], True) - service.handle = attribute_handle - service.end_group_handle = end_group_handle + # Create a service proxy for this service + service = ServiceProxy( + self, + attribute_handle, + end_group_handle, + UUID.from_bytes(attribute_value), + True + ) # Filter out returned services based on the given uuids list if (not uuids) or (service.uuid in uuids): @@ -233,10 +321,8 @@ class Client: logger.warning(f'bogus handle values: {attribute_handle} {end_group_handle}') return - # Create a primary service object - service = Service(uuid, [], True) - service.handle = attribute_handle - service.end_group_handle = end_group_handle + # Create a service proxy for this service + service = ServiceProxy(self, attribute_handle, end_group_handle, uuid, True) # Add the service to the peer's service list services.append(service) @@ -314,8 +400,7 @@ class Client: properties, handle = struct.unpack_from('<BH', attribute_value) characteristic_uuid = UUID.from_bytes(attribute_value[3:]) - characteristic = Characteristic(characteristic_uuid, properties, 0) - characteristic.handle = handle + characteristic = CharacteristicProxy(self, handle, 0, characteristic_uuid, properties) # Set the previous characteristic's end handle if characteristics: @@ -382,8 +467,7 @@ class Client: logger.warning(f'bogus handle value: {attribute_handle}') return [] - descriptor = Descriptor(UUID.from_bytes(attribute_uuid), 0) - descriptor.handle = attribute_handle + descriptor = DescriptorProxy(self, attribute_handle, UUID.from_bytes(attribute_uuid)) descriptors.append(descriptor) # TODO: read descriptor value @@ -427,8 +511,7 @@ class Client: logger.warning(f'bogus handle value: {attribute_handle}') return [] - attribute = Attribute(attribute_uuid, 0) - attribute.handle = attribute_handle + attribute = AttributeProxy(self, attribute_handle, 0, UUID.from_bytes(attribute_uuid)) attributes.append(attribute) # Move on to the next attributes diff --git a/bumble/profiles/__init__.py b/bumble/profiles/__init__.py new file mode 100644 index 0000000..4b7e706 --- /dev/null +++ b/bumble/profiles/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2021-2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/bumble/profiles/battery_service.py b/bumble/profiles/battery_service.py new file mode 100644 index 0000000..a978c05 --- /dev/null +++ b/bumble/profiles/battery_service.py @@ -0,0 +1,61 @@ +# Copyright 2021-2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +# ----------------------------------------------------------------------------- +# Imports +# ----------------------------------------------------------------------------- +from ..gatt_client import ProfileServiceProxy +from ..gatt import ( + GATT_BATTERY_SERVICE, + GATT_BATTERY_LEVEL_CHARACTERISTIC, + TemplateService, + Characteristic, + CharacteristicValue, + PackedCharacteristicAdapter +) + + +# ----------------------------------------------------------------------------- +class BatteryService(TemplateService): + UUID = GATT_BATTERY_SERVICE + BATTERY_LEVEL_FORMAT = 'B' + + def __init__(self, read_battery_level): + self.battery_level_characteristic = PackedCharacteristicAdapter( + Characteristic( + GATT_BATTERY_LEVEL_CHARACTERISTIC, + Characteristic.READ | Characteristic.NOTIFY, + Characteristic.READABLE, + CharacteristicValue(read=read_battery_level) + ), + format=BatteryService.BATTERY_LEVEL_FORMAT + ) + super().__init__([self.battery_level_characteristic]) + + +# ----------------------------------------------------------------------------- +class BatteryServiceProxy(ProfileServiceProxy): + SERVICE_CLASS = BatteryService + + def __init__(self, service_proxy): + self.service_proxy = service_proxy + + if characteristics := service_proxy.get_characteristics_by_uuid(GATT_BATTERY_LEVEL_CHARACTERISTIC): + self.battery_level = PackedCharacteristicAdapter( + characteristics[0], + format=BatteryService.BATTERY_LEVEL_FORMAT + ) + else: + self.battery_level = None diff --git a/bumble/profiles/device_information_service.py b/bumble/profiles/device_information_service.py new file mode 100644 index 0000000..99765b4 --- /dev/null +++ b/bumble/profiles/device_information_service.py @@ -0,0 +1,135 @@ +# Copyright 2021-2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +# ----------------------------------------------------------------------------- +# Imports +# ----------------------------------------------------------------------------- +import struct +from typing import Tuple + +from ..gatt_client import ProfileServiceProxy +from ..gatt import ( + GATT_DEVICE_INFORMATION_SERVICE, + GATT_FIRMWARE_REVISION_STRING_CHARACTERISTIC, + GATT_HARDWARE_REVISION_STRING_CHARACTERISTIC, + GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC, + GATT_MODEL_NUMBER_STRING_CHARACTERISTIC, + GATT_SERIAL_NUMBER_STRING_CHARACTERISTIC, + GATT_SOFTWARE_REVISION_STRING_CHARACTERISTIC, + GATT_SYSTEM_ID_CHARACTERISTIC, + GATT_REGULATORY_CERTIFICATION_DATA_LIST_CHARACTERISTIC, + TemplateService, + Characteristic, + DelegatedCharacteristicAdapter, + UTF8CharacteristicAdapter +) + + +# ----------------------------------------------------------------------------- +class DeviceInformationService(TemplateService): + UUID = GATT_DEVICE_INFORMATION_SERVICE + + @staticmethod + def pack_system_id(oui, manufacturer_id): + return struct.pack('<Q', oui << 40 | manufacturer_id) + + @staticmethod + def unpack_system_id(buffer): + system_id = struct.unpack('<Q', buffer)[0] + return (system_id >> 40, system_id & 0xFFFFFFFFFF) + + def __init__( + self, + manufacturer_name: str = None, + model_number: str = None, + serial_number: str = None, + hardware_revision: str = None, + firmware_revision: str = None, + software_revision: str = None, + system_id: Tuple[int, int] = None, # (OUI, Manufacturer ID) + ieee_regulatory_certification_data_list: bytes = None + # TODO: pnp_id + ): + characteristics = [ + Characteristic( + uuid, + Characteristic.READ, + Characteristic.READABLE, + field + ) + for (field, uuid) in ( + (manufacturer_name, GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC), + (model_number, GATT_MODEL_NUMBER_STRING_CHARACTERISTIC), + (serial_number, GATT_SERIAL_NUMBER_STRING_CHARACTERISTIC), + (hardware_revision, GATT_HARDWARE_REVISION_STRING_CHARACTERISTIC), + (firmware_revision, GATT_FIRMWARE_REVISION_STRING_CHARACTERISTIC), + (software_revision, GATT_SOFTWARE_REVISION_STRING_CHARACTERISTIC) + ) + if field is not None + ] + + if system_id is not None: + characteristics.append(Characteristic( + GATT_SYSTEM_ID_CHARACTERISTIC, + Characteristic.READ, + Characteristic.READABLE, + self.pack_system_id(*system_id) + )) + + if ieee_regulatory_certification_data_list is not None: + characteristics.append(Characteristic( + GATT_REGULATORY_CERTIFICATION_DATA_LIST_CHARACTERISTIC, + Characteristic.READ, + Characteristic.READABLE, + ieee_regulatory_certification_data_list + )) + + super().__init__(characteristics) + + +# ----------------------------------------------------------------------------- +class DeviceInformationServiceProxy(ProfileServiceProxy): + SERVICE_CLASS = DeviceInformationService + + def __init__(self, service_proxy): + self.service_proxy = service_proxy + + for (field, uuid) in ( + ('manufacturer_name', GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC), + ('model_number', GATT_MODEL_NUMBER_STRING_CHARACTERISTIC), + ('serial_number', GATT_SERIAL_NUMBER_STRING_CHARACTERISTIC), + ('hardware_revision', GATT_HARDWARE_REVISION_STRING_CHARACTERISTIC), + ('firmware_revision', GATT_FIRMWARE_REVISION_STRING_CHARACTERISTIC), + ('software_revision', GATT_SOFTWARE_REVISION_STRING_CHARACTERISTIC) + ): + if characteristics := service_proxy.get_characteristics_by_uuid(uuid): + characteristic = UTF8CharacteristicAdapter(characteristics[0]) + else: + characteristic = None + self.__setattr__(field, characteristic) + + if characteristics := service_proxy.get_characteristics_by_uuid(GATT_SYSTEM_ID_CHARACTERISTIC): + self.system_id = DelegatedCharacteristicAdapter( + characteristics[0], + encode=lambda v: DeviceInformationService.pack_system_id(*v), + decode=DeviceInformationService.unpack_system_id + ) + else: + self.system_id = None + + if characteristics := service_proxy.get_characteristics_by_uuid(GATT_REGULATORY_CERTIFICATION_DATA_LIST_CHARACTERISTIC): + self.ieee_regulatory_certification_data_list = characteristics[0] + else: + self.ieee_regulatory_certification_data_list = None diff --git a/examples/battery_client.py b/examples/battery_client.py new file mode 100644 index 0000000..888b23e --- /dev/null +++ b/examples/battery_client.py @@ -0,0 +1,72 @@ +# Copyright 2021-2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# ----------------------------------------------------------------------------- +# Imports +# ----------------------------------------------------------------------------- +import asyncio +import sys +import os +import logging +from colors import color +from bumble.device import Device, Peer +from bumble.transport import open_transport +from bumble.profiles.battery_service import BatteryServiceProxy + + +# ----------------------------------------------------------------------------- +async def main(): + if len(sys.argv) != 3: + print('Usage: battery_client.py <transport-spec> <bluetooth-address>') + print('example: battery_client.py usb:0 E1:CA:72:48:C4:E8') + return + + print('<<< connecting to HCI...') + async with await open_transport(sys.argv[1]) as (hci_source, hci_sink): + print('<<< connected') + + # Create and start a device + device = Device.with_hci('Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink) + await device.power_on() + + # Connect to the peer + target_address = sys.argv[2] + print(f'=== Connecting to {target_address}...') + connection = await device.connect(target_address) + print(f'=== Connected to {connection}') + + # Discover the Battery Service + peer = Peer(connection) + print('=== Discovering Battery Service') + battery_service = await peer.discover_and_create_service_proxy(BatteryServiceProxy) + + # Check that the service was found + if not battery_service: + print('!!! Service not found') + return + + # Subscribe to and read the battery level + if battery_service.battery_level: + await battery_service.battery_level.subscribe( + lambda value: print(f'{color("Battery Level Update:", "green")} {value}') + ) + value = await battery_service.battery_level.read_value() + print(f'{color("Initial Battery Level:", "green")} {value}') + + await hci_source.wait_for_termination() + + +# ----------------------------------------------------------------------------- +logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper()) +asyncio.run(main()) diff --git a/examples/battery_service.py b/examples/battery_server.py index b205687..3fabf0d 100644 --- a/examples/battery_service.py +++ b/examples/battery_server.py @@ -25,59 +25,41 @@ import struct from bumble.core import AdvertisingData from bumble.device import Device from bumble.transport import open_transport_or_link -from bumble.gatt import ( - Service, - Characteristic, - CharacteristicValue, - GATT_DEVICE_BATTERY_SERVICE, - GATT_BATTERY_LEVEL_CHARACTERISTIC -) - - -# ----------------------------------------------------------------------------- -def read_battery_level(connection): - return bytes([random.randint(0, 100)]) +from bumble.profiles.battery_service import BatteryService # ----------------------------------------------------------------------------- async def main(): if len(sys.argv) != 3: - print('Usage: python battery_service.py <device-config> <transport-spec>') - print('example: python battery_service.py device1.json usb:0') + print('Usage: python battery_server.py <device-config> <transport-spec>') + print('example: python battery_server.py device1.json usb:0') return async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink): - # Create a device to manage the host device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink) - # Add a Battery Service to the GATT sever - device.add_services([ - Service( - GATT_DEVICE_BATTERY_SERVICE, - [ - Characteristic( - GATT_BATTERY_LEVEL_CHARACTERISTIC, - Characteristic.READ, - Characteristic.READABLE, - CharacteristicValue(read=read_battery_level) - ) - ] - ) - ]) + # Add a Device Information Service and Battery Service to the GATT sever + battery_service = BatteryService(lambda _: random.randint(0, 100)) + device.add_service(battery_service) # Set the advertising data device.advertising_data = bytes( AdvertisingData([ (AdvertisingData.COMPLETE_LOCAL_NAME, bytes('Bumble Battery', 'utf-8')), - (AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS, struct.pack('<H', 0x180F)), + (AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS, bytes(battery_service.uuid)), (AdvertisingData.APPEARANCE, struct.pack('<H', 0x0340)) ]) ) # Go! await device.power_on() - await device.start_advertising() - await hci_source.wait_for_termination() + await device.start_advertising(auto_restart=True) + + # Notify every 3 seconds + while True: + await asyncio.sleep(3.0) + await device.notify_subscribers(battery_service.battery_level_characteristic) + # ----------------------------------------------------------------------------- logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper()) diff --git a/examples/device_information_client.py b/examples/device_information_client.py new file mode 100644 index 0000000..ed5892b --- /dev/null +++ b/examples/device_information_client.py @@ -0,0 +1,80 @@ +# Copyright 2021-2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# ----------------------------------------------------------------------------- +# Imports +# ----------------------------------------------------------------------------- +import asyncio +import sys +import os +import logging +from colors import color +from bumble.device import Device, Peer +from bumble.profiles.device_information_service import DeviceInformationServiceProxy +from bumble.transport import open_transport + + +# ----------------------------------------------------------------------------- +async def main(): + if len(sys.argv) != 3: + print('Usage: device_information_client.py <transport-spec> <bluetooth-address>') + print('example: device_information_client.py usb:0 E1:CA:72:48:C4:E8') + return + + print('<<< connecting to HCI...') + async with await open_transport(sys.argv[1]) as (hci_source, hci_sink): + print('<<< connected') + + # Create and start a device + device = Device.with_hci('Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink) + await device.power_on() + + # Connect to the peer + target_address = sys.argv[2] + print(f'=== Connecting to {target_address}...') + connection = await device.connect(target_address) + print(f'=== Connected to {connection}') + + # Discover the Device Information service + peer = Peer(connection) + print('=== Discovering Device Information Service') + device_information_service = await peer.discover_service_and_create_proxy(DeviceInformationServiceProxy) + + # Check that the service was found + if device_information_service is None: + print('!!! Service not found') + return + + # Read and print the fields + if device_information_service.manufacturer_name is not None: + print(color('Manufacturer Name: ', 'green'), await device_information_service.manufacturer_name.read_value()) + if device_information_service.model_number is not None: + print(color('Model Number: ', 'green'), await device_information_service.model_number.read_value()) + if device_information_service.serial_number is not None: + print(color('Serial Number: ', 'green'), await device_information_service.serial_number.read_value()) + if device_information_service.hardware_revision is not None: + print(color('Hardware Revision: ', 'green'), await device_information_service.hardware_revision.read_value()) + if device_information_service.firmware_revision is not None: + print(color('Firmware Revision: ', 'green'), await device_information_service.firmware_revision.read_value()) + if device_information_service.software_revision is not None: + print(color('Software Revision: ', 'green'), await device_information_service.software_revision.read_value()) + if device_information_service.system_id is not None: + print(color('System ID: ', 'green'), await device_information_service.system_id.read_value()) + if device_information_service.ieee_regulatory_certification_data_list is not None: + print(color('Regulatory Certification:', 'green'), (await device_information_service.ieee_regulatory_certification_data_list.read_value()).hex()) + + +# ----------------------------------------------------------------------------- +logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper()) +asyncio.run(main()) diff --git a/examples/device_information_server.py b/examples/device_information_server.py new file mode 100644 index 0000000..9c3b6b1 --- /dev/null +++ b/examples/device_information_server.py @@ -0,0 +1,66 @@ +# Copyright 2021-2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# ----------------------------------------------------------------------------- +# Imports +# ----------------------------------------------------------------------------- +import asyncio +import sys +import os +import logging +import struct + +from bumble.core import AdvertisingData +from bumble.device import Device +from bumble.transport import open_transport_or_link +from bumble.profiles.device_information_service import DeviceInformationService + + +# ----------------------------------------------------------------------------- +async def main(): + if len(sys.argv) != 3: + print('Usage: python device_info_server.py <device-config> <transport-spec>') + print('example: python device_info_server.py device1.json usb:0') + return + + async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink): + device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink) + + # Add a Device Information Service to the GATT sever + device_information_service = DeviceInformationService( + manufacturer_name = 'ACME', + model_number = 'AB-102', + serial_number = '7654321', + hardware_revision = '1.1.3', + software_revision = '2.5.6', + system_id = (0x123456, 0x8877665544) + ) + device.add_service(device_information_service) + + # Set the advertising data + device.advertising_data = bytes( + AdvertisingData([ + (AdvertisingData.COMPLETE_LOCAL_NAME, bytes('Bumble Device', 'utf-8')), + (AdvertisingData.APPEARANCE, struct.pack('<H', 0x0340)) + ]) + ) + + # Go! + await device.power_on() + await device.start_advertising(auto_restart=True) + await hci_source.wait_for_termination() + +# ----------------------------------------------------------------------------- +logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper()) +asyncio.run(main()) diff --git a/examples/get_peer_device_info.py b/examples/get_peer_device_info.py deleted file mode 100644 index e4babfc..0000000 --- a/examples/get_peer_device_info.py +++ /dev/null @@ -1,96 +0,0 @@ -# Copyright 2021-2022 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -# ----------------------------------------------------------------------------- -# Imports -# ----------------------------------------------------------------------------- -import asyncio -import sys -import os -import logging -from colors import color -from bumble.device import Device, Peer -from bumble.host import Host -from bumble.transport import open_transport -from bumble.utils import AsyncRunner -from bumble import gatt - - -# ----------------------------------------------------------------------------- -class Listener(Device.Listener): - def __init__(self, device): - self.device = device - self.done = asyncio.get_running_loop().create_future() - - @AsyncRunner.run_in_task() - async def on_connection(self, connection): - print(f'=== Connected to {connection}') - - # Discover the Device Info service - peer = Peer(connection) - print('=== Discovering Device Info') - await peer.discover_services([gatt.GATT_DEVICE_INFORMATION_SERVICE]) - - # Check that the service was found - device_info_services = peer.get_services_by_uuid(gatt.GATT_DEVICE_INFORMATION_SERVICE) - if not device_info_services: - print('!!! Service not found') - return - - # Get the characteristics we want from the (first) device info service - service = device_info_services[0] - await peer.discover_characteristics([ - gatt.GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC - ], service) - - # Read the manufacturer name - manufacturer_name = peer.get_characteristics_by_uuid(gatt.GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC, service) - if manufacturer_name: - value = await peer.read_value(manufacturer_name[0]) - print(color('Manufacturer Name:', 'green'), value.decode('utf-8')) - else: - print('>>> No manufacturer name') - - self.done.set_result(None) - - -# ----------------------------------------------------------------------------- -async def main(): - if len(sys.argv) != 3: - print('Usage: get_peer_device_info.py <transport-spec> <bluetooth-address>') - print('example: get_peer_device_info.py usb:0 E1:CA:72:48:C4:E8') - return - - print('<<< connecting to HCI...') - packet_source, packet_sink = await open_transport(sys.argv[1]) - print('<<< connected') - - # Create a host using the packet source and sink as controller - host = Host(controller_source=packet_source, controller_sink=packet_sink) - - # Create a device to manage the host, with a custom listener - device = Device('Bumble', address = 'F0:F1:F2:F3:F4:F5', host = host) - device.listener = Listener(device) - await device.power_on() - - # Connect to a peer - target_address = sys.argv[2] - print(f'=== Connecting to {target_address}...') - await device.connect(target_address) - await device.listener.done - - -# ----------------------------------------------------------------------------- -logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'DEBUG').upper()) -asyncio.run(main()) diff --git a/examples/run_controller.py b/examples/run_controller.py index 1a8cd0e..d8295d4 100644 --- a/examples/run_controller.py +++ b/examples/run_controller.py @@ -45,8 +45,7 @@ async def main(): # Create a first controller using the packet source/sink as its host interface controller1 = Controller('C1', host_source = hci_source, host_sink = hci_sink, link = link) - print("====", sys.argv) - controller1.address = sys.argv[1] + controller1.random_address = sys.argv[1] # Create a second controller using the same link controller2 = Controller('C2', link = link) diff --git a/examples/run_gatt_client.py b/examples/run_gatt_client.py index 99781fb..5af86fb 100644 --- a/examples/run_gatt_client.py +++ b/examples/run_gatt_client.py @@ -41,10 +41,10 @@ class Listener(Device.Listener): print('=== Discovering services') peer = Peer(connection) await peer.discover_services() - await peer.discover_characteristics() for service in peer.services: + await service.discover_characteristics() for characteristic in service.characteristics: - await peer.discover_descriptors(characteristic) + await characteristic.discover_descriptors() print('=== Services discovered') show_services(peer.services) diff --git a/examples/run_gatt_client_and_server.py b/examples/run_gatt_client_and_server.py index 211eb5c..940b1a8 100644 --- a/examples/run_gatt_client_and_server.py +++ b/examples/run_gatt_client_and_server.py @@ -25,7 +25,6 @@ from bumble.controller import Controller from bumble.device import Device, Peer from bumble.host import Host from bumble.link import LocalLink -from bumble.utils import AsyncRunner from bumble.gatt import ( Service, Characteristic, @@ -38,43 +37,6 @@ from bumble.gatt import ( # ----------------------------------------------------------------------------- -class ClientListener(Device.Listener): - def __init__(self, device): - self.device = device - - @AsyncRunner.run_in_task() - async def on_connection(self, connection): - print(f'=== Client: connected to {connection}') - - # Discover all services - print('=== Discovering services') - peer = Peer(connection) - await peer.discover_services() - await peer.discover_characteristics() - for service in peer.services: - for characteristic in service.characteristics: - await peer.discover_descriptors(characteristic) - - print('=== Services discovered') - show_services(peer.services) - - # Discover all attributes - print('=== Discovering attributes') - attributes = await peer.discover_attributes() - for attribute in attributes: - print(attribute) - print('=== Attributes discovered') - - # Read all attributes - for attribute in attributes: - try: - value = await peer.read_value(attribute) - print(color(f'0x{attribute.handle:04X} = {value.hex()}', 'green')) - except ProtocolError as error: - print(color(f'cannot read {attribute.handle:04X}:', 'red'), error) - - -# ----------------------------------------------------------------------------- class ServerListener(Device.Listener): def on_connection(self, connection): print(f'### Server: connected to {connection}') @@ -90,7 +52,6 @@ async def main(): client_host = Host() client_host.controller = client_controller client_device = Device("client", address = 'F0:F1:F2:F3:F4:F5', host = client_host) - client_device.listener = ClientListener(client_device) await client_device.power_on() # Setup a stack for the server @@ -116,7 +77,36 @@ async def main(): server_device.add_service(device_info_service) # Connect the client to the server - await client_device.connect(server_device.address) + connection = await client_device.connect(server_device.random_address) + print(f'=== Client: connected to {connection}') + + # Discover all services + print('=== Discovering services') + peer = Peer(connection) + await peer.discover_services() + for service in peer.services: + await service.discover_characteristics() + for characteristic in service.characteristics: + await characteristic.discover_descriptors() + + print('=== Services discovered') + show_services(peer.services) + + # Discover all attributes + print('=== Discovering attributes') + attributes = await peer.discover_attributes() + for attribute in attributes: + print(attribute) + print('=== Attributes discovered') + + # Read all attributes + for attribute in attributes: + try: + value = await attribute.read_value() + print(color(f'0x{attribute.handle:04X} = {value.hex()}', 'green')) + except ProtocolError as error: + print(color(f'cannot read {attribute.handle:04X}:', 'red'), error) + await asyncio.get_running_loop().create_future() # ----------------------------------------------------------------------------- diff --git a/tests/gatt_test.py b/tests/gatt_test.py index 867156c..5df6e08 100644 --- a/tests/gatt_test.py +++ b/tests/gatt_test.py @@ -18,6 +18,7 @@ import asyncio import logging import os +import struct import pytest from bumble.controller import Controller @@ -25,6 +26,12 @@ from bumble.link import LocalLink from bumble.device import Device, Peer from bumble.host import Host from bumble.gatt import ( + GATT_BATTERY_LEVEL_CHARACTERISTIC, + CharacteristicAdapter, + DelegatedCharacteristicAdapter, + PackedCharacteristicAdapter, + MappedCharacteristicAdapter, + UTF8CharacteristicAdapter, Service, Characteristic, CharacteristicValue @@ -92,6 +99,96 @@ def test_ATT_Read_By_Group_Type_Request(): # ----------------------------------------------------------------------------- +def test_CharacteristicAdapter(): + # Check that the CharacteristicAdapter base class is transparent + v = bytes([1, 2, 3]) + c = Characteristic(GATT_BATTERY_LEVEL_CHARACTERISTIC, Characteristic.READ, Characteristic.READABLE, v) + a = CharacteristicAdapter(c) + + value = a.read_value(None) + assert(value == v) + + v = bytes([3, 4, 5]) + a.write_value(None, v) + assert(c.value == v) + + # Simple delegated adapter + a = DelegatedCharacteristicAdapter(c, lambda x: bytes(reversed(x)), lambda x: bytes(reversed(x))) + + value = a.read_value(None) + assert(value == bytes(reversed(v))) + + v = bytes([3, 4, 5]) + a.write_value(None, v) + assert(a.value == bytes(reversed(v))) + + # Packed adapter with single element format + v = 1234 + pv = struct.pack('>H', v) + c.value = v + a = PackedCharacteristicAdapter(c, '>H') + + value = a.read_value(None) + assert(value == pv) + c.value = None + a.write_value(None, pv) + assert(a.value == v) + + # Packed adapter with multi-element format + v1 = 1234 + v2 = 5678 + pv = struct.pack('>HH', v1, v2) + c.value = (v1, v2) + a = PackedCharacteristicAdapter(c, '>HH') + + value = a.read_value(None) + assert(value == pv) + c.value = None + a.write_value(None, pv) + assert(a.value == (v1, v2)) + + # Mapped adapter + v1 = 1234 + v2 = 5678 + pv = struct.pack('>HH', v1, v2) + mapped = {'v1': v1, 'v2': v2} + c.value = mapped + a = MappedCharacteristicAdapter(c, '>HH', ('v1', 'v2')) + + value = a.read_value(None) + assert(value == pv) + c.value = None + a.write_value(None, pv) + assert(a.value == mapped) + + # UTF-8 adapter + v = 'Hello π' + ev = v.encode('utf-8') + c.value = v + a = UTF8CharacteristicAdapter(c) + + value = a.read_value(None) + assert(value == ev) + c.value = None + a.write_value(None, ev) + assert(a.value == v) + + +# ----------------------------------------------------------------------------- +def test_CharacteristicValue(): + b = bytes([1, 2, 3]) + c = CharacteristicValue(read=lambda _: b) + x = c.read(None) + assert(x == b) + + result = [] + c = CharacteristicValue(write=lambda connection, value: result.append((connection, value))) + z = object() + c.write(z, b) + assert(result == [(z, b)]) + + +# ----------------------------------------------------------------------------- class TwoDevices: def __init__(self): self.connections = [None, None] @@ -201,6 +298,56 @@ async def test_read_write(): # ----------------------------------------------------------------------------- @pytest.mark.asyncio +async def test_read_write2(): + [client, server] = TwoDevices().devices + + v = bytes([0x11, 0x22, 0x33, 0x44]) + characteristic1 = Characteristic( + 'FDB159DB-036C-49E3-B3DB-6325AC750806', + Characteristic.READ | Characteristic.WRITE, + Characteristic.READABLE | Characteristic.WRITEABLE, + value=v + ) + + service1 = Service( + '3A657F47-D34F-46B3-B1EC-698E29B6B829', + [ + characteristic1 + ] + ) + server.add_services([service1]) + + await client.power_on() + await server.power_on() + connection = await client.connect(server.random_address) + peer = Peer(connection) + + await peer.discover_services() + c = peer.get_services_by_uuid(service1.uuid) + assert(len(c) == 1) + s = c[0] + await s.discover_characteristics() + c = s.get_characteristics_by_uuid(characteristic1.uuid) + assert(len(c) == 1) + c1 = c[0] + + v1 = await c1.read_value() + assert(v1 == v) + + a1 = PackedCharacteristicAdapter(c1, '>I') + v1 = await a1.read_value() + assert(v1 == struct.unpack('>I', v)[0]) + + b = bytes([0x55, 0x66, 0x77, 0x88]) + await a1.write_value(struct.unpack('>I', b)[0]) + await async_barrier() + assert(characteristic1.value == b) + v1 = await a1.read_value() + assert(v1 == struct.unpack('>I', b)[0]) + + +# ----------------------------------------------------------------------------- +@pytest.mark.asyncio async def test_subscribe_notify(): [client, server] = TwoDevices().devices @@ -330,6 +477,7 @@ async def test_subscribe_notify(): # ----------------------------------------------------------------------------- async def async_main(): await test_read_write() + await test_read_write2() await test_subscribe_notify() # ----------------------------------------------------------------------------- @@ -338,4 +486,6 @@ if __name__ == '__main__': test_UUID() test_ATT_Error_Response() test_ATT_Read_By_Group_Type_Request() + test_CharacteristicValue() + test_CharacteristicAdapter() asyncio.run(async_main()) |