bumble 0.0.211__py3-none-any.whl → 0.0.213__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.
Files changed (95) hide show
  1. bumble/_version.py +2 -2
  2. bumble/a2dp.py +6 -0
  3. bumble/apps/README.md +0 -3
  4. bumble/apps/auracast.py +11 -9
  5. bumble/apps/bench.py +482 -31
  6. bumble/apps/console.py +5 -5
  7. bumble/apps/controller_info.py +47 -10
  8. bumble/apps/controller_loopback.py +7 -3
  9. bumble/apps/controllers.py +2 -2
  10. bumble/apps/device_info.py +2 -2
  11. bumble/apps/gatt_dump.py +2 -2
  12. bumble/apps/gg_bridge.py +2 -2
  13. bumble/apps/hci_bridge.py +2 -2
  14. bumble/apps/l2cap_bridge.py +2 -2
  15. bumble/apps/lea_unicast/app.py +6 -1
  16. bumble/apps/pair.py +204 -43
  17. bumble/apps/pandora_server.py +2 -2
  18. bumble/apps/rfcomm_bridge.py +1 -1
  19. bumble/apps/scan.py +2 -2
  20. bumble/apps/show.py +4 -2
  21. bumble/apps/speaker/speaker.html +1 -0
  22. bumble/apps/speaker/speaker.js +113 -62
  23. bumble/apps/speaker/speaker.py +126 -18
  24. bumble/at.py +4 -4
  25. bumble/att.py +15 -18
  26. bumble/avc.py +7 -7
  27. bumble/avctp.py +5 -5
  28. bumble/avdtp.py +138 -88
  29. bumble/avrcp.py +52 -58
  30. bumble/colors.py +2 -2
  31. bumble/controller.py +84 -23
  32. bumble/core.py +13 -7
  33. bumble/{crypto.py → crypto/__init__.py} +11 -95
  34. bumble/crypto/builtin.py +652 -0
  35. bumble/crypto/cryptography.py +84 -0
  36. bumble/device.py +688 -345
  37. bumble/drivers/__init__.py +2 -2
  38. bumble/drivers/common.py +0 -2
  39. bumble/drivers/intel.py +40 -40
  40. bumble/drivers/rtk.py +28 -35
  41. bumble/gatt.py +7 -9
  42. bumble/gatt_adapters.py +4 -5
  43. bumble/gatt_client.py +31 -34
  44. bumble/gatt_server.py +15 -17
  45. bumble/hci.py +2635 -2878
  46. bumble/helpers.py +4 -5
  47. bumble/hfp.py +76 -57
  48. bumble/hid.py +24 -12
  49. bumble/host.py +117 -34
  50. bumble/keys.py +68 -52
  51. bumble/l2cap.py +329 -403
  52. bumble/link.py +6 -270
  53. bumble/pairing.py +23 -20
  54. bumble/pandora/__init__.py +1 -1
  55. bumble/pandora/config.py +2 -2
  56. bumble/pandora/device.py +6 -6
  57. bumble/pandora/host.py +38 -39
  58. bumble/pandora/l2cap.py +4 -4
  59. bumble/pandora/security.py +73 -57
  60. bumble/pandora/utils.py +3 -3
  61. bumble/profiles/aics.py +3 -5
  62. bumble/profiles/ancs.py +3 -1
  63. bumble/profiles/ascs.py +143 -136
  64. bumble/profiles/asha.py +13 -8
  65. bumble/profiles/bap.py +3 -4
  66. bumble/profiles/csip.py +3 -5
  67. bumble/profiles/device_information_service.py +2 -2
  68. bumble/profiles/gap.py +2 -2
  69. bumble/profiles/gatt_service.py +1 -3
  70. bumble/profiles/hap.py +42 -58
  71. bumble/profiles/le_audio.py +4 -4
  72. bumble/profiles/mcp.py +16 -13
  73. bumble/profiles/vcs.py +8 -10
  74. bumble/profiles/vocs.py +6 -9
  75. bumble/rfcomm.py +27 -18
  76. bumble/rtp.py +1 -2
  77. bumble/sdp.py +2 -2
  78. bumble/smp.py +71 -69
  79. bumble/tools/rtk_util.py +2 -2
  80. bumble/transport/__init__.py +2 -16
  81. bumble/transport/android_netsim.py +5 -5
  82. bumble/transport/common.py +4 -4
  83. bumble/transport/pyusb.py +2 -2
  84. bumble/utils.py +2 -5
  85. bumble/vendor/android/hci.py +118 -200
  86. bumble/vendor/zephyr/hci.py +32 -27
  87. {bumble-0.0.211.dist-info → bumble-0.0.213.dist-info}/METADATA +5 -5
  88. {bumble-0.0.211.dist-info → bumble-0.0.213.dist-info}/RECORD +92 -93
  89. {bumble-0.0.211.dist-info → bumble-0.0.213.dist-info}/WHEEL +1 -1
  90. {bumble-0.0.211.dist-info → bumble-0.0.213.dist-info}/entry_points.txt +0 -1
  91. bumble/apps/link_relay/__init__.py +0 -0
  92. bumble/apps/link_relay/link_relay.py +0 -289
  93. bumble/apps/link_relay/logging.yml +0 -21
  94. {bumble-0.0.211.dist-info → bumble-0.0.213.dist-info}/licenses/LICENSE +0 -0
  95. {bumble-0.0.211.dist-info → bumble-0.0.213.dist-info}/top_level.txt +0 -0
