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/gatt.py CHANGED
@@ -28,7 +28,7 @@ import enum
28
28
  import functools
29
29
  import logging
30
30
  import struct
31
- from typing import Optional, Sequence
31
+ from typing import Optional, Sequence, List
32
32
 
33
33
  from .colors import color
34
34
  from .core import UUID, get_dict_key_by_value
@@ -259,63 +259,68 @@ class Characteristic(Attribute):
259
259
  See Vol 3, Part G - 3.3 CHARACTERISTIC DEFINITION
260
260
  '''
261
261
 
262
- # Property flags
263
- BROADCAST = 0x01
264
- READ = 0x02
265
- WRITE_WITHOUT_RESPONSE = 0x04
266
- WRITE = 0x08
267
- NOTIFY = 0x10
268
- INDICATE = 0x20
269
- AUTHENTICATED_SIGNED_WRITES = 0x40
270
- EXTENDED_PROPERTIES = 0x80
271
-
272
- PROPERTY_NAMES = {
273
- BROADCAST: 'BROADCAST',
274
- READ: 'READ',
275
- WRITE_WITHOUT_RESPONSE: 'WRITE_WITHOUT_RESPONSE',
276
- WRITE: 'WRITE',
277
- NOTIFY: 'NOTIFY',
278
- INDICATE: 'INDICATE',
279
- AUTHENTICATED_SIGNED_WRITES: 'AUTHENTICATED_SIGNED_WRITES',
280
- EXTENDED_PROPERTIES: 'EXTENDED_PROPERTIES',
281
- }
282
-
283
- @staticmethod
284
- def property_name(property_int):
285
- return Characteristic.PROPERTY_NAMES.get(property_int, '')
286
-
287
- @staticmethod
288
- def properties_as_string(properties):
289
- return ','.join(
290
- [
291
- Characteristic.property_name(p)
292
- for p in Characteristic.PROPERTY_NAMES
293
- if properties & p
294
- ]
295
- )
296
-
297
- @staticmethod
298
- def string_to_properties(properties_str: str):
299
- return functools.reduce(
300
- lambda x, y: x | get_dict_key_by_value(Characteristic.PROPERTY_NAMES, y),
301
- properties_str.split(","),
302
- 0,
303
- )
262
+ uuid: UUID
263
+ properties: Characteristic.Properties
264
+
265
+ class Properties(enum.IntFlag):
266
+ """Property flags"""
267
+
268
+ BROADCAST = 0x01
269
+ READ = 0x02
270
+ WRITE_WITHOUT_RESPONSE = 0x04
271
+ WRITE = 0x08
272
+ NOTIFY = 0x10
273
+ INDICATE = 0x20
274
+ AUTHENTICATED_SIGNED_WRITES = 0x40
275
+ EXTENDED_PROPERTIES = 0x80
276
+
277
+ @staticmethod
278
+ def from_string(properties_str: str) -> Characteristic.Properties:
279
+ property_names: List[str] = []
280
+ for property in Characteristic.Properties:
281
+ if property.name is None:
282
+ raise TypeError()
283
+ property_names.append(property.name)
284
+
285
+ def string_to_property(property_string) -> Characteristic.Properties:
286
+ for property in zip(Characteristic.Properties, property_names):
287
+ if property_string == property[1]:
288
+ return property[0]
289
+ raise TypeError(f"Unable to convert {property_string} to Property")
290
+
291
+ try:
292
+ return functools.reduce(
293
+ lambda x, y: x | string_to_property(y),
294
+ properties_str.split(","),
295
+ Characteristic.Properties(0),
296
+ )
297
+ except TypeError:
298
+ raise TypeError(
299
+ f"Characteristic.Properties::from_string() error:\nExpected a string containing any of the keys, separated by commas: {','.join(property_names)}\nGot: {properties_str}"
300
+ )
301
+
302
+ # For backwards compatibility these are defined here
303
+ # For new code, please use Characteristic.Properties.X
304
+ BROADCAST = Properties.BROADCAST
305
+ READ = Properties.READ
306
+ WRITE_WITHOUT_RESPONSE = Properties.WRITE_WITHOUT_RESPONSE
307
+ WRITE = Properties.WRITE
308
+ NOTIFY = Properties.NOTIFY
309
+ INDICATE = Properties.INDICATE
310
+ AUTHENTICATED_SIGNED_WRITES = Properties.AUTHENTICATED_SIGNED_WRITES
311
+ EXTENDED_PROPERTIES = Properties.EXTENDED_PROPERTIES
304
312
 
305
313
  def __init__(
306
314
  self,
307
315
  uuid,
308
- properties,
316
+ properties: Characteristic.Properties,
309
317
  permissions,
310
318
  value=b'',
311
319
  descriptors: Sequence[Descriptor] = (),
312
320
  ):
313
321
  super().__init__(uuid, permissions, value)
314
322
  self.uuid = self.type
315
- if isinstance(properties, str):
316
- self.properties = Characteristic.string_to_properties(properties)
317
- else:
318
- self.properties = properties
323
+ self.properties = properties
319
324
  self.descriptors = descriptors
320
325
 
321
326
  def get_descriptor(self, descriptor_type):
@@ -325,12 +330,15 @@ class Characteristic(Attribute):
325
330
 
326
331
  return None
327
332
 
333
+ def has_properties(self, properties: Characteristic.Properties) -> bool:
334
+ return self.properties & properties == properties
335
+
328
336
  def __str__(self):
329
337
  return (
330
338
  f'Characteristic(handle=0x{self.handle:04X}, '
331
339
  f'end=0x{self.end_group_handle:04X}, '
332
340
  f'uuid={self.uuid}, '
333
- f'properties={Characteristic.properties_as_string(self.properties)})'
341
+ f'{self.properties!s})'
334
342
  )
335
343
 
336
344
 
@@ -340,6 +348,8 @@ class CharacteristicDeclaration(Attribute):
340
348
  See Vol 3, Part G - 3.3.1 CHARACTERISTIC DECLARATION
341
349
  '''
