Merge pull request #624 from zxzxwu/gatt

Support GATT Service
This commit is contained in:
zxzxwu
2025-01-28 20:02:43 +08:00
committed by GitHub
6 changed files with 398 additions and 36 deletions

View File

@@ -93,6 +93,7 @@ from bumble import smp
from bumble import sdp from bumble import sdp
from bumble import l2cap from bumble import l2cap
from bumble import core from bumble import core
from bumble.profiles import gatt_service
if TYPE_CHECKING: if TYPE_CHECKING:
from .transport.common import TransportSource, TransportSink from .transport.common import TransportSource, TransportSink
@@ -1747,6 +1748,8 @@ class DeviceConfiguration:
cis_enabled: bool = False cis_enabled: bool = False
identity_address_type: Optional[int] = None identity_address_type: Optional[int] = None
io_capability: int = pairing.PairingDelegate.IoCapability.NO_OUTPUT_NO_INPUT 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: def __post_init__(self) -> None:
self.gatt_services: list[Dict[str, Any]] = [] self.gatt_services: list[Dict[str, Any]] = []
@@ -1929,6 +1932,7 @@ class Device(CompositeEventEmitter):
bis_links = dict[int, BisLink]() bis_links = dict[int, BisLink]()
big_syncs = dict[int, BigSync]() big_syncs = dict[int, BigSync]()
_pending_cis: Dict[int, tuple[int, int]] _pending_cis: Dict[int, tuple[int, int]]
gatt_service: gatt_service.GenericAttributeProfileService | None = None
@composite_listener @composite_listener
class Listener: class Listener:
@@ -1995,7 +1999,6 @@ class Device(CompositeEventEmitter):
address: Optional[hci.Address] = None, address: Optional[hci.Address] = None,
config: Optional[DeviceConfiguration] = None, config: Optional[DeviceConfiguration] = None,
host: Optional[Host] = None, host: Optional[Host] = None,
generic_access_service: bool = True,
) -> None: ) -> None:
super().__init__() super().__init__()
@@ -2142,7 +2145,10 @@ class Device(CompositeEventEmitter):
# Register the SDP server with the L2CAP Channel Manager # Register the SDP server with the L2CAP Channel Manager
self.sdp_server.register(self.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) self.l2cap_channel_manager.register_fixed_channel(ATT_CID, self.on_gatt_pdu)
# Forward some events # Forward some events
@@ -4506,10 +4512,15 @@ class Device(CompositeEventEmitter):
def add_services(self, services): def add_services(self, services):
self.gatt_server.add_services(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 # Add a GAP Service if requested
if generic_access_service: if add_gap_service:
self.gatt_server.add_service(GenericAccessService(self.name)) 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): async def notify_subscriber(self, connection, attribute, value=None, force=False):
await self.gatt_server.notify_subscriber(connection, attribute, value, force) await self.gatt_server.notify_subscriber(connection, attribute, value, force)

View File

@@ -48,7 +48,6 @@ from bumble.utils import ByteSerializable
if TYPE_CHECKING: if TYPE_CHECKING:
from bumble.gatt_client import AttributeProxy from bumble.gatt_client import AttributeProxy
from bumble.device import Connection
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@@ -802,3 +801,23 @@ class ClientCharacteristicConfigurationBits(enum.IntFlag):
DEFAULT = 0x0000 DEFAULT = 0x0000
NOTIFICATION = 0x0001 NOTIFICATION = 0x0001
INDICATION = 0x0002 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

View File

@@ -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("<H", attribute.handle)
+ attribute.type.to_bytes()
+ attribute.value
)
elif attribute.type in (
gatt.GATT_CHARACTERISTIC_USER_DESCRIPTION_DESCRIPTOR,
gatt.GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR,
gatt.GATT_SERVER_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR,
gatt.GATT_CHARACTERISTIC_PRESENTATION_FORMAT_DESCRIPTOR,
gatt.GATT_CHARACTERISTIC_AGGREGATE_FORMAT_DESCRIPTOR,
):
return struct.pack("<H", attribute.handle) + attribute.type.to_bytes()
return b''
def get_database_hash(self, connection: device.Connection | None) -> 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])

View File

