bumble 0.0.213__py3-none-any.whl → 0.0.214__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/ams.py ADDED
@@ -0,0 +1,404 @@
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 Media Service (AMS).
17
+ """
18
+
19
+ # -----------------------------------------------------------------------------
20
+ # Imports
21
+ # -----------------------------------------------------------------------------
22
+ from __future__ import annotations
23
+ import asyncio
24
+ import dataclasses
25
+ import enum
26
+ import logging
27
+ from typing import Optional, Iterable, Union
28
+
29
+
30
+ from bumble.device import Peer
31
+ from bumble.gatt import (
32
+ Characteristic,
33
+ GATT_AMS_SERVICE,
34
+ GATT_AMS_REMOTE_COMMAND_CHARACTERISTIC,
35
+ GATT_AMS_ENTITY_UPDATE_CHARACTERISTIC,
36
+ GATT_AMS_ENTITY_ATTRIBUTE_CHARACTERISTIC,
37
+ TemplateService,
38
+ )
39
+ from bumble.gatt_client import CharacteristicProxy, ProfileServiceProxy, ServiceProxy
40
+ from bumble import utils
41
+
42
+
43
+ # -----------------------------------------------------------------------------
44
+ # Logging
45
+ # -----------------------------------------------------------------------------
46
+ logger = logging.getLogger(__name__)
47
+
48
+
49
+ # -----------------------------------------------------------------------------
50
+ # Protocol
51
+ # -----------------------------------------------------------------------------
52
+ class RemoteCommandId(utils.OpenIntEnum):
53
+ PLAY = 0
54
+ PAUSE = 1
55
+ TOGGLE_PLAY_PAUSE = 2
56
+ NEXT_TRACK = 3
57
+ PREVIOUS_TRACK = 4
58
+ VOLUME_UP = 5
59
+ VOLUME_DOWN = 6
60
+ ADVANCE_REPEAT_MODE = 7
61
+ ADVANCE_SHUFFLE_MODE = 8
62
+ SKIP_FORWARD = 9
63
+ SKIP_BACKWARD = 10
64
+ LIKE_TRACK = 11
65
+ DISLIKE_TRACK = 12
66
+ BOOKMARK_TRACK = 13
67
+
68
+
69
+ class EntityId(utils.OpenIntEnum):
70
+ PLAYER = 0
71
+ QUEUE = 1
72
+ TRACK = 2
73
+
74
+
75
+ class ActionId(utils.OpenIntEnum):
76
+ POSITIVE = 0
77
+ NEGATIVE = 1
78
+
79
+
80
+ class EntityUpdateFlags(enum.IntFlag):
81
+ TRUNCATED = 1
82
+
83
+
84
+ class PlayerAttributeId(utils.OpenIntEnum):
85
+ NAME = 0
86
+ PLAYBACK_INFO = 1
87
+ VOLUME = 2
88
+
89
+
90
+ class QueueAttributeId(utils.OpenIntEnum):
91
+ INDEX = 0
92
+ COUNT = 1
93
+ SHUFFLE_MODE = 2
94
+ REPEAT_MODE = 3
95
+
96
+
97
+ class ShuffleMode(utils.OpenIntEnum):
98
+ OFF = 0
99
+ ONE = 1
100
+ ALL = 2
101
+
102
+
103
+ class RepeatMode(utils.OpenIntEnum):
104
+ OFF = 0
105
+ ONE = 1
106
+ ALL = 2
107
+
108
+
109
+ class TrackAttributeId(utils.OpenIntEnum):
110
+ ARTIST = 0
111
+ ALBUM = 1
112
+ TITLE = 2
113
+ DURATION = 3
114
+
115
+
116
+ class PlaybackState(utils.OpenIntEnum):
117
+ PAUSED = 0
118
+ PLAYING = 1
119
+ REWINDING = 2
120
+ FAST_FORWARDING = 3
121
+
122
+
123
+ @dataclasses.dataclass
124
+ class PlaybackInfo:
125
+ playback_state: PlaybackState = PlaybackState.PAUSED
126
+ playback_rate: float = 1.0
127
+ elapsed_time: float = 0.0
128
+
129
+
130
+ # -----------------------------------------------------------------------------
131
+ # GATT Server-side
132
+ # -----------------------------------------------------------------------------
133
+ class Ams(TemplateService):
134
+ UUID = GATT_AMS_SERVICE
135
+
136
+ remote_command_characteristic: Characteristic
137
+ entity_update_characteristic: Characteristic
138
+ entity_attribute_characteristic: Characteristic
139
+
140
+ def __init__(self) -> None:
141
+ # TODO not the final implementation
142
+ self.remote_command_characteristic = Characteristic(
143
+ GATT_AMS_REMOTE_COMMAND_CHARACTERISTIC,
144
+ Characteristic.Properties.NOTIFY
145
+ | Characteristic.Properties.WRITE_WITHOUT_RESPONSE,
146
+ Characteristic.Permissions.WRITEABLE,
147
+ )
148
+
149
+ # TODO not the final implementation
150
+ self.entity_update_characteristic = Characteristic(
151
+ GATT_AMS_ENTITY_UPDATE_CHARACTERISTIC,
152
+ Characteristic.Properties.NOTIFY | Characteristic.Properties.WRITE,
153
+ Characteristic.Permissions.WRITEABLE,
154
+ )
155
+
156
+ # TODO not the final implementation
157
+ self.entity_attribute_characteristic = Characteristic(
158
+ GATT_AMS_ENTITY_ATTRIBUTE_CHARACTERISTIC,
159
+ Characteristic.Properties.READ
160
+ | Characteristic.Properties.WRITE_WITHOUT_RESPONSE,
161
+ Characteristic.Permissions.WRITEABLE | Characteristic.Permissions.READABLE,
162
+ )
163
+
164
+ super().__init__(
165
+ [
166
+ self.remote_command_characteristic,
167
+ self.entity_update_characteristic,
168
+ self.entity_attribute_characteristic,
169
+ ]
170
+ )
171
+
172
+
173
+ # -----------------------------------------------------------------------------
174
+ # GATT Client-side
175
+ # -----------------------------------------------------------------------------
176
+ class AmsProxy(ProfileServiceProxy):
177
+ SERVICE_CLASS = Ams
178
+
179
+ # NOTE: these don't use adapters, because the format for write and notifications
180
+ # are different.
181
+ remote_command: CharacteristicProxy[bytes]
182
+ entity_update: CharacteristicProxy[bytes]
183
+ entity_attribute: CharacteristicProxy[bytes]
184
+
185
+ def __init__(self, service_proxy: ServiceProxy):
186
+ self.remote_command = service_proxy.get_required_characteristic_by_uuid(
187
+ GATT_AMS_REMOTE_COMMAND_CHARACTERISTIC
188
+ )
189
+
190
+ self.entity_update = service_proxy.get_required_characteristic_by_uuid(
191
+ GATT_AMS_ENTITY_UPDATE_CHARACTERISTIC
192
+ )
193
+
194
+ self.entity_attribute = service_proxy.get_required_characteristic_by_uuid(
195
+ GATT_AMS_ENTITY_ATTRIBUTE_CHARACTERISTIC
196
+ )
197
+
198
+
199
+ class AmsClient(utils.EventEmitter):
200
+ EVENT_SUPPORTED_COMMANDS = "supported_commands"
201
+ EVENT_PLAYER_NAME = "player_name"
202
+ EVENT_PLAYER_PLAYBACK_INFO = "player_playback_info"
203
+ EVENT_PLAYER_VOLUME = "player_volume"
204
+ EVENT_QUEUE_COUNT = "queue_count"
205
+ EVENT_QUEUE_INDEX = "queue_index"
206
+ EVENT_QUEUE_SHUFFLE_MODE = "queue_shuffle_mode"
207
+ EVENT_QUEUE_REPEAT_MODE = "queue_repeat_mode"
208
+ EVENT_TRACK_ARTIST = "track_artist"
209
+ EVENT_TRACK_ALBUM = "track_album"
210
+ EVENT_TRACK_TITLE = "track_title"
211
+ EVENT_TRACK_DURATION = "track_duration"
212
+
213
+ supported_commands: set[RemoteCommandId]
214
+ player_name: str = ""
215
+ player_playback_info: PlaybackInfo = PlaybackInfo(PlaybackState.PAUSED, 0.0, 0.0)
216
+ player_volume: float = 1.0
217
+ queue_count: int = 0
218
+ queue_index: int = 0
219
+ queue_shuffle_mode: ShuffleMode = ShuffleMode.OFF
220
+ queue_repeat_mode: RepeatMode = RepeatMode.OFF
221
+ track_artist: str = ""
222
+ track_album: str = ""
223
+ track_title: str = ""
224
+ track_duration: float = 0.0
225
+
226
+ def __init__(self, ams_proxy: AmsProxy) -> None:
227
+ super().__init__()
228
+ self._ams_proxy = ams_proxy
229
+ self._started = False
230
+ self._read_attribute_semaphore = asyncio.Semaphore()
231
+ self.supported_commands = set()
232
+
233
+ @classmethod
234
+ async def for_peer(cls, peer: Peer) -> Optional[AmsClient]:
235
+ ams_proxy = await peer.discover_service_and_create_proxy(AmsProxy)
236
+ if ams_proxy is None:
237
+ return None
238
+ return cls(ams_proxy)
239
+
240
+ async def start(self) -> None:
241
+ logger.debug("subscribing to remote command characteristic")
242
+ await self._ams_proxy.remote_command.subscribe(
243
+ self._on_remote_command_notification
244
+ )
245
+
246
+ logger.debug("subscribing to entity update characteristic")
247
+ await self._ams_proxy.entity_update.subscribe(
248
+ lambda data: utils.AsyncRunner.spawn(
249
+ self._on_entity_update_notification(data)
250
+ )
251
+ )
252
+
253
+ self._started = True
254
+
255
+ async def stop(self) -> None:
256
+ await self._ams_proxy.remote_command.unsubscribe(
257
+ self._on_remote_command_notification
258
+ )
259
+ await self._ams_proxy.entity_update.unsubscribe(
260
+ self._on_entity_update_notification
261
+ )
262
+ self._started = False
263
+
264
+ async def observe(
265
+ self,
266
+ entity: EntityId,
267
+ attributes: Iterable[
268
+ Union[PlayerAttributeId, QueueAttributeId, TrackAttributeId]
269
+ ],
270
+ ) -> None:
271
+ await self._ams_proxy.entity_update.write_value(
272
+ bytes([entity] + list(attributes)), with_response=True
273
+ )
274
+
275
+ async def command(self, command: RemoteCommandId) -> None:
276
+ await self._ams_proxy.remote_command.write_value(
277
+ bytes([command]), with_response=True
278
+ )
279
+
280
+ async def play(self) -> None:
281
+ await self.command(RemoteCommandId.PLAY)
282
+
283
+ async def pause(self) -> None:
284
+ await self.command(RemoteCommandId.PAUSE)
285
+
286
+ async def toggle_play_pause(self) -> None:
287
+ await self.command(RemoteCommandId.TOGGLE_PLAY_PAUSE)
288
+
289
+ async def next_track(self) -> None:
290
+ await self.command(RemoteCommandId.NEXT_TRACK)
291
+
292
+ async def previous_track(self) -> None:
293
+ await self.command(RemoteCommandId.PREVIOUS_TRACK)
294
+
295
+ async def volume_up(self) -> None:
296
+ await self.command(RemoteCommandId.VOLUME_UP)
297
+
298
+ async def volume_down(self) -> None:
299
+ await self.command(RemoteCommandId.VOLUME_DOWN)
300
+
301
+ async def advance_repeat_mode(self) -> None:
302
+ await self.command(RemoteCommandId.ADVANCE_REPEAT_MODE)
303
+
304
+ async def advance_shuffle_mode(self) -> None:
305
+ await self.command(RemoteCommandId.ADVANCE_SHUFFLE_MODE)
306
+
307
+ async def skip_forward(self) -> None:
308
+ await self.command(RemoteCommandId.SKIP_FORWARD)
309
+
310
+ async def skip_backward(self) -> None:
311
+ await self.command(RemoteCommandId.SKIP_BACKWARD)
312
+
313
+ async def like_track(self) -> None:
314
+ await self.command(RemoteCommandId.LIKE_TRACK)
315
+
316
+ async def dislike_track(self) -> None:
317
+ await self.command(RemoteCommandId.DISLIKE_TRACK)
318
+
319
+ async def bookmark_track(self) -> None:
320
+ await self.command(RemoteCommandId.BOOKMARK_TRACK)
321
+
322
+ def _on_remote_command_notification(self, data: bytes) -> None:
323
+ supported_commands = [RemoteCommandId(command) for command in data]
324
+ logger.debug(
325
+ f"supported commands: {[command.name for command in supported_commands]}"
326
+ )
327
+ for command in supported_commands:
328
+ self.supported_commands.add(command)
329
+
330
+ self.emit(self.EVENT_SUPPORTED_COMMANDS)
331
+
332
+ async def _on_entity_update_notification(self, data: bytes) -> None:
333
+ entity = EntityId(data[0])
334
+ flags = EntityUpdateFlags(data[2])
335
+ value = data[3:]
336
+
337
+ if flags & EntityUpdateFlags.TRUNCATED:
338
+ logger.debug("truncated attribute, fetching full value")
339
+
340
+ # Write the entity and attribute we're interested in
341
+ # (protected by a semaphore, so that we only read one attribute at a time)
342
+ async with self._read_attribute_semaphore:
343
+ await self._ams_proxy.entity_attribute.write_value(
344
+ data[:2], with_response=True
345
+ )
346
+ value = await self._ams_proxy.entity_attribute.read_value()
347
+
348
+ if entity == EntityId.PLAYER:
349
+ player_attribute = PlayerAttributeId(data[1])
350
+ if player_attribute == PlayerAttributeId.NAME:
351
+ self.player_name = value.decode()
352
+ self.emit(self.EVENT_PLAYER_NAME)
353
+ elif player_attribute == PlayerAttributeId.PLAYBACK_INFO:
354
+ playback_state_str, playback_rate_str, elapsed_time_str = (
355
+ value.decode().split(",")
356
+ )
357
+ self.player_playback_info = PlaybackInfo(
358
+ PlaybackState(int(playback_state_str)),
359
+ float(playback_rate_str),
360
+ float(elapsed_time_str),
361
+ )
362
+ self.emit(self.EVENT_PLAYER_PLAYBACK_INFO)
363
+ elif player_attribute == PlayerAttributeId.VOLUME:
364
+ self.player_volume = float(value.decode())
365
+ self.emit(self.EVENT_PLAYER_VOLUME)
366
+ else:
367
+ logger.warning(f"received unknown player attribute {player_attribute}")
368
+
369
+ elif entity == EntityId.QUEUE:
370
+ queue_attribute = QueueAttributeId(data[1])
371
+ if queue_attribute == QueueAttributeId.COUNT:
372
+ self.queue_count = int(value)
373
+ self.emit(self.EVENT_QUEUE_COUNT)
374
+ elif queue_attribute == QueueAttributeId.INDEX:
375
+ self.queue_index = int(value)
376
+ self.emit(self.EVENT_QUEUE_INDEX)
377
+ elif queue_attribute == QueueAttributeId.REPEAT_MODE:
378
+ self.queue_repeat_mode = RepeatMode(int(value))
379
+ self.emit(self.EVENT_QUEUE_REPEAT_MODE)
380
+ elif queue_attribute == QueueAttributeId.SHUFFLE_MODE:
381
+ self.queue_shuffle_mode = ShuffleMode(int(value))
382
+ self.emit(self.EVENT_QUEUE_SHUFFLE_MODE)
383
+ else:
384
+ logger.warning(f"received unknown queue attribute {queue_attribute}")
385
+
386
+ elif entity == EntityId.TRACK:
387
+ track_attribute = TrackAttributeId(data[1])
388
+ if track_attribute == TrackAttributeId.ARTIST:
389
+ self.track_artist = value.decode()
390
+ self.emit(self.EVENT_TRACK_ARTIST)
391
+ elif track_attribute == TrackAttributeId.ALBUM:
392
+ self.track_album = value.decode()
393
+ self.emit(self.EVENT_TRACK_ALBUM)
394
+ elif track_attribute == TrackAttributeId.TITLE:
395
+ self.track_title = value.decode()
396
+ self.emit(self.EVENT_TRACK_TITLE)
397
+ elif track_attribute == TrackAttributeId.DURATION:
398
+ self.track_duration = float(value.decode())
399
+ self.emit(self.EVENT_TRACK_DURATION)
400
+ else:
401
+ logger.warning(f"received unknown track attribute {track_attribute}")
402
+
403
+ else:
404
+ logger.warning(f"received unknown attribute ID {data[1]}")
bumble/profiles/ascs.py CHANGED
@@ -452,6 +452,16 @@ class AseStateMachine(gatt.Characteristic):
452
452
 
453
453
  self.metadata = le_audio.Metadata.from_bytes(metadata)
454
454
  self.state = self.State.ENABLING
455
+ # CIS could be established before enable.
456
+ if cis_link := next(
457
+ (
458
+ cis_link
459
+ for cis_link in self.service.device.cis_links.values()
460
+ if cis_link.cig_id == self.cig_id and cis_link.cis_id == self.cis_id
461
+ ),
462
+ None,
463
+ ):
464
+ self.on_cis_establishment(cis_link)
455
465
 
456
466
  return (AseResponseCode.SUCCESS, AseReasonCode.NONE)
457
467
 
bumble/smp.py CHANGED
@@ -946,7 +946,9 @@ class Session:
946
946
  self.tk = self.passkey.to_bytes(16, byteorder='little')
947
947
  logger.debug(f'TK from passkey = {self.tk.hex()}')
948
948
 
949
- await self.pairing_config.delegate.display_number(self.passkey, digits=6)
949
+ self.connection.cancel_on_disconnection(
950
+ self.pairing_config.delegate.display_number(self.passkey, digits=6)
951
+ )
950
952
 
951
953
  def input_passkey(self, next_steps: Optional[Callable[[], None]] = None) -> None:
952
954
  # Prompt the user for the passkey displayed on the peer
@@ -1569,11 +1571,12 @@ class Session:
1569
1571
  if self.pairing_method == PairingMethod.CTKD_OVER_CLASSIC:
1570
1572
  # Authentication is already done in SMP, so remote shall start keys distribution immediately
1571
1573
  return
1572
- elif self.sc:
1573
- if self.pairing_method == PairingMethod.PASSKEY:
1574
- self.display_or_input_passkey()
1575
1574
 
1575
+ if self.sc:
1576
1576
  self.send_public_key_command()
1577
+
1578
+ if self.pairing_method == PairingMethod.PASSKEY:
1579
+ self.display_or_input_passkey()
1577
1580
  else:
1578
1581
  if self.pairing_method == PairingMethod.PASSKEY:
1579
1582
  self.display_or_input_passkey(self.send_pairing_confirm_command)
@@ -1846,10 +1849,10 @@ class Session:
1846
1849
  elif self.pairing_method == PairingMethod.PASSKEY:
1847
1850
  self.send_pairing_confirm_command()
1848
1851
  else:
1852
+ # Send our public key back to the initiator
1853
+ self.send_public_key_command()
1849
1854
 
1850
1855
  def next_steps() -> None:
1851
- # Send our public key back to the initiator
1852
- self.send_public_key_command()
1853
1856
 
1854
1857
  if self.pairing_method in (
1855
1858
  PairingMethod.JUST_WORKS,
@@ -17,7 +17,6 @@
17
17
  # -----------------------------------------------------------------------------
18
18
  import logging
19
19
  import asyncio
20
- import os
21
20
  from typing import Any, Optional
22
21
 
23
22
  import click
@@ -26,6 +25,8 @@ from bumble.colors import color
26
25
  from bumble import transport
27
26
  from bumble.drivers import intel
28
27
  from bumble.host import Host
28
+ import bumble.logging
29
+
29
30
 
30
31
  # -----------------------------------------------------------------------------
31
32
  # Logging
@@ -107,7 +108,7 @@ async def do_bootloader(usb_transport: str, force: bool) -> None:
107
108
  # -----------------------------------------------------------------------------
108
109
  @click.group()
109
110
  def main():
110
- logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
111
+ bumble.logging.setup_basic_logging()
111
112
 
112
113
 
113
114
  @main.command
bumble/tools/rtk_util.py CHANGED
@@ -15,15 +15,16 @@
15
15
  # -----------------------------------------------------------------------------
16
16
  # Imports
17
17
  # -----------------------------------------------------------------------------
18
- import logging
19
18
  import asyncio
20
- import os
19
+ import logging
21
20
 
22
21
  import click
23
22
 
24
23
  from bumble import transport
25
24
  from bumble.host import Host
26
25
  from bumble.drivers import rtk
26
+ import bumble.logging
27
+
27
28
 
28
29
  # -----------------------------------------------------------------------------
29
30
  # Logging
@@ -112,7 +113,7 @@ async def do_info(usb_transport, force):
112
113
  # -----------------------------------------------------------------------------
113
114
  @click.group()
114
115
  def main():
115
- logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
116
+ bumble.logging.setup_basic_logging()
116
117
 
117
118
 
118
119
  @main.command
@@ -1,8 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bumble
3
- Version: 0.0.213
3
+ Version: 0.0.214
4
4
  Summary: Bluetooth Stack for Apps, Emulation, Test and Experimentation
5
5
  Author-email: Google <bumble-dev@google.com>
6
+ License-Expression: Apache-2.0
6
7
  Project-URL: Homepage, https://github.com/google/bumble
7
8
  Requires-Python: >=3.9
8
9
  Description-Content-Type: text/markdown
@@ -33,7 +34,7 @@ Requires-Dist: pytest-asyncio>=0.23.5; extra == "test"
33
34
  Requires-Dist: pytest-html>=3.2.0; extra == "test"
34
35
  Requires-Dist: coverage>=6.4; extra == "test"
35
36
  Provides-Extra: development
36
- Requires-Dist: black==24.3; extra == "development"
37
+ Requires-Dist: black~=25.1; extra == "development"
37
38
  Requires-Dist: bt-test-interfaces>=0.0.6; extra == "development"
38
39
  Requires-Dist: grpcio-tools>=1.62.1; extra == "development"
39
40
  Requires-Dist: invoke>=1.7.3; extra == "development"