diff --git a/bumble/device.py b/bumble/device.py index 56f4f00..fb3a7ee 100644 --- a/bumble/device.py +++ b/bumble/device.py @@ -93,6 +93,7 @@ from bumble import smp from bumble import sdp from bumble import l2cap from bumble import core +from bumble.profiles import gatt_service if TYPE_CHECKING: from .transport.common import TransportSource, TransportSink @@ -1756,6 +1757,8 @@ class DeviceConfiguration: cis_enabled: bool = False identity_address_type: Optional[int] = None io_capability: int = pairing.PairingDelegate.IoCapability.NO_OUTPUT_NO_INPUT + gap_service_enabled: bool = True + gatt_service_enabled: bool = True def __post_init__(self) -> None: self.gatt_services: list[Dict[str, Any]] = [] @@ -1938,6 +1941,7 @@ class Device(CompositeEventEmitter): bis_links = dict[int, BisLink]() big_syncs = dict[int, BigSync]() _pending_cis: Dict[int, tuple[int, int]] + gatt_service: gatt_service.GenericAttributeProfileService | None = None @composite_listener class Listener: @@ -2004,7 +2008,6 @@ class Device(CompositeEventEmitter): address: Optional[hci.Address] = None, config: Optional[DeviceConfiguration] = None, host: Optional[Host] = None, - generic_access_service: bool = True, ) -> None: super().__init__() @@ -2151,7 +2154,10 @@ class Device(CompositeEventEmitter): # Register the SDP server with the L2CAP Channel Manager self.sdp_server.register(self.l2cap_channel_manager) - self.add_default_services(generic_access_service) + self.add_default_services( + add_gap_service=config.gap_service_enabled, + add_gatt_service=config.gatt_service_enabled, + ) self.l2cap_channel_manager.register_fixed_channel(ATT_CID, self.on_gatt_pdu) # Forward some events @@ -4515,10 +4521,15 @@ class Device(CompositeEventEmitter): def add_services(self, services): self.gatt_server.add_services(services) - def add_default_services(self, generic_access_service=True): + def add_default_services( + self, add_gap_service: bool = True, add_gatt_service: bool = True + ) -> None: # Add a GAP Service if requested - if generic_access_service: + if add_gap_service: self.gatt_server.add_service(GenericAccessService(self.name)) + if add_gatt_service: + self.gatt_service = gatt_service.GenericAttributeProfileService() + self.gatt_server.add_service(self.gatt_service) async def notify_subscriber(self, connection, attribute, value=None, force=False): await self.gatt_server.notify_subscriber(connection, attribute, value, force) diff --git a/bumble/gatt.py b/bumble/gatt.py index a800657..9ab532e 100644 --- a/bumble/gatt.py +++ b/bumble/gatt.py @@ -48,7 +48,6 @@ from bumble.utils import ByteSerializable if TYPE_CHECKING: from bumble.gatt_client import AttributeProxy - from bumble.device import Connection # ----------------------------------------------------------------------------- @@ -802,3 +801,23 @@ class ClientCharacteristicConfigurationBits(enum.IntFlag): DEFAULT = 0x0000 NOTIFICATION = 0x0001 INDICATION = 0x0002 + + +# ----------------------------------------------------------------------------- +class ClientSupportedFeatures(enum.IntFlag): + ''' + See Vol 3, Part G - 7.2 - Table 7.6: Client Supported Features bit assignments. + ''' + + ROBUST_CACHING = 0x01 + ENHANCED_ATT_BEARER = 0x02 + MULTIPLE_HANDLE_VALUE_NOTIFICATIONS = 0x04 + + +# ----------------------------------------------------------------------------- +class ServerSupportedFeatures(enum.IntFlag): + ''' + See Vol 3, Part G - 7.4 - Table 7.11: Server Supported Features bit assignments. + ''' + + EATT_SUPPORTED = 0x01 diff --git a/bumble/profiles/gatt_service.py b/bumble/profiles/gatt_service.py new file mode 100644 index 0000000..9830013 --- /dev/null +++ b/bumble/profiles/gatt_service.py @@ -0,0 +1,166 @@ +# Copyright 2021-2025 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. + +from __future__ import annotations + +import struct +from typing import TYPE_CHECKING + +from bumble import att +from bumble import gatt +from bumble import gatt_client +from bumble import crypto + +if TYPE_CHECKING: + from bumble import device + + +# ----------------------------------------------------------------------------- +class GenericAttributeProfileService(gatt.TemplateService): + '''See Vol 3, Part G - 7 - DEFINED GENERIC ATTRIBUTE PROFILE SERVICE.''' + + UUID = gatt.GATT_GENERIC_ATTRIBUTE_SERVICE + + client_supported_features_characteristic: gatt.Characteristic | None = None + server_supported_features_characteristic: gatt.Characteristic | None = None + database_hash_characteristic: gatt.Characteristic | None = None + service_changed_characteristic: gatt.Characteristic | None = None + + def __init__( + self, + server_supported_features: gatt.ServerSupportedFeatures | None = None, + database_hash_enabled: bool = True, + service_change_enabled: bool = True, + ) -> None: + + if server_supported_features is not None: + self.server_supported_features_characteristic = gatt.Characteristic( + uuid=gatt.GATT_SERVER_SUPPORTED_FEATURES_CHARACTERISTIC, + properties=gatt.Characteristic.Properties.READ, + permissions=gatt.Characteristic.Permissions.READABLE, + value=bytes([server_supported_features]), + ) + + if database_hash_enabled: + self.database_hash_characteristic = gatt.Characteristic( + uuid=gatt.GATT_DATABASE_HASH_CHARACTERISTIC, + properties=gatt.Characteristic.Properties.READ, + permissions=gatt.Characteristic.Permissions.READABLE, + value=gatt.CharacteristicValue(read=self.get_database_hash), + ) + + if service_change_enabled: + self.service_changed_characteristic = gatt.Characteristic( + uuid=gatt.GATT_SERVICE_CHANGED_CHARACTERISTIC, + properties=gatt.Characteristic.Properties.INDICATE, + permissions=gatt.Characteristic.Permissions(0), + value=b'', + ) + + if (database_hash_enabled and service_change_enabled) or ( + server_supported_features + and ( + server_supported_features & gatt.ServerSupportedFeatures.EATT_SUPPORTED + ) + ): # TODO: Support Multiple Handle Value Notifications + self.client_supported_features_characteristic = gatt.Characteristic( + uuid=gatt.GATT_CLIENT_SUPPORTED_FEATURES_CHARACTERISTIC, + properties=( + gatt.Characteristic.Properties.READ + | gatt.Characteristic.Properties.WRITE + ), + permissions=( + gatt.Characteristic.Permissions.READABLE + | gatt.Characteristic.Permissions.WRITEABLE + ), + value=bytes(1), + ) + + super().__init__( + characteristics=[ + c + for c in ( + self.service_changed_characteristic, + self.client_supported_features_characteristic, + self.database_hash_characteristic, + self.server_supported_features_characteristic, + ) + if c is not None + ], + primary=True, + ) + + @classmethod + def get_attribute_data(cls, attribute: att.Attribute) -> bytes: + if attribute.type in ( + gatt.GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE, + gatt.GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE, + gatt.GATT_INCLUDE_ATTRIBUTE_TYPE, + gatt.GATT_CHARACTERISTIC_ATTRIBUTE_TYPE, + gatt.GATT_CHARACTERISTIC_EXTENDED_PROPERTIES_DESCRIPTOR, + ): + return ( + struct.pack(" bytes: + assert connection + + m = b''.join( + [ + self.get_attribute_data(attribute) + for attribute in connection.device.gatt_server.attributes + ] + ) + + return crypto.aes_cmac(m=m, k=bytes(16)) + + +class GenericAttributeProfileServiceProxy(gatt_client.ProfileServiceProxy): + SERVICE_CLASS = GenericAttributeProfileService + + client_supported_features_characteristic: gatt_client.CharacteristicProxy | None = ( + None + ) + server_supported_features_characteristic: gatt_client.CharacteristicProxy | None = ( + None + ) + database_hash_characteristic: gatt_client.CharacteristicProxy | None = None + service_changed_characteristic: gatt_client.CharacteristicProxy | None = None + + _CHARACTERISTICS = { + gatt.GATT_CLIENT_SUPPORTED_FEATURES_CHARACTERISTIC: 'client_supported_features_characteristic', + gatt.GATT_SERVER_SUPPORTED_FEATURES_CHARACTERISTIC: 'server_supported_features_characteristic', + gatt.GATT_DATABASE_HASH_CHARACTERISTIC: 'database_hash_characteristic', + gatt.GATT_SERVICE_CHANGED_CHARACTERISTIC: 'service_changed_characteristic', + } + + def __init__(self, service_proxy: gatt_client.ServiceProxy) -> None: + self.service_proxy = service_proxy + + for uuid, attribute_name in self._CHARACTERISTICS.items(): + if characteristics := self.service_proxy.get_characteristics_by_uuid(uuid): + setattr(self, attribute_name, characteristics[0]) diff --git a/tests/device_test.py b/tests/device_test.py index 1f6175a..8ecfe2c 100644 --- a/tests/device_test.py +++ b/tests/device_test.py @@ -50,12 +50,7 @@ from bumble.hci import ( HCI_Error, HCI_Packet, ) -from bumble.gatt import ( - GATT_GENERIC_ACCESS_SERVICE, - GATT_CHARACTERISTIC_ATTRIBUTE_TYPE, - GATT_DEVICE_NAME_CHARACTERISTIC, - GATT_APPEARANCE_CHARACTERISTIC, -) +from bumble import gatt from .test_utils import TwoDevices, async_barrier @@ -592,32 +587,54 @@ async def test_power_on_default_static_address_should_not_be_any(): # ----------------------------------------------------------------------------- -def test_gatt_services_with_gas(): +def test_gatt_services_with_gas_and_gatt(): device = Device(host=Host(None, None)) - # there should be one service and two chars, therefore 5 attributes - assert len(device.gatt_server.attributes) == 5 - assert device.gatt_server.attributes[0].uuid == GATT_GENERIC_ACCESS_SERVICE - assert device.gatt_server.attributes[1].type == GATT_CHARACTERISTIC_ATTRIBUTE_TYPE - assert device.gatt_server.attributes[2].uuid == GATT_DEVICE_NAME_CHARACTERISTIC - assert device.gatt_server.attributes[3].type == GATT_CHARACTERISTIC_ATTRIBUTE_TYPE - assert device.gatt_server.attributes[4].uuid == GATT_APPEARANCE_CHARACTERISTIC + # there should be 2 service, 5 chars, and 1 descriptors, therefore 13 attributes + assert len(device.gatt_server.attributes) == 13 + assert device.gatt_server.attributes[0].uuid == gatt.GATT_GENERIC_ACCESS_SERVICE + assert ( + device.gatt_server.attributes[1].type == gatt.GATT_CHARACTERISTIC_ATTRIBUTE_TYPE + ) + assert device.gatt_server.attributes[2].uuid == gatt.GATT_DEVICE_NAME_CHARACTERISTIC + assert ( + device.gatt_server.attributes[3].type == gatt.GATT_CHARACTERISTIC_ATTRIBUTE_TYPE + ) + assert device.gatt_server.attributes[4].uuid == gatt.GATT_APPEARANCE_CHARACTERISTIC - -# ----------------------------------------------------------------------------- -def test_gatt_services_without_gas(): - device = Device(host=Host(None, None), generic_access_service=False) - - # there should be no services - assert len(device.gatt_server.attributes) == 0 + assert device.gatt_server.attributes[5].uuid == gatt.GATT_GENERIC_ATTRIBUTE_SERVICE + assert ( + device.gatt_server.attributes[6].type == gatt.GATT_CHARACTERISTIC_ATTRIBUTE_TYPE + ) + assert ( + device.gatt_server.attributes[7].uuid + == gatt.GATT_SERVICE_CHANGED_CHARACTERISTIC + ) + assert ( + device.gatt_server.attributes[8].type + == gatt.GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR + ) + assert ( + device.gatt_server.attributes[9].type == gatt.GATT_CHARACTERISTIC_ATTRIBUTE_TYPE + ) + assert ( + device.gatt_server.attributes[10].uuid + == gatt.GATT_CLIENT_SUPPORTED_FEATURES_CHARACTERISTIC + ) + assert ( + device.gatt_server.attributes[11].type + == gatt.GATT_CHARACTERISTIC_ATTRIBUTE_TYPE + ) + assert ( + device.gatt_server.attributes[12].uuid == gatt.GATT_DATABASE_HASH_CHARACTERISTIC + ) # ----------------------------------------------------------------------------- async def run_test_device(): await test_device_connect_parallel() await test_flush() - await test_gatt_services_with_gas() - await test_gatt_services_without_gas() + await test_gatt_services_with_gas_and_gatt() # ----------------------------------------------------------------------------- diff --git a/tests/gatt_service_test.py b/tests/gatt_service_test.py new file mode 100644 index 0000000..89cc5ea --- /dev/null +++ b/tests/gatt_service_test.py @@ -0,0 +1,140 @@ +# Copyright 2021-2025 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 __future__ import annotations + +from . import test_utils + +from bumble import device +from bumble import gatt +from bumble.profiles import gatt_service + + +# ----------------------------------------------------------------------------- +async def test_database_hash(): + devices = await test_utils.TwoDevices.create_with_connection() + devices[0].gatt_server.services.clear() + devices[0].gatt_server.attributes.clear() + devices[0].gatt_server.attributes_by_handle.clear() + devices[0].add_service( + gatt.Service( + gatt.GATT_GENERIC_ACCESS_SERVICE, + characteristics=[ + gatt.Characteristic( + gatt.GATT_DEVICE_NAME_CHARACTERISTIC, + ( + gatt.Characteristic.Properties.READ + | gatt.Characteristic.Properties.WRITE + ), + gatt.Characteristic.Permissions.READ_REQUIRES_AUTHENTICATION, + ), + gatt.Characteristic( + gatt.GATT_APPEARANCE_CHARACTERISTIC, + gatt.Characteristic.Properties.READ, + gatt.Characteristic.Permissions.READ_REQUIRES_AUTHENTICATION, + ), + ], + ) + ) + devices[0].add_service( + gatt_service.GenericAttributeProfileService( + server_supported_features=None, + database_hash_enabled=True, + service_change_enabled=True, + ) + ) + devices[0].gatt_server.add_attribute( + gatt.Service(gatt.GATT_GLUCOSE_SERVICE, characteristics=[]) + ) + # There is a special attribute order in the spec, so we need to add attribute one by + # one here. + battery_service = gatt.Service( + gatt.GATT_BATTERY_SERVICE, + characteristics=[ + gatt.Characteristic( + gatt.GATT_BATTERY_LEVEL_CHARACTERISTIC, + properties=gatt.Characteristic.Properties.READ, + permissions=gatt.Characteristic.Permissions.READ_REQUIRES_AUTHENTICATION, + ) + ], + primary=False, + ) + battery_service.handle = 0x0014 + battery_service.end_group_handle = 0x0016 + devices[0].gatt_server.add_attribute( + gatt.IncludedServiceDeclaration(battery_service) + ) + c = gatt.Characteristic( + '2A18', + properties=( + gatt.Characteristic.Properties.READ + | gatt.Characteristic.Properties.INDICATE + | gatt.Characteristic.Properties.EXTENDED_PROPERTIES + ), + permissions=gatt.Characteristic.Permissions.READ_REQUIRES_AUTHENTICATION, + ) + devices[0].gatt_server.add_attribute( + gatt.CharacteristicDeclaration(c, devices[0].gatt_server.next_handle() + 1) + ) + devices[0].gatt_server.add_attribute(c) + devices[0].gatt_server.add_attribute( + gatt.Descriptor( + gatt.GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR, + gatt.Descriptor.Permissions.READ_REQUIRES_AUTHENTICATION, + b'\x02\x00', + ), + ) + devices[0].gatt_server.add_attribute( + gatt.Descriptor( + gatt.GATT_CHARACTERISTIC_EXTENDED_PROPERTIES_DESCRIPTOR, + gatt.Descriptor.Permissions.READ_REQUIRES_AUTHENTICATION, + b'\x00\x00', + ), + ) + devices[0].add_service(battery_service) + + peer = device.Peer(devices.connections[1]) + client = await peer.discover_service_and_create_proxy( + gatt_service.GenericAttributeProfileServiceProxy + ) + assert client.database_hash_characteristic + assert await client.database_hash_characteristic.read_value() == bytes.fromhex( + 'F1CA2D48ECF58BAC8A8830BBB9FBA990' + ) + + +# ----------------------------------------------------------------------------- +async def test_service_changed(): + devices = await test_utils.TwoDevices.create_with_connection() + assert (service := devices[0].gatt_service) + + peer = device.Peer(devices.connections[1]) + assert ( + client := await peer.discover_service_and_create_proxy( + gatt_service.GenericAttributeProfileServiceProxy + ) + ) + assert client.service_changed_characteristic + indications = [] + await client.service_changed_characteristic.subscribe( + indications.append, prefer_notify=False + ) + await devices[0].indicate_subscribers( + service.service_changed_characteristic, b'1234' + ) + await test_utils.async_barrier() + assert indications[0] == b'1234' diff --git a/tests/gatt_test.py b/tests/gatt_test.py index f97e45b..fd5f8c6 100644 --- a/tests/gatt_test.py +++ b/tests/gatt_test.py @@ -957,11 +957,12 @@ async def test_discover_all(): peer = Peer(connection) await peer.discover_all() - assert len(peer.gatt_client.services) == 3 - # service 1800 gets added automatically + assert len(peer.gatt_client.services) == 4 + # service 1800 and 1801 get added automatically assert peer.gatt_client.services[0].uuid == UUID('1800') - assert peer.gatt_client.services[1].uuid == service1.uuid - assert peer.gatt_client.services[2].uuid == service2.uuid + assert peer.gatt_client.services[1].uuid == UUID('1801') + assert peer.gatt_client.services[2].uuid == service1.uuid + assert peer.gatt_client.services[3].uuid == service2.uuid s = peer.get_services_by_uuid(service1.uuid) assert len(s) == 1 assert len(s[0].characteristics) == 2 @@ -1084,10 +1085,18 @@ CharacteristicDeclaration(handle=0x0002, value_handle=0x0003, uuid=UUID-16:2A00 Characteristic(handle=0x0003, end=0x0003, uuid=UUID-16:2A00 (Device Name), READ) CharacteristicDeclaration(handle=0x0004, value_handle=0x0005, uuid=UUID-16:2A01 (Appearance), READ) Characteristic(handle=0x0005, end=0x0005, uuid=UUID-16:2A01 (Appearance), READ) -Service(handle=0x0006, end=0x0009, uuid=3A657F47-D34F-46B3-B1EC-698E29B6B829) -CharacteristicDeclaration(handle=0x0007, value_handle=0x0008, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, READ|WRITE|NOTIFY) -Characteristic(handle=0x0008, end=0x0009, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, READ|WRITE|NOTIFY) -Descriptor(handle=0x0009, type=UUID-16:2902 (Client Characteristic Configuration), value=0000)""" +Service(handle=0x0006, end=0x000D, uuid=UUID-16:1801 (Generic Attribute)) +CharacteristicDeclaration(handle=0x0007, value_handle=0x0008, uuid=UUID-16:2A05 (Service Changed), INDICATE) +Characteristic(handle=0x0008, end=0x0009, uuid=UUID-16:2A05 (Service Changed), INDICATE) +Descriptor(handle=0x0009, type=UUID-16:2902 (Client Characteristic Configuration), value=0000) +CharacteristicDeclaration(handle=0x000A, value_handle=0x000B, uuid=UUID-16:2B29 (Client Supported Features), READ|WRITE) +Characteristic(handle=0x000B, end=0x000B, uuid=UUID-16:2B29 (Client Supported Features), READ|WRITE) +CharacteristicDeclaration(handle=0x000C, value_handle=0x000D, uuid=UUID-16:2B2A (Database Hash), READ) +Characteristic(handle=0x000D, end=0x000D, uuid=UUID-16:2B2A (Database Hash), READ) +Service(handle=0x000E, end=0x0011, uuid=3A657F47-D34F-46B3-B1EC-698E29B6B829) +CharacteristicDeclaration(handle=0x000F, value_handle=0x0010, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, READ|WRITE|NOTIFY) +Characteristic(handle=0x0010, end=0x0011, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, READ|WRITE|NOTIFY) +Descriptor(handle=0x0011, type=UUID-16:2902 (Client Characteristic Configuration), value=0000)""" )