342
350
 
351
+ characteristic: Characteristic
352
+
343
353
  def __init__(self, characteristic, value_handle):
344
354
  declaration_bytes = (
345
355
  struct.pack('<BH', characteristic.properties, value_handle)
@@ -355,8 +365,8 @@ class CharacteristicDeclaration(Attribute):
355
365
  return (
356
366
  f'CharacteristicDeclaration(handle=0x{self.handle:04X}, '
357
367
  f'value_handle=0x{self.value_handle:04X}, '
358
- f'uuid={self.characteristic.uuid}, properties='
359
- f'{Characteristic.properties_as_string(self.characteristic.properties)})'
368
+ f'uuid={self.characteristic.uuid}, '
369
+ f'{self.characteristic.properties!s})'
360
370
  )
361
371
 
362
372
 
bumble/gatt_client.py CHANGED
@@ -27,7 +27,8 @@ from __future__ import annotations
27
27
  import asyncio
28
28
  import logging
29
29
  import struct
30
- from typing import List, Optional
30
+ from datetime import datetime
31
+ from typing import List, Optional, Dict, Tuple, Callable, Union, Any
31
32
 
32
33
  from pyee import EventEmitter
33
34
 
@@ -62,7 +63,6 @@ from .gatt import (
62
63
  GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE,
63
64
  GATT_REQUEST_TIMEOUT,
64
65
  GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE,
65
- Service,
66
66
  Characteristic,
67
67
  ClientCharacteristicConfigurationBits,
68
68
  )
@@ -139,12 +139,21 @@ class ServiceProxy(AttributeProxy):
139
139
 
140
140
 
141
141
  class CharacteristicProxy(AttributeProxy):
142
+ properties: Characteristic.Properties
142
143
  descriptors: List[DescriptorProxy]
144
+ subscribers: Dict[Any, Callable]
143
145
 
144
- def __init__(self, client, handle, end_group_handle, uuid, properties):
146
+ def __init__(
147
+ self,
148
+ client,
149
+ handle,
150
+ end_group_handle,
151
+ uuid,
152
+ properties: int,
153
+ ):
145
154
  super().__init__(client, handle, end_group_handle, uuid)
146
155
  self.uuid = uuid
147
- self.properties = properties
156
+ self.properties = Characteristic.Properties(properties)
148
157
  self.descriptors = []
149
158
  self.descriptors_discovered = False
150
159
  self.subscribers = {} # Map from subscriber to proxy subscriber
@@ -159,7 +168,9 @@ class CharacteristicProxy(AttributeProxy):
159
168
  async def discover_descriptors(self):
160
169
  return await self.client.discover_descriptors(self)
161
170
 
162
- async def subscribe(self, subscriber=None, prefer_notify=True):
171
+ async def subscribe(
172
+ self, subscriber: Optional[Callable] = None, prefer_notify=True
173
+ ):
163
174
  if subscriber is not None:
164
175
  if subscriber in self.subscribers:
165
176
  # We already have a proxy subscriber
@@ -186,7 +197,7 @@ class CharacteristicProxy(AttributeProxy):
186
197
  return (
187
198
  f'Characteristic(handle=0x{self.handle:04X}, '
188
199
  f'uuid={self.uuid}, '
189
- f'properties={Characteristic.properties_as_string(self.properties)})'
200
+ f'{self.properties!s})'
190
201
  )
191
202
 
192
203
 
@@ -213,6 +224,7 @@ class ProfileServiceProxy:
213
224
  # -----------------------------------------------------------------------------
214
225
  class Client:
215
226
  services: List[ServiceProxy]
227
+ cached_values: Dict[int, Tuple[datetime, bytes]]
216
228
 
217
229
  def __init__(self, connection):
218
230
  self.connection = connection
@@ -225,6 +237,7 @@ class Client:
225
237
  ) # Notification subscribers, by attribute handle
