bumble 0.0.193__py3-none-any.whl → 0.0.195__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 CHANGED
@@ -12,5 +12,5 @@ __version__: str
12
12
  __version_tuple__: VERSION_TUPLE
13
13
  version_tuple: VERSION_TUPLE
14
14
 
15
- __version__ = version = '0.0.193'
16
- __version_tuple__ = version_tuple = (0, 0, 193)
15
+ __version__ = version = '0.0.195'
16
+ __version_tuple__ = version_tuple = (0, 0, 195)
@@ -0,0 +1,407 @@
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 dataclasses
21
+ import logging
22
+ import os
23
+ from typing import cast, Dict, Optional, Tuple
24
+
25
+ import click
26
+ import pyee
27
+
28
+ from bumble.colors import color
29
+ import bumble.company_ids
30
+ import bumble.core
31
+ import bumble.device
32
+ import bumble.gatt
33
+ import bumble.hci
34
+ import bumble.profiles.bap
35
+ import bumble.profiles.pbp
36
+ import bumble.transport
37
+ import bumble.utils
38
+
39
+
40
+ # -----------------------------------------------------------------------------
41
+ # Logging
42
+ # -----------------------------------------------------------------------------
43
+ logger = logging.getLogger(__name__)
44
+
45
+
46
+ # -----------------------------------------------------------------------------
47
+ # Constants
48
+ # -----------------------------------------------------------------------------
49
+ AURACAST_DEFAULT_DEVICE_NAME = "Bumble Auracast"
50
+ AURACAST_DEFAULT_DEVICE_ADDRESS = bumble.hci.Address("F0:F1:F2:F3:F4:F5")
51
+
52
+
53
+ # -----------------------------------------------------------------------------
54
+ # Discover Broadcasts
55
+ # -----------------------------------------------------------------------------
56
+ class BroadcastDiscoverer:
57
+ @dataclasses.dataclass
58
+ class Broadcast(pyee.EventEmitter):
59
+ name: str
60
+ sync: bumble.device.PeriodicAdvertisingSync
61
+ rssi: int = 0
62
+ public_broadcast_announcement: Optional[
63
+ bumble.profiles.pbp.PublicBroadcastAnnouncement
64
+ ] = None
65
+ broadcast_audio_announcement: Optional[
66
+ bumble.profiles.bap.BroadcastAudioAnnouncement
67
+ ] = None
68
+ basic_audio_announcement: Optional[
69
+ bumble.profiles.bap.BasicAudioAnnouncement
70
+ ] = None
71
+ appearance: Optional[bumble.core.Appearance] = None
72
+ biginfo: Optional[bumble.device.BIGInfoAdvertisement] = None
73
+ manufacturer_data: Optional[Tuple[str, bytes]] = None
74
+
75
+ def __post_init__(self) -> None:
76
+ super().__init__()
77
+ self.sync.on('establishment', self.on_sync_establishment)
78
+ self.sync.on('loss', self.on_sync_loss)
79
+ self.sync.on('periodic_advertisement', self.on_periodic_advertisement)
80
+ self.sync.on('biginfo_advertisement', self.on_biginfo_advertisement)
81
+
82
+ self.establishment_timeout_task = asyncio.create_task(
83
+ self.wait_for_establishment()
84
+ )
85
+
86
+ async def wait_for_establishment(self) -> None:
87
+ await asyncio.sleep(5.0)
88
+ if self.sync.state == bumble.device.PeriodicAdvertisingSync.State.PENDING:
89
+ print(
90
+ color(
91
+ '!!! Periodic advertisement sync not established in time, '
92
+ 'canceling',
93
+ 'red',
94
+ )
95
+ )
96
+ await self.sync.terminate()
97
+
98
+ def update(self, advertisement: bumble.device.Advertisement) -> None:
99
+ self.rssi = advertisement.rssi
100
+ for service_data in advertisement.data.get_all(
101
+ bumble.core.AdvertisingData.SERVICE_DATA
102
+ ):
103
+ assert isinstance(service_data, tuple)
104
+ service_uuid, data = service_data
105
+ assert isinstance(data, bytes)
106
+
107
+ if (
108
+ service_uuid
109
+ == bumble.gatt.GATT_PUBLIC_BROADCAST_ANNOUNCEMENT_SERVICE
110
+ ):
111
+ self.public_broadcast_announcement = (
112
+ bumble.profiles.pbp.PublicBroadcastAnnouncement.from_bytes(data)
113
+ )
114
+ continue
115
+
116
+ if (
117
+ service_uuid
118
+ == bumble.gatt.GATT_BROADCAST_AUDIO_ANNOUNCEMENT_SERVICE
119
+ ):
120
+ self.broadcast_audio_announcement = (
121
+ bumble.profiles.bap.BroadcastAudioAnnouncement.from_bytes(data)
122
+ )
123
+ continue
124
+
125
+ self.appearance = advertisement.data.get( # type: ignore[assignment]
126
+ bumble.core.AdvertisingData.APPEARANCE
127
+ )
128
+
129
+ if manufacturer_data := advertisement.data.get(
130
+ bumble.core.AdvertisingData.MANUFACTURER_SPECIFIC_DATA
131
+ ):
132
+ assert isinstance(manufacturer_data, tuple)
133
+ company_id = cast(int, manufacturer_data[0])
134
+ data = cast(bytes, manufacturer_data[1])
135
+ self.manufacturer_data = (
136
+ bumble.company_ids.COMPANY_IDENTIFIERS.get(
137
+ company_id, f'0x{company_id:04X}'
138
+ ),
139
+ data,
140
+ )
141
+
142
+ def print(self) -> None:
143
+ print(
144
+ color('Broadcast:', 'yellow'),
145
+ self.sync.advertiser_address,
146
+ color(self.sync.state.name, 'green'),
147
+ )
148
+ print(f' {color("Name", "cyan")}: {self.name}')
149
+ if self.appearance:
150
+ print(f' {color("Appearance", "cyan")}: {str(self.appearance)}')
151
+ print(f' {color("RSSI", "cyan")}: {self.rssi}')
152
+ print(f' {color("SID", "cyan")}: {self.sync.sid}')
153
+
154
+ if self.manufacturer_data:
155
+ print(
156
+ f' {color("Manufacturer Data", "cyan")}: '
157
+ f'{self.manufacturer_data[0]} -> {self.manufacturer_data[1].hex()}'
158
+ )
159
+
160
+ if self.broadcast_audio_announcement:
161
+ print(
162
+ f' {color("Broadcast ID", "cyan")}: '
163
+ f'{self.broadcast_audio_announcement.broadcast_id}'
164
+ )
165
+
166
+ if self.public_broadcast_announcement:
167
+ print(
168
+ f' {color("Features", "cyan")}: '
169
+ f'{self.public_broadcast_announcement.features}'
170
+ )
171
+ print(
172
+ f' {color("Metadata", "cyan")}: '
173
+ f'{self.public_broadcast_announcement.metadata}'
174
+ )
175
+
176
+ if self.basic_audio_announcement:
177
+ print(color(' Audio:', 'cyan'))
178
+ print(
179
+ color(' Presentation Delay:', 'magenta'),
180
+ self.basic_audio_announcement.presentation_delay,
181
+ )
182
+ for subgroup in self.basic_audio_announcement.subgroups:
183
+ print(color(' Subgroup:', 'magenta'))
184
+ print(color(' Codec ID:', 'yellow'))
185
+ print(
186
+ color(' Coding Format: ', 'green'),
187
+ subgroup.codec_id.coding_format.name,
188
+ )
189
+ print(
190
+ color(' Company ID: ', 'green'),
191
+ subgroup.codec_id.company_id,
192
+ )
193
+ print(
194
+ color(' Vendor Specific Codec ID:', 'green'),
195
+ subgroup.codec_id.vendor_specific_codec_id,
196
+ )
197
+ print(
198
+ color(' Codec Config:', 'yellow'),
199
+ subgroup.codec_specific_configuration,
200
+ )
201
+ print(color(' Metadata: ', 'yellow'), subgroup.metadata)
202
+
203
+ for bis in subgroup.bis:
204
+ print(color(f' BIS [{bis.index}]:', 'yellow'))
205
+ print(
206
+ color(' Codec Config:', 'green'),
207
+ bis.codec_specific_configuration,
208
+ )
209
+
210
+ if self.biginfo:
211
+ print(color(' BIG:', 'cyan'))
212
+ print(
213
+ color(' Number of BIS:', 'magenta'),
214
+ self.biginfo.num_bis,
215
+ )
216
+ print(
217
+ color(' PHY: ', 'magenta'),
218
+ self.biginfo.phy.name,
219
+ )
220
+ print(
221
+ color(' Framed: ', 'magenta'),
222
+ self.biginfo.framed,
223
+ )
224
+ print(
225
+ color(' Encrypted: ', 'magenta'),
226
+ self.biginfo.encrypted,
227
+ )
228
+
229
+ def on_sync_establishment(self) -> None:
230
+ self.establishment_timeout_task.cancel()
231
+ self.emit('change')
232
+
233
+ def on_sync_loss(self) -> None:
234
+ self.basic_audio_announcement = None
235
+ self.biginfo = None
236
+ self.emit('change')
237
+
238
+ def on_periodic_advertisement(
239
+ self, advertisement: bumble.device.PeriodicAdvertisement
240
+ ) -> None:
241
+ if advertisement.data is None:
242
+ return
243
+
244
+ for service_data in advertisement.data.get_all(
245
+ bumble.core.AdvertisingData.SERVICE_DATA
246
+ ):
247
+ assert isinstance(service_data, tuple)
248
+ service_uuid, data = service_data
249
+ assert isinstance(data, bytes)
250
+
251
+ if service_uuid == bumble.gatt.GATT_BASIC_AUDIO_ANNOUNCEMENT_SERVICE:
252
+ self.basic_audio_announcement = (
253
+ bumble.profiles.bap.BasicAudioAnnouncement.from_bytes(data)
254
+ )
255
+ break
256
+
257
+ self.emit('change')
258
+
259
+ def on_biginfo_advertisement(
260
+ self, advertisement: bumble.device.BIGInfoAdvertisement
261
+ ) -> None:
262
+ self.biginfo = advertisement
263
+ self.emit('change')
264
+
265
+ def __init__(
266
+ self,
267
+ device: bumble.device.Device,
268
+ filter_duplicates: bool,
269
+ sync_timeout: float,
270
+ ):
271
+ self.device = device
272
+ self.filter_duplicates = filter_duplicates
273
+ self.sync_timeout = sync_timeout
274
+ self.broadcasts: Dict[bumble.hci.Address, BroadcastDiscoverer.Broadcast] = {}
275
+ self.status_message = ''
276
+ device.on('advertisement', self.on_advertisement)
277
+
278
+ async def run(self) -> None:
279
+ self.status_message = color('Scanning...', 'green')
280
+ await self.device.start_scanning(
281
+ active=False,
282
+ filter_duplicates=False,
283
+ )
284
+
285
+ def refresh(self) -> None:
286
+ # Clear the screen from the top
287
+ print('\033[H')
288
+ print('\033[0J')
289
+ print('\033[H')
290
+
291
+ # Print the status message
292
+ print(self.status_message)
293
+ print("==========================================")
294
+
295
+ # Print all broadcasts
296
+ for broadcast in self.broadcasts.values():
297
+ broadcast.print()
298
+ print('------------------------------------------')
299
+
300
+ # Clear the screen to the bottom
301
+ print('\033[0J')
302
+
303
+ def on_advertisement(self, advertisement: bumble.device.Advertisement) -> None:
304
+ if (
305
+ broadcast_name := advertisement.data.get(
306
+ bumble.core.AdvertisingData.BROADCAST_NAME
307
+ )
308
+ ) is None:
309
+ return
310
+ assert isinstance(broadcast_name, str)
311
+
312
+ if broadcast := self.broadcasts.get(advertisement.address):
313
+ broadcast.update(advertisement)
314
+ self.refresh()
315
+ return
316
+
317
+ bumble.utils.AsyncRunner.spawn(
318
+ self.on_new_broadcast(broadcast_name, advertisement)
319
+ )
320
+
321
+ async def on_new_broadcast(
322
+ self, name: str, advertisement: bumble.device.Advertisement
323
+ ) -> None:
324
+ periodic_advertising_sync = await self.device.create_periodic_advertising_sync(
325
+ advertiser_address=advertisement.address,
326
+ sid=advertisement.sid,
327
+ sync_timeout=self.sync_timeout,
328
+ filter_duplicates=self.filter_duplicates,
329
+ )
330
+ broadcast = self.Broadcast(
331
+ name,
332
+ periodic_advertising_sync,
333
+ )
334
+ broadcast.on('change', self.refresh)
335
+ broadcast.update(advertisement)
336
+ self.broadcasts[advertisement.address] = broadcast
337
+ periodic_advertising_sync.on('loss', lambda: self.on_broadcast_loss(broadcast))
338
+ self.status_message = color(
339
+ f'+Found {len(self.broadcasts)} broadcasts', 'green'
340
+ )
341
+ self.refresh()
342
+
343
+ def on_broadcast_loss(self, broadcast: Broadcast) -> None:
344
+ del self.broadcasts[broadcast.sync.advertiser_address]
345
+ bumble.utils.AsyncRunner.spawn(broadcast.sync.terminate())
346
+ self.status_message = color(
347
+ f'-Found {len(self.broadcasts)} broadcasts', 'green'
348
+ )
349
+ self.refresh()
350
+
351
+
352
+ async def run_discover_broadcasts(
353
+ filter_duplicates: bool, sync_timeout: float, transport: str
354
+ ) -> None:
355
+ async with await bumble.transport.open_transport(transport) as (
356
+ hci_source,
357
+ hci_sink,
358
+ ):
359
+ device = bumble.device.Device.with_hci(
360
+ AURACAST_DEFAULT_DEVICE_NAME,
361
+ AURACAST_DEFAULT_DEVICE_ADDRESS,
362
+ hci_source,
363
+ hci_sink,
364
+ )
365
+ await device.power_on()
366
+ discoverer = BroadcastDiscoverer(device, filter_duplicates, sync_timeout)
367
+ await discoverer.run()
368
+ await hci_source.terminated
369
+
370
+
371
+ # -----------------------------------------------------------------------------
372
+ # Main
373
+ # -----------------------------------------------------------------------------
374
+ @click.group()
375
+ @click.pass_context
376
+ def auracast(
377
+ ctx,
378
+ ):
379
+ ctx.ensure_object(dict)
380
+
381
+
382
+ @auracast.command('discover-broadcasts')
383
+ @click.option(
384
+ '--filter-duplicates', is_flag=True, default=False, help='Filter duplicates'
385
+ )
386
+ @click.option(
387
+ '--sync-timeout',
388
+ metavar='SYNC_TIMEOUT',
389
+ type=float,
390
+ default=5.0,
391
+ help='Sync timeout (in seconds)',
392
+ )
393
+ @click.argument('transport')
394
+ @click.pass_context
395
+ def discover_broadcasts(ctx, filter_duplicates, sync_timeout, transport):
396
+ """Discover public broadcasts"""
397
+ asyncio.run(run_discover_broadcasts(filter_duplicates, sync_timeout, transport))
398
+
399
+
400
+ def main():
401
+ logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
402
+ auracast()
403
+
404
+
405
+ # -----------------------------------------------------------------------------
406
+ if __name__ == "__main__":
407
+ main() # pylint: disable=no-value-for-parameter