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/_version.py +2 -2
- bumble/apps/pair.py +32 -5
- bumble/att.py +56 -40
- bumble/avdtp.py +2 -2
- bumble/decoder.py +14 -10
- bumble/drivers/rtk.py +19 -5
- bumble/gatt.py +24 -19
- bumble/gatt_client.py +5 -25
- bumble/gatt_server.py +14 -6
- bumble/hci.py +272 -7
- bumble/host.py +16 -6
- bumble/pandora/__init__.py +3 -0
- bumble/pandora/l2cap.py +310 -0
- bumble/profiles/aics.py +520 -0
- bumble/profiles/asha.py +295 -0
- bumble/profiles/hap.py +665 -0
- bumble/profiles/vcp.py +5 -3
- bumble/smp.py +23 -4
- bumble/transport/pyusb.py +19 -2
- {bumble-0.0.198.dist-info → bumble-0.0.199.dist-info}/METADATA +1 -1
- {bumble-0.0.198.dist-info → bumble-0.0.199.dist-info}/RECORD +25 -22
- {bumble-0.0.198.dist-info → bumble-0.0.199.dist-info}/WHEEL +1 -1
- bumble/profiles/asha_service.py +0 -193
- {bumble-0.0.198.dist-info → bumble-0.0.199.dist-info}/LICENSE +0 -0
- {bumble-0.0.198.dist-info → bumble-0.0.199.dist-info}/entry_points.txt +0 -0
- {bumble-0.0.198.dist-info → bumble-0.0.199.dist-info}/top_level.txt +0 -0
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
|