bumble 0.0.193__py3-none-any.whl → 0.0.195__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
@@ -16,22 +16,21 @@
16
16
  # Imports
17
17
  # -----------------------------------------------------------------------------
18
18
  from __future__ import annotations
19
- from enum import IntEnum
20
- import copy
21
- import functools
22
- import json
23
19
  import asyncio
24
- import logging
25
- import secrets
26
- import sys
20
+ from collections.abc import Iterable
27
21
  from contextlib import (
28
22
  asynccontextmanager,
29
23
  AsyncExitStack,
30
24
  closing,
31
- AbstractAsyncContextManager,
32
25
  )
26
+ import copy
33
27
  from dataclasses import dataclass, field
34
- from collections.abc import Iterable
28
+ from enum import Enum, IntEnum
29
+ import functools
30
+ import json
31
+ import logging
32
+ import secrets
33
+ import sys
35
34
  from typing import (
36
35
  Any,
37
36
  Callable,
@@ -81,6 +80,7 @@ from .hci import (
81
80
  HCI_MITM_REQUIRED_GENERAL_BONDING_AUTHENTICATION_REQUIREMENTS,
82
81
  HCI_MITM_REQUIRED_NO_BONDING_AUTHENTICATION_REQUIREMENTS,
83
82
  HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY,
83
+ HCI_OPERATION_CANCELLED_BY_HOST_ERROR,
84
84
  HCI_R2_PAGE_SCAN_REPETITION_MODE,
85
85
  HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
86
86
  HCI_SUCCESS,
@@ -102,11 +102,16 @@ from .hci import (
102
102
  HCI_LE_Accept_CIS_Request_Command,
103
103
  HCI_LE_Add_Device_To_Resolving_List_Command,
104
104
  HCI_LE_Advertising_Report_Event,
105
+ HCI_LE_BIGInfo_Advertising_Report_Event,
105
106
  HCI_LE_Clear_Resolving_List_Command,
106
107
  HCI_LE_Connection_Update_Command,
107
108
  HCI_LE_Create_Connection_Cancel_Command,
108
109
  HCI_LE_Create_Connection_Command,
109
110
  HCI_LE_Create_CIS_Command,
111
+ HCI_LE_Periodic_Advertising_Create_Sync_Command,
112
+ HCI_LE_Periodic_Advertising_Create_Sync_Cancel_Command,
113
+ HCI_LE_Periodic_Advertising_Report_Event,
114
+ HCI_LE_Periodic_Advertising_Terminate_Sync_Command,
110
115
  HCI_LE_Enable_Encryption_Command,
111
116
  HCI_LE_Extended_Advertising_Report_Event,
112
117
  HCI_LE_Extended_Create_Connection_Command,
@@ -248,6 +253,8 @@ DEVICE_DEFAULT_L2CAP_COC_MAX_CREDITS = l2cap.L2CAP_LE_CREDIT_BASED_CONN
248
253
  DEVICE_DEFAULT_ADVERTISING_TX_POWER = (
249
254
  HCI_LE_Set_Extended_Advertising_Parameters_Command.TX_POWER_NO_PREFERENCE
250
255
  )
256
+ DEVICE_DEFAULT_PERIODIC_ADVERTISING_SYNC_SKIP = 0
257
+ DEVICE_DEFAULT_PERIODIC_ADVERTISING_SYNC_TIMEOUT = 5.0
251
258
 
252
259
  # fmt: on
253
260
  # pylint: enable=line-too-long
@@ -552,6 +559,70 @@ class AdvertisingEventProperties:
552
559
  )
553
560
 
554
561
 
562
+ # -----------------------------------------------------------------------------
563
+ @dataclass
564
+ class PeriodicAdvertisement:
565
+ address: Address
566
+ sid: int
567
+ tx_power: int = (
568
+ HCI_LE_Periodic_Advertising_Report_Event.TX_POWER_INFORMATION_NOT_AVAILABLE
569
+ )
570
+ rssi: int = HCI_LE_Periodic_Advertising_Report_Event.RSSI_NOT_AVAILABLE
571
+ is_truncated: bool = False
572
+ data_bytes: bytes = b''
573
+
574
+ # Constants
575
+ TX_POWER_NOT_AVAILABLE: ClassVar[int] = (
576
+ HCI_LE_Periodic_Advertising_Report_Event.TX_POWER_INFORMATION_NOT_AVAILABLE
577
+ )
578
+ RSSI_NOT_AVAILABLE: ClassVar[int] = (
579
+ HCI_LE_Periodic_Advertising_Report_Event.RSSI_NOT_AVAILABLE
580
+ )
581
+
582
+ def __post_init__(self) -> None:
583
+ self.data = (
584
+ None if self.is_truncated else AdvertisingData.from_bytes(self.data_bytes)
585
+ )
586
+
587
+
588
+ # -----------------------------------------------------------------------------
589
+ @dataclass
590
+ class BIGInfoAdvertisement:
591
+ address: Address
592
+ sid: int
593
+ num_bis: int
594
+ nse: int
595
+ iso_interval: int
596
+ bn: int
597
+ pto: int
598
+ irc: int
599
+ max_pdu: int
600
+ sdu_interval: int
601
+ max_sdu: int
602
+ phy: Phy
603
+ framed: bool
604
+ encrypted: bool
605
+
606
+ @classmethod
607
+ def from_report(cls, address: Address, sid: int, report) -> Self:
608
+ return cls(
609
+ address,
610
+ sid,
611
+ report.num_bis,
612
+ report.nse,
613
+ report.iso_interval,
614
+ report.bn,
615
+ report.pto,
616
+ report.irc,
617
+ report.max_pdu,
618
+ report.sdu_interval,
619
+ report.max_sdu,
620
+ Phy(report.phy),
621
+ report.framing != 0,
622
+ report.encryption != 0,
623
+ )
624
+
625
+
555
626
  # -----------------------------------------------------------------------------
556
627
  # TODO: replace with typing.TypeAlias when the code base is all Python >= 3.10
557
628
  AdvertisingChannelMap = HCI_LE_Set_Extended_Advertising_Parameters_Command.ChannelMap
@@ -795,6 +866,201 @@ class AdvertisingSet(EventEmitter):
795
866
  self.emit('termination', status)
796
867
 
797
868
 
869
+ # -----------------------------------------------------------------------------
870
+ class PeriodicAdvertisingSync(EventEmitter):
871
+ class State(Enum):
872
+ INIT = 0
873
+ PENDING = 1
874
+ ESTABLISHED = 2
875
+ CANCELLED = 3
876
+ ERROR = 4
877
+ LOST = 5
878
+ TERMINATED = 6
879
+
880
+ _state: State
881
+ sync_handle: Optional[int]
882
+ advertiser_address: Address
883
+ sid: int
884
+ skip: int
885
+ sync_timeout: float # Sync timeout, in seconds
886
+ filter_duplicates: bool
887
+ status: int
888
+ advertiser_phy: int
889
+ periodic_advertising_interval: int
890
+ advertiser_clock_accuracy: int
891
+
892
+ def __init__(
893
+ self,
894
+ device: Device,
895
+ advertiser_address: Address,
896
+ sid: int,
897
+ skip: int,
898
+ sync_timeout: float,
899
+ filter_duplicates: bool,
900
+ ) -> None:
901
+ super().__init__()
902
+ self._state = self.State.INIT
903
+ self.sync_handle = None
904
+ self.device = device
905
+ self.advertiser_address = advertiser_address
906
+ self.sid = sid
907
+ self.skip = skip
908
+ self.sync_timeout = sync_timeout
909
+ self.filter_duplicates = filter_duplicates
910
+ self.status = HCI_SUCCESS
911
+ self.advertiser_phy = 0
912
+ self.periodic_advertising_interval = 0
913
+ self.advertiser_clock_accuracy = 0
914
+ self.data_accumulator = b''
915
+
916
+ @property
917
+ def state(self) -> State:
918
+ return self._state
919
+
920
+ @state.setter
921
+ def state(self, state: State) -> None:
922
+ logger.debug(f'{self} -> {state.name}')
923
+ self._state = state
924
+ self.emit('state_change')
925
+
926
+ async def establish(self) -> None:
927
+ if self.state != self.State.INIT:
928
+ raise InvalidStateError('sync not in init state')
929
+
930
+ options = HCI_LE_Periodic_Advertising_Create_Sync_Command.Options(0)
931
+ if self.filter_duplicates:
932
+ options |= (
933
+ HCI_LE_Periodic_Advertising_Create_Sync_Command.Options.DUPLICATE_FILTERING_INITIALLY_ENABLED
934
+ )
935
+
936
+ response = await self.device.send_command(
937
+ HCI_LE_Periodic_Advertising_Create_Sync_Command(
938
+ options=options,
939
+ advertising_sid=self.sid,
940
+ advertiser_address_type=self.advertiser_address.address_type,
941
+ advertiser_address=self.advertiser_address,
942
+ skip=self.skip,
943
+ sync_timeout=int(self.sync_timeout * 100),
944
+ sync_cte_type=0,
945
+ )
946
+ )
947
+ if response.status != HCI_Command_Status_Event.PENDING:
948
+ raise HCI_StatusError(response)
949
+
950
+ self.state = self.State.PENDING
951
+
952
+ async def terminate(self) -> None:
953
+ if self.state in (self.State.INIT, self.State.CANCELLED, self.State.TERMINATED):
954
+ return
955
+
956
+ if self.state == self.State.PENDING:
957
+ self.state = self.State.CANCELLED
958
+ response = await self.device.send_command(
959
+ HCI_LE_Periodic_Advertising_Create_Sync_Cancel_Command(),
960
+ )
961
+ if response.status == HCI_SUCCESS:
962
+ if self in self.device.periodic_advertising_syncs:
963
+ self.device.periodic_advertising_syncs.remove(self)
964
+ return
965
+
966
+ if self.state in (self.State.ESTABLISHED, self.State.ERROR, self.State.LOST):
967
+ self.state = self.State.TERMINATED
968
+ await self.device.send_command(
969
+ HCI_LE_Periodic_Advertising_Terminate_Sync_Command(
970
+ sync_handle=self.sync_handle
971
+ )
972
+ )
973
+ self.device.periodic_advertising_syncs.remove(self)
974
+
975
+ def on_establishment(
976
+ self,
977
+ status,
978
+ sync_handle,
979
+ advertiser_phy,
980
+ periodic_advertising_interval,
981
+ advertiser_clock_accuracy,
982
+ ) -> None:
983
+ self.status = status
984
+
985
+ if self.state == self.State.CANCELLED:
986
+ # Somehow, we receive an established event after trying to cancel, most
987
+ # likely because the cancel command was sent too late, when the sync was
988
+ # already established, but before the established event was sent.
989
+ # We need to automatically terminate.
990
+ logger.debug(
991
+ "received established event for cancelled sync, will terminate"
992
+ )
993
+ self.state = self.State.ESTABLISHED
994
+ AsyncRunner.spawn(self.terminate())
995
+ return
996
+
997
+ if status == HCI_SUCCESS:
998
+ self.sync_handle = sync_handle
999
+ self.advertiser_phy = advertiser_phy
1000
+ self.periodic_advertising_interval = periodic_advertising_interval
1001
+ self.advertiser_clock_accuracy = advertiser_clock_accuracy
1002
+ self.state = self.State.ESTABLISHED
1003
+ self.emit('establishment')
1004
+ return
1005
+
1006
+ # We don't need to keep a reference anymore
1007
+ if self in self.device.periodic_advertising_syncs:
1008
+ self.device.periodic_advertising_syncs.remove(self)
1009
+
1010
+ if status == HCI_OPERATION_CANCELLED_BY_HOST_ERROR:
1011
+ self.state = self.State.CANCELLED
1012
+ self.emit('cancellation')
1013
+ return
1014
+
1015
+ self.state = self.State.ERROR
1016
+ self.emit('error')
1017
+
1018
+ def on_loss(self):
1019
+ self.state = self.State.LOST
1020
+ self.emit('loss')
1021
+
1022
+ def on_periodic_advertising_report(self, report) -> None:
1023
+ self.data_accumulator += report.data
1024
+ if (
1025
+ report.data_status
1026
+ == HCI_LE_Periodic_Advertising_Report_Event.DataStatus.DATA_INCOMPLETE_MORE_TO_COME
1027
+ ):
1028
+ return
1029
+
1030
+ self.emit(
1031
+ 'periodic_advertisement',
1032
+ PeriodicAdvertisement(
1033
+ self.advertiser_address,
1034
+ self.sid,
1035
+ report.tx_power,
1036
+ report.rssi,
1037
+ is_truncated=(
1038
+ report.data_status
1039
+ == HCI_LE_Periodic_Advertising_Report_Event.DataStatus.DATA_INCOMPLETE_TRUNCATED_NO_MORE_TO_COME
1040
+ ),
1041
+ data_bytes=self.data_accumulator,
1042
+ ),
1043
+ )
1044
+ self.data_accumulator = b''
1045
+
1046
+ def on_biginfo_advertising_report(self, report) -> None:
1047
+ self.emit(
1048
+ 'biginfo_advertisement',
1049
+ BIGInfoAdvertisement.from_report(self.advertiser_address, self.sid, report),
1050
+ )
1051
+
1052
+ def __str__(self) -> str:
1053
+ return (
1054
+ 'PeriodicAdvertisingSync('
1055
+ f'state={self.state.name}, '
1056
+ f'sync_handle={self.sync_handle}, '
1057
+ f'sid={self.sid}, '
1058
+ f'skip={self.skip}, '
1059
+ f'filter_duplicates={self.filter_duplicates}'
1060
+ ')'
1061
+ )
1062
+
1063
+
798
1064
  # -----------------------------------------------------------------------------
799
1065
  class LePhyOptions:
800
1066
  # Coded PHY preference
@@ -1409,6 +1675,20 @@ def try_with_connection_from_address(function):
1409
1675
  return wrapper
1410
1676
 
1411
1677
 
1678
+ # Decorator that converts the first argument from a sync handle to a periodic
1679
+ # advertising sync object
1680
+ def with_periodic_advertising_sync_from_handle(function):
1681
+ @functools.wraps(function)
1682
+ def wrapper(self, sync_handle, *args, **kwargs):
1683
+ if (sync := self.lookup_periodic_advertising_sync(sync_handle)) is None:
1684
+ raise ValueError(
1685
+ f'no periodic advertising sync for handle: 0x{sync_handle:04x}'
1686
+ )
1687
+ return function(self, sync, *args, **kwargs)
1688
+
1689
+ return wrapper
1690
+
1691
+
1412
1692
  # Decorator that adds a method to the list of event handlers for host events.
1413
1693
  # This assumes that the method name starts with `on_`
1414
1694
  def host_event_handler(function):
@@ -1439,6 +1719,7 @@ class Device(CompositeEventEmitter):
1439
1719
  Address, List[asyncio.Future[Union[Connection, Tuple[Address, int, int]]]]
1440
1720
  ]
1441
1721
  advertisement_accumulators: Dict[Address, AdvertisementDataAccumulator]
1722
+ periodic_advertising_syncs: List[PeriodicAdvertisingSync]
1442
1723
  config: DeviceConfiguration
1443
1724
  legacy_advertiser: Optional[LegacyAdvertiser]
1444
1725
  sco_links: Dict[int, ScoLink]
@@ -1524,6 +1805,7 @@ class Device(CompositeEventEmitter):
1524
1805
  [l2cap.L2CAP_Information_Request.EXTENDED_FEATURE_FIXED_CHANNELS]
1525
1806
  )
