bumble 0.0.204__py3-none-any.whl → 0.0.207__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/profiles/aics.py CHANGED
@@ -451,54 +451,35 @@ class AICSServiceProxy(ProfileServiceProxy):
451
451
  def __init__(self, service_proxy: ServiceProxy) -> None:
452
452
  self.service_proxy = service_proxy
453
453
 
454
- if not (
455
- characteristics := service_proxy.get_characteristics_by_uuid(
456
- GATT_AUDIO_INPUT_STATE_CHARACTERISTIC
457
- )
458
- ):
459
- raise gatt.InvalidServiceError("Audio Input State Characteristic not found")
460
454
  self.audio_input_state = SerializableCharacteristicAdapter(
461
- characteristics[0], AudioInputState
455
+ service_proxy.get_required_characteristic_by_uuid(
456
+ GATT_AUDIO_INPUT_STATE_CHARACTERISTIC
457
+ ),
458
+ AudioInputState,
462
459
  )
463
460
 
464
- if not (
465
- characteristics := service_proxy.get_characteristics_by_uuid(
466
- GATT_GAIN_SETTINGS_ATTRIBUTE_CHARACTERISTIC
467
- )
468
- ):
469
- raise gatt.InvalidServiceError(
470
- "Gain Settings Attribute Characteristic not found"
471
- )
472
461
  self.gain_settings_properties = SerializableCharacteristicAdapter(
473
- characteristics[0], GainSettingsProperties
462
+ service_proxy.get_required_characteristic_by_uuid(
463
+ GATT_GAIN_SETTINGS_ATTRIBUTE_CHARACTERISTIC
464
+ ),
465
+ GainSettingsProperties,
474
466
  )
475
467
 
476
- if not (
477
- characteristics := service_proxy.get_characteristics_by_uuid(
468
+ self.audio_input_status = PackedCharacteristicAdapter(
469
+ service_proxy.get_required_characteristic_by_uuid(
478
470
  GATT_AUDIO_INPUT_STATUS_CHARACTERISTIC
479
- )
480
- ):
481
- raise gatt.InvalidServiceError(
482
- "Audio Input Status Characteristic not found"
483
- )
484
- self.audio_input_status = PackedCharacteristicAdapter(characteristics[0], 'B')
471
+ ),
472
+ 'B',
473
+ )
485
474
 
