bumble 0.0.207__py3-none-any.whl → 0.0.209__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 (47) hide show
  1. bumble/_version.py +9 -4
  2. bumble/apps/auracast.py +29 -35
  3. bumble/apps/bench.py +13 -10
  4. bumble/apps/console.py +19 -12
  5. bumble/apps/gg_bridge.py +1 -1
  6. bumble/att.py +61 -39
  7. bumble/controller.py +7 -8
  8. bumble/core.py +306 -159
  9. bumble/device.py +127 -82
  10. bumble/gatt.py +25 -228
  11. bumble/gatt_adapters.py +374 -0
  12. bumble/gatt_client.py +38 -31
  13. bumble/gatt_server.py +5 -5
  14. bumble/hci.py +76 -71
  15. bumble/host.py +19 -8
  16. bumble/l2cap.py +2 -2
  17. bumble/link.py +2 -2
  18. bumble/pairing.py +5 -5
  19. bumble/pandora/host.py +19 -23
  20. bumble/pandora/security.py +2 -3
  21. bumble/pandora/utils.py +2 -2
  22. bumble/profiles/aics.py +33 -23
  23. bumble/profiles/ancs.py +514 -0
  24. bumble/profiles/ascs.py +2 -1
  25. bumble/profiles/asha.py +11 -9
  26. bumble/profiles/bass.py +8 -5
  27. bumble/profiles/battery_service.py +13 -3
  28. bumble/profiles/device_information_service.py +16 -14
  29. bumble/profiles/gap.py +12 -8
  30. bumble/profiles/gatt_service.py +1 -0
  31. bumble/profiles/gmap.py +16 -11
  32. bumble/profiles/hap.py +8 -6
  33. bumble/profiles/heart_rate_service.py +20 -4
  34. bumble/profiles/mcp.py +11 -9
  35. bumble/profiles/pacs.py +37 -24
  36. bumble/profiles/tmap.py +6 -4
  37. bumble/profiles/vcs.py +6 -5
  38. bumble/profiles/vocs.py +49 -41
  39. bumble/smp.py +3 -3
  40. bumble/transport/usb.py +1 -3
  41. bumble/utils.py +10 -0
  42. {bumble-0.0.207.dist-info → bumble-0.0.209.dist-info}/METADATA +3 -3
  43. {bumble-0.0.207.dist-info → bumble-0.0.209.dist-info}/RECORD +47 -45
  44. {bumble-0.0.207.dist-info → bumble-0.0.209.dist-info}/WHEEL +1 -1
  45. {bumble-0.0.207.dist-info → bumble-0.0.209.dist-info}/LICENSE +0 -0
  46. {bumble-0.0.207.dist-info → bumble-0.0.209.dist-info}/entry_points.txt +0 -0
  47. {bumble-0.0.207.dist-info → bumble-0.0.209.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,514 @@
1
+ # Copyright 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
+ """
16
+ Apple Notification Center Service (ANCS).
17
+ """
18
+
19
+ # -----------------------------------------------------------------------------
20
+ # Imports
21
+ # -----------------------------------------------------------------------------
22
+ from __future__ import annotations
23
+ import asyncio
24
+ import dataclasses
25
+ import datetime
26
+ import enum
27
+ import logging
28
+ import struct
29
+ from typing import Optional, Sequence, Union
30
+
31
+ from pyee import EventEmitter
32
+
33
+ from bumble.att import ATT_Error
34
+ from bumble.device import Peer
35
+ from bumble.gatt import (
36
+ Characteristic,
37
+ GATT_ANCS_SERVICE,
38
+ GATT_ANCS_NOTIFICATION_SOURCE_CHARACTERISTIC,
39
+ GATT_ANCS_CONTROL_POINT_CHARACTERISTIC,
40
+ GATT_ANCS_DATA_SOURCE_CHARACTERISTIC,
41
+ TemplateService,
42
+ )
43
+ from bumble.gatt_client import CharacteristicProxy, ProfileServiceProxy, ServiceProxy
44
+ from bumble.gatt_adapters import SerializableCharacteristicProxyAdapter
45
+ from bumble.utils import OpenIntEnum
46
+
47
+
48
+ # -----------------------------------------------------------------------------
49
+ # Constants
50
+ # -----------------------------------------------------------------------------
51
+ _DEFAULT_ATTRIBUTE_MAX_LENGTH = 65535
52
+
53
+
54
+ # -----------------------------------------------------------------------------
55
+ # Logging
56
+ # -----------------------------------------------------------------------------
57
+ logger = logging.getLogger(__name__)
58
+
59
+
60
+ # -----------------------------------------------------------------------------
61
+ # Protocol
62
+ # -----------------------------------------------------------------------------
63
+ class ActionId(OpenIntEnum):
64
+ POSITIVE = 0
65
+ NEGATIVE = 1
66
+
67
+
68
+ class AppAttributeId(OpenIntEnum):
69
+ DISPLAY_NAME = 0
70
+
71
+
72
+ class CategoryId(OpenIntEnum):
73
+ OTHER = 0
74
+ INCOMING_CALL = 1
75
+ MISSED_CALL = 2
76
+ VOICEMAIL = 3
77
+ SOCIAL = 4
78
+ SCHEDULE = 5
79
+ EMAIL = 6
80
+ NEWS = 7
81
+ HEALTH_AND_FITNESS = 8
82
+ BUSINESS_AND_FINANCE = 9
83
+ LOCATION = 10
84
+ ENTERTAINMENT = 11
85
+
86
+
87
+ class CommandId(OpenIntEnum):
88
+ GET_NOTIFICATION_ATTRIBUTES = 0
89
+ GET_APP_ATTRIBUTES = 1
90
+ PERFORM_NOTIFICATION_ACTION = 2
91
+
92
+
93
+ class EventId(OpenIntEnum):
94
+ NOTIFICATION_ADDED = 0
95
+ NOTIFICATION_MODIFIED = 1
96
+ NOTIFICATION_REMOVED = 2
97
+
98
+
99
+ class EventFlags(enum.IntFlag):
100
+ SILENT = 1 << 0
101
+ IMPORTANT = 1 << 1
102
+ PRE_EXISTING = 1 << 2
103
+ POSITIVE_ACTION = 1 << 3
104
+ NEGATIVE_ACTION = 1 << 4
105
+
106
+
107
+ class NotificationAttributeId(OpenIntEnum):
108
+ APP_IDENTIFIER = 0
109
+ TITLE = 1
110
+ SUBTITLE = 2
111
+ MESSAGE = 3
112
+ MESSAGE_SIZE = 4
113
+ DATE = 5
114
+ POSITIVE_ACTION_LABEL = 6
115
+ NEGATIVE_ACTION_LABEL = 7
116
+
117
+
118
+ @dataclasses.dataclass
119
+ class NotificationAttribute:
120
+ attribute_id: NotificationAttributeId
121
+ value: Union[str, int, datetime.datetime]
122
+
123
+
124
+ @dataclasses.dataclass
125
+ class AppAttribute:
126
+ attribute_id: AppAttributeId
127
+ value: str
128
+
129
+
130
+ @dataclasses.dataclass
131
+ class Notification:
132
+ event_id: EventId
133
+ event_flags: EventFlags
134
+ category_id: CategoryId
135
+ category_count: int
136
+ notification_uid: int
137
+
138
+ @classmethod
139
+ def from_bytes(cls, data: bytes) -> Notification:
140
+ return cls(
141
+ event_id=EventId(data[0]),
142
+ event_flags=EventFlags(data[1]),
143
+ category_id=CategoryId(data[2]),
144
+ category_count=data[3],
145
+ notification_uid=int.from_bytes(data[4:8], 'little'),
146
+ )
147
+
148
+ def __bytes__(self) -> bytes:
149
+ return struct.pack(
150
+ "<BBBBI",
151
+ self.event_id,
152
+ self.event_flags,
153
+ self.category_id,
154
+ self.category_count,
155
+ self.notification_uid,
156
+ )
157
+
158
+
159
+ class ErrorCode(OpenIntEnum):
160
+ UNKNOWN_COMMAND = 0xA0
161
+ INVALID_COMMAND = 0xA1
162
+ INVALID_PARAMETER = 0xA2
163
+ ACTION_FAILED = 0xA3
164
+
165
+
166
+ class ProtocolError(Exception):
167
+ pass
168
+
169
+
170
+ class CommandError(Exception):
171
+ def __init__(self, error_code: ErrorCode) -> None:
172
+ self.error_code = error_code
173
+
174
+ def __str__(self) -> str:
175
+ return f"CommandError(error_code={self.error_code.name})"
176
+
177
+
178
+ # -----------------------------------------------------------------------------
179
+ # GATT Server-side
180
+ # -----------------------------------------------------------------------------
181
+ class Ancs(TemplateService):
182
+ UUID = GATT_ANCS_SERVICE
183
+
184
+ notification_source_characteristic: Characteristic
185
+ data_source_characteristic: Characteristic
186
+ control_point_characteristic: Characteristic
187
+
188
+ def __init__(self) -> None:
189
+ # TODO not the final implementation
190
+ self.notification_source_characteristic = Characteristic(
191
+ GATT_ANCS_NOTIFICATION_SOURCE_CHARACTERISTIC,
192
+ Characteristic.Properties.NOTIFY,
193
+ Characteristic.Permissions.READABLE,
194
+ )
195
+
196
+ # TODO not the final implementation
197
+ self.data_source_characteristic = Characteristic(
198
+ GATT_ANCS_DATA_SOURCE_CHARACTERISTIC,
199
+ Characteristic.Properties.NOTIFY,
200
+ Characteristic.Permissions.READABLE,
201
+ )
202
+
203
+ # TODO not the final implementation
204
+ self.control_point_characteristic = Characteristic(
205
+ GATT_ANCS_CONTROL_POINT_CHARACTERISTIC,
206
+ Characteristic.Properties.WRITE,
207
+ Characteristic.Permissions.WRITEABLE,
208
+ )
209
+
210
+ super().__init__(
211
+ [
212
+ self.notification_source_characteristic,
213
+ self.data_source_characteristic,
214
+ self.control_point_characteristic,
215
+ ]
216
+ )
217
+
218
+
219
+ # -----------------------------------------------------------------------------
220
+ # GATT Client-side
221
+ # -----------------------------------------------------------------------------
222
+ class AncsProxy(ProfileServiceProxy):
223
+ SERVICE_CLASS = Ancs
224
+
225
+ notification_source: CharacteristicProxy[Notification]
226
+ data_source: CharacteristicProxy
227
+ control_point: CharacteristicProxy[bytes]
228
+
229
+ def __init__(self, service_proxy: ServiceProxy):
230
+ self.notification_source = SerializableCharacteristicProxyAdapter(
231
+ service_proxy.get_required_characteristic_by_uuid(
232
+ GATT_ANCS_NOTIFICATION_SOURCE_CHARACTERISTIC
233
+ ),
234
+ Notification,
235
+ )
236
+
237
+ self.data_source = service_proxy.get_required_characteristic_by_uuid(
238
+ GATT_ANCS_DATA_SOURCE_CHARACTERISTIC
239
+ )
240
+
241
+ self.control_point = service_proxy.get_required_characteristic_by_uuid(
242
+ GATT_ANCS_CONTROL_POINT_CHARACTERISTIC
243
+ )
244
+
245
+
246
+ class AncsClient(EventEmitter):
247
+ _expected_response_command_id: Optional[CommandId]
248
+ _expected_response_notification_uid: Optional[int]
249
+ _expected_response_app_identifier: Optional[str]
250
+ _expected_app_identifier: Optional[str]
251
+ _expected_response_tuples: int
252
+ _response_accumulator: bytes
253
+
254
+ def __init__(self, ancs_proxy: AncsProxy) -> None:
255
+ super().__init__()
256
+ self._ancs_proxy = ancs_proxy
257
+ self._command_semaphore = asyncio.Semaphore()
258
+ self._response: Optional[asyncio.Future] = None
259
+ self._reset_response()
260
+ self._started = False
261
+
262
+ @classmethod
263
+ async def for_peer(cls, peer: Peer) -> Optional[AncsClient]:
264
+ ancs_proxy = await peer.discover_service_and_create_proxy(AncsProxy)
265
+ if ancs_proxy is None:
266
+ return None
267
+ return cls(ancs_proxy)
268
+
269
+ async def start(self) -> None:
270
+ await self._ancs_proxy.notification_source.subscribe(self._on_notification)
271
+ await self._ancs_proxy.data_source.subscribe(self._on_data)
272
+ self._started = True
273
+
274
+ async def stop(self) -> None:
275
+ await self._ancs_proxy.notification_source.unsubscribe(self._on_notification)
276
+ await self._ancs_proxy.data_source.unsubscribe(self._on_data)
277
+ self._started = False
278
+
279
+ def _reset_response(self) -> None:
280
+ self._expected_response_command_id = None
281
+ self._expected_response_notification_uid = None
282
+ self._expected_app_identifier = None
283
+ self._expected_response_tuples = 0
284
+ self._response_accumulator = b""
285
+
286
+ def _on_notification(self, notification: Notification) -> None:
287
+ logger.debug(f"ANCS NOTIFICATION: {notification}")
288
+ self.emit("notification", notification)
289
+
290
+ def _on_data(self, data: bytes) -> None:
291
+ logger.debug(f"ANCS DATA: {data.hex()}")
292
+
293
+ if not self._response:
294
+ logger.warning("received unexpected data, discarding")
295
+ return
296
+
297
+ self._response_accumulator += data
298
+
299
+ # Try to parse the accumulated data until we have all we need.
300
+ if not self._response_accumulator:
301
+ logger.warning("empty data from data source")
302
+ return
303
+
304
+ command_id = self._response_accumulator[0]
305
+ if command_id != self._expected_response_command_id:
306
+ logger.warning(
307
+ "unexpected response command id: "
308
+ f"expected {self._expected_response_command_id} "
309
+ f"but got {command_id}"
310
+ )
311
+ self._reset_response()
312
+ if not self._response.done():
313
+ self._response.set_exception(ProtocolError())
314
+
315
+ if len(self._response_accumulator) < 5:
316
+ # Not enough data yet.
317
+ return
318
+
319
+ attributes: list[Union[NotificationAttribute, AppAttribute]] = []
320
+
321
+ if command_id == CommandId.GET_NOTIFICATION_ATTRIBUTES:
322
+ (notification_uid,) = struct.unpack_from(
323
+ "<I", self._response_accumulator, 1
324
+ )
325
+ if notification_uid != self._expected_response_notification_uid:
326
+ logger.warning(
327
+ "unexpected response notification uid: "
328
+ f"expected {self._expected_response_notification_uid} "
329
+ f"but got {notification_uid}"
330
+ )
331
+ self._reset_response()
332
+ if not self._response.done():
333
+ self._response.set_exception(ProtocolError())
334
+
335
+ attribute_data = self._response_accumulator[5:]
336
+ while len(attribute_data) >= 3:
337
+ attribute_id, attribute_data_length = struct.unpack_from(
338
+ "<BH", attribute_data, 0
339
+ )
340
+ if len(attribute_data) < 3 + attribute_data_length:
341
+ return
342
+ str_value = attribute_data[3 : 3 + attribute_data_length].decode(
343
+ "utf-8"
344
+ )
345
+ value: Union[str, int, datetime.datetime]
346
+ if attribute_id == NotificationAttributeId.MESSAGE_SIZE:
347
+ value = int(str_value)
348
+ elif attribute_id == NotificationAttributeId.DATE:
349
+ year = int(str_value[:4])
350
+ month = int(str_value[4:6])
351
+ day = int(str_value[6:8])
352
+ hour = int(str_value[9:11])
353
+ minute = int(str_value[11:13])
354
+ second = int(str_value[13:15])
355
+ value = datetime.datetime(year, month, day, hour, minute, second)
356
+ else:
357
+ value = str_value
358
+ attributes.append(
359
+ NotificationAttribute(NotificationAttributeId(attribute_id), value)
360
+ )
361
+ attribute_data = attribute_data[3 + attribute_data_length :]
362
+ elif command_id == CommandId.GET_APP_ATTRIBUTES:
363
+ if 0 not in self._response_accumulator[1:]:
364
+ # No null-terminated string yet.
365
+ return
366
+
367
+ app_identifier_length = self._response_accumulator.find(0, 1) - 1
368
+ app_identifier = self._response_accumulator[
369
+ 1 : 1 + app_identifier_length
370
+ ].decode("utf-8")
371
+ if app_identifier != self._expected_response_app_identifier:
372
+ logger.warning(
373
+ "unexpected response app identifier: "
374
+ f"expected {self._expected_response_app_identifier} "
375
+ f"but got {app_identifier}"
376
+ )
377
+ self._reset_response()
378
+ if not self._response.done():
379
+ self._response.set_exception(ProtocolError())
380
+
381
+ attribute_data = self._response_accumulator[1 + app_identifier_length + 1 :]
382
+ while len(attribute_data) >= 3:
383
+ attribute_id, attribute_data_length = struct.unpack_from(
384
+ "<BH", attribute_data, 0
385
+ )
386
+ if len(attribute_data) < 3 + attribute_data_length:
387
+ return
388
+ attributes.append(
389
+ AppAttribute(
390
+ AppAttributeId(attribute_id),
391
+ attribute_data[3 : 3 + attribute_data_length].decode("utf-8"),
392
+ )
393
+ )
394
+ attribute_data = attribute_data[3 + attribute_data_length :]
395
+ else:
396
+ logger.warning(f"unexpected response command id {command_id}")
397
+ return
398
+
399
+ if len(attributes) < self._expected_response_tuples:
400
+ # We have not received all the tuples yet.
401
+ return
402
+
403
+ if not self._response.done():
404
+ self._response.set_result(attributes)
405
+
406
+ async def _send_command(self, command: bytes) -> None:
407
+ try:
408
+ await self._ancs_proxy.control_point.write_value(
409
+ command, with_response=True
410
+ )
411
+ except ATT_Error as error:
412
+ raise CommandError(error_code=ErrorCode(error.error_code)) from error
413
+
414
+ async def get_notification_attributes(
415
+ self,
416
+ notification_uid: int,
417
+ attributes: Sequence[
418
+ Union[NotificationAttributeId, tuple[NotificationAttributeId, int]]
419
+ ],
420
+ ) -> list[NotificationAttribute]:
421
+ if not self._started:
422
+ raise RuntimeError("client not started")
423
+
424
+ command = struct.pack(
425
+ "<BI", CommandId.GET_NOTIFICATION_ATTRIBUTES, notification_uid
426
+ )
427
+ for attribute in attributes:
428
+ attribute_max_length = 0
429
+ if isinstance(attribute, tuple):
430
+ attribute_id, attribute_max_length = attribute
431
+ if attribute_id not in (
432
+ NotificationAttributeId.TITLE,
433
+ NotificationAttributeId.SUBTITLE,
434
+ NotificationAttributeId.MESSAGE,
435
+ ):
436
+ raise ValueError(
437
+ "this attribute does not allow specifying a max length"
438
+ )
439
+ else:
440
+ attribute_id = attribute
441
+ if attribute_id in (
442
+ NotificationAttributeId.TITLE,
443
+ NotificationAttributeId.SUBTITLE,
444
+ NotificationAttributeId.MESSAGE,
445
+ ):
446
+ attribute_max_length = _DEFAULT_ATTRIBUTE_MAX_LENGTH
447
+
448
+ if attribute_max_length:
449
+ command += struct.pack("<BH", attribute_id, attribute_max_length)
450
+ else:
451
+ command += struct.pack("B", attribute_id)
452
+
453
+ try:
454
+ async with self._command_semaphore:
455
+ self._expected_response_notification_uid = notification_uid
456
+ self._expected_response_tuples = len(attributes)
457
+ self._expected_response_command_id = (
458
+ CommandId.GET_NOTIFICATION_ATTRIBUTES
459
+ )
460
+ self._response = asyncio.Future()
461
+
462
+ # Send the command.
463
+ await self._send_command(command)
464
+
465
+ # Wait for the response.
466
+ return await self._response
467
+ finally:
468
+ self._reset_response()
469
+
470
+ async def get_app_attributes(
471
+ self, app_identifier: str, attributes: Sequence[AppAttributeId]
472
+ ) -> list[AppAttribute]:
473
+ if not self._started:
474
+ raise RuntimeError("client not started")
475
+
476
+ command = (
477
+ bytes([CommandId.GET_APP_ATTRIBUTES])
478
+ + app_identifier.encode("utf-8")
479
+ + b"\0"
480
+ )
481
+ for attribute_id in attributes:
482
+ command += struct.pack("B", attribute_id)
483
+
484
+ try:
485
+ async with self._command_semaphore:
486
+ self._expected_response_app_identifier = app_identifier
487
+ self._expected_response_tuples = len(attributes)
488
+ self._expected_response_command_id = CommandId.GET_APP_ATTRIBUTES
489
+ self._response = asyncio.Future()
490
+
491
+ # Send the command.
492
+ await self._send_command(command)
493
+
494
+ # Wait for the response.
495
+ return await self._response
496
+ finally:
497
+ self._reset_response()
498
+
499
+ async def perform_action(self, notification_uid: int, action: ActionId) -> None:
500
+ if not self._started:
501
+ raise RuntimeError("client not started")
502
+
503
+ command = struct.pack(
504
+ "<BIB", CommandId.PERFORM_NOTIFICATION_ACTION, notification_uid, action
505
+ )
506
+
507
+ async with self._command_semaphore:
508
+ await self._send_command(command)
509
+
510
+ async def perform_positive_action(self, notification_uid: int) -> None:
511
+ return await self.perform_action(notification_uid, ActionId.POSITIVE)
512
+
513
+ async def perform_negative_action(self, notification_uid: int) -> None:
514
+ return await self.perform_action(notification_uid, ActionId.NEGATIVE)
bumble/profiles/ascs.py CHANGED
@@ -301,7 +301,7 @@ class AseStateMachine(gatt.Characteristic):
301
301
  presentation_delay = 0
302
302
 
303
303
  # Additional parameters in ENABLING, STREAMING, DISABLING State
304
- metadata = le_audio.Metadata()
304
+ metadata: le_audio.Metadata
305
305
 
306
306
  def __init__(
307
307
  self,
@@ -313,6 +313,7 @@ class AseStateMachine(gatt.Characteristic):
313
313
  self.ase_id = ase_id
314
314
  self._state = AseStateMachine.State.IDLE
315
315
  self.role = role
316
+ self.metadata = le_audio.Metadata()
316
317
 
317
318
  uuid = (
318
319
  gatt.GATT_SINK_ASE_CHARACTERISTIC
bumble/profiles/asha.py CHANGED
@@ -134,12 +134,14 @@ class AshaService(gatt.TemplateService):
134
134
  ),
135
135
  )
136
136
 
137
- self.audio_control_point_characteristic = gatt.Characteristic(
138
- gatt.GATT_ASHA_AUDIO_CONTROL_POINT_CHARACTERISTIC,
139
- gatt.Characteristic.Properties.WRITE
140
- | gatt.Characteristic.Properties.WRITE_WITHOUT_RESPONSE,
141
- gatt.Characteristic.WRITEABLE,
142
- gatt.CharacteristicValue(write=self._on_audio_control_point_write),
137
+ self.audio_control_point_characteristic: gatt.Characteristic[bytes] = (
138
+ gatt.Characteristic(
139
+ gatt.GATT_ASHA_AUDIO_CONTROL_POINT_CHARACTERISTIC,
140
+ gatt.Characteristic.Properties.WRITE
141
+ | gatt.Characteristic.Properties.WRITE_WITHOUT_RESPONSE,
142
+ gatt.Characteristic.WRITEABLE,
143
+ gatt.CharacteristicValue(write=self._on_audio_control_point_write),
144
+ )
143
145
  )
144
146
  self.audio_status_characteristic = gatt.Characteristic(
145
147
  gatt.GATT_ASHA_AUDIO_STATUS_CHARACTERISTIC,
@@ -147,7 +149,7 @@ class AshaService(gatt.TemplateService):
147
149
  gatt.Characteristic.READABLE,
148
150
  bytes([AudioStatus.OK]),
149
151
  )
150
- self.volume_characteristic = gatt.Characteristic(
152
+ self.volume_characteristic: gatt.Characteristic[bytes] = gatt.Characteristic(
151
153
  gatt.GATT_ASHA_VOLUME_CHARACTERISTIC,
152
154
  gatt.Characteristic.Properties.WRITE_WITHOUT_RESPONSE,
153
155
  gatt.Characteristic.WRITEABLE,
@@ -166,13 +168,13 @@ class AshaService(gatt.TemplateService):
166
168
  struct.pack('<H', self.psm),
167
169
  )
168
170
 
169
- characteristics = [
171
+ characteristics = (
170
172
  self.read_only_properties_characteristic,
171
173
  self.audio_control_point_characteristic,
172
174
  self.audio_status_characteristic,
173
175
  self.volume_characteristic,
174
176
  self.le_psm_out_characteristic,
175
- ]
177
+ )
176
178
 
177
179
  super().__init__(characteristics)
178
180
 
bumble/profiles/bass.py CHANGED
@@ -20,11 +20,12 @@ from __future__ import annotations
20
20
  import dataclasses
21
21
  import logging
22
22
  import struct
23
- from typing import ClassVar, List, Optional, Sequence
23
+ from typing import ClassVar, Optional, Sequence
24
24
 
25
25
  from bumble import core
26
26
  from bumble import device
27
27
  from bumble import gatt
28
+ from bumble import gatt_adapters
28
29
  from bumble import gatt_client
29
30
  from bumble import hci
30
31
  from bumble import utils
@@ -52,7 +53,7 @@ def encode_subgroups(subgroups: Sequence[SubgroupInfo]) -> bytes:
52
53
  )
53
54
 
54
55
 
55
- def decode_subgroups(data: bytes) -> List[SubgroupInfo]:
56
+ def decode_subgroups(data: bytes) -> list[SubgroupInfo]:
56
57
  num_subgroups = data[0]
57
58
  offset = 1
58
59
  subgroups = []
@@ -273,7 +274,7 @@ class BroadcastReceiveState:
273
274
  pa_sync_state: PeriodicAdvertisingSyncState
274
275
  big_encryption: BigEncryption
275
276
  bad_code: bytes
276
- subgroups: List[SubgroupInfo]
277
+ subgroups: list[SubgroupInfo]
277
278
 
278
279
  @classmethod
279
280
  def from_bytes(cls, data: bytes) -> BroadcastReceiveState:
@@ -354,7 +355,9 @@ class BroadcastAudioScanServiceProxy(gatt_client.ProfileServiceProxy):
354
355
  SERVICE_CLASS = BroadcastAudioScanService
355
356
 
356
357
  broadcast_audio_scan_control_point: gatt_client.CharacteristicProxy
357
- broadcast_receive_states: List[gatt.DelegatedCharacteristicAdapter]
358
+ broadcast_receive_states: list[
359
+ gatt_client.CharacteristicProxy[Optional[BroadcastReceiveState]]
360
+ ]
358
361
 
359
362
  def __init__(self, service_proxy: gatt_client.ServiceProxy):
360
363
  self.service_proxy = service_proxy
@@ -366,7 +369,7 @@ class BroadcastAudioScanServiceProxy(gatt_client.ProfileServiceProxy):
366
369
  )
367
370
 
368
371
  self.broadcast_receive_states = [
369
- gatt.DelegatedCharacteristicAdapter(
372
+ gatt_adapters.DelegatedCharacteristicProxyAdapter(
370
373
  characteristic,
371
374
  decode=lambda x: BroadcastReceiveState.from_bytes(x) if x else None,
372
375
  )
@@ -16,14 +16,20 @@
16
16
  # -----------------------------------------------------------------------------
17
17
  # Imports
18
18
  # -----------------------------------------------------------------------------
19
- from ..gatt_client import ProfileServiceProxy
20
- from ..gatt import (
19
+ from typing import Optional
20
+
21
+ from bumble.gatt_client import ProfileServiceProxy
22
+ from bumble.gatt import (
21
23
  GATT_BATTERY_SERVICE,
22
24
  GATT_BATTERY_LEVEL_CHARACTERISTIC,
23
25
  TemplateService,
24
26
  Characteristic,
25
27
  CharacteristicValue,
28
+ )
29
+ from bumble.gatt_client import CharacteristicProxy
30
+ from bumble.gatt_adapters import (
26
31
  PackedCharacteristicAdapter,
32
+ PackedCharacteristicProxyAdapter,
27
33
  )
28
34
 
29
35
 
@@ -32,6 +38,8 @@ class BatteryService(TemplateService):
32
38
  UUID = GATT_BATTERY_SERVICE
33
39
  BATTERY_LEVEL_FORMAT = 'B'
34
40
 
41
+ battery_level_characteristic: Characteristic[int]
42
+
35
43
  def __init__(self, read_battery_level):
36
44
  self.battery_level_characteristic = PackedCharacteristicAdapter(
37
45
  Characteristic(
@@ -49,13 +57,15 @@ class BatteryService(TemplateService):
49
57
  class BatteryServiceProxy(ProfileServiceProxy):
50
58
  SERVICE_CLASS = BatteryService
51
59
 
60
+ battery_level: Optional[CharacteristicProxy[int]]
61
+
52
62
  def __init__(self, service_proxy):
53
63
  self.service_proxy = service_proxy
54
64
 
55
65
  if characteristics := service_proxy.get_characteristics_by_uuid(
56
66
  GATT_BATTERY_LEVEL_CHARACTERISTIC
57
67
  ):
58
- self.battery_level = PackedCharacteristicAdapter(
68
+ self.battery_level = PackedCharacteristicProxyAdapter(
59
69
  characteristics[0], pack_format=BatteryService.BATTERY_LEVEL_FORMAT
60
70
  )
61
71
  else: