bumble 0.0.203__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.
Files changed (50) hide show
  1. bumble/_version.py +2 -2
  2. bumble/apps/auracast.py +626 -87
  3. bumble/apps/bench.py +227 -148
  4. bumble/apps/controller_info.py +23 -7
  5. bumble/apps/device_info.py +50 -4
  6. bumble/apps/lea_unicast/app.py +61 -201
  7. bumble/apps/pair.py +13 -8
  8. bumble/apps/show.py +6 -6
  9. bumble/att.py +10 -11
  10. bumble/audio/__init__.py +17 -0
  11. bumble/audio/io.py +553 -0
  12. bumble/controller.py +24 -9
  13. bumble/core.py +4 -1
  14. bumble/device.py +993 -48
  15. bumble/drivers/common.py +2 -0
  16. bumble/drivers/intel.py +593 -24
  17. bumble/gatt.py +67 -12
  18. bumble/gatt_client.py +14 -2
  19. bumble/gatt_server.py +12 -1
  20. bumble/hci.py +854 -33
  21. bumble/host.py +363 -64
  22. bumble/l2cap.py +3 -16
  23. bumble/pairing.py +3 -0
  24. bumble/profiles/aics.py +45 -80
  25. bumble/profiles/ascs.py +6 -18
  26. bumble/profiles/asha.py +5 -5
  27. bumble/profiles/bass.py +9 -21
  28. bumble/profiles/device_information_service.py +4 -1
  29. bumble/profiles/gatt_service.py +166 -0
  30. bumble/profiles/gmap.py +193 -0
  31. bumble/profiles/heart_rate_service.py +5 -6
  32. bumble/profiles/le_audio.py +87 -4
  33. bumble/profiles/pacs.py +48 -16
  34. bumble/profiles/tmap.py +3 -9
  35. bumble/profiles/{vcp.py → vcs.py} +33 -28
  36. bumble/profiles/vocs.py +299 -0
  37. bumble/sdp.py +223 -93
  38. bumble/smp.py +8 -3
  39. bumble/tools/intel_fw_download.py +130 -0
  40. bumble/tools/intel_util.py +154 -0
  41. bumble/transport/usb.py +8 -2
  42. bumble/utils.py +22 -7
  43. bumble/vendor/android/hci.py +29 -4
  44. {bumble-0.0.203.dist-info → bumble-0.0.207.dist-info}/METADATA +12 -10
  45. {bumble-0.0.203.dist-info → bumble-0.0.207.dist-info}/RECORD +49 -43
  46. {bumble-0.0.203.dist-info → bumble-0.0.207.dist-info}/WHEEL +1 -1
  47. {bumble-0.0.203.dist-info → bumble-0.0.207.dist-info}/entry_points.txt +3 -0
  48. bumble/apps/lea_unicast/liblc3.wasm +0 -0
  49. {bumble-0.0.203.dist-info → bumble-0.0.207.dist-info}/LICENSE +0 -0
  50. {bumble-0.0.203.dist-info → bumble-0.0.207.dist-info}/top_level.txt +0 -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
+ )
@@ -30,6 +30,7 @@ from ..gatt import (
30
30
  TemplateService,
31
31
  Characteristic,
32
32
  CharacteristicValue,
33
+ SerializableCharacteristicAdapter,
33
34
  DelegatedCharacteristicAdapter,
34
35
  PackedCharacteristicAdapter,
35
36
  )
@@ -150,15 +151,14 @@ class HeartRateService(TemplateService):
150
151
  body_sensor_location=None,
151
152
  reset_energy_expended=None,
152
153
  ):