486
- if not (
487
- characteristics := service_proxy.get_characteristics_by_uuid(
475
+ self.audio_input_control_point = (
476
+ service_proxy.get_required_characteristic_by_uuid(
488
477
  GATT_AUDIO_INPUT_CONTROL_POINT_CHARACTERISTIC
489
478
  )
490
- ):
491
- raise gatt.InvalidServiceError(
492
- "Audio Input Control Point Characteristic not found"
493
- )
494
- self.audio_input_control_point = characteristics[0]
479
+ )
495
480
 
496
- if not (
497
- characteristics := service_proxy.get_characteristics_by_uuid(
481
+ self.audio_input_description = UTF8CharacteristicAdapter(
482
+ service_proxy.get_required_characteristic_by_uuid(
498
483
  GATT_AUDIO_INPUT_DESCRIPTION_CHARACTERISTIC
499
484
  )
500
- ):
501
- raise gatt.InvalidServiceError(
502
- "Audio Input Description Characteristic not found"
503
- )
504
- self.audio_input_description = UTF8CharacteristicAdapter(characteristics[0])
485
+ )
bumble/profiles/ascs.py CHANGED
@@ -17,6 +17,7 @@
17
17
  # Imports
18
18
  # -----------------------------------------------------------------------------
19
19
  from __future__ import annotations
20
+
20
21
  import enum
21
22
  import logging
22
23
  import struct
@@ -258,8 +259,8 @@ class AseReasonCode(enum.IntEnum):
258
259
 
259
260
  # -----------------------------------------------------------------------------
260
261
  class AudioRole(enum.IntEnum):
261
- SINK = hci.HCI_LE_Setup_ISO_Data_Path_Command.Direction.CONTROLLER_TO_HOST
262
- SOURCE = hci.HCI_LE_Setup_ISO_Data_Path_Command.Direction.HOST_TO_CONTROLLER
262
+ SINK = device.CisLink.Direction.CONTROLLER_TO_HOST
263
+ SOURCE = device.CisLink.Direction.HOST_TO_CONTROLLER
263
264
 
264
265
 
265
266
  # -----------------------------------------------------------------------------
@@ -354,16 +355,7 @@ class AseStateMachine(gatt.Characteristic):
354
355
  cis_link.on('disconnection', self.on_cis_disconnection)
355
356
 
356
357
  async def post_cis_established():
357
- await self.service.device.send_command(
358
- hci.HCI_LE_Setup_ISO_Data_Path_Command(
359
- connection_handle=cis_link.handle,
360
- data_path_direction=self.role,
361
- data_path_id=0x00, # Fixed HCI
362
- codec_id=hci.CodingFormat(hci.CodecID.TRANSPARENT),
363
- controller_delay=0,
364
- codec_configuration=b'',
365
- )
366
- )
358
+ await cis_link.setup_data_path(direction=self.role)
367
359
  if self.role == AudioRole.SINK:
368
360
  self.state = self.State.STREAMING
369
361
  await self.service.device.notify_subscribers(self, self.value)
@@ -511,12 +503,8 @@ class AseStateMachine(gatt.Characteristic):
511
503
  self.state = self.State.RELEASING
512
504
 
513
505
  async def remove_cis_async():
514
- await self.service.device.send_command(
515
- hci.HCI_LE_Remove_ISO_Data_Path_Command(
516
- connection_handle=self.cis_link.handle,
517
- data_path_direction=self.role,
518
- )
519
- )
506
+ if self.cis_link:
507
+ await self.cis_link.remove_data_path(self.role)
520
508
  self.state = self.State.IDLE
521
509
  await self.service.device.notify_subscribers(self, self.value)
522
510
 
bumble/profiles/asha.py CHANGED
@@ -288,8 +288,8 @@ class AshaServiceProxy(gatt_client.ProfileServiceProxy):
288
288
  'psm_characteristic',
289
289
  ),
290
290
  ):
291
- if not (
292
- characteristics := self.service_proxy.get_characteristics_by_uuid(uuid)
293
- ):
294
- raise gatt.InvalidServiceError(f"Missing {uuid} Characteristic")
295
- setattr(self, attribute_name, characteristics[0])
291
+ setattr(
292
+ self,
293
+ attribute_name,
294
+ self.service_proxy.get_required_characteristic_by_uuid(uuid),
295
+ )
bumble/profiles/bass.py CHANGED
@@ -354,34 +354,25 @@ class BroadcastAudioScanServiceProxy(gatt_client.ProfileServiceProxy):
354
354
  SERVICE_CLASS = BroadcastAudioScanService
355
355
 
356
356
  broadcast_audio_scan_control_point: gatt_client.CharacteristicProxy
357
- broadcast_receive_states: List[gatt.SerializableCharacteristicAdapter]
357
+ broadcast_receive_states: List[gatt.DelegatedCharacteristicAdapter]
358
358
 
359
359
  def __init__(self, service_proxy: gatt_client.ServiceProxy):
360
360
  self.service_proxy = service_proxy
361
361
 
