bumble 0.0.154__py3-none-any.whl → 0.0.156__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 +2 -2
- bumble/a2dp.py +1 -0
- bumble/apps/pair.py +14 -14
- bumble/apps/scan.py +5 -6
- bumble/apps/speaker/__init__.py +0 -0
- bumble/apps/speaker/logo.svg +42 -0
- bumble/apps/speaker/speaker.css +76 -0
- bumble/apps/speaker/speaker.html +34 -0
- bumble/apps/speaker/speaker.js +315 -0
- bumble/apps/speaker/speaker.py +747 -0
- bumble/apps/unbond.py +40 -22
- bumble/avdtp.py +50 -31
- bumble/codecs.py +381 -0
- bumble/device.py +17 -4
- bumble/hci.py +13 -9
- bumble/hfp.py +14 -8
- bumble/host.py +7 -1
- bumble/keys.py +72 -46
- bumble/pandora/host.py +2 -1
- bumble/pandora/security.py +4 -7
- bumble/rfcomm.py +110 -67
- bumble/smp.py +1 -1
- {bumble-0.0.154.dist-info → bumble-0.0.156.dist-info}/METADATA +5 -4
- {bumble-0.0.154.dist-info → bumble-0.0.156.dist-info}/RECORD +28 -21
- {bumble-0.0.154.dist-info → bumble-0.0.156.dist-info}/entry_points.txt +1 -0
- {bumble-0.0.154.dist-info → bumble-0.0.156.dist-info}/LICENSE +0 -0
- {bumble-0.0.154.dist-info → bumble-0.0.156.dist-info}/WHEEL +0 -0
- {bumble-0.0.154.dist-info → bumble-0.0.156.dist-info}/top_level.txt +0 -0
bumble/device.py
CHANGED
|
@@ -954,12 +954,16 @@ class Device(CompositeEventEmitter):
|
|
|
954
954
|
config.load_from_file(filename)
|
|
955
955
|
return cls(config=config)
|
|
956
956
|
|
|
957
|
+
@classmethod
|
|
958
|
+
def from_config_with_hci(cls, config, hci_source, hci_sink):
|
|
959
|
+
host = Host(controller_source=hci_source, controller_sink=hci_sink)
|
|
960
|
+
return cls(config=config, host=host)
|
|
961
|
+
|
|
957
962
|
@classmethod
|
|
958
963
|
def from_config_file_with_hci(cls, filename, hci_source, hci_sink):
|
|
959
964
|
config = DeviceConfiguration()
|
|
960
965
|
config.load_from_file(filename)
|
|
961
|
-
|
|
962
|
-
return cls(config=config, host=host)
|
|
966
|
+
return cls.from_config_with_hci(config, hci_source, hci_sink)
|
|
963
967
|
|
|
964
968
|
def __init__(
|
|
965
969
|
self,
|
|
@@ -2441,7 +2445,7 @@ class Device(CompositeEventEmitter):
|
|
|
2441
2445
|
|
|
2442
2446
|
if result.status != HCI_COMMAND_STATUS_PENDING:
|
|
2443
2447
|
logger.warning(
|
|
2444
|
-
'
|
|
2448
|
+
'HCI_Remote_Name_Request_Command failed: '
|
|
2445
2449
|
f'{HCI_Constant.error_name(result.status)}'
|
|
2446
2450
|
)
|
|
2447
2451
|
raise HCI_StatusError(result)
|
|
@@ -3094,7 +3098,16 @@ class Device(CompositeEventEmitter):
|
|
|
3094
3098
|
def on_pairing_start(self, connection: Connection) -> None:
|
|
3095
3099
|
connection.emit('pairing_start')
|
|
3096
3100
|
|
|
3097
|
-
def on_pairing(
|
|
3101
|
+
def on_pairing(
|
|
3102
|
+
self,
|
|
3103
|
+
connection: Connection,
|
|
3104
|
+
identity_address: Optional[Address],
|
|
3105
|
+
keys: PairingKeys,
|
|
3106
|
+
sc: bool,
|
|
3107
|
+
) -> None:
|
|
3108
|
+
if identity_address is not None:
|
|
3109
|
+
connection.peer_resolvable_address = connection.peer_address
|
|
3110
|
+
connection.peer_address = identity_address
|
|
3098
3111
|
connection.sc = sc
|
|
3099
3112
|
connection.authenticated = True
|
|
3100
3113
|
connection.emit('pairing', keys)
|
bumble/hci.py
CHANGED
|
@@ -62,7 +62,7 @@ def map_null_terminated_utf8_string(utf8_bytes):
|
|
|
62
62
|
try:
|
|
63
63
|
terminator = utf8_bytes.find(0)
|
|
64
64
|
if terminator < 0:
|
|
65
|
-
|
|
65
|
+
terminator = len(utf8_bytes)
|
|
66
66
|
return utf8_bytes[0:terminator].decode('utf8')
|
|
67
67
|
except UnicodeDecodeError:
|
|
68
68
|
return utf8_bytes
|
|
@@ -1795,6 +1795,16 @@ class Address:
|
|
|
1795
1795
|
def to_bytes(self):
|
|
1796
1796
|
return self.address_bytes
|
|
1797
1797
|
|
|
1798
|
+
def to_string(self, with_type_qualifier=True):
|
|
1799
|
+
'''
|
|
1800
|
+
String representation of the address, MSB first, with an optional type
|
|
1801
|
+
qualifier.
|
|
1802
|
+
'''
|
|
1803
|
+
result = ':'.join([f'{x:02X}' for x in reversed(self.address_bytes)])
|
|
1804
|
+
if not with_type_qualifier or not self.is_public:
|
|
1805
|
+
return result
|
|
1806
|
+
return result + '/P'
|
|
1807
|
+
|
|
1798
1808
|
def __bytes__(self):
|
|
1799
1809
|
return self.to_bytes()
|
|
1800
1810
|
|
|
@@ -1808,13 +1818,7 @@ class Address:
|
|
|
1808
1818
|
)
|
|
1809
1819
|
|
|
1810
1820
|
def __str__(self):
|
|
1811
|
-
|
|
1812
|
-
String representation of the address, MSB first
|
|
1813
|
-
'''
|
|
1814
|
-
result = ':'.join([f'{x:02X}' for x in reversed(self.address_bytes)])
|
|
1815
|
-
if not self.is_public:
|
|
1816
|
-
return result
|
|
1817
|
-
return result + '/P'
|
|
1821
|
+
return self.to_string()
|
|
1818
1822
|
|
|
1819
1823
|
|
|
1820
1824
|
# Predefined address values
|
|
@@ -5373,7 +5377,7 @@ class HCI_AclDataPacket:
|
|
|
5373
5377
|
def __str__(self):
|
|
5374
5378
|
return (
|
|
5375
5379
|
f'{color("ACL", "blue")}: '
|
|
5376
|
-
f'handle=0x{self.connection_handle:04x}'
|
|
5380
|
+
f'handle=0x{self.connection_handle:04x}, '
|
|
5377
5381
|
f'pb={self.pb_flag}, bc={self.bc_flag}, '
|
|
5378
5382
|
f'data_total_length={self.data_total_length}, '
|
|
5379
5383
|
f'data={self.data.hex()}'
|
bumble/hfp.py
CHANGED
|
@@ -18,10 +18,11 @@
|
|
|
18
18
|
import logging
|
|
19
19
|
import asyncio
|
|
20
20
|
import collections
|
|
21
|
+
from typing import Union
|
|
21
22
|
|
|
23
|
+
from . import rfcomm
|
|
22
24
|
from .colors import color
|
|
23
25
|
|
|
24
|
-
|
|
25
26
|
# -----------------------------------------------------------------------------
|
|
26
27
|
# Logging
|
|
27
28
|
# -----------------------------------------------------------------------------
|
|
@@ -34,7 +35,12 @@ logger = logging.getLogger(__name__)
|
|
|
34
35
|
|
|
35
36
|
# -----------------------------------------------------------------------------
|
|
36
37
|
class HfpProtocol:
|
|
37
|
-
|
|
38
|
+
dlc: rfcomm.DLC
|
|
39
|
+
buffer: str
|
|
40
|
+
lines: collections.deque
|
|
41
|
+
lines_available: asyncio.Event
|
|
42
|
+
|
|
43
|
+
def __init__(self, dlc: rfcomm.DLC) -> None:
|
|
38
44
|
self.dlc = dlc
|
|
39
45
|
self.buffer = ''
|
|
40
46
|
self.lines = collections.deque()
|
|
@@ -42,7 +48,7 @@ class HfpProtocol:
|
|
|
42
48
|
|
|
43
49
|
dlc.sink = self.feed
|
|
44
50
|
|
|
45
|
-
def feed(self, data):
|
|
51
|
+
def feed(self, data: Union[bytes, str]) -> None:
|
|
46
52
|
# Convert the data to a string if needed
|
|
47
53
|
if isinstance(data, bytes):
|
|
48
54
|
data = data.decode('utf-8')
|
|
@@ -57,19 +63,19 @@ class HfpProtocol:
|
|
|
57
63
|
if len(line) > 0:
|
|
58
64
|
self.on_line(line)
|
|
59
65
|
|
|
60
|
-
def on_line(self, line):
|
|
66
|
+
def on_line(self, line: str) -> None:
|
|
61
67
|
self.lines.append(line)
|
|
62
68
|
self.lines_available.set()
|
|
63
69
|
|
|
64
|
-
def send_command_line(self, line):
|
|
70
|
+
def send_command_line(self, line: str) -> None:
|
|
65
71
|
logger.debug(color(f'>>> {line}', 'yellow'))
|
|
66
72
|
self.dlc.write(line + '\r')
|
|
67
73
|
|
|
68
|
-
def send_response_line(self, line):
|
|
74
|
+
def send_response_line(self, line: str) -> None:
|
|
69
75
|
logger.debug(color(f'>>> {line}', 'yellow'))
|
|
70
76
|
self.dlc.write('\r\n' + line + '\r\n')
|
|
71
77
|
|
|
72
|
-
async def next_line(self):
|
|
78
|
+
async def next_line(self) -> str:
|
|
73
79
|
await self.lines_available.wait()
|
|
74
80
|
line = self.lines.popleft()
|
|
75
81
|
if not self.lines:
|
|
@@ -77,7 +83,7 @@ class HfpProtocol:
|
|
|
77
83
|
logger.debug(color(f'<<< {line}', 'green'))
|
|
78
84
|
return line
|
|
79
85
|
|
|
80
|
-
async def initialize_service(self):
|
|
86
|
+
async def initialize_service(self) -> None:
|
|
81
87
|
# Perform Service Level Connection Initialization
|
|
82
88
|
self.send_command_line('AT+BRSF=2072') # Retrieve Supported Features
|
|
83
89
|
await (self.next_line())
|
bumble/host.py
CHANGED
|
@@ -62,6 +62,7 @@ from .hci import (
|
|
|
62
62
|
HCI_Read_Local_Version_Information_Command,
|
|
63
63
|
HCI_Reset_Command,
|
|
64
64
|
HCI_Set_Event_Mask_Command,
|
|
65
|
+
map_null_terminated_utf8_string,
|
|
65
66
|
)
|
|
66
67
|
from .core import (
|
|
67
68
|
BT_BR_EDR_TRANSPORT,
|
|
@@ -887,7 +888,12 @@ class Host(AbortableEventEmitter):
|
|
|
887
888
|
if event.status != HCI_SUCCESS:
|
|
888
889
|
self.emit('remote_name_failure', event.bd_addr, event.status)
|
|
889
890
|
else:
|
|
890
|
-
|
|
891
|
+
utf8_name = event.remote_name
|
|
892
|
+
terminator = utf8_name.find(0)
|
|
893
|
+
if terminator >= 0:
|
|
894
|
+
utf8_name = utf8_name[0:terminator]
|
|
895
|
+
|
|
896
|
+
self.emit('remote_name', event.bd_addr, utf8_name)
|
|
891
897
|
|
|
892
898
|
def on_hci_remote_host_supported_features_notification_event(self, event):
|
|
893
899
|
self.emit(
|
bumble/keys.py
CHANGED
|
@@ -190,10 +190,44 @@ class KeyStore:
|
|
|
190
190
|
|
|
191
191
|
# -----------------------------------------------------------------------------
|
|
192
192
|
class JsonKeyStore(KeyStore):
|
|
193
|
+
"""
|
|
194
|
+
KeyStore implementation that is backed by a JSON file.
|
|
195
|
+
|
|
196
|
+
This implementation supports storing a hierarchy of key sets in a single file.
|
|
197
|
+
A key set is a representation of a PairingKeys object. Each key set is stored
|
|
198
|
+
in a map, with the address of paired peer as the key. Maps are themselves grouped
|
|
199
|
+
into namespaces, grouping pairing keys by controller addresses.
|
|
200
|
+
The JSON object model looks like:
|
|
201
|
+
{
|
|
202
|
+
"<namespace>": {
|
|
203
|
+
"peer-address": {
|
|
204
|
+
"address_type": <n>,
|
|
205
|
+
"irk" : {
|
|
206
|
+
"authenticated": <true/false>,
|
|
207
|
+
"value": "hex-encoded-key"
|
|
208
|
+
},
|
|
209
|
+
... other keys ...
|
|
210
|
+
},
|
|
211
|
+
... other peers ...
|
|
212
|
+
}
|
|
213
|
+
... other namespaces ...
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
A namespace is typically the BD_ADDR of a controller, since that is a convenient
|
|
217
|
+
unique identifier, but it may be something else.
|
|
218
|
+
A special namespace, called the "default" namespace, is used when instantiating this
|
|
219
|
+
class without a namespace. With the default namespace, reading from a file will
|
|
220
|
+
load an existing namespace if there is only one, which may be convenient for reading
|
|
221
|
+
from a file with a single key set and for which the namespace isn't known. If the
|
|
222
|
+
file does not include any existing key set, or if there are more than one and none
|
|
223
|
+
has the default name, a new one will be created with the name "__DEFAULT__".
|
|
224
|
+
"""
|
|
225
|
+
|
|
193
226
|
APP_NAME = 'Bumble'
|
|
194
227
|
APP_AUTHOR = 'Google'
|
|
195
228
|
KEYS_DIR = 'Pairing'
|
|
196
229
|
DEFAULT_NAMESPACE = '__DEFAULT__'
|
|
230
|
+
DEFAULT_BASE_NAME = "keys"
|
|
197
231
|
|
|
198
232
|
def __init__(self, namespace, filename=None):
|
|
199
233
|
self.namespace = namespace if namespace is not None else self.DEFAULT_NAMESPACE
|
|
@@ -208,8 +242,9 @@ class JsonKeyStore(KeyStore):
|
|
|
208
242
|
self.directory_name = os.path.join(
|
|
209
243
|
appdirs.user_data_dir(self.APP_NAME, self.APP_AUTHOR), self.KEYS_DIR
|
|
210
244
|
)
|
|
245
|
+
base_name = self.DEFAULT_BASE_NAME if namespace is None else self.namespace
|
|
211
246
|
json_filename = (
|
|
212
|
-
f'{
|
|
247
|
+
f'{base_name}.json'.lower().replace(':', '-').replace('/p', '-p')
|
|
213
248
|
)
|
|
214
249
|
self.filename = os.path.join(self.directory_name, json_filename)
|
|
215
250
|
else:
|
|
@@ -219,11 +254,13 @@ class JsonKeyStore(KeyStore):
|
|
|
219
254
|
logger.debug(f'JSON keystore: {self.filename}')
|
|
220
255
|
|
|
221
256
|
@staticmethod
|
|
222
|
-
def from_device(device: Device) -> Optional[JsonKeyStore]:
|
|
223
|
-
if not
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
257
|
+
def from_device(device: Device, filename=None) -> Optional[JsonKeyStore]:
|
|
258
|
+
if not filename:
|
|
259
|
+
# Extract the filename from the config if there is one
|
|
260
|
+
if device.config.keystore is not None:
|
|
261
|
+
params = device.config.keystore.split(':', 1)[1:]
|
|
262
|
+
if params:
|
|
263
|
+
filename = params[0]
|
|
227
264
|
|
|
228
265
|
# Use a namespace based on the device address
|
|
229
266
|
if device.public_address not in (Address.ANY, Address.ANY_RANDOM):
|
|
@@ -232,19 +269,31 @@ class JsonKeyStore(KeyStore):
|
|
|
232
269
|
namespace = str(device.random_address)
|
|
233
270
|
else:
|
|
234
271
|
namespace = JsonKeyStore.DEFAULT_NAMESPACE
|
|
235
|
-
if params:
|
|
236
|
-
filename = params[0]
|
|
237
|
-
else:
|
|
238
|
-
filename = None
|
|
239
272
|
|
|
240
273
|
return JsonKeyStore(namespace, filename)
|
|
241
274
|
|
|
242
275
|
async def load(self):
|
|
276
|
+
# Try to open the file, without failing. If the file does not exist, it
|
|
277
|
+
# will be created upon saving.
|
|
243
278
|
try:
|
|
244
279
|
with open(self.filename, 'r', encoding='utf-8') as json_file:
|
|
245
|
-
|
|
280
|
+
db = json.load(json_file)
|
|
246
281
|
except FileNotFoundError:
|
|
247
|
-
|
|
282
|
+
db = {}
|
|
283
|
+
|
|
284
|
+
# First, look for a namespace match
|
|
285
|
+
if self.namespace in db:
|
|
286
|
+
return (db, db[self.namespace])
|
|
287
|
+
|
|
288
|
+
# Then, if the namespace is the default namespace, and there's
|
|
289
|
+
# only one entry in the db, use that
|
|
290
|
+
if self.namespace == self.DEFAULT_NAMESPACE and len(db) == 1:
|
|
291
|
+
return next(iter(db.items()))
|
|
292
|
+
|
|
293
|
+
# Finally, just create an empty key map for the namespace
|
|
294
|
+
key_map = {}
|
|
295
|
+
db[self.namespace] = key_map
|
|
296
|
+
return (db, key_map)
|
|
248
297
|
|
|
249
298
|
async def save(self, db):
|
|
250
299
|
# Create the directory if it doesn't exist
|
|
@@ -260,53 +309,30 @@ class JsonKeyStore(KeyStore):
|
|
|
260
309
|
os.replace(temp_filename, self.filename)
|
|
261
310
|
|
|
262
311
|
async def delete(self, name: str) -> None:
|
|
263
|
-
db = await self.load()
|
|
264
|
-
|
|
265
|
-
namespace = db.get(self.namespace)
|
|
266
|
-
if namespace is None:
|
|
267
|
-
raise KeyError(name)
|
|
268
|
-
|
|
269
|
-
del namespace[name]
|
|
312
|
+
db, key_map = await self.load()
|
|
313
|
+
del key_map[name]
|
|
270
314
|
await self.save(db)
|
|
271
315
|
|
|
272
316
|
async def update(self, name, keys):
|
|
273
|
-
db = await self.load()
|
|
274
|
-
|
|
275
|
-
namespace = db.setdefault(self.namespace, {})
|
|
276
|
-
namespace.setdefault(name, {}).update(keys.to_dict())
|
|
277
|
-
|
|
317
|
+
db, key_map = await self.load()
|
|
318
|
+
key_map.setdefault(name, {}).update(keys.to_dict())
|
|
278
319
|
await self.save(db)
|
|
279
320
|
|
|
280
321
|
async def get_all(self):
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
namespace = db.get(self.namespace)
|
|
284
|
-
if namespace is None:
|
|
285
|
-
return []
|
|
286
|
-
|
|
287
|
-
return [
|
|
288
|
-
(name, PairingKeys.from_dict(keys)) for (name, keys) in namespace.items()
|
|
289
|
-
]
|
|
322
|
+
_, key_map = await self.load()
|
|
323
|
+
return [(name, PairingKeys.from_dict(keys)) for (name, keys) in key_map.items()]
|
|
290
324
|
|
|
291
325
|
async def delete_all(self):
|
|
292
|
-
db = await self.load()
|
|
293
|
-
|
|
294
|
-
db.pop(self.namespace, None)
|
|
295
|
-
|
|
326
|
+
db, key_map = await self.load()
|
|
327
|
+
key_map.clear()
|
|
296
328
|
await self.save(db)
|
|
297
329
|
|
|
298
330
|
async def get(self, name: str) -> Optional[PairingKeys]:
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
namespace = db.get(self.namespace)
|
|
302
|
-
if namespace is None:
|
|
303
|
-
return None
|
|
304
|
-
|
|
305
|
-
keys = namespace.get(name)
|
|
306
|
-
if keys is None:
|
|
331
|
+
_, key_map = await self.load()
|
|
332
|
+
if name not in key_map:
|
|
307
333
|
return None
|
|
308
334
|
|
|
309
|
-
return PairingKeys.from_dict(
|
|
335
|
+
return PairingKeys.from_dict(key_map[name])
|
|
310
336
|
|
|
311
337
|
|
|
312
338
|
# -----------------------------------------------------------------------------
|
bumble/pandora/host.py
CHANGED
|
@@ -43,7 +43,8 @@ from bumble.hci import (
|
|
|
43
43
|
HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
|
|
44
44
|
Address,
|
|
45
45
|
)
|
|
46
|
-
from google.protobuf import any_pb2
|
|
46
|
+
from google.protobuf import any_pb2 # pytype: disable=pyi-error
|
|
47
|
+
from google.protobuf import empty_pb2 # pytype: disable=pyi-error
|
|
47
48
|
from pandora.host_grpc_aio import HostServicer
|
|
48
49
|
from pandora.host_pb2 import (
|
|
49
50
|
NOT_CONNECTABLE,
|
bumble/pandora/security.py
CHANGED
|
@@ -29,12 +29,9 @@ from bumble.device import Connection as BumbleConnection, Device
|
|
|
29
29
|
from bumble.hci import HCI_Error
|
|
30
30
|
from bumble.pairing import PairingConfig, PairingDelegate as BasePairingDelegate
|
|
31
31
|
from contextlib import suppress
|
|
32
|
-
from google.protobuf import
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
wrappers_pb2,
|
|
36
|
-
) # pytype: disable=pyi-error
|
|
37
|
-
from google.protobuf.wrappers_pb2 import BoolValue # pytype: disable=pyi-error
|
|
32
|
+
from google.protobuf import any_pb2 # pytype: disable=pyi-error
|
|
33
|
+
from google.protobuf import empty_pb2 # pytype: disable=pyi-error
|
|
34
|
+
from google.protobuf import wrappers_pb2 # pytype: disable=pyi-error
|
|
38
35
|
from pandora.host_pb2 import Connection
|
|
39
36
|
from pandora.security_grpc_aio import SecurityServicer, SecurityStorageServicer
|
|
40
37
|
from pandora.security_pb2 import (
|
|
@@ -513,7 +510,7 @@ class SecurityStorageService(SecurityStorageServicer):
|
|
|
513
510
|
else:
|
|
514
511
|
is_bonded = False
|
|
515
512
|
|
|
516
|
-
return BoolValue(value=is_bonded)
|
|
513
|
+
return wrappers_pb2.BoolValue(value=is_bonded)
|
|
517
514
|
|
|
518
515
|
@utils.rpc
|
|
519
516
|
async def DeleteBond(
|