226
238
  self.indication_subscribers = {} # Indication subscribers, by attribute handle
227
239
  self.services = []
240
+ self.cached_values = {}
228
241
 
229
242
  def send_gatt_pdu(self, pdu):
230
243
  self.connection.send_l2cap_pdu(ATT_CID, pdu)
@@ -309,6 +322,35 @@ class Client:
309
322
  if c.uuid == uuid
310
323
  ]
311
324
 
325
+ def get_attribute_grouping(
326
+ self, attribute_handle: int
327
+ ) -> Optional[
328
+ Union[
329
+ ServiceProxy,
330
+ Tuple[ServiceProxy, CharacteristicProxy],
331
+ Tuple[ServiceProxy, CharacteristicProxy, DescriptorProxy],
332
+ ]
333
+ ]:
334
+ """
335
+ Get the attribute(s) associated with an attribute handle
336
+ """
337
+ for service in self.services:
338
+ if service.handle == attribute_handle:
339
+ return service
340
+ if service.handle <= attribute_handle <= service.end_group_handle:
341
+ for characteristic in service.characteristics:
342
+ if characteristic.handle == attribute_handle:
343
+ return (service, characteristic)
344
+ if (
345
+ characteristic.handle
346
+ <= attribute_handle
347
+ <= characteristic.end_group_handle
348
+ ):
349
+ for descriptor in characteristic.descriptors:
350
+ if descriptor.handle == attribute_handle:
351
+ return (service, characteristic, descriptor)
352
+ return None
353
+
312
354
  def on_service_discovered(self, service):
313
355
  '''Add a service to the service list if it wasn't already there'''
314
356
  already_known = False
@@ -678,8 +720,8 @@ class Client:
678
720
  return
679
721
 
680
722
  if (
681
- characteristic.properties & Characteristic.NOTIFY
682
- and characteristic.properties & Characteristic.INDICATE
723
+ characteristic.properties & Characteristic.Properties.NOTIFY
724
+ and characteristic.properties & Characteristic.Properties.INDICATE
683
725
  ):
684
726
  if prefer_notify:
685
727
  bits = ClientCharacteristicConfigurationBits.NOTIFICATION
@@ -687,10 +729,10 @@ class Client:
687
729
  else:
688
730
  bits = ClientCharacteristicConfigurationBits.INDICATION
689
731
  subscribers = self.indication_subscribers
690
- elif characteristic.properties & Characteristic.NOTIFY:
732
+ elif characteristic.properties & Characteristic.Properties.NOTIFY:
691
733
  bits = ClientCharacteristicConfigurationBits.NOTIFICATION
