bumble 0.0.208__py3-none-any.whl → 0.0.210__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/a2dp.py +7 -7
- bumble/apps/auracast.py +37 -29
- bumble/apps/bench.py +9 -7
- bumble/apps/console.py +1 -1
- bumble/apps/lea_unicast/app.py +6 -2
- bumble/apps/pair.py +4 -3
- bumble/apps/player/player.py +3 -3
- bumble/apps/rfcomm_bridge.py +1 -1
- bumble/apps/speaker/speaker.py +4 -2
- bumble/att.py +12 -5
- bumble/avc.py +5 -5
- bumble/avdtp.py +9 -10
- bumble/avrcp.py +18 -19
- bumble/bridge.py +2 -2
- bumble/controller.py +13 -15
- bumble/core.py +61 -60
- bumble/device.py +193 -162
- bumble/drivers/__init__.py +2 -2
- bumble/gap.py +1 -1
- bumble/gatt.py +16 -0
- bumble/gatt_adapters.py +3 -3
- bumble/gatt_client.py +27 -21
- bumble/gatt_server.py +9 -10
- bumble/hci.py +109 -90
- bumble/hfp.py +3 -3
- bumble/hid.py +4 -3
- bumble/host.py +30 -19
- bumble/keys.py +3 -3
- bumble/l2cap.py +21 -19
- bumble/link.py +5 -6
- bumble/pairing.py +3 -3
- bumble/pandora/__init__.py +5 -5
- bumble/pandora/host.py +30 -23
- bumble/pandora/l2cap.py +2 -2
- bumble/pandora/security.py +17 -19
- bumble/pandora/utils.py +2 -2
- bumble/profiles/aics.py +6 -6
- bumble/profiles/ancs.py +513 -0
- bumble/profiles/ascs.py +17 -10
- bumble/profiles/asha.py +5 -5
- bumble/profiles/bass.py +1 -1
- bumble/profiles/csip.py +10 -10
- bumble/profiles/gatt_service.py +12 -12
- bumble/profiles/hap.py +16 -16
- bumble/profiles/mcp.py +26 -24
- bumble/profiles/pacs.py +6 -6
- bumble/profiles/pbp.py +1 -1
- bumble/profiles/vcs.py +6 -4
- bumble/profiles/vocs.py +3 -3
- bumble/rfcomm.py +8 -8
- bumble/sdp.py +1 -1
- bumble/smp.py +39 -33
- bumble/transport/__init__.py +24 -19
- bumble/transport/android_emulator.py +8 -4
- bumble/transport/android_netsim.py +8 -5
- bumble/transport/common.py +5 -1
- bumble/transport/file.py +1 -1
- bumble/transport/hci_socket.py +1 -1
- bumble/transport/pty.py +1 -1
- bumble/transport/pyusb.py +3 -3
- bumble/transport/serial.py +1 -1
- bumble/transport/tcp_client.py +1 -1
- bumble/transport/tcp_server.py +1 -1
- bumble/transport/udp.py +1 -1
- bumble/transport/unix.py +1 -1
- bumble/transport/usb.py +1 -3
- bumble/transport/vhci.py +2 -2
- bumble/transport/ws_client.py +6 -1
- bumble/transport/ws_server.py +1 -1
- bumble/utils.py +89 -76
- {bumble-0.0.208.dist-info → bumble-0.0.210.dist-info}/METADATA +3 -2
- {bumble-0.0.208.dist-info → bumble-0.0.210.dist-info}/RECORD +77 -76
- {bumble-0.0.208.dist-info → bumble-0.0.210.dist-info}/WHEEL +1 -1
- {bumble-0.0.208.dist-info → bumble-0.0.210.dist-info}/entry_points.txt +0 -0
- {bumble-0.0.208.dist-info → bumble-0.0.210.dist-info/licenses}/LICENSE +0 -0
- {bumble-0.0.208.dist-info → bumble-0.0.210.dist-info}/top_level.txt +0 -0
bumble/profiles/ancs.py
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
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
|
+
|
|
32
|
+
from bumble.att import ATT_Error
|
|
33
|
+
from bumble.device import Peer
|
|
34
|
+
from bumble.gatt import (
|
|
35
|
+
Characteristic,
|
|
36
|
+
GATT_ANCS_SERVICE,
|
|
37
|
+
GATT_ANCS_NOTIFICATION_SOURCE_CHARACTERISTIC,
|
|
38
|
+
GATT_ANCS_CONTROL_POINT_CHARACTERISTIC,
|
|
39
|
+
GATT_ANCS_DATA_SOURCE_CHARACTERISTIC,
|
|
40
|
+
TemplateService,
|
|
41
|
+
)
|
|
42
|
+
from bumble.gatt_client import CharacteristicProxy, ProfileServiceProxy, ServiceProxy
|
|
43
|
+
from bumble.gatt_adapters import SerializableCharacteristicProxyAdapter
|
|
44
|
+
from bumble import utils
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# -----------------------------------------------------------------------------
|
|
48
|
+
# Constants
|
|
49
|
+
# -----------------------------------------------------------------------------
|
|
50
|
+
_DEFAULT_ATTRIBUTE_MAX_LENGTH = 65535
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# -----------------------------------------------------------------------------
|
|
54
|
+
# Logging
|
|
55
|
+
# -----------------------------------------------------------------------------
|
|
56
|
+
logger = logging.getLogger(__name__)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# -----------------------------------------------------------------------------
|
|
60
|
+
# Protocol
|
|
61
|
+
# -----------------------------------------------------------------------------
|
|
62
|
+
class ActionId(utils.OpenIntEnum):
|
|
63
|
+
POSITIVE = 0
|
|
64
|
+
NEGATIVE = 1
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class AppAttributeId(utils.OpenIntEnum):
|
|
68
|
+
DISPLAY_NAME = 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class CategoryId(utils.OpenIntEnum):
|
|
72
|
+
OTHER = 0
|
|
73
|
+
INCOMING_CALL = 1
|
|
74
|
+
MISSED_CALL = 2
|
|
75
|
+
VOICEMAIL = 3
|
|
76
|
+
SOCIAL = 4
|
|
77
|
+
SCHEDULE = 5
|
|
78
|
+
EMAIL = 6
|
|
79
|
+
NEWS = 7
|
|
80
|
+
HEALTH_AND_FITNESS = 8
|
|
81
|
+
BUSINESS_AND_FINANCE = 9
|
|
82
|
+
LOCATION = 10
|
|
83
|
+
ENTERTAINMENT = 11
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class CommandId(utils.OpenIntEnum):
|
|
87
|
+
GET_NOTIFICATION_ATTRIBUTES = 0
|
|
88
|
+
GET_APP_ATTRIBUTES = 1
|
|
89
|
+
PERFORM_NOTIFICATION_ACTION = 2
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class EventId(utils.OpenIntEnum):
|
|
93
|
+
NOTIFICATION_ADDED = 0
|
|
94
|
+
NOTIFICATION_MODIFIED = 1
|
|
95
|
+
NOTIFICATION_REMOVED = 2
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class EventFlags(enum.IntFlag):
|
|
99
|
+
SILENT = 1 << 0
|
|
100
|
+
IMPORTANT = 1 << 1
|
|
101
|
+
PRE_EXISTING = 1 << 2
|
|
102
|
+
POSITIVE_ACTION = 1 << 3
|
|
103
|
+
NEGATIVE_ACTION = 1 << 4
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class NotificationAttributeId(utils.OpenIntEnum):
|
|
107
|
+
APP_IDENTIFIER = 0
|
|
108
|
+
TITLE = 1
|
|
109
|
+
SUBTITLE = 2
|
|
110
|
+
MESSAGE = 3
|
|
111
|
+
MESSAGE_SIZE = 4
|
|
112
|
+
DATE = 5
|
|
113
|
+
POSITIVE_ACTION_LABEL = 6
|
|
114
|
+
NEGATIVE_ACTION_LABEL = 7
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclasses.dataclass
|
|
118
|
+
class NotificationAttribute:
|
|
119
|
+
attribute_id: NotificationAttributeId
|
|
120
|
+
value: Union[str, int, datetime.datetime]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclasses.dataclass
|
|
124
|
+
class AppAttribute:
|
|
125
|
+
attribute_id: AppAttributeId
|
|
126
|
+
value: str
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclasses.dataclass
|
|
130
|
+
class Notification:
|
|
131
|
+
event_id: EventId
|
|
132
|
+
event_flags: EventFlags
|
|
133
|
+
category_id: CategoryId
|
|
134
|
+
category_count: int
|
|
135
|
+
notification_uid: int
|
|
136
|
+
|
|
137
|
+
@classmethod
|
|
138
|
+
def from_bytes(cls, data: bytes) -> Notification:
|
|
139
|
+
return cls(
|
|
140
|
+
event_id=EventId(data[0]),
|
|
141
|
+
event_flags=EventFlags(data[1]),
|
|
142
|
+
category_id=CategoryId(data[2]),
|
|
143
|
+
category_count=data[3],
|
|
144
|
+
notification_uid=int.from_bytes(data[4:8], 'little'),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def __bytes__(self) -> bytes:
|
|
148
|
+
return struct.pack(
|
|
149
|
+
"<BBBBI",
|
|
150
|
+
self.event_id,
|
|
151
|
+
self.event_flags,
|
|
152
|
+
self.category_id,
|
|
153
|
+
self.category_count,
|
|
154
|
+
self.notification_uid,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class ErrorCode(utils.OpenIntEnum):
|
|
159
|
+
UNKNOWN_COMMAND = 0xA0
|
|
160
|
+
INVALID_COMMAND = 0xA1
|
|
161
|
+
INVALID_PARAMETER = 0xA2
|
|
162
|
+
ACTION_FAILED = 0xA3
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class ProtocolError(Exception):
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class CommandError(Exception):
|
|
170
|
+
def __init__(self, error_code: ErrorCode) -> None:
|
|
171
|
+
self.error_code = error_code
|
|
172
|
+
|
|
173
|
+
def __str__(self) -> str:
|
|
174
|
+
return f"CommandError(error_code={self.error_code.name})"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# -----------------------------------------------------------------------------
|
|
178
|
+
# GATT Server-side
|
|
179
|
+
# -----------------------------------------------------------------------------
|
|
180
|
+
class Ancs(TemplateService):
|
|
181
|
+
UUID = GATT_ANCS_SERVICE
|
|
182
|
+
|
|
183
|
+
notification_source_characteristic: Characteristic
|
|
184
|
+
data_source_characteristic: Characteristic
|
|
185
|
+
control_point_characteristic: Characteristic
|
|
186
|
+
|
|
187
|
+
def __init__(self) -> None:
|
|
188
|
+
# TODO not the final implementation
|
|
189
|
+
self.notification_source_characteristic = Characteristic(
|
|
190
|
+
GATT_ANCS_NOTIFICATION_SOURCE_CHARACTERISTIC,
|
|
191
|
+
Characteristic.Properties.NOTIFY,
|
|
192
|
+
Characteristic.Permissions.READABLE,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# TODO not the final implementation
|
|
196
|
+
self.data_source_characteristic = Characteristic(
|
|
197
|
+
GATT_ANCS_DATA_SOURCE_CHARACTERISTIC,
|
|
198
|
+
Characteristic.Properties.NOTIFY,
|
|
199
|
+
Characteristic.Permissions.READABLE,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# TODO not the final implementation
|
|
203
|
+
self.control_point_characteristic = Characteristic(
|
|
204
|
+
GATT_ANCS_CONTROL_POINT_CHARACTERISTIC,
|
|
205
|
+
Characteristic.Properties.WRITE,
|
|
206
|
+
Characteristic.Permissions.WRITEABLE,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
super().__init__(
|
|
210
|
+
[
|
|
211
|
+
self.notification_source_characteristic,
|
|
212
|
+
self.data_source_characteristic,
|
|
213
|
+
self.control_point_characteristic,
|
|
214
|
+
]
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# -----------------------------------------------------------------------------
|
|
219
|
+
# GATT Client-side
|
|
220
|
+
# -----------------------------------------------------------------------------
|
|
221
|
+
class AncsProxy(ProfileServiceProxy):
|
|
222
|
+
SERVICE_CLASS = Ancs
|
|
223
|
+
|
|
224
|
+
notification_source: CharacteristicProxy[Notification]
|
|
225
|
+
data_source: CharacteristicProxy
|
|
226
|
+
control_point: CharacteristicProxy[bytes]
|
|
227
|
+
|
|
228
|
+
def __init__(self, service_proxy: ServiceProxy):
|
|
229
|
+
self.notification_source = SerializableCharacteristicProxyAdapter(
|
|
230
|
+
service_proxy.get_required_characteristic_by_uuid(
|
|
231
|
+
GATT_ANCS_NOTIFICATION_SOURCE_CHARACTERISTIC
|
|
232
|
+
),
|
|
233
|
+
Notification,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
self.data_source = service_proxy.get_required_characteristic_by_uuid(
|
|
237
|
+
GATT_ANCS_DATA_SOURCE_CHARACTERISTIC
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
self.control_point = service_proxy.get_required_characteristic_by_uuid(
|
|
241
|
+
GATT_ANCS_CONTROL_POINT_CHARACTERISTIC
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class AncsClient(utils.EventEmitter):
|
|
246
|
+
_expected_response_command_id: Optional[CommandId]
|
|
247
|
+
_expected_response_notification_uid: Optional[int]
|
|
248
|
+
_expected_response_app_identifier: Optional[str]
|
|
249
|
+
_expected_app_identifier: Optional[str]
|
|
250
|
+
_expected_response_tuples: int
|
|
251
|
+
_response_accumulator: bytes
|
|
252
|
+
|
|
253
|
+
def __init__(self, ancs_proxy: AncsProxy) -> None:
|
|
254
|
+
super().__init__()
|
|
255
|
+
self._ancs_proxy = ancs_proxy
|
|
256
|
+
self._command_semaphore = asyncio.Semaphore()
|
|
257
|
+
self._response: Optional[asyncio.Future] = None
|
|
258
|
+
self._reset_response()
|
|
259
|
+
self._started = False
|
|
260
|
+
|
|
261
|
+
@classmethod
|
|
262
|
+
async def for_peer(cls, peer: Peer) -> Optional[AncsClient]:
|
|
263
|
+
ancs_proxy = await peer.discover_service_and_create_proxy(AncsProxy)
|
|
264
|
+
if ancs_proxy is None:
|
|
265
|
+
return None
|
|
266
|
+
return cls(ancs_proxy)
|
|
267
|
+
|
|
268
|
+
async def start(self) -> None:
|
|
269
|
+
await self._ancs_proxy.notification_source.subscribe(self._on_notification)
|
|
270
|
+
await self._ancs_proxy.data_source.subscribe(self._on_data)
|
|
271
|
+
self._started = True
|
|
272
|
+
|
|
273
|
+
async def stop(self) -> None:
|
|
274
|
+
await self._ancs_proxy.notification_source.unsubscribe(self._on_notification)
|
|
275
|
+
await self._ancs_proxy.data_source.unsubscribe(self._on_data)
|
|
276
|
+
self._started = False
|
|
277
|
+
|
|
278
|
+
def _reset_response(self) -> None:
|
|
279
|
+
self._expected_response_command_id = None
|
|
280
|
+
self._expected_response_notification_uid = None
|
|
281
|
+
self._expected_app_identifier = None
|
|
282
|
+
self._expected_response_tuples = 0
|
|
283
|
+
self._response_accumulator = b""
|
|
284
|
+
|
|
285
|
+
def _on_notification(self, notification: Notification) -> None:
|
|
286
|
+
logger.debug(f"ANCS NOTIFICATION: {notification}")
|
|
287
|
+
self.emit("notification", notification)
|
|
288
|
+
|
|
289
|
+
def _on_data(self, data: bytes) -> None:
|
|
290
|
+
logger.debug(f"ANCS DATA: {data.hex()}")
|
|
291
|
+
|
|
292
|
+
if not self._response:
|
|
293
|
+
logger.warning("received unexpected data, discarding")
|
|
294
|
+
return
|
|
295
|
+
|
|
296
|
+
self._response_accumulator += data
|
|
297
|
+
|
|
298
|
+
# Try to parse the accumulated data until we have all we need.
|
|
299
|
+
if not self._response_accumulator:
|
|
300
|
+
logger.warning("empty data from data source")
|
|
301
|
+
return
|
|
302
|
+
|
|
303
|
+
command_id = self._response_accumulator[0]
|
|
304
|
+
if command_id != self._expected_response_command_id:
|
|
305
|
+
logger.warning(
|
|
306
|
+
"unexpected response command id: "
|
|
307
|
+
f"expected {self._expected_response_command_id} "
|
|
308
|
+
f"but got {command_id}"
|
|
309
|
+
)
|
|
310
|
+
self._reset_response()
|
|
311
|
+
if not self._response.done():
|
|
312
|
+
self._response.set_exception(ProtocolError())
|
|
313
|
+
|
|
314
|
+
if len(self._response_accumulator) < 5:
|
|
315
|
+
# Not enough data yet.
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
attributes: list[Union[NotificationAttribute, AppAttribute]] = []
|
|
319
|
+
|
|
320
|
+
if command_id == CommandId.GET_NOTIFICATION_ATTRIBUTES:
|
|
321
|
+
(notification_uid,) = struct.unpack_from(
|
|
322
|
+
"<I", self._response_accumulator, 1
|
|
323
|
+
)
|
|
324
|
+
if notification_uid != self._expected_response_notification_uid:
|
|
325
|
+
logger.warning(
|
|
326
|
+
"unexpected response notification uid: "
|
|
327
|
+
f"expected {self._expected_response_notification_uid} "
|
|
328
|
+
f"but got {notification_uid}"
|
|
329
|
+
)
|
|
330
|
+
self._reset_response()
|
|
331
|
+
if not self._response.done():
|
|
332
|
+
self._response.set_exception(ProtocolError())
|
|
333
|
+
|
|
334
|
+
attribute_data = self._response_accumulator[5:]
|
|
335
|
+
while len(attribute_data) >= 3:
|
|
336
|
+
attribute_id, attribute_data_length = struct.unpack_from(
|
|
337
|
+
"<BH", attribute_data, 0
|
|
338
|
+
)
|
|
339
|
+
if len(attribute_data) < 3 + attribute_data_length:
|
|
340
|
+
return
|
|
341
|
+
str_value = attribute_data[3 : 3 + attribute_data_length].decode(
|
|
342
|
+
"utf-8"
|
|
343
|
+
)
|
|
344
|
+
value: Union[str, int, datetime.datetime]
|
|
345
|
+
if attribute_id == NotificationAttributeId.MESSAGE_SIZE:
|
|
346
|
+
value = int(str_value)
|
|
347
|
+
elif attribute_id == NotificationAttributeId.DATE:
|
|
348
|
+
year = int(str_value[:4])
|
|
349
|
+
month = int(str_value[4:6])
|
|
350
|
+
day = int(str_value[6:8])
|
|
351
|
+
hour = int(str_value[9:11])
|
|
352
|
+
minute = int(str_value[11:13])
|
|
353
|
+
second = int(str_value[13:15])
|
|
354
|
+
value = datetime.datetime(year, month, day, hour, minute, second)
|
|
355
|
+
else:
|
|
356
|
+
value = str_value
|
|
357
|
+
attributes.append(
|
|
358
|
+
NotificationAttribute(NotificationAttributeId(attribute_id), value)
|
|
359
|
+
)
|
|
360
|
+
attribute_data = attribute_data[3 + attribute_data_length :]
|
|
361
|
+
elif command_id == CommandId.GET_APP_ATTRIBUTES:
|
|
362
|
+
if 0 not in self._response_accumulator[1:]:
|
|
363
|
+
# No null-terminated string yet.
|
|
364
|
+
return
|
|
365
|
+
|
|
366
|
+
app_identifier_length = self._response_accumulator.find(0, 1) - 1
|
|
367
|
+
app_identifier = self._response_accumulator[
|
|
368
|
+
1 : 1 + app_identifier_length
|
|
369
|
+
].decode("utf-8")
|
|
370
|
+
if app_identifier != self._expected_response_app_identifier:
|
|
371
|
+
logger.warning(
|
|
372
|
+
"unexpected response app identifier: "
|
|
373
|
+
f"expected {self._expected_response_app_identifier} "
|
|
374
|
+
f"but got {app_identifier}"
|
|
375
|
+
)
|
|
376
|
+
self._reset_response()
|
|
377
|
+
if not self._response.done():
|
|
378
|
+
self._response.set_exception(ProtocolError())
|
|
379
|
+
|
|
380
|
+
attribute_data = self._response_accumulator[1 + app_identifier_length + 1 :]
|
|
381
|
+
while len(attribute_data) >= 3:
|
|
382
|
+
attribute_id, attribute_data_length = struct.unpack_from(
|
|
383
|
+
"<BH", attribute_data, 0
|
|
384
|
+
)
|
|
385
|
+
if len(attribute_data) < 3 + attribute_data_length:
|
|
386
|
+
return
|
|
387
|
+
attributes.append(
|
|
388
|
+
AppAttribute(
|
|
389
|
+
AppAttributeId(attribute_id),
|
|
390
|
+
attribute_data[3 : 3 + attribute_data_length].decode("utf-8"),
|
|
391
|
+
)
|
|
392
|
+
)
|
|
393
|
+
attribute_data = attribute_data[3 + attribute_data_length :]
|
|
394
|
+
else:
|
|
395
|
+
logger.warning(f"unexpected response command id {command_id}")
|
|
396
|
+
return
|
|
397
|
+
|
|
398
|
+
if len(attributes) < self._expected_response_tuples:
|
|
399
|
+
# We have not received all the tuples yet.
|
|
400
|
+
return
|
|
401
|
+
|
|
402
|
+
if not self._response.done():
|
|
403
|
+
self._response.set_result(attributes)
|
|
404
|
+
|
|
405
|
+
async def _send_command(self, command: bytes) -> None:
|
|
406
|
+
try:
|
|
407
|
+
await self._ancs_proxy.control_point.write_value(
|
|
408
|
+
command, with_response=True
|
|
409
|
+
)
|
|
410
|
+
except ATT_Error as error:
|
|
411
|
+
raise CommandError(error_code=ErrorCode(error.error_code)) from error
|
|
412
|
+
|
|
413
|
+
async def get_notification_attributes(
|
|
414
|
+
self,
|
|
415
|
+
notification_uid: int,
|
|
416
|
+
attributes: Sequence[
|
|
417
|
+
Union[NotificationAttributeId, tuple[NotificationAttributeId, int]]
|
|
418
|
+
],
|
|
419
|
+
) -> list[NotificationAttribute]:
|
|
420
|
+
if not self._started:
|
|
421
|
+
raise RuntimeError("client not started")
|
|
422
|
+
|
|
423
|
+
command = struct.pack(
|
|
424
|
+
"<BI", CommandId.GET_NOTIFICATION_ATTRIBUTES, notification_uid
|
|
425
|
+
)
|
|
426
|
+
for attribute in attributes:
|
|
427
|
+
attribute_max_length = 0
|
|
428
|
+
if isinstance(attribute, tuple):
|
|
429
|
+
attribute_id, attribute_max_length = attribute
|
|
430
|
+
if attribute_id not in (
|
|
431
|
+
NotificationAttributeId.TITLE,
|
|
432
|
+
NotificationAttributeId.SUBTITLE,
|
|
433
|
+
NotificationAttributeId.MESSAGE,
|
|
434
|
+
):
|
|
435
|
+
raise ValueError(
|
|
436
|
+
"this attribute does not allow specifying a max length"
|
|
437
|
+
)
|
|
438
|
+
else:
|
|
439
|
+
attribute_id = attribute
|
|
440
|
+
if attribute_id in (
|
|
441
|
+
NotificationAttributeId.TITLE,
|
|
442
|
+
NotificationAttributeId.SUBTITLE,
|
|
443
|
+
NotificationAttributeId.MESSAGE,
|
|
444
|
+
):
|
|
445
|
+
attribute_max_length = _DEFAULT_ATTRIBUTE_MAX_LENGTH
|
|
446
|
+
|
|
447
|
+
if attribute_max_length:
|
|
448
|
+
command += struct.pack("<BH", attribute_id, attribute_max_length)
|
|
449
|
+
else:
|
|
450
|
+
command += struct.pack("B", attribute_id)
|
|
451
|
+
|
|
452
|
+
try:
|
|
453
|
+
async with self._command_semaphore:
|
|
454
|
+
self._expected_response_notification_uid = notification_uid
|
|
455
|
+
self._expected_response_tuples = len(attributes)
|
|
456
|
+
self._expected_response_command_id = (
|
|
457
|
+
CommandId.GET_NOTIFICATION_ATTRIBUTES
|
|
458
|
+
)
|
|
459
|
+
self._response = asyncio.Future()
|
|
460
|
+
|
|
461
|
+
# Send the command.
|
|
462
|
+
await self._send_command(command)
|
|
463
|
+
|
|
464
|
+
# Wait for the response.
|
|
465
|
+
return await self._response
|
|
466
|
+
finally:
|
|
467
|
+
self._reset_response()
|
|
468
|
+
|
|
469
|
+
async def get_app_attributes(
|
|
470
|
+
self, app_identifier: str, attributes: Sequence[AppAttributeId]
|
|
471
|
+
) -> list[AppAttribute]:
|
|
472
|
+
if not self._started:
|
|
473
|
+
raise RuntimeError("client not started")
|
|
474
|
+
|
|
475
|
+
command = (
|
|
476
|
+
bytes([CommandId.GET_APP_ATTRIBUTES])
|
|
477
|
+
+ app_identifier.encode("utf-8")
|
|
478
|
+
+ b"\0"
|
|
479
|
+
)
|
|
480
|
+
for attribute_id in attributes:
|
|
481
|
+
command += struct.pack("B", attribute_id)
|
|
482
|
+
|
|
483
|
+
try:
|
|
484
|
+
async with self._command_semaphore:
|
|
485
|
+
self._expected_response_app_identifier = app_identifier
|
|
486
|
+
self._expected_response_tuples = len(attributes)
|
|
487
|
+
self._expected_response_command_id = CommandId.GET_APP_ATTRIBUTES
|
|
488
|
+
self._response = asyncio.Future()
|
|
489
|
+
|
|
490
|
+
# Send the command.
|
|
491
|
+
await self._send_command(command)
|
|
492
|
+
|
|
493
|
+
# Wait for the response.
|
|
494
|
+
return await self._response
|
|
495
|
+
finally:
|
|
496
|
+
self._reset_response()
|
|
497
|
+
|
|
498
|
+
async def perform_action(self, notification_uid: int, action: ActionId) -> None:
|
|
499
|
+
if not self._started:
|
|
500
|
+
raise RuntimeError("client not started")
|
|
501
|
+
|
|
502
|
+
command = struct.pack(
|
|
503
|
+
"<BIB", CommandId.PERFORM_NOTIFICATION_ACTION, notification_uid, action
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
async with self._command_semaphore:
|
|
507
|
+
await self._send_command(command)
|
|
508
|
+
|
|
509
|
+
async def perform_positive_action(self, notification_uid: int) -> None:
|
|
510
|
+
return await self.perform_action(notification_uid, ActionId.POSITIVE)
|
|
511
|
+
|
|
512
|
+
async def perform_negative_action(self, notification_uid: int) -> None:
|
|
513
|
+
return await self.perform_action(notification_uid, ActionId.NEGATIVE)
|
bumble/profiles/ascs.py
CHANGED
|
@@ -23,6 +23,7 @@ import logging
|
|
|
23
23
|
import struct
|
|
24
24
|
from typing import Any, Dict, List, Optional, Sequence, Tuple, Type, Union
|
|
25
25
|
|
|
26
|
+
from bumble import utils
|
|
26
27
|
from bumble import colors
|
|
27
28
|
from bumble.profiles.bap import CodecSpecificConfiguration
|
|
28
29
|
from bumble.profiles import le_audio
|
|
@@ -343,8 +344,10 @@ class AseStateMachine(gatt.Characteristic):
|
|
|
343
344
|
and cis_id == self.cis_id
|
|
344
345
|
and self.state == self.State.ENABLING
|
|
345
346
|
):
|
|
346
|
-
|
|
347
|
-
|
|
347
|
+
utils.cancel_on_event(
|
|
348
|
+
acl_connection,
|
|
349
|
+
'flush',
|
|
350
|
+
self.service.device.accept_cis_request(cis_handle),
|
|
348
351
|
)
|
|
349
352
|
|
|
350
353
|
def on_cis_establishment(self, cis_link: device.CisLink) -> None:
|
|
@@ -361,7 +364,9 @@ class AseStateMachine(gatt.Characteristic):
|
|
|
361
364
|
self.state = self.State.STREAMING
|
|
362
365
|
await self.service.device.notify_subscribers(self, self.value)
|
|
363
366
|
|
|
364
|
-
|
|
367
|
+
utils.cancel_on_event(
|
|
368
|
+
cis_link.acl_connection, 'flush', post_cis_established()
|
|
369
|
+
)
|
|
365
370
|
self.cis_link = cis_link
|
|
366
371
|
|
|
367
372
|
def on_cis_disconnection(self, _reason) -> None:
|
|
@@ -509,7 +514,7 @@ class AseStateMachine(gatt.Characteristic):
|
|
|
509
514
|
self.state = self.State.IDLE
|
|
510
515
|
await self.service.device.notify_subscribers(self, self.value)
|
|
511
516
|
|
|
512
|
-
self.service.device
|
|
517
|
+
utils.cancel_on_event(self.service.device, 'flush', remove_cis_async())
|
|
513
518
|
return (AseResponseCode.SUCCESS, AseReasonCode.NONE)
|
|
514
519
|
|
|
515
520
|
@property
|
|
@@ -594,7 +599,7 @@ class AudioStreamControlService(gatt.TemplateService):
|
|
|
594
599
|
UUID = gatt.GATT_AUDIO_STREAM_CONTROL_SERVICE
|
|
595
600
|
|
|
596
601
|
ase_state_machines: Dict[int, AseStateMachine]
|
|
597
|
-
ase_control_point: gatt.Characteristic
|
|
602
|
+
ase_control_point: gatt.Characteristic[bytes]
|
|
598
603
|
_active_client: Optional[device.Connection] = None
|
|
599
604
|
|
|
600
605
|
def __init__(
|
|
@@ -691,7 +696,8 @@ class AudioStreamControlService(gatt.TemplateService):
|
|
|
691
696
|
control_point_notification = bytes(
|
|
692
697
|
[operation.op_code, len(responses)]
|
|
693
698
|
) + b''.join(map(bytes, responses))
|
|
694
|
-
|
|
699
|
+
utils.cancel_on_event(
|
|
700
|
+
self.device,
|
|
695
701
|
'flush',
|
|
696
702
|
self.device.notify_subscribers(
|
|
697
703
|
self.ase_control_point, control_point_notification
|
|
@@ -700,7 +706,8 @@ class AudioStreamControlService(gatt.TemplateService):
|
|
|
700
706
|
|
|
701
707
|
for ase_id, *_ in responses:
|
|
702
708
|
if ase := self.ase_state_machines.get(ase_id):
|
|
703
|
-
|
|
709
|
+
utils.cancel_on_event(
|
|
710
|
+
self.device,
|
|
704
711
|
'flush',
|
|
705
712
|
self.device.notify_subscribers(ase, ase.value),
|
|
706
713
|
)
|
|
@@ -710,9 +717,9 @@ class AudioStreamControlService(gatt.TemplateService):
|
|
|
710
717
|
class AudioStreamControlServiceProxy(gatt_client.ProfileServiceProxy):
|
|
711
718
|
SERVICE_CLASS = AudioStreamControlService
|
|
712
719
|
|
|
713
|
-
sink_ase: List[gatt_client.CharacteristicProxy]
|
|
714
|
-
source_ase: List[gatt_client.CharacteristicProxy]
|
|
715
|
-
ase_control_point: gatt_client.CharacteristicProxy
|
|
720
|
+
sink_ase: List[gatt_client.CharacteristicProxy[bytes]]
|
|
721
|
+
source_ase: List[gatt_client.CharacteristicProxy[bytes]]
|
|
722
|
+
ase_control_point: gatt_client.CharacteristicProxy[bytes]
|
|
716
723
|
|
|
717
724
|
def __init__(self, service_proxy: gatt_client.ServiceProxy):
|
|
718
725
|
self.service_proxy = service_proxy
|
bumble/profiles/asha.py
CHANGED
|
@@ -259,11 +259,11 @@ class AshaService(gatt.TemplateService):
|
|
|
259
259
|
# -----------------------------------------------------------------------------
|
|
260
260
|
class AshaServiceProxy(gatt_client.ProfileServiceProxy):
|
|
261
261
|
SERVICE_CLASS = AshaService
|
|
262
|
-
read_only_properties_characteristic: gatt_client.CharacteristicProxy
|
|
263
|
-
audio_control_point_characteristic: gatt_client.CharacteristicProxy
|
|
264
|
-
audio_status_point_characteristic: gatt_client.CharacteristicProxy
|
|
265
|
-
volume_characteristic: gatt_client.CharacteristicProxy
|
|
266
|
-
psm_characteristic: gatt_client.CharacteristicProxy
|
|
262
|
+
read_only_properties_characteristic: gatt_client.CharacteristicProxy[bytes]
|
|
263
|
+
audio_control_point_characteristic: gatt_client.CharacteristicProxy[bytes]
|
|
264
|
+
audio_status_point_characteristic: gatt_client.CharacteristicProxy[bytes]
|
|
265
|
+
volume_characteristic: gatt_client.CharacteristicProxy[bytes]
|
|
266
|
+
psm_characteristic: gatt_client.CharacteristicProxy[bytes]
|
|
267
267
|
|
|
268
268
|
def __init__(self, service_proxy: gatt_client.ServiceProxy) -> None:
|
|
269
269
|
self.service_proxy = service_proxy
|
bumble/profiles/bass.py
CHANGED
|
@@ -354,7 +354,7 @@ class BroadcastAudioScanService(gatt.TemplateService):
|
|
|
354
354
|
class BroadcastAudioScanServiceProxy(gatt_client.ProfileServiceProxy):
|
|
355
355
|
SERVICE_CLASS = BroadcastAudioScanService
|
|
356
356
|
|
|
357
|
-
broadcast_audio_scan_control_point: gatt_client.CharacteristicProxy
|
|
357
|
+
broadcast_audio_scan_control_point: gatt_client.CharacteristicProxy[bytes]
|
|
358
358
|
broadcast_receive_states: list[
|
|
359
359
|
gatt_client.CharacteristicProxy[Optional[BroadcastReceiveState]]
|
|
360
360
|
]
|
bumble/profiles/csip.py
CHANGED
|
@@ -99,10 +99,10 @@ class CoordinatedSetIdentificationService(gatt.TemplateService):
|
|
|
99
99
|
UUID = gatt.GATT_COORDINATED_SET_IDENTIFICATION_SERVICE
|
|
100
100
|
|
|
101
101
|
set_identity_resolving_key: bytes
|
|
102
|
-
set_identity_resolving_key_characteristic: gatt.Characteristic
|
|
103
|
-
coordinated_set_size_characteristic: Optional[gatt.Characteristic] = None
|
|
104
|
-
set_member_lock_characteristic: Optional[gatt.Characteristic] = None
|
|
105
|
-
set_member_rank_characteristic: Optional[gatt.Characteristic] = None
|
|
102
|
+
set_identity_resolving_key_characteristic: gatt.Characteristic[bytes]
|
|
103
|
+
coordinated_set_size_characteristic: Optional[gatt.Characteristic[bytes]] = None
|
|
104
|
+
set_member_lock_characteristic: Optional[gatt.Characteristic[bytes]] = None
|
|
105
|
+
set_member_rank_characteristic: Optional[gatt.Characteristic[bytes]] = None
|
|
106
106
|
|
|
107
107
|
def __init__(
|
|
108
108
|
self,
|
|
@@ -170,7 +170,7 @@ class CoordinatedSetIdentificationService(gatt.TemplateService):
|
|
|
170
170
|
else:
|
|
171
171
|
assert connection
|
|
172
172
|
|
|
173
|
-
if connection.transport == core.
|
|
173
|
+
if connection.transport == core.PhysicalTransport.LE:
|
|
174
174
|
key = await connection.device.get_long_term_key(
|
|
175
175
|
connection_handle=connection.handle, rand=b'', ediv=0
|
|
176
176
|
)
|
|
@@ -203,10 +203,10 @@ class CoordinatedSetIdentificationService(gatt.TemplateService):
|
|
|
203
203
|
class CoordinatedSetIdentificationProxy(gatt_client.ProfileServiceProxy):
|
|
204
204
|
SERVICE_CLASS = CoordinatedSetIdentificationService
|
|
205
205
|
|
|
206
|
-
set_identity_resolving_key: gatt_client.CharacteristicProxy
|
|
207
|
-
coordinated_set_size: Optional[gatt_client.CharacteristicProxy] = None
|
|
208
|
-
set_member_lock: Optional[gatt_client.CharacteristicProxy] = None
|
|
209
|
-
set_member_rank: Optional[gatt_client.CharacteristicProxy] = None
|
|
206
|
+
set_identity_resolving_key: gatt_client.CharacteristicProxy[bytes]
|
|
207
|
+
coordinated_set_size: Optional[gatt_client.CharacteristicProxy[bytes]] = None
|
|
208
|
+
set_member_lock: Optional[gatt_client.CharacteristicProxy[bytes]] = None
|
|
209
|
+
set_member_rank: Optional[gatt_client.CharacteristicProxy[bytes]] = None
|
|
210
210
|
|
|
211
211
|
def __init__(self, service_proxy: gatt_client.ServiceProxy) -> None:
|
|
212
212
|
self.service_proxy = service_proxy
|
|
@@ -242,7 +242,7 @@ class CoordinatedSetIdentificationProxy(gatt_client.ProfileServiceProxy):
|
|
|
242
242
|
else:
|
|
243
243
|
connection = self.service_proxy.client.connection
|
|
244
244
|
device = connection.device
|
|
245
|
-
if connection.transport == core.
|
|
245
|
+
if connection.transport == core.PhysicalTransport.LE:
|
|
246
246
|
key = await device.get_long_term_key(
|
|
247
247
|
connection_handle=connection.handle, rand=b'', ediv=0
|
|
248
248
|
)
|