bumble 0.0.194__py3-none-any.whl → 0.0.198__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 (54) hide show
  1. bumble/_version.py +2 -2
  2. bumble/apps/auracast.py +692 -0
  3. bumble/apps/bench.py +77 -23
  4. bumble/apps/console.py +5 -20
  5. bumble/apps/controller_info.py +3 -3
  6. bumble/apps/device_info.py +230 -0
  7. bumble/apps/gatt_dump.py +4 -0
  8. bumble/apps/lea_unicast/app.py +16 -17
  9. bumble/at.py +12 -6
  10. bumble/avc.py +8 -5
  11. bumble/avctp.py +3 -2
  12. bumble/avdtp.py +5 -1
  13. bumble/avrcp.py +2 -1
  14. bumble/codecs.py +17 -13
  15. bumble/colors.py +6 -2
  16. bumble/core.py +726 -122
  17. bumble/device.py +817 -117
  18. bumble/drivers/rtk.py +13 -8
  19. bumble/gatt.py +6 -1
  20. bumble/gatt_client.py +10 -4
  21. bumble/hci.py +283 -20
  22. bumble/hid.py +24 -28
  23. bumble/host.py +29 -0
  24. bumble/l2cap.py +24 -17
  25. bumble/link.py +8 -3
  26. bumble/pandora/host.py +3 -2
  27. bumble/profiles/ascs.py +739 -0
  28. bumble/profiles/bap.py +85 -862
  29. bumble/profiles/bass.py +440 -0
  30. bumble/profiles/csip.py +4 -4
  31. bumble/profiles/gap.py +110 -0
  32. bumble/profiles/heart_rate_service.py +4 -3
  33. bumble/profiles/le_audio.py +83 -0
  34. bumble/profiles/mcp.py +448 -0
  35. bumble/profiles/pacs.py +210 -0
  36. bumble/profiles/pbp.py +46 -0
  37. bumble/profiles/tmap.py +89 -0
  38. bumble/rfcomm.py +14 -3
  39. bumble/sdp.py +13 -11
  40. bumble/smp.py +20 -8
  41. bumble/snoop.py +5 -4
  42. bumble/transport/__init__.py +8 -2
  43. bumble/transport/android_emulator.py +9 -3
  44. bumble/transport/android_netsim.py +9 -7
  45. bumble/transport/common.py +46 -18
  46. bumble/transport/pyusb.py +2 -2
  47. bumble/transport/unix.py +56 -0
  48. bumble/transport/usb.py +57 -46
  49. {bumble-0.0.194.dist-info → bumble-0.0.198.dist-info}/METADATA +41 -41
  50. {bumble-0.0.194.dist-info → bumble-0.0.198.dist-info}/RECORD +54 -43
  51. {bumble-0.0.194.dist-info → bumble-0.0.198.dist-info}/WHEEL +1 -1
  52. {bumble-0.0.194.dist-info → bumble-0.0.198.dist-info}/LICENSE +0 -0
  53. {bumble-0.0.194.dist-info → bumble-0.0.198.dist-info}/entry_points.txt +0 -0
  54. {bumble-0.0.194.dist-info → bumble-0.0.198.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,440 @@
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
13
+
14
+ """LE Audio - Broadcast Audio Scan Service"""
15
+
16
+ # -----------------------------------------------------------------------------
17
+ # Imports
18
+ # -----------------------------------------------------------------------------
19
+ from __future__ import annotations
20
+ import dataclasses
21
+ import logging
22
+ import struct
23
+ from typing import ClassVar, List, Optional, Sequence
24
+
25
+ from bumble import core
26
+ from bumble import device
27
+ from bumble import gatt
28
+ from bumble import gatt_client
29
+ from bumble import hci
30
+ from bumble import utils
31
+
32
+ # -----------------------------------------------------------------------------
33
+ # Logging
34
+ # -----------------------------------------------------------------------------
35
+ logger = logging.getLogger(__name__)
36
+
37
+
38
+ # -----------------------------------------------------------------------------
39
+ # Constants
40
+ # -----------------------------------------------------------------------------
41
+ class ApplicationError(utils.OpenIntEnum):
42
+ OPCODE_NOT_SUPPORTED = 0x80
43
+ INVALID_SOURCE_ID = 0x81
44
+
45
+
46
+ # -----------------------------------------------------------------------------
47
+ def encode_subgroups(subgroups: Sequence[SubgroupInfo]) -> bytes:
48
+ return bytes([len(subgroups)]) + b"".join(
49
+ struct.pack("<IB", subgroup.bis_sync, len(subgroup.metadata))
50
+ + subgroup.metadata
51
+ for subgroup in subgroups
52
+ )
53
+
54
+
55
+ def decode_subgroups(data: bytes) -> List[SubgroupInfo]:
56
+ num_subgroups = data[0]
57
+ offset = 1
58
+ subgroups = []
59
+ for _ in range(num_subgroups):
60
+ bis_sync = struct.unpack("<I", data[offset : offset + 4])[0]
61
+ metadata_length = data[offset + 4]
62
+ metadata = data[offset + 5 : offset + 5 + metadata_length]
63
+ offset += 5 + metadata_length
64
+ subgroups.append(SubgroupInfo(bis_sync, metadata))
65
+
66
+ return subgroups
67
+
68
+
69
+ # -----------------------------------------------------------------------------
70
+ class PeriodicAdvertisingSyncParams(utils.OpenIntEnum):
71
+ DO_NOT_SYNCHRONIZE_TO_PA = 0x00
72
+ SYNCHRONIZE_TO_PA_PAST_AVAILABLE = 0x01
73
+ SYNCHRONIZE_TO_PA_PAST_NOT_AVAILABLE = 0x02
74
+
75
+
76
+ @dataclasses.dataclass
77
+ class SubgroupInfo:
78
+ ANY_BIS: ClassVar[int] = 0xFFFFFFFF
79
+
80
+ bis_sync: int
81
+ metadata: bytes
82
+
83
+
84
+ class ControlPointOperation:
85
+ class OpCode(utils.OpenIntEnum):
86
+ REMOTE_SCAN_STOPPED = 0x00
87
+ REMOTE_SCAN_STARTED = 0x01
88
+ ADD_SOURCE = 0x02
89
+ MODIFY_SOURCE = 0x03
90
+ SET_BROADCAST_CODE = 0x04
91
+ REMOVE_SOURCE = 0x05
92
+
93
+ op_code: OpCode
94
+ parameters: bytes
95
+
96
+ @classmethod
97
+ def from_bytes(cls, data: bytes) -> ControlPointOperation:
98
+ op_code = data[0]
99
+
100
+ if op_code == cls.OpCode.REMOTE_SCAN_STOPPED:
101
+ return RemoteScanStoppedOperation()
102
+
103
+ if op_code == cls.OpCode.REMOTE_SCAN_STARTED:
104
+ return RemoteScanStartedOperation()
105
+
106
+ if op_code == cls.OpCode.ADD_SOURCE:
107
+ return AddSourceOperation.from_parameters(data[1:])
108
+
109
+ if op_code == cls.OpCode.MODIFY_SOURCE:
110
+ return ModifySourceOperation.from_parameters(data[1:])
111
+
112
+ if op_code == cls.OpCode.SET_BROADCAST_CODE:
113
+ return SetBroadcastCodeOperation.from_parameters(data[1:])
114
+
115
+ if op_code == cls.OpCode.REMOVE_SOURCE:
116
+ return RemoveSourceOperation.from_parameters(data[1:])
117
+
118
+ raise core.InvalidArgumentError("invalid op code")
119
+
120
+ def __init__(self, op_code: OpCode, parameters: bytes = b"") -> None:
121
+ self.op_code = op_code
122
+ self.parameters = parameters
123
+
124
+ def __bytes__(self) -> bytes:
125
+ return bytes([self.op_code]) + self.parameters
126
+
127
+
128
+ class RemoteScanStoppedOperation(ControlPointOperation):
129
+ def __init__(self) -> None:
130
+ super().__init__(ControlPointOperation.OpCode.REMOTE_SCAN_STOPPED)
131
+
132
+
133
+ class RemoteScanStartedOperation(ControlPointOperation):
134
+ def __init__(self) -> None:
135
+ super().__init__(ControlPointOperation.OpCode.REMOTE_SCAN_STARTED)
136
+
137
+
138
+ class AddSourceOperation(ControlPointOperation):
139
+ @classmethod
140
+ def from_parameters(cls, parameters: bytes) -> AddSourceOperation:
141
+ instance = cls.__new__(cls)
142
+ instance.op_code = ControlPointOperation.OpCode.ADD_SOURCE
143
+ instance.parameters = parameters
144
+ instance.advertiser_address = hci.Address.parse_address_preceded_by_type(
145
+ parameters, 1
146
+ )[1]
147
+ instance.advertising_sid = parameters[7]
148
+ instance.broadcast_id = int.from_bytes(parameters[8:11], "little")
149
+ instance.pa_sync = PeriodicAdvertisingSyncParams(parameters[11])
150
+ instance.pa_interval = struct.unpack("<H", parameters[12:14])[0]
151
+ instance.subgroups = decode_subgroups(parameters[14:])
152
+ return instance
153
+
154
+ def __init__(
155
+ self,
156
+ advertiser_address: hci.Address,
157
+ advertising_sid: int,
158
+ broadcast_id: int,
159
+ pa_sync: PeriodicAdvertisingSyncParams,
160
+ pa_interval: int,
161
+ subgroups: Sequence[SubgroupInfo],
162
+ ) -> None:
163
+ super().__init__(
164
+ ControlPointOperation.OpCode.ADD_SOURCE,
165
+ struct.pack(
166
+ "<B6sB3sBH",
167
+ advertiser_address.address_type,
168
+ bytes(advertiser_address),
169
+ advertising_sid,
170
+ broadcast_id.to_bytes(3, "little"),
171
+ pa_sync,
172
+ pa_interval,
173
+ )
174
+ + encode_subgroups(subgroups),
175
+ )
176
+ self.advertiser_address = advertiser_address
177
+ self.advertising_sid = advertising_sid
178
+ self.broadcast_id = broadcast_id
179
+ self.pa_sync = pa_sync
180
+ self.pa_interval = pa_interval
181
+ self.subgroups = list(subgroups)
182
+
183
+
184
+ class ModifySourceOperation(ControlPointOperation):
185
+ @classmethod
186
+ def from_parameters(cls, parameters: bytes) -> ModifySourceOperation:
187
+ instance = cls.__new__(cls)
188
+ instance.op_code = ControlPointOperation.OpCode.MODIFY_SOURCE
189
+ instance.parameters = parameters
190
+ instance.source_id = parameters[0]
191
+ instance.pa_sync = PeriodicAdvertisingSyncParams(parameters[1])
192
+ instance.pa_interval = struct.unpack("<H", parameters[2:4])[0]
193
+ instance.subgroups = decode_subgroups(parameters[4:])
194
+ return instance
195
+
196
+ def __init__(
197
+ self,
198
+ source_id: int,
199
+ pa_sync: PeriodicAdvertisingSyncParams,
200
+ pa_interval: int,
201
+ subgroups: Sequence[SubgroupInfo],
202
+ ) -> None:
203
+ super().__init__(
204
+ ControlPointOperation.OpCode.MODIFY_SOURCE,
205
+ struct.pack("<BBH", source_id, pa_sync, pa_interval)
206
+ + encode_subgroups(subgroups),
207
+ )
208
+ self.source_id = source_id
209
+ self.pa_sync = pa_sync
210
+ self.pa_interval = pa_interval
211
+ self.subgroups = list(subgroups)
212
+
213
+
214
+ class SetBroadcastCodeOperation(ControlPointOperation):
215
+ @classmethod
216
+ def from_parameters(cls, parameters: bytes) -> SetBroadcastCodeOperation:
217
+ instance = cls.__new__(cls)
218
+ instance.op_code = ControlPointOperation.OpCode.SET_BROADCAST_CODE
219
+ instance.parameters = parameters
220
+ instance.source_id = parameters[0]
221
+ instance.broadcast_code = parameters[1:17]
222
+ return instance
223
+
224
+ def __init__(
225
+ self,
226
+ source_id: int,
227
+ broadcast_code: bytes,
228
+ ) -> None:
229
+ super().__init__(
230
+ ControlPointOperation.OpCode.SET_BROADCAST_CODE,
231
+ bytes([source_id]) + broadcast_code,
232
+ )
233
+ self.source_id = source_id
234
+ self.broadcast_code = broadcast_code
235
+
236
+ if len(self.broadcast_code) != 16:
237
+ raise core.InvalidArgumentError("broadcast_code must be 16 bytes")
238
+
239
+
240
+ class RemoveSourceOperation(ControlPointOperation):
241
+ @classmethod
242
+ def from_parameters(cls, parameters: bytes) -> RemoveSourceOperation:
243
+ instance = cls.__new__(cls)
244
+ instance.op_code = ControlPointOperation.OpCode.REMOVE_SOURCE
245
+ instance.parameters = parameters
246
+ instance.source_id = parameters[0]
247
+ return instance
248
+
249
+ def __init__(self, source_id: int) -> None:
250
+ super().__init__(ControlPointOperation.OpCode.REMOVE_SOURCE, bytes([source_id]))
251
+ self.source_id = source_id
252
+
253
+
254
+ @dataclasses.dataclass
255
+ class BroadcastReceiveState:
256
+ class PeriodicAdvertisingSyncState(utils.OpenIntEnum):
257
+ NOT_SYNCHRONIZED_TO_PA = 0x00
258
+ SYNCINFO_REQUEST = 0x01
259
+ SYNCHRONIZED_TO_PA = 0x02
260
+ FAILED_TO_SYNCHRONIZE_TO_PA = 0x03
261
+ NO_PAST = 0x04
262
+
263
+ class BigEncryption(utils.OpenIntEnum):
264
+ NOT_ENCRYPTED = 0x00
265
+ BROADCAST_CODE_REQUIRED = 0x01
266
+ DECRYPTING = 0x02
267
+ BAD_CODE = 0x03
268
+
269
+ source_id: int
270
+ source_address: hci.Address
271
+ source_adv_sid: int
272
+ broadcast_id: int
273
+ pa_sync_state: PeriodicAdvertisingSyncState
274
+ big_encryption: BigEncryption
275
+ bad_code: bytes
276
+ subgroups: List[SubgroupInfo]
277
+
278
+ @classmethod
279
+ def from_bytes(cls, data: bytes) -> Optional[BroadcastReceiveState]:
280
+ if not data:
281
+ return None
282
+
283
+ source_id = data[0]
284
+ _, source_address = hci.Address.parse_address_preceded_by_type(data, 2)
285
+ source_adv_sid = data[8]
286
+ broadcast_id = int.from_bytes(data[9:12], "little")
287
+ pa_sync_state = cls.PeriodicAdvertisingSyncState(data[12])
288
+ big_encryption = cls.BigEncryption(data[13])
289
+ if big_encryption == cls.BigEncryption.BAD_CODE:
290
+ bad_code = data[14:30]
291
+ subgroups = decode_subgroups(data[30:])
292
+ else:
293
+ bad_code = b""
294
+ subgroups = decode_subgroups(data[14:])
295
+
296
+ return cls(
297
+ source_id,
298
+ source_address,
299
+ source_adv_sid,
300
+ broadcast_id,
301
+ pa_sync_state,
302
+ big_encryption,
303
+ bad_code,
304
+ subgroups,
305
+ )
306
+
307
+ def __bytes__(self) -> bytes:
308
+ return (
309
+ struct.pack(
310
+ "<BB6sB3sBB",
311
+ self.source_id,
312
+ self.source_address.address_type,
313
+ bytes(self.source_address),
314
+ self.source_adv_sid,
315
+ self.broadcast_id.to_bytes(3, "little"),
316
+ self.pa_sync_state,
317
+ self.big_encryption,
318
+ )
319
+ + self.bad_code
320
+ + encode_subgroups(self.subgroups)
321
+ )
322
+
323
+
324
+ # -----------------------------------------------------------------------------
325
+ class BroadcastAudioScanService(gatt.TemplateService):
326
+ UUID = gatt.GATT_BROADCAST_AUDIO_SCAN_SERVICE
327
+
328
+ def __init__(self):
329
+ self.broadcast_audio_scan_control_point_characteristic = gatt.Characteristic(
330
+ gatt.GATT_BROADCAST_AUDIO_SCAN_CONTROL_POINT_CHARACTERISTIC,
331
+ gatt.Characteristic.Properties.WRITE
332
+ | gatt.Characteristic.Properties.WRITE_WITHOUT_RESPONSE,
333
+ gatt.Characteristic.WRITEABLE,
334
+ gatt.CharacteristicValue(
335
+ write=self.on_broadcast_audio_scan_control_point_write
336
+ ),
337
+ )
338
+
339
+ self.broadcast_receive_state_characteristic = gatt.Characteristic(
340
+ gatt.GATT_BROADCAST_RECEIVE_STATE_CHARACTERISTIC,
341
+ gatt.Characteristic.Properties.READ | gatt.Characteristic.Properties.NOTIFY,
342
+ gatt.Characteristic.Permissions.READABLE
343
+ | gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
344
+ b"12", # TEST
345
+ )
346
+
347
+ super().__init__([self.battery_level_characteristic])
348
+
349
+ def on_broadcast_audio_scan_control_point_write(
350
+ self, connection: device.Connection, value: bytes
351
+ ) -> None:
352
+ pass
353
+
354
+
355
+ # -----------------------------------------------------------------------------
356
+ class BroadcastAudioScanServiceProxy(gatt_client.ProfileServiceProxy):
357
+ SERVICE_CLASS = BroadcastAudioScanService
358
+
359
+ broadcast_audio_scan_control_point: gatt_client.CharacteristicProxy
360
+ broadcast_receive_states: List[gatt.DelegatedCharacteristicAdapter]
361
+
362
+ def __init__(self, service_proxy: gatt_client.ServiceProxy):
363
+ self.service_proxy = service_proxy
364
+
365
+ if not (
366
+ characteristics := service_proxy.get_characteristics_by_uuid(
367
+ gatt.GATT_BROADCAST_AUDIO_SCAN_CONTROL_POINT_CHARACTERISTIC
368
+ )
369
+ ):
370
+ raise gatt.InvalidServiceError(
371
+ "Broadcast Audio Scan Control Point characteristic not found"
372
+ )
373
+ self.broadcast_audio_scan_control_point = characteristics[0]
374
+
375
+ if not (
376
+ characteristics := service_proxy.get_characteristics_by_uuid(
377
+ gatt.GATT_BROADCAST_RECEIVE_STATE_CHARACTERISTIC
378
+ )
379
+ ):
380
+ raise gatt.InvalidServiceError(
381
+ "Broadcast Receive State characteristic not found"
382
+ )
383
+ self.broadcast_receive_states = [
384
+ gatt.DelegatedCharacteristicAdapter(
385
+ characteristic, decode=BroadcastReceiveState.from_bytes
386
+ )
387
+ for characteristic in characteristics
388
+ ]
389
+
390
+ async def send_control_point_operation(
391
+ self, operation: ControlPointOperation
392
+ ) -> None:
393
+ await self.broadcast_audio_scan_control_point.write_value(
394
+ bytes(operation), with_response=True
395
+ )
396
+
397
+ async def remote_scan_started(self) -> None:
398
+ await self.send_control_point_operation(RemoteScanStartedOperation())
399
+
400
+ async def remote_scan_stopped(self) -> None:
401
+ await self.send_control_point_operation(RemoteScanStoppedOperation())
402
+
403
+ async def add_source(
404
+ self,
405
+ advertiser_address: hci.Address,
406
+ advertising_sid: int,
407
+ broadcast_id: int,
408
+ pa_sync: PeriodicAdvertisingSyncParams,
409
+ pa_interval: int,
410
+ subgroups: Sequence[SubgroupInfo],
411
+ ) -> None:
412
+ await self.send_control_point_operation(
413
+ AddSourceOperation(
414
+ advertiser_address,
415
+ advertising_sid,
416
+ broadcast_id,
417
+ pa_sync,
418
+ pa_interval,
419
+ subgroups,
420
+ )
421
+ )
422
+
423
+ async def modify_source(
424
+ self,
425
+ source_id: int,
426
+ pa_sync: PeriodicAdvertisingSyncParams,
427
+ pa_interval: int,
428
+ subgroups: Sequence[SubgroupInfo],
429
+ ) -> None:
430
+ await self.send_control_point_operation(
431
+ ModifySourceOperation(
432
+ source_id,
433
+ pa_sync,
434
+ pa_interval,
435
+ subgroups,
436
+ )
437
+ )
438
+
439
+ async def remove_source(self, source_id: int) -> None:
440
+ await self.send_control_point_operation(RemoveSourceOperation(source_id))
bumble/profiles/csip.py CHANGED
@@ -113,7 +113,7 @@ class CoordinatedSetIdentificationService(gatt.TemplateService):
113
113
  set_member_rank: Optional[int] = None,
114
114
  ) -> None:
115
115
  if len(set_identity_resolving_key) != SET_IDENTITY_RESOLVING_KEY_LENGTH:
116
- raise ValueError(
116
+ raise core.InvalidArgumentError(
117
117
  f'Invalid SIRK length {len(set_identity_resolving_key)}, expected {SET_IDENTITY_RESOLVING_KEY_LENGTH}'
118
118
  )
119
119
 
@@ -178,7 +178,7 @@ class CoordinatedSetIdentificationService(gatt.TemplateService):
178
178
  key = await connection.device.get_link_key(connection.peer_address)
179
179
 
180
180
  if not key:
181
- raise RuntimeError('LTK or LinkKey is not present')
181
+ raise core.InvalidOperationError('LTK or LinkKey is not present')
182
182
 
183
183
  sirk_bytes = sef(key, self.set_identity_resolving_key)
184
184
 
@@ -234,7 +234,7 @@ class CoordinatedSetIdentificationProxy(gatt_client.ProfileServiceProxy):
234
234
  '''Reads SIRK and decrypts if encrypted.'''
235
235
  response = await self.set_identity_resolving_key.read_value()
236
236
  if len(response) != SET_IDENTITY_RESOLVING_KEY_LENGTH + 1:
237
- raise RuntimeError('Invalid SIRK value')
237
+ raise core.InvalidPacketError('Invalid SIRK value')
238
238
 
239
239
  sirk_type = SirkType(response[0])
240
240
  if sirk_type == SirkType.PLAINTEXT:
@@ -250,7 +250,7 @@ class CoordinatedSetIdentificationProxy(gatt_client.ProfileServiceProxy):
250
250
  key = await device.get_link_key(connection.peer_address)
251
251
 
252
252
  if not key:
253
- raise RuntimeError('LTK or LinkKey is not present')
253
+ raise core.InvalidOperationError('LTK or LinkKey is not present')
254
254
 
255
255
  sirk = sef(key, response[1:])
256
256
 
bumble/profiles/gap.py ADDED
@@ -0,0 +1,110 @@
1
+ # Copyright 2021-2022 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
+ """Generic Access Profile"""
16
+
17
+ # -----------------------------------------------------------------------------
18
+ # Imports
19
+ # -----------------------------------------------------------------------------
20
+ import logging
21
+ import struct
22
+ from typing import Optional, Tuple, Union
23
+
24
+ from bumble.core import Appearance
25
+ from bumble.gatt import (
26
+ TemplateService,
27
+ Characteristic,
28
+ CharacteristicAdapter,
29
+ DelegatedCharacteristicAdapter,
30
+ UTF8CharacteristicAdapter,
31
+ GATT_GENERIC_ACCESS_SERVICE,
32
+ GATT_DEVICE_NAME_CHARACTERISTIC,
33
+ GATT_APPEARANCE_CHARACTERISTIC,
34
+ )
35
+ from bumble.gatt_client import ProfileServiceProxy, ServiceProxy
36
+
37
+ # -----------------------------------------------------------------------------
38
+ # Logging
39
+ # -----------------------------------------------------------------------------
40
+ logger = logging.getLogger(__name__)
41
+
42
+
43
+ # -----------------------------------------------------------------------------
44
+ # Classes
45
+ # -----------------------------------------------------------------------------
46
+
47
+
48
+ # -----------------------------------------------------------------------------
49
+ class GenericAccessService(TemplateService):
50
+ UUID = GATT_GENERIC_ACCESS_SERVICE
51
+
52
+ def __init__(
53
+ self, device_name: str, appearance: Union[Appearance, Tuple[int, int], int] = 0
54
+ ):
55
+ if isinstance(appearance, int):
56
+ appearance_int = appearance
57
+ elif isinstance(appearance, tuple):
58
+ appearance_int = (appearance[0] << 6) | appearance[1]
59
+ elif isinstance(appearance, Appearance):
60
+ appearance_int = int(appearance)
61
+ else:
62
+ raise TypeError()
63
+
64
+ self.device_name_characteristic = Characteristic(
65
+ GATT_DEVICE_NAME_CHARACTERISTIC,
66
+ Characteristic.Properties.READ,
67
+ Characteristic.READABLE,
68
+ device_name.encode('utf-8')[:248],
69
+ )
70
+
71
+ self.appearance_characteristic = Characteristic(
72
+ GATT_APPEARANCE_CHARACTERISTIC,
73
+ Characteristic.Properties.READ,
74
+ Characteristic.READABLE,
75
+ struct.pack('<H', appearance_int),
76
+ )
77
+
78
+ super().__init__(
79
+ [self.device_name_characteristic, self.appearance_characteristic]
80
+ )
81
+
82
+
83
+ # -----------------------------------------------------------------------------
84
+ class GenericAccessServiceProxy(ProfileServiceProxy):
85
+ SERVICE_CLASS = GenericAccessService
86
+
87
+ device_name: Optional[CharacteristicAdapter]
88
+ appearance: Optional[DelegatedCharacteristicAdapter]
89
+
90
+ def __init__(self, service_proxy: ServiceProxy):
91
+ self.service_proxy = service_proxy
92
+
93
+ if characteristics := service_proxy.get_characteristics_by_uuid(
94
+ GATT_DEVICE_NAME_CHARACTERISTIC
95
+ ):
96
+ self.device_name = UTF8CharacteristicAdapter(characteristics[0])
97
+ else:
98
+ self.device_name = None
99
+
100
+ if characteristics := service_proxy.get_characteristics_by_uuid(
101
+ GATT_APPEARANCE_CHARACTERISTIC
102
+ ):
103
+ self.appearance = DelegatedCharacteristicAdapter(
104
+ characteristics[0],
105
+ decode=lambda value: Appearance.from_int(
106
+ struct.unpack_from('<H', value, 0)[0],
107
+ ),
108
+ )
109
+ else:
110
+ self.appearance = None
@@ -19,6 +19,7 @@
19
19
  from enum import IntEnum
20
20
  import struct
21
21
 
22
+ from bumble import core
22
23
  from ..gatt_client import ProfileServiceProxy
23
24
  from ..att import ATT_Error
24
25
  from ..gatt import (
@@ -59,17 +60,17 @@ class HeartRateService(TemplateService):
59
60
  rr_intervals=None,
60
61
  ):
61
62
  if heart_rate < 0 or heart_rate > 0xFFFF:
62
- raise ValueError('heart_rate out of range')
63
+ raise core.InvalidArgumentError('heart_rate out of range')
63
64
 
64
65
  if energy_expended is not None and (
65
66
  energy_expended < 0 or energy_expended > 0xFFFF
66
67
  ):
67
- raise ValueError('energy_expended out of range')
68
+ raise core.InvalidArgumentError('energy_expended out of range')
68
69
 
69
70
  if rr_intervals:
70
71
  for rr_interval in rr_intervals:
71
72
  if rr_interval < 0 or rr_interval * 1024 > 0xFFFF:
72
- raise ValueError('rr_intervals out of range')
73
+ raise core.InvalidArgumentError('rr_intervals out of range')
73
74
 
74
75
  self.heart_rate = heart_rate
75
76
  self.sensor_contact_detected = sensor_contact_detected
@@ -0,0 +1,83 @@
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
+ # -----------------------------------------------------------------------------
16
+ # Imports
17
+ # -----------------------------------------------------------------------------
18
+ from __future__ import annotations
19
+ import dataclasses
20
+ import struct
21
+ from typing import List, Type
22
+ from typing_extensions import Self
23
+
24
+ from bumble import utils
25
+
26
+
27
+ # -----------------------------------------------------------------------------
28
+ # Classes
29
+ # -----------------------------------------------------------------------------
30
+ @dataclasses.dataclass
31
+ class Metadata:
32
+ '''Bluetooth Assigned Numbers, Section 6.12.6 - Metadata LTV structures.
33
+
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.
37
+ '''
38
+
39
+ class Tag(utils.OpenIntEnum):
40
+ # fmt: off
41
+ PREFERRED_AUDIO_CONTEXTS = 0x01
42
+ STREAMING_AUDIO_CONTEXTS = 0x02
43
+ PROGRAM_INFO = 0x03
44
+ LANGUAGE = 0x04
45
+ CCID_LIST = 0x05
46
+ PARENTAL_RATING = 0x06
47
+ PROGRAM_INFO_URI = 0x07
48
+ AUDIO_ACTIVE_STATE = 0x08
49
+ BROADCAST_AUDIO_IMMEDIATE_RENDERING_FLAG = 0x09
50
+ ASSISTED_LISTENING_STREAM = 0x0A
51
+ BROADCAST_NAME = 0x0B
52
+ EXTENDED_METADATA = 0xFE
53
+ VENDOR_SPECIFIC = 0xFF
54
+
55
+ @dataclasses.dataclass
56
+ class Entry:
57
+ tag: Metadata.Tag
58
+ data: bytes
59
+
60
+ @classmethod
61
+ def from_bytes(cls: Type[Self], data: bytes) -> Self:
62
+ return cls(tag=Metadata.Tag(data[0]), data=data[1:])
63
+
64
+ def __bytes__(self) -> bytes:
65
+ return bytes([len(self.data) + 1, self.tag]) + self.data
66
+
67
+ entries: List[Entry] = dataclasses.field(default_factory=list)
68
+
69
+ @classmethod
70
+ def from_bytes(cls: Type[Self], data: bytes) -> Self:
71
+ entries = []
72
+ offset = 0
73
+ length = len(data)
74
+ while offset < length:
75
+ entry_length = data[offset]
76
+ offset += 1
77
+ entries.append(cls.Entry.from_bytes(data[offset : offset + entry_length]))
78
+ offset += entry_length
79
+
80
+ return cls(entries)
81
+
82
+ def __bytes__(self) -> bytes:
83
+ return b''.join([bytes(entry) for entry in self.entries])