153
- self.heart_rate_measurement_characteristic = DelegatedCharacteristicAdapter(
154
+ self.heart_rate_measurement_characteristic = SerializableCharacteristicAdapter(
154
155
  Characteristic(
155
156
  GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC,
156
157
  Characteristic.Properties.NOTIFY,
157
158
  0,
158
159
  CharacteristicValue(read=read_heart_rate_measurement),
159
160
  ),
160
- # pylint: disable=unnecessary-lambda
161
- encode=lambda value: bytes(value),
161
+ HeartRateService.HeartRateMeasurement,
162
162
  )
163
163
  characteristics = [self.heart_rate_measurement_characteristic]
164
164
 
@@ -204,9 +204,8 @@ class HeartRateServiceProxy(ProfileServiceProxy):
204
204
  if characteristics := service_proxy.get_characteristics_by_uuid(
205
205
  GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC
206
206
  ):
207
- self.heart_rate_measurement = DelegatedCharacteristicAdapter(
208
- characteristics[0],
209
- decode=HeartRateService.HeartRateMeasurement.from_bytes,
207
+ self.heart_rate_measurement = SerializableCharacteristicAdapter(
208
+ characteristics[0], HeartRateService.HeartRateMeasurement
210
209
  )
211
210
  else:
212
211
  self.heart_rate_measurement = None
@@ -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)})'
bumble/profiles/pacs.py CHANGED
@@ -72,6 +72,19 @@ class PacRecord:
72
72
  metadata=metadata,
73
73
  )
74
74
 
75
+ @classmethod
76
+ def list_from_bytes(cls, data: bytes) -> list[PacRecord]:
77
+ """Parse a serialized list of records preceded by a one byte list length."""
78
+ record_count = data[0]
79
+ records = []
80
+ offset = 1
81
+ for _ in range(record_count):
82
+ record = PacRecord.from_bytes(data[offset:])
83
+ offset += len(bytes(record))
84
+ records.append(record)
85
+
86
+ return records
87
+
75
88
  def __bytes__(self) -> bytes:
76
89
  capabilities_bytes = bytes(self.codec_specific_capabilities)
77
90
  metadata_bytes = bytes(self.metadata)
@@ -172,39 +185,58 @@ class PublishedAudioCapabilitiesService(gatt.TemplateService):
172
185
  class PublishedAudioCapabilitiesServiceProxy(gatt_client.ProfileServiceProxy):
173
186
  SERVICE_CLASS = PublishedAudioCapabilitiesService
174
187
 
175
- sink_pac: Optional[gatt_client.CharacteristicProxy] = None
176
- sink_audio_locations: Optional[gatt_client.CharacteristicProxy] = None
177
- source_pac: Optional[gatt_client.CharacteristicProxy] = None
178
- source_audio_locations: Optional[gatt_client.CharacteristicProxy] = None
179
- available_audio_contexts: gatt_client.CharacteristicProxy
180
- supported_audio_contexts: gatt_client.CharacteristicProxy
188
+ sink_pac: Optional[gatt.DelegatedCharacteristicAdapter] = None
189
+ sink_audio_locations: Optional[gatt.DelegatedCharacteristicAdapter] = None
190
+ source_pac: Optional[gatt.DelegatedCharacteristicAdapter] = None
191
+ source_audio_locations: Optional[gatt.DelegatedCharacteristicAdapter] = None
192
+ available_audio_contexts: gatt.DelegatedCharacteristicAdapter
193
+ supported_audio_contexts: gatt.DelegatedCharacteristicAdapter
181
194
 
182
195
  def __init__(self, service_proxy: gatt_client.ServiceProxy):
183
196
  self.service_proxy = service_proxy
184
197
 
185
- self.available_audio_contexts = service_proxy.get_characteristics_by_uuid(
186
- gatt.GATT_AVAILABLE_AUDIO_CONTEXTS_CHARACTERISTIC
187
- )[0]
188
- self.supported_audio_contexts = service_proxy.get_characteristics_by_uuid(
189
- gatt.GATT_SUPPORTED_AUDIO_CONTEXTS_CHARACTERISTIC
190
- )[0]
198
+ self.available_audio_contexts = gatt.DelegatedCharacteristicAdapter(
199
+ service_proxy.get_required_characteristic_by_uuid(
200
+ gatt.GATT_AVAILABLE_AUDIO_CONTEXTS_CHARACTERISTIC
201
+ ),
202
+ decode=lambda x: tuple(map(ContextType, struct.unpack('<HH', x))),
203
+ )
204
+
205
+ self.supported_audio_contexts = gatt.DelegatedCharacteristicAdapter(
206
+ service_proxy.get_required_characteristic_by_uuid(
207
+ gatt.GATT_SUPPORTED_AUDIO_CONTEXTS_CHARACTERISTIC
208
+ ),
209
+ decode=lambda x: tuple(map(ContextType, struct.unpack('<HH', x))),
210
+ )
191
211
 
