bumble 0.0.192__py3-none-any.whl → 0.0.194__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
bumble/device.py CHANGED
@@ -17,12 +17,19 @@
17
17
  # -----------------------------------------------------------------------------
18
18
  from __future__ import annotations
19
19
  from enum import IntEnum
20
+ import copy
20
21
  import functools
21
22
  import json
22
23
  import asyncio
23
24
  import logging
24
25
  import secrets
25
- from contextlib import asynccontextmanager, AsyncExitStack, closing
26
+ import sys
27
+ from contextlib import (
28
+ asynccontextmanager,
29
+ AsyncExitStack,
30
+ closing,
31
+ AbstractAsyncContextManager,
32
+ )
26
33
  from dataclasses import dataclass, field
27
34
  from collections.abc import Iterable
28
35
  from typing import (
@@ -40,6 +47,7 @@ from typing import (
40
47
  overload,
41
48
  TYPE_CHECKING,
42
49
  )
50
+ from typing_extensions import Self
43
51
 
44
52
  from pyee import EventEmitter
45
53
 
@@ -959,8 +967,9 @@ class ScoLink(CompositeEventEmitter):
959
967
  acl_connection: Connection
960
968
  handle: int
961
969
  link_type: int
970
+ sink: Optional[Callable[[HCI_SynchronousDataPacket], Any]] = None
962
971
 
963
- def __post_init__(self):
972
+ def __post_init__(self) -> None:
964
973
  super().__init__()
965
974
 
966
975
  async def disconnect(
@@ -982,8 +991,9 @@ class CisLink(CompositeEventEmitter):
982
991
  cis_id: int # CIS ID assigned by Central device
983
992
  cig_id: int # CIG ID assigned by Central device
984
993
  state: State = State.PENDING
994
+ sink: Optional[Callable[[HCI_IsoDataPacket], Any]] = None
985
995
 
986
- def __post_init__(self):
996
+ def __post_init__(self) -> None:
987
997
  super().__init__()
988
998
 
989
999
  async def disconnect(
@@ -1252,75 +1262,47 @@ class Connection(CompositeEventEmitter):
1252
1262
 
1253
1263
 
1254
1264
  # -----------------------------------------------------------------------------
1265
+ @dataclass
1255
1266
  class DeviceConfiguration:
1256
- def __init__(self) -> None:
1257
- # Setup defaults
1258
- self.name = DEVICE_DEFAULT_NAME
1259
- self.address = Address(DEVICE_DEFAULT_ADDRESS)
1260
- self.class_of_device = DEVICE_DEFAULT_CLASS_OF_DEVICE
1261
- self.scan_response_data = DEVICE_DEFAULT_SCAN_RESPONSE_DATA
1262
- self.advertising_interval_min = DEVICE_DEFAULT_ADVERTISING_INTERVAL
1263
- self.advertising_interval_max = DEVICE_DEFAULT_ADVERTISING_INTERVAL
1264
- self.le_enabled = True
1265
- # LE host enable 2nd parameter
1266
- self.le_simultaneous_enabled = False
1267
- self.classic_enabled = False
1268
- self.classic_sc_enabled = True
1269
- self.classic_ssp_enabled = True
1270
- self.classic_smp_enabled = True
1271
- self.classic_accept_any = True
1272
- self.connectable = True
1273
- self.discoverable = True
1274
- self.advertising_data = bytes(
1275
- AdvertisingData(
1276
- [(AdvertisingData.COMPLETE_LOCAL_NAME, bytes(self.name, 'utf-8'))]
1277
- )
1267
+ # Setup defaults
1268
+ name: str = DEVICE_DEFAULT_NAME
1269
+ address: Address = Address(DEVICE_DEFAULT_ADDRESS)
1270
+ class_of_device: int = DEVICE_DEFAULT_CLASS_OF_DEVICE
1271
+ scan_response_data: bytes = DEVICE_DEFAULT_SCAN_RESPONSE_DATA
1272
+ advertising_interval_min: int = DEVICE_DEFAULT_ADVERTISING_INTERVAL
1273
+ advertising_interval_max: int = DEVICE_DEFAULT_ADVERTISING_INTERVAL
1274
+ le_enabled: bool = True
1275
+ # LE host enable 2nd parameter
1276
+ le_simultaneous_enabled: bool = False
1277
+ classic_enabled: bool = False
1278
+ classic_sc_enabled: bool = True
1279
+ classic_ssp_enabled: bool = True
1280
+ classic_smp_enabled: bool = True
1281
+ classic_accept_any: bool = True
1282
+ connectable: bool = True
1283
+ discoverable: bool = True
1284
+ advertising_data: bytes = bytes(
1285
+ AdvertisingData(
1286
+ [(AdvertisingData.COMPLETE_LOCAL_NAME, bytes(DEVICE_DEFAULT_NAME, 'utf-8'))]
1278
1287
  )
1279
- self.irk = bytes(16) # This really must be changed for any level of security
1280
- self.keystore = None
1288
+ )
1289
+ irk: bytes = bytes(16) # This really must be changed for any level of security
1290
+ keystore: Optional[str] = None
1291
+ address_resolution_offload: bool = False
1292
+ cis_enabled: bool = False
1293
+
1294
+ def __post_init__(self) -> None:
1281
1295
  self.gatt_services: List[Dict[str, Any]] = []
1282
- self.address_resolution_offload = False
1283
- self.cis_enabled = False
1284
1296
 
1285
1297
  def load_from_dict(self, config: Dict[str, Any]) -> None:
1298
+ config = copy.deepcopy(config)
1299
+
1286
1300
  # Load simple properties
1287
- self.name = config.get('name', self.name)
1288
- if address := config.get('address', None):
1301
+ if address := config.pop('address', None):
1289
1302
  self.address = Address(address)
1290
- self.class_of_device = config.get('class_of_device', self.class_of_device)
1291
- self.advertising_interval_min = config.get(
1292
- 'advertising_interval', self.advertising_interval_min
1293
- )
1294
- self.advertising_interval_max = self.advertising_interval_min
1295
- self.keystore = config.get('keystore')
1296
- self.le_enabled = config.get('le_enabled', self.le_enabled)
1297
- self.le_simultaneous_enabled = config.get(
1298
- 'le_simultaneous_enabled', self.le_simultaneous_enabled
1299
- )
1300
- self.classic_enabled = config.get('classic_enabled', self.classic_enabled)
1301
- self.classic_sc_enabled = config.get(
1302
- 'classic_sc_enabled', self.classic_sc_enabled
1303
- )
1304
- self.classic_ssp_enabled = config.get(
1305
- 'classic_ssp_enabled', self.classic_ssp_enabled
1306
- )
1307
- self.classic_smp_enabled = config.get(
1308
- 'classic_smp_enabled', self.classic_smp_enabled
1309
- )
1310
- self.classic_accept_any = config.get(
1311
- 'classic_accept_any', self.classic_accept_any
1312
- )
1313
- self.connectable = config.get('connectable', self.connectable)
1314
- self.discoverable = config.get('discoverable', self.discoverable)
1315
- self.gatt_services = config.get('gatt_services', self.gatt_services)
1316
- self.address_resolution_offload = config.get(
1317
- 'address_resolution_offload', self.address_resolution_offload
1318
- )
1319
- self.cis_enabled = config.get('cis_enabled', self.cis_enabled)
1320
1303
 
1321
1304
  # Load or synthesize an IRK
1322
- irk = config.get('irk')
1323
- if irk:
1305
+ if irk := config.pop('irk', None):
1324
1306
  self.irk = bytes.fromhex(irk)
1325
1307
  elif self.address != Address(DEVICE_DEFAULT_ADDRESS):
1326
1308
  # Construct an IRK from the address bytes
@@ -1332,21 +1314,53 @@ class DeviceConfiguration:
1332
1314
  # Fallback - when both IRK and address are not set, randomly generate an IRK.
1333
1315
  self.irk = secrets.token_bytes(16)
1334
1316
 
1317
+ if (name := config.pop('name', None)) is not None:
1318
+ self.name = name
1319
+
1335
1320
  # Load advertising data
1336
- advertising_data = config.get('advertising_data')
1337
- if advertising_data:
1321
+ if advertising_data := config.pop('advertising_data', None):
1338
1322
  self.advertising_data = bytes.fromhex(advertising_data)
1339
- elif config.get('name') is not None:
1323
+ elif name is not None:
1340
1324
  self.advertising_data = bytes(
1341
1325
  AdvertisingData(
1342
1326
  [(AdvertisingData.COMPLETE_LOCAL_NAME, bytes(self.name, 'utf-8'))]
1343
1327
  )
1344
1328
  )
1345
1329
 
1346
- def load_from_file(self, filename):
1330
+ # Load advertising interval (for backward compatibility)
1331
+ if advertising_interval := config.pop('advertising_interval', None):
1332
+ self.advertising_interval_min = advertising_interval
1333
+ self.advertising_interval_max = advertising_interval
1334
+ if (
1335
+ 'advertising_interval_max' in config
1336
+ or 'advertising_interval_min' in config
1337
+ ):
1338
+ logger.warning(
1339
+ 'Trying to set both advertising_interval and '
1340
+ 'advertising_interval_min/max, advertising_interval will be'
1341
+ 'ignored.'
1342
+ )
1343
+
1344
+ # Load data in primitive types.
1345
+ for key, value in config.items():
1346
+ setattr(self, key, value)
1347
+
1348
+ def load_from_file(self, filename: str) -> None:
1347
1349
  with open(filename, 'r', encoding='utf-8') as file:
1348
1350
  self.load_from_dict(json.load(file))
1349
1351
 
1352
+ @classmethod
1353
+ def from_file(cls: Type[Self], filename: str) -> Self:
1354
+ config = cls()
1355
+ config.load_from_file(filename)
1356
+ return config
1357
+
1358
+ @classmethod
1359
+ def from_dict(cls: Type[Self], config: Dict[str, Any]) -> Self:
1360
+ device_config = cls()
1361
+ device_config.load_from_dict(config)
1362
+ return device_config
1363
+
1350
1364
 
1351
1365
  # -----------------------------------------------------------------------------
1352
1366
  # Decorators used with the following Device class
@@ -1470,8 +1484,7 @@ class Device(CompositeEventEmitter):
1470
1484
 
1471
1485
  @classmethod
1472
1486
  def from_config_file(cls, filename: str) -> Device:
1473
- config = DeviceConfiguration()
1474
- config.load_from_file(filename)
1487
+ config = DeviceConfiguration.from_file(filename)
1475
1488
  return cls(config=config)
1476
1489
 
1477
1490
  @classmethod
@@ -1488,8 +1501,7 @@ class Device(CompositeEventEmitter):
1488
1501
  def from_config_file_with_hci(
1489
1502
  cls, filename: str, hci_source: TransportSource, hci_sink: TransportSink
1490
1503
  ) -> Device:
1491
- config = DeviceConfiguration()
1492
- config.load_from_file(filename)
1504
+ config = DeviceConfiguration.from_file(filename)
1493
1505
  return cls.from_config_with_hci(config, hci_source, hci_sink)
1494
1506
 
1495
1507
  def __init__(
@@ -1529,6 +1541,12 @@ class Device(CompositeEventEmitter):
1529
1541
  Address.ANY: []
1530
1542
  } # Futures, by BD address OR [Futures] for Address.ANY
1531
1543
 
1544
+ # In Python <= 3.9 + Rust Runtime, asyncio.Lock cannot be properly initiated.
1545
+ if sys.version_info >= (3, 10):
1546
+ self._cis_lock = asyncio.Lock()
1547
+ else:
1548
+ self._cis_lock = AsyncExitStack()
1549
+
1532
1550
  # Own address type cache
1533
1551
  self.connect_own_address_type = None
1534
1552
 
@@ -2184,7 +2202,7 @@ class Device(CompositeEventEmitter):
2184
2202
  # controller.
2185
2203
  await self.send_command(
2186
2204
  HCI_LE_Remove_Advertising_Set_Command(
2187
- advertising_handle=advertising_data
2205
+ advertising_handle=advertising_handle
2188
2206
  ),
2189
2207
  check_result=False,
2190
2208
  )
@@ -3402,49 +3420,71 @@ class Device(CompositeEventEmitter):
3402
3420
  for cis_handle, _ in cis_acl_pairs
3403
3421
  }
3404
3422
 
3405
- @watcher.on(self, 'cis_establishment')
3406
3423
  def on_cis_establishment(cis_link: CisLink) -> None:
3407
3424
  if pending_future := pending_cis_establishments.get(cis_link.handle):
3408
3425
  pending_future.set_result(cis_link)
3409
3426
 
3410
- result = await self.send_command(
3427
+ def on_cis_establishment_failure(cis_handle: int, status: int) -> None:
3428
+ if pending_future := pending_cis_establishments.get(cis_handle):
3429
+ pending_future.set_exception(HCI_Error(status))
3430
+
3431
+ watcher.on(self, 'cis_establishment', on_cis_establishment)
3432
+ watcher.on(self, 'cis_establishment_failure', on_cis_establishment_failure)
3433
+ await self.send_command(
3411
3434
  HCI_LE_Create_CIS_Command(
3412
3435
  cis_connection_handle=[p[0] for p in cis_acl_pairs],
3413
3436
  acl_connection_handle=[p[1] for p in cis_acl_pairs],
3414
3437
  ),
3438
+ check_result=True,
3415
3439
  )
3416
- if result.status != HCI_COMMAND_STATUS_PENDING:
3417
- logger.warning(
3418
- 'HCI_LE_Create_CIS_Command failed: '
3419
- f'{HCI_Constant.error_name(result.status)}'
3420
- )
3421
- raise HCI_StatusError(result)
3422
3440
 
3423
3441
  return await asyncio.gather(*pending_cis_establishments.values())
3424
3442
 
3425
3443
  # [LE only]
3426
3444
  @experimental('Only for testing.')
3427
3445
  async def accept_cis_request(self, handle: int) -> CisLink:
3428
- result = await self.send_command(
3429
- HCI_LE_Accept_CIS_Request_Command(connection_handle=handle),
3430
- )
3431
- if result.status != HCI_COMMAND_STATUS_PENDING:
3432
- logger.warning(
3433
- 'HCI_LE_Accept_CIS_Request_Command failed: '
3434
- f'{HCI_Constant.error_name(result.status)}'
3435
- )
3436
- raise HCI_StatusError(result)
3446
+ """[LE Only] Accepts an incoming CIS request.
3437
3447
 
3438
- pending_cis_establishment = asyncio.get_running_loop().create_future()
3448
+ When the specified CIS handle is already created, this method returns the
3449
+ existed CIS link object immediately.
3439
3450
 
3440
- with closing(EventWatcher()) as watcher:
3451
+ Args:
3452
+ handle: CIS handle to accept.
3441
3453
 
3442
- @watcher.on(self, 'cis_establishment')
3443
- def on_cis_establishment(cis_link: CisLink) -> None:
3444
- if cis_link.handle == handle:
3445
- pending_cis_establishment.set_result(cis_link)
3454
+ Returns:
3455
+ CIS link object on the given handle.
3456
+ """
3457
+ if not (cis_link := self.cis_links.get(handle)):
3458
+ raise InvalidStateError(f'No pending CIS request of handle {handle}')
3459
+
3460
+ # There might be multiple ASE sharing a CIS channel.
3461
+ # If one of them has accepted the request, the others should just leverage it.
3462
+ async with self._cis_lock:
3463
+ if cis_link.state == CisLink.State.ESTABLISHED:
3464
+ return cis_link
3465
+
3466
+ with closing(EventWatcher()) as watcher:
3467
+ pending_establishment = asyncio.get_running_loop().create_future()
3468
+
3469
+ def on_establishment() -> None:
3470
+ pending_establishment.set_result(None)
3471
+
3472
+ def on_establishment_failure(status: int) -> None:
3473
+ pending_establishment.set_exception(HCI_Error(status))
3474
+
3475
+ watcher.on(cis_link, 'establishment', on_establishment)
3476
+ watcher.on(cis_link, 'establishment_failure', on_establishment_failure)
3477
+
3478
+ await self.send_command(
3479
+ HCI_LE_Accept_CIS_Request_Command(connection_handle=handle),
3480
+ check_result=True,
3481
+ )
3482
+
3483
+ await pending_establishment
3484
+ return cis_link
3446
3485
 
3447
- return await pending_cis_establishment
3486
+ # Mypy believes this is reachable when context is an ExitStack.
3487
+ raise InvalidStateError('Unreachable')
3448
3488
 
3449
3489
  # [LE only]
3450
3490
  @experimental('Only for testing.')
@@ -3453,15 +3493,10 @@ class Device(CompositeEventEmitter):
3453
3493
  handle: int,
3454
3494
  reason: int = HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
3455
3495
  ) -> None:
3456
- result = await self.send_command(
3496
+ await self.send_command(
3457
3497
  HCI_LE_Reject_CIS_Request_Command(connection_handle=handle, reason=reason),
3498
+ check_result=True,
3458
3499
  )
3459
- if result.status != HCI_COMMAND_STATUS_PENDING:
3460
- logger.warning(
3461
- 'HCI_LE_Reject_CIS_Request_Command failed: '
3462
- f'{HCI_Constant.error_name(result.status)}'
3463
- )
3464
- raise HCI_StatusError(result)
3465
3500
 
3466
3501
  async def get_remote_le_features(self, connection: Connection) -> LeFeatureMask:
3467
3502
  """[LE Only] Reads remote LE supported features.
@@ -3481,11 +3516,17 @@ class Device(CompositeEventEmitter):
3481
3516
  if handle == connection.handle:
3482
3517
  read_feature_future.set_result(LeFeatureMask(features))
3483
3518
 
3519
+ def on_failure(handle: int, status: int):
3520
+ if handle == connection.handle:
3521
+ read_feature_future.set_exception(HCI_Error(status))
3522
+
3484
3523
  watcher.on(self.host, 'le_remote_features', on_le_remote_features)
3524
+ watcher.on(self.host, 'le_remote_features_failure', on_failure)
3485
3525
  await self.send_command(
3486
3526
  HCI_LE_Read_Remote_Features_Command(
3487
3527
  connection_handle=connection.handle
3488
3528
  ),
3529
+ check_result=True,
3489
3530
  )
3490
3531
  return await read_feature_future
3491
3532
 
@@ -3660,7 +3701,6 @@ class Device(CompositeEventEmitter):
3660
3701
  # We were connected via a legacy advertisement.
3661
3702
  if self.legacy_advertiser:
3662
3703
  own_address_type = self.legacy_advertiser.own_address_type
3663
- self.legacy_advertiser = None
3664
3704
  else:
3665
3705
  # This should not happen, but just in case, pick a default.
3666
3706
  logger.warning("connection without an advertiser")
@@ -3691,15 +3731,14 @@ class Device(CompositeEventEmitter):
3691
3731
  )
3692
3732
  self.connections[connection_handle] = connection
3693
3733
 
3694
- if (
3695
- role == HCI_PERIPHERAL_ROLE
3696
- and self.legacy_advertiser
3697
- and self.legacy_advertiser.auto_restart
3698
- ):
3699
- connection.once(
3700
- 'disconnection',
3701
- lambda _: self.abort_on('flush', self.legacy_advertiser.start()),
3702
- )
3734
+ if role == HCI_PERIPHERAL_ROLE and self.legacy_advertiser:
3735
+ if self.legacy_advertiser.auto_restart:
3736
+ connection.once(
3737
+ 'disconnection',
3738
+ lambda _: self.abort_on('flush', self.legacy_advertiser.start()),
3739
+ )
3740
+ else:
3741
+ self.legacy_advertiser = None
3703
3742
 
3704
3743
  if role == HCI_CENTRAL_ROLE or not self.supports_le_extended_advertising:
3705
3744
  # We can emit now, we have all the info we need
@@ -4107,8 +4146,8 @@ class Device(CompositeEventEmitter):
4107
4146
  @host_event_handler
4108
4147
  @experimental('Only for testing')
4109
4148
  def on_sco_packet(self, sco_handle: int, packet: HCI_SynchronousDataPacket) -> None:
4110
- if sco_link := self.sco_links.get(sco_handle):
4111
- sco_link.emit('pdu', packet)
4149
+ if (sco_link := self.sco_links.get(sco_handle)) and sco_link.sink:
4150
+ sco_link.sink(packet)
4112
4151
 
4113
4152
  # [LE only]
4114
4153
  @host_event_handler
@@ -4164,15 +4203,15 @@ class Device(CompositeEventEmitter):
4164
4203
  def on_cis_establishment_failure(self, cis_handle: int, status: int) -> None:
4165
4204
  logger.debug(f'*** CIS Establishment Failure: cis=[0x{cis_handle:04X}] ***')
4166
4205
  if cis_link := self.cis_links.pop(cis_handle):
4167
- cis_link.emit('establishment_failure')
4206
+ cis_link.emit('establishment_failure', status)
4168
4207
  self.emit('cis_establishment_failure', cis_handle, status)
4169
4208
 
4170
4209
  # [LE only]
4171
4210
  @host_event_handler
4172
4211
  @experimental('Only for testing')
4173
4212
  def on_iso_packet(self, handle: int, packet: HCI_IsoDataPacket) -> None:
4174
- if cis_link := self.cis_links.get(handle):
4175
- cis_link.emit('pdu', packet)
4213
+ if (cis_link := self.cis_links.get(handle)) and cis_link.sink:
4214
+ cis_link.sink(packet)
4176
4215
 
4177
4216
  @host_event_handler
4178
4217
  @with_connection_from_handle
bumble/hci.py CHANGED
@@ -23,7 +23,7 @@ import functools
23
23
  import logging
24
24
  import secrets
25
25
  import struct
26
- from typing import Any, Callable, Dict, Iterable, List, Optional, Type, Union
26
+ from typing import Any, Callable, Dict, Iterable, List, Optional, Type, Union, ClassVar
27
27
 
28
28
  from bumble import crypto
29
29
  from .colors import color
@@ -2003,7 +2003,7 @@ class HCI_Packet:
2003
2003
  Abstract Base class for HCI packets
2004
2004
  '''
2005
2005
 
2006
- hci_packet_type: int
2006
+ hci_packet_type: ClassVar[int]
2007
2007
 
2008
2008
  @staticmethod
2009
2009
  def from_bytes(packet: bytes) -> HCI_Packet:
@@ -6192,12 +6192,23 @@ class HCI_SynchronousDataPacket(HCI_Packet):
6192
6192
 
6193
6193
 
6194
6194
  # -----------------------------------------------------------------------------
6195
+ @dataclasses.dataclass
6195
6196
  class HCI_IsoDataPacket(HCI_Packet):
6196
6197
  '''
6197
6198
  See Bluetooth spec @ 5.4.5 HCI ISO Data Packets
6198
6199
  '''
6199
6200
 
6200
- hci_packet_type = HCI_ISO_DATA_PACKET
6201
+ hci_packet_type: ClassVar[int] = HCI_ISO_DATA_PACKET
6202
+
6203
+ connection_handle: int
6204
+ data_total_length: int
6205
+ iso_sdu_fragment: bytes
6206
+ pb_flag: int
6207
+ ts_flag: int = 0
6208
+ time_stamp: Optional[int] = None
6209
+ packet_sequence_number: Optional[int] = None
6210
+ iso_sdu_length: Optional[int] = None
6211
+ packet_status_flag: Optional[int] = None
6201
6212
 
6202
6213
  @staticmethod
6203
6214
  def from_bytes(packet: bytes) -> HCI_IsoDataPacket:
@@ -6241,28 +6252,6 @@ class HCI_IsoDataPacket(HCI_Packet):
6241
6252
  iso_sdu_fragment=iso_sdu_fragment,
6242
6253
  )
6243
6254
 
6244
- def __init__(
6245
- self,
6246
- connection_handle: int,
6247
- pb_flag: int,
6248
- ts_flag: int,
6249
- data_total_length: int,
6250
- time_stamp: Optional[int],
6251
- packet_sequence_number: Optional[int],
6252
- iso_sdu_length: Optional[int],
6253
- packet_status_flag: Optional[int],
6254
- iso_sdu_fragment: bytes,
6255
- ) -> None:
6256
- self.connection_handle = connection_handle
6257
- self.pb_flag = pb_flag
6258
- self.ts_flag = ts_flag
6259
- self.data_total_length = data_total_length
6260
- self.time_stamp = time_stamp
6261
- self.packet_sequence_number = packet_sequence_number
6262
- self.iso_sdu_length = iso_sdu_length
6263
- self.packet_status_flag = packet_status_flag
6264
- self.iso_sdu_fragment = iso_sdu_fragment
6265
-
6266
6255
  def __bytes__(self) -> bytes:
6267
6256
  return self.to_bytes()
6268
6257