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.
- bumble/_version.py +9 -4
- bumble/apps/auracast.py +29 -35
- bumble/apps/bench.py +13 -10
- bumble/apps/console.py +19 -12
- bumble/apps/gg_bridge.py +1 -1
- bumble/att.py +61 -39
- bumble/controller.py +7 -8
- bumble/core.py +306 -159
- bumble/device.py +127 -82
- bumble/gatt.py +25 -228
- bumble/gatt_adapters.py +374 -0
- bumble/gatt_client.py +38 -31
- bumble/gatt_server.py +5 -5
- bumble/hci.py +76 -71
- bumble/host.py +19 -8
- bumble/l2cap.py +2 -2
- bumble/link.py +2 -2
- bumble/pairing.py +5 -5
- bumble/pandora/host.py +19 -23
- bumble/pandora/security.py +2 -3
- bumble/pandora/utils.py +2 -2
- bumble/profiles/aics.py +33 -23
- bumble/profiles/ancs.py +514 -0
- bumble/profiles/ascs.py +2 -1
- bumble/profiles/asha.py +11 -9
- bumble/profiles/bass.py +8 -5
- bumble/profiles/battery_service.py +13 -3
- bumble/profiles/device_information_service.py +16 -14
- bumble/profiles/gap.py +12 -8
- bumble/profiles/gatt_service.py +1 -0
- bumble/profiles/gmap.py +16 -11
- bumble/profiles/hap.py +8 -6
- bumble/profiles/heart_rate_service.py +20 -4
- bumble/profiles/mcp.py +11 -9
- bumble/profiles/pacs.py +37 -24
- bumble/profiles/tmap.py +6 -4
- bumble/profiles/vcs.py +6 -5
- bumble/profiles/vocs.py +49 -41
- bumble/smp.py +3 -3
- bumble/transport/usb.py +1 -3
- bumble/utils.py +10 -0
- {bumble-0.0.207.dist-info → bumble-0.0.209.dist-info}/METADATA +3 -3
- {bumble-0.0.207.dist-info → bumble-0.0.209.dist-info}/RECORD +47 -45
- {bumble-0.0.207.dist-info → bumble-0.0.209.dist-info}/WHEEL +1 -1
- {bumble-0.0.207.dist-info → bumble-0.0.209.dist-info}/LICENSE +0 -0
- {bumble-0.0.207.dist-info → bumble-0.0.209.dist-info}/entry_points.txt +0 -0
- {bumble-0.0.207.dist-info → bumble-0.0.209.dist-info}/top_level.txt +0 -0
bumble/profiles/ancs.py
ADDED
|
@@ -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
|
|
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
|
|
138
|
-
gatt.
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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,
|
|
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) ->
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
20
|
-
|
|
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 =
|
|
68
|
+
self.battery_level = PackedCharacteristicProxyAdapter(
|
|
59
69
|
characteristics[0], pack_format=BatteryService.BATTERY_LEVEL_FORMAT
|
|
60
70
|
)
|
|
61
71
|
else:
|