192
212
  if characteristics := service_proxy.get_characteristics_by_uuid(
193
213
  gatt.GATT_SINK_PAC_CHARACTERISTIC
194
214
  ):
195
- self.sink_pac = characteristics[0]
215
+ self.sink_pac = gatt.DelegatedCharacteristicAdapter(
216
+ characteristics[0],
217
+ decode=PacRecord.list_from_bytes,
218
+ )
196
219
 
197
220
  if characteristics := service_proxy.get_characteristics_by_uuid(
198
221
  gatt.GATT_SOURCE_PAC_CHARACTERISTIC
199
222
  ):
200
- self.source_pac = characteristics[0]
223
+ self.source_pac = gatt.DelegatedCharacteristicAdapter(
224
+ characteristics[0],
225
+ decode=PacRecord.list_from_bytes,
226
+ )
201
227
 
202
228
  if characteristics := service_proxy.get_characteristics_by_uuid(
203
229
  gatt.GATT_SINK_AUDIO_LOCATION_CHARACTERISTIC
204
230
  ):
205
- self.sink_audio_locations = characteristics[0]
231
+ self.sink_audio_locations = gatt.DelegatedCharacteristicAdapter(
232
+ characteristics[0],
233
+ decode=lambda x: AudioLocation(struct.unpack('<I', x)[0]),
234
+ )
206
235
 
207
236
  if characteristics := service_proxy.get_characteristics_by_uuid(
208
237
  gatt.GATT_SOURCE_AUDIO_LOCATION_CHARACTERISTIC
209
238
  ):
210
- self.source_audio_locations = characteristics[0]
239
+ self.source_audio_locations = gatt.DelegatedCharacteristicAdapter(
240
+ characteristics[0],
241
+ decode=lambda x: AudioLocation(struct.unpack('<I', x)[0]),
242
+ )
bumble/profiles/tmap.py CHANGED
@@ -25,7 +25,6 @@ from bumble.gatt import (
25
25
  TemplateService,
26
26
  Characteristic,
27
27
  DelegatedCharacteristicAdapter,
28
- InvalidServiceError,
29
28
  GATT_TELEPHONY_AND_MEDIA_AUDIO_SERVICE,
30
29
  GATT_TMAP_ROLE_CHARACTERISTIC,
31
30
  )
@@ -74,15 +73,10 @@ class TelephonyAndMediaAudioServiceProxy(ProfileServiceProxy):
74
73
  def __init__(self, service_proxy: ServiceProxy):
75
74
  self.service_proxy = service_proxy
76
75
 
77
- if not (
78
- characteristics := service_proxy.get_characteristics_by_uuid(
79
- GATT_TMAP_ROLE_CHARACTERISTIC
80
- )
81
- ):
82
- raise InvalidServiceError('TMAP Role characteristic not found')
83
-
84
76
  self.role = DelegatedCharacteristicAdapter(
85
- characteristics[0],
77
+ service_proxy.get_required_characteristic_by_uuid(
78
+ GATT_TMAP_ROLE_CHARACTERISTIC
79
+ ),
86
80
  decode=lambda value: Role(
87
81
  struct.unpack_from('<H', value, 0)[0],
88
82
  ),
@@ -1,4 +1,4 @@
1
- # Copyright 2021-2024 Google LLC
1
+ # Copyright 2021-2025 Google LLC
2
2
  #
3
3
  # Licensed under the Apache License, Version 2.0 (the "License");
4
4
  # you may not use this file except in compliance with the License.
@@ -17,14 +17,16 @@
17
17
  # Imports
18
18
  # -----------------------------------------------------------------------------
19
19
  from __future__ import annotations
20
+ import dataclasses
20
21
  import enum
21
22
 
23
+ from typing import Optional, Sequence
24
+
22
25
  from bumble import att
23
26
  from bumble import device
24
27
  from bumble import gatt
25
28
  from bumble import gatt_client
26
29
 
27
- from typing import Optional, Sequence
28
30
 
29
31
  # -----------------------------------------------------------------------------
30
32
  # Constants
@@ -67,6 +69,20 @@ class VolumeControlPointOpcode(enum.IntEnum):
67
69
  MUTE = 0x06
68
70
 
69
71
 
72
+ @dataclasses.dataclass
73
+ class VolumeState:
74
+ volume_setting: int
75
+ mute: int
76
+ change_counter: int
77
+
78
+ @classmethod
79
+ def from_bytes(cls, data: bytes) -> VolumeState:
80
+ return cls(data[0], data[1], data[2])
81
+
82
+ def __bytes__(self) -> bytes:
83
+ return bytes([self.volume_setting, self.mute, self.change_counter])
84
+
85
+
70
86
  # -----------------------------------------------------------------------------
71
87
  # Server
72
88
  # -----------------------------------------------------------------------------
@@ -126,16 +142,8 @@ class VolumeControlService(gatt.TemplateService):
126
142
  included_services=list(included_services),
127
143
  )