bumble/apps/pair.py CHANGED
@@ -18,28 +18,35 @@
18
18
  import asyncio
19
19
  import os
20
20
  import logging
21
+ import struct
22
+
21
23
  import click
22
24
  from prompt_toolkit.shortcuts import PromptSession
23
25
 
26
+ from bumble.a2dp import make_audio_sink_service_sdp_records
24
27
  from bumble.colors import color
25
28
  from bumble.device import Device, Peer
26
- from bumble.transport import open_transport_or_link
29
+ from bumble.transport import open_transport
27
30
  from bumble.pairing import OobData, PairingDelegate, PairingConfig
28
31
  from bumble.smp import OobContext, OobLegacyContext
29
32
  from bumble.smp import error_name as smp_error_name
30
33
  from bumble.keys import JsonKeyStore
31
34
  from bumble.core import (
32
35
  AdvertisingData,
36
+ Appearance,
33
37
  ProtocolError,
34
38
  PhysicalTransport,
39
+ UUID,
35
40
  )
36
41
  from bumble.gatt import (
37
42
  GATT_DEVICE_NAME_CHARACTERISTIC,
38
43
  GATT_GENERIC_ACCESS_SERVICE,
44
+ GATT_HEART_RATE_SERVICE,
45
+ GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC,
39
46
  Service,
40
47
  Characteristic,
41
- CharacteristicValue,
42
48
  )
