bumble 0.0.190__py3-none-any.whl → 0.0.192__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/_version.py CHANGED
@@ -12,5 +12,5 @@ __version__: str
12
12
  __version_tuple__: VERSION_TUPLE
13
13
  version_tuple: VERSION_TUPLE
14
14
 
15
- __version__ = version = '0.0.190'
16
- __version_tuple__ = version_tuple = (0, 0, 190)
15
+ __version__ = version = '0.0.192'
16
+ __version_tuple__ = version_tuple = (0, 0, 192)
bumble/gatt_client.py CHANGED
@@ -90,6 +90,22 @@ if TYPE_CHECKING:
90
90
  logger = logging.getLogger(__name__)
91
91
 
92
92
 
93
+ # -----------------------------------------------------------------------------
94
+ # Utils
95
+ # -----------------------------------------------------------------------------
96
+
97
+
98
+ def show_services(services: Iterable[ServiceProxy]) -> None:
99
+ for service in services:
100
+ print(color(str(service), 'cyan'))
101
+
102
+ for characteristic in service.characteristics:
103
+ print(color(' ' + str(characteristic), 'magenta'))
104
+
105
+ for descriptor in characteristic.descriptors:
106
+ print(color(' ' + str(descriptor), 'green'))
107
+
108
+
93
109
  # -----------------------------------------------------------------------------
94
110
  # Proxies
95
111
  # -----------------------------------------------------------------------------
bumble/hfp.py CHANGED
@@ -15,6 +15,9 @@
15
15
  # -----------------------------------------------------------------------------
16
16
  # Imports
17
17
  # -----------------------------------------------------------------------------
18
+ from __future__ import annotations
19
+
20
+ import collections
18
21
  import collections.abc
19
22
  import logging
20
23
  import asyncio
@@ -22,16 +25,32 @@ import dataclasses
22
25
  import enum
23
26
  import traceback
24
27
  import pyee
25
- from typing import Dict, List, Union, Set, Any, Optional, Type, TYPE_CHECKING
28
+ import re
29
+ from typing import (
30
+ Dict,
31
+ List,
32
+ Union,
33
+ Set,
34
+ Any,
35
+ Optional,
36
+ Type,
37
+ Tuple,
38
+ ClassVar,
39
+ Iterable,
40
+ TYPE_CHECKING,
41
+ )
26
42
  from typing_extensions import Self
27
43
 
28
44
  from bumble import at
45
+ from bumble import device
29
46
  from bumble import rfcomm
47
+ from bumble import sdp
30
48
  from bumble.colors import color
31
49
  from bumble.core import (
32
50
  ProtocolError,
33
51
  BT_GENERIC_AUDIO_SERVICE,
34
52
  BT_HANDSFREE_SERVICE,
53
+ BT_HEADSET_AUDIO_GATEWAY_SERVICE,
35
54
  BT_L2CAP_PROTOCOL_ID,
36
55
  BT_RFCOMM_PROTOCOL_ID,
37
56
  )
@@ -40,15 +59,6 @@ from bumble.hci import (
40
59
  CodingFormat,
41
60
  CodecID,
42
61
  )
43
- from bumble.sdp import (
44
- DataElement,
45
- ServiceAttribute,
46
- SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
47
- SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
48
- SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
49
- SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
50
- SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID,
51
- )
52
62
 
53
63
 
54
64
  # -----------------------------------------------------------------------------
@@ -329,6 +339,21 @@ class CallInfo:
329
339
  type: Optional[int] = None
330
340
 
331
341
 
342
+ class CmeError(enum.IntEnum):
343
+ """
344
+ CME ERROR codes (partial listed).
345
+
346
+ TS 127 007 - V6.8.0, 9.2.1 General errors
347
+ """
348
+
349
+ PHONE_FAILURE = 0
350
+ OPERATION_NOT_ALLOWED = 3
351
+ OPERATION_NOT_SUPPORTED = 4
352
+ MEMORY_FULL = 20
353
+ INVALID_INDEX = 21
354
+ NOT_FOUND = 22
355
+
356
+
332
357
  # -----------------------------------------------------------------------------
333
358
  # Hands-Free Control Interoperability Requirements
334
359
  # -----------------------------------------------------------------------------
@@ -402,12 +427,21 @@ STATUS_CODES = [
402
427
 
403
428
 
404
429
  @dataclasses.dataclass
405
- class Configuration:
430
+ class HfConfiguration:
406
431
  supported_hf_features: List[HfFeature]
407
432
  supported_hf_indicators: List[HfIndicator]
408
433
  supported_audio_codecs: List[AudioCodec]
409
434
 
410
435
 
436
+ @dataclasses.dataclass
437
+ class AgConfiguration:
438
+ supported_ag_features: Iterable[AgFeature]
439
+ supported_ag_indicators: collections.abc.Sequence[AgIndicatorState]
440
+ supported_hf_indicators: Iterable[HfIndicator]
441
+ supported_ag_call_hold_operations: Iterable[CallHoldOperation]
442
+ supported_audio_codecs: Iterable[AudioCodec]
443
+
444
+
411
445
  class AtResponseType(enum.Enum):
412
446
  """
413
447
  Indicates if a response is expected from an AT command, and if multiple responses are accepted.
@@ -435,18 +469,148 @@ class AtResponse:
435
469
  )
436
470
 
437
471
 
472
+ @dataclasses.dataclass
473
+ class AtCommand:
474
+ class SubCode(str, enum.Enum):
475
+ NONE = ''
476
+ SET = '='
477
+ TEST = '=?'
478
+ READ = '?'
479
+
480
+ code: str
481
+ sub_code: SubCode
482
+ parameters: list
483
+
484
+ _PARSE_PATTERN: ClassVar[re.Pattern] = re.compile(
485
+ r'AT\+(?P<code>[A-Z]+)(?P<sub_code>=\?|=|\?)?(?P<parameters>.*)'
486
+ )
487
+
488
+ @classmethod
489
+ def parse_from(cls: Type[Self], buffer: bytearray) -> Self:
490
+ if not (match := cls._PARSE_PATTERN.fullmatch(buffer.decode())):
491
+ if buffer.startswith(b'ATA'):
492
+ return cls(code='A', sub_code=AtCommand.SubCode.NONE, parameters=[])
493
+ if buffer.startswith(b'ATD'):
494
+ return cls(
495
+ code='D', sub_code=AtCommand.SubCode.NONE, parameters=[buffer[3:]]
496
+ )
497
+ raise HfpProtocolError('Invalid command')
498
+
499
+ parameters = []
500
+ if parameters_text := match.group('parameters'):
501
+ parameters = at.parse_parameters(parameters_text.encode())
502
+
503
+ return cls(
504
+ code=match.group('code'),
505
+ sub_code=AtCommand.SubCode(match.group('sub_code') or ''),
506
+ parameters=parameters,
507
+ )
508
+
509
+
438
510
  @dataclasses.dataclass
439
511
  class AgIndicatorState:
440
- description: str
441
- index: int
512
+ """State wrapper of AG indicator.
513
+
514
+ Attributes:
515
+ indicator: Indicator of this indicator state.
516
+ supported_values: Supported values of this indicator.
517
+ current_status: Current status of this indicator.
518
+ index: (HF only) Index of this indicator.
519
+ enabled: (AG only) Whether this indicator is enabled to report.
520
+ on_test_text: Text message reported in AT+CIND=? of this indicator.
521
+ """
522
+
523
+ indicator: AgIndicator
442
524
  supported_values: Set[int]
443
525
  current_status: int
526
+ index: Optional[int] = None
527
+ enabled: bool = True
528
+
529
+ @property
530
+ def on_test_text(self) -> str:
531
+ min_value = min(self.supported_values)
532
+ max_value = max(self.supported_values)
533
+ if len(self.supported_values) == (max_value - min_value + 1):
534
+ supported_values_text = f'({min_value}-{max_value})'
535
+ else:
536
+ supported_values_text = (
537
+ f'({",".join(str(v) for v in self.supported_values)})'
538
+ )
539
+ return f'(\"{self.indicator.value}\",{supported_values_text})'
540
+
541
+ @classmethod
542
+ def call(cls: Type[Self]) -> Self:
543
+ """Default call indicator state."""
544
+ return cls(
545
+ indicator=AgIndicator.CALL, supported_values={0, 1}, current_status=0
546
+ )
547
+
548
+ @classmethod
549
+ def callsetup(cls: Type[Self]) -> Self:
550
+ """Default callsetup indicator state."""
551
+ return cls(
552
+ indicator=AgIndicator.CALL_SETUP,
553
+ supported_values={0, 1, 2, 3},
554
+ current_status=0,
555
+ )
556
+
557
+ @classmethod
558
+ def callheld(cls: Type[Self]) -> Self:
559
+ """Default call indicator state."""
560
+ return cls(
561
+ indicator=AgIndicator.CALL_HELD,
562
+ supported_values={0, 1, 2},
563
+ current_status=0,
564
+ )
565
+
566
+ @classmethod
567
+ def service(cls: Type[Self]) -> Self:
568
+ """Default service indicator state."""
569
+ return cls(
570
+ indicator=AgIndicator.SERVICE, supported_values={0, 1}, current_status=0
571
+ )
572
+
573
+ @classmethod
574
+ def signal(cls: Type[Self]) -> Self:
575
+ """Default signal indicator state."""
576
+ return cls(
577
+ indicator=AgIndicator.SIGNAL,
578
+ supported_values={0, 1, 2, 3, 4, 5},
579
+ current_status=0,
580
+ )
581
+
582
+ @classmethod
583
+ def roam(cls: Type[Self]) -> Self:
584
+ """Default roam indicator state."""
585
+ return cls(
586
+ indicator=AgIndicator.CALL, supported_values={0, 1}, current_status=0
587
+ )
588
+
589
+ @classmethod
590
+ def battchg(cls: Type[Self]) -> Self:
591
+ """Default battery charge indicator state."""
592
+ return cls(
593
+ indicator=AgIndicator.BATTERY_CHARGE,
594
+ supported_values={0, 1, 2, 3, 4, 5},
595
+ current_status=0,
596
+ )
444
597
 
445
598
 
446
599
  @dataclasses.dataclass
447
600
  class HfIndicatorState:
601
+ """State wrapper of HF indicator.
602
+
603
+ Attributes:
604
+ indicator: Indicator of this indicator state.
605
+ supported: Whether this indicator is supported.
606
+ enabled: Whether this indicator is enabled.
607
+ current_status: Current (last-reported) status value of this indicaotr.
608
+ """
609
+
610
+ indicator: HfIndicator
448
611
  supported: bool = False
449
612
  enabled: bool = False
613
+ current_status: int = 0
450
614
 
451
615
 
452
616
  class HfProtocol(pyee.EventEmitter):
@@ -464,6 +628,9 @@ class HfProtocol(pyee.EventEmitter):
464
628
  ag_indicator: AgIndicator
465
629
  """
466
630
 
631
+ class HfLoopTermination(HfpProtocolError): ...
632
+ """Termination signal for run() loop."""
633
+
467
634
  supported_hf_features: int
468
635
  supported_audio_codecs: List[AudioCodec]
469
636
 
@@ -477,14 +644,14 @@ class HfProtocol(pyee.EventEmitter):
477
644
  command_lock: asyncio.Lock
478
645
  if TYPE_CHECKING:
479
646
  response_queue: asyncio.Queue[AtResponse]
480
- unsolicited_queue: asyncio.Queue[AtResponse]
647
+ unsolicited_queue: asyncio.Queue[Optional[AtResponse]]
481
648
  else:
482
649
  response_queue: asyncio.Queue
483
650
  unsolicited_queue: asyncio.Queue
484
651
  read_buffer: bytearray
485
652
  active_codec: AudioCodec
486
653
 
487
- def __init__(self, dlc: rfcomm.DLC, configuration: Configuration) -> None:
654
+ def __init__(self, dlc: rfcomm.DLC, configuration: HfConfiguration) -> None:
488
655
  super().__init__()
489
656
 
490
657
  # Configure internal state.
@@ -494,13 +661,14 @@ class HfProtocol(pyee.EventEmitter):
494
661
  self.unsolicited_queue = asyncio.Queue()
495
662
  self.read_buffer = bytearray()
496
663
  self.active_codec = AudioCodec.CVSD
664
+ self._slc_initialized = False
497
665
 
498
666
  # Build local features.
499
667
  self.supported_hf_features = sum(configuration.supported_hf_features)
500
668
  self.supported_audio_codecs = configuration.supported_audio_codecs
501
669
 
502
670
  self.hf_indicators = {
503
- indicator: HfIndicatorState()
671
+ indicator: HfIndicatorState(indicator=indicator)
504
672
  for indicator in configuration.supported_hf_indicators
505
673
  }
506
674
 
@@ -511,6 +679,10 @@ class HfProtocol(pyee.EventEmitter):
511
679
 
512
680
  # Bind the AT reader to the RFCOMM channel.
513
681
  self.dlc.sink = self._read_at
682
+ # Stop the run() loop when L2CAP is closed.
683
+ self.dlc.multiplexer.l2cap_channel.on(
684
+ 'close', lambda: self.unsolicited_queue.put_nowait(None)
685
+ )
514
686
 
515
687
  def supports_hf_feature(self, feature: HfFeature) -> bool:
516
688
  return (self.supported_hf_features & feature) != 0
@@ -621,7 +793,7 @@ class HfProtocol(pyee.EventEmitter):
621
793
  # If both the HF and AG do support the Codec Negotiation feature
622
794
  # then the HF shall send the AT+BAC=<HF available codecs> command to
623
795
  # the AG to notify the AG of the available codecs in the HF.
624
- codecs = [str(c) for c in self.supported_audio_codecs]
796
+ codecs = [str(c.value) for c in self.supported_audio_codecs]
625
797
  await self.execute_command(f"AT+BAC={','.join(codecs)}")
626
798
 
627
799
  # 4.2.1.3 AG Indicators
@@ -639,7 +811,7 @@ class HfProtocol(pyee.EventEmitter):
639
811
 
640
812
  self.ag_indicators = []
641
813
  for index, indicator in enumerate(response.parameters):
642
- description = indicator[0].decode()
814
+ description = AgIndicator(indicator[0].decode())
643
815
  supported_values = []
644
816
  for value in indicator[1]:
645
817
  value = value.split(b'-')
@@ -697,7 +869,7 @@ class HfProtocol(pyee.EventEmitter):
697
869
  # shall send the AT+BIND=<HF supported HF indicators> command to the AG
698
870
  # to notify the AG of the supported indicators’ assigned numbers in the
699
871
  # HF. The AG shall respond with OK
700
- indicators = [str(i) for i in self.hf_indicators.keys()]
872
+ indicators = [str(i.value) for i in self.hf_indicators]
701
873
  await self.execute_command(f"AT+BIND={','.join(indicators)}")
702
874
 
703
875
  # After having provided the AG with the HF indicators it supports,
@@ -733,6 +905,7 @@ class HfProtocol(pyee.EventEmitter):
733
905
  self.hf_indicators[indicator].enabled = True
734
906
 
735
907
  logger.info("SLC setup completed")
908
+ self._slc_initialized = True
736
909
 
737
910
  async def setup_audio_connection(self):
738
911
  """4.11.2 Audio Connection Setup by HF."""
@@ -824,11 +997,13 @@ class HfProtocol(pyee.EventEmitter):
824
997
  ag_indicator = self.ag_indicators[index - 1]
825
998
  ag_indicator.current_status = value
826
999
  self.emit('ag_indicator', ag_indicator)
827
- logger.info(f"AG indicator updated: {ag_indicator.description}, {value}")
1000
+ logger.info(f"AG indicator updated: {ag_indicator.indicator}, {value}")
828
1001
 
829
1002
  async def handle_unsolicited(self):
830
1003
  """Handle unsolicited result codes sent by the audio gateway."""
831
1004
  result = await self.unsolicited_queue.get()
1005
+ if not result:
1006
+ raise HfProtocol.HfLoopTermination()
832
1007
  if result.code == "+BCS":
833
1008
  await self.setup_codec_connection(int(result.parameters[0]))
834
1009
  elif result.code == "+CIEV":
@@ -846,14 +1021,350 @@ class HfProtocol(pyee.EventEmitter):
846
1021
  """
847
1022
 
848
1023
  try:
849
- await self.initiate_slc()
1024
+ if not self._slc_initialized:
1025
+ await self.initiate_slc()
850
1026
  while True:
851
1027
  await self.handle_unsolicited()
1028
+ except HfProtocol.HfLoopTermination:
1029
+ logger.info('Loop terminated')
852
1030
  except Exception:
853
1031
  logger.error("HFP-HF protocol failed with the following error:")
854
1032
  logger.error(traceback.format_exc())
855
1033
 
856
1034
 
1035
+ class AgProtocol(pyee.EventEmitter):
1036
+ """
1037
+ Implementation for the Audio-Gateway side of the Hands-Free profile.
1038
+
1039
+ Reference specification Hands-Free Profile v1.8.
1040
+
1041
+ Emitted events:
1042
+ slc_complete: Emit when SLC procedure is completed.
1043
+ codec_negotiation: When codec is renegotiated, notify the new codec.
1044
+ Args:
1045
+ active_codec: AudioCodec
1046
+ hf_indicator: When HF update their indicators, notify the new state.
1047
+ Args:
1048
+ hf_indicator: HfIndicator
1049
+ codec_connection_request: Emit when HF sends AT+BCC to request codec connection.
1050
+ answer: Emit when HF sends ATA to answer phone call.
1051
+ hang_up: Emit when HF sends AT+CHUP to hang up phone call.
1052
+ dial: Emit when HF sends ATD to dial phone call.
1053
+ """
1054
+
1055
+ supported_hf_features: int
1056
+ supported_hf_indicators: Set[HfIndicator]
1057
+ supported_audio_codecs: List[AudioCodec]
1058
+
1059
+ supported_ag_features: int
1060
+ supported_ag_call_hold_operations: List[CallHoldOperation]
1061
+
1062
+ ag_indicators: List[AgIndicatorState]
1063
+ hf_indicators: collections.OrderedDict[HfIndicator, HfIndicatorState]
1064
+
1065
+ dlc: rfcomm.DLC
1066
+
1067
+ read_buffer: bytearray
1068
+ active_codec: AudioCodec
1069
+
1070
+ indicator_report_enabled: bool
1071
+ inband_ringtone_enabled: bool
1072
+ cme_error_enabled: bool
1073
+ _remained_slc_setup_features: Set[HfFeature]
1074
+
1075
+ def __init__(self, dlc: rfcomm.DLC, configuration: AgConfiguration) -> None:
1076
+ super().__init__()
1077
+
1078
+ # Configure internal state.
1079
+ self.dlc = dlc
1080
+ self.read_buffer = bytearray()
1081
+ self.active_codec = AudioCodec.CVSD
1082
+
1083
+ # Build local features.
1084
+ self.supported_ag_features = sum(configuration.supported_ag_features)
1085
+ self.supported_ag_call_hold_operations = list(
1086
+ configuration.supported_ag_call_hold_operations
1087
+ )
1088
+ self.ag_indicators = list(configuration.supported_ag_indicators)
1089
+ self.supported_hf_indicators = set(configuration.supported_hf_indicators)
1090
+ self.inband_ringtone_enabled = True
1091
+ self._remained_slc_setup_features = set()
1092
+
1093
+ # Clear remote features.
1094
+ self.supported_hf_features = 0
1095
+ self.supported_audio_codecs = []
1096
+ self.indicator_report_enabled = False
1097
+ self.cme_error_enabled = False
1098
+
1099
+ self.hf_indicators = collections.OrderedDict()
1100
+
1101
+ # Bind the AT reader to the RFCOMM channel.
1102
+ self.dlc.sink = self._read_at
1103
+
1104
+ def supports_hf_feature(self, feature: HfFeature) -> bool:
1105
+ return (self.supported_hf_features & feature) != 0
1106
+
1107
+ def supports_ag_feature(self, feature: AgFeature) -> bool:
1108
+ return (self.supported_ag_features & feature) != 0
1109
+
1110
+ def _read_at(self, data: bytes):
1111
+ """
1112
+ Reads AT messages from the RFCOMM channel.
1113
+ """
1114
+ # Append to the read buffer.
1115
+ self.read_buffer.extend(data)
1116
+
1117
+ # Locate the trailer.
1118
+ trailer = self.read_buffer.find(b'\r')
1119
+ if trailer == -1:
1120
+ return
1121
+
1122
+ # Isolate the AT response code and parameters.
1123
+ raw_command = self.read_buffer[:trailer]
1124
+ command = AtCommand.parse_from(raw_command)
1125
+ logger.debug(f"<<< {raw_command.decode()}")
1126
+
1127
+ # Consume the response bytes.
1128
+ self.read_buffer = self.read_buffer[trailer + 1 :]
1129
+
1130
+ if command.sub_code == AtCommand.SubCode.TEST:
1131
+ handler_name = f'_on_{command.code.lower()}_test'
1132
+ elif command.sub_code == AtCommand.SubCode.READ:
1133
+ handler_name = f'_on_{command.code.lower()}_read'
1134
+ else:
1135
+ handler_name = f'_on_{command.code.lower()}'
1136
+
1137
+ if handler := getattr(self, handler_name, None):
1138
+ handler(*command.parameters)
1139
+ else:
1140
+ logger.warning('Handler %s not found', handler_name)
1141
+ self.send_response('ERROR')
1142
+
1143
+ def send_response(self, response: str) -> None:
1144
+ """Sends an AT response."""
1145
+ self.dlc.write(f'\r\n{response}\r\n')
1146
+
1147
+ def send_cme_error(self, error_code: CmeError) -> None:
1148
+ """Sends an CME ERROR response.
1149
+
1150
+ If CME Error is not enabled by HF, sends ERROR instead.
1151
+ """
1152
+ if self.cme_error_enabled:
1153
+ self.send_response(f'+CME ERROR: {error_code.value}')
1154
+ else:
1155
+ self.send_error()
1156
+
1157
+ def send_ok(self) -> None:
1158
+ """Sends an OK response."""
1159
+ self.send_response('OK')
1160
+
1161
+ def send_error(self) -> None:
1162
+ """Sends an ERROR response."""
1163
+ self.send_response('ERROR')
1164
+
1165
+ def set_inband_ringtone_enabled(self, enabled: bool) -> None:
1166
+ """Enables or disables in-band ringtone."""
1167
+
1168
+ self.inband_ringtone_enabled = enabled
1169
+ self.send_response(f'+BSIR: {1 if enabled else 0}')
1170
+
1171
+ def update_ag_indicator(self, indicator: AgIndicator, value: int) -> None:
1172
+ """Updates AG indicator.
1173
+
1174
+ Args:
1175
+ indicator: Name of the indicator.
1176
+ value: new value of the indicator.
1177
+ """
1178
+
1179
+ search_result = next(
1180
+ (
1181
+ (index, state)
1182
+ for index, state in enumerate(self.ag_indicators)
1183
+ if state.indicator == indicator
1184
+ ),
1185
+ None,
1186
+ )
1187
+ if not search_result:
1188
+ raise KeyError(f'{indicator} is not supported.')
1189
+
1190
+ index, indicator_state = search_result
1191
+ if not self.indicator_report_enabled:
1192
+ logger.warning('AG indicator report is disabled')
1193
+ if not indicator_state.enabled:
1194
+ logger.warning(f'AG indicator {indicator} is disabled')
1195
+
1196
+ indicator_state.current_status = value
1197
+ self.send_response(f'+CIEV: {index+1},{value}')
1198
+
1199
+ async def negotiate_codec(self, codec: AudioCodec) -> None:
1200
+ """Starts codec negotiation."""
1201
+
1202
+ if not self.supports_ag_feature(AgFeature.CODEC_NEGOTIATION):
1203
+ logger.warning('Local does not support Codec Negotiation')
1204
+ if not self.supports_hf_feature(HfFeature.CODEC_NEGOTIATION):
1205
+ logger.warning('Peer does not support Codec Negotiation')
1206
+ if codec not in self.supported_audio_codecs:
1207
+ logger.warning(f'{codec} is not supported by peer')
1208
+
1209
+ at_bcs_future = asyncio.get_running_loop().create_future()
1210
+ self.once('codec_negotiation', at_bcs_future.set_result)
1211
+ self.send_response(f'+BCS: {codec.value}')
1212
+ if (new_codec := await at_bcs_future) != codec:
1213
+ raise HfpProtocolError(f'Expect codec: {codec}, but get {new_codec}')
1214
+
1215
+ def _check_remained_slc_commands(self) -> None:
1216
+ if not self._remained_slc_setup_features:
1217
+ self.emit('slc_complete')
1218
+
1219
+ def _on_brsf(self, hf_features: bytes) -> None:
1220
+ self.supported_hf_features = int(hf_features)
1221
+ self.send_response(f'+BRSF: {self.supported_ag_features}')
1222
+ self.send_ok()
1223
+
1224
+ if self.supports_hf_feature(
1225
+ HfFeature.HF_INDICATORS
1226
+ ) and self.supports_ag_feature(AgFeature.HF_INDICATORS):
1227
+ self._remained_slc_setup_features.add(HfFeature.HF_INDICATORS)
1228
+
1229
+ if self.supports_hf_feature(
1230
+ HfFeature.THREE_WAY_CALLING
1231
+ ) and self.supports_ag_feature(AgFeature.THREE_WAY_CALLING):
1232
+ self._remained_slc_setup_features.add(HfFeature.THREE_WAY_CALLING)
1233
+
1234
+ def _on_bac(self, *args) -> None:
1235
+ self.supported_audio_codecs = [AudioCodec(int(value)) for value in args]
1236
+ self.send_ok()
1237
+
1238
+ def _on_bcs(self, codec: bytes) -> None:
1239
+ self.active_codec = AudioCodec(int(codec))
1240
+ self.send_ok()
1241
+ self.emit('codec_negotiation', self.active_codec)
1242
+
1243
+ def _on_cind_test(self) -> None:
1244
+ if not self.ag_indicators:
1245
+ self.send_cme_error(CmeError.NOT_FOUND)
1246
+ return
1247
+
1248
+ indicator_list_str = ",".join(
1249
+ indicator.on_test_text for indicator in self.ag_indicators
1250
+ )
1251
+ self.send_response(f'+CIND: {indicator_list_str}')
1252
+ self.send_ok()
1253
+
1254
+ def _on_cind_read(self) -> None:
1255
+ if not self.ag_indicators:
1256
+ self.send_cme_error(CmeError.NOT_FOUND)
1257
+ return
1258
+
1259
+ indicator_list_str = ",".join(
1260
+ str(indicator.current_status) for indicator in self.ag_indicators
1261
+ )
1262
+ self.send_response(f'+CIND: {indicator_list_str}')
1263
+ self.send_ok()
1264
+
1265
+ self._check_remained_slc_commands()
1266
+
1267
+ def _on_cmer(
1268
+ self,
1269
+ mode: bytes,
1270
+ keypad: Optional[bytes] = None,
1271
+ display: Optional[bytes] = None,
1272
+ indicator: bytes = b'',
1273
+ ) -> None:
1274
+ if int(mode) != 3 or keypad or display or int(indicator) not in (0, 1):
1275
+ logger.error(
1276
+ f'Unexpected values: mode={mode!r}, keypad={keypad!r}, '
1277
+ f'display={display!r}, indicator={indicator!r}'
1278
+ )
1279
+ self.send_cme_error(CmeError.INVALID_INDEX)
1280
+
1281
+ self.indicator_report_enabled = bool(int(indicator))
1282
+ self.send_ok()
1283
+
1284
+ def _on_cmee(self, enabled: bytes) -> None:
1285
+ self.cme_error_enabled = bool(int(enabled))
1286
+ self.send_ok()
1287
+
1288
+ def _on_bind(self, *args) -> None:
1289
+ if not self.supports_ag_feature(AgFeature.HF_INDICATORS):
1290
+ self.send_error()
1291
+ return
1292
+
1293
+ peer_supported_indicators = set(
1294
+ HfIndicator(int(indicator)) for indicator in args
1295
+ )
1296
+ self.hf_indicators = collections.OrderedDict(
1297
+ {
1298
+ indicator: HfIndicatorState(indicator=indicator)
1299
+ for indicator in self.supported_hf_indicators.intersection(
1300
+ peer_supported_indicators
1301
+ )
1302
+ }
1303
+ )
1304
+ self.send_ok()
1305
+
1306
+ def _on_bind_test(self) -> None:
1307
+ if not self.supports_ag_feature(AgFeature.HF_INDICATORS):
1308
+ self.send_error()
1309
+ return
1310
+
1311
+ hf_indicator_list_str = ",".join(
1312
+ str(indicator.value) for indicator in self.supported_hf_indicators
1313
+ )
1314
+ self.send_response(f'+BIND: ({hf_indicator_list_str})')
1315
+ self.send_ok()
1316
+
1317
+ def _on_bind_read(self) -> None:
1318
+ if not self.supports_ag_feature(AgFeature.HF_INDICATORS):
1319
+ self.send_error()
1320
+ return
1321
+
1322
+ for indicator in self.hf_indicators:
1323
+ self.send_response(f'+BIND: {indicator.value},1')
1324
+
1325
+ self.send_ok()
1326
+
1327
+ self._remained_slc_setup_features.remove(HfFeature.HF_INDICATORS)
1328
+ self._check_remained_slc_commands()
1329
+
1330
+ def _on_biev(self, index_bytes: bytes, value_bytes: bytes) -> None:
1331
+ if not self.supports_ag_feature(AgFeature.HF_INDICATORS):
1332
+ self.send_error()
1333
+ return
1334
+
1335
+ index = HfIndicator(int(index_bytes))
1336
+ if index not in self.hf_indicators:
1337
+ self.send_error()
1338
+ return
1339
+
1340
+ self.hf_indicators[index].current_status = int(value_bytes)
1341
+ self.emit('hf_indicator', self.hf_indicators[index])
1342
+ self.send_ok()
1343
+
1344
+ def _on_bia(self, *args) -> None:
1345
+ for enabled, state in zip(args, self.ag_indicators):
1346
+ state.enabled = bool(int(enabled))
1347
+ self.send_ok()
1348
+
1349
+ def _on_bcc(self) -> None:
1350
+ self.emit('codec_connection_request')
1351
+ self.send_ok()
1352
+
1353
+ def _on_a(self) -> None:
1354
+ """ATA handler."""
1355
+ self.emit('answer')
1356
+ self.send_ok()
1357
+
1358
+ def _on_d(self, number: bytes) -> None:
1359
+ """ATD handler."""
1360
+ self.emit('dial', number.decode())
1361
+ self.send_ok()
1362
+
1363
+ def _on_chup(self) -> None:
1364
+ self.emit('hang_up')
1365
+ self.send_ok()
1366
+
1367
+
857
1368
  # -----------------------------------------------------------------------------
858
1369
  # Normative SDP definitions
859
1370
  # -----------------------------------------------------------------------------
@@ -907,9 +1418,12 @@ class AgSdpFeature(enum.IntFlag):
907
1418
  VOICE_RECOGNITION_TEST = 0x80
908
1419
 
909
1420
 
910
- def sdp_records(
911
- service_record_handle: int, rfcomm_channel: int, configuration: Configuration
912
- ) -> List[ServiceAttribute]:
1421
+ def make_hf_sdp_records(
1422
+ service_record_handle: int,
1423
+ rfcomm_channel: int,
1424
+ configuration: HfConfiguration,
1425
+ version: ProfileVersion = ProfileVersion.V1_8,
1426
+ ) -> List[sdp.ServiceAttribute]:
913
1427
  """
914
1428
  Generates the SDP record for HFP Hands-Free support.
915
1429
 
@@ -941,53 +1455,226 @@ def sdp_records(
941
1455
  hf_supported_features |= HfSdpFeature.WIDE_BAND
942
1456
 
943
1457
  return [
944
- ServiceAttribute(
945
- SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
946
- DataElement.unsigned_integer_32(service_record_handle),
1458
+ sdp.ServiceAttribute(
1459
+ sdp.SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
1460
+ sdp.DataElement.unsigned_integer_32(service_record_handle),
1461
+ ),
1462
+ sdp.ServiceAttribute(
1463
+ sdp.SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
1464
+ sdp.DataElement.sequence(
1465
+ [
1466
+ sdp.DataElement.uuid(BT_HANDSFREE_SERVICE),
1467
+ sdp.DataElement.uuid(BT_GENERIC_AUDIO_SERVICE),
1468
+ ]
1469
+ ),
1470
+ ),
1471
+ sdp.ServiceAttribute(
1472
+ sdp.SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
1473
+ sdp.DataElement.sequence(
1474
+ [
1475
+ sdp.DataElement.sequence(
1476
+ [sdp.DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]
1477
+ ),
1478
+ sdp.DataElement.sequence(
1479
+ [
1480
+ sdp.DataElement.uuid(BT_RFCOMM_PROTOCOL_ID),
1481
+ sdp.DataElement.unsigned_integer_8(rfcomm_channel),
1482
+ ]
1483
+ ),
1484
+ ]
1485
+ ),
1486
+ ),
1487
+ sdp.ServiceAttribute(
1488
+ sdp.SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
1489
+ sdp.DataElement.sequence(
1490
+ [
1491
+ sdp.DataElement.sequence(
1492
+ [
1493
+ sdp.DataElement.uuid(BT_HANDSFREE_SERVICE),
1494
+ sdp.DataElement.unsigned_integer_16(version),
1495
+ ]
1496
+ )
1497
+ ]
1498
+ ),
1499
+ ),
1500
+ sdp.ServiceAttribute(
1501
+ sdp.SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID,
1502
+ sdp.DataElement.unsigned_integer_16(hf_supported_features),
1503
+ ),
1504
+ ]
1505
+
1506
+
1507
+ def make_ag_sdp_records(
1508
+ service_record_handle: int,
1509
+ rfcomm_channel: int,
1510
+ configuration: AgConfiguration,
1511
+ version: ProfileVersion = ProfileVersion.V1_8,
1512
+ ) -> List[sdp.ServiceAttribute]:
1513
+ """
1514
+ Generates the SDP record for HFP Audio-Gateway support.
1515
+
1516
+ The record exposes the features supported in the input configuration,
1517
+ and the allocated RFCOMM channel.
1518
+ """
1519
+
1520
+ ag_supported_features = 0
1521
+
1522
+ if AgFeature.EC_NR in configuration.supported_ag_features:
1523
+ ag_supported_features |= AgSdpFeature.EC_NR
1524
+ if AgFeature.THREE_WAY_CALLING in configuration.supported_ag_features:
1525
+ ag_supported_features |= AgSdpFeature.THREE_WAY_CALLING
1526
+ if (
1527
+ AgFeature.ENHANCED_VOICE_RECOGNITION_STATUS
1528
+ in configuration.supported_ag_features
1529
+ ):
1530
+ ag_supported_features |= AgSdpFeature.ENHANCED_VOICE_RECOGNITION_STATUS
1531
+ if AgFeature.VOICE_RECOGNITION_TEST in configuration.supported_ag_features:
1532
+ ag_supported_features |= AgSdpFeature.VOICE_RECOGNITION_TEST
1533
+ if AgFeature.IN_BAND_RING_TONE_CAPABILITY in configuration.supported_ag_features:
1534
+ ag_supported_features |= AgSdpFeature.IN_BAND_RING_TONE_CAPABILITY
1535
+ if AgFeature.VOICE_RECOGNITION_FUNCTION in configuration.supported_ag_features:
1536
+ ag_supported_features |= AgSdpFeature.VOICE_RECOGNITION_FUNCTION
1537
+ if AudioCodec.MSBC in configuration.supported_audio_codecs:
1538
+ ag_supported_features |= AgSdpFeature.WIDE_BAND
1539
+
1540
+ return [
1541
+ sdp.ServiceAttribute(
1542
+ sdp.SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
1543
+ sdp.DataElement.unsigned_integer_32(service_record_handle),
947
1544
  ),
948
- ServiceAttribute(
949
- SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
950
- DataElement.sequence(
1545
+ sdp.ServiceAttribute(
1546
+ sdp.SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
1547
+ sdp.DataElement.sequence(
951
1548
  [
952
- DataElement.uuid(BT_HANDSFREE_SERVICE),
953
- DataElement.uuid(BT_GENERIC_AUDIO_SERVICE),
1549
+ sdp.DataElement.uuid(BT_HEADSET_AUDIO_GATEWAY_SERVICE),
1550
+ sdp.DataElement.uuid(BT_GENERIC_AUDIO_SERVICE),
954
1551
  ]
955
1552
  ),
956
1553
  ),
957
- ServiceAttribute(
958
- SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
959
- DataElement.sequence(
1554
+ sdp.ServiceAttribute(
1555
+ sdp.SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
1556
+ sdp.DataElement.sequence(
960
1557
  [
961
- DataElement.sequence([DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]),
962
- DataElement.sequence(
1558
+ sdp.DataElement.sequence(
1559
+ [sdp.DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]
1560
+ ),
1561
+ sdp.DataElement.sequence(
963
1562
  [
964
- DataElement.uuid(BT_RFCOMM_PROTOCOL_ID),
965
- DataElement.unsigned_integer_8(rfcomm_channel),
1563
+ sdp.DataElement.uuid(BT_RFCOMM_PROTOCOL_ID),
1564
+ sdp.DataElement.unsigned_integer_8(rfcomm_channel),
966
1565
  ]
967
1566
  ),
968
1567
  ]
969
1568
  ),
970
1569
  ),
971
- ServiceAttribute(
972
- SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
973
- DataElement.sequence(
1570
+ sdp.ServiceAttribute(
1571
+ sdp.SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
1572
+ sdp.DataElement.sequence(
974
1573
  [
975
- DataElement.sequence(
1574
+ sdp.DataElement.sequence(
976
1575
  [
977
- DataElement.uuid(BT_HANDSFREE_SERVICE),
978
- DataElement.unsigned_integer_16(ProfileVersion.V1_8),
1576
+ sdp.DataElement.uuid(BT_HEADSET_AUDIO_GATEWAY_SERVICE),
1577
+ sdp.DataElement.unsigned_integer_16(version),
979
1578
  ]
980
1579
  )
981
1580
  ]
982
1581
  ),
983
1582
  ),
984
- ServiceAttribute(
985
- SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID,
986
- DataElement.unsigned_integer_16(hf_supported_features),
1583
+ sdp.ServiceAttribute(
1584
+ sdp.SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID,
1585
+ sdp.DataElement.unsigned_integer_16(ag_supported_features),
987
1586
  ),
988
1587
  ]
989
1588
 
990
1589
 
1590
+ async def find_hf_sdp_record(
1591
+ connection: device.Connection,
1592
+ ) -> Optional[Tuple[int, ProfileVersion, HfSdpFeature]]:
1593
+ """Searches a Hands-Free SDP record from remote device.
1594
+
1595
+ Args:
1596
+ connection: ACL connection to make SDP search.
1597
+
1598
+ Returns:
1599
+ Dictionary mapping from channel number to service class UUID list.
1600
+ """
1601
+ async with sdp.Client(connection) as sdp_client:
1602
+ search_result = await sdp_client.search_attributes(
1603
+ uuids=[BT_HANDSFREE_SERVICE],
1604
+ attribute_ids=[
1605
+ sdp.SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
1606
+ sdp.SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
1607
+ sdp.SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID,
1608
+ ],
1609
+ )
1610
+ for attribute_lists in search_result:
1611
+ channel: Optional[int] = None
1612
+ version: Optional[ProfileVersion] = None
1613
+ features: Optional[HfSdpFeature] = None
1614
+ for attribute in attribute_lists:
1615
+ # The layout is [[L2CAP_PROTOCOL], [RFCOMM_PROTOCOL, RFCOMM_CHANNEL]].
1616
+ if attribute.id == sdp.SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID:
1617
+ protocol_descriptor_list = attribute.value.value
1618
+ channel = protocol_descriptor_list[1].value[1].value
1619
+ elif (
1620
+ attribute.id
1621
+ == sdp.SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID
1622
+ ):
1623
+ profile_descriptor_list = attribute.value.value
1624
+ version = ProfileVersion(profile_descriptor_list[0].value[1].value)
1625
+ elif attribute.id == sdp.SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID:
1626
+ features = HfSdpFeature(attribute.value.value)
1627
+ if not channel or not version or features is None:
1628
+ logger.warning(f"Bad result {attribute_lists}.")
1629
+ return None
1630
+ return (channel, version, features)
1631
+ return None
1632
+
1633
+
1634
+ async def find_ag_sdp_record(
1635
+ connection: device.Connection,
1636
+ ) -> Optional[Tuple[int, ProfileVersion, AgSdpFeature]]:
1637
+ """Searches an Audio-Gateway SDP record from remote device.
1638
+
1639
+ Args:
1640
+ connection: ACL connection to make SDP search.
1641
+
1642
+ Returns:
1643
+ Dictionary mapping from channel number to service class UUID list.
1644
+ """
1645
+ async with sdp.Client(connection) as sdp_client:
1646
+ search_result = await sdp_client.search_attributes(
1647
+ uuids=[BT_HEADSET_AUDIO_GATEWAY_SERVICE],
1648
+ attribute_ids=[
1649
+ sdp.SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
1650
+ sdp.SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
1651
+ sdp.SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID,
1652
+ ],
1653
+ )
1654
+ for attribute_lists in search_result:
1655
+ channel: Optional[int] = None
1656
+ version: Optional[ProfileVersion] = None
1657
+ features: Optional[AgSdpFeature] = None
1658
+ for attribute in attribute_lists:
1659
+ # The layout is [[L2CAP_PROTOCOL], [RFCOMM_PROTOCOL, RFCOMM_CHANNEL]].
1660
+ if attribute.id == sdp.SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID:
1661
+ protocol_descriptor_list = attribute.value.value
1662
+ channel = protocol_descriptor_list[1].value[1].value
1663
+ elif (
1664
+ attribute.id
1665
+ == sdp.SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID
1666
+ ):
1667
+ profile_descriptor_list = attribute.value.value
1668
+ version = ProfileVersion(profile_descriptor_list[0].value[1].value)
1669
+ elif attribute.id == sdp.SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID:
1670
+ features = AgSdpFeature(attribute.value.value)
1671
+ if not channel or not version or features is None:
1672
+ logger.warning(f"Bad result {attribute_lists}.")
1673
+ return None
1674
+ return (channel, version, features)
1675
+ return None
1676
+
1677
+
991
1678
  # -----------------------------------------------------------------------------
992
1679
  # ESCO Codec Default Parameters
993
1680
  # -----------------------------------------------------------------------------
bumble/host.py CHANGED
@@ -184,7 +184,7 @@ class Host(AbortableEventEmitter):
184
184
  self.long_term_key_provider = None
185
185
  self.link_key_provider = None
186
186
  self.pairing_io_capability_provider = None # Classic only
187
- self.snooper = None
187
+ self.snooper: Optional[Snooper] = None
188
188
 
189
189
  # Connect to the source and sink if specified
190
190
  if controller_source:
@@ -19,8 +19,8 @@
19
19
  import struct
20
20
  from typing import Optional, Tuple
21
21
 
22
- from ..gatt_client import ProfileServiceProxy
23
- from ..gatt import (
22
+ from bumble.gatt_client import ServiceProxy, ProfileServiceProxy, CharacteristicProxy
23
+ from bumble.gatt import (
24
24
  GATT_DEVICE_INFORMATION_SERVICE,
25
25
  GATT_FIRMWARE_REVISION_STRING_CHARACTERISTIC,
26
26
  GATT_HARDWARE_REVISION_STRING_CHARACTERISTIC,
@@ -104,7 +104,16 @@ class DeviceInformationService(TemplateService):
104
104
  class DeviceInformationServiceProxy(ProfileServiceProxy):
105
105
  SERVICE_CLASS = DeviceInformationService
106
106
 
107
- def __init__(self, service_proxy):
107
+ manufacturer_name: Optional[UTF8CharacteristicAdapter]
108
+ model_number: Optional[UTF8CharacteristicAdapter]
109
+ serial_number: Optional[UTF8CharacteristicAdapter]
110
+ hardware_revision: Optional[UTF8CharacteristicAdapter]
111
+ firmware_revision: Optional[UTF8CharacteristicAdapter]
112
+ software_revision: Optional[UTF8CharacteristicAdapter]
113
+ system_id: Optional[DelegatedCharacteristicAdapter]
114
+ ieee_regulatory_certification_data_list: Optional[CharacteristicProxy]
115
+
116
+ def __init__(self, service_proxy: ServiceProxy):
108
117
  self.service_proxy = service_proxy
109
118
 
110
119
  for field, uuid in (
bumble/transport/pyusb.py CHANGED
@@ -23,11 +23,24 @@ import time
23
23
  import usb.core
24
24
  import usb.util
25
25
 
26
+ from typing import Optional
27
+ from usb.core import Device as UsbDevice
28
+ from usb.core import USBError
29
+ from usb.util import CTRL_TYPE_CLASS, CTRL_RECIPIENT_OTHER
30
+ from usb.legacy import REQ_SET_FEATURE, REQ_CLEAR_FEATURE, CLASS_HUB
31
+
26
32
  from .common import Transport, ParserSource
27
33
  from .. import hci
28
34
  from ..colors import color
29
35
 
30
36
 
37
+ # -----------------------------------------------------------------------------
38
+ # Constant
39
+ # -----------------------------------------------------------------------------
40
+ USB_PORT_FEATURE_POWER = 8
41
+ POWER_CYCLE_DELAY = 1
42
+ RESET_DELAY = 3
43
+
31
44
  # -----------------------------------------------------------------------------
32
45
  # Logging
33
46
  # -----------------------------------------------------------------------------
@@ -214,6 +227,10 @@ async def open_pyusb_transport(spec: str) -> Transport:
214
227
  usb_find = libusb_package.find
215
228
 
216
229
  # Find the device according to the spec moniker
230
+ power_cycle = False
231
+ if spec.startswith('!'):
232
+ power_cycle = True
233
+ spec = spec[1:]
217
234
  if ':' in spec:
218
235
  vendor_id, product_id = spec.split(':')
219
236
  device = usb_find(idVendor=int(vendor_id, 16), idProduct=int(product_id, 16))
@@ -245,6 +262,14 @@ async def open_pyusb_transport(spec: str) -> Transport:
245
262
  raise ValueError('device not found')
246
263
  logger.debug(f'USB Device: {device}')
247
264
 
265
+ # Power Cycle the device
266
+ if power_cycle:
267
+ try:
268
+ device = await _power_cycle(device) # type: ignore
269
+ except Exception as e:
270
+ logging.debug(e)
271
+ logging.info(f"Unable to power cycle {hex(device.idVendor)} {hex(device.idProduct)}") # type: ignore
272
+
248
273
  # Collect the metadata
249
274
  device_metadata = {'vendor_id': device.idVendor, 'product_id': device.idProduct}
250
275
 
@@ -308,3 +333,73 @@ async def open_pyusb_transport(spec: str) -> Transport:
308
333
  packet_sink.start()
309
334
 
310
335
  return UsbTransport(device, packet_source, packet_sink)
336
+
337
+
338
+ async def _power_cycle(device: UsbDevice) -> UsbDevice:
339
+ """
340
+ For devices connected to compatible USB hubs: Performs a power cycle on a given USB device.
341
+ This involves temporarily disabling its port on the hub and then re-enabling it.
342
+ """
343
+ device_path = f'{device.bus}-{".".join(map(str, device.port_numbers))}' # type: ignore
344
+ hub = _find_hub_by_device_path(device_path)
345
+
346
+ if hub:
347
+ try:
348
+ device_port = device.port_numbers[-1] # type: ignore
349
+ _set_port_status(hub, device_port, False)
350
+ await asyncio.sleep(POWER_CYCLE_DELAY)
351
+ _set_port_status(hub, device_port, True)
352
+ await asyncio.sleep(RESET_DELAY)
353
+
354
+ # Device needs to be find again otherwise it will appear as disconnected
355
+ return usb.core.find(idVendor=device.idVendor, idProduct=device.idProduct) # type: ignore
356
+ except USBError as e:
357
+ logger.error(f"Adjustment needed: Please revise the udev rule for device {hex(device.idVendor)}:{hex(device.idProduct)} for proper recognition.") # type: ignore
358
+ logger.error(e)
359
+
360
+ return device
361
+
362
+
363
+ def _set_port_status(device: UsbDevice, port: int, on: bool):
364
+ """Sets the power status of a specific port on a USB hub."""
365
+ device.ctrl_transfer(
366
+ bmRequestType=CTRL_TYPE_CLASS | CTRL_RECIPIENT_OTHER,
367
+ bRequest=REQ_SET_FEATURE if on else REQ_CLEAR_FEATURE,
368
+ wIndex=port,
369
+ wValue=USB_PORT_FEATURE_POWER,
370
+ )
371
+
372
+
373
+ def _find_device_by_path(sys_path: str) -> Optional[UsbDevice]:
374
+ """Finds a USB device based on its system path."""
375
+ bus_num, *port_parts = sys_path.split('-')
376
+ ports = [int(port) for port in port_parts[0].split('.')]
377
+ devices = usb.core.find(find_all=True, bus=int(bus_num))
378
+ if devices:
379
+ for device in devices:
380
+ if device.bus == int(bus_num) and list(device.port_numbers) == ports: # type: ignore
381
+ return device
382
+
383
+ return None
384
+
385
+
386
+ def _find_hub_by_device_path(sys_path: str) -> Optional[UsbDevice]:
387
+ """Finds the USB hub associated with a specific device path."""
388
+ hub_sys_path = sys_path.rsplit('.', 1)[0]
389
+ hub_device = _find_device_by_path(hub_sys_path)
390
+
391
+ if hub_device is None:
392
+ return None
393
+ else:
394
+ return hub_device if _is_hub(hub_device) else None
395
+
396
+
397
+ def _is_hub(device: UsbDevice) -> bool:
398
+ """Checks if a USB device is a hub"""
399
+ if device.bDeviceClass == CLASS_HUB: # type: ignore
400
+ return True
401
+ for config in device:
402
+ for interface in config:
403
+ if interface.bInterfaceClass == CLASS_HUB: # type: ignore
404
+ return True
405
+ return False
@@ -30,6 +30,7 @@ logger = logging.getLogger(__name__)
30
30
 
31
31
  # -----------------------------------------------------------------------------
32
32
 
33
+
33
34
  # A pass-through function to ease mock testing.
34
35
  async def _create_server(*args, **kw_args):
35
36
  await asyncio.get_running_loop().create_server(*args, **kw_args)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: bumble
3
- Version: 0.0.190
3
+ Version: 0.0.192
4
4
  Summary: Bluetooth Stack for Apps, Emulation, Test and Experimentation
5
5
  Home-page: https://github.com/google/bumble
6
6
  Author: Google
@@ -11,7 +11,6 @@ License-File: LICENSE
11
11
  Requires-Dist: pyee >=8.2.2
12
12
  Requires-Dist: aiohttp ~=3.8 ; platform_system != "Emscripten"
13
13
  Requires-Dist: appdirs >=1.4 ; platform_system != "Emscripten"
14
- Requires-Dist: bt-test-interfaces >=0.0.6 ; platform_system != "Emscripten"
15
14
  Requires-Dist: click >=8.1.3 ; platform_system != "Emscripten"
16
15
  Requires-Dist: cryptography ==39 ; platform_system != "Emscripten"
17
16
  Requires-Dist: grpcio >=1.62.1 ; platform_system != "Emscripten"
@@ -47,9 +46,11 @@ Provides-Extra: documentation
47
46
  Requires-Dist: mkdocs >=1.4.0 ; extra == 'documentation'
48
47
  Requires-Dist: mkdocs-material >=8.5.6 ; extra == 'documentation'
49
48
  Requires-Dist: mkdocstrings[python] >=0.19.0 ; extra == 'documentation'
49
+ Provides-Extra: pandora
50
+ Requires-Dist: bt-test-interfaces >=0.0.6 ; extra == 'pandora'
50
51
  Provides-Extra: test
51
52
  Requires-Dist: pytest >=8.0 ; extra == 'test'
52
- Requires-Dist: pytest-asyncio >=0.21.1 ; extra == 'test'
53
+ Requires-Dist: pytest-asyncio >=0.23.5 ; extra == 'test'
53
54
  Requires-Dist: pytest-html >=3.2.0 ; extra == 'test'
54
55
  Requires-Dist: coverage >=6.4 ; extra == 'test'
55
56
 
@@ -1,5 +1,5 @@
1
1
  bumble/__init__.py,sha256=Q8jkz6rgl95IMAeInQVt_2GLoJl3DcEP2cxtrQ-ho5c,110
2
- bumble/_version.py,sha256=lIV0i6X65UjykL3W2hVxyK2omTPh4jNlE5Ya4uSriLA,415
2
+ bumble/_version.py,sha256=lSefnTH6R6u0mUz0L1w0kV_TidGT3hsmjlFRbEtBud0,415
3
3
  bumble/a2dp.py,sha256=VEeAOCfT1ZqpwnEgel6DJ32vxR8jYX3IAaBfCqPdWO8,22675
4
4
  bumble/at.py,sha256=kdrcsx2C8Rg61EWESD2QHwpZntkXkRBJLrPn9auv9K8,2961
5
5
  bumble/att.py,sha256=TGzhhBKCQPA_P_eDDSNASJVfa3dCr-QzzrRB3GekrI0,32366
@@ -18,13 +18,13 @@ bumble/decoder.py,sha256=N9nMvuVhuwpnfw7EDVuNe9uYY6B6c3RY2dh8RhRPC1U,9608
18
18
  bumble/device.py,sha256=XvAdIoGcYZIdlJzJNHRA_4nPYiJF1GRQZAp8HflKe60,167653
19
19
  bumble/gap.py,sha256=dRU2_TWvqTDx80hxeSbXlWIeWvptWH4_XbItG5y948Q,2138
20
20
  bumble/gatt.py,sha256=W7h8hEyxM8fu3HbAKYJ2HStb8NM7T98UICVnf4G9HDo,38447
21
- bumble/gatt_client.py,sha256=UUOLszwNBbOG0x2nFuIAK2llAysj0zK4euXJxb2gVSo,42523
21
+ bumble/gatt_client.py,sha256=pJ29537m9L_cY2nrtEqZdssOEpg4147fF7vhz0BweyY,43071
22
22
  bumble/gatt_server.py,sha256=uPYbn2-y0MLnyR8xxpOf18gPua_Q49pSlMR1zxEnU-Q,37118
23
23
  bumble/hci.py,sha256=3MYwWLuuolxY9xMitm5tOHpqvGKqtsReZ6QUfI1DcVA,267323
24
24
  bumble/helpers.py,sha256=m0w4UgFFNDEnXwHrDyfRlcBObdVed2fqXGL0lvR3c8s,12733
25
- bumble/hfp.py,sha256=hc7IVZxsb7skfWrxWPyt0BXFQRMh9a7mdv--A9k5aNg,41865
25
+ bumble/hfp.py,sha256=zee-gfWdKK1gBEqF32u2BaMf5Uc6LsoepO-ZFV65bj8,66094
26
26
  bumble/hid.py,sha256=Dd4rsmkRxcxt1IjoozJdu9Qd-QWruKJfsiYqTT89NDk,20590
27
- bumble/host.py,sha256=tLFay8cdJIHl92UyVTvChKQXUh9QEFI3Ib6unYJLLHw,46909
27
+ bumble/host.py,sha256=g9twZR9JapJdOD_HYFTbyA8U2dyUVKBEbq-2RtfSdQA,46928
28
28
  bumble/keys.py,sha256=58BMWd8LocY0bazVQ-qw3DKrOxgilhYaKBkmJRcNPp4,12593
29
29
  bumble/l2cap.py,sha256=8m_1Kv6Tk-M-DilkAz_OXx0XsiLUhXycEZjUkICwyj0,81064
30
30
  bumble/link.py,sha256=QiiMSCZ0z0ko2oUEMYg6nbq-h5A_3DLN4pjqAx_E-SA,23980
@@ -79,7 +79,7 @@ bumble/profiles/bap.py,sha256=jY0wHBIlc_Qxv6j-3rF_4nI4uM2z4I8WT99Teu4o0S8,46006
79
79
  bumble/profiles/battery_service.py,sha256=w-uF4jLoDozJOoykimb2RkrKjVyCke6ts2-h-F1PYyc,2292
80
80
  bumble/profiles/cap.py,sha256=6gH7oOnUKjOggMPuB7rtbwj0AneoNmnWzQ_iR3io8e0,1945
81
81
  bumble/profiles/csip.py,sha256=wzSpNRCOMWtKw2Yd9OTAzPoFDoQWG-KYwWdA6sUkwiI,10102
82
- bumble/profiles/device_information_service.py,sha256=MOMEY9AaMMNOJppJyniaHS-OeuXUdMNd4O9EE5gWd8Y,5657
82
+ bumble/profiles/device_information_service.py,sha256=RfqnXywcwcSTiFalxd1LVTTdeWLxHGsMvlvr9fI0GJI,6193
83
83
  bumble/profiles/heart_rate_service.py,sha256=7V2LGcWLp6RurjWxsVgMWr3wPDt5aS9qjNxTbHcOK6o,8575
84
84
  bumble/profiles/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
85
85
  bumble/profiles/vcp.py,sha256=wkbTf2NRCbBtvpXplpNJq4dzXp6JGeaEHeeC1kHqW7s,7897
@@ -95,10 +95,10 @@ bumble/transport/file.py,sha256=eVM2V6Nk2nDAFdE7Rt01ZI3JdTovsH9OEU1gKYPJjpE,2010
95
95
  bumble/transport/hci_socket.py,sha256=EdgWi3-O5yvYcH4R4BkPtG79pnUo7GQtXWawuUHDoDQ,6331
96
96
  bumble/transport/pty.py,sha256=grTl-yvjMWHflNwuME4ccVqDbk6NIEgQMgH6Y9lf1fU,2732
97
97
  bumble/transport/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
98
- bumble/transport/pyusb.py,sha256=RV42LSRlMhLx1LOdaF8TY4qePPGlONFaPqta7I5mYTw,11987
98
+ bumble/transport/pyusb.py,sha256=Wa1QlfjwN_9bdisoaBVYXN0DfHQgwi4L7nWbUisZmVE,15504
99
99
  bumble/transport/serial.py,sha256=loQxkeG7uE09enXWg2uGbxi6CeG70wn3kzPbEwULKw4,2446
100
100
  bumble/transport/tcp_client.py,sha256=deyUJYpj04QE00Mw_PTU5PHPA6mr1Nui3f5-QCy2zOw,1854
101
- bumble/transport/tcp_server.py,sha256=qcpTeLFSkDWvqHEjYxtZ75wYvAKxa3LiwHJwT5bhn88,3778
101
+ bumble/transport/tcp_server.py,sha256=tvu7FuPeqiXfoj2HQU8wu4AiwKjDDDCKlKjgtqWc5hg,3779
102
102
  bumble/transport/udp.py,sha256=di8I6HHACgBx3un-dzAahz9lTIUrh4LdeuYpeoifQEM,2239
103
103
  bumble/transport/usb.py,sha256=dFNN-kGI3pMTXeT5Amwu2H6e4J48WAJotG_D18W3RBM,21399
104
104
  bumble/transport/vhci.py,sha256=iI2WpighnvIP5zeyJUFSbjEdmCo24CWMdICamIcyJck,2250
@@ -137,9 +137,9 @@ bumble/vendor/android/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3h
137
137
  bumble/vendor/android/hci.py,sha256=GZrkhaWmcMt1JpnRhv0NoySGkf2H4lNUV2f_omRZW0I,10741
138
138
  bumble/vendor/zephyr/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
139
139
  bumble/vendor/zephyr/hci.py,sha256=d83bC0TvT947eN4roFjLkQefWtHOoNsr4xib2ctSkvA,3195
140
- bumble-0.0.190.dist-info/LICENSE,sha256=FvaYh4NRWIGgS_OwoBs5gFgkCmAghZ-DYnIGBZPuw-s,12142
141
- bumble-0.0.190.dist-info/METADATA,sha256=3toWetMEz1r3wfytRfmK4DjkdzspHLbhVbagGQxyJBk,5684
142
- bumble-0.0.190.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
143
- bumble-0.0.190.dist-info/entry_points.txt,sha256=UkNj1KMZDhzOb7O4OU7Jn4YI5KaxJZgQF2GF64BwOlQ,883
144
- bumble-0.0.190.dist-info/top_level.txt,sha256=tV6JJKaHPYMFiJYiBYFW24PCcfLxTJZdlu6BmH3Cb00,7
145
- bumble-0.0.190.dist-info/RECORD,,
140
+ bumble-0.0.192.dist-info/LICENSE,sha256=FvaYh4NRWIGgS_OwoBs5gFgkCmAghZ-DYnIGBZPuw-s,12142
141
+ bumble-0.0.192.dist-info/METADATA,sha256=-PS1I7x-VWaCdUl1Wcgmnzd1W_uAtjjsuC1F_JPKL5Q,5695
142
+ bumble-0.0.192.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
143
+ bumble-0.0.192.dist-info/entry_points.txt,sha256=UkNj1KMZDhzOb7O4OU7Jn4YI5KaxJZgQF2GF64BwOlQ,883
144
+ bumble-0.0.192.dist-info/top_level.txt,sha256=tV6JJKaHPYMFiJYiBYFW24PCcfLxTJZdlu6BmH3Cb00,7
145
+ bumble-0.0.192.dist-info/RECORD,,