1526
1807
  self.advertisement_accumulators = {} # Accumulators, by address
1808
+ self.periodic_advertising_syncs = []
1527
1809
  self.scanning = False
1528
1810
  self.scanning_is_passive = False
1529
1811
  self.discovering = False
@@ -1574,6 +1856,7 @@ class Device(CompositeEventEmitter):
1574
1856
 
1575
1857
  # Extended advertising.
1576
1858
  self.extended_advertising_sets: Dict[int, AdvertisingSet] = {}
1859
+ self.connecting_extended_advertising_sets: Dict[int, AdvertisingSet] = {}
1577
1860
 
1578
1861
  # Legacy advertising.
1579
1862
  # The advertising and scan response data, as well as the advertising interval
@@ -1706,6 +1989,18 @@ class Device(CompositeEventEmitter):
1706
1989
 
1707
1990
  return None
1708
1991
 
1992
+ def lookup_periodic_advertising_sync(
1993
+ self, sync_handle: int
1994
+ ) -> Optional[PeriodicAdvertisingSync]:
1995
+ return next(
1996
+ (
1997
+ sync
1998
+ for sync in self.periodic_advertising_syncs
1999
+ if sync.sync_handle == sync_handle
2000
+ ),
2001
+ None,
2002
+ )
2003
+
1709
2004
  @deprecated("Please use create_l2cap_server()")