49
+ from bumble.hci import OwnAddressType
43
50
  from bumble.att import (
44
51
  ATT_Error,
45
52
  ATT_INSUFFICIENT_AUTHENTICATION_ERROR,
@@ -62,7 +69,7 @@ class Waiter:
62
69
  self.linger = linger
63
70
 
64
71
  def terminate(self):
65
- if not self.linger:
72
+ if not self.linger and not self.done.done:
66
73
  self.done.set_result(None)
67
74
 
68
75
  async def wait_until_terminated(self):
@@ -193,7 +200,7 @@ class Delegate(PairingDelegate):
193
200
 
194
201
  # -----------------------------------------------------------------------------
195
202
  async def get_peer_name(peer, mode):
196
- if mode == 'classic':
203
+ if peer.connection.transport == PhysicalTransport.BR_EDR:
197
204
  return await peer.request_name()
198
205
 
199
206
  # Try to get the peer name from GATT
@@ -225,13 +232,14 @@ def read_with_error(connection):
225
232
  raise ATT_Error(ATT_INSUFFICIENT_AUTHENTICATION_ERROR)
226
233
 
227
234
 
228
- def write_with_error(connection, _value):
229
- if not connection.is_encrypted:
230
- raise ATT_Error(ATT_INSUFFICIENT_ENCRYPTION_ERROR)
231
-
232
- if not AUTHENTICATION_ERROR_RETURNED[1]:
233
- AUTHENTICATION_ERROR_RETURNED[1] = True
234
- raise ATT_Error(ATT_INSUFFICIENT_AUTHENTICATION_ERROR)
235
+ # -----------------------------------------------------------------------------
236
+ def sdp_records():
237
+ service_record_handle = 0x00010001
238
+ return {
239
+ service_record_handle: make_audio_sink_service_sdp_records(
240
+ service_record_handle
241
+ )
242
+ }
235
243
 
236
244
 
237
245
  # -----------------------------------------------------------------------------
@@ -239,15 +247,19 @@ def on_connection(connection, request):
239
247
  print(color(f'<<< Connection: {connection}', 'green'))
240
248
 
241
249
  # Listen for pairing events
242
- connection.on('pairing_start', on_pairing_start)
243
- connection.on('pairing', lambda keys: on_pairing(connection, keys))
250
+ connection.on(connection.EVENT_PAIRING_START, on_pairing_start)
251
+ connection.on(connection.EVENT_PAIRING, lambda keys: on_pairing(connection, keys))
244
252
  connection.on(
245
- 'pairing_failure', lambda reason: on_pairing_failure(connection, reason)
253
+ connection.EVENT_CLASSIC_PAIRING, lambda: on_classic_pairing(connection)
254
+ )
255
+ connection.on(
256
+ connection.EVENT_PAIRING_FAILURE,
257
+ lambda reason: on_pairing_failure(connection, reason),
246
258
  )
247
259
 
248
260
  # Listen for encryption changes
249
261
  connection.on(
250
- 'connection_encryption_change',
262
+ connection.EVENT_CONNECTION_ENCRYPTION_CHANGE,
251
263
  lambda: on_connection_encryption_change(connection),
252
264
  )
253
265
 
@@ -288,6 +300,20 @@ async def on_pairing(connection, keys):
288
300
  Waiter.instance.terminate()
289
301
 
290
302
 
303
+ # -----------------------------------------------------------------------------
304
+ @AsyncRunner.run_in_task()
305
+ async def on_classic_pairing(connection):
306
+ print(color('***-----------------------------------', 'cyan'))
307
+ print(
308
+ color(
309
+ f'*** Paired [Classic]! (peer identity={connection.peer_address})', 'cyan'
310
+ )
311
+ )
312
+ print(color('***-----------------------------------', 'cyan'))
313
+ await asyncio.sleep(POST_PAIRING_DELAY)
314
+ Waiter.instance.terminate()
315
+
316
+
291
317
  # -----------------------------------------------------------------------------
292
318
  @AsyncRunner.run_in_task()
293
319
  async def on_pairing_failure(connection, reason):
@@ -305,6 +331,7 @@ async def pair(
305
331
  mitm,
306
332
  bond,
307
333
  ctkd,
334
+ advertising_address,
308
335
  identity_address,
309
336
  linger,
310
337
  io,
@@ -313,6 +340,8 @@ async def pair(
313
340
  request,
314
341
  print_keys,
315
342
  keystore_file,
343
+ advertise_service_uuids,
344
+ advertise_appearance,
316
345
  device_config,
317
346
  hci_transport,
318
347
  address_or_name,
@@ -320,7 +349,7 @@ async def pair(
320
349
  Waiter.instance = Waiter(linger=linger)
321
350
 
322
351
  print('<<< connecting to HCI...')
323
- async with await open_transport_or_link(hci_transport) as (hci_source, hci_sink):
352
+ async with await open_transport(hci_transport) as (hci_source, hci_sink):
324
353
  print('<<< connected')
325
354
 
326
355
  # Create a device to manage the host
@@ -328,29 +357,33 @@ async def pair(
328
357
 
329
358
  # Expose a GATT characteristic that can be used to trigger pairing by
330
359
  # responding with an authentication error when read
331
- if mode == 'le':
332
- device.le_enabled = True
360
+ if mode in ('le', 'dual'):
333
361
  device.add_service(
334
362
  Service(
335
- '50DB505C-8AC4-4738-8448-3B1D9CC09CC5',
363
+ GATT_HEART_RATE_SERVICE,
336
364
  [
337
365
  Characteristic(
338
- '552957FB-CF1F-4A31-9535-E78847E1A714',
339
- Characteristic.Properties.READ
340
- | Characteristic.Properties.WRITE,
341
- Characteristic.READABLE | Characteristic.WRITEABLE,
342
- CharacteristicValue(
343
- read=read_with_error, write=write_with_error
344
- ),
366
+ GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC,
367
+ Characteristic.Properties.READ,
368
+ Characteristic.READ_REQUIRES_AUTHENTICATION,
369
+ bytes(1),
345
370
  )
346
371
  ],
347
372
  )
348
373
  )
349
374
 
350
- # Select LE or Classic
351
- if mode == 'classic':
375
+ # LE and Classic support
376
+ if mode in ('classic', 'dual'):
352
377
  device.classic_enabled = True
353
378
  device.classic_smp_enabled = ctkd
379
+ if mode in ('le', 'dual'):
380
+ device.le_enabled = True
381
+ if mode == 'dual':
382
+ device.le_simultaneous_enabled = True
383
+
384
+ # Setup SDP
385
+ if mode in ('classic', 'dual'):
386
+ device.sdp_service_records = sdp_records()
354
387
 
355
388
  # Get things going
356
389
  await device.power_on()
@@ -369,14 +402,19 @@ async def pair(
369
402
  # Create an OOB context if needed
370
403
  if oob:
371
404
  our_oob_context = OobContext()
372
- shared_data = (
373
- None
374
- if oob == '-'
375
- else OobData.from_ad(
405
+ if oob == '-':
406
+ shared_data = None
407
+ legacy_context = OobLegacyContext()
408
+ else:
409
+ oob_data = OobData.from_ad(
376
410
  AdvertisingData.from_bytes(bytes.fromhex(oob))
377
- ).shared_data
378
- )
379
- legacy_context = OobLegacyContext()
411
+ )
412
+ shared_data = oob_data.shared_data
413
+ legacy_context = oob_data.legacy_context
414
+ if legacy_context is None and not sc:
415
+ print(color('OOB pairing in legacy mode requires TK', 'red'))
416
+ return
417
+
380
418
  oob_contexts = PairingConfig.OobConfig(
381
419
  our_context=our_oob_context,
382
420
  peer_data=shared_data,
@@ -386,7 +424,9 @@ async def pair(
386
424
  print(color('@@@ OOB Data:', 'yellow'))
387
425
  if shared_data is None:
388
426
  oob_data = OobData(
389
- address=device.random_address, shared_data=our_oob_context.share()
427
+ address=device.random_address,
428
+ shared_data=our_oob_context.share(),
429
+ legacy_context=(None if sc else legacy_context),
390
430
  )
391
431
  print(
392
432
  color(
@@ -394,7 +434,8 @@ async def pair(
394
434
  'yellow',
395
435
  )
396
436
  )
397
- print(color(f'@@@ TK={legacy_context.tk.hex()}', 'yellow'))
437
+ if legacy_context:
438
+ print(color(f'@@@ TK={legacy_context.tk.hex()}', 'yellow'))
398
439
  print(color('@@@-----------------------------------', 'yellow'))
399
440
  else:
400
441
  oob_contexts = None
@@ -436,13 +477,109 @@ async def pair(
436
477
  print(color(f'Pairing failed: {error}', 'red'))
437
478
 
438
479
  else:
439
- if mode == 'le':
440
- # Advertise so that peers can find us and connect
441
- await device.start_advertising(auto_restart=True)
442
- else:
480
+ if mode in ('le', 'dual'):
481
+ # Advertise so that peers can find us and connect.
482
+ # Include the heart rate service UUID in the advertisement data
483
+ # so that devices like iPhones can show this device in their
484
+ # Bluetooth selector.
485
+ service_uuids_16 = []
486
+ service_uuids_32 = []
487
+ service_uuids_128 = []
488
+ if advertise_service_uuids:
489
+ for uuid in advertise_service_uuids:
490
+ uuid = uuid.replace("-", "")
491
+ if len(uuid) == 4:
492
+ service_uuids_16.append(UUID(uuid))
493
+ elif len(uuid) == 8:
494
+ service_uuids_32.append(UUID(uuid))
495
+ elif len(uuid) == 32:
496
+ service_uuids_128.append(UUID(uuid))
497
+ else:
498
+ print(color('Invalid UUID format', 'red'))
499
+ return
500
+ else:
501
+ service_uuids_16.append(GATT_HEART_RATE_SERVICE)
502
+
503
+ flags = AdvertisingData.Flags.LE_LIMITED_DISCOVERABLE_MODE
504
+ if mode == 'le':
505
+ flags |= AdvertisingData.Flags.BR_EDR_NOT_SUPPORTED
506
+ if mode == 'dual':
507
+ flags |= AdvertisingData.Flags.SIMULTANEOUS_LE_BR_EDR_CAPABLE
508
+
509
+ ad_structs = [
510
+ (
511
+ AdvertisingData.FLAGS,
512
+ bytes([flags]),
513
+ ),
514
+ (AdvertisingData.COMPLETE_LOCAL_NAME, 'Bumble'.encode()),
515
+ ]
516
+ if service_uuids_16:
517
+ ad_structs.append(
518
+ (
519
+ AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
520
+ b"".join(bytes(uuid) for uuid in service_uuids_16),
521
+ )
522
+ )
523
+ if service_uuids_32:
524
+ ad_structs.append(
525
+ (
526
+ AdvertisingData.INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS,
527
+ b"".join(bytes(uuid) for uuid in service_uuids_32),
528
+ )
529
+ )
530
+ if service_uuids_128:
531
+ ad_structs.append(
532
+ (
533
+ AdvertisingData.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS,
534
+ b"".join(bytes(uuid) for uuid in service_uuids_128),
535
+ )
536
+ )
537
+
538
+ if advertise_appearance:
539
+ advertise_appearance = advertise_appearance.upper()
540
+ try:
541
+ advertise_appearance_int = int(advertise_appearance)
542
+ except ValueError:
543
+ category, subcategory = advertise_appearance.split('/')
544
+ try:
545
+ category_enum = Appearance.Category[category]
546
+ except ValueError:
547
+ print(
548
+ color(f'Invalid appearance category {category}', 'red')
549
+ )
550
+ return
551
+ subcategory_class = Appearance.SUBCATEGORY_CLASSES[
552
+ category_enum
553
+ ]
554
+ try:
555
+ subcategory_enum = subcategory_class[subcategory]
556
+ except ValueError:
557
+ print(color(f'Invalid subcategory {subcategory}', 'red'))
558
+ return
559
+ advertise_appearance_int = int(
560
+ Appearance(category_enum, subcategory_enum)
561
+ )
562
+ ad_structs.append(
563
+ (
564
+ AdvertisingData.APPEARANCE,
565
+ struct.pack('<H', advertise_appearance_int),
566
+ )
567
+ )
568
+ device.advertising_data = bytes(AdvertisingData(ad_structs))
569
+ await device.start_advertising(
570
+ auto_restart=True,
571
+ own_address_type=(
572
+ OwnAddressType.PUBLIC
573
+ if advertising_address == 'public'
574
+ else OwnAddressType.RANDOM
575
+ ),
576
+ )
577
+
578
+ if mode in ('classic', 'dual'):
443
579
  # Become discoverable and connectable
444
580
  await device.set_discoverable(True)
445
581
  await device.set_connectable(True)
582
+ print(color('Ready for connections on', 'blue'), device.public_address)
446
583
 
447
584
  # Run until the user asks to exit
448
585
  await Waiter.instance.wait_until_terminated()
@@ -462,7 +599,10 @@ class LogHandler(logging.Handler):
462
599
  # -----------------------------------------------------------------------------
463
600
  @click.command()
464
601
  @click.option(
465
- '--mode', type=click.Choice(['le', 'classic']), default='le', show_default=True
602
+ '--mode',
603
+ type=click.Choice(['le', 'classic', 'dual']),
604
+ default='le',
605
+ show_default=True,
466
606
  )
467
607
  @click.option(
468
608
  '--sc',
@@ -484,6 +624,10 @@ class LogHandler(logging.Handler):
484
624
  help='Enable CTKD',
485
625
  show_default=True,
486
626
  )
627
+ @click.option(
628
+ '--advertising-address',
629
+ type=click.Choice(['random', 'public']),
630
+ )
487
631
  @click.option(
488
632
  '--identity-address',
489
633
  type=click.Choice(['random', 'public']),
@@ -512,9 +656,20 @@ class LogHandler(logging.Handler):
512
656
  @click.option('--print-keys', is_flag=True, help='Print the bond keys before pairing')
513
657
  @click.option(
514
658
  '--keystore-file',
515
- metavar='<filename>',
659
+ metavar='FILENAME',
516
660
  help='File in which to store the pairing keys',
517
661
  )
662
+ @click.option(
663
+ '--advertise-service-uuid',
664
+ metavar="UUID",
665
+ multiple=True,
666
+ help="Advertise a GATT service UUID (may be specified more than once)",
667
+ )
668
+ @click.option(
669
+ '--advertise-appearance',
670
+ metavar='APPEARANCE',
671
+ help='Advertise an Appearance ID (int value or string)',
672
+ )
518
673
  @click.argument('device-config')
519
674
  @click.argument('hci_transport')
520
675
  @click.argument('address-or-name', required=False)
@@ -524,6 +679,7 @@ def main(
524
679
  mitm,
525
680
  bond,
526
681
  ctkd,
682
+ advertising_address,
527
683
  identity_address,
528
684
  linger,
529
685
  io,
@@ -532,6 +688,8 @@ def main(
532
688
  request,
533
689
  print_keys,
534
690
  keystore_file,
691
+ advertise_service_uuid,
692
+ advertise_appearance,
535
693
  device_config,
536
694
  hci_transport,
537
695
  address_or_name,
@@ -550,6 +708,7 @@ def main(
550
708
  mitm,
551
709
  bond,
552
710
  ctkd,
711
+ advertising_address,
553
712
  identity_address,
554
713
  linger,
555
714
  io,
@@ -558,6 +717,8 @@ def main(
558
717
  request,
559
718
  print_keys,
560
719
  keystore_file,
720
+ advertise_service_uuid,
721
+ advertise_appearance,
561
722
  device_config,
562
723
  hci_transport,
563
724
  address_or_name,
@@ -4,7 +4,7 @@ import logging
4
4
  import json
5
5
 
6
6
  from bumble.pandora import PandoraDevice, Config, serve
7
- from typing import Dict, Any
7
+ from typing import Any
8
8
 
9
9
  BUMBLE_SERVER_GRPC_PORT = 7999
10
10
  ROOTCANAL_PORT_CUTTLEFISH = 7300
@@ -39,7 +39,7 @@ def main(grpc_port: int, rootcanal_port: int, transport: str, config: str) -> No
39
39
  asyncio.run(serve(device, config=server_config, port=grpc_port))
40
40
 
41
41
 
42
- def retrieve_config(config: str) -> Dict[str, Any]:
42
+ def retrieve_config(config: str) -> dict[str, Any]:
43
43
  if not config:
44
44
  return {}
45
45
 
@@ -406,7 +406,7 @@ class ClientBridge:
406
406
  # -----------------------------------------------------------------------------
407
407
  async def run(device_config, hci_transport, bridge):
408
408
  print("<<< connecting to HCI...")
409
- async with await transport.open_transport_or_link(hci_transport) as (
409
+ async with await transport.open_transport(hci_transport) as (
410
410
  hci_source,
411
411
  hci_sink,
412
412
  ):
bumble/apps/scan.py CHANGED
@@ -22,7 +22,7 @@ import click
22
22
 
23
23
  from bumble.colors import color
24
24
  from bumble.device import Device
25
- from bumble.transport import open_transport_or_link
25
+ from bumble.transport import open_transport
26
26
  from bumble.keys import JsonKeyStore
27
27
  from bumble.smp import AddressResolver
28
28
  from bumble.device import Advertisement
@@ -127,7 +127,7 @@ async def scan(
127
127
  transport,
128
128
  ):
129
129
  print('<<< connecting to HCI...')
130
- async with await open_transport_or_link(transport) as (hci_source, hci_sink):
130
+ async with await open_transport(transport) as (hci_source, hci_sink):
131
131
  print('<<< connected')
132
132
 
133
133
  if device_config:
bumble/apps/show.py CHANGED
@@ -16,6 +16,7 @@
16
16
  # Imports
17
17
  # -----------------------------------------------------------------------------
18
18
  import datetime
19
+ import importlib
19
20
  import logging
20
21
  import os
21
22
  import struct
@@ -154,9 +155,10 @@ class Printer:
154
155
  def main(format, vendor, filename):
155
156
  for vendor_name in vendor:
156
157
  if vendor_name == 'android':
157
- import bumble.vendor.android.hci
158
+ # Prevent being deleted by linter.
159
+ importlib.import_module('bumble.vendor.android.hci')
158
160
  elif vendor_name == 'zephyr':
159
- import bumble.vendor.zephyr.hci
161
+ importlib.import_module('bumble.vendor.zephyr.hci')
160
162
 
161
163
  input = open(filename, 'rb')
162
164
  if format == 'h4':
@@ -15,6 +15,7 @@
15
15
  <tr><td>Codec</td><td><span id="codecText"></span></td></tr>
16
16
  <tr><td>Packets</td><td><span id="packetsReceivedText"></span></td></tr>
17
17
  <tr><td>Bytes</td><td><span id="bytesReceivedText"></span></td></tr>
18
+ <tr><td>Bitrate</td><td><span id="bitrate"></span></td></tr>
18
19
  </table>
19
20
  </td>
20
21
  <td>