bumble 0.0.198__py3-none-any.whl → 0.0.199__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/hap.py ADDED
@@ -0,0 +1,665 @@
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 asyncio
20
+ import functools
21
+ from bumble import att, gatt, gatt_client
22
+ from bumble.core import InvalidArgumentError, InvalidStateError
23
+ from bumble.device import Device, Connection
24
+ from bumble.utils import AsyncRunner, OpenIntEnum
25
+ from bumble.hci import Address
26
+ from dataclasses import dataclass, field
27
+ import logging
28
+ from typing import Dict, List, Optional, Set, Union
29
+
30
+
31
+ # -----------------------------------------------------------------------------
32
+ # Constants
33
+ # -----------------------------------------------------------------------------
34
+ class ErrorCode(OpenIntEnum):
35
+ '''See Hearing Access Service 2.4. Attribute Profile error codes.'''
36
+
37
+ INVALID_OPCODE = 0x80
38
+ WRITE_NAME_NOT_ALLOWED = 0x81
39
+ PRESET_SYNCHRONIZATION_NOT_SUPPORTED = 0x82
40
+ PRESET_OPERATION_NOT_POSSIBLE = 0x83
41
+ INVALID_PARAMETERS_LENGTH = 0x84
42
+
43
+
44
+ class HearingAidType(OpenIntEnum):
45
+ '''See Hearing Access Service 3.1. Hearing Aid Features.'''
46
+
47
+ BINAURAL_HEARING_AID = 0b00
48
+ MONAURAL_HEARING_AID = 0b01
49
+ BANDED_HEARING_AID = 0b10
50
+
51
+
52
+ class PresetSynchronizationSupport(OpenIntEnum):
53
+ '''See Hearing Access Service 3.1. Hearing Aid Features.'''
54
+
55
+ PRESET_SYNCHRONIZATION_IS_NOT_SUPPORTED = 0b0
56
+ PRESET_SYNCHRONIZATION_IS_SUPPORTED = 0b1
57
+
58
+
59
+ class IndependentPresets(OpenIntEnum):
60
+ '''See Hearing Access Service 3.1. Hearing Aid Features.'''
61
+
62
+ IDENTICAL_PRESET_RECORD = 0b0
63
+ DIFFERENT_PRESET_RECORD = 0b1
64
+
65
+
66
+ class DynamicPresets(OpenIntEnum):
67
+ '''See Hearing Access Service 3.1. Hearing Aid Features.'''
68
+
69
+ PRESET_RECORDS_DOES_NOT_CHANGE = 0b0
70
+ PRESET_RECORDS_MAY_CHANGE = 0b1
71
+
72
+
73
+ class WritablePresetsSupport(OpenIntEnum):
74
+ '''See Hearing Access Service 3.1. Hearing Aid Features.'''
75
+
76
+ WRITABLE_PRESET_RECORDS_NOT_SUPPORTED = 0b0
77
+ WRITABLE_PRESET_RECORDS_SUPPORTED = 0b1
78
+
79
+
80
+ class HearingAidPresetControlPointOpcode(OpenIntEnum):
81
+ '''See Hearing Access Service 3.3.1 Hearing Aid Preset Control Point operation requirements.'''
82
+
83
+ # fmt: off
84
+ READ_PRESETS_REQUEST = 0x01
85
+ READ_PRESET_RESPONSE = 0x02
86
+ PRESET_CHANGED = 0x03
87
+ WRITE_PRESET_NAME = 0x04
88
+ SET_ACTIVE_PRESET = 0x05
89
+ SET_NEXT_PRESET = 0x06
90
+ SET_PREVIOUS_PRESET = 0x07
91
+ SET_ACTIVE_PRESET_SYNCHRONIZED_LOCALLY = 0x08
92
+ SET_NEXT_PRESET_SYNCHRONIZED_LOCALLY = 0x09
93
+ SET_PREVIOUS_PRESET_SYNCHRONIZED_LOCALLY = 0x0A
94
+
95
+
96
+ @dataclass
97
+ class HearingAidFeatures:
98
+ '''See Hearing Access Service 3.1. Hearing Aid Features.'''
99
+
100
+ hearing_aid_type: HearingAidType
101
+ preset_synchronization_support: PresetSynchronizationSupport
102
+ independent_presets: IndependentPresets
103
+ dynamic_presets: DynamicPresets
104
+ writable_presets_support: WritablePresetsSupport
105
+
106
+ def __bytes__(self) -> bytes:
107
+ return bytes(
108
+ [
109
+ (self.hearing_aid_type << 0)
110
+ | (self.preset_synchronization_support << 2)
111
+ | (self.independent_presets << 3)
112
+ | (self.dynamic_presets << 4)
113
+ | (self.writable_presets_support << 5)
114
+ ]
115
+ )
116
+
117
+
118
+ def HearingAidFeatures_from_bytes(data: int) -> HearingAidFeatures:
119
+ return HearingAidFeatures(
120
+ HearingAidType(data & 0b11),
121
+ PresetSynchronizationSupport(data >> 2 & 0b1),
122
+ IndependentPresets(data >> 3 & 0b1),
123
+ DynamicPresets(data >> 4 & 0b1),
124
+ WritablePresetsSupport(data >> 5 & 0b1),
125
+ )
126
+
127
+
128
+ @dataclass
129
+ class PresetChangedOperation:
130
+ '''See Hearing Access Service 3.2.2.2. Preset Changed operation.'''
131
+
132
+ class ChangeId(OpenIntEnum):
133
+ # fmt: off
134
+ GENERIC_UPDATE = 0x00
135
+ PRESET_RECORD_DELETED = 0x01
136
+ PRESET_RECORD_AVAILABLE = 0x02
137
+ PRESET_RECORD_UNAVAILABLE = 0x03
138
+
139
+ @dataclass
140
+ class Generic:
141
+ prev_index: int
142
+ preset_record: PresetRecord
143
+
144
+ def __bytes__(self) -> bytes:
145
+ return bytes([self.prev_index]) + bytes(self.preset_record)
146
+
147
+ change_id: ChangeId
148
+ additional_parameters: Union[Generic, int]
149
+
150
+ def to_bytes(self, is_last: bool) -> bytes:
151
+ if isinstance(self.additional_parameters, PresetChangedOperation.Generic):
152
+ additional_parameters_bytes = bytes(self.additional_parameters)
153
+ else:
154
+ additional_parameters_bytes = bytes([self.additional_parameters])
155
+
156
+ return (
157
+ bytes(
158
+ [
159
+ HearingAidPresetControlPointOpcode.PRESET_CHANGED,
160
+ self.change_id,
161
+ is_last,
162
+ ]
163
+ )
164
+ + additional_parameters_bytes
165
+ )
166
+
167
+
168
+ class PresetChangedOperationDeleted(PresetChangedOperation):
169
+ def __init__(self, index) -> None:
170
+ self.change_id = PresetChangedOperation.ChangeId.PRESET_RECORD_DELETED
171
+ self.additional_parameters = index
172
+
173
+
174
+ class PresetChangedOperationAvailable(PresetChangedOperation):
175
+ def __init__(self, index) -> None:
176
+ self.change_id = PresetChangedOperation.ChangeId.PRESET_RECORD_AVAILABLE
177
+ self.additional_parameters = index
178
+
179
+
180
+ class PresetChangedOperationUnavailable(PresetChangedOperation):
181
+ def __init__(self, index) -> None:
182
+ self.change_id = PresetChangedOperation.ChangeId.PRESET_RECORD_UNAVAILABLE
183
+ self.additional_parameters = index
184
+
185
+
186
+ @dataclass
187
+ class PresetRecord:
188
+ '''See Hearing Access Service 2.8. Preset record.'''
189
+
190
+ @dataclass
191
+ class Property:
192
+ class Writable(OpenIntEnum):
193
+ CANNOT_BE_WRITTEN = 0b0
194
+ CAN_BE_WRITTEN = 0b1
195
+
196
+ class IsAvailable(OpenIntEnum):
197
+ IS_UNAVAILABLE = 0b0
198
+ IS_AVAILABLE = 0b1
199
+
200
+ writable: Writable = Writable.CAN_BE_WRITTEN
201
+ is_available: IsAvailable = IsAvailable.IS_AVAILABLE
202
+
203
+ def __bytes__(self) -> bytes:
204
+ return bytes([self.writable | (self.is_available << 1)])
205
+
206
+ index: int
207
+ name: str
208
+ properties: Property = field(default_factory=Property)
209
+
210
+ def __bytes__(self) -> bytes:
211
+ return bytes([self.index]) + bytes(self.properties) + self.name.encode('utf-8')
212
+
213
+ def is_available(self) -> bool:
214
+ return (
215
+ self.properties.is_available
216
+ == PresetRecord.Property.IsAvailable.IS_AVAILABLE
217
+ )
218
+
219
+
220
+ # -----------------------------------------------------------------------------
221
+ # Server
222
+ # -----------------------------------------------------------------------------
223
+ class HearingAccessService(gatt.TemplateService):
224
+ UUID = gatt.GATT_HEARING_ACCESS_SERVICE
225
+
226
+ hearing_aid_features_characteristic: gatt.Characteristic
227
+ hearing_aid_preset_control_point: gatt.Characteristic
228
+ active_preset_index_characteristic: gatt.Characteristic
229
+ active_preset_index: int
230
+ active_preset_index_per_device: Dict[Address, int]
231
+
232
+ device: Device
233
+
234
+ server_features: HearingAidFeatures
235
+ preset_records: Dict[int, PresetRecord] # key is the preset index
236
+ read_presets_request_in_progress: bool
237
+
238
+ preset_changed_operations_history_per_device: Dict[
239
+ Address, List[PresetChangedOperation]
240
+ ]
241
+
242
+ # Keep an updated list of connected client to send notification to
243
+ currently_connected_clients: Set[Connection]
244
+
245
+ def __init__(
246
+ self, device: Device, features: HearingAidFeatures, presets: List[PresetRecord]
247
+ ) -> None:
248
+ self.active_preset_index_per_device = {}
249
+ self.read_presets_request_in_progress = False
250
+ self.preset_changed_operations_history_per_device = {}
251
+ self.currently_connected_clients = set()
252
+
253
+ self.device = device
254
+ self.server_features = features
255
+ if len(presets) < 1:
256
+ raise InvalidArgumentError(f'Invalid presets: {presets}')
257
+
258
+ self.preset_records = {}
259
+ for p in presets:
260
+ if len(p.name.encode()) < 1 or len(p.name.encode()) > 40:
261
+ raise InvalidArgumentError(f'Invalid name: {p.name}')
262
+
263
+ self.preset_records[p.index] = p
264
+
265
+ # associate the lowest index as the current active preset at startup
266
+ self.active_preset_index = sorted(self.preset_records.keys())[0]
267
+
268
+ @device.on('connection') # type: ignore
269
+ def on_connection(connection: Connection) -> None:
270
+ @connection.on('disconnection') # type: ignore
271
+ def on_disconnection(_reason) -> None:
272
+ self.currently_connected_clients.remove(connection)
273
+
274
+ # TODO Should we filter on device bonded && device is HAP ?
275
+ self.currently_connected_clients.add(connection)
276
+ if (
277
+ connection.peer_address
278
+ not in self.preset_changed_operations_history_per_device
279
+ ):
280
+ self.preset_changed_operations_history_per_device[
281
+ connection.peer_address
282
+ ] = []
283
+ return
284
+
285
+ async def on_connection_async() -> None:
286
+ # Send all the PresetChangedOperation that occur when not connected
287
+ await self._preset_changed_operation(connection)
288
+ # Update the active preset index if needed
289
+ await self.notify_active_preset_for_connection(connection)
290
+
291
+ connection.abort_on('disconnection', on_connection_async())
292
+
293
+ self.hearing_aid_features_characteristic = gatt.Characteristic(
294
+ uuid=gatt.GATT_HEARING_AID_FEATURES_CHARACTERISTIC,
295
+ properties=gatt.Characteristic.Properties.READ,
296
+ permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
297
+ value=bytes(self.server_features),
298
+ )
299
+ self.hearing_aid_preset_control_point = gatt.Characteristic(
300
+ uuid=gatt.GATT_HEARING_AID_PRESET_CONTROL_POINT_CHARACTERISTIC,
301
+ properties=(
302
+ gatt.Characteristic.Properties.WRITE
303
+ | gatt.Characteristic.Properties.INDICATE
304
+ ),
305
+ permissions=gatt.Characteristic.Permissions.WRITE_REQUIRES_ENCRYPTION,
306
+ value=gatt.CharacteristicValue(
307
+ write=self._on_write_hearing_aid_preset_control_point
308
+ ),
309
+ )
310
+ self.active_preset_index_characteristic = gatt.Characteristic(
311
+ uuid=gatt.GATT_ACTIVE_PRESET_INDEX_CHARACTERISTIC,
312
+ properties=(
313
+ gatt.Characteristic.Properties.READ
314
+ | gatt.Characteristic.Properties.NOTIFY
315
+ ),
316
+ permissions=gatt.Characteristic.Permissions.READ_REQUIRES_ENCRYPTION,
317
+ value=gatt.CharacteristicValue(read=self._on_read_active_preset_index),
318
+ )
319
+
320
+ super().__init__(
321
+ [
322
+ self.hearing_aid_features_characteristic,
323
+ self.hearing_aid_preset_control_point,
324
+ self.active_preset_index_characteristic,
325
+ ]
326
+ )
327
+
328
+ def _on_read_active_preset_index(
329
+ self, __connection__: Optional[Connection]
330
+ ) -> bytes:
331
+ return bytes([self.active_preset_index])
332
+
333
+ # TODO this need to be triggered when device is unbonded
334
+ def on_forget(self, addr: Address) -> None:
335
+ self.preset_changed_operations_history_per_device.pop(addr)
336
+
337
+ async def _on_write_hearing_aid_preset_control_point(
338
+ self, connection: Optional[Connection], value: bytes
339
+ ):
340
+ assert connection
341
+
342
+ opcode = HearingAidPresetControlPointOpcode(value[0])
343
+ handler = getattr(self, '_on_' + opcode.name.lower())
344
+ await handler(connection, value)
345
+
346
+ async def _on_read_presets_request(
347
+ self, connection: Optional[Connection], value: bytes
348
+ ):
349
+ assert connection
350
+ if connection.att_mtu < 49: # 2.5. GATT sub-procedure requirements
351
+ logging.warning(f'HAS require MTU >= 49: {connection}')
352
+
353
+ if self.read_presets_request_in_progress:
354
+ raise att.ATT_Error(att.ErrorCode.PROCEDURE_ALREADY_IN_PROGRESS)
355
+ self.read_presets_request_in_progress = True
356
+
357
+ start_index = value[1]
358
+ if start_index == 0x00:
359
+ raise att.ATT_Error(att.ErrorCode.OUT_OF_RANGE)
360
+
361
+ num_presets = value[2]
362
+ if num_presets == 0x00:
363
+ raise att.ATT_Error(att.ErrorCode.OUT_OF_RANGE)
364
+
365
+ # Sending `num_presets` presets ordered by increasing index field, starting from start_index
366
+ presets = [
367
+ self.preset_records[key]
368
+ for key in sorted(self.preset_records.keys())
369
+ if self.preset_records[key].index >= start_index
370
+ ]
371
+ del presets[num_presets:]
372
+ if len(presets) == 0:
373
+ raise att.ATT_Error(att.ErrorCode.OUT_OF_RANGE)
374
+
375
+ AsyncRunner.spawn(self._read_preset_response(connection, presets))
376
+
377
+ async def _read_preset_response(
378
+ self, connection: Connection, presets: List[PresetRecord]
379
+ ):
380
+ # If the ATT bearer is terminated before all notifications or indications are sent, then the server shall consider the Read Presets Request operation aborted and shall not either continue or restart the operation when the client reconnects.
381
+ try:
382
+ for i, preset in enumerate(presets):
383
+ await connection.device.indicate_subscriber(
384
+ connection,
385
+ self.hearing_aid_preset_control_point,
386
+ value=bytes(
387
+ [
388
+ HearingAidPresetControlPointOpcode.READ_PRESET_RESPONSE,
389
+ i == len(presets) - 1,
390
+ ]
391
+ )
392
+ + bytes(preset),
393
+ )
394
+
395
+ finally:
396
+ # indicate_subscriber can raise a TimeoutError, we need to gracefully terminate the operation
397
+ self.read_presets_request_in_progress = False
398
+
399
+ async def generic_update(self, op: PresetChangedOperation) -> None:
400
+ '''Server API to perform a generic update. It is the responsibility of the caller to modify the preset_records to match the PresetChangedOperation being sent'''
401
+ await self._notifyPresetOperations(op)
402
+
403
+ async def delete_preset(self, index: int) -> None:
404
+ '''Server API to delete a preset. It should not be the current active preset'''
405
+
406
+ if index == self.active_preset_index:
407
+ raise InvalidStateError('Cannot delete active preset')
408
+
409
+ del self.preset_records[index]
410
+ await self._notifyPresetOperations(PresetChangedOperationDeleted(index))
411
+
412
+ async def available_preset(self, index: int) -> None:
413
+ '''Server API to make a preset available'''
414
+
415
+ preset = self.preset_records[index]
416
+ preset.properties.is_available = PresetRecord.Property.IsAvailable.IS_AVAILABLE
417
+ await self._notifyPresetOperations(PresetChangedOperationAvailable(index))
418
+
419
+ async def unavailable_preset(self, index: int) -> None:
420
+ '''Server API to make a preset unavailable. It should not be the current active preset'''
421
+
422
+ if index == self.active_preset_index:
423
+ raise InvalidStateError('Cannot set active preset as unavailable')
424
+
425
+ preset = self.preset_records[index]
426
+ preset.properties.is_available = (
427
+ PresetRecord.Property.IsAvailable.IS_UNAVAILABLE
428
+ )
429
+ await self._notifyPresetOperations(PresetChangedOperationUnavailable(index))
430
+
431
+ async def _preset_changed_operation(self, connection: Connection) -> None:
432
+ '''Send all PresetChangedOperation saved for a given connection'''
433
+ op_list = self.preset_changed_operations_history_per_device.get(
434
+ connection.peer_address, []
435
+ )
436
+
437
+ # Notification will be sent in index order
438
+ def get_op_index(op: PresetChangedOperation) -> int:
439
+ if isinstance(op.additional_parameters, PresetChangedOperation.Generic):
440
+ return op.additional_parameters.prev_index
441
+ return op.additional_parameters
442
+
443
+ op_list.sort(key=get_op_index)
444
+ # If the ATT bearer is terminated before all notifications or indications are sent, then the server shall consider the Preset Changed operation aborted and shall continue the operation when the client reconnects.
445
+ while len(op_list) > 0:
446
+ try:
447
+ await connection.device.indicate_subscriber(
448
+ connection,
449
+ self.hearing_aid_preset_control_point,
450
+ value=op_list[0].to_bytes(len(op_list) == 1),
451
+ )
452
+ # Remove item once sent, and keep the non sent item in the list
453
+ op_list.pop(0)
454
+ except TimeoutError:
455
+ break
456
+
457
+ async def _notifyPresetOperations(self, op: PresetChangedOperation) -> None:
458
+ for historyList in self.preset_changed_operations_history_per_device.values():
459
+ historyList.append(op)
460
+
461
+ for connection in self.currently_connected_clients:
462
+ await self._preset_changed_operation(connection)
463
+
464
+ async def _on_write_preset_name(
465
+ self, connection: Optional[Connection], value: bytes
466
+ ):
467
+ assert connection
468
+
469
+ if self.read_presets_request_in_progress:
470
+ raise att.ATT_Error(att.ErrorCode.PROCEDURE_ALREADY_IN_PROGRESS)
471
+
472
+ index = value[1]
473
+ preset = self.preset_records.get(index, None)
474
+ if (
475
+ not preset
476
+ or preset.properties.writable
477
+ == PresetRecord.Property.Writable.CANNOT_BE_WRITTEN
478
+ ):
479
+ raise att.ATT_Error(ErrorCode.WRITE_NAME_NOT_ALLOWED)
480
+
481
+ name = value[2:].decode('utf-8')
482
+ if not name or len(name) > 40:
483
+ raise att.ATT_Error(ErrorCode.INVALID_PARAMETERS_LENGTH)
484
+
485
+ preset.name = name
486
+
487
+ await self.generic_update(
488
+ PresetChangedOperation(
489
+ PresetChangedOperation.ChangeId.GENERIC_UPDATE,
490
+ PresetChangedOperation.Generic(index, preset),
491
+ )
492
+ )
493
+
494
+ async def notify_active_preset_for_connection(self, connection: Connection) -> None:
495
+ if (
496
+ self.active_preset_index_per_device.get(connection.peer_address, 0x00)
497
+ == self.active_preset_index
498
+ ):
499
+ # Nothing to do, peer is already updated
500
+ return
501
+
502
+ await connection.device.notify_subscriber(
503
+ connection,
504
+ attribute=self.active_preset_index_characteristic,
505
+ value=bytes([self.active_preset_index]),
506
+ )
507
+ self.active_preset_index_per_device[connection.peer_address] = (
508
+ self.active_preset_index
509
+ )
510
+
511
+ async def notify_active_preset(self) -> None:
512
+ for connection in self.currently_connected_clients:
513
+ await self.notify_active_preset_for_connection(connection)
514
+
515
+ async def set_active_preset(
516
+ self, connection: Optional[Connection], value: bytes
517
+ ) -> None:
518
+ assert connection
519
+ index = value[1]
520
+ preset = self.preset_records.get(index, None)
521
+ if (
522
+ not preset
523
+ or preset.properties.is_available
524
+ != PresetRecord.Property.IsAvailable.IS_AVAILABLE
525
+ ):
526
+ raise att.ATT_Error(ErrorCode.PRESET_OPERATION_NOT_POSSIBLE)
527
+
528
+ if index == self.active_preset_index:
529
+ # Already at correct value
530
+ return
531
+
532
+ self.active_preset_index = index
533
+ await self.notify_active_preset()
534
+
535
+ async def _on_set_active_preset(
536
+ self, connection: Optional[Connection], value: bytes
537
+ ):
538
+ await self.set_active_preset(connection, value)
539
+
540
+ async def set_next_or_previous_preset(
541
+ self, connection: Optional[Connection], is_previous
542
+ ):
543
+ '''Set the next or the previous preset as active'''
544
+ assert connection
545
+
546
+ if self.active_preset_index == 0x00:
547
+ raise att.ATT_Error(ErrorCode.PRESET_OPERATION_NOT_POSSIBLE)
548
+
549
+ first_preset: Optional[PresetRecord] = None # To loop to first preset
550
+ next_preset: Optional[PresetRecord] = None
551
+ for index, record in sorted(self.preset_records.items(), reverse=is_previous):
552
+ if not record.is_available():
553
+ continue
554
+ if first_preset == None:
555
+ first_preset = record
556
+ if is_previous:
557
+ if index >= self.active_preset_index:
558
+ continue
559
+ elif index <= self.active_preset_index:
560
+ continue
561
+ next_preset = record
562
+ break
563
+
564
+ if not first_preset: # If no other preset are available
565
+ raise att.ATT_Error(ErrorCode.PRESET_OPERATION_NOT_POSSIBLE)
566
+
567
+ if next_preset:
568
+ self.active_preset_index = next_preset.index
569
+ else:
570
+ self.active_preset_index = first_preset.index
571
+ await self.notify_active_preset()
572
+
573
+ async def _on_set_next_preset(
574
+ self, connection: Optional[Connection], __value__: bytes
575
+ ) -> None:
576
+ await self.set_next_or_previous_preset(connection, False)
577
+
578
+ async def _on_set_previous_preset(
579
+ self, connection: Optional[Connection], __value__: bytes
580
+ ) -> None:
581
+ await self.set_next_or_previous_preset(connection, True)
582
+
583
+ async def _on_set_active_preset_synchronized_locally(
584
+ self, connection: Optional[Connection], value: bytes
585
+ ):
586
+ if (
587
+ self.server_features.preset_synchronization_support
588
+ == PresetSynchronizationSupport.PRESET_SYNCHRONIZATION_IS_SUPPORTED
589
+ ):
590
+ raise att.ATT_Error(ErrorCode.PRESET_SYNCHRONIZATION_NOT_SUPPORTED)
591
+ await self.set_active_preset(connection, value)
592
+ # TODO (low priority) inform other server of the change
593
+
594
+ async def _on_set_next_preset_synchronized_locally(
595
+ self, connection: Optional[Connection], __value__: bytes
596
+ ):
597
+ if (
598
+ self.server_features.preset_synchronization_support
599
+ == PresetSynchronizationSupport.PRESET_SYNCHRONIZATION_IS_SUPPORTED
600
+ ):
601
+ raise att.ATT_Error(ErrorCode.PRESET_SYNCHRONIZATION_NOT_SUPPORTED)
602
+ await self.set_next_or_previous_preset(connection, False)
603
+ # TODO (low priority) inform other server of the change
604
+
605
+ async def _on_set_previous_preset_synchronized_locally(
606
+ self, connection: Optional[Connection], __value__: bytes
607
+ ):
608
+ if (
609
+ self.server_features.preset_synchronization_support
610
+ == PresetSynchronizationSupport.PRESET_SYNCHRONIZATION_IS_SUPPORTED
611
+ ):
612
+ raise att.ATT_Error(ErrorCode.PRESET_SYNCHRONIZATION_NOT_SUPPORTED)
613
+ await self.set_next_or_previous_preset(connection, True)
614
+ # TODO (low priority) inform other server of the change
615
+
616
+
617
+ # -----------------------------------------------------------------------------
618
+ # Client
619
+ # -----------------------------------------------------------------------------
620
+ class HearingAccessServiceProxy(gatt_client.ProfileServiceProxy):
621
+ SERVICE_CLASS = HearingAccessService
622
+
623
+ hearing_aid_preset_control_point: gatt_client.CharacteristicProxy
624
+ preset_control_point_indications: asyncio.Queue
625
+
626
+ def __init__(self, service_proxy: gatt_client.ServiceProxy) -> None:
627
+ self.service_proxy = service_proxy
628
+
629
+ self.server_features = gatt.PackedCharacteristicAdapter(
630
+ service_proxy.get_characteristics_by_uuid(
631
+ gatt.GATT_HEARING_AID_FEATURES_CHARACTERISTIC
632
+ )[0],
633
+ 'B',
634
+ )
635
+
636
+ self.hearing_aid_preset_control_point = (
637
+ service_proxy.get_characteristics_by_uuid(
638
+ gatt.GATT_HEARING_AID_PRESET_CONTROL_POINT_CHARACTERISTIC
639
+ )[0]
640
+ )
641
+
642
+ self.active_preset_index = gatt.PackedCharacteristicAdapter(
643
+ service_proxy.get_characteristics_by_uuid(
644
+ gatt.GATT_ACTIVE_PRESET_INDEX_CHARACTERISTIC
645
+ )[0],
646
+ 'B',
647
+ )
648
+
649
+ async def setup_subscription(self):
650
+ self.preset_control_point_indications = asyncio.Queue()
651
+ self.active_preset_index_notification = asyncio.Queue()
652
+
653
+ def on_active_preset_index_notification(data: bytes):
654
+ self.active_preset_index_notification.put_nowait(data)
655
+
656
+ def on_preset_control_point_indication(data: bytes):
657
+ self.preset_control_point_indications.put_nowait(data)
658
+
659
+ await self.hearing_aid_preset_control_point.subscribe(
660
+ functools.partial(on_preset_control_point_indication), prefer_notify=False
661
+ )
662
+
663
+ await self.active_preset_index.subscribe(
664
+ functools.partial(on_active_preset_index_notification)
665
+ )
bumble/profiles/vcp.py CHANGED
@@ -24,7 +24,7 @@ from bumble import device
24
24
  from bumble import gatt
25
25
  from bumble import gatt_client
26
26
 
27
- from typing import Optional
27
+ from typing import Optional, Sequence
28
28
 
29
29
  # -----------------------------------------------------------------------------
30
30
  # Constants
@@ -88,6 +88,7 @@ class VolumeControlService(gatt.TemplateService):
88
88
  muted: int = 0,
89
89
  change_counter: int = 0,
90
90
  volume_flags: int = 0,
91
+ included_services: Sequence[gatt.Service] = (),
91
92
  ) -> None:
92
93
  self.step_size = step_size
93
94
  self.volume_setting = volume_setting
@@ -117,11 +118,12 @@ class VolumeControlService(gatt.TemplateService):
117
118
  )
118
119
 
119
120
  super().__init__(
120
- [
121
+ characteristics=[
121
122
  self.volume_state,
122
123
  self.volume_control_point,
123
124
  self.volume_flags,
124
- ]
125
+ ],
126
+ included_services=list(included_services),
125
127
  )
126
128
 
127
129
  @property