bumble 0.0.147__py3-none-any.whl → 0.0.149__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
@@ -1,4 +1,4 @@
1
1
  # file generated by setuptools_scm
2
2
  # don't change, don't track in version control
3
- __version__ = version = '0.0.147'
4
- __version_tuple__ = version_tuple = (0, 0, 147)
3
+ __version__ = version = '0.0.149'
4
+ __version_tuple__ = version_tuple = (0, 0, 149)
bumble/apps/bench.py CHANGED
@@ -558,11 +558,13 @@ class GattServer:
558
558
  # Setup the GATT service
559
559
  self.speed_tx = Characteristic(
560
560
  SPEED_TX_UUID,
561
- Characteristic.WRITE,
561
+ Characteristic.Properties.WRITE,
562
562
  Characteristic.WRITEABLE,
563
563
  CharacteristicValue(write=self.on_tx_write),
564
564
  )
565
- self.speed_rx = Characteristic(SPEED_RX_UUID, Characteristic.NOTIFY, 0)
565
+ self.speed_rx = Characteristic(
566
+ SPEED_RX_UUID, Characteristic.Properties.NOTIFY, 0
567
+ )
566
568
 
567
569
  speed_service = Service(
568
570
  SPEED_SERVICE_UUID,
bumble/apps/console.py CHANGED
@@ -24,10 +24,12 @@ import logging
24
24
  import os
25
25
  import random
26
26
  import re
27
- from typing import Optional
27
+ import humanize
28
+ from typing import Optional, Union
28
29
  from collections import OrderedDict
29
30
 
30
31
  import click
32
+ from prettytable import PrettyTable
31
33
 
32
34
  from prompt_toolkit import Application
33
35
  from prompt_toolkit.history import FileHistory
@@ -125,7 +127,8 @@ class ConsoleApp:
125
127
 
126
128
  def __init__(self):
127
129
  self.known_addresses = set()
128
- self.known_attributes = []
130
+ self.known_remote_attributes = []
131
+ self.known_local_attributes = []
129
132
  self.device = None
130
133
  self.connected_peer = None
131
134
  self.top_tab = 'device'
@@ -162,6 +165,8 @@ class ConsoleApp:
162
165
  'device': None,
163
166
  'local-services': None,
164
167
  'remote-services': None,
168
+ 'local-values': None,
169
+ 'remote-values': None,
165
170
  },
166
171
  'filter': {
167
172
  'address': None,
@@ -172,10 +177,11 @@ class ConsoleApp:
172
177
  'disconnect': None,
173
178
  'discover': {'services': None, 'attributes': None},
174
179
  'request-mtu': None,
175
- 'read': LiveCompleter(self.known_attributes),
176
- 'write': LiveCompleter(self.known_attributes),
177
- 'subscribe': LiveCompleter(self.known_attributes),
178
- 'unsubscribe': LiveCompleter(self.known_attributes),
180
+ 'read': LiveCompleter(self.known_remote_attributes),
181
+ 'write': LiveCompleter(self.known_remote_attributes),
182
+ 'local-write': LiveCompleter(self.known_local_attributes),
183
+ 'subscribe': LiveCompleter(self.known_remote_attributes),
184
+ 'unsubscribe': LiveCompleter(self.known_remote_attributes),
179
185
  'set-phy': {'1m': None, '2m': None, 'coded': None},
180
186
  'set-default-phy': None,
181
187
  'quit': None,
@@ -207,6 +213,8 @@ class ConsoleApp:
207
213
  self.log_text = FormattedTextControl(
208
214
  get_cursor_position=lambda: Point(0, max(0, len(self.log_lines) - 1))
209
215
  )
216
+ self.local_values_text = FormattedTextControl()
217
+ self.remote_values_text = FormattedTextControl()
210
218
  self.log_height = Dimension(min=7, weight=4)
211
219
  self.log_max_lines = 100
212
220
  self.log_lines = []
@@ -221,10 +229,18 @@ class ConsoleApp:
221
229
  Frame(Window(self.local_services_text), title='Local Services'),
222
230
  filter=Condition(lambda: self.top_tab == 'local-services'),
223
231
  ),
232
+ ConditionalContainer(
233
+ Frame(Window(self.local_values_text), title='Local Values'),
234
+ filter=Condition(lambda: self.top_tab == 'local-values'),
235
+ ),
224
236
  ConditionalContainer(
225
237
  Frame(Window(self.remote_services_text), title='Remote Services'),
226
238
  filter=Condition(lambda: self.top_tab == 'remote-services'),
227
239
  ),
240
+ ConditionalContainer(
241
+ Frame(Window(self.remote_values_text), title='Remote Values'),
242
+ filter=Condition(lambda: self.top_tab == 'remote-values'),
243
+ ),
228
244
  ConditionalContainer(
229
245
  Frame(Window(self.log_text, height=self.log_height), title='Log'),
230
246
  filter=Condition(lambda: self.top_tab == 'log'),
@@ -366,17 +382,19 @@ class ConsoleApp:
366
382
 
367
383
  def show_remote_services(self, services):
368
384
  lines = []
369
- del self.known_attributes[:]
385
+ del self.known_remote_attributes[:]
370
386
  for service in services:
371
387
  lines.append(("ansicyan", f"{service}\n"))
372
388
 
373
389
  for characteristic in service.characteristics:
374
390
  lines.append(('ansimagenta', f' {characteristic} + \n'))
375
- self.known_attributes.append(
391
+ self.known_remote_attributes.append(
376
392
  f'{service.uuid.to_hex_str()}.{characteristic.uuid.to_hex_str()}'
377
393
  )
378
- self.known_attributes.append(f'*.{characteristic.uuid.to_hex_str()}')
379
- self.known_attributes.append(f'#{characteristic.handle:X}')
394
+ self.known_remote_attributes.append(
395
+ f'*.{characteristic.uuid.to_hex_str()}'
396
+ )
397
+ self.known_remote_attributes.append(f'#{characteristic.handle:X}')
380
398
  for descriptor in characteristic.descriptors:
381
399
  lines.append(("ansigreen", f" {descriptor}\n"))
382
400
 
@@ -385,12 +403,31 @@ class ConsoleApp:
385
403
 
386
404
  def show_local_services(self, attributes):
387
405
  lines = []
406
+ del self.known_local_attributes[:]
388
407
  for attribute in attributes:
389
408
  if isinstance(attribute, Service):
409
+ # Save the most recent service for use later
410
+ service = attribute
390
411
  lines.append(("ansicyan", f"{attribute}\n"))
391
- elif isinstance(attribute, (Characteristic, CharacteristicDeclaration)):
412
+ elif isinstance(attribute, Characteristic):
413
+ # CharacteristicDeclaration includes all info from Characteristic
414
+ # no need to print it twice
415
+ continue
416
+ elif isinstance(attribute, CharacteristicDeclaration):
417
+ # Save the most recent characteristic declaration for use later
418
+ characteristic_declaration = attribute
419
+ self.known_local_attributes.append(
420
+ f'{service.uuid.to_hex_str()}.{attribute.characteristic.uuid.to_hex_str()}'
421
+ )
422
+ self.known_local_attributes.append(
423
+ f'#{attribute.characteristic.handle:X}'
424
+ )
392
425
  lines.append(("ansimagenta", f" {attribute}\n"))
393
426
  elif isinstance(attribute, Descriptor):
427
+ self.known_local_attributes.append(
428
+ f'{service.uuid.to_hex_str()}.{characteristic_declaration.characteristic.uuid.to_hex_str()}.{attribute.type.to_hex_str()}'
429
+ )
430
+ self.known_local_attributes.append(f'#{attribute.handle:X}')
394
431
  lines.append(("ansigreen", f" {attribute}\n"))
395
432
  else:
396
433
  lines.append(("ansiyellow", f"{attribute}\n"))
@@ -494,7 +531,7 @@ class ConsoleApp:
494
531
 
495
532
  self.show_attributes(attributes)
496
533
 
497
- def find_characteristic(self, param) -> Optional[CharacteristicProxy]:
534
+ def find_remote_characteristic(self, param) -> Optional[CharacteristicProxy]:
498
535
  if not self.connected_peer:
499
536
  return None
500
537
  parts = param.split('.')
@@ -516,6 +553,38 @@ class ConsoleApp:
516
553
 
517
554
  return None
518
555
 
556
+ def find_local_attribute(
557
+ self, param
558
+ ) -> Optional[Union[Characteristic, Descriptor]]:
559
+ parts = param.split('.')
560
+ if len(parts) == 3:
561
+ service_uuid = UUID(parts[0])
562
+ characteristic_uuid = UUID(parts[1])
563
+ descriptor_uuid = UUID(parts[2])
564
+ return self.device.gatt_server.get_descriptor_attribute(
565
+ service_uuid, characteristic_uuid, descriptor_uuid
566
+ )
567
+ if len(parts) == 2:
568
+ service_uuid = UUID(parts[0])
569
+ characteristic_uuid = UUID(parts[1])
570
+ characteristic_attributes = (
571
+ self.device.gatt_server.get_characteristic_attributes(
572
+ service_uuid, characteristic_uuid
573
+ )
574
+ )
575
+ if characteristic_attributes:
576
+ return characteristic_attributes[1]
577
+ return None
578
+ elif len(parts) == 1:
579
+ if parts[0].startswith('#'):
580
+ attribute_handle = int(f'{parts[0][1:]}', 16)
581
+ attribute = self.device.gatt_server.get_attribute(attribute_handle)
582
+ if isinstance(attribute, (Characteristic, Descriptor)):
583
+ return attribute
584
+ return None
585
+
586
+ return None
587
+
519
588
  async def rssi_monitor_loop(self):
520
589
  while True:
521
590
  if self.monitor_rssi and self.connected_peer:
@@ -674,10 +743,109 @@ class ConsoleApp:
674
743
  'device',
675
744
  'local-services',
676
745
  'remote-services',
746
+ 'local-values',
747
+ 'remote-values',
677
748
  }:
678
749
  self.top_tab = params[0]
679
750
  self.ui.invalidate()
680
751
 
752
+ while self.top_tab == 'local-values':
753
+ await self.do_show_local_values()
754
+ await asyncio.sleep(1)
755
+
756
+ while self.top_tab == 'remote-values':
757
+ await self.do_show_remote_values()
758
+ await asyncio.sleep(1)
759
+
760
+ async def do_show_local_values(self):
761
+ prettytable = PrettyTable()
762
+ field_names = ["Service", "Characteristic", "Descriptor"]
763
+
764
+ # if there's no connections, add a column just for value
765
+ if not self.device.connections:
766
+ field_names.append("Value")
767
+
768
+ # if there are connections, add a column for each connection's value
769
+ for connection in self.device.connections.values():
770
+ field_names.append(f"Connection {connection.handle}")
771
+
772
+ for attribute in self.device.gatt_server.attributes:
773
+ if isinstance(attribute, Characteristic):
774
+ service = self.device.gatt_server.get_attribute_group(
775
+ attribute.handle, Service
776
+ )
777
+ if not service:
778
+ continue
779
+ values = [
780
+ attribute.read_value(connection)
781
+ for connection in self.device.connections.values()
782
+ ]
783
+ if not values:
784
+ values = [attribute.read_value(None)]
785
+ prettytable.add_row([f"{service.uuid}", attribute.uuid, ""] + values)
786
+
787
+ elif isinstance(attribute, Descriptor):
788
+ service = self.device.gatt_server.get_attribute_group(
789
+ attribute.handle, Service
790
+ )
791
+ if not service:
792
+ continue
793
+ characteristic = self.device.gatt_server.get_attribute_group(
794
+ attribute.handle, Characteristic
795
+ )
796
+ if not characteristic:
797
+ continue
798
+ values = [
799
+ attribute.read_value(connection)
800
+ for connection in self.device.connections.values()
801
+ ]
802
+ if not values:
803
+ values = [attribute.read_value(None)]
804
+
805
+ # TODO: future optimization: convert CCCD value to human readable string
806
+
807
+ prettytable.add_row(
808
+ [service.uuid, characteristic.uuid, attribute.type] + values
809
+ )
810
+
811
+ prettytable.field_names = field_names
812
+ self.local_values_text.text = prettytable.get_string()
813
+ self.ui.invalidate()
814
+
815
+ async def do_show_remote_values(self):
816
+ prettytable = PrettyTable(
817
+ field_names=[
818
+ "Connection",
819
+ "Service",
820
+ "Characteristic",
821
+ "Descriptor",
822
+ "Time",
823
+ "Value",
824
+ ]
825
+ )
826
+ for connection in self.device.connections.values():
827
+ for handle, (time, value) in connection.gatt_client.cached_values.items():
828
+ row = [connection.handle]
829
+ attribute = connection.gatt_client.get_attributes(handle)
830
+ if not attribute:
831
+ continue
832
+ if len(attribute) == 3:
833
+ row.extend(
834
+ [attribute[0].uuid, attribute[1].uuid, attribute[2].type]
835
+ )
836
+ elif len(attribute) == 2:
837
+ row.extend([attribute[0].uuid, attribute[1].uuid, ""])
838
+ elif len(attribute) == 1:
839
+ row.extend([attribute[0].uuid, "", ""])
840
+ else:
841
+ continue
842
+
843
+ row.extend([humanize.naturaltime(time), value])
844
+ prettytable.add_row(row)
845
+
846
+ self.remote_values_text.text = prettytable.get_string()
847
+ self.ui.invalidate()
848
+
681
849
  async def do_get_phy(self, _):
682
850
  if not self.connected_peer:
683
851
  self.show_error('not connected')
@@ -720,7 +888,7 @@ class ConsoleApp:
720
888
  self.show_error('not connected')
721
889
  return
722
890
 
723
- characteristic = self.find_characteristic(params[0])
891
+ characteristic = self.find_remote_characteristic(params[0])
724
892
  if characteristic is None:
725
893
  self.show_error('no such characteristic')
726
894
  return
@@ -745,15 +913,43 @@ class ConsoleApp:
745
913
  except ValueError:
746
914
  value = str.encode(params[1]) # must be a string
747
915
 
748
- characteristic = self.find_characteristic(params[0])
916
+ characteristic = self.find_remote_characteristic(params[0])
749
917
  if characteristic is None:
750
918
  self.show_error('no such characteristic')
751
919
  return
752
920
 
753
921
  # use write with response if supported
754
- with_response = characteristic.properties & Characteristic.WRITE
922
+ with_response = characteristic.properties & Characteristic.Properties.WRITE
755
923
  await characteristic.write_value(value, with_response=with_response)
756
924
 
925
+ async def do_local_write(self, params):
926
+ if len(params) != 2:
927
+ self.show_error(
928
+ 'invalid syntax', 'expected local-write <attribute> <value>'
929
+ )
930
+ return
931
+
932
+ if params[1].upper().startswith("0X"):
933
+ value = bytes.fromhex(params[1][2:]) # parse as hex string
934
+ else:
935
+ try:
936
+ value = int(params[1]).to_bytes(2, "little") # try as 2 byte integer
937
+ except ValueError:
938
+ value = str.encode(params[1]) # must be a string
939
+
940
+ attribute = self.find_local_attribute(params[0])
941
+ if not attribute:
942
+ self.show_error('invalid syntax', 'unable to find attribute')
943
+ return
944
+
945
+ # send data to any subscribers
946
+ if isinstance(attribute, Characteristic):
947
+ attribute.write_value(None, value)
948
+ if attribute.has_properties(Characteristic.NOTIFY):
949
+ await self.device.gatt_server.notify_subscribers(attribute)
950
+ if attribute.has_properties(Characteristic.INDICATE):
951
+ await self.device.gatt_server.indicate_subscribers(attribute)
952
+
757
953
  async def do_subscribe(self, params):
758
954
  if not self.connected_peer:
759
955
  self.show_error('not connected')
@@ -763,7 +959,7 @@ class ConsoleApp:
763
959
  self.show_error('invalid syntax', 'expected subscribe <attribute>')
764
960
  return
765
961
 
766
- characteristic = self.find_characteristic(params[0])
962
+ characteristic = self.find_remote_characteristic(params[0])
767
963
  if characteristic is None:
768
964
  self.show_error('no such characteristic')
769
965
  return
@@ -783,7 +979,7 @@ class ConsoleApp:
783
979
  self.show_error('invalid syntax', 'expected subscribe <attribute>')
784
980
  return
785
981
 
786
- characteristic = self.find_characteristic(params[0])
982
+ characteristic = self.find_remote_characteristic(params[0])
787
983
  if characteristic is None:
788
984
  self.show_error('no such characteristic')
789
985
  return
bumble/apps/gg_bridge.py CHANGED
@@ -230,13 +230,13 @@ class GattlinkNodeBridge(GattlinkL2capEndpoint, Device.Listener):
230
230
  )
