mirror of
https://github.com/google/bumble.git
synced 2026-06-08 08:42:26 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c86125de4f | |||
| a8eff737e6 | |||
| 4417eb636c | |||
| f4e5e61bbb | |||
| ba7a60025f | |||
| 691450c7de | |||
| 99a0eb21c1 | |||
| ab4859bd94 | |||
| 0d70cbde64 | |||
| f41d0682b2 | |||
| 062dc1e53d | |||
| 662704e551 | |||
| 02a474c44e | |||
| a1c7aec492 | |||
| 6112f00049 | |||
| f56ac14f2c | |||
| b89f9030a0 | |||
| 5f1d57fcb0 | |||
| 9c133706e6 |
@@ -14,6 +14,10 @@ jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.8", "3.9", "3.10"]
|
||||
fail-fast: false
|
||||
|
||||
steps:
|
||||
- name: Check out from Git
|
||||
@@ -22,10 +26,10 @@ jobs:
|
||||
run: |
|
||||
git fetch --prune --unshallow
|
||||
git fetch --depth=1 origin +refs/tags/*:refs/tags/*
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v3
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.10"
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
|
||||
+1
-4
@@ -3,9 +3,6 @@ build/
|
||||
dist/
|
||||
*.egg-info/
|
||||
*~
|
||||
bumble/__pycache__
|
||||
docs/mkdocs/site
|
||||
tests/__pycache__
|
||||
test-results.xml
|
||||
bumble/transport/__pycache__
|
||||
bumble/profiles/__pycache__
|
||||
__pycache__
|
||||
|
||||
+7
-1
@@ -58,6 +58,12 @@ def padded_bytes(buffer, size):
|
||||
return buffer + bytes(padding_size)
|
||||
|
||||
|
||||
def get_dict_key_by_value(dictionary, value):
|
||||
for key, val in dictionary.items():
|
||||
if val == value:
|
||||
return key
|
||||
return None
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Exceptions
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -135,7 +141,7 @@ class UUID:
|
||||
else:
|
||||
uuid_str = uuid_str_or_int
|
||||
if len(uuid_str) != 32 and len(uuid_str) != 8 and len(uuid_str) != 4:
|
||||
raise ValueError('invalid UUID format')
|
||||
raise ValueError(f"invalid UUID format: {uuid_str}")
|
||||
self.uuid_bytes = bytes(reversed(bytes.fromhex(uuid_str)))
|
||||
self.name = name
|
||||
|
||||
|
||||
+112
-51
@@ -413,12 +413,36 @@ class Connection(CompositeEventEmitter):
|
||||
self.parameters = parameters
|
||||
self.encryption = 0
|
||||
self.authenticated = False
|
||||
self.sc = False
|
||||
self.link_key_type = None
|
||||
self.authenticating = False
|
||||
self.phy = phy
|
||||
self.att_mtu = ATT_DEFAULT_MTU
|
||||
self.data_length = DEVICE_DEFAULT_DATA_LENGTH
|
||||
self.gatt_client = None # Per-connection client
|
||||
self.gatt_server = device.gatt_server # By default, use the device's shared server
|
||||
|
||||
# [Classic only]
|
||||
@classmethod
|
||||
def incomplete(cls, device, peer_address):
|
||||
"""
|
||||
Instantiate an incomplete connection (ie. one waiting for a HCI Connection Complete event).
|
||||
Once received it shall be completed using the `.complete` method.
|
||||
"""
|
||||
return cls(device, None, BT_BR_EDR_TRANSPORT, device.public_address, peer_address, None, None, None, None)
|
||||
|
||||
# [Classic only]
|
||||
def complete(self, handle, peer_resolvable_address, role, parameters):
|
||||
"""
|
||||
Finish an incomplete connection upon completion.
|
||||
"""
|
||||
assert self.handle is None
|
||||
assert self.transport == BT_BR_EDR_TRANSPORT
|
||||
self.handle = handle
|
||||
self.peer_resolvable_address = peer_resolvable_address
|
||||
self.role = role
|
||||
self.parameters = parameters
|
||||
|
||||
@property
|
||||
def role_name(self):
|
||||
return 'CENTRAL' if self.role == BT_CENTRAL_ROLE else 'PERIPHERAL'
|
||||
@@ -540,6 +564,7 @@ class DeviceConfiguration:
|
||||
)
|
||||
self.irk = bytes(16) # This really must be changed for any level of security
|
||||
self.keystore = None
|
||||
self.gatt_services = []
|
||||
|
||||
def load_from_dict(self, config):
|
||||
# Load simple properties
|
||||
@@ -556,6 +581,7 @@ class DeviceConfiguration:
|
||||
self.classic_accept_any = config.get('classic_accept_any', self.classic_accept_any)
|
||||
self.connectable = config.get('connectable', self.connectable)
|
||||
self.discoverable = config.get('discoverable', self.discoverable)
|
||||
self.gatt_services = config.get('gatt_services', self.gatt_services)
|
||||
|
||||
# Load or synthesize an IRK
|
||||
irk = config.get('irk')
|
||||
@@ -589,7 +615,7 @@ def with_connection_from_handle(function):
|
||||
@functools.wraps(function)
|
||||
def wrapper(self, connection_handle, *args, **kwargs):
|
||||
if (connection := self.lookup_connection(connection_handle)) is None:
|
||||
raise ValueError('no connection for handle')
|
||||
raise ValueError(f"no connection for handle: 0x{connection_handle:04x}")
|
||||
return function(self, connection, *args, **kwargs)
|
||||
return wrapper
|
||||
|
||||
@@ -598,6 +624,8 @@ def with_connection_from_handle(function):
|
||||
def with_connection_from_address(function):
|
||||
@functools.wraps(function)
|
||||
def wrapper(self, address, *args, **kwargs):
|
||||
if (connection := self.pending_connections.get(address, False)):
|
||||
return function(self, connection, *args, **kwargs)
|
||||
for connection in self.connections.values():
|
||||
if connection.peer_address == address:
|
||||
return function(self, connection, *args, **kwargs)
|
||||
@@ -609,6 +637,8 @@ def with_connection_from_address(function):
|
||||
def try_with_connection_from_address(function):
|
||||
@functools.wraps(function)
|
||||
def wrapper(self, address, *args, **kwargs):
|
||||
if (connection := self.pending_connections.get(address, False)):
|
||||
return function(self, connection, address, *args, **kwargs)
|
||||
for connection in self.connections.values():
|
||||
if connection.peer_address == address:
|
||||
return function(self, connection, address, *args, **kwargs)
|
||||
@@ -696,6 +726,7 @@ class Device(CompositeEventEmitter):
|
||||
self.le_connecting = False
|
||||
self.disconnecting = False
|
||||
self.connections = {} # Connections, by connection handle
|
||||
self.pending_connections = {} # Connections, by BD address (BR/EDR only)
|
||||
self.classic_enabled = False
|
||||
self.inquiry_response = None
|
||||
self.address_resolver = None
|
||||
@@ -726,6 +757,26 @@ class Device(CompositeEventEmitter):
|
||||
self.connectable = config.connectable
|
||||
self.classic_accept_any = config.classic_accept_any
|
||||
|
||||
for service in config.gatt_services:
|
||||
characteristics = []
|
||||
for characteristic in service.get("characteristics", []):
|
||||
descriptors = []
|
||||
for descriptor in characteristic.get("descriptors", []):
|
||||
new_descriptor = Descriptor(
|
||||
descriptor_type=descriptor["descriptor_type"],
|
||||
permissions=descriptor["permission"],
|
||||
)
|
||||
descriptors.append(new_descriptor)
|
||||
new_characteristic = Characteristic(
|
||||
uuid=characteristic["uuid"],
|
||||
properties=characteristic["properties"],
|
||||
permissions=int(characteristic["permissions"], 0),
|
||||
descriptors=descriptors,
|
||||
)
|
||||
characteristics.append(new_characteristic)
|
||||
new_service = Service(uuid=service["uuid"], characteristics=characteristics)
|
||||
self.gatt_server.add_service(new_service)
|
||||
|
||||
# If a name is passed, override the name from the config
|
||||
if name:
|
||||
self.name = name
|
||||
@@ -746,9 +797,7 @@ class Device(CompositeEventEmitter):
|
||||
# Register the SDP server with the L2CAP Channel Manager
|
||||
self.sdp_server.register(self.l2cap_channel_manager)
|
||||
|
||||
# Add a GAP Service if requested
|
||||
if generic_access_service:
|
||||
self.gatt_server.add_service(GenericAccessService(self.name))
|
||||
self.add_default_services(generic_access_service)
|
||||
self.l2cap_channel_manager.register_fixed_channel(ATT_CID, self.on_gatt_pdu)
|
||||
|
||||
# Forward some events
|
||||
@@ -796,7 +845,7 @@ class Device(CompositeEventEmitter):
|
||||
|
||||
def find_connection_by_bd_addr(self, bd_addr, transport=None, check_address_type=False):
|
||||
for connection in self.connections.values():
|
||||
if connection.peer_address.get_bytes() == bd_addr.get_bytes():
|
||||
if connection.peer_address.to_bytes() == bd_addr.to_bytes():
|
||||
if check_address_type and connection.peer_address.address_type != bd_addr.address_type:
|
||||
continue
|
||||
if transport is None or connection.transport == transport:
|
||||
@@ -1323,6 +1372,9 @@ class Device(CompositeEventEmitter):
|
||||
max_ce_length = int(prefs.max_ce_length / 0.625),
|
||||
))
|
||||
else:
|
||||
# Save pending connection
|
||||
self.pending_connections[peer_address] = Connection.incomplete(self, peer_address)
|
||||
|
||||
# TODO: allow passing other settings
|
||||
result = await self.send_command(HCI_Create_Connection_Command(
|
||||
bd_addr = peer_address,
|
||||
@@ -1360,6 +1412,8 @@ class Device(CompositeEventEmitter):
|
||||
if transport == BT_LE_TRANSPORT:
|
||||
self.le_connecting = False
|
||||
self.connect_own_address_type = None
|
||||
else:
|
||||
self.pending_connections.pop(peer_address, None)
|
||||
|
||||
async def accept(
|
||||
self,
|
||||
@@ -1373,7 +1427,7 @@ class Device(CompositeEventEmitter):
|
||||
Notes:
|
||||
* A `connect` to the same peer will also complete this call.
|
||||
* The `timeout` parameter is only handled while waiting for the connection request,
|
||||
once received and accepeted, the controller shall issue a connection complete event.
|
||||
once received and accepted, the controller shall issue a connection complete event.
|
||||
'''
|
||||
|
||||
if type(peer_address) is str:
|
||||
@@ -1429,6 +1483,9 @@ class Device(CompositeEventEmitter):
|
||||
self.on('connection', on_connection)
|
||||
self.on('connection_failure', on_connection_failure)
|
||||
|
||||
# Save pending connection
|
||||
self.pending_connections[peer_address] = Connection.incomplete(self, peer_address)
|
||||
|
||||
try:
|
||||
# Accept connection request
|
||||
await self.send_command(HCI_Accept_Connection_Request_Command(
|
||||
@@ -1442,6 +1499,7 @@ class Device(CompositeEventEmitter):
|
||||
finally:
|
||||
self.remove_listener('connection', on_connection)
|
||||
self.remove_listener('connection_failure', on_connection_failure)
|
||||
self.pending_connections.pop(peer_address, None)
|
||||
|
||||
@asynccontextmanager
|
||||
async def connect_as_gatt(self, peer_address):
|
||||
@@ -1685,9 +1743,13 @@ class Device(CompositeEventEmitter):
|
||||
logger.warn(f'HCI_Authentication_Requested_Command failed: {HCI_Constant.error_name(result.status)}')
|
||||
raise HCI_StatusError(result)
|
||||
|
||||
# Save in connection we are trying to authenticate
|
||||
connection.authenticating = True
|
||||
|
||||
# Wait for the authentication to complete
|
||||
await pending_authentication
|
||||
finally:
|
||||
connection.authenticating = False
|
||||
connection.remove_listener('connection_authentication', on_authentication)
|
||||
connection.remove_listener('connection_authentication_failure', on_authentication_failure)
|
||||
|
||||
@@ -1764,28 +1826,18 @@ class Device(CompositeEventEmitter):
|
||||
# Set up event handlers
|
||||
pending_name = asyncio.get_running_loop().create_future()
|
||||
|
||||
if type(remote) == Address:
|
||||
peer_address = remote
|
||||
handler = self.on(
|
||||
'remote_name',
|
||||
lambda address, remote_name:
|
||||
pending_name.set_result(remote_name) if address == remote else None
|
||||
)
|
||||
failure_handler = self.on(
|
||||
'remote_name_failure',
|
||||
lambda address, error_code:
|
||||
pending_name.set_exception(HCI_Error(error_code)) if address == remote else None
|
||||
)
|
||||
else:
|
||||
peer_address = remote.peer_address
|
||||
handler = remote.on(
|
||||
'remote_name',
|
||||
lambda: pending_name.set_result(remote.peer_name)
|
||||
)
|
||||
failure_handler = remote.on(
|
||||
'remote_name_failure',
|
||||
lambda error_code: pending_name.set_exception(HCI_Error(error_code))
|
||||
)
|
||||
peer_address = remote if type(remote) == Address else remote.peer_address
|
||||
|
||||
handler = self.on(
|
||||
'remote_name',
|
||||
lambda address, remote_name:
|
||||
pending_name.set_result(remote_name) if address == peer_address else None
|
||||
)
|
||||
failure_handler = self.on(
|
||||
'remote_name_failure',
|
||||
lambda address, error_code:
|
||||
pending_name.set_exception(HCI_Error(error_code)) if address == peer_address else None
|
||||
)
|
||||
|
||||
try:
|
||||
result = await self.send_command(
|
||||
@@ -1804,12 +1856,8 @@ class Device(CompositeEventEmitter):
|
||||
# Wait for the result
|
||||
return await pending_name
|
||||
finally:
|
||||
if type(remote) == Address:
|
||||
self.remove_listener('remote_name', handler)
|
||||
self.remove_listener('remote_name_failure', failure_handler)
|
||||
else:
|
||||
remote.remove_listener('remote_name', handler)
|
||||
remote.remove_listener('remote_name_failure', failure_handler)
|
||||
self.remove_listener('remote_name', handler)
|
||||
self.remove_listener('remote_name_failure', failure_handler)
|
||||
|
||||
# [Classic only]
|
||||
@host_event_handler
|
||||
@@ -1827,12 +1875,20 @@ class Device(CompositeEventEmitter):
|
||||
|
||||
asyncio.create_task(store_keys())
|
||||
|
||||
if (connection := self.find_connection_by_bd_addr(bd_addr, transport=BT_BR_EDR_TRANSPORT)):
|
||||
connection.link_key_type = key_type
|
||||
|
||||
def add_service(self, service):
|
||||
self.gatt_server.add_service(service)
|
||||
|
||||
def add_services(self, services):
|
||||
self.gatt_server.add_services(services)
|
||||
|
||||
def add_default_services(self, generic_access_service=True):
|
||||
# Add a GAP Service if requested
|
||||
if generic_access_service:
|
||||
self.gatt_server.add_service(GenericAccessService(self.name))
|
||||
|
||||
async def notify_subscriber(self, connection, attribute, value=None, force=False):
|
||||
await self.gatt_server.notify_subscriber(connection, attribute, value, force)
|
||||
|
||||
@@ -1853,21 +1909,12 @@ class Device(CompositeEventEmitter):
|
||||
|
||||
if transport == BT_BR_EDR_TRANSPORT:
|
||||
# Create a new connection
|
||||
connection = Connection(
|
||||
self,
|
||||
connection_handle,
|
||||
transport,
|
||||
self.public_address,
|
||||
peer_address,
|
||||
peer_resolvable_address,
|
||||
role,
|
||||
connection_parameters,
|
||||
phy=None
|
||||
)
|
||||
connection: Connection = self.pending_connections.pop(peer_address)
|
||||
connection.complete(connection_handle, peer_resolvable_address, role, connection_parameters)
|
||||
self.connections[connection_handle] = connection
|
||||
|
||||
# We may have an accept ongoing waiting for a connection request for `peer_address`.
|
||||
# Typicaly happen when using `connect` to the same `peer_address` we are waiting with
|
||||
# Typically happen when using `connect` to the same `peer_address` we are waiting with
|
||||
# an `accept` for.
|
||||
# In this case, set the completed `connection` to the `accept` future result.
|
||||
if peer_address in self.classic_pending_accepts:
|
||||
@@ -1969,6 +2016,9 @@ class Device(CompositeEventEmitter):
|
||||
|
||||
# device configuration is set to accept any incoming connection
|
||||
elif self.classic_accept_any:
|
||||
# Save pending connection
|
||||
self.pending_connections[bd_addr] = Connection.incomplete(self, bd_addr)
|
||||
|
||||
self.host.send_command_sync(
|
||||
HCI_Accept_Connection_Request_Command(
|
||||
bd_addr = bd_addr,
|
||||
@@ -2042,6 +2092,17 @@ class Device(CompositeEventEmitter):
|
||||
logger.debug(f'*** Connection Authentication Failure: [0x{connection.handle:04X}] {connection.peer_address} as {connection.role_name}, error={error}')
|
||||
connection.emit('connection_authentication_failure', error)
|
||||
|
||||
@host_event_handler
|
||||
@with_connection_from_address
|
||||
def on_ssp_complete(self, connection):
|
||||
# On Secure Simple Pairing complete, in case:
|
||||
# - Connection isn't already authenticated
|
||||
# - AND we are not the initiator of the authentication
|
||||
# We must trigger authentication to known if we are truly authenticated
|
||||
if not connection.authenticating and not connection.authenticated:
|
||||
logger.debug(f'*** Trigger Connection Authentication: [0x{connection.handle:04X}] {connection.peer_address}')
|
||||
asyncio.create_task(connection.authenticate())
|
||||
|
||||
# [Classic only]
|
||||
@host_event_handler
|
||||
@with_connection_from_address
|
||||
@@ -2178,8 +2239,7 @@ class Device(CompositeEventEmitter):
|
||||
if connection:
|
||||
connection.peer_name = remote_name
|
||||
connection.emit('remote_name')
|
||||
else:
|
||||
self.emit('remote_name', address, remote_name)
|
||||
self.emit('remote_name', address, remote_name)
|
||||
except UnicodeDecodeError as error:
|
||||
logger.warning('peer name is not valid UTF-8')
|
||||
if connection:
|
||||
@@ -2193,8 +2253,7 @@ class Device(CompositeEventEmitter):
|
||||
def on_remote_name_failure(self, connection, address, error):
|
||||
if connection:
|
||||
connection.emit('remote_name_failure', error)
|
||||
else:
|
||||
self.emit('remote_name_failure', address, error)
|
||||
self.emit('remote_name_failure', address, error)
|
||||
|
||||
@host_event_handler
|
||||
@with_connection_from_handle
|
||||
@@ -2260,7 +2319,9 @@ class Device(CompositeEventEmitter):
|
||||
connection.emit('pairing_start')
|
||||
|
||||
@with_connection_from_handle
|
||||
def on_pairing(self, connection, keys):
|
||||
def on_pairing(self, connection, keys, sc):
|
||||
connection.sc = sc
|
||||
connection.authenticated = True
|
||||
connection.emit('pairing', keys)
|
||||
|
||||
@with_connection_from_handle
|
||||
|
||||
+33
-3
@@ -22,6 +22,7 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import enum
|
||||
import types
|
||||
@@ -187,7 +188,7 @@ class Service(Attribute):
|
||||
See Vol 3, Part G - 3.1 SERVICE DEFINITION
|
||||
'''
|
||||
|
||||
def __init__(self, uuid, characteristics, primary=True):
|
||||
def __init__(self, uuid, characteristics: list[Characteristic], primary=True):
|
||||
# Convert the uuid to a UUID object if it isn't already
|
||||
if type(uuid) is str:
|
||||
uuid = UUID(uuid)
|
||||
@@ -256,10 +257,21 @@ class Characteristic(Attribute):
|
||||
if properties & p
|
||||
])
|
||||
|
||||
def __init__(self, uuid, properties, permissions, value = b'', descriptors = []):
|
||||
@staticmethod
|
||||
def string_to_properties(properties_str: str):
|
||||
return functools.reduce(
|
||||
lambda x, y: x | get_dict_key_by_value(Characteristic.PROPERTY_NAMES, y),
|
||||
properties_str.split(","),
|
||||
0,
|
||||
)
|
||||
|
||||
def __init__(self, uuid, properties, permissions, value = b'', descriptors: list[Descriptor] = []):
|
||||
super().__init__(uuid, permissions, value)
|
||||
self.uuid = self.type
|
||||
self.properties = properties
|
||||
if type(properties) is str:
|
||||
self.properties = Characteristic.string_to_properties(properties)
|
||||
else:
|
||||
self.properties = properties
|
||||
self.descriptors = descriptors
|
||||
|
||||
def get_descriptor(self, descriptor_type):
|
||||
@@ -271,6 +283,24 @@ class Characteristic(Attribute):
|
||||
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 CharacteristicDeclaration(Attribute):
|
||||
'''
|
||||
See Vol 3, Part G - 3.3.1 CHARACTERISTIC DECLARATION
|
||||
'''
|
||||
def __init__(self, characteristic, value_handle):
|
||||
declaration_bytes = struct.pack(
|
||||
'<BH',
|
||||
characteristic.properties,
|
||||
value_handle
|
||||
) + characteristic.uuid.to_pdu_bytes()
|
||||
super().__init__(GATT_CHARACTERISTIC_ATTRIBUTE_TYPE, Attribute.READABLE, declaration_bytes)
|
||||
self.value_handle = value_handle
|
||||
self.characteristic = characteristic
|
||||
|
||||
def __str__(self):
|
||||
return f'CharacteristicDeclaration(handle=0x{self.handle:04X}, value_handle=0x{self.value_handle:04X}, uuid={self.characteristic.uuid}, properties={Characteristic.properties_as_string(self.characteristic.properties)})'
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class CharacteristicValue:
|
||||
'''
|
||||
|
||||
+65
-11
@@ -26,6 +26,7 @@
|
||||
import asyncio
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from typing import Tuple, Optional
|
||||
from pyee import EventEmitter
|
||||
from colors import color
|
||||
|
||||
@@ -60,6 +61,9 @@ class Server(EventEmitter):
|
||||
self.indication_semaphores = defaultdict(lambda: asyncio.Semaphore(1))
|
||||
self.pending_confirmations = defaultdict(lambda: None)
|
||||
|
||||
def __str__(self):
|
||||
return "\n".join(map(str, self.attributes))
|
||||
|
||||
def send_gatt_pdu(self, connection_handle, pdu):
|
||||
self.device.send_l2cap_pdu(connection_handle, ATT_CID, pdu)
|
||||
|
||||
@@ -79,6 +83,63 @@ class Server(EventEmitter):
|
||||
return attribute
|
||||
return None
|
||||
|
||||
def get_service_attribute(self, service_uuid: UUID) -> Optional[Service]:
|
||||
return next(
|
||||
(
|
||||
attribute
|
||||
for attribute in self.attributes
|
||||
if attribute.type == GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE
|
||||
and attribute.uuid == service_uuid
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
def get_characteristic_attributes(
|
||||
self, service_uuid: UUID, characteristic_uuid: UUID
|
||||
) -> Optional[Tuple[CharacteristicDeclaration, Characteristic]]:
|
||||
service_handle = self.get_service_attribute(service_uuid)
|
||||
if not service_handle:
|
||||
return None
|
||||
|
||||
return next(
|
||||
(
|
||||
(attribute, self.get_attribute(attribute.characteristic.handle))
|
||||
for attribute in map(
|
||||
self.get_attribute,
|
||||
range(service_handle.handle, service_handle.end_group_handle + 1),
|
||||
)
|
||||
if attribute.type == GATT_CHARACTERISTIC_ATTRIBUTE_TYPE
|
||||
and attribute.characteristic.uuid == characteristic_uuid
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
def get_descriptor_attribute(
|
||||
self, service_uuid: UUID, characteristic_uuid: UUID, descriptor_uuid: UUID
|
||||
) -> Optional[Descriptor]:
|
||||
characteristics = self.get_characteristic_attributes(
|
||||
service_uuid, characteristic_uuid
|
||||
)
|
||||
if not characteristics:
|
||||
return None
|
||||
|
||||
(_, characteristic_value) = characteristics
|
||||
|
||||
return next(
|
||||
(
|
||||
attribute
|
||||
for attribute in map(
|
||||
self.get_attribute,
|
||||
range(
|
||||
characteristic_value.handle + 1,
|
||||
characteristic_value.end_group_handle + 1,
|
||||
),
|
||||
)
|
||||
if attribute.type == descriptor_uuid
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
def add_attribute(self, attribute):
|
||||
# Assign a handle to this attribute
|
||||
attribute.handle = self.next_handle()
|
||||
@@ -87,7 +148,7 @@ class Server(EventEmitter):
|
||||
# Add this attribute to the list
|
||||
self.attributes.append(attribute)
|
||||
|
||||
def add_service(self, service):
|
||||
def add_service(self, service: Service):
|
||||
# Add the service attribute to the DB
|
||||
self.add_attribute(service)
|
||||
|
||||
@@ -95,16 +156,9 @@ class Server(EventEmitter):
|
||||
|
||||
# Add all characteristics
|
||||
for characteristic in service.characteristics:
|
||||
# Add a Characteristic Declaration (Vol 3, Part G - 3.3.1 Characteristic Declaration)
|
||||
declaration_bytes = struct.pack(
|
||||
'<BH',
|
||||
characteristic.properties,
|
||||
self.next_handle() + 1, # The value will be the next attribute after this declaration
|
||||
) + characteristic.uuid.to_pdu_bytes()
|
||||
characteristic_declaration = Attribute(
|
||||
GATT_CHARACTERISTIC_ATTRIBUTE_TYPE,
|
||||
Attribute.READABLE,
|
||||
declaration_bytes
|
||||
# Add a Characteristic Declaration
|
||||
characteristic_declaration = CharacteristicDeclaration(
|
||||
characteristic, self.next_handle() + 1
|
||||
)
|
||||
self.add_attribute(characteristic_declaration)
|
||||
|
||||
|
||||
+4
-10
@@ -1652,16 +1652,6 @@ class Address:
|
||||
|
||||
ADDRESS_TYPE_SPEC = {'size': 1, 'mapper': lambda x: Address.address_type_name(x)}
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def ANY(cls):
|
||||
return cls(b"\xff\xff\xff\xff\xff\xff", cls.PUBLIC_DEVICE_ADDRESS)
|
||||
|
||||
@classmethod
|
||||
@property
|
||||
def NIL(cls):
|
||||
return cls(b"\x00\x00\x00\x00\x00\x00", cls.PUBLIC_DEVICE_ADDRESS)
|
||||
|
||||
@staticmethod
|
||||
def address_type_name(address_type):
|
||||
return name_or_number(Address.ADDRESS_TYPE_NAMES, address_type)
|
||||
@@ -1759,6 +1749,10 @@ class Address:
|
||||
return str + '/P'
|
||||
|
||||
|
||||
# Predefined address values
|
||||
Address.NIL = Address(b"\xff\xff\xff\xff\xff\xff", Address.PUBLIC_DEVICE_ADDRESS)
|
||||
Address.ANY = Address(b"\x00\x00\x00\x00\x00\x00", Address.PUBLIC_DEVICE_ADDRESS)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
class OwnAddressType:
|
||||
PUBLIC = 0
|
||||
|
||||
@@ -599,6 +599,9 @@ class Host(EventEmitter):
|
||||
|
||||
def on_hci_simple_pairing_complete_event(self, event):
|
||||
logger.debug(f'simple pairing complete for {event.bd_addr}: status={HCI_Constant.status_name(event.status)}')
|
||||
# Notify the client
|
||||
if event.status == HCI_SUCCESS:
|
||||
self.emit('ssp_complete', event.bd_addr)
|
||||
|
||||
def on_hci_pin_code_request_event(self, event):
|
||||
# For now, just refuse all requests
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import json
|
||||
@@ -143,6 +144,10 @@ class KeyStore:
|
||||
async def get_all(self):
|
||||
return []
|
||||
|
||||
async def delete_all(self):
|
||||
all_keys = await self.get_all()
|
||||
await asyncio.gather(*(self.delete(name) for (name, _) in all_keys))
|
||||
|
||||
async def get_resolving_keys(self):
|
||||
all_keys = await self.get_all()
|
||||
resolving_keys = []
|
||||
@@ -259,6 +264,13 @@ class JsonKeyStore(KeyStore):
|
||||
|
||||
return [(name, PairingKeys.from_dict(keys)) for (name, keys) in namespace.items()]
|
||||
|
||||
async def delete_all(self):
|
||||
db = await self.load()
|
||||
|
||||
db.pop(self.namespace, None)
|
||||
|
||||
await self.save(db)
|
||||
|
||||
async def get(self, name):
|
||||
db = await self.load()
|
||||
|
||||
|
||||
+1
-1
@@ -1583,7 +1583,7 @@ class Manager(EventEmitter):
|
||||
asyncio.create_task(store_keys())
|
||||
|
||||
# Notify the device
|
||||
self.device.on_pairing(session.connection.handle, keys)
|
||||
self.device.on_pairing(session.connection.handle, keys, session.sc)
|
||||
|
||||
def on_pairing_failure(self, session, reason):
|
||||
self.device.on_pairing_failure(session.connection.handle, reason)
|
||||
|
||||
@@ -52,8 +52,9 @@ build_tasks.add_task(mkdocs, name="mkdocs")
|
||||
test_tasks = Collection()
|
||||
ns.add_collection(test_tasks, name="test")
|
||||
|
||||
@task
|
||||
def test(ctx, filter=None, junit=False, install=False, html=False):
|
||||
|
||||
@task(incrementable=["verbose"])
|
||||
def test(ctx, filter=None, junit=False, install=False, html=False, verbose=0):
|
||||
# Install the package before running the tests
|
||||
if install:
|
||||
ctx.run("python -m pip install .[test]")
|
||||
@@ -62,10 +63,12 @@ def test(ctx, filter=None, junit=False, install=False, html=False):
|
||||
if junit:
|
||||
args += "--junit-xml test-results.xml"
|
||||
if filter is not None:
|
||||
args += " -k '{}'".format(filter)
|
||||
args += f" -k '{filter}'"
|
||||
if html:
|
||||
args += "--html results.html"
|
||||
ctx.run("python -m pytest {} {}".format(os.path.join(ROOT_DIR, "tests"), args))
|
||||
args += " --html results.html"
|
||||
if verbose > 0:
|
||||
args += f" -{'v' * verbose}"
|
||||
ctx.run(f"python -m pytest {os.path.join(ROOT_DIR, 'tests')} {args}")
|
||||
|
||||
test_tasks.add_task(test, default=True)
|
||||
|
||||
|
||||
+11
-2
@@ -15,8 +15,7 @@
|
||||
# -----------------------------------------------------------------------------
|
||||
# Imports
|
||||
# -----------------------------------------------------------------------------
|
||||
from bumble.core import AdvertisingData
|
||||
|
||||
from bumble.core import AdvertisingData, get_dict_key_by_value
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_ad_data():
|
||||
@@ -39,6 +38,16 @@ def test_ad_data():
|
||||
assert(ad.get(AdvertisingData.TX_POWER_LEVEL, return_all=True, raw=True) == [bytes([123]), bytes([234])])
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_get_dict_key_by_value():
|
||||
dictionary = {
|
||||
"A": 1,
|
||||
"B": 2
|
||||
}
|
||||
assert get_dict_key_by_value(dictionary, 1) == "A"
|
||||
assert get_dict_key_by_value(dictionary, 2) == "B"
|
||||
assert get_dict_key_by_value(dictionary, 3) is None
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
if __name__ == '__main__':
|
||||
test_ad_data()
|
||||
+22
-1
@@ -28,7 +28,7 @@ from bumble.hci import (
|
||||
HCI_ACCEPT_CONNECTION_REQUEST_COMMAND, HCI_COMMAND_STATUS_PENDING, HCI_CREATE_CONNECTION_COMMAND, HCI_SUCCESS,
|
||||
Address, HCI_Command_Complete_Event, HCI_Command_Status_Event, HCI_Connection_Complete_Event, HCI_Connection_Request_Event, HCI_Packet
|
||||
)
|
||||
|
||||
from bumble.gatt import GATT_GENERIC_ACCESS_SERVICE, GATT_CHARACTERISTIC_ATTRIBUTE_TYPE, GATT_DEVICE_NAME_CHARACTERISTIC, GATT_APPEARANCE_CHARACTERISTIC
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Logging
|
||||
@@ -182,6 +182,27 @@ async def run_test_device():
|
||||
await test_device_connect_parallel()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_gatt_services_with_gas():
|
||||
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
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
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
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
if __name__ == '__main__':
|
||||
logging.basicConfig(level = os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
|
||||
|
||||
@@ -28,6 +28,7 @@ from bumble.device import Device, Peer
|
||||
from bumble.host import Host
|
||||
from bumble.gatt import (
|
||||
GATT_BATTERY_LEVEL_CHARACTERISTIC,
|
||||
GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR,
|
||||
CharacteristicAdapter,
|
||||
DelegatedCharacteristicAdapter,
|
||||
PackedCharacteristicAdapter,
|
||||
@@ -226,6 +227,37 @@ async def test_characteristic_encoding():
|
||||
assert last_change is None
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_attribute_getters():
|
||||
[client, server] = LinkedDevices().devices[:2]
|
||||
|
||||
characteristic_uuid = UUID('FDB159DB-036C-49E3-B3DB-6325AC750806')
|
||||
characteristic = Characteristic(
|
||||
characteristic_uuid,
|
||||
Characteristic.READ | Characteristic.WRITE | Characteristic.NOTIFY,
|
||||
Characteristic.READABLE | Characteristic.WRITEABLE,
|
||||
bytes([123])
|
||||
)
|
||||
|
||||
service_uuid = UUID('3A657F47-D34F-46B3-B1EC-698E29B6B829')
|
||||
service = Service(service_uuid, [characteristic])
|
||||
server.add_service(service)
|
||||
|
||||
service_attr = server.gatt_server.get_service_attribute(service_uuid)
|
||||
assert service_attr
|
||||
|
||||
(char_decl_attr, char_value_attr) = server.gatt_server.get_characteristic_attributes(service_uuid, characteristic_uuid)
|
||||
assert char_decl_attr and char_value_attr
|
||||
|
||||
desc_attr = server.gatt_server.get_descriptor_attribute(service_uuid, characteristic_uuid, GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR)
|
||||
assert desc_attr
|
||||
|
||||
# assert all handles are in expected order
|
||||
assert service_attr.handle < char_decl_attr.handle < char_value_attr.handle < desc_attr.handle == service_attr.end_group_handle
|
||||
# assert characteristic declarations attribute is followed by characteristic value attribute
|
||||
assert char_decl_attr.handle + 1 == char_value_attr.handle
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_CharacteristicAdapter():
|
||||
# Check that the CharacteristicAdapter base class is transparent
|
||||
@@ -705,6 +737,55 @@ async def test_mtu_exchange():
|
||||
assert d2_connection.att_mtu == 50
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_char_property_to_string():
|
||||
# single
|
||||
assert Characteristic.property_name(0x01) == "BROADCAST"
|
||||
assert Characteristic.property_name(Characteristic.BROADCAST) == "BROADCAST"
|
||||
|
||||
# double
|
||||
assert Characteristic.properties_as_string(0x03) == "BROADCAST,READ"
|
||||
assert Characteristic.properties_as_string(Characteristic.BROADCAST | Characteristic.READ) == "BROADCAST,READ"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
def test_char_property_string_to_type():
|
||||
# single
|
||||
assert Characteristic.string_to_properties("BROADCAST") == Characteristic.BROADCAST
|
||||
|
||||
# double
|
||||
assert Characteristic.string_to_properties("BROADCAST,READ") == Characteristic.BROADCAST | Characteristic.READ
|
||||
assert Characteristic.string_to_properties("READ,BROADCAST") == Characteristic.BROADCAST | Characteristic.READ
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_string():
|
||||
[_, server] = LinkedDevices().devices[:2]
|
||||
|
||||
characteristic = Characteristic(
|
||||
'FDB159DB-036C-49E3-B3DB-6325AC750806',
|
||||
Characteristic.READ | Characteristic.WRITE | Characteristic.NOTIFY,
|
||||
Characteristic.READABLE | Characteristic.WRITEABLE,
|
||||
bytes([123])
|
||||
)
|
||||
|
||||
service = Service(
|
||||
'3A657F47-D34F-46B3-B1EC-698E29B6B829',
|
||||
[characteristic]
|
||||
)
|
||||
server.add_service(service)
|
||||
|
||||
assert str(server.gatt_server) == """Service(handle=0x0001, end=0x0005, uuid=UUID-16:1800 (Generic Access))
|
||||
CharacteristicDeclaration(handle=0x0002, value_handle=0x0003, uuid=UUID-16:2A00 (Device Name), properties=READ)
|
||||
Characteristic(handle=0x0003, end=0x0003, uuid=UUID-16:2A00 (Device Name), properties=READ)
|
||||
CharacteristicDeclaration(handle=0x0004, value_handle=0x0005, uuid=UUID-16:2A01 (Appearance), properties=READ)
|
||||
Characteristic(handle=0x0005, end=0x0005, uuid=UUID-16:2A01 (Appearance), properties=READ)
|
||||
Service(handle=0x0006, end=0x0009, uuid=3A657F47-D34F-46B3-B1EC-698E29B6B829)
|
||||
CharacteristicDeclaration(handle=0x0007, value_handle=0x0008, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, properties=READ,WRITE,NOTIFY)
|
||||
Characteristic(handle=0x0008, end=0x0009, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, properties=READ,WRITE,NOTIFY)
|
||||
Descriptor(handle=0x0009, type=UUID-16:2902 (Client Characteristic Configuration), value=0000)"""
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
async def async_main():
|
||||
await test_read_write()
|
||||
|
||||
Reference in New Issue
Block a user