362
- if not (
363
- characteristics := service_proxy.get_characteristics_by_uuid(
362
+ self.broadcast_audio_scan_control_point = (
363
+ service_proxy.get_required_characteristic_by_uuid(
364
364
  gatt.GATT_BROADCAST_AUDIO_SCAN_CONTROL_POINT_CHARACTERISTIC
365
365
  )
366
- ):
367
- raise gatt.InvalidServiceError(
368
- "Broadcast Audio Scan Control Point characteristic not found"
369
- )
370
- self.broadcast_audio_scan_control_point = characteristics[0]
366
+ )
371
367
 
372
- if not (
373
- characteristics := service_proxy.get_characteristics_by_uuid(
374
- gatt.GATT_BROADCAST_RECEIVE_STATE_CHARACTERISTIC
375
- )
376
- ):
377
- raise gatt.InvalidServiceError(
378
- "Broadcast Receive State characteristic not found"
379
- )
380
368
  self.broadcast_receive_states = [
381
- gatt.SerializableCharacteristicAdapter(
382
- characteristic, BroadcastReceiveState
369
+ gatt.DelegatedCharacteristicAdapter(
370
+ characteristic,
371
+ decode=lambda x: BroadcastReceiveState.from_bytes(x) if x else None,
372
+ )
373
+ for characteristic in service_proxy.get_characteristics_by_uuid(
374
+ gatt.GATT_BROADCAST_RECEIVE_STATE_CHARACTERISTIC
383
375
  )
384
- for characteristic in characteristics
385
376
  ]
386
377
 
387
378
  async def send_control_point_operation(
@@ -0,0 +1,166 @@
1
+ # Copyright 2021-2025 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from __future__ import annotations
16
+
17
+ import struct
18
+ from typing import TYPE_CHECKING
19
+
20
+ from bumble import att
21
+ from bumble import gatt
22
+ from bumble import gatt_client
23
+ from bumble import crypto
24
+
25
+ if TYPE_CHECKING:
26
+ from bumble import device
27
+
28
+
29
+ # -----------------------------------------------------------------------------
30
+ class GenericAttributeProfileService(gatt.TemplateService):
31
+ '''See Vol 3, Part G - 7 - DEFINED GENERIC ATTRIBUTE PROFILE SERVICE.'''
32
+
33
+ UUID = gatt.GATT_GENERIC_ATTRIBUTE_SERVICE
34
+
35
+ client_supported_features_characteristic: gatt.Characteristic | None = None
36
+ server_supported_features_characteristic: gatt.Characteristic | None = None
37
+ database_hash_characteristic: gatt.Characteristic | None = None
38
+ service_changed_characteristic: gatt.Characteristic | None = None
39
+
40
+ def __init__(
41
+ self,
42
+ server_supported_features: gatt.ServerSupportedFeatures | None = None,
43
+ database_hash_enabled: bool = True,
44
+ service_change_enabled: bool = True,
45
+ ) -> None:
46
+
47
+ if server_supported_features is not None:
48
+ self.server_supported_features_characteristic = gatt.Characteristic(
49
+ uuid=gatt.GATT_SERVER_SUPPORTED_FEATURES_CHARACTERISTIC,
50
+ properties=gatt.Characteristic.Properties.READ,
51
+ permissions=gatt.Characteristic.Permissions.READABLE,
52
+ value=bytes([server_supported_features]),
53
+ )
54
+
55
+ if database_hash_enabled:
56
+ self.database_hash_characteristic = gatt.Characteristic(
57
+ uuid=gatt.GATT_DATABASE_HASH_CHARACTERISTIC,
58
+ properties=gatt.Characteristic.Properties.READ,
59
+ permissions=gatt.Characteristic.Permissions.READABLE,
60
+ value=gatt.CharacteristicValue(read=self.get_database_hash),
61
+ )
62
+
63
+ if service_change_enabled:
64
+ self.service_changed_characteristic = gatt.Characteristic(
65
+ uuid=gatt.GATT_SERVICE_CHANGED_CHARACTERISTIC,
66
+ properties=gatt.Characteristic.Properties.INDICATE,
67
+ permissions=gatt.Characteristic.Permissions(0),
68
+ value=b'',
69
+ )
70
+
71
+ if (database_hash_enabled and service_change_enabled) or (
72
+ server_supported_features
73
+ and (
74
+ server_supported_features & gatt.ServerSupportedFeatures.EATT_SUPPORTED
75
+ )
76
+ ): # TODO: Support Multiple Handle Value Notifications
77
+ self.client_supported_features_characteristic = gatt.Characteristic(
78
+ uuid=gatt.GATT_CLIENT_SUPPORTED_FEATURES_CHARACTERISTIC,
79
+ properties=(
80
+ gatt.Characteristic.Properties.READ
81
+ | gatt.Characteristic.Properties.WRITE
82
+ ),
83
+ permissions=(
84
+ gatt.Characteristic.Permissions.READABLE
85
+ | gatt.Characteristic.Permissions.WRITEABLE
86
+ ),
87
+ value=bytes(1),
88
+ )
89
+
90
+ super().__init__(
91
+ characteristics=[
92
+ c
93
+ for c in (
94
+ self.service_changed_characteristic,
95
+ self.client_supported_features_characteristic,
96
+ self.database_hash_characteristic,
97
+ self.server_supported_features_characteristic,
98
+ )
99
+ if c is not None
100
+ ],
101
+ primary=True,
102
+ )
103
+
104
+ @classmethod
105
+ def get_attribute_data(cls, attribute: att.Attribute) -> bytes:
106
+ if attribute.type in (
107
+ gatt.GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE,
108
+ gatt.GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE,
109
+ gatt.GATT_INCLUDE_ATTRIBUTE_TYPE,
110
+ gatt.GATT_CHARACTERISTIC_ATTRIBUTE_TYPE,
111
+ gatt.GATT_CHARACTERISTIC_EXTENDED_PROPERTIES_DESCRIPTOR,
112
+ ):
113
+ return (
114
+ struct.pack("<H", attribute.handle)
115
+ + attribute.type.to_bytes()
116
+ + attribute.value
117
+ )
118
+ elif attribute.type in (
119
+ gatt.GATT_CHARACTERISTIC_USER_DESCRIPTION_DESCRIPTOR,
120
+ gatt.GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR,
121
+ gatt.GATT_SERVER_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR,
122
+ gatt.GATT_CHARACTERISTIC_PRESENTATION_FORMAT_DESCRIPTOR,
123
+ gatt.GATT_CHARACTERISTIC_AGGREGATE_FORMAT_DESCRIPTOR,
124
+ ):
125
+ return struct.pack("<H", attribute.handle) + attribute.type.to_bytes()
126
+
127
+ return b''
128
+
129
+ def get_database_hash(self, connection: device.Connection | None) -> bytes:
130
+ assert connection
131
+
132
+ m = b''.join(
133
+ [
134
+ self.get_attribute_data(attribute)
135
+ for attribute in connection.device.gatt_server.attributes
136
+ ]
137
+ )
138
+
139
+ return crypto.aes_cmac(m=m, k=bytes(16))
140
+
141
+
142
+ class GenericAttributeProfileServiceProxy(gatt_client.ProfileServiceProxy):
143
+ SERVICE_CLASS = GenericAttributeProfileService
144
+
145
+ client_supported_features_characteristic: gatt_client.CharacteristicProxy | None = (
146
+ None
147
+ )
148
+ server_supported_features_characteristic: gatt_client.CharacteristicProxy | None = (
149
+ None
150
+ )
151
+ database_hash_characteristic: gatt_client.CharacteristicProxy | None = None
152
+ service_changed_characteristic: gatt_client.CharacteristicProxy | None = None
153
+
154
+ _CHARACTERISTICS = {
155
+ gatt.GATT_CLIENT_SUPPORTED_FEATURES_CHARACTERISTIC: 'client_supported_features_characteristic',
156
+ gatt.GATT_SERVER_SUPPORTED_FEATURES_CHARACTERISTIC: 'server_supported_features_characteristic',
157
+ gatt.GATT_DATABASE_HASH_CHARACTERISTIC: 'database_hash_characteristic',
158
+ gatt.GATT_SERVICE_CHANGED_CHARACTERISTIC: 'service_changed_characteristic',
159
+ }
160
+
161
+ def __init__(self, service_proxy: gatt_client.ServiceProxy) -> None:
162
+ self.service_proxy = service_proxy
163
+
164
+ for uuid, attribute_name in self._CHARACTERISTICS.items():
165
+ if characteristics := self.service_proxy.get_characteristics_by_uuid(uuid):
166
+ setattr(self, attribute_name, characteristics[0])
@@ -0,0 +1,193 @@
1
+ # Copyright 2024 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """LE Audio - Gaming Audio Profile"""
16
+
17
+ # -----------------------------------------------------------------------------
18
+ # Imports
19
+ # -----------------------------------------------------------------------------
20
+ import struct
21
+ from typing import Optional
22
+
23
+ from bumble.gatt import (
24
+ TemplateService,
25
+ DelegatedCharacteristicAdapter,
26
+ Characteristic,
27
+ GATT_GAMING_AUDIO_SERVICE,
28
+ GATT_GMAP_ROLE_CHARACTERISTIC,
29
+ GATT_UGG_FEATURES_CHARACTERISTIC,
30
+ GATT_UGT_FEATURES_CHARACTERISTIC,
31
+ GATT_BGS_FEATURES_CHARACTERISTIC,
32
+ GATT_BGR_FEATURES_CHARACTERISTIC,
33
+ )
34
+ from bumble.gatt_client import ProfileServiceProxy, ServiceProxy
35
+ from enum import IntFlag
36
+
37
+
38
+ # -----------------------------------------------------------------------------
39
+ # Classes
40
+ # -----------------------------------------------------------------------------
41
+ class GmapRole(IntFlag):
42
+ UNICAST_GAME_GATEWAY = 1 << 0
43
+ UNICAST_GAME_TERMINAL = 1 << 1
44
+ BROADCAST_GAME_SENDER = 1 << 2
45
+ BROADCAST_GAME_RECEIVER = 1 << 3
46
+
47
+
48
+ class UggFeatures(IntFlag):
49
+ UGG_MULTIPLEX = 1 << 0
50
+ UGG_96_KBPS_SOURCE = 1 << 1
51
+ UGG_MULTISINK = 1 << 2
52
+
53
+
54
+ class UgtFeatures(IntFlag):
55
+ UGT_SOURCE = 1 << 0
56
+ UGT_80_KBPS_SOURCE = 1 << 1
57
+ UGT_SINK = 1 << 2
58
+ UGT_64_KBPS_SINK = 1 << 3
59
+ UGT_MULTIPLEX = 1 << 4
60
+ UGT_MULTISINK = 1 << 5
61
+ UGT_MULTISOURCE = 1 << 6
62
+
63
+
64
+ class BgsFeatures(IntFlag):
65
+ BGS_96_KBPS = 1 << 0
66
+
67
+
68
+ class BgrFeatures(IntFlag):
69
+ BGR_MULTISINK = 1 << 0
70
+ BGR_MULTIPLEX = 1 << 1
71
+
72
+
73
+ # -----------------------------------------------------------------------------
74
+ # Server
75
+ # -----------------------------------------------------------------------------
76
+ class GamingAudioService(TemplateService):
77
+ UUID = GATT_GAMING_AUDIO_SERVICE
78
+
79
+ gmap_role: Characteristic
80
+ ugg_features: Optional[Characteristic] = None
81
+ ugt_features: Optional[Characteristic] = None
82
+ bgs_features: Optional[Characteristic] = None
83
+ bgr_features: Optional[Characteristic] = None
84
+
85
+ def __init__(
86
+ self,
87
+ gmap_role: GmapRole,
88
+ ugg_features: Optional[UggFeatures] = None,
89
+ ugt_features: Optional[UgtFeatures] = None,
90
+ bgs_features: Optional[BgsFeatures] = None,
91
+ bgr_features: Optional[BgrFeatures] = None,
92
+ ) -> None:
93
+ characteristics = []
94
+
95
+ ugg_features = UggFeatures(0) if ugg_features is None else ugg_features
96
+ ugt_features = UgtFeatures(0) if ugt_features is None else ugt_features
97
+ bgs_features = BgsFeatures(0) if bgs_features is None else bgs_features
98
+ bgr_features = BgrFeatures(0) if bgr_features is None else bgr_features
99
+
100
+ self.gmap_role = Characteristic(
101
+ uuid=GATT_GMAP_ROLE_CHARACTERISTIC,
102
+ properties=Characteristic.Properties.READ,
103
+ permissions=Characteristic.Permissions.READABLE,
104
+ value=struct.pack('B', gmap_role),
105
+ )
106
+ characteristics.append(self.gmap_role)
107
+
108
+ if gmap_role & GmapRole.UNICAST_GAME_GATEWAY:
109
+ self.ugg_features = Characteristic(
110
+ uuid=GATT_UGG_FEATURES_CHARACTERISTIC,
111
+ properties=Characteristic.Properties.READ,
112
+ permissions=Characteristic.Permissions.READABLE,
113
+ value=struct.pack('B', ugg_features),
114
+ )
115
+ characteristics.append(self.ugg_features)
116
+
117
+ if gmap_role & GmapRole.UNICAST_GAME_TERMINAL:
118
+ self.ugt_features = Characteristic(
119
+ uuid=GATT_UGT_FEATURES_CHARACTERISTIC,
120
+ properties=Characteristic.Properties.READ,
121
+ permissions=Characteristic.Permissions.READABLE,
122
+ value=struct.pack('B', ugt_features),
123
+ )
124
+ characteristics.append(self.ugt_features)
125
+
126
+ if gmap_role & GmapRole.BROADCAST_GAME_SENDER:
127
+ self.bgs_features = Characteristic(
128
+ uuid=GATT_BGS_FEATURES_CHARACTERISTIC,
129
+ properties=Characteristic.Properties.READ,
130
+ permissions=Characteristic.Permissions.READABLE,
131
+ value=struct.pack('B', bgs_features),
132
+ )
133
+ characteristics.append(self.bgs_features)
134
+
135
+ if gmap_role & GmapRole.BROADCAST_GAME_RECEIVER:
136
+ self.bgr_features = Characteristic(
137
+ uuid=GATT_BGR_FEATURES_CHARACTERISTIC,
138
+ properties=Characteristic.Properties.READ,
139
+ permissions=Characteristic.Permissions.READABLE,
140
+ value=struct.pack('B', bgr_features),
141
+ )
142
+ characteristics.append(self.bgr_features)
143
+
144
+ super().__init__(characteristics)
145
+
146
+
147
+ # -----------------------------------------------------------------------------
148
+ # Client
149
+ # -----------------------------------------------------------------------------
150
+ class GamingAudioServiceProxy(ProfileServiceProxy):
151
+ SERVICE_CLASS = GamingAudioService
152
+
153
+ def __init__(self, service_proxy: ServiceProxy) -> None:
154
+ self.service_proxy = service_proxy
155
+
156
+ self.gmap_role = DelegatedCharacteristicAdapter(
157
+ service_proxy.get_required_characteristic_by_uuid(
158
+ GATT_GMAP_ROLE_CHARACTERISTIC
159
+ ),
160
+ decode=lambda value: GmapRole(value[0]),
161
+ )
162
+
163
+ if characteristics := service_proxy.get_characteristics_by_uuid(
164
+ GATT_UGG_FEATURES_CHARACTERISTIC
165
+ ):
166
+ self.ugg_features = DelegatedCharacteristicAdapter(
167
+ characteristic=characteristics[0],
168
+ decode=lambda value: UggFeatures(value[0]),
169
+ )
170
+
171
+ if characteristics := service_proxy.get_characteristics_by_uuid(
172
+ GATT_UGT_FEATURES_CHARACTERISTIC
173
+ ):
174
+ self.ugt_features = DelegatedCharacteristicAdapter(
175
+ characteristic=characteristics[0],
176
+ decode=lambda value: UgtFeatures(value[0]),
177
+ )
178
+
179
+ if characteristics := service_proxy.get_characteristics_by_uuid(
180
+ GATT_BGS_FEATURES_CHARACTERISTIC
181
+ ):
182
+ self.bgs_features = DelegatedCharacteristicAdapter(
183
+ characteristic=characteristics[0],
184
+ decode=lambda value: BgsFeatures(value[0]),
185
+ )
186
+
187
+ if characteristics := service_proxy.get_characteristics_by_uuid(
188
+ GATT_BGR_FEATURES_CHARACTERISTIC
189
+ ):
190
+ self.bgr_features = DelegatedCharacteristicAdapter(
191
+ characteristic=characteristics[0],
192
+ decode=lambda value: BgrFeatures(value[0]),
193
+ )
@@ -17,23 +17,35 @@
17
17
  # -----------------------------------------------------------------------------
18
18
  from __future__ import annotations
19
19
  import dataclasses
20
+ import enum
20
21
  import struct
21
- from typing import List, Type
22
+ from typing import Any, List, Type
22
23
  from typing_extensions import Self
23
24
 
25
+ from bumble.profiles import bap
24
26
  from bumble import utils
25
27
 
26
28
 
27
29
  # -----------------------------------------------------------------------------
28
30
  # Classes
29
31
  # -----------------------------------------------------------------------------
32
+ class AudioActiveState(utils.OpenIntEnum):
33
+ NO_AUDIO_DATA_TRANSMITTED = 0x00
34
+ AUDIO_DATA_TRANSMITTED = 0x01
35
+
36
+
37
+ class AssistedListeningStream(utils.OpenIntEnum):
38
+ UNSPECIFIED_AUDIO_ENHANCEMENT = 0x00
39
+
40
+
30
41
  @dataclasses.dataclass
31
42
  class Metadata:
32
43
  '''Bluetooth Assigned Numbers, Section 6.12.6 - Metadata LTV structures.
33
44
 
34
- As Metadata fields may extend, and Spec doesn't forbid duplication, we don't parse
35
- Metadata into a key-value style dataclass here. Rather, we encourage users to parse
36
- again outside the lib.
45
+ As Metadata fields may extend, and the spec may not guarantee the uniqueness of
46
+ tags, we don't automatically parse the Metadata data into specific classes.
47
+ Users of this class may decode the data by themselves, or use the Entry.decode
48
+ method.
37
49
  '''
38
50
 
39
51
  class Tag(utils.OpenIntEnum):
@@ -57,6 +69,44 @@ class Metadata:
57
69
  tag: Metadata.Tag
58
70
  data: bytes
59
71
 
72
+ def decode(self) -> Any:
73
+ """
74
+ Decode the data into an object, if possible.
75
+
76
+ If no specific object class exists to represent the data, the raw data
77
+ bytes are returned.
78
+ """
79
+
80
+ if self.tag in (
81
+ Metadata.Tag.PREFERRED_AUDIO_CONTEXTS,
82
+ Metadata.Tag.STREAMING_AUDIO_CONTEXTS,
83
+ ):
84
+ return bap.ContextType(struct.unpack("<H", self.data)[0])
85
+
86
+ if self.tag in (
87
+ Metadata.Tag.PROGRAM_INFO,
88
+ Metadata.Tag.PROGRAM_INFO_URI,
89
+ Metadata.Tag.BROADCAST_NAME,
90
+ ):
91
+ return self.data.decode("utf-8")
92
+
93
+ if self.tag == Metadata.Tag.LANGUAGE:
94
+ return self.data.decode("ascii")
95
+
96
+ if self.tag == Metadata.Tag.CCID_LIST:
97
+ return list(self.data)
98
+
99
+ if self.tag == Metadata.Tag.PARENTAL_RATING:
100
+ return self.data[0]
101
+
102
+ if self.tag == Metadata.Tag.AUDIO_ACTIVE_STATE:
103
+ return AudioActiveState(self.data[0])
104
+
105
+ if self.tag == Metadata.Tag.ASSISTED_LISTENING_STREAM:
106
+ return AssistedListeningStream(self.data[0])
107
+
108
+ return self.data
109
+
60
110
  @classmethod
61
111
  def from_bytes(cls: Type[Self], data: bytes) -> Self:
62
112
  return cls(tag=Metadata.Tag(data[0]), data=data[1:])
@@ -66,6 +116,29 @@ class Metadata:
66
116
 
67
117
  entries: List[Entry] = dataclasses.field(default_factory=list)
68
118
 
119
+ def pretty_print(self, indent: str) -> str:
120
+ """Convenience method to generate a string with one key-value pair per line."""
121
+
122
+ max_key_length = 0
123
+ keys = []
124
+ values = []
125
+ for entry in self.entries:
126
+ key = entry.tag.name
127
+ max_key_length = max(max_key_length, len(key))
128
+ keys.append(key)
129
+ decoded = entry.decode()
130
+ if isinstance(decoded, enum.Enum):
131
+ values.append(decoded.name)
132
+ elif isinstance(decoded, bytes):
133
+ values.append(decoded.hex())
134
+ else:
135
+ values.append(str(decoded))
136
+
137
+ return '\n'.join(
138
+ f'{indent}{key}: {" " * (max_key_length-len(key))}{value}'
139
+ for key, value in zip(keys, values)
140
+ )
141
+
69
142
  @classmethod
70
143
  def from_bytes(cls: Type[Self], data: bytes) -> Self:
71
144
  entries = []
@@ -81,3 +154,13 @@ class Metadata:
81
154
 
82
155
  def __bytes__(self) -> bytes:
83
156
  return b''.join([bytes(entry) for entry in self.entries])
157
+
158
+ def __str__(self) -> str:
159
+ entries_str = []
160
+ for entry in self.entries:
161
+ decoded = entry.decode()
162
+ entries_str.append(
163
+ f'{entry.tag.name}: '
164
+ f'{decoded.hex() if isinstance(decoded, bytes) else decoded!r}'
165
+ )
166
+ return f'Metadata(entries={", ".join(entry_str for entry_str in entries_str)})'