128
144
 
129
- @property
130
- def volume_state_bytes(self) -> bytes:
131
- return bytes([self.volume_setting, self.muted, self.change_counter])
132
-
133
- @volume_state_bytes.setter
134
- def volume_state_bytes(self, new_value: bytes) -> None:
135
- self.volume_setting, self.muted, self.change_counter = new_value
136
-
137
145
  def _on_read_volume_state(self, _connection: Optional[device.Connection]) -> bytes:
138
- return self.volume_state_bytes
146
+ return bytes(VolumeState(self.volume_setting, self.muted, self.change_counter))
139
147
 
140
148
  def _on_write_volume_control_point(
141
149
  self, connection: Optional[device.Connection], value: bytes
@@ -153,14 +161,9 @@ class VolumeControlService(gatt.TemplateService):
153
161
  self.change_counter = (self.change_counter + 1) % 256
154
162
  connection.abort_on(
155
163
  'disconnection',
156
- connection.device.notify_subscribers(
157
- attribute=self.volume_state,
158
- value=self.volume_state_bytes,
159
- ),
160
- )
161
- self.emit(
162
- 'volume_state', self.volume_setting, self.muted, self.change_counter
164
+ connection.device.notify_subscribers(attribute=self.volume_state),
163
165
  )
166
+ self.emit('volume_state_change')
164
167
 
165
168
  def _on_relative_volume_down(self) -> bool:
166
169
  old_volume = self.volume_setting
@@ -207,24 +210,26 @@ class VolumeControlServiceProxy(gatt_client.ProfileServiceProxy):
207
210
  SERVICE_CLASS = VolumeControlService
208
211
 
209
212
  volume_control_point: gatt_client.CharacteristicProxy
213
+ volume_state: gatt.SerializableCharacteristicAdapter
214
+ volume_flags: gatt.DelegatedCharacteristicAdapter
210
215
 
211
216
  def __init__(self, service_proxy: gatt_client.ServiceProxy) -> None:
212
217
  self.service_proxy = service_proxy
213
218
 
214
- self.volume_state = gatt.PackedCharacteristicAdapter(
215
- service_proxy.get_characteristics_by_uuid(
219
+ self.volume_state = gatt.SerializableCharacteristicAdapter(
220
+ service_proxy.get_required_characteristic_by_uuid(
216
221
  gatt.GATT_VOLUME_STATE_CHARACTERISTIC
217
- )[0],
218
- 'BBB',
222
+ ),
223
+ VolumeState,
219
224
  )
220
225
 
221
- self.volume_control_point = service_proxy.get_characteristics_by_uuid(
226
+ self.volume_control_point = service_proxy.get_required_characteristic_by_uuid(
222
227
  gatt.GATT_VOLUME_CONTROL_POINT_CHARACTERISTIC
223
- )[0]
228
+ )
224
229
 
225
- self.volume_flags = gatt.PackedCharacteristicAdapter(
226
- service_proxy.get_characteristics_by_uuid(
230
+ self.volume_flags = gatt.DelegatedCharacteristicAdapter(
231
+ service_proxy.get_required_characteristic_by_uuid(
227
232
  gatt.GATT_VOLUME_FLAGS_CHARACTERISTIC
228
- )[0],
229
- 'B',
233
+ ),
234
+ decode=lambda data: VolumeFlags(data[0]),
230
235
  )