692
734
  subscribers = self.notification_subscribers
693
- elif characteristic.properties & Characteristic.INDICATE:
735
+ elif characteristic.properties & Characteristic.Properties.INDICATE:
694
736
  bits = ClientCharacteristicConfigurationBits.INDICATION
695
737
  subscribers = self.indication_subscribers
696
738
  else:
@@ -800,6 +842,7 @@ class Client:
800
842
 
801
843
  offset += len(part)
802
844
 
845
+ self.cache_value(attribute_handle, attribute_value)
803
846
  # Return the value as bytes
804
847
  return attribute_value
805
848
 
@@ -934,6 +977,8 @@ class Client:
934
977
  )
935
978
  if not subscribers:
936
979
  logger.warning('!!! received notification with no subscriber')
980
+
981
+ self.cache_value(notification.attribute_handle, notification.attribute_value)
937
982
  for subscriber in subscribers:
938
983
  if callable(subscriber):
939
984
  subscriber(notification.attribute_value)
@@ -945,6 +990,8 @@ class Client:
945
990
  subscribers = self.indication_subscribers.get(indication.attribute_handle, [])
946
991
  if not subscribers:
947
992
  logger.warning('!!! received indication with no subscriber')
993
+
994
+ self.cache_value(indication.attribute_handle, indication.attribute_value)
948
995
  for subscriber in subscribers:
949
996
  if callable(subscriber):
950
997
  subscriber(indication.attribute_value)
@@ -953,3 +1000,9 @@ class Client:
953
1000
 
954
1001
  # Confirm that we received the indication
955
1002
  self.send_confirmation(ATT_Handle_Value_Confirmation())
1003
+
1004
+ def cache_value(self, attribute_handle: int, value: bytes):
1005
+ self.cached_values[attribute_handle] = (
1006
+ datetime.now(),
1007
+ value,
1008
+ )
bumble/gatt_server.py CHANGED
@@ -27,7 +27,7 @@ import asyncio
27
27
  import logging
28
28
  from collections import defaultdict
29
29
  import struct
30
- from typing import List, Tuple, Optional
30
+ from typing import List, Tuple, Optional, TypeVar, Type
31
31
  from pyee import EventEmitter
32
32
 
33
33
  from .colors import color
@@ -135,6 +135,21 @@ class Server(EventEmitter):
135
135
  return attribute
136
136
  return None
137
137
 
138
+ AttributeGroupType = TypeVar('AttributeGroupType', Service, Characteristic)
139
+
140
+ def get_attribute_group(
141
+ self, handle: int, group_type: Type[AttributeGroupType]
142
+ ) -> Optional[AttributeGroupType]:
143
+ return next(
144
+ (
145
+ attribute
146
+ for attribute in self.attributes
147
+ if isinstance(attribute, group_type)
148
+ and attribute.handle <= handle <= attribute.end_group_handle
149
+ ),
150
+ None,
151
+ )
152
+
138
153
  def get_service_attribute(self, service_uuid: UUID) -> Optional[Service]:
139
154
  return next(
140
155
  (
@@ -228,7 +243,10 @@ class Server(EventEmitter):
228
243
  # unless there is one already
229
244
  if (
230
245
  characteristic.properties
231
- & (Characteristic.NOTIFY | Characteristic.INDICATE)
246
+ & (
247
+ Characteristic.Properties.NOTIFY
248
+ | Characteristic.Properties.INDICATE
249
+ )
232
250
  and characteristic.get_descriptor(
233
251
  GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR
234
252
  )
bumble/host.py CHANGED
@@ -94,10 +94,9 @@ HOST_HC_TOTAL_NUM_ACL_DATA_PACKETS = 1
94
94
 
95
95
  # -----------------------------------------------------------------------------
96
96
  class Connection:
97
- def __init__(self, host, handle, role, peer_address, transport):
97
+ def __init__(self, host, handle, peer_address, transport):
98
98
  self.host = host
99
99
  self.handle = handle
100
- self.role = role
101
100
  self.peer_address = peer_address
102
101
  self.assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu)
103
102
  self.transport = transport
@@ -396,8 +395,8 @@ class Host(AbortableEventEmitter):
396
395
 
397
396
  def supports_command(self, command):
398
397
  # Find the support flag position for this command
399
- for (octet, flags) in enumerate(HCI_SUPPORTED_COMMANDS_FLAGS):
400
- for (flag_position, value) in enumerate(flags):
398
+ for octet, flags in enumerate(HCI_SUPPORTED_COMMANDS_FLAGS):
399
+ for flag_position, value in enumerate(flags):
401
400
  if value == command:
402
401
  # Check if the flag is set
403
402
  if octet < len(self.local_supported_commands) and flag_position < 8:
@@ -410,7 +409,7 @@ class Host(AbortableEventEmitter):
410
409
  @property
411
410
  def supported_commands(self):
412
411
  commands = []
413
- for (octet, flags) in enumerate(self.local_supported_commands):
412
+ for octet, flags in enumerate(self.local_supported_commands):
414
413
  if octet < len(HCI_SUPPORTED_COMMANDS_FLAGS):
415
414
  for flag in range(8):
416
415
  if flags & (1 << flag) != 0:
@@ -534,7 +533,7 @@ class Host(AbortableEventEmitter):
534
533
  if event.status == HCI_SUCCESS:
535
534
  # Create/update the connection
536
535
  logger.debug(
537
- f'### CONNECTION: [0x{event.connection_handle:04X}] '
536
+ f'### LE CONNECTION: [0x{event.connection_handle:04X}] '
538
537
  f'{event.peer_address} as {HCI_Constant.role_name(event.role)}'
539
538
  )
540
539
 
@@ -543,7 +542,6 @@ class Host(AbortableEventEmitter):
543
542
  connection = Connection(
544
543
  self,
545
544
  event.connection_handle,
546
- event.role,
547
545
  event.peer_address,
548
546
  BT_LE_TRANSPORT,
549
547
  )
@@ -560,7 +558,6 @@ class Host(AbortableEventEmitter):
560
558
  event.connection_handle,
561
559
  BT_LE_TRANSPORT,
562
560
  event.peer_address,
563
- None,
564
561
  event.role,
565
562
  connection_parameters,
566
563
  )
@@ -589,7 +586,6 @@ class Host(AbortableEventEmitter):
589
586
  connection = Connection(
590
587
  self,
591
588
  event.connection_handle,
592
- BT_CENTRAL_ROLE,
593
589
  event.bd_addr,
594
590
  BT_BR_EDR_TRANSPORT,
595
591
  )
@@ -602,7 +598,6 @@ class Host(AbortableEventEmitter):
602
598
  BT_BR_EDR_TRANSPORT,
603
599
  event.bd_addr,
604
600
  None,
605
- BT_CENTRAL_ROLE,
606
601
  None,
607
602
  )
608
603
  else:
@@ -622,8 +617,7 @@ class Host(AbortableEventEmitter):
622
617
  if event.status == HCI_SUCCESS:
623
618
  logger.debug(
624
619
  f'### DISCONNECTION: [0x{event.connection_handle:04X}] '
625
- f'{connection.peer_address} as '
626
- f'{HCI_Constant.role_name(connection.role)}, '
620
+ f'{connection.peer_address} '
627
621
  f'reason={event.reason}'
628
622
  )
629
623
  del self.connections[event.connection_handle]
@@ -739,10 +733,6 @@ class Host(AbortableEventEmitter):
739
733
  f'role change for {event.bd_addr}: '
740
734
  f'{HCI_Constant.role_name(event.new_role)}'
741
735
  )
742
- if connection := self.find_connection_by_bd_addr(
743
- event.bd_addr, BT_BR_EDR_TRANSPORT
744
- ):
745
- connection.role = event.new_role
746
736
  self.emit('role_change', event.bd_addr, event.new_role)
747
737
  else:
