bumble 0.0.194__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 +2 -2
- bumble/apps/auracast.py +407 -0
- bumble/apps/bench.py +77 -23
- bumble/apps/controller_info.py +3 -3
- bumble/core.py +689 -115
- bumble/device.py +441 -12
- bumble/hci.py +250 -12
- bumble/host.py +25 -0
- bumble/pandora/host.py +3 -2
- bumble/profiles/bap.py +101 -5
- bumble/profiles/le_audio.py +49 -0
- bumble/profiles/pbp.py +46 -0
- bumble/rfcomm.py +10 -1
- {bumble-0.0.194.dist-info → bumble-0.0.195.dist-info}/METADATA +1 -1
- {bumble-0.0.194.dist-info → bumble-0.0.195.dist-info}/RECORD +19 -16
- {bumble-0.0.194.dist-info → bumble-0.0.195.dist-info}/LICENSE +0 -0
- {bumble-0.0.194.dist-info → bumble-0.0.195.dist-info}/WHEEL +0 -0
- {bumble-0.0.194.dist-info → bumble-0.0.195.dist-info}/entry_points.txt +0 -0
- {bumble-0.0.194.dist-info → bumble-0.0.195.dist-info}/top_level.txt +0 -0
bumble/_version.py
CHANGED
bumble/apps/auracast.py
ADDED
|
@@ -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
|
bumble/apps/bench.py
CHANGED
|
@@ -40,6 +40,8 @@ from bumble.hci import (
|
|
|
40
40
|
HCI_LE_1M_PHY,
|
|
41
41
|
HCI_LE_2M_PHY,
|
|
42
42
|
HCI_LE_CODED_PHY,
|
|
43
|
+
HCI_CENTRAL_ROLE,
|
|
44
|
+
HCI_PERIPHERAL_ROLE,
|
|
43
45
|
HCI_Constant,
|
|
44
46
|
HCI_Error,
|
|
45
47
|
HCI_StatusError,
|
|
@@ -57,6 +59,7 @@ from bumble.transport import open_transport_or_link
|
|
|
57
59
|
import bumble.rfcomm
|
|
58
60
|
import bumble.core
|
|
59
61
|
from bumble.utils import AsyncRunner
|
|
62
|
+
from bumble.pairing import PairingConfig
|
|
60
63
|
|
|
61
64
|
|
|
62
65
|
# -----------------------------------------------------------------------------
|
|
@@ -128,40 +131,34 @@ def le_phy_name(phy_id):
|
|
|
128
131
|
|
|
129
132
|
|
|
130
133
|
def print_connection(connection):
|
|
134
|
+
params = []
|
|
131
135
|
if connection.transport == BT_LE_TRANSPORT:
|
|
132
|
-
|
|
136
|
+
params.append(
|
|
133
137
|
'PHY='
|
|
134
138
|
f'TX:{le_phy_name(connection.phy.tx_phy)}/'
|
|
135
139
|
f'RX:{le_phy_name(connection.phy.rx_phy)}'
|
|
136
140
|
)
|
|
137
141
|
|
|
138
|
-
|
|
142
|
+
params.append(
|
|
139
143
|
'DL=('
|
|
140
144
|
f'TX:{connection.data_length[0]}/{connection.data_length[1]},'
|
|
141
145
|
f'RX:{connection.data_length[2]}/{connection.data_length[3]}'
|
|
142
146
|
')'
|
|
143
147
|
)
|
|
144
|
-
|
|
148
|
+
|
|
149
|
+
params.append(
|
|
145
150
|
'Parameters='
|
|
146
151
|
f'{connection.parameters.connection_interval * 1.25:.2f}/'
|
|
147
152
|
f'{connection.parameters.peripheral_latency}/'
|
|
148
153
|
f'{connection.parameters.supervision_timeout * 10} '
|
|
149
154
|
)
|
|
150
155
|
|
|
151
|
-
|
|
152
|
-
phy_state = ''
|
|
153
|
-
data_length = ''
|
|
154
|
-
connection_parameters = ''
|
|
156
|
+
params.append(f'MTU={connection.att_mtu}')
|
|
155
157
|
|
|
156
|
-
|
|
158
|
+
else:
|
|
159
|
+
params.append(f'Role={HCI_Constant.role_name(connection.role)}')
|
|
157
160
|
|
|
158
|
-
logging.info(
|
|
159
|
-
f'{color("@@@ Connection:", "yellow")} '
|
|
160
|
-
f'{connection_parameters} '
|
|
161
|
-
f'{data_length} '
|
|
162
|
-
f'{phy_state} '
|
|
163
|
-
f'MTU={mtu}'
|
|
164
|
-
)
|
|
161
|
+
logging.info(color('@@@ Connection: ', 'yellow') + ' '.join(params))
|
|
165
162
|
|
|
166
163
|
|
|
167
164
|
def make_sdp_records(channel):
|
|
@@ -214,6 +211,17 @@ def log_stats(title, stats):
|
|
|
214
211
|
)
|
|
215
212
|
|
|
216
213
|
|
|
214
|
+
async def switch_roles(connection, role):
|
|
215
|
+
target_role = HCI_CENTRAL_ROLE if role == "central" else HCI_PERIPHERAL_ROLE
|
|
216
|
+
if connection.role != target_role:
|
|
217
|
+
logging.info(f'{color("### Switching roles to:", "cyan")} {role}')
|
|
218
|
+
try:
|
|
219
|
+
await connection.switch_role(target_role)
|
|
220
|
+
logging.info(color('### Role switch complete', 'cyan'))
|
|
221
|
+
except HCI_Error as error:
|
|
222
|
+
logging.info(f'{color("### Role switch failed:", "red")} {error}')
|
|
223
|
+
|
|
224
|
+
|
|
217
225
|
class PacketType(enum.IntEnum):
|
|
218
226
|
RESET = 0
|
|
219
227
|
SEQUENCE = 1
|
|
@@ -1034,6 +1042,10 @@ class RfcommServer(StreamedPacketIO):
|
|
|
1034
1042
|
|
|
1035
1043
|
def on_dlc(self, dlc):
|
|
1036
1044
|
logging.info(color(f'*** DLC connected: {dlc}', 'blue'))
|
|
1045
|
+
if self.credits_threshold is not None:
|
|
1046
|
+
dlc.rx_threshold = self.credits_threshold
|
|
1047
|
+
if self.max_credits is not None:
|
|
1048
|
+
dlc.rx_max_credits = self.max_credits
|
|
1037
1049
|
dlc.sink = self.on_packet
|
|
1038
1050
|
self.io_sink = dlc.write
|
|
1039
1051
|
self.dlc = dlc
|
|
@@ -1063,6 +1075,7 @@ class Central(Connection.Listener):
|
|
|
1063
1075
|
authenticate,
|
|
1064
1076
|
encrypt,
|
|
1065
1077
|
extended_data_length,
|
|
1078
|
+
role_switch,
|
|
1066
1079
|
):
|
|
1067
1080
|
super().__init__()
|
|
1068
1081
|
self.transport = transport
|
|
@@ -1073,6 +1086,7 @@ class Central(Connection.Listener):
|
|
|
1073
1086
|
self.authenticate = authenticate
|
|
1074
1087
|
self.encrypt = encrypt or authenticate
|
|
1075
1088
|
self.extended_data_length = extended_data_length
|
|
1089
|
+
self.role_switch = role_switch
|
|
1076
1090
|
self.device = None
|
|
1077
1091
|
self.connection = None
|
|
1078
1092
|
|
|
@@ -1123,6 +1137,11 @@ class Central(Connection.Listener):
|
|
|
1123
1137
|
role = self.role_factory(mode)
|
|
1124
1138
|
self.device.classic_enabled = self.classic
|
|
1125
1139
|
|
|
1140
|
+
# Set up a pairing config factory with minimal requirements.
|
|
1141
|
+
self.device.pairing_config_factory = lambda _: PairingConfig(
|
|
1142
|
+
sc=False, mitm=False, bonding=False
|
|
1143
|
+
)
|
|
1144
|
+
|
|
1126
1145
|
await self.device.power_on()
|
|
1127
1146
|
|
|
1128
1147
|
if self.classic:
|
|
@@ -1151,6 +1170,10 @@ class Central(Connection.Listener):
|
|
|
1151
1170
|
self.connection.listener = self
|
|
1152
1171
|
print_connection(self.connection)
|
|
1153
1172
|
|
|
1173
|
+
# Switch roles if needed.
|
|
1174
|
+
if self.role_switch:
|
|
1175
|
+
await switch_roles(self.connection, self.role_switch)
|
|
1176
|
+
|
|
1154
1177
|
# Wait a bit after the connection, some controllers aren't very good when
|
|
1155
1178
|
# we start sending data right away while some connection parameters are
|
|
1156
1179
|
# updated post connection
|
|
@@ -1212,20 +1235,30 @@ class Central(Connection.Listener):
|
|
|
1212
1235
|
def on_connection_data_length_change(self):
|
|
1213
1236
|
print_connection(self.connection)
|
|
1214
1237
|
|
|
1238
|
+
def on_role_change(self):
|
|
1239
|
+
print_connection(self.connection)
|
|
1240
|
+
|
|
1215
1241
|
|
|
1216
1242
|
# -----------------------------------------------------------------------------
|
|
1217
1243
|
# Peripheral
|
|
1218
1244
|
# -----------------------------------------------------------------------------
|
|
1219
1245
|
class Peripheral(Device.Listener, Connection.Listener):
|
|
1220
1246
|
def __init__(
|
|
1221
|
-
self,
|
|
1247
|
+
self,
|
|
1248
|
+
transport,
|
|
1249
|
+
role_factory,
|
|
1250
|
+
mode_factory,
|
|
1251
|
+
classic,
|
|
1252
|
+
extended_data_length,
|
|
1253
|
+
role_switch,
|
|
1222
1254
|
):
|
|
1223
1255
|
self.transport = transport
|
|
1224
1256
|
self.classic = classic
|
|
1225
|
-
self.extended_data_length = extended_data_length
|
|
1226
1257
|
self.role_factory = role_factory
|
|
1227
|
-
self.role = None
|
|
1228
1258
|
self.mode_factory = mode_factory
|
|
1259
|
+
self.extended_data_length = extended_data_length
|
|
1260
|
+
self.role_switch = role_switch
|
|
1261
|
+
self.role = None
|
|
1229
1262
|
self.mode = None
|
|
1230
1263
|
self.device = None
|
|
1231
1264
|
self.connection = None
|
|
@@ -1248,6 +1281,11 @@ class Peripheral(Device.Listener, Connection.Listener):
|
|
|
1248
1281
|
self.role = self.role_factory(self.mode)
|
|
1249
1282
|
self.device.classic_enabled = self.classic
|
|
1250
1283
|
|
|
1284
|
+
# Set up a pairing config factory with minimal requirements.
|
|
1285
|
+
self.device.pairing_config_factory = lambda _: PairingConfig(
|
|
1286
|
+
sc=False, mitm=False, bonding=False
|
|
1287
|
+
)
|
|
1288
|
+
|
|
1251
1289
|
await self.device.power_on()
|
|
1252
1290
|
|
|
1253
1291
|
if self.classic:
|
|
@@ -1274,6 +1312,7 @@ class Peripheral(Device.Listener, Connection.Listener):
|
|
|
1274
1312
|
|
|
1275
1313
|
await self.connected.wait()
|
|
1276
1314
|
logging.info(color('### Connected', 'cyan'))
|
|
1315
|
+
print_connection(self.connection)
|
|
1277
1316
|
|
|
1278
1317
|
await self.mode.on_connection(self.connection)
|
|
1279
1318
|
await self.role.run()
|
|
@@ -1290,7 +1329,7 @@ class Peripheral(Device.Listener, Connection.Listener):
|
|
|
1290
1329
|
AsyncRunner.spawn(self.device.set_connectable(False))
|
|
1291
1330
|
|
|
1292
1331
|
# Request a new data length if needed
|
|
1293
|
-
if self.extended_data_length:
|
|
1332
|
+
if not self.classic and self.extended_data_length:
|
|
1294
1333
|
logging.info("+++ Requesting extended data length")
|
|
1295
1334
|
AsyncRunner.spawn(
|
|
1296
1335
|
connection.set_data_length(
|
|
@@ -1298,6 +1337,10 @@ class Peripheral(Device.Listener, Connection.Listener):
|
|
|
1298
1337
|
)
|
|
1299
1338
|
)
|
|
1300
1339
|
|
|
1340
|
+
# Switch roles if needed.
|
|
1341
|
+
if self.role_switch:
|
|
1342
|
+
AsyncRunner.spawn(switch_roles(connection, self.role_switch))
|
|
1343
|
+
|
|
1301
1344
|
def on_disconnection(self, reason):
|
|
1302
1345
|
logging.info(color(f'!!! Disconnection: reason={reason}', 'red'))
|
|
1303
1346
|
self.connection = None
|
|
@@ -1319,6 +1362,9 @@ class Peripheral(Device.Listener, Connection.Listener):
|
|
|
1319
1362
|
def on_connection_data_length_change(self):
|
|
1320
1363
|
print_connection(self.connection)
|
|
1321
1364
|
|
|
1365
|
+
def on_role_change(self):
|
|
1366
|
+
print_connection(self.connection)
|
|
1367
|
+
|
|
1322
1368
|
|
|
1323
1369
|
# -----------------------------------------------------------------------------
|
|
1324
1370
|
def create_mode_factory(ctx, default_mode):
|
|
@@ -1448,6 +1494,11 @@ def create_role_factory(ctx, default_role):
|
|
|
1448
1494
|
'--extended-data-length',
|
|
1449
1495
|
help='Request a data length upon connection, specified as tx_octets/tx_time',
|
|
1450
1496
|
)
|
|
1497
|
+
@click.option(
|
|
1498
|
+
'--role-switch',
|
|
1499
|
+
type=click.Choice(['central', 'peripheral']),
|
|
1500
|
+
help='Request role switch upon connection (central or peripheral)',
|
|
1501
|
+
)
|
|
1451
1502
|
@click.option(
|
|
1452
1503
|
'--rfcomm-channel',
|
|
1453
1504
|
type=int,
|
|
@@ -1512,7 +1563,7 @@ def create_role_factory(ctx, default_role):
|
|
|
1512
1563
|
'--packet-size',
|
|
1513
1564
|
'-s',
|
|
1514
1565
|
metavar='SIZE',
|
|
1515
|
-
type=click.IntRange(8,
|
|
1566
|
+
type=click.IntRange(8, 8192),
|
|
1516
1567
|
default=500,
|
|
1517
1568
|
help='Packet size (client or ping role)',
|
|
1518
1569
|
)
|
|
@@ -1572,6 +1623,7 @@ def bench(
|
|
|
1572
1623
|
mode,
|
|
1573
1624
|
att_mtu,
|
|
1574
1625
|
extended_data_length,
|
|
1626
|
+
role_switch,
|
|
1575
1627
|
packet_size,
|
|
1576
1628
|
packet_count,
|
|
1577
1629
|
start_delay,
|
|
@@ -1614,12 +1666,12 @@ def bench(
|
|
|
1614
1666
|
ctx.obj['repeat_delay'] = repeat_delay
|
|
1615
1667
|
ctx.obj['pace'] = pace
|
|
1616
1668
|
ctx.obj['linger'] = linger
|
|
1617
|
-
|
|
1618
1669
|
ctx.obj['extended_data_length'] = (
|
|
1619
1670
|
[int(x) for x in extended_data_length.split('/')]
|
|
1620
1671
|
if extended_data_length
|
|
1621
1672
|
else None
|
|
1622
1673
|
)
|
|
1674
|
+
ctx.obj['role_switch'] = role_switch
|
|
1623
1675
|
ctx.obj['classic'] = mode in ('rfcomm-client', 'rfcomm-server')
|
|
1624
1676
|
|
|
1625
1677
|
|
|
@@ -1663,6 +1715,7 @@ def central(
|
|
|
1663
1715
|
authenticate,
|
|
1664
1716
|
encrypt or authenticate,
|
|
1665
1717
|
ctx.obj['extended_data_length'],
|
|
1718
|
+
ctx.obj['role_switch'],
|
|
1666
1719
|
).run()
|
|
1667
1720
|
|
|
1668
1721
|
asyncio.run(run_central())
|
|
@@ -1679,10 +1732,11 @@ def peripheral(ctx, transport):
|
|
|
1679
1732
|
async def run_peripheral():
|
|
1680
1733
|
await Peripheral(
|
|
1681
1734
|
transport,
|
|
1682
|
-
ctx.obj['classic'],
|
|
1683
|
-
ctx.obj['extended_data_length'],
|
|
1684
1735
|
role_factory,
|
|
1685
1736
|
mode_factory,
|
|
1737
|
+
ctx.obj['classic'],
|
|
1738
|
+
ctx.obj['extended_data_length'],
|
|
1739
|
+
ctx.obj['role_switch'],
|
|
1686
1740
|
).run()
|
|
1687
1741
|
|
|
1688
1742
|
asyncio.run(run_peripheral())
|