231
231
  self.tx_characteristic = Characteristic(
232
232
  GG_GATTLINK_TX_CHARACTERISTIC_UUID,
233
- Characteristic.NOTIFY,
233
+ Characteristic.Properties.NOTIFY,
234
234
  Characteristic.READABLE,
235
235
  )
236
236
  self.tx_characteristic.on('subscription', self.on_tx_subscription)
237
237
  self.psm_characteristic = Characteristic(
238
238
  GG_GATTLINK_L2CAP_CHANNEL_PSM_CHARACTERISTIC_UUID,
239
- Characteristic.READ | Characteristic.NOTIFY,
239
+ Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
240
240
  Characteristic.READABLE,
241
241
  bytes([psm, 0]),
242
242
  )
@@ -339,8 +339,7 @@ async def run(
339
339
 
340
340
  # Create a UDP to TX bridge (receive from TX, send to UDP)
341
341
  bridge.tx_socket, _ = await loop.create_datagram_endpoint(
342
- # pylint: disable-next=unnecessary-lambda
343
- lambda: asyncio.DatagramProtocol(),
342
+ asyncio.DatagramProtocol,
344
343
  remote_addr=(send_host, send_port),
345
344
  )
346
345
 
bumble/apps/pair.py CHANGED
@@ -24,7 +24,7 @@ from prompt_toolkit.shortcuts import PromptSession
24
24
  from bumble.colors import color
25
25
  from bumble.device import Device, Peer
26
26
  from bumble.transport import open_transport_or_link
27
- from bumble.smp import PairingDelegate, PairingConfig
27
+ from bumble.pairing import PairingDelegate, PairingConfig
28
28
  from bumble.smp import error_name as smp_error_name
29
29
  from bumble.keys import JsonKeyStore
30
30
  from bumble.core import ProtocolError
@@ -264,6 +264,7 @@ async def pair(
264
264
  sc,
265
265
  mitm,
266
266
  bond,
267
+ ctkd,
267
268
  io,
268
269
  prompt,
269
270
  request,
@@ -302,7 +303,8 @@ async def pair(
302
303
  [
303
304
  Characteristic(
304
305
  '552957FB-CF1F-4A31-9535-E78847E1A714',
305
- Characteristic.READ | Characteristic.WRITE,
306
+ Characteristic.Properties.READ
307
+ | Characteristic.Properties.WRITE,
306
308
  Characteristic.READABLE | Characteristic.WRITEABLE,
307
309
  CharacteristicValue(
308
310
  read=read_with_error, write=write_with_error
@@ -316,6 +318,7 @@ async def pair(
316
318
  if mode == 'classic':
317
319
  device.classic_enabled = True
318
320
  device.le_enabled = False
321
+ device.classic_smp_enabled = ctkd
319
322
 
320
323
  # Get things going
321
324
  await device.power_on()
@@ -342,8 +345,13 @@ async def pair(
342
345
  print(color(f'Pairing failed: {error}', 'red'))
343
346
  return
344
347
  else:
345
- # Advertise so that peers can find us and connect
346
- await device.start_advertising(auto_restart=True)
348
+ if mode == 'le':
349
+ # Advertise so that peers can find us and connect
350
+ await device.start_advertising(auto_restart=True)
351
+ else:
352
+ # Become discoverable and connectable
353
+ await device.set_discoverable(True)
354
+ await device.set_connectable(True)
347
355
 
348
356
  # Run until the user asks to exit
349
357
  await Waiter.instance.wait_until_terminated()
@@ -378,6 +386,13 @@ class LogHandler(logging.Handler):
378
386
  @click.option(
379
387
  '--bond', type=bool, default=True, help='Enable bonding', show_default=True
380
388
  )
389
+ @click.option(
390
+ '--ctkd',
391
+ type=bool,
392
+ default=True,
393
+ help='Enable CTKD',
394
+ show_default=True,
395
+ )
381
396
  @click.option(
382
397
  '--io',
383
398
  type=click.Choice(
@@ -404,6 +419,7 @@ def main(
404
419
  sc,
405
420
  mitm,
406
421
  bond,
422
+ ctkd,
407
423
  io,
408
424
  prompt,
409
425
  request,
@@ -426,6 +442,7 @@ def main(
426
442
  sc,
427
443
  mitm,
428
444
  bond,
445
+ ctkd,
429
446
  io,
430
447
  prompt,
431
448
  request,
bumble/att.py CHANGED
@@ -190,7 +190,7 @@ class ATT_Error(ProtocolError):
190
190
  super().__init__(
191
191
  error_code,
192
192
  error_namespace='att',
193
- error_name=ATT_PDU.error_name(self.error_code),
193
+ error_name=ATT_PDU.error_name(error_code),
194
194
  )
195
195
  self.att_handle = att_handle
196
196
  self.message = message
@@ -750,10 +750,10 @@ class Attribute(EventEmitter):
750
750
  permissions_str.split(","),
751
751
  0,
752
752
  )
753
- except TypeError:
753
+ except TypeError as exc:
754
754
  raise TypeError(
755
- f"Attribute::permissions error:\nExpected a string containing any of the keys, seperated by commas: {','.join(Attribute.PERMISSION_NAMES.values())}\nGot: {permissions_str}"
756
- )
755
+ f"Attribute::permissions error:\nExpected a string containing any of the keys, separated by commas: {','.join(Attribute.PERMISSION_NAMES.values())}\nGot: {permissions_str}"
756
+ ) from exc
757
757
 
758
758
  def __init__(self, attribute_type, permissions, value=b''):
759
759
  EventEmitter.__init__(self)