748
738
  logger.debug(
@@ -849,7 +839,12 @@ class Host(AbortableEventEmitter):
849
839
  self.emit('authentication_io_capability_request', event.bd_addr)
850
840
 
851
841
  def on_hci_io_capability_response_event(self, event):
852
- pass
842
+ self.emit(
843
+ 'authentication_io_capability_response',
844
+ event.bd_addr,
845
+ event.io_capability,
846
+ event.authentication_requirements,
847
+ )
853
848
 
854
849
  def on_hci_user_confirmation_request_event(self, event):
855
850
  self.emit(
bumble/keys.py CHANGED
@@ -20,15 +20,19 @@
20
20
  # -----------------------------------------------------------------------------
21
21
  # Imports
22
22
  # -----------------------------------------------------------------------------
23
+ from __future__ import annotations
23
24
  import asyncio
24
25
  import logging
25
26
  import os
26
27
  import json
27
- from typing import Optional
28
+ from typing import TYPE_CHECKING, Optional
28
29
 
29
30
  from .colors import color
30
31
  from .hci import Address
31
32
 
33
+ if TYPE_CHECKING:
34
+ from .device import Device
35
+
32
36
 
33
37
  # -----------------------------------------------------------------------------
34
38
  # Logging
@@ -173,13 +177,13 @@ class KeyStore:
173
177
  separator = '\n'
174
178
 
175
179
  @staticmethod
176
- def create_for_device(device_config):
177
- if device_config.keystore is None:
180
+ def create_for_device(device: Device) -> Optional[KeyStore]:
181
+ if device.config.keystore is None:
178
182
  return None
179
183
 
180
- keystore_type = device_config.keystore.split(':', 1)[0]
184
+ keystore_type = device.config.keystore.split(':', 1)[0]
181
185
  if keystore_type == 'JsonKeyStore':
182
- return JsonKeyStore.from_device_config(device_config)
186
+ return JsonKeyStore.from_device(device)
183
187
 
184
188
  return None
185
189
 
@@ -204,7 +208,9 @@ class JsonKeyStore(KeyStore):
204
208
  self.directory_name = os.path.join(
205
209
  appdirs.user_data_dir(self.APP_NAME, self.APP_AUTHOR), self.KEYS_DIR
206
210
  )
207
- json_filename = f'{self.namespace}.json'.lower().replace(':', '-')
211
+ json_filename = (
212
+ f'{self.namespace}.json'.lower().replace(':', '-').replace('/p', '-p')
213
+ )
208
214
  self.filename = os.path.join(self.directory_name, json_filename)
209
215
  else:
210
216
  self.filename = filename
@@ -213,9 +219,19 @@ class JsonKeyStore(KeyStore):
213
219
  logger.debug(f'JSON keystore: {self.filename}')
214
220
 
215
221
  @staticmethod
216
- def from_device_config(device_config):
217
- params = device_config.keystore.split(':', 1)[1:]
218
- namespace = str(device_config.address)
222
+ def from_device(device: Device) -> Optional[JsonKeyStore]:
223
+ if not device.config.keystore:
224
+ return None
225
+
226
+ params = device.config.keystore.split(':', 1)[1:]
227
+
228
+ # Use a namespace based on the device address
229
+ if device.public_address not in (Address.ANY, Address.ANY_RANDOM):
230
+ namespace = str(device.public_address)
231
+ elif device.random_address != Address.ANY_RANDOM:
232
+ namespace = str(device.random_address)
233
+ else:
234
+ namespace = JsonKeyStore.DEFAULT_NAMESPACE
219
235
  if params:
220
236
  filename = params[0]
221
237
  else:
@@ -241,7 +257,7 @@ class JsonKeyStore(KeyStore):
241
257
  json.dump(db, output, sort_keys=True, indent=4)
242
258
 
243
259
  # Atomically replace the previous file
244
- os.rename(temp_filename, self.filename)
260
+ os.replace(temp_filename, self.filename)
245
261
 
246
262
  async def delete(self, name: str) -> None:
247
263
  db = await self.load()
@@ -257,7 +273,7 @@ class JsonKeyStore(KeyStore):
257
273
  db = await self.load()
258
274
 
259
275
  namespace = db.setdefault(self.namespace, {})
260
- namespace[name] = keys.to_dict()
276
+ namespace.setdefault(name, {}).update(keys.to_dict())
261
277
 
262
278
  await self.save(db)
263
279