1710
2005
  def register_l2cap_server(self, psm, server) -> int:
1711
2006
  return self.l2cap_channel_manager.register_server(psm, server)
@@ -2368,6 +2663,116 @@ class Device(CompositeEventEmitter):
2368
2663
  if advertisement := accumulator.update(report):
2369
2664
  self.emit('advertisement', advertisement)
2370
2665
 
2666
+ async def create_periodic_advertising_sync(
2667
+ self,
2668
+ advertiser_address: Address,
2669
+ sid: int,
2670
+ skip: int = DEVICE_DEFAULT_PERIODIC_ADVERTISING_SYNC_SKIP,
2671
+ sync_timeout: float = DEVICE_DEFAULT_PERIODIC_ADVERTISING_SYNC_TIMEOUT,
2672
+ filter_duplicates: bool = False,
2673
+ ) -> PeriodicAdvertisingSync:
2674
+ # Check that there isn't already an equivalent entry
2675
+ if any(
2676
+ sync.advertiser_address == advertiser_address and sync.sid == sid
2677
+ for sync in self.periodic_advertising_syncs
2678
+ ):
2679
+ raise ValueError("equivalent entry already created")
2680
+
2681
+ # Create a new entry
2682
+ sync = PeriodicAdvertisingSync(
2683
+ device=self,
2684
+ advertiser_address=advertiser_address,
2685
+ sid=sid,
2686
+ skip=skip,
2687
+ sync_timeout=sync_timeout,
2688
+ filter_duplicates=filter_duplicates,
2689
+ )
2690
+
2691
+ self.periodic_advertising_syncs.append(sync)
2692
+
2693
+ # Check if any sync should be started
2694
+ await self._update_periodic_advertising_syncs()
2695
+
2696
+ return sync
2697
+
2698
+ async def _update_periodic_advertising_syncs(self) -> None:
2699
+ # Check if there's already a pending sync
2700
+ if any(
2701
+ sync.state == PeriodicAdvertisingSync.State.PENDING
2702
+ for sync in self.periodic_advertising_syncs
2703
+ ):
2704
+ logger.debug("at least one sync pending, nothing to update yet")
2705
+ return
2706
+
2707
+ # Start the next sync that's waiting to be started
2708
+ if ready := next(
2709
+ (
2710
+ sync
2711
+ for sync in self.periodic_advertising_syncs
2712
+ if sync.state == PeriodicAdvertisingSync.State.INIT
2713
+ ),
2714
+ None,
2715
+ ):
2716
+ await ready.establish()
2717
+ return
2718
+
2719
+ @host_event_handler
2720
+ def on_periodic_advertising_sync_establishment(
2721
+ self,
2722
+ status: int,
2723
+ sync_handle: int,
2724
+ advertising_sid: int,
2725
+ advertiser_address: Address,
2726
+ advertiser_phy: int,
2727
+ periodic_advertising_interval: int,
2728
+ advertiser_clock_accuracy: int,
2729
+ ) -> None:
2730
+ for periodic_advertising_sync in self.periodic_advertising_syncs:
2731
+ if (
2732
+ periodic_advertising_sync.advertiser_address == advertiser_address
2733
+ and periodic_advertising_sync.sid == advertising_sid
2734
+ ):
2735
+ periodic_advertising_sync.on_establishment(
2736
+ status,
2737
+ sync_handle,
2738
+ advertiser_phy,
2739
+ periodic_advertising_interval,
2740
+ advertiser_clock_accuracy,
2741
+ )
2742
+
2743
+ AsyncRunner.spawn(self._update_periodic_advertising_syncs())
2744
+
2745
+ return
2746
+
2747
+ logger.warning(
2748
+ "periodic advertising sync establishment for unknown address/sid"
2749
+ )
2750
+
2751
+ @host_event_handler
2752
+ @with_periodic_advertising_sync_from_handle
2753
+ def on_periodic_advertising_sync_loss(
2754
+ self, periodic_advertising_sync: PeriodicAdvertisingSync
2755
+ ):
2756
+ periodic_advertising_sync.on_loss()
2757
+
2758
+ @host_event_handler
2759
+ @with_periodic_advertising_sync_from_handle
2760
+ def on_periodic_advertising_report(
2761
+ self,
2762
+ periodic_advertising_sync: PeriodicAdvertisingSync,
2763
+ report: HCI_LE_Periodic_Advertising_Report_Event,
2764
+ ):
2765
+ periodic_advertising_sync.on_periodic_advertising_report(report)
2766
+
2767
+ @host_event_handler
2768
+ @with_periodic_advertising_sync_from_handle
2769
+ def on_biginfo_advertising_report(
2770
+ self,
2771
+ periodic_advertising_sync: PeriodicAdvertisingSync,
2772
+ report: HCI_LE_BIGInfo_Advertising_Report_Event,
2773
+ ):
2774
+ periodic_advertising_sync.on_biginfo_advertising_report(report)
2775
+
2371
2776
  async def start_discovery(self, auto_restart: bool = True) -> None:
2372
2777
  await self.send_command(
2373
2778
  HCI_Write_Inquiry_Mode_Command(inquiry_mode=HCI_EXTENDED_INQUIRY_MODE),
@@ -3605,14 +4010,28 @@ class Device(CompositeEventEmitter):
3605
4010
  )
3606
4011
  return
3607
4012
 
3608
- if not (connection := self.lookup_connection(connection_handle)):
3609
- logger.warning(f'no connection for handle 0x{connection_handle:04x}')
4013
+ if connection := self.lookup_connection(connection_handle):
4014
+ # We have already received the connection complete event.
4015
+ self._complete_le_extended_advertising_connection(
4016
+ connection, advertising_set
4017
+ )
3610
4018
  return
3611
4019
 
4020
+ # Associate the connection handle with the advertising set, the connection
4021
+ # will complete later.
4022
+ logger.debug(
4023
+ f'the connection with handle {connection_handle:04X} will complete later'
4024
+ )
4025
+ self.connecting_extended_advertising_sets[connection_handle] = advertising_set
4026
+
4027
+ def _complete_le_extended_advertising_connection(
4028
+ self, connection: Connection, advertising_set: AdvertisingSet
4029
+ ) -> None:
3612
4030
  # Update the connection address.
3613
4031
  connection.self_address = (
3614
4032
  advertising_set.random_address
3615
- if advertising_set.advertising_parameters.own_address_type
4033
+ if advertising_set.random_address is not None
4034
+ and advertising_set.advertising_parameters.own_address_type
3616
4035
  in (OwnAddressType.RANDOM, OwnAddressType.RESOLVABLE_OR_RANDOM)
3617
4036
  else self.public_address
3618
4037
  )
@@ -3743,6 +4162,16 @@ class Device(CompositeEventEmitter):
3743
4162
  if role == HCI_CENTRAL_ROLE or not self.supports_le_extended_advertising:
3744
4163
  # We can emit now, we have all the info we need
3745
4164
  self._emit_le_connection(connection)
4165
+ return
4166
+
4167
+ if role == HCI_PERIPHERAL_ROLE and self.supports_le_extended_advertising:
4168
+ if advertising_set := self.connecting_extended_advertising_sets.pop(
4169
+ connection_handle, None
4170
+ ):
4171
+ # We have already received the advertising set termination event.
4172
+ self._complete_le_extended_advertising_connection(
4173
+ connection, advertising_set
4174
+ )
3746
4175
 
3747
4176
  @host_event_handler
3748
4177
  def on_connection_failure(self, transport, peer_address, error_code):