@@ -50,12 +50,7 @@ from bumble.hci import (
HCI_Error, HCI_Error,
HCI_Packet, HCI_Packet,
) )
from bumble.gatt import ( from bumble import gatt
GATT_GENERIC_ACCESS_SERVICE,
GATT_CHARACTERISTIC_ATTRIBUTE_TYPE,
GATT_DEVICE_NAME_CHARACTERISTIC,
GATT_APPEARANCE_CHARACTERISTIC,
)
from .test_utils import TwoDevices, async_barrier 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)) device = Device(host=Host(None, None))
# there should be one service and two chars, therefore 5 attributes # there should be 2 service, 5 chars, and 1 descriptors, therefore 13 attributes
assert len(device.gatt_server.attributes) == 5 assert len(device.gatt_server.attributes) == 13
assert device.gatt_server.attributes[0].uuid == GATT_GENERIC_ACCESS_SERVICE assert device.gatt_server.attributes[0].uuid == gatt.GATT_GENERIC_ACCESS_SERVICE
assert device.gatt_server.attributes[1].type == GATT_CHARACTERISTIC_ATTRIBUTE_TYPE assert (
assert device.gatt_server.attributes[2].uuid == GATT_DEVICE_NAME_CHARACTERISTIC device.gatt_server.attributes[1].type == gatt.GATT_CHARACTERISTIC_ATTRIBUTE_TYPE
assert device.gatt_server.attributes[3].type == GATT_CHARACTERISTIC_ATTRIBUTE_TYPE )
assert device.gatt_server.attributes[4].uuid == GATT_APPEARANCE_CHARACTERISTIC 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
assert device.gatt_server.attributes[5].uuid == gatt.GATT_GENERIC_ATTRIBUTE_SERVICE
# ----------------------------------------------------------------------------- assert (
def test_gatt_services_without_gas(): device.gatt_server.attributes[6].type == gatt.GATT_CHARACTERISTIC_ATTRIBUTE_TYPE
device = Device(host=Host(None, None), generic_access_service=False) )
assert (
# there should be no services device.gatt_server.attributes[7].uuid
assert len(device.gatt_server.attributes) == 0 == 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(): async def run_test_device():
await test_device_connect_parallel() await test_device_connect_parallel()
await test_flush() await test_flush()
await test_gatt_services_with_gas() await test_gatt_services_with_gas_and_gatt()
await test_gatt_services_without_gas()
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------

140
tests/gatt_service_test.py Normal file
View File

@@ -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'

View File

@@ -957,11 +957,12 @@ async def test_discover_all():
peer = Peer(connection) peer = Peer(connection)
await peer.discover_all() await peer.discover_all()
assert len(peer.gatt_client.services) == 3 assert len(peer.gatt_client.services) == 4
# service 1800 gets added automatically # service 1800 and 1801 get added automatically
assert peer.gatt_client.services[0].uuid == UUID('1800') assert peer.gatt_client.services[0].uuid == UUID('1800')
assert peer.gatt_client.services[1].uuid == service1.uuid assert peer.gatt_client.services[1].uuid == UUID('1801')
assert peer.gatt_client.services[2].uuid == service2.uuid 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) s = peer.get_services_by_uuid(service1.uuid)
assert len(s) == 1 assert len(s) == 1
assert len(s[0].characteristics) == 2 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) Characteristic(handle=0x0003, end=0x0003, uuid=UUID-16:2A00 (Device Name), READ)
CharacteristicDeclaration(handle=0x0004, value_handle=0x0005, uuid=UUID-16:2A01 (Appearance), READ) CharacteristicDeclaration(handle=0x0004, value_handle=0x0005, uuid=UUID-16:2A01 (Appearance), READ)
Characteristic(handle=0x0005, end=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) Service(handle=0x0006, end=0x000D, uuid=UUID-16:1801 (Generic Attribute))
CharacteristicDeclaration(handle=0x0007, value_handle=0x0008, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, READ|WRITE|NOTIFY) CharacteristicDeclaration(handle=0x0007, value_handle=0x0008, uuid=UUID-16:2A05 (Service Changed), INDICATE)
Characteristic(handle=0x0008, end=0x0009, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, READ|WRITE|NOTIFY) Characteristic(handle=0x0008, end=0x0009, uuid=UUID-16:2A05 (Service Changed), INDICATE)
Descriptor(handle=0x0009, type=UUID-16:2902 (Client Characteristic Configuration), value=0000)""" 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)"""
) )