Compare commits

...

91 Commits

Author SHA1 Message Date
Gilles Boccon-Gibod
4ffc050eed restore python < 11 compat 2023-07-27 16:37:27 -07:00
Gilles Boccon-Gibod
60678419a0 compatibility with python 11 2023-07-27 14:55:28 -07:00
Gilles Boccon-Gibod
648dcc9305 use type object instead of type strings 2023-07-27 13:19:37 -07:00
Josh Wu
190529184e L2CAP: Import device.Connection for typing 2023-07-27 09:07:55 -07:00
Josh Wu
46eb81466d Add more argement hints in L2CAP 2023-07-27 09:07:55 -07:00
Josh Wu
9c70c487b9 Add type hint to L2CAP module 2023-07-27 09:07:55 -07:00
Josh Wu
43234d7c3e Use with-patch to mock SMP session 2023-07-27 08:00:36 -07:00
Josh Wu
dbf878dc3f SMP: Remove PairingMethod.__str__ 2023-07-27 08:00:36 -07:00
Josh Wu
f6c0bd88d7 SMP: Do not send phase 2 commands in CTKD 2023-07-27 08:00:36 -07:00
Josh Wu
8440b7fbf1 SMP: Refactor pairing method as enum 2023-07-27 08:00:36 -07:00
Gilles Boccon-Gibod
808ab54135 Merge pull request #221 from google/gbg/core-classes
add new device class major/minor identifiers
2023-07-25 09:49:05 -07:00
Gilles Boccon-Gibod
52b29ad680 add new device class major/minor identifiers 2023-07-24 17:41:57 -07:00
Gilles Boccon-Gibod
d41bf9c587 Merge pull request #216 from google/gbg/host-buffer-size-command
accept Host Buffer Size Command in the controller
2023-07-24 09:05:10 -07:00
Gilles Boccon-Gibod
b758825164 add flow control command 2023-07-22 13:04:39 -07:00
Gilles Boccon-Gibod
779dfe5473 accept Host Buffer Size Command in the controller 2023-07-21 19:36:26 -07:00
Gilles Boccon-Gibod
f9a4c7518e Merge pull request #214 from marshallpierce/mp/scanner-rssi
Add a space after RSSI
2023-07-14 10:52:54 -07:00
Marshall Pierce
bad2fdf69f Add a space after RSSI
The other data elements have a space, so I'm guessing that RSSI
is intended to as well. Perhaps there's some subtle reason why
it should have a space, though, in which case feel free to
close this.

Output now looks like this:

```
>>> 58:D3:49:E7:40:DA/P [PUBLIC]:
  RSSI: -67
  [Flags]: LE General,BR/EDR C,BR/EDR H
  [TX Power Level]: 4
  [Manufacturer Specific Data]: company=Apple, Inc., data=0f08c00af4392b00040c10020f04
```
2023-07-13 12:47:45 -06:00
Lucas Abel
a84df469cd pairing: handle user errors from all delegate calls 2023-07-12 11:03:21 -07:00
Gilles Boccon-Gibod
03e33e39bd Merge pull request #211 from google/gbg/fix-ws-transport-doc
fix doc for ws-client ws-server transports
2023-07-12 07:06:32 -07:00
Gilles Boccon-Gibod
753fb69272 fix doc for ws-client ws-server transports 2023-07-12 06:06:20 -07:00
Gilles Boccon-Gibod
81a5f3a395 Merge pull request #203 from google/gbg/realtek-driver
realtek driver
2023-07-11 07:06:07 -07:00
Gilles Boccon-Gibod
696a8d82fd look for files in linux FW dir 2023-07-11 06:41:34 -07:00
Gilles Boccon-Gibod
5f294b1fea python 3.8 compatibility 2023-07-11 06:41:34 -07:00
Gilles Boccon-Gibod
2d8f5e80fb add missing doc files 2023-07-11 06:41:34 -07:00
Gilles Boccon-Gibod
7a042db78e add more USB ids 2023-07-11 06:41:34 -07:00
Gilles Boccon-Gibod
41ce311836 allow custom driver factories 2023-07-11 06:41:34 -07:00
Gilles Boccon-Gibod
03538d0f8a add doc 2023-07-11 06:41:34 -07:00
Gilles Boccon-Gibod
86bc222dc0 add missing file 2023-07-11 06:41:34 -07:00
Gilles Boccon-Gibod
e8d285fdab add downloader tool 2023-07-11 06:41:34 -07:00
Gilles Boccon-Gibod
852c933c92 wip (+4 squashed commits)
Squashed commits:
[d29a350] wip
[7f541ed] wip
[1e2902e] basic working version
[14b497a] wip
2023-07-11 06:41:34 -07:00
Lucas Abel
7867a99a54 Merge pull request #209 from google/click-types-quick-fix
temporarily pin click to 8.1.3
2023-07-11 06:21:11 -07:00
Gilles Boccon-Gibod
6cd14bb503 temporarily pin click to 8.1.3 2023-07-11 00:11:24 -07:00
Gilles Boccon-Gibod
532b99ffea Merge pull request #206 from benquike/main
Add some commands and events in hci
2023-07-10 01:23:08 -07:00
Hui Peng
d80f40ff5d Add some commands and events in hci 2023-06-28 08:51:10 -07:00
Gilles Boccon-Gibod
e9dc0d6855 Merge pull request #201 from benquike/main
Pin aiohttp at version 3.8
2023-06-14 11:23:31 -07:00
Hui Peng
b18104c9a7 Pin aiohttp at version 3.8.4
Recently aiohttp package is upgraded to 4.0.x version,
which breaks setup.py. This change fix the build issue
by pinning it at version 3.8.4.
2023-06-13 09:44:54 -07:00
Gilles Boccon-Gibod
50d1884365 Merge pull request #199 from benquike/main
Add support for legacy pairing over bt classic
2023-06-12 10:45:51 -07:00
Gilles Boccon-Gibod
78581cc36f Merge pull request #195 from google/gbg/speaker-app
speaker app
2023-06-10 15:24:26 -07:00
Gilles Boccon-Gibod
b2c635768f fix format 2023-06-09 15:54:32 -07:00
Gilles Boccon-Gibod
bd8236a501 appear as speaker instead of headset 2023-06-08 16:01:36 -07:00
Gilles Boccon-Gibod
56594a0c2f fix indentiation 2023-06-08 16:00:56 -07:00
Hui Peng
4d2e821e50 Add support for legacy pairing over bt classic 2023-06-07 11:39:06 -07:00
Lucas Abel
7f987dc3cd Merge pull request #198 from qiaoccolato/main
reformat protobuf import
2023-06-07 10:05:30 -07:00
qiaoccolato
689745040f Merge branch 'google:main' into main 2023-06-07 09:19:54 -07:00
Qiao Yang
809d4a18f5 reformat protobuf import 2023-06-07 09:14:50 -07:00
Gilles Boccon-Gibod
54be8b328a Merge pull request #197 from zxzxwu/typing
Add typing for HFP and RFCOMM
2023-06-07 07:09:26 -07:00
Gilles Boccon-Gibod
57b469198a Merge pull request #196 from google/gbg/better-address-resolving
pairing event improvement
2023-06-07 07:03:53 -07:00
Josh Wu
4d74339c04 Add typing for RFCOMM 2023-06-06 00:04:25 +08:00
Josh Wu
39db278f2e Add typing for HFP 2023-06-05 23:54:42 +08:00
Gilles Boccon-Gibod
a1327e910b allow the ui to join late 2023-06-04 15:11:13 -07:00
Gilles Boccon-Gibod
ab4390fbde fix weakref type 2023-06-04 13:17:32 -07:00
Gilles Boccon-Gibod
a118792279 fix format 2023-06-04 13:12:11 -07:00
Gilles Boccon-Gibod
df848b0f24 Merge branch 'main' into gbg/speaker-app 2023-06-04 13:09:43 -07:00
Gilles Boccon-Gibod
27fbb58447 add basic keystore test 2023-06-04 13:01:07 -07:00
Gilles Boccon-Gibod
7ec57d6d6a fix typo 2023-06-04 12:52:27 -07:00
Gilles Boccon-Gibod
de706e9671 simplify command line 2023-06-04 12:52:09 -07:00
Gilles Boccon-Gibod
c425b87549 add doc 2023-06-04 12:51:16 -07:00
Gilles Boccon-Gibod
a74c39dc2b Merge pull request #193 from benquike/main
Update device name in advertising data from load_from_dict
2023-05-24 15:04:03 -07:00
Hui Peng
22f7cef771 Update device name in advertising data from load_from_dict 2023-05-23 16:13:24 -07:00
Lucas Abel
5790d3aae8 Merge pull request #192 from google/uael/gatt-fixes
gatt: reset args ordering to original
2023-05-23 06:40:51 +02:00
Lucas Abel
744294f00e gatt: reset args ordering to original
This was a breaking change
2023-05-22 09:34:30 +00:00
Gilles Boccon-Gibod
371ea07442 wip 2023-05-19 16:05:21 -07:00
Gilles Boccon-Gibod
3697b8dde9 Merge pull request #189 from google/dependabot/pip/docs/mkdocs/pymdown-extensions-10.0
Bump pymdown-extensions from 9.6 to 10.0 in /docs/mkdocs
2023-05-17 19:05:23 -07:00
Lucas Abel
f3bfbab44d Merge pull request #190 from google/uael/pandora-server
pandora: import bumble pandora server from avatar
2023-05-17 22:31:09 +02:00
uael
afcce0d6c8 pandora: import bumble pandora server from avatar 2023-05-17 18:18:43 +00:00
Gilles Boccon-Gibod
121b0a6a93 fix aiohttp version 2023-05-16 11:42:15 -07:00
Gilles Boccon-Gibod
55a01033a0 wip 2023-05-15 14:29:58 -07:00
dependabot[bot]
69d45bed21 Bump pymdown-extensions from 9.6 to 10.0 in /docs/mkdocs
Bumps [pymdown-extensions](https://github.com/facelessuser/pymdown-extensions) from 9.6 to 10.0.
- [Release notes](https://github.com/facelessuser/pymdown-extensions/releases)
- [Commits](https://github.com/facelessuser/pymdown-extensions/compare/9.6...10.0)

---
updated-dependencies:
- dependency-name: pymdown-extensions
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-05-15 20:54:41 +00:00
Gilles Boccon-Gibod
7b7ef85b14 wip 2023-05-12 16:26:57 -07:00
Gilles Boccon-Gibod
e6a623db93 initial speaker app skeleton 2023-05-12 16:26:57 -07:00
Gilles Boccon-Gibod
b6e1d569d3 a2dp and avdtp improvements 2023-05-12 16:26:57 -07:00
Lucas Abel
4bd8c24f54 Merge pull request #186 from zxzxwu/gatt
GATT included service declaration & discovery
2023-05-09 11:34:53 -07:00
Josh Wu
8d09693654 Implement GATT server included service declaration 2023-05-09 00:59:22 +08:00
Josh Wu
7d7534928f Add self GATT included service tests 2023-05-08 14:59:58 +08:00
Josh Wu
e9bf5757c4 Implement GATT client included service discovery 2023-05-08 14:59:47 +08:00
Josh Wu
f9f694dfcf Replace list[] legacy typing 2023-05-08 14:56:20 +08:00
Gilles Boccon-Gibod
6826f68478 fix linter warnings 2023-05-05 16:16:55 -07:00
Gilles Boccon-Gibod
f80c83d0b3 better doc and default behavior for json keystore 2023-05-05 16:11:20 -07:00
Gilles Boccon-Gibod
3de35193bc rebase 2023-05-05 16:09:01 -07:00
Gilles Boccon-Gibod
740a2e0ca0 instantiate keystore after power_on 2023-05-05 16:07:16 -07:00
Lucas Abel
022c23500a Merge pull request #178 from google/uael/pairing
Overall fixes and improvements
2023-05-03 21:39:50 -07:00
uael
5d4f811a65 smp: add simple Session proxy
This allow modifying the SMP behavior at runtime for testing purpose.
2023-05-04 04:33:50 +00:00
uael
3c81b248a3 smp: add type hints 2023-05-04 04:33:50 +00:00
uael
fdee5ecf70 uuid: add separator to to_hex_str + type hints 2023-05-04 04:33:50 +00:00
uael
29bd693bab device: fix advertising data UUID list parse loop 2023-05-04 04:32:38 +00:00
uael
30934969b8 ssp: simplify pairing and fix just-works
Even through the previous implementation was correct:
- Always call `delegate.confirm()` for `just-works` pairing, but with
  `auto` parameter set to `True`.
- Trust the controller and do not double check the devices IO
  capabilities.
2023-05-04 04:32:38 +00:00
uael
4a333b6c0f keys: add an in-memory key-store fallback
Instead of defaulting the key-store to `None`, use an in-memory one.
This way a keystore is always available. A future improvement could be
to rework the device keystore initialization to remove checks like
`if self.keystore:` along the codebase.
2023-05-04 04:32:38 +00:00
Gilles Boccon-Gibod
dad7957d92 Merge pull request #185 from google/gbg/netsim-transport
support new android emulator gRPC interface
2023-05-03 08:54:55 -07:00
uael
b5cc167e31 pairing: apply strict typing 2023-05-01 06:19:11 +00:00
Gilles Boccon-Gibod
51d3a869a4 Merge pull request #183 from google/gbg/181
fix keystore save implementation for windows
2023-04-30 13:59:28 -07:00
Gilles Boccon-Gibod
dd930e3bde fix implementation for Windows 2023-04-30 11:42:28 -07:00
62 changed files with 6778 additions and 817 deletions

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.8", "3.9", "3.10"]
python-version: ["3.8", "3.9", "3.10", "3.11"]
fail-fast: false
steps:

View File

@@ -157,6 +157,26 @@ class Delegate(PairingDelegate):
self.print(f'### PIN: {number:0{digits}}')
self.print('###-----------------------------------')
async def get_string(self, max_length: int):
await self.update_peer_name()
# Prompt a PIN (for legacy pairing in classic)
self.print('###-----------------------------------')
self.print(f'### Pairing with {self.peer_name}')
self.print('###-----------------------------------')
count = 0
while True:
response = await self.prompt('>>> Enter PIN (1-6 chars):')
if len(response) == 0:
count += 1
if count > 3:
self.print('too many tries, stopping the pairing')
return None
self.print('no PIN was entered, try again')
continue
return response
# -----------------------------------------------------------------------------
async def get_peer_name(peer, mode):
@@ -207,7 +227,7 @@ def on_connection(connection, request):
# Listen for pairing events
connection.on('pairing_start', on_pairing_start)
connection.on('pairing', on_pairing)
connection.on('pairing', lambda keys: on_pairing(connection.peer_address, keys))
connection.on('pairing_failure', on_pairing_failure)
# Listen for encryption changes
@@ -242,9 +262,9 @@ def on_pairing_start():
# -----------------------------------------------------------------------------
def on_pairing(keys):
def on_pairing(address, keys):
print(color('***-----------------------------------', 'cyan'))
print(color('*** Paired!', 'cyan'))
print(color(f'*** Paired! (peer identity={address})', 'cyan'))
keys.print(prefix=color('*** ', 'cyan'))
print(color('***-----------------------------------', 'cyan'))
Waiter.instance.terminate()
@@ -283,17 +303,6 @@ async def pair(
# Create a device to manage the host
device = Device.from_config_file_with_hci(device_config, hci_source, hci_sink)
# Set a custom keystore if specified on the command line
if keystore_file:
device.keystore = JsonKeyStore(namespace=None, filename=keystore_file)
# Print the existing keys before pairing
if print_keys and device.keystore:
print(color('@@@-----------------------------------', 'blue'))
print(color('@@@ Pairing Keys:', 'blue'))
await device.keystore.print(prefix=color('@@@ ', 'blue'))
print(color('@@@-----------------------------------', 'blue'))
# Expose a GATT characteristic that can be used to trigger pairing by
# responding with an authentication error when read
if mode == 'le':
@@ -323,6 +332,17 @@ async def pair(
# Get things going
await device.power_on()
# Set a custom keystore if specified on the command line
if keystore_file:
device.keystore = JsonKeyStore.from_device(device, filename=keystore_file)
# Print the existing keys before pairing
if print_keys and device.keystore:
print(color('@@@-----------------------------------', 'blue'))
print(color('@@@ Pairing Keys:', 'blue'))
await device.keystore.print(prefix=color('@@@ ', 'blue'))
print(color('@@@-----------------------------------', 'blue'))
# Set up a pairing config factory
device.pairing_config_factory = lambda connection: PairingConfig(
sc, mitm, bond, Delegate(mode, connection, io, prompt)

30
apps/pandora_server.py Normal file
View File

@@ -0,0 +1,30 @@
import asyncio
import click
import logging
from bumble.pandora import PandoraDevice, serve
BUMBLE_SERVER_GRPC_PORT = 7999
ROOTCANAL_PORT_CUTTLEFISH = 7300
@click.command()
@click.option('--grpc-port', help='gRPC port to serve', default=BUMBLE_SERVER_GRPC_PORT)
@click.option(
'--rootcanal-port', help='Rootcanal TCP port', default=ROOTCANAL_PORT_CUTTLEFISH
)
@click.option(
'--transport',
help='HCI transport',
default=f'tcp-client:127.0.0.1:<rootcanal-port>',
)
def main(grpc_port: int, rootcanal_port: int, transport: str) -> None:
if '<rootcanal-port>' in transport:
transport = transport.replace('<rootcanal-port>', str(rootcanal_port))
device = PandoraDevice({'transport': transport})
logging.basicConfig(level=logging.DEBUG)
asyncio.run(serve(device, port=grpc_port))
if __name__ == '__main__':
main() # pylint: disable=no-value-for-parameter

View File

@@ -133,15 +133,16 @@ async def scan(
'Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink
)
await device.power_on()
if keystore_file:
keystore = JsonKeyStore(namespace=None, filename=keystore_file)
device.keystore = keystore
else:
resolver = None
device.keystore = JsonKeyStore.from_device(device, filename=keystore_file)
if device.keystore:
resolving_keys = await device.keystore.get_resolving_keys()
resolver = AddressResolver(resolving_keys)
else:
resolver = None
printer = AdvertisementPrinter(min_rssi, resolver)
if raw:
@@ -149,8 +150,6 @@ async def scan(
else:
device.on('advertisement', printer.on_advertisement)
await device.power_on()
if phy is None:
scanning_phys = [HCI_LE_1M_PHY, HCI_LE_CODED_PHY]
else:

0
apps/speaker/__init__.py Normal file
View File

42
apps/speaker/logo.svg Normal file
View File

@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <!-- Created with Vectornator for iOS (http://vectornator.io/) --><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg height="100%" style="fill-rule:nonzero;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="100%" xmlns:vectornator="http://vectornator.io" version="1.1" viewBox="0 0 745 744.634">
<metadata>
<vectornator:setting key="DimensionsVisible" value="1"/>
<vectornator:setting key="PencilOnly" value="0"/>
<vectornator:setting key="SnapToPoints" value="0"/>
<vectornator:setting key="OutlineMode" value="0"/>
<vectornator:setting key="CMYKEnabledKey" value="0"/>
<vectornator:setting key="RulersVisible" value="1"/>
<vectornator:setting key="SnapToEdges" value="0"/>
<vectornator:setting key="GuidesVisible" value="1"/>
<vectornator:setting key="DisplayWhiteBackground" value="0"/>
<vectornator:setting key="doHistoryDisabled" value="0"/>
<vectornator:setting key="SnapToGuides" value="1"/>
<vectornator:setting key="TimeLapseWatermarkDisabled" value="0"/>
<vectornator:setting key="Units" value="Pixels"/>
<vectornator:setting key="DynamicGuides" value="0"/>
<vectornator:setting key="IsolateActiveLayer" value="0"/>
<vectornator:setting key="SnapToGrid" value="0"/>
</metadata>
<defs/>
<g id="Layer 1" vectornator:layerName="Layer 1">
<path stroke="#000000" stroke-width="18.6464" d="M368.753+729.441L58.8847+550.539L58.8848+192.734L368.753+13.8313L678.621+192.734L678.621+550.539L368.753+729.441Z" fill="#0082fc" stroke-linecap="butt" fill-opacity="0.307489" opacity="1" stroke-linejoin="round"/>
<g opacity="1">
<g opacity="1">
<path stroke="#000000" stroke-width="20" d="M292.873+289.256L442.872+289.256L442.872+539.254L292.873+539.254L292.873+289.256Z" fill="#fcd100" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
<path stroke="#000000" stroke-width="20" d="M292.873+289.256C292.873+247.835+326.452+214.257+367.873+214.257C409.294+214.257+442.872+247.835+442.872+289.256C442.872+330.677+409.294+364.256+367.873+364.256C326.452+364.256+292.873+330.677+292.873+289.256Z" fill="#fcd100" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
<path stroke="#000000" stroke-width="20" d="M292.873+539.254C292.873+497.833+326.452+464.255+367.873+464.255C409.294+464.255+442.872+497.833+442.872+539.254C442.872+580.675+409.294+614.254+367.873+614.254C326.452+614.254+292.873+580.675+292.873+539.254Z" fill="#fcd100" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
<path stroke="#0082fc" stroke-width="0.1" d="M302.873+289.073L432.872+289.073L432.872+539.072L302.873+539.072L302.873+289.073Z" fill="#fcd100" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
</g>
<path stroke="#000000" stroke-width="0.1" d="M103.161+309.167L226.956+443.903L366.671+309.604L103.161+309.167Z" fill="#0082fc" stroke-linecap="round" opacity="1" stroke-linejoin="round"/>
<path stroke="#000000" stroke-width="0.1" d="M383.411+307.076L508.887+440.112L650.5+307.507L383.411+307.076Z" fill="#0082fc" stroke-linecap="round" opacity="1" stroke-linejoin="round"/>
<path stroke="#000000" stroke-width="20" d="M522.045+154.808L229.559+448.882L83.8397+300.104L653.666+302.936L511.759+444.785L223.101+156.114" fill="none" stroke-linecap="round" opacity="1" stroke-linejoin="round"/>
<path stroke="#000000" stroke-width="61.8698" d="M295.857+418.738L438.9+418.738" fill="none" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
<path stroke="#000000" stroke-width="61.8698" d="M295.857+521.737L438.9+521.737" fill="none" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
<g opacity="1">
<path stroke="#0082fc" stroke-width="0.1" d="M367.769+667.024L367.821+616.383L403.677+616.336C383.137+626.447+368.263+638.69+367.769+667.024Z" fill="#000000" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
<path stroke="#0082fc" stroke-width="0.1" d="M367.836+667.024L367.784+616.383L331.928+616.336C352.468+626.447+367.341+638.69+367.836+667.024Z" fill="#000000" stroke-linecap="butt" opacity="1" stroke-linejoin="round"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

76
apps/speaker/speaker.css Normal file
View File

@@ -0,0 +1,76 @@
body, h1, h2, h3, h4, h5, h6 {
font-family: sans-serif;
}
#controlsDiv {
margin: 6px;
}
#connectionText {
background-color: rgb(239, 89, 75);
border: none;
border-radius: 4px;
padding: 8px;
display: inline-block;
margin: 4px;
}
#startButton {
padding: 4px;
margin: 6px;
}
#fftCanvas {
border-radius: 16px;
margin: 6px;
}
#bandwidthCanvas {
border: grey;
border-style: solid;
border-radius: 8px;
margin: 6px;
}
#streamStateText {
background-color: rgb(93, 165, 93);
border: none;
border-radius: 8px;
padding: 10px 20px;
display: inline-block;
margin: 6px;
}
#connectionStateText {
background-color: rgb(112, 146, 206);
border: none;
border-radius: 8px;
padding: 10px 20px;
display: inline-block;
margin: 6px;
}
#propertiesTable {
border: grey;
border-style: solid;
border-radius: 4px;
padding: 4px;
margin: 6px;
margin-left: 0px;
}
th, td {
padding-left: 6px;
padding-right: 6px;
}
.properties td:nth-child(even) {
background-color: #D6EEEE;
font-family: monospace;
}
.properties td:nth-child(odd) {
font-weight: bold;
}
.properties tr td:nth-child(2) { width: 150px; }

34
apps/speaker/speaker.html Normal file
View File

@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html>
<head>
<title>Bumble Speaker</title>
<script type="text/javascript" src="speaker.js"></script>
<link rel="stylesheet" href="speaker.css">
</head>
<body>
<h1><img src="logo.svg" width=100 height=100 style="vertical-align:middle" alt=""/>Bumble Virtual Speaker</h1>
<div id="connectionText"></div>
<div id="speaker">
<table><tr>
<td>
<table id="propertiesTable" class="properties">
<tr><td>Codec</td><td><span id="codecText"></span></td></tr>
<tr><td>Packets</td><td><span id="packetsReceivedText"></span></td></tr>
<tr><td>Bytes</td><td><span id="bytesReceivedText"></span></td></tr>
</table>
</td>
<td>
<canvas id="bandwidthCanvas" width="500", height="100">Bandwidth Graph</canvas>
</td>
</tr></table>
<span id="streamStateText">IDLE</span>
<span id="connectionStateText">NOT CONNECTED</span>
<div id="controlsDiv">
<button id="audioOnButton">Audio On</button>
<span id="audioSupportMessageText"></span>
</div>
<canvas id="fftCanvas" width="1024", height="300">Audio Frequencies Animation</canvas>
<audio id="audio"></audio>
</div>
</body>
</html>

315
apps/speaker/speaker.js Normal file
View File

@@ -0,0 +1,315 @@
(function () {
'use strict';
const channelUrl = ((window.location.protocol === "https:") ? "wss://" : "ws://") + window.location.host + "/channel";
let channelSocket;
let connectionText;
let codecText;
let packetsReceivedText;
let bytesReceivedText;
let streamStateText;
let connectionStateText;
let controlsDiv;
let audioOnButton;
let mediaSource;
let sourceBuffer;
let audioElement;
let audioContext;
let audioAnalyzer;
let audioFrequencyBinCount;
let audioFrequencyData;
let packetsReceived = 0;
let bytesReceived = 0;
let audioState = "stopped";
let streamState = "IDLE";
let audioSupportMessageText;
let fftCanvas;
let fftCanvasContext;
let bandwidthCanvas;
let bandwidthCanvasContext;
let bandwidthBinCount;
let bandwidthBins = [];
const FFT_WIDTH = 800;
const FFT_HEIGHT = 256;
const BANDWIDTH_WIDTH = 500;
const BANDWIDTH_HEIGHT = 100;
function hexToBytes(hex) {
return Uint8Array.from(hex.match(/.{1,2}/g).map((byte) => parseInt(byte, 16)));
}
function init() {
initUI();
initMediaSource();
initAudioElement();
initAnalyzer();
connect();
}
function initUI() {
controlsDiv = document.getElementById("controlsDiv");
controlsDiv.style.visibility = "hidden";
connectionText = document.getElementById("connectionText");
audioOnButton = document.getElementById("audioOnButton");
codecText = document.getElementById("codecText");
packetsReceivedText = document.getElementById("packetsReceivedText");
bytesReceivedText = document.getElementById("bytesReceivedText");
streamStateText = document.getElementById("streamStateText");
connectionStateText = document.getElementById("connectionStateText");
audioSupportMessageText = document.getElementById("audioSupportMessageText");
audioOnButton.onclick = () => startAudio();
setConnectionText("");
requestAnimationFrame(onAnimationFrame);
}
function initMediaSource() {
mediaSource = new MediaSource();
mediaSource.onsourceopen = onMediaSourceOpen;
mediaSource.onsourceclose = onMediaSourceClose;
mediaSource.onsourceended = onMediaSourceEnd;
}
function initAudioElement() {
audioElement = document.getElementById("audio");
audioElement.src = URL.createObjectURL(mediaSource);
// audioElement.controls = true;
}
function initAnalyzer() {
fftCanvas = document.getElementById("fftCanvas");
fftCanvas.width = FFT_WIDTH
fftCanvas.height = FFT_HEIGHT
fftCanvasContext = fftCanvas.getContext('2d');
fftCanvasContext.fillStyle = "rgb(0, 0, 0)";
fftCanvasContext.fillRect(0, 0, FFT_WIDTH, FFT_HEIGHT);
bandwidthCanvas = document.getElementById("bandwidthCanvas");
bandwidthCanvas.width = BANDWIDTH_WIDTH
bandwidthCanvas.height = BANDWIDTH_HEIGHT
bandwidthCanvasContext = bandwidthCanvas.getContext('2d');
bandwidthCanvasContext.fillStyle = "rgb(255, 255, 255)";
bandwidthCanvasContext.fillRect(0, 0, BANDWIDTH_WIDTH, BANDWIDTH_HEIGHT);
}
function startAnalyzer() {
// FFT
if (audioElement.captureStream !== undefined) {
audioContext = new AudioContext();
audioAnalyzer = audioContext.createAnalyser();
audioAnalyzer.fftSize = 128;
audioFrequencyBinCount = audioAnalyzer.frequencyBinCount;
audioFrequencyData = new Uint8Array(audioFrequencyBinCount);
const stream = audioElement.captureStream();
const source = audioContext.createMediaStreamSource(stream);
source.connect(audioAnalyzer);
}
// Bandwidth
bandwidthBinCount = BANDWIDTH_WIDTH / 2;
bandwidthBins = [];
}
function setConnectionText(message) {
connectionText.innerText = message;
if (message.length == 0) {
connectionText.style.display = "none";
} else {
connectionText.style.display = "inline-block";
}
}
function setStreamState(state) {
streamState = state;
streamStateText.innerText = streamState;
}
function onAnimationFrame() {
// FFT
if (audioAnalyzer !== undefined) {
audioAnalyzer.getByteFrequencyData(audioFrequencyData);
fftCanvasContext.fillStyle = "rgb(0, 0, 0)";
fftCanvasContext.fillRect(0, 0, FFT_WIDTH, FFT_HEIGHT);
const barCount = audioFrequencyBinCount;
const barWidth = (FFT_WIDTH / audioFrequencyBinCount) - 1;
for (let bar = 0; bar < barCount; bar++) {
const barHeight = audioFrequencyData[bar];
fftCanvasContext.fillStyle = `rgb(${barHeight / 256 * 200 + 50}, 50, ${50 + 2 * bar})`;
fftCanvasContext.fillRect(bar * (barWidth + 1), FFT_HEIGHT - barHeight, barWidth, barHeight);
}
}
// Bandwidth
bandwidthCanvasContext.fillStyle = "rgb(255, 255, 255)";
bandwidthCanvasContext.fillRect(0, 0, BANDWIDTH_WIDTH, BANDWIDTH_HEIGHT);
bandwidthCanvasContext.fillStyle = `rgb(100, 100, 100)`;
for (let t = 0; t < bandwidthBins.length; t++) {
const lineHeight = (bandwidthBins[t] / 1000) * BANDWIDTH_HEIGHT;
bandwidthCanvasContext.fillRect(t * 2, BANDWIDTH_HEIGHT - lineHeight, 2, lineHeight);
}
// Display again at the next frame
requestAnimationFrame(onAnimationFrame);
}
function onMediaSourceOpen() {
console.log(this.readyState);
sourceBuffer = mediaSource.addSourceBuffer("audio/aac");
}
function onMediaSourceClose() {
console.log(this.readyState);
}
function onMediaSourceEnd() {
console.log(this.readyState);
}
async function startAudio() {
try {
console.log("starting audio...");
audioOnButton.disabled = true;
audioState = "starting";
await audioElement.play();
console.log("audio started");
audioState = "playing";
startAnalyzer();
} catch(error) {
console.error(`play failed: ${error}`);
audioState = "stopped";
audioOnButton.disabled = false;
}
}
function onAudioPacket(packet) {
if (audioState != "stopped") {
// Queue the audio packet.
sourceBuffer.appendBuffer(packet);
}
packetsReceived += 1;
packetsReceivedText.innerText = packetsReceived;
bytesReceived += packet.byteLength;
bytesReceivedText.innerText = bytesReceived;
bandwidthBins[bandwidthBins.length] = packet.byteLength;
if (bandwidthBins.length > bandwidthBinCount) {
bandwidthBins.shift();
}
}
function onChannelOpen() {
console.log('channel OPEN');
setConnectionText("");
controlsDiv.style.visibility = "visible";
// Handshake with the backend.
sendMessage({
type: "hello"
});
}
function onChannelClose() {
console.log('channel CLOSED');
setConnectionText("Connection to CLI app closed, restart it and reload this page.");
controlsDiv.style.visibility = "hidden";
}
function onChannelError(error) {
console.log(`channel ERROR: ${error}`);
setConnectionText(`Connection to CLI app error ({${error}}), restart it and reload this page.`);
controlsDiv.style.visibility = "hidden";
}
function onChannelMessage(message) {
if (typeof message.data === 'string' || message.data instanceof String) {
// JSON message.
const jsonMessage = JSON.parse(message.data);
console.log(`channel MESSAGE: ${message.data}`);
// Dispatch the message.
const handlerName = `on${jsonMessage.type.charAt(0).toUpperCase()}${jsonMessage.type.slice(1)}Message`
const handler = messageHandlers[handlerName];
if (handler !== undefined) {
const params = jsonMessage.params;
if (params === undefined) {
params = {};
}
handler(params);
} else {
console.warn(`unhandled message: ${jsonMessage.type}`)
}
} else {
// BINARY audio data.
onAudioPacket(message.data);
}
}
function onHelloMessage(params) {
codecText.innerText = params.codec;
if (params.codec != "aac") {
audioOnButton.disabled = true;
audioSupportMessageText.innerText = "Only AAC can be played, audio will be disabled";
audioSupportMessageText.style.display = "inline-block";
} else {
audioSupportMessageText.innerText = "";
audioSupportMessageText.style.display = "none";
}
if (params.streamState) {
setStreamState(params.streamState);
}
}
function onStartMessage(params) {
setStreamState("STARTED");
}
function onStopMessage(params) {
setStreamState("STOPPED");
}
function onSuspendMessage(params) {
setStreamState("SUSPENDED");
}
function onConnectionMessage(params) {
connectionStateText.innerText = `CONNECTED: ${params.peer_name} (${params.peer_address})`;
}
function onDisconnectionMessage(params) {
connectionStateText.innerText = "DISCONNECTED";
}
function sendMessage(message) {
channelSocket.send(JSON.stringify(message));
}
function connect() {
console.log("connecting to CLI app");
channelSocket = new WebSocket(channelUrl);
channelSocket.binaryType = "arraybuffer";
channelSocket.onopen = onChannelOpen;
channelSocket.onclose = onChannelClose;
channelSocket.onerror = onChannelError;
channelSocket.onmessage = onChannelMessage;
}
const messageHandlers = {
onHelloMessage,
onStartMessage,
onStopMessage,
onSuspendMessage,
onConnectionMessage,
onDisconnectionMessage
}
window.onload = (event) => {
init();
}
}());

747
apps/speaker/speaker.py Normal file
View File

@@ -0,0 +1,747 @@
# Copyright 2021-2023 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
from __future__ import annotations
import asyncio
import asyncio.subprocess
from importlib import resources
import enum
import json
import os
import logging
import pathlib
import subprocess
from typing import Dict, List, Optional
import weakref
import click
import aiohttp
from aiohttp import web
import bumble
from bumble.colors import color
from bumble.core import BT_BR_EDR_TRANSPORT, CommandTimeoutError
from bumble.device import Connection, Device, DeviceConfiguration
from bumble.hci import HCI_StatusError
from bumble.pairing import PairingConfig
from bumble.sdp import ServiceAttribute
from bumble.transport import open_transport
from bumble.avdtp import (
AVDTP_AUDIO_MEDIA_TYPE,
Listener,
MediaCodecCapabilities,
MediaPacket,
Protocol,
)
from bumble.a2dp import (
MPEG_2_AAC_LC_OBJECT_TYPE,
make_audio_sink_service_sdp_records,
A2DP_SBC_CODEC_TYPE,
A2DP_MPEG_2_4_AAC_CODEC_TYPE,
SBC_MONO_CHANNEL_MODE,
SBC_DUAL_CHANNEL_MODE,
SBC_SNR_ALLOCATION_METHOD,
SBC_LOUDNESS_ALLOCATION_METHOD,
SBC_STEREO_CHANNEL_MODE,
SBC_JOINT_STEREO_CHANNEL_MODE,
SbcMediaCodecInformation,
AacMediaCodecInformation,
)
from bumble.utils import AsyncRunner
from bumble.codecs import AacAudioRtpPacket
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
DEFAULT_UI_PORT = 7654
# -----------------------------------------------------------------------------
class AudioExtractor:
@staticmethod
def create(codec: str):
if codec == 'aac':
return AacAudioExtractor()
if codec == 'sbc':
return SbcAudioExtractor()
def extract_audio(self, packet: MediaPacket) -> bytes:
raise NotImplementedError()
# -----------------------------------------------------------------------------
class AacAudioExtractor:
def extract_audio(self, packet: MediaPacket) -> bytes:
return AacAudioRtpPacket(packet.payload).to_adts()
# -----------------------------------------------------------------------------
class SbcAudioExtractor:
def extract_audio(self, packet: MediaPacket) -> bytes:
# header = packet.payload[0]
# fragmented = header >> 7
# start = (header >> 6) & 0x01
# last = (header >> 5) & 0x01
# number_of_frames = header & 0x0F
# TODO: support fragmented payloads
return packet.payload[1:]
# -----------------------------------------------------------------------------
class Output:
async def start(self) -> None:
pass
async def stop(self) -> None:
pass
async def suspend(self) -> None:
pass
async def on_connection(self, connection: Connection) -> None:
pass
async def on_disconnection(self, reason: int) -> None:
pass
def on_rtp_packet(self, packet: MediaPacket) -> None:
pass
# -----------------------------------------------------------------------------
class FileOutput(Output):
filename: str
codec: str
extractor: AudioExtractor
def __init__(self, filename, codec):
self.filename = filename
self.codec = codec
self.file = open(filename, 'wb')
self.extractor = AudioExtractor.create(codec)
def on_rtp_packet(self, packet: MediaPacket) -> None:
self.file.write(self.extractor.extract_audio(packet))
# -----------------------------------------------------------------------------
class QueuedOutput(Output):
MAX_QUEUE_SIZE = 32768
packets: asyncio.Queue
extractor: AudioExtractor
packet_pump_task: Optional[asyncio.Task]
started: bool
def __init__(self, extractor):
self.extractor = extractor
self.packets = asyncio.Queue()
self.packet_pump_task = None
self.started = False
async def start(self):
if self.started:
return
self.packet_pump_task = asyncio.create_task(self.pump_packets())
async def pump_packets(self):
while True:
packet = await self.packets.get()
await self.on_audio_packet(packet)
async def on_audio_packet(self, packet: bytes) -> None:
pass
def on_rtp_packet(self, packet: MediaPacket) -> None:
if self.packets.qsize() > self.MAX_QUEUE_SIZE:
logger.debug("queue full, dropping")
return
self.packets.put_nowait(self.extractor.extract_audio(packet))
# -----------------------------------------------------------------------------
class WebSocketOutput(QueuedOutput):
def __init__(self, codec, send_audio, send_message):
super().__init__(AudioExtractor.create(codec))
self.send_audio = send_audio
self.send_message = send_message
async def on_connection(self, connection: Connection) -> None:
try:
await connection.request_remote_name()
except HCI_StatusError:
pass
peer_name = '' if connection.peer_name is None else connection.peer_name
peer_address = str(connection.peer_address).replace('/P', '')
await self.send_message(
'connection',
peer_address=peer_address,
peer_name=peer_name,
)
async def on_disconnection(self, reason) -> None:
await self.send_message('disconnection')
async def on_audio_packet(self, packet: bytes) -> None:
await self.send_audio(packet)
async def start(self):
await super().start()
await self.send_message('start')
async def stop(self):
await super().stop()
await self.send_message('stop')
async def suspend(self):
await super().suspend()
await self.send_message('suspend')
# -----------------------------------------------------------------------------
class FfplayOutput(QueuedOutput):
MAX_QUEUE_SIZE = 32768
subprocess: Optional[asyncio.subprocess.Process]
ffplay_task: Optional[asyncio.Task]
def __init__(self) -> None:
super().__init__(AacAudioExtractor())
self.subprocess = None
self.ffplay_task = None
async def start(self):
if self.started:
return
await super().start()
self.subprocess = await asyncio.create_subprocess_shell(
'ffplay -acodec aac pipe:0',
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
self.ffplay_task = asyncio.create_task(self.monitor_ffplay())
async def stop(self):
# TODO
pass
async def suspend(self):
# TODO
pass
async def monitor_ffplay(self):
async def read_stream(name, stream):
while True:
data = await stream.read()
logger.debug(f'{name}:', data)
await asyncio.wait(
[
asyncio.create_task(
read_stream('[ffplay stdout]', self.subprocess.stdout)
),
asyncio.create_task(
read_stream('[ffplay stderr]', self.subprocess.stderr)
),
asyncio.create_task(self.subprocess.wait()),
]
)
logger.debug("FFPLAY done")
async def on_audio_packet(self, packet):
try:
self.subprocess.stdin.write(packet)
except Exception:
logger.warning('!!!! exception while sending audio to ffplay pipe')
# -----------------------------------------------------------------------------
class UiServer:
speaker: weakref.ReferenceType[Speaker]
port: int
def __init__(self, speaker: Speaker, port: int) -> None:
self.speaker = weakref.ref(speaker)
self.port = port
self.channel_socket = None
async def start_http(self) -> None:
"""Start the UI HTTP server."""
app = web.Application()
app.add_routes(
[
web.get('/', self.get_static),
web.get('/speaker.html', self.get_static),
web.get('/speaker.js', self.get_static),
web.get('/speaker.css', self.get_static),
web.get('/logo.svg', self.get_static),
web.get('/channel', self.get_channel),
]
)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, 'localhost', self.port)
print('UI HTTP server at ' + color(f'http://127.0.0.1:{self.port}', 'green'))
await site.start()
async def get_static(self, request):
path = request.path
if path == '/':
path = '/speaker.html'
if path.endswith('.html'):
content_type = 'text/html'
elif path.endswith('.js'):
content_type = 'text/javascript'
elif path.endswith('.css'):
content_type = 'text/css'
elif path.endswith('.svg'):
content_type = 'image/svg+xml'
else:
content_type = 'text/plain'
text = (
resources.files("bumble.apps.speaker")
.joinpath(pathlib.Path(path).relative_to('/'))
.read_text(encoding="utf-8")
)
return aiohttp.web.Response(text=text, content_type=content_type)
async def get_channel(self, request):
ws = web.WebSocketResponse()
await ws.prepare(request)
# Process messages until the socket is closed.
self.channel_socket = ws
async for message in ws:
if message.type == aiohttp.WSMsgType.TEXT:
logger.debug(f'<<< received message: {message.data}')
await self.on_message(message.data)
elif message.type == aiohttp.WSMsgType.ERROR:
logger.debug(
f'channel connection closed with exception {ws.exception()}'
)
self.channel_socket = None
logger.debug('--- channel connection closed')
return ws
async def on_message(self, message_str: str):
# Parse the message as JSON
message = json.loads(message_str)
# Dispatch the message
message_type = message['type']
message_params = message.get('params', {})
handler = getattr(self, f'on_{message_type}_message')
if handler:
await handler(**message_params)
async def on_hello_message(self):
await self.send_message(
'hello',
bumble_version=bumble.__version__,
codec=self.speaker().codec,
streamState=self.speaker().stream_state.name,
)
if connection := self.speaker().connection:
await self.send_message(
'connection',
peer_address=str(connection.peer_address).replace('/P', ''),
peer_name=connection.peer_name,
)
async def send_message(self, message_type: str, **kwargs) -> None:
if self.channel_socket is None:
return
message = {'type': message_type, 'params': kwargs}
await self.channel_socket.send_json(message)
async def send_audio(self, data: bytes) -> None:
if self.channel_socket is None:
return
try:
await self.channel_socket.send_bytes(data)
except Exception as error:
logger.warning(f'exception while sending audio packet: {error}')
# -----------------------------------------------------------------------------
class Speaker:
class StreamState(enum.Enum):
IDLE = 0
STOPPED = 1
STARTED = 2
SUSPENDED = 3
def __init__(self, device_config, transport, codec, discover, outputs, ui_port):
self.device_config = device_config
self.transport = transport
self.codec = codec
self.discover = discover
self.ui_port = ui_port
self.device = None
self.connection = None
self.listener = None
self.packets_received = 0
self.bytes_received = 0
self.stream_state = Speaker.StreamState.IDLE
self.outputs = []
for output in outputs:
if output == '@ffplay':
self.outputs.append(FfplayOutput())
continue
# Default to FileOutput
self.outputs.append(FileOutput(output, codec))
# Create an HTTP server for the UI
self.ui_server = UiServer(speaker=self, port=ui_port)
def sdp_records(self) -> Dict[int, List[ServiceAttribute]]:
service_record_handle = 0x00010001
return {
service_record_handle: make_audio_sink_service_sdp_records(
service_record_handle
)
}
def codec_capabilities(self) -> MediaCodecCapabilities:
if self.codec == 'aac':
return self.aac_codec_capabilities()
if self.codec == 'sbc':
return self.sbc_codec_capabilities()
raise RuntimeError('unsupported codec')
def aac_codec_capabilities(self) -> MediaCodecCapabilities:
return MediaCodecCapabilities(
media_type=AVDTP_AUDIO_MEDIA_TYPE,
media_codec_type=A2DP_MPEG_2_4_AAC_CODEC_TYPE,
media_codec_information=AacMediaCodecInformation.from_lists(
object_types=[MPEG_2_AAC_LC_OBJECT_TYPE],
sampling_frequencies=[48000, 44100],
channels=[1, 2],
vbr=1,
bitrate=256000,
),
)
def sbc_codec_capabilities(self) -> MediaCodecCapabilities:
return MediaCodecCapabilities(
media_type=AVDTP_AUDIO_MEDIA_TYPE,
media_codec_type=A2DP_SBC_CODEC_TYPE,
media_codec_information=SbcMediaCodecInformation.from_lists(
sampling_frequencies=[48000, 44100, 32000, 16000],
channel_modes=[
SBC_MONO_CHANNEL_MODE,
SBC_DUAL_CHANNEL_MODE,
SBC_STEREO_CHANNEL_MODE,
SBC_JOINT_STEREO_CHANNEL_MODE,
],
block_lengths=[4, 8, 12, 16],
subbands=[4, 8],
allocation_methods=[
SBC_LOUDNESS_ALLOCATION_METHOD,
SBC_SNR_ALLOCATION_METHOD,
],
minimum_bitpool_value=2,
maximum_bitpool_value=53,
),
)
async def dispatch_to_outputs(self, function):
for output in self.outputs:
await function(output)
def on_bluetooth_connection(self, connection):
print(f'Connection: {connection}')
self.connection = connection
connection.on('disconnection', self.on_bluetooth_disconnection)
AsyncRunner.spawn(
self.dispatch_to_outputs(lambda output: output.on_connection(connection))
)
def on_bluetooth_disconnection(self, reason):
print(f'Disconnection ({reason})')
self.connection = None
AsyncRunner.spawn(self.advertise())
AsyncRunner.spawn(
self.dispatch_to_outputs(lambda output: output.on_disconnection(reason))
)
def on_avdtp_connection(self, protocol):
print('Audio Stream Open')
# Add a sink endpoint to the server
sink = protocol.add_sink(self.codec_capabilities())
sink.on('start', self.on_sink_start)
sink.on('stop', self.on_sink_stop)
sink.on('suspend', self.on_sink_suspend)
sink.on('configuration', lambda: self.on_sink_configuration(sink.configuration))
sink.on('rtp_packet', self.on_rtp_packet)
sink.on('rtp_channel_open', self.on_rtp_channel_open)
sink.on('rtp_channel_close', self.on_rtp_channel_close)
# Listen for close events
protocol.on('close', self.on_avdtp_close)
# Discover all endpoints on the remote device is requested
if self.discover:
AsyncRunner.spawn(self.discover_remote_endpoints(protocol))
def on_avdtp_close(self):
print("Audio Stream Closed")
def on_sink_start(self):
print("Sink Started\u001b[0K")
self.stream_state = self.StreamState.STARTED
AsyncRunner.spawn(self.dispatch_to_outputs(lambda output: output.start()))
def on_sink_stop(self):
print("Sink Stopped\u001b[0K")
self.stream_state = self.StreamState.STOPPED
AsyncRunner.spawn(self.dispatch_to_outputs(lambda output: output.stop()))
def on_sink_suspend(self):
print("Sink Suspended\u001b[0K")
self.stream_state = self.StreamState.SUSPENDED
AsyncRunner.spawn(self.dispatch_to_outputs(lambda output: output.suspend()))
def on_sink_configuration(self, config):
print("Sink Configuration:")
print('\n'.join([" " + str(capability) for capability in config]))
def on_rtp_channel_open(self):
print("RTP Channel Open")
def on_rtp_channel_close(self):
print("RTP Channel Closed")
self.stream_state = self.StreamState.IDLE
def on_rtp_packet(self, packet):
self.packets_received += 1
self.bytes_received += len(packet.payload)
print(
f'[{self.bytes_received} bytes in {self.packets_received} packets] {packet}',
end='\r',
)
for output in self.outputs:
output.on_rtp_packet(packet)
async def advertise(self):
await self.device.set_discoverable(True)
await self.device.set_connectable(True)
async def connect(self, address):
# Connect to the source
print(f'=== Connecting to {address}...')
connection = await self.device.connect(address, transport=BT_BR_EDR_TRANSPORT)
print(f'=== Connected to {connection.peer_address}')
# Request authentication
print('*** Authenticating...')
await connection.authenticate()
print('*** Authenticated')
# Enable encryption
print('*** Enabling encryption...')
await connection.encrypt()
print('*** Encryption on')
protocol = await Protocol.connect(connection)
self.listener.set_server(connection, protocol)
self.on_avdtp_connection(protocol)
async def discover_remote_endpoints(self, protocol):
endpoints = await protocol.discover_remote_endpoints()
print(f'@@@ Found {len(endpoints)} endpoints')
for endpoint in endpoints:
print('@@@', endpoint)
async def run(self, connect_address):
await self.ui_server.start_http()
self.outputs.append(
WebSocketOutput(
self.codec, self.ui_server.send_audio, self.ui_server.send_message
)
)
async with await open_transport(self.transport) as (hci_source, hci_sink):
# Create a device
device_config = DeviceConfiguration()
if self.device_config:
device_config.load_from_file(self.device_config)
else:
device_config.name = "Bumble Speaker"
device_config.class_of_device = 0x240414
device_config.keystore = "JsonKeyStore"
device_config.classic_enabled = True
device_config.le_enabled = False
self.device = Device.from_config_with_hci(
device_config, hci_source, hci_sink
)
# Setup the SDP to expose the sink service
self.device.sdp_service_records = self.sdp_records()
# Don't require MITM when pairing.
self.device.pairing_config_factory = lambda connection: PairingConfig(
mitm=False
)
# Start the controller
await self.device.power_on()
# Print some of the config/properties
print("Speaker Name:", color(device_config.name, 'yellow'))
print(
"Speaker Bluetooth Address:",
color(
self.device.public_address.to_string(with_type_qualifier=False),
'yellow',
),
)
# Listen for Bluetooth connections
self.device.on('connection', self.on_bluetooth_connection)
# Create a listener to wait for AVDTP connections
self.listener = Listener(Listener.create_registrar(self.device))
self.listener.on('connection', self.on_avdtp_connection)
print(f'Speaker ready to play, codec={color(self.codec, "cyan")}')
if connect_address:
# Connect to the source
try:
await self.connect(connect_address)
except CommandTimeoutError:
print(color("Connection timed out", "red"))
return
else:
# Start being discoverable and connectable
print("Waiting for connection...")
await self.advertise()
await hci_source.wait_for_termination()
for output in self.outputs:
await output.stop()
# -----------------------------------------------------------------------------
@click.group()
@click.pass_context
def speaker_cli(ctx, device_config):
ctx.ensure_object(dict)
ctx.obj['device_config'] = device_config
@click.command()
@click.option(
'--codec', type=click.Choice(['sbc', 'aac']), default='aac', show_default=True
)
@click.option(
'--discover', is_flag=True, help='Discover remote endpoints once connected'
)
@click.option(
'--output',
multiple=True,
metavar='NAME',
help=(
'Send audio to this named output '
'(may be used more than once for multiple outputs)'
),
)
@click.option(
'--ui-port',
'ui_port',
metavar='HTTP_PORT',
default=DEFAULT_UI_PORT,
show_default=True,
help='HTTP port for the UI server',
)
@click.option(
'--connect',
'connect_address',
metavar='ADDRESS_OR_NAME',
help='Address or name to connect to',
)
@click.option('--device-config', metavar='FILENAME', help='Device configuration file')
@click.argument('transport')
def speaker(
transport, codec, connect_address, discover, output, ui_port, device_config
):
"""Run the speaker."""
# ffplay only works with AAC for now
if codec != 'aac' and '@ffplay' in output:
print(
color(
f'{codec} not supported with @ffplay output, '
'@ffplay output will be skipped',
'yellow',
)
)
output = list(filter(lambda x: x != '@ffplay', output))
if '@ffplay' in output:
# Check if ffplay is installed
try:
subprocess.run(['ffplay', '-version'], capture_output=True, check=True)
except FileNotFoundError:
print(
color('ffplay not installed, @ffplay output will be disabled', 'yellow')
)
output = list(filter(lambda x: x != '@ffplay', output))
asyncio.run(
Speaker(device_config, transport, codec, discover, output, ui_port).run(
connect_address
)
)
# -----------------------------------------------------------------------------
def main():
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'WARNING').upper())
speaker()
# -----------------------------------------------------------------------------
if __name__ == "__main__":
main() # pylint: disable=no-value-for-parameter

View File

@@ -22,40 +22,58 @@ import click
from bumble.device import Device
from bumble.keys import JsonKeyStore
from bumble.transport import open_transport
# -----------------------------------------------------------------------------
async def unbond_with_keystore(keystore, address):
if address is None:
return await keystore.print()
try:
await keystore.delete(address)
except KeyError:
print('!!! pairing not found')
# -----------------------------------------------------------------------------
async def unbond(keystore_file, device_config, address):
# Create a device to manage the host
device = Device.from_config_file(device_config)
# Get all entries in the keystore
async def unbond(keystore_file, device_config, hci_transport, address):
# With a keystore file, we can instantiate the keystore directly
if keystore_file:
keystore = JsonKeyStore(None, keystore_file)
else:
keystore = device.keystore
return await unbond_with_keystore(JsonKeyStore(None, keystore_file), address)
if keystore is None:
print('no keystore')
return
# Without a keystore file, we need to obtain the keystore from the device
async with await open_transport(hci_transport) as (hci_source, hci_sink):
# Create a device to manage the host
device = Device.from_config_file_with_hci(device_config, hci_source, hci_sink)
if address is None:
await keystore.print()
else:
try:
await keystore.delete(address)
except KeyError:
print('!!! pairing not found')
# Power-on the device to ensure we have a key store
await device.power_on()
return await unbond_with_keystore(device.keystore, address)
# -----------------------------------------------------------------------------
@click.command()
@click.option('--keystore-file', help='File in which to store the pairing keys')
@click.argument('device-config')
@click.option('--keystore-file', help='File in which the pairing keys are stored')
@click.option('--hci-transport', help='HCI transport for the controller')
@click.argument('device-config', required=False)
@click.argument('address', required=False)
def main(keystore_file, device_config, address):
def main(keystore_file, hci_transport, device_config, address):
"""
Remove pairing keys for a device, given its address.
If no keystore file is specified, the --hci-transport option must be used to
connect to a controller, so that the keystore for that controller can be
instantiated.
If no address is passed, the existing pairing keys for all addresses are printed.
"""
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
asyncio.run(unbond(keystore_file, device_config, address))
if not keystore_file and not hci_transport:
print('either --keystore-file or --hci-transport must be specified.')
return
asyncio.run(unbond(keystore_file, device_config, hci_transport, address))
# -----------------------------------------------------------------------------

View File

@@ -432,6 +432,7 @@ class AacMediaCodecInformation(
cls.SAMPLING_FREQUENCY_BITS[x] for x in sampling_frequencies
),
channels=sum(cls.CHANNELS_BITS[x] for x in channels),
rfa=0,
vbr=vbr,
bitrate=bitrate,
)

View File

@@ -1207,7 +1207,7 @@ class DelayReport_Reject(Simple_Reject):
# -----------------------------------------------------------------------------
class Protocol:
class Protocol(EventEmitter):
SINGLE_PACKET = 0
START_PACKET = 1
CONTINUE_PACKET = 2
@@ -1234,6 +1234,7 @@ class Protocol:
return protocol
def __init__(self, l2cap_channel, version=(1, 3)):
super().__init__()
self.l2cap_channel = l2cap_channel
self.version = version
self.rtx_sig_timer = AVDTP_DEFAULT_RTX_SIG_TIMER
@@ -1250,6 +1251,7 @@ class Protocol:
# Register to receive PDUs from the channel
l2cap_channel.sink = self.on_pdu
l2cap_channel.on('open', self.on_l2cap_channel_open)
l2cap_channel.on('close', self.on_l2cap_channel_close)
def get_local_endpoint_by_seid(self, seid):
if 0 < seid <= len(self.local_endpoints):
@@ -1392,11 +1394,18 @@ class Protocol:
def on_l2cap_connection(self, channel):
# Forward the channel to the endpoint that's expecting it
if self.channel_acceptor:
self.channel_acceptor.on_l2cap_connection(channel)
if self.channel_acceptor is None:
logger.warning(color('!!! l2cap connection with no acceptor', 'red'))
return
self.channel_acceptor.on_l2cap_connection(channel)
def on_l2cap_channel_open(self):
logger.debug(color('<<< L2CAP channel open', 'magenta'))
self.emit('open')
def on_l2cap_channel_close(self):
logger.debug(color('<<< L2CAP channel close', 'magenta'))
self.emit('close')
def send_message(self, transaction_label, message):
logger.debug(
@@ -1651,6 +1660,10 @@ class Listener(EventEmitter):
def set_server(self, connection, server):
self.servers[connection.handle] = server
def remove_server(self, connection):
if connection.handle in self.servers:
del self.servers[connection.handle]
def __init__(self, registrar, version=(1, 3)):
super().__init__()
self.version = version
@@ -1669,11 +1682,17 @@ class Listener(EventEmitter):
else:
# This is a new command/response channel
def on_channel_open():
logger.debug('setting up new Protocol for the connection')
server = Protocol(channel, self.version)
self.set_server(channel.connection, server)
self.emit('connection', server)
def on_channel_close():
logger.debug('removing Protocol for the connection')
self.remove_server(channel.connection)
channel.on('open', on_channel_open)
channel.on('close', on_channel_close)
# -----------------------------------------------------------------------------
@@ -1967,11 +1986,12 @@ class DiscoveredStreamEndPoint(StreamEndPoint, StreamEndPointProxy):
# -----------------------------------------------------------------------------
class LocalStreamEndPoint(StreamEndPoint):
class LocalStreamEndPoint(StreamEndPoint, EventEmitter):
def __init__(
self, protocol, seid, media_type, tsep, capabilities, configuration=None
):
super().__init__(seid, media_type, tsep, 0, capabilities)
StreamEndPoint.__init__(self, seid, media_type, tsep, 0, capabilities)
EventEmitter.__init__(self)
self.protocol = protocol
self.configuration = configuration if configuration is not None else []
self.stream = None
@@ -1988,40 +2008,47 @@ class LocalStreamEndPoint(StreamEndPoint):
def on_reconfigure_command(self, command):
pass
def on_set_configuration_command(self, configuration):
logger.debug(
'<<< received configuration: '
f'{",".join([str(capability) for capability in configuration])}'
)
self.configuration = configuration
self.emit('configuration')
def on_get_configuration_command(self):
return Get_Configuration_Response(self.configuration)
def on_open_command(self):
pass
self.emit('open')
def on_start_command(self):
pass
self.emit('start')
def on_suspend_command(self):
pass
self.emit('suspend')
def on_close_command(self):
pass
self.emit('close')
def on_abort_command(self):
pass
self.emit('abort')
def on_rtp_channel_open(self):
pass
self.emit('rtp_channel_open')
def on_rtp_channel_close(self):
pass
self.emit('rtp_channel_close')
# -----------------------------------------------------------------------------
class LocalSource(LocalStreamEndPoint, EventEmitter):
class LocalSource(LocalStreamEndPoint):
def __init__(self, protocol, seid, codec_capabilities, packet_pump):
capabilities = [
ServiceCapabilities(AVDTP_MEDIA_TRANSPORT_SERVICE_CATEGORY),
codec_capabilities,
]
LocalStreamEndPoint.__init__(
self,
super().__init__(
protocol,
seid,
codec_capabilities.media_type,
@@ -2029,14 +2056,13 @@ class LocalSource(LocalStreamEndPoint, EventEmitter):
capabilities,
capabilities,
)
EventEmitter.__init__(self)
self.packet_pump = packet_pump
async def start(self):
if self.packet_pump:
return await self.packet_pump.start(self.stream.rtp_channel)
self.emit('start', self.stream.rtp_channel)
self.emit('start')
async def stop(self):
if self.packet_pump:
@@ -2044,11 +2070,6 @@ class LocalSource(LocalStreamEndPoint, EventEmitter):
self.emit('stop')
def on_set_configuration_command(self, configuration):
# For now, blindly accept the configuration
logger.debug(f'<<< received source configuration: {configuration}')
self.configuration = configuration
def on_start_command(self):
asyncio.create_task(self.start())
@@ -2057,30 +2078,28 @@ class LocalSource(LocalStreamEndPoint, EventEmitter):
# -----------------------------------------------------------------------------
class LocalSink(LocalStreamEndPoint, EventEmitter):
class LocalSink(LocalStreamEndPoint):
def __init__(self, protocol, seid, codec_capabilities):
capabilities = [
ServiceCapabilities(AVDTP_MEDIA_TRANSPORT_SERVICE_CATEGORY),
codec_capabilities,
]
LocalStreamEndPoint.__init__(
self,
super().__init__(
protocol,
seid,
codec_capabilities.media_type,
AVDTP_TSEP_SNK,
capabilities,
)
EventEmitter.__init__(self)
def on_set_configuration_command(self, configuration):
# For now, blindly accept the configuration
logger.debug(f'<<< received sink configuration: {configuration}')
self.configuration = configuration
def on_rtp_channel_open(self):
logger.debug(color('<<< RTP channel open', 'magenta'))
self.stream.rtp_channel.sink = self.on_avdtp_packet
super().on_rtp_channel_open()
def on_rtp_channel_close(self):
logger.debug(color('<<< RTP channel close', 'magenta'))
super().on_rtp_channel_close()
def on_avdtp_packet(self, packet):
rtp_packet = MediaPacket.from_bytes(packet)

381
bumble/codecs.py Normal file
View File

@@ -0,0 +1,381 @@
# Copyright 2023 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
from __future__ import annotations
from dataclasses import dataclass
# -----------------------------------------------------------------------------
class BitReader:
"""Simple but not optimized bit stream reader."""
data: bytes
bytes_position: int
bit_position: int
cache: int
bits_cached: int
def __init__(self, data: bytes):
self.data = data
self.byte_position = 0
self.bit_position = 0
self.cache = 0
self.bits_cached = 0
def read(self, bits: int) -> int:
""" "Read up to 32 bits."""
if bits > 32:
raise ValueError('maximum read size is 32')
if self.bits_cached >= bits:
# We have enough bits.
self.bits_cached -= bits
self.bit_position += bits
return (self.cache >> self.bits_cached) & ((1 << bits) - 1)
# Read more cache, up to 32 bits
feed_bytes = self.data[self.byte_position : self.byte_position + 4]
feed_size = len(feed_bytes)
feed_int = int.from_bytes(feed_bytes, byteorder='big')
if 8 * feed_size + self.bits_cached < bits:
raise ValueError('trying to read past the data')
self.byte_position += feed_size
# Combine the new cache and the old cache
cache = self.cache & ((1 << self.bits_cached) - 1)
new_bits = bits - self.bits_cached
self.bits_cached = 8 * feed_size - new_bits
result = (feed_int >> self.bits_cached) | (cache << new_bits)
self.cache = feed_int
self.bit_position += bits
return result
def read_bytes(self, count: int):
if self.bit_position + 8 * count > 8 * len(self.data):
raise ValueError('not enough data')
if self.bit_position % 8:
# Not byte aligned
result = bytearray(count)
for i in range(count):
result[i] = self.read(8)
return bytes(result)
# Byte aligned
self.byte_position = self.bit_position // 8
self.bits_cached = 0
self.cache = 0
offset = self.bit_position // 8
self.bit_position += 8 * count
return self.data[offset : offset + count]
def bits_left(self) -> int:
return (8 * len(self.data)) - self.bit_position
def skip(self, bits: int) -> None:
# Slow, but simple...
while bits:
if bits > 32:
self.read(32)
bits -= 32
else:
self.read(bits)
break
# -----------------------------------------------------------------------------
class AacAudioRtpPacket:
"""AAC payload encapsulated in an RTP packet payload"""
@staticmethod
def latm_value(reader: BitReader) -> int:
bytes_for_value = reader.read(2)
value = 0
for _ in range(bytes_for_value + 1):
value = value * 256 + reader.read(8)
return value
@staticmethod
def program_config_element(reader: BitReader):
raise ValueError('program_config_element not supported')
@dataclass
class GASpecificConfig:
def __init__(
self, reader: BitReader, channel_configuration: int, audio_object_type: int
) -> None:
# GASpecificConfig - ISO/EIC 14496-3 Table 4.1
frame_length_flag = reader.read(1)
depends_on_core_coder = reader.read(1)
if depends_on_core_coder:
self.core_coder_delay = reader.read(14)
extension_flag = reader.read(1)
if not channel_configuration:
AacAudioRtpPacket.program_config_element(reader)
if audio_object_type in (6, 20):
self.layer_nr = reader.read(3)
if extension_flag:
if audio_object_type == 22:
num_of_sub_frame = reader.read(5)
layer_length = reader.read(11)
if audio_object_type in (17, 19, 20, 23):
aac_section_data_resilience_flags = reader.read(1)
aac_scale_factor_data_resilience_flags = reader.read(1)
aac_spectral_data_resilience_flags = reader.read(1)
extension_flag_3 = reader.read(1)
if extension_flag_3 == 1:
raise ValueError('extensionFlag3 == 1 not supported')
@staticmethod
def audio_object_type(reader: BitReader):
# GetAudioObjectType - ISO/EIC 14496-3 Table 1.16
audio_object_type = reader.read(5)
if audio_object_type == 31:
audio_object_type = 32 + reader.read(6)
return audio_object_type
@dataclass
class AudioSpecificConfig:
audio_object_type: int
sampling_frequency_index: int
sampling_frequency: int
channel_configuration: int
sbr_present_flag: int
ps_present_flag: int
extension_audio_object_type: int
extension_sampling_frequency_index: int
extension_sampling_frequency: int
extension_channel_configuration: int
SAMPLING_FREQUENCIES = [
96000,
88200,
64000,
48000,
44100,
32000,
24000,
22050,
16000,
12000,
11025,
8000,
7350,
]
def __init__(self, reader: BitReader) -> None:
# AudioSpecificConfig - ISO/EIC 14496-3 Table 1.15
self.audio_object_type = AacAudioRtpPacket.audio_object_type(reader)
self.sampling_frequency_index = reader.read(4)
if self.sampling_frequency_index == 0xF:
self.sampling_frequency = reader.read(24)
else:
self.sampling_frequency = self.SAMPLING_FREQUENCIES[
self.sampling_frequency_index
]
self.channel_configuration = reader.read(4)
self.sbr_present_flag = -1
self.ps_present_flag = -1
if self.audio_object_type in (5, 29):
self.extension_audio_object_type = 5
self.sbc_present_flag = 1
if self.audio_object_type == 29:
self.ps_present_flag = 1
self.extension_sampling_frequency_index = reader.read(4)
if self.extension_sampling_frequency_index == 0xF:
self.extension_sampling_frequency = reader.read(24)
else:
self.extension_sampling_frequency = self.SAMPLING_FREQUENCIES[
self.extension_sampling_frequency_index
]
self.audio_object_type = AacAudioRtpPacket.audio_object_type(reader)
if self.audio_object_type == 22:
self.extension_channel_configuration = reader.read(4)
else:
self.extension_audio_object_type = 0
if self.audio_object_type in (1, 2, 3, 4, 6, 7, 17, 19, 20, 21, 22, 23):
ga_specific_config = AacAudioRtpPacket.GASpecificConfig(
reader, self.channel_configuration, self.audio_object_type
)
else:
raise ValueError(
f'audioObjectType {self.audio_object_type} not supported'
)
# if self.extension_audio_object_type != 5 and bits_to_decode >= 16:
# sync_extension_type = reader.read(11)
# if sync_extension_type == 0x2B7:
# self.extension_audio_object_type = AacAudioRtpPacket.audio_object_type(reader)
# if self.extension_audio_object_type == 5:
# self.sbr_present_flag = reader.read(1)
# if self.sbr_present_flag:
# self.extension_sampling_frequency_index = reader.read(4)
# if self.extension_sampling_frequency_index == 0xF:
# self.extension_sampling_frequency = reader.read(24)
# else:
# self.extension_sampling_frequency = self.SAMPLING_FREQUENCIES[self.extension_sampling_frequency_index]
# if bits_to_decode >= 12:
# sync_extension_type = reader.read(11)
# if sync_extension_type == 0x548:
# self.ps_present_flag = reader.read(1)
# elif self.extension_audio_object_type == 22:
# self.sbr_present_flag = reader.read(1)
# if self.sbr_present_flag:
# self.extension_sampling_frequency_index = reader.read(4)
# if self.extension_sampling_frequency_index == 0xF:
# self.extension_sampling_frequency = reader.read(24)
# else:
# self.extension_sampling_frequency = self.SAMPLING_FREQUENCIES[self.extension_sampling_frequency_index]
# self.extension_channel_configuration = reader.read(4)
@dataclass
class StreamMuxConfig:
other_data_present: int
other_data_len_bits: int
audio_specific_config: AacAudioRtpPacket.AudioSpecificConfig
def __init__(self, reader: BitReader) -> None:
# StreamMuxConfig - ISO/EIC 14496-3 Table 1.42
audio_mux_version = reader.read(1)
if audio_mux_version == 1:
audio_mux_version_a = reader.read(1)
else:
audio_mux_version_a = 0
if audio_mux_version_a != 0:
raise ValueError('audioMuxVersionA != 0 not supported')
if audio_mux_version == 1:
tara_buffer_fullness = AacAudioRtpPacket.latm_value(reader)
stream_cnt = 0
all_streams_same_time_framing = reader.read(1)
num_sub_frames = reader.read(6)
num_program = reader.read(4)
if num_program != 0:
raise ValueError('num_program != 0 not supported')
num_layer = reader.read(3)
if num_layer != 0:
raise ValueError('num_layer != 0 not supported')
if audio_mux_version == 0:
self.audio_specific_config = AacAudioRtpPacket.AudioSpecificConfig(
reader
)
else:
asc_len = AacAudioRtpPacket.latm_value(reader)
marker = reader.bit_position
self.audio_specific_config = AacAudioRtpPacket.AudioSpecificConfig(
reader
)
audio_specific_config_len = reader.bit_position - marker
if asc_len < audio_specific_config_len:
raise ValueError('audio_specific_config_len > asc_len')
asc_len -= audio_specific_config_len
reader.skip(asc_len)
frame_length_type = reader.read(3)
if frame_length_type == 0:
latm_buffer_fullness = reader.read(8)
elif frame_length_type == 1:
frame_length = reader.read(9)
else:
raise ValueError(f'frame_length_type {frame_length_type} not supported')
self.other_data_present = reader.read(1)
if self.other_data_present:
if audio_mux_version == 1:
self.other_data_len_bits = AacAudioRtpPacket.latm_value(reader)
else:
self.other_data_len_bits = 0
while True:
self.other_data_len_bits *= 256
other_data_len_esc = reader.read(1)
self.other_data_len_bits += reader.read(8)
if other_data_len_esc == 0:
break
crc_check_present = reader.read(1)
if crc_check_present:
crc_checksum = reader.read(8)
@dataclass
class AudioMuxElement:
payload: bytes
stream_mux_config: AacAudioRtpPacket.StreamMuxConfig
def __init__(self, reader: BitReader, mux_config_present: int):
if mux_config_present == 0:
raise ValueError('muxConfigPresent == 0 not supported')
# AudioMuxElement - ISO/EIC 14496-3 Table 1.41
use_same_stream_mux = reader.read(1)
if use_same_stream_mux:
raise ValueError('useSameStreamMux == 1 not supported')
self.stream_mux_config = AacAudioRtpPacket.StreamMuxConfig(reader)
# We only support:
# allStreamsSameTimeFraming == 1
# audioMuxVersionA == 0,
# numProgram == 0
# numSubFrames == 0
# numLayer == 0
mux_slot_length_bytes = 0
while True:
tmp = reader.read(8)
mux_slot_length_bytes += tmp
if tmp != 255:
break
self.payload = reader.read_bytes(mux_slot_length_bytes)
if self.stream_mux_config.other_data_present:
reader.skip(self.stream_mux_config.other_data_len_bits)
# ByteAlign
while reader.bit_position % 8:
reader.read(1)
def __init__(self, data: bytes) -> None:
# Parse the bit stream
reader = BitReader(data)
self.audio_mux_element = self.AudioMuxElement(reader, mux_config_present=1)
def to_adts(self):
# pylint: disable=line-too-long
sampling_frequency_index = (
self.audio_mux_element.stream_mux_config.audio_specific_config.sampling_frequency_index
)
channel_configuration = (
self.audio_mux_element.stream_mux_config.audio_specific_config.channel_configuration
)
frame_size = len(self.audio_mux_element.payload)
return (
bytes(
[
0xFF,
0xF1, # 0xF9 (MPEG2)
0x40
| (sampling_frequency_index << 2)
| (channel_configuration >> 2),
((channel_configuration & 0x3) << 6) | ((frame_size + 7) >> 11),
((frame_size + 7) >> 3) & 0xFF,
(((frame_size + 7) << 5) & 0xFF) | 0x1F,
0xFC,
]
)
+ self.audio_mux_element.payload
)

View File

@@ -654,7 +654,7 @@ class Controller:
def on_hci_create_connection_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.1.5 Create Connection command
See Bluetooth spec Vol 4, Part E - 7.1.5 Create Connection command
'''
if self.link is None:
@@ -685,7 +685,7 @@ class Controller:
def on_hci_disconnect_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.1.6 Disconnect Command
See Bluetooth spec Vol 4, Part E - 7.1.6 Disconnect Command
'''
# First, say that the disconnection is pending
self.send_hci_packet(
@@ -719,7 +719,7 @@ class Controller:
def on_hci_accept_connection_request_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.1.8 Accept Connection Request command
See Bluetooth spec Vol 4, Part E - 7.1.8 Accept Connection Request command
'''
if self.link is None:
@@ -735,7 +735,7 @@ class Controller:
def on_hci_switch_role_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.2.8 Switch Role command
See Bluetooth spec Vol 4, Part E - 7.2.8 Switch Role command
'''
if self.link is None:
@@ -751,21 +751,21 @@ class Controller:
def on_hci_set_event_mask_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.1 Set Event Mask Command
See Bluetooth spec Vol 4, Part E - 7.3.1 Set Event Mask Command
'''
self.event_mask = command.event_mask
return bytes([HCI_SUCCESS])
def on_hci_reset_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.2 Reset Command
See Bluetooth spec Vol 4, Part E - 7.3.2 Reset Command
'''
# TODO: cleanup what needs to be reset
return bytes([HCI_SUCCESS])
def on_hci_write_local_name_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.11 Write Local Name Command
See Bluetooth spec Vol 4, Part E - 7.3.11 Write Local Name Command
'''
local_name = command.local_name
if len(local_name):
@@ -780,7 +780,7 @@ class Controller:
def on_hci_read_local_name_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.12 Read Local Name Command
See Bluetooth spec Vol 4, Part E - 7.3.12 Read Local Name Command
'''
local_name = bytes(self.local_name, 'utf-8')[:248]
if len(local_name) < 248:
@@ -790,19 +790,19 @@ class Controller:
def on_hci_read_class_of_device_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.25 Read Class of Device Command
See Bluetooth spec Vol 4, Part E - 7.3.25 Read Class of Device Command
'''
return bytes([HCI_SUCCESS, 0, 0, 0])
def on_hci_write_class_of_device_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.26 Write Class of Device Command
See Bluetooth spec Vol 4, Part E - 7.3.26 Write Class of Device Command
'''
return bytes([HCI_SUCCESS])
def on_hci_read_synchronous_flow_control_enable_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.36 Read Synchronous Flow Control Enable
See Bluetooth spec Vol 4, Part E - 7.3.36 Read Synchronous Flow Control Enable
Command
'''
if self.sync_flow_control:
@@ -813,7 +813,7 @@ class Controller:
def on_hci_write_synchronous_flow_control_enable_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.37 Write Synchronous Flow Control Enable
See Bluetooth spec Vol 4, Part E - 7.3.37 Write Synchronous Flow Control Enable
Command
'''
ret = HCI_SUCCESS
@@ -825,41 +825,59 @@ class Controller:
ret = HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR
return bytes([ret])
def on_hci_set_controller_to_host_flow_control_command(self, _command):
'''
See Bluetooth spec Vol 4, Part E - 7.3.38 Set Controller To Host Flow Control
Command
'''
# For now we just accept the command but ignore the values.
# TODO: respect the passed in values.
return bytes([HCI_SUCCESS])
def on_hci_host_buffer_size_command(self, _command):
'''
See Bluetooth spec Vol 4, Part E - 7.3.39 Host Buffer Size Command
'''
# For now we just accept the command but ignore the values.
# TODO: respect the passed in values.
return bytes([HCI_SUCCESS])
def on_hci_write_extended_inquiry_response_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.59 Write Simple Pairing Mode Command
See Bluetooth spec Vol 4, Part E - 7.3.56 Write Extended Inquiry Response
Command
'''
return bytes([HCI_SUCCESS])
def on_hci_write_simple_pairing_mode_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.59 Write Simple Pairing Mode Command
See Bluetooth spec Vol 4, Part E - 7.3.59 Write Simple Pairing Mode Command
'''
return bytes([HCI_SUCCESS])
def on_hci_set_event_mask_page_2_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.69 Set Event Mask Page 2 Command
See Bluetooth spec Vol 4, Part E - 7.3.69 Set Event Mask Page 2 Command
'''
self.event_mask_page_2 = command.event_mask_page_2
return bytes([HCI_SUCCESS])
def on_hci_read_le_host_support_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.78 Write LE Host Support Command
See Bluetooth spec Vol 4, Part E - 7.3.78 Write LE Host Support Command
'''
return bytes([HCI_SUCCESS, 1, 0])
def on_hci_write_le_host_support_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.79 Write LE Host Support Command
See Bluetooth spec Vol 4, Part E - 7.3.79 Write LE Host Support Command
'''
# TODO / Just ignore for now
return bytes([HCI_SUCCESS])
def on_hci_write_authenticated_payload_timeout_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.3.94 Write Authenticated Payload Timeout
See Bluetooth spec Vol 4, Part E - 7.3.94 Write Authenticated Payload Timeout
Command
'''
# TODO
@@ -867,7 +885,7 @@ class Controller:
def on_hci_read_local_version_information_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.4.1 Read Local Version Information Command
See Bluetooth spec Vol 4, Part E - 7.4.1 Read Local Version Information Command
'''
return struct.pack(
'<BBHBHH',
@@ -881,19 +899,19 @@ class Controller:
def on_hci_read_local_supported_commands_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.4.2 Read Local Supported Commands Command
See Bluetooth spec Vol 4, Part E - 7.4.2 Read Local Supported Commands Command
'''
return bytes([HCI_SUCCESS]) + self.supported_commands
def on_hci_read_local_supported_features_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.4.3 Read Local Supported Features Command
See Bluetooth spec Vol 4, Part E - 7.4.3 Read Local Supported Features Command
'''
return bytes([HCI_SUCCESS]) + self.lmp_features
def on_hci_read_bd_addr_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.4.6 Read BD_ADDR Command
See Bluetooth spec Vol 4, Part E - 7.4.6 Read BD_ADDR Command
'''
bd_addr = (
self._public_address.to_bytes()
@@ -904,14 +922,14 @@ class Controller:
def on_hci_le_set_event_mask_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.1 LE Set Event Mask Command
See Bluetooth spec Vol 4, Part E - 7.8.1 LE Set Event Mask Command
'''
self.le_event_mask = command.le_event_mask
return bytes([HCI_SUCCESS])
def on_hci_le_read_buffer_size_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.2 LE Read Buffer Size Command
See Bluetooth spec Vol 4, Part E - 7.8.2 LE Read Buffer Size Command
'''
return struct.pack(
'<BHB',
@@ -922,49 +940,49 @@ class Controller:
def on_hci_le_read_local_supported_features_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.3 LE Read Local Supported Features
See Bluetooth spec Vol 4, Part E - 7.8.3 LE Read Local Supported Features
Command
'''
return bytes([HCI_SUCCESS]) + self.le_features
def on_hci_le_set_random_address_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.4 LE Set Random Address Command
See Bluetooth spec Vol 4, Part E - 7.8.4 LE Set Random Address Command
'''
self.random_address = command.random_address
return bytes([HCI_SUCCESS])
def on_hci_le_set_advertising_parameters_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.5 LE Set Advertising Parameters Command
See Bluetooth spec Vol 4, Part E - 7.8.5 LE Set Advertising Parameters Command
'''
self.advertising_parameters = command
return bytes([HCI_SUCCESS])
def on_hci_le_read_advertising_physical_channel_tx_power_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.6 LE Read Advertising Physical Channel
See Bluetooth spec Vol 4, Part E - 7.8.6 LE Read Advertising Physical Channel
Tx Power Command
'''
return bytes([HCI_SUCCESS, self.advertising_channel_tx_power])
def on_hci_le_set_advertising_data_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.7 LE Set Advertising Data Command
See Bluetooth spec Vol 4, Part E - 7.8.7 LE Set Advertising Data Command
'''
self.advertising_data = command.advertising_data
return bytes([HCI_SUCCESS])
def on_hci_le_set_scan_response_data_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.8 LE Set Scan Response Data Command
See Bluetooth spec Vol 4, Part E - 7.8.8 LE Set Scan Response Data Command
'''
self.le_scan_response_data = command.scan_response_data
return bytes([HCI_SUCCESS])
def on_hci_le_set_advertising_enable_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.9 LE Set Advertising Enable Command
See Bluetooth spec Vol 4, Part E - 7.8.9 LE Set Advertising Enable Command
'''
if command.advertising_enable:
self.start_advertising()
@@ -975,7 +993,7 @@ class Controller:
def on_hci_le_set_scan_parameters_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.10 LE Set Scan Parameters Command
See Bluetooth spec Vol 4, Part E - 7.8.10 LE Set Scan Parameters Command
'''
self.le_scan_type = command.le_scan_type
self.le_scan_interval = command.le_scan_interval
@@ -986,7 +1004,7 @@ class Controller:
def on_hci_le_set_scan_enable_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.11 LE Set Scan Enable Command
See Bluetooth spec Vol 4, Part E - 7.8.11 LE Set Scan Enable Command
'''
self.le_scan_enable = command.le_scan_enable
self.filter_duplicates = command.filter_duplicates
@@ -994,7 +1012,7 @@ class Controller:
def on_hci_le_create_connection_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.12 LE Create Connection Command
See Bluetooth spec Vol 4, Part E - 7.8.12 LE Create Connection Command
'''
if not self.link:
@@ -1027,40 +1045,40 @@ class Controller:
def on_hci_le_create_connection_cancel_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.13 LE Create Connection Cancel Command
See Bluetooth spec Vol 4, Part E - 7.8.13 LE Create Connection Cancel Command
'''
return bytes([HCI_SUCCESS])
def on_hci_le_read_filter_accept_list_size_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.14 LE Read Filter Accept List Size
See Bluetooth spec Vol 4, Part E - 7.8.14 LE Read Filter Accept List Size
Command
'''
return bytes([HCI_SUCCESS, self.filter_accept_list_size])
def on_hci_le_clear_filter_accept_list_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.15 LE Clear Filter Accept List Command
See Bluetooth spec Vol 4, Part E - 7.8.15 LE Clear Filter Accept List Command
'''
return bytes([HCI_SUCCESS])
def on_hci_le_add_device_to_filter_accept_list_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.16 LE Add Device To Filter Accept List
See Bluetooth spec Vol 4, Part E - 7.8.16 LE Add Device To Filter Accept List
Command
'''
return bytes([HCI_SUCCESS])
def on_hci_le_remove_device_from_filter_accept_list_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.17 LE Remove Device From Filter Accept
See Bluetooth spec Vol 4, Part E - 7.8.17 LE Remove Device From Filter Accept
List Command
'''
return bytes([HCI_SUCCESS])
def on_hci_le_read_remote_features_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.21 LE Read Remote Features Command
See Bluetooth spec Vol 4, Part E - 7.8.21 LE Read Remote Features Command
'''
# First, say that the command is pending
@@ -1083,13 +1101,13 @@ class Controller:
def on_hci_le_rand_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.23 LE Rand Command
See Bluetooth spec Vol 4, Part E - 7.8.23 LE Rand Command
'''
return bytes([HCI_SUCCESS]) + struct.pack('Q', random.randint(0, 1 << 64))
def on_hci_le_enable_encryption_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.24 LE Enable Encryption Command
See Bluetooth spec Vol 4, Part E - 7.8.24 LE Enable Encryption Command
'''
# Check the parameters
@@ -1122,13 +1140,13 @@ class Controller:
def on_hci_le_read_supported_states_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.27 LE Read Supported States Command
See Bluetooth spec Vol 4, Part E - 7.8.27 LE Read Supported States Command
'''
return bytes([HCI_SUCCESS]) + self.le_states
def on_hci_le_read_suggested_default_data_length_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.34 LE Read Suggested Default Data Length
See Bluetooth spec Vol 4, Part E - 7.8.34 LE Read Suggested Default Data Length
Command
'''
return struct.pack(
@@ -1140,7 +1158,7 @@ class Controller:
def on_hci_le_write_suggested_default_data_length_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.35 LE Write Suggested Default Data Length
See Bluetooth spec Vol 4, Part E - 7.8.35 LE Write Suggested Default Data Length
Command
'''
self.suggested_max_tx_octets, self.suggested_max_tx_time = struct.unpack(
@@ -1150,33 +1168,33 @@ class Controller:
def on_hci_le_read_local_p_256_public_key_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.36 LE Read P-256 Public Key Command
See Bluetooth spec Vol 4, Part E - 7.8.36 LE Read P-256 Public Key Command
'''
# TODO create key and send HCI_LE_Read_Local_P-256_Public_Key_Complete event
return bytes([HCI_SUCCESS])
def on_hci_le_add_device_to_resolving_list_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.38 LE Add Device To Resolving List
See Bluetooth spec Vol 4, Part E - 7.8.38 LE Add Device To Resolving List
Command
'''
return bytes([HCI_SUCCESS])
def on_hci_le_clear_resolving_list_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.40 LE Clear Resolving List Command
See Bluetooth spec Vol 4, Part E - 7.8.40 LE Clear Resolving List Command
'''
return bytes([HCI_SUCCESS])
def on_hci_le_read_resolving_list_size_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.41 LE Read Resolving List Size Command
See Bluetooth spec Vol 4, Part E - 7.8.41 LE Read Resolving List Size Command
'''
return bytes([HCI_SUCCESS, self.resolving_list_size])
def on_hci_le_set_address_resolution_enable_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.44 LE Set Address Resolution Enable
See Bluetooth spec Vol 4, Part E - 7.8.44 LE Set Address Resolution Enable
Command
'''
ret = HCI_SUCCESS
@@ -1190,7 +1208,7 @@ class Controller:
def on_hci_le_set_resolvable_private_address_timeout_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.45 LE Set Resolvable Private Address
See Bluetooth spec Vol 4, Part E - 7.8.45 LE Set Resolvable Private Address
Timeout Command
'''
self.le_rpa_timeout = command.rpa_timeout
@@ -1198,7 +1216,7 @@ class Controller:
def on_hci_le_read_maximum_data_length_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.46 LE Read Maximum Data Length Command
See Bluetooth spec Vol 4, Part E - 7.8.46 LE Read Maximum Data Length Command
'''
return struct.pack(
'<BHHHH',
@@ -1211,7 +1229,7 @@ class Controller:
def on_hci_le_read_phy_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.47 LE Read PHY Command
See Bluetooth spec Vol 4, Part E - 7.8.47 LE Read PHY Command
'''
return struct.pack(
'<BHBB',
@@ -1223,7 +1241,7 @@ class Controller:
def on_hci_le_set_default_phy_command(self, command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.48 LE Set Default PHY Command
See Bluetooth spec Vol 4, Part E - 7.8.48 LE Set Default PHY Command
'''
self.default_phy = {
'all_phys': command.all_phys,
@@ -1234,6 +1252,6 @@ class Controller:
def on_hci_le_read_transmit_power_command(self, _command):
'''
See Bluetooth spec Vol 2, Part E - 7.8.74 LE Read Transmit Power Command
See Bluetooth spec Vol 4, Part E - 7.8.74 LE Read Transmit Power Command
'''
return struct.pack('<BBB', HCI_SUCCESS, 0, 0)

View File

@@ -17,7 +17,7 @@
# -----------------------------------------------------------------------------
from __future__ import annotations
import struct
from typing import List, Optional, Tuple, Union, cast
from typing import List, Optional, Tuple, Union, cast, Dict
from .company_ids import COMPANY_IDENTIFIERS
@@ -53,7 +53,7 @@ def bit_flags_to_strings(bits, bit_flag_names):
return names
def name_or_number(dictionary, number, width=2):
def name_or_number(dictionary: Dict[int, str], number: int, width: int = 2) -> str:
name = dictionary.get(number)
if name is not None:
return name
@@ -152,7 +152,12 @@ class UUID:
BASE_UUID = bytes.fromhex('00001000800000805F9B34FB')[::-1] # little-endian
UUIDS: List[UUID] = [] # Registry of all instances created
def __init__(self, uuid_str_or_int, name=None):
uuid_bytes: bytes
name: Optional[str]
def __init__(
self, uuid_str_or_int: Union[str, int], name: Optional[str] = None
) -> None:
if isinstance(uuid_str_or_int, int):
self.uuid_bytes = struct.pack('<H', uuid_str_or_int)
else:
@@ -172,7 +177,7 @@ class UUID:
self.uuid_bytes = bytes(reversed(bytes.fromhex(uuid_str)))
self.name = name
def register(self):
def register(self) -> UUID:
# Register this object in the class registry, and update the entry's name if
# it wasn't set already
for uuid in self.UUIDS:
@@ -196,22 +201,22 @@ class UUID:
raise ValueError('only 2, 4 and 16 bytes are allowed')
@classmethod
def from_16_bits(cls, uuid_16, name=None):
def from_16_bits(cls, uuid_16: int, name: Optional[str] = None) -> UUID:
return cls.from_bytes(struct.pack('<H', uuid_16), name)
@classmethod
def from_32_bits(cls, uuid_32, name=None):
def from_32_bits(cls, uuid_32: int, name: Optional[str] = None) -> UUID:
return cls.from_bytes(struct.pack('<I', uuid_32), name)
@classmethod
def parse_uuid(cls, uuid_as_bytes, offset):
def parse_uuid(cls, uuid_as_bytes: bytes, offset: int) -> Tuple[int, UUID]:
return len(uuid_as_bytes), cls.from_bytes(uuid_as_bytes[offset:])
@classmethod
def parse_uuid_2(cls, uuid_as_bytes, offset):
def parse_uuid_2(cls, uuid_as_bytes: bytes, offset: int) -> Tuple[int, UUID]:
return offset + 2, cls.from_bytes(uuid_as_bytes[offset : offset + 2])
def to_bytes(self, force_128=False):
def to_bytes(self, force_128: bool = False) -> bytes:
'''
Serialize UUID in little-endian byte-order
'''
@@ -227,7 +232,7 @@ class UUID:
else:
assert False, "unreachable"
def to_pdu_bytes(self):
def to_pdu_bytes(self) -> bytes:
'''
Convert to bytes for use in an ATT PDU.
According to Vol 3, Part F - 3.2.1 Attribute Type:
@@ -236,11 +241,11 @@ class UUID:
'''
return self.to_bytes(force_128=(len(self.uuid_bytes) == 4))
def to_hex_str(self) -> str:
def to_hex_str(self, separator: str = '') -> str:
if len(self.uuid_bytes) == 2 or len(self.uuid_bytes) == 4:
return bytes(reversed(self.uuid_bytes)).hex().upper()
return ''.join(
return separator.join(
[
bytes(reversed(self.uuid_bytes[12:16])).hex(),
bytes(reversed(self.uuid_bytes[10:12])).hex(),
@@ -250,10 +255,10 @@ class UUID:
]
).upper()
def __bytes__(self):
def __bytes__(self) -> bytes:
return self.to_bytes()
def __eq__(self, other):
def __eq__(self, other: object) -> bool:
if isinstance(other, UUID):
return self.to_bytes(force_128=True) == other.to_bytes(force_128=True)
@@ -262,35 +267,19 @@ class UUID:
return False
def __hash__(self):
def __hash__(self) -> int:
return hash(self.uuid_bytes)
def __str__(self):
def __str__(self) -> str:
result = self.to_hex_str(separator='-')
if len(self.uuid_bytes) == 2:
uuid = struct.unpack('<H', self.uuid_bytes)[0]
result = f'UUID-16:{uuid:04X}'
result = 'UUID-16:' + result
elif len(self.uuid_bytes) == 4:
uuid = struct.unpack('<I', self.uuid_bytes)[0]
result = f'UUID-32:{uuid:08X}'
else:
result = '-'.join(
[
bytes(reversed(self.uuid_bytes[12:16])).hex(),
bytes(reversed(self.uuid_bytes[10:12])).hex(),
bytes(reversed(self.uuid_bytes[8:10])).hex(),
bytes(reversed(self.uuid_bytes[6:8])).hex(),
bytes(reversed(self.uuid_bytes[0:6])).hex(),
]
).upper()
result = 'UUID-32:' + result
if self.name is not None:
return result + f' ({self.name})'
result += f' ({self.name})'
return result
def __repr__(self):
return str(self)
# -----------------------------------------------------------------------------
# Common UUID constants
@@ -573,11 +562,82 @@ class DeviceClass:
PERIPHERAL_HANDHELD_GESTURAL_INPUT_DEVICE_MINOR_DEVICE_CLASS: 'Handheld gestural input device'
}
WEARABLE_UNCATEGORIZED_MINOR_DEVICE_CLASS = 0x00
WEARABLE_WRISTWATCH_MINOR_DEVICE_CLASS = 0x01
WEARABLE_PAGER_MINOR_DEVICE_CLASS = 0x02
WEARABLE_JACKET_MINOR_DEVICE_CLASS = 0x03
WEARABLE_HELMET_MINOR_DEVICE_CLASS = 0x04
WEARABLE_GLASSES_MINOR_DEVICE_CLASS = 0x05
WEARABLE_MINOR_DEVICE_CLASS_NAMES = {
WEARABLE_UNCATEGORIZED_MINOR_DEVICE_CLASS: 'Uncategorized',
WEARABLE_WRISTWATCH_MINOR_DEVICE_CLASS: 'Wristwatch',
WEARABLE_PAGER_MINOR_DEVICE_CLASS: 'Pager',
WEARABLE_JACKET_MINOR_DEVICE_CLASS: 'Jacket',
WEARABLE_HELMET_MINOR_DEVICE_CLASS: 'Helmet',
WEARABLE_GLASSES_MINOR_DEVICE_CLASS: 'Glasses',
}
TOY_UNCATEGORIZED_MINOR_DEVICE_CLASS = 0x00
TOY_ROBOT_MINOR_DEVICE_CLASS = 0x01
TOY_VEHICLE_MINOR_DEVICE_CLASS = 0x02
TOY_DOLL_ACTION_FIGURE_MINOR_DEVICE_CLASS = 0x03
TOY_CONTROLLER_MINOR_DEVICE_CLASS = 0x04
TOY_GAME_MINOR_DEVICE_CLASS = 0x05
TOY_MINOR_DEVICE_CLASS_NAMES = {
TOY_UNCATEGORIZED_MINOR_DEVICE_CLASS: 'Uncategorized',
TOY_ROBOT_MINOR_DEVICE_CLASS: 'Robot',
TOY_VEHICLE_MINOR_DEVICE_CLASS: 'Vehicle',
TOY_DOLL_ACTION_FIGURE_MINOR_DEVICE_CLASS: 'Doll/Action figure',
TOY_CONTROLLER_MINOR_DEVICE_CLASS: 'Controller',
TOY_GAME_MINOR_DEVICE_CLASS: 'Game',
}
HEALTH_UNDEFINED_MINOR_DEVICE_CLASS = 0x00
HEALTH_BLOOD_PRESSURE_MONITOR_MINOR_DEVICE_CLASS = 0x01
HEALTH_THERMOMETER_MINOR_DEVICE_CLASS = 0x02
HEALTH_WEIGHING_SCALE_MINOR_DEVICE_CLASS = 0x03
HEALTH_GLUCOSE_METER_MINOR_DEVICE_CLASS = 0x04
HEALTH_PULSE_OXIMETER_MINOR_DEVICE_CLASS = 0x05
HEALTH_HEART_PULSE_RATE_MONITOR_MINOR_DEVICE_CLASS = 0x06
HEALTH_HEALTH_DATA_DISPLAY_MINOR_DEVICE_CLASS = 0x07
HEALTH_STEP_COUNTER_MINOR_DEVICE_CLASS = 0x08
HEALTH_BODY_COMPOSITION_ANALYZER_MINOR_DEVICE_CLASS = 0x09
HEALTH_PEAK_FLOW_MONITOR_MINOR_DEVICE_CLASS = 0x0A
HEALTH_MEDICATION_MONITOR_MINOR_DEVICE_CLASS = 0x0B
HEALTH_KNEE_PROSTHESIS_MINOR_DEVICE_CLASS = 0x0C
HEALTH_ANKLE_PROSTHESIS_MINOR_DEVICE_CLASS = 0x0D
HEALTH_GENERIC_HEALTH_MANAGER_MINOR_DEVICE_CLASS = 0x0E
HEALTH_PERSONAL_MOBILITY_DEVICE_MINOR_DEVICE_CLASS = 0x0F
HEALTH_MINOR_DEVICE_CLASS_NAMES = {
HEALTH_UNDEFINED_MINOR_DEVICE_CLASS: 'Undefined',
HEALTH_BLOOD_PRESSURE_MONITOR_MINOR_DEVICE_CLASS: 'Blood Pressure Monitor',
HEALTH_THERMOMETER_MINOR_DEVICE_CLASS: 'Thermometer',
HEALTH_WEIGHING_SCALE_MINOR_DEVICE_CLASS: 'Weighing Scale',
HEALTH_GLUCOSE_METER_MINOR_DEVICE_CLASS: 'Glucose Meter',
HEALTH_PULSE_OXIMETER_MINOR_DEVICE_CLASS: 'Pulse Oximeter',
HEALTH_HEART_PULSE_RATE_MONITOR_MINOR_DEVICE_CLASS: 'Heart/Pulse Rate Monitor',
HEALTH_HEALTH_DATA_DISPLAY_MINOR_DEVICE_CLASS: 'Health Data Display',
HEALTH_STEP_COUNTER_MINOR_DEVICE_CLASS: 'Step Counter',
HEALTH_BODY_COMPOSITION_ANALYZER_MINOR_DEVICE_CLASS: 'Body Composition Analyzer',
HEALTH_PEAK_FLOW_MONITOR_MINOR_DEVICE_CLASS: 'Peak Flow Monitor',
HEALTH_MEDICATION_MONITOR_MINOR_DEVICE_CLASS: 'Medication Monitor',
HEALTH_KNEE_PROSTHESIS_MINOR_DEVICE_CLASS: 'Knee Prosthesis',
HEALTH_ANKLE_PROSTHESIS_MINOR_DEVICE_CLASS: 'Ankle Prosthesis',
HEALTH_GENERIC_HEALTH_MANAGER_MINOR_DEVICE_CLASS: 'Generic Health Manager',
HEALTH_PERSONAL_MOBILITY_DEVICE_MINOR_DEVICE_CLASS: 'Personal Mobility Device',
}
MINOR_DEVICE_CLASS_NAMES = {
COMPUTER_MAJOR_DEVICE_CLASS: COMPUTER_MINOR_DEVICE_CLASS_NAMES,
PHONE_MAJOR_DEVICE_CLASS: PHONE_MINOR_DEVICE_CLASS_NAMES,
AUDIO_VIDEO_MAJOR_DEVICE_CLASS: AUDIO_VIDEO_MINOR_DEVICE_CLASS_NAMES,
PERIPHERAL_MAJOR_DEVICE_CLASS: PERIPHERAL_MINOR_DEVICE_CLASS_NAMES
PERIPHERAL_MAJOR_DEVICE_CLASS: PERIPHERAL_MINOR_DEVICE_CLASS_NAMES,
WEARABLE_MAJOR_DEVICE_CLASS: WEARABLE_MINOR_DEVICE_CLASS_NAMES,
TOY_MAJOR_DEVICE_CLASS: TOY_MINOR_DEVICE_CLASS_NAMES,
HEALTH_MAJOR_DEVICE_CLASS: HEALTH_MINOR_DEVICE_CLASS_NAMES,
}
# fmt: on
@@ -773,7 +833,7 @@ class AdvertisingData:
def uuid_list_to_objects(ad_data: bytes, uuid_size: int) -> List[UUID]:
uuids = []
offset = 0
while (uuid_size * (offset + 1)) <= len(ad_data):
while (offset + uuid_size) <= len(ad_data):
uuids.append(UUID.from_bytes(ad_data[offset : offset + uuid_size]))
offset += uuid_size
return uuids

View File

@@ -23,7 +23,7 @@ import asyncio
import logging
from contextlib import asynccontextmanager, AsyncExitStack
from dataclasses import dataclass
from typing import Any, ClassVar, Dict, List, Optional, Tuple, Union
from typing import Any, Callable, ClassVar, Dict, List, Optional, Tuple, Type, Union
from .colors import color
from .att import ATT_CID, ATT_DEFAULT_MTU, ATT_PDU
@@ -528,6 +528,7 @@ class Connection(CompositeEventEmitter):
transport: int
self_address: Address
peer_address: Address
peer_resolvable_address: Optional[Address]
role: int
encryption: int
authenticated: bool
@@ -825,6 +826,12 @@ class DeviceConfiguration:
advertising_data = config.get('advertising_data')
if advertising_data:
self.advertising_data = bytes.fromhex(advertising_data)
elif config.get('name') is not None:
self.advertising_data = bytes(
AdvertisingData(
[(AdvertisingData.COMPLETE_LOCAL_NAME, bytes(self.name, 'utf-8'))]
)
)
def load_from_file(self, filename):
with open(filename, 'r', encoding='utf-8') as file:
@@ -888,7 +895,7 @@ def host_event_handler(function):
# List of host event handlers for the Device class.
# (we define this list outside the class, because referencing a class in method
# decorators is not straightforward)
device_host_event_handlers: list[str] = []
device_host_event_handlers: List[str] = []
# -----------------------------------------------------------------------------
@@ -947,12 +954,16 @@ class Device(CompositeEventEmitter):
config.load_from_file(filename)
return cls(config=config)
@classmethod
def from_config_with_hci(cls, config, hci_source, hci_sink):
host = Host(controller_source=hci_source, controller_sink=hci_sink)
return cls(config=config, host=host)
@classmethod
def from_config_file_with_hci(cls, filename, hci_source, hci_sink):
config = DeviceConfiguration()
config.load_from_file(filename)
host = Host(controller_source=hci_source, controller_sink=hci_sink)
return cls(config=config, host=host)
return cls.from_config_with_hci(config, hci_source, hci_sink)
def __init__(
self,
@@ -2196,13 +2207,23 @@ class Device(CompositeEventEmitter):
await self.stop_discovery()
@property
def pairing_config_factory(self):
def pairing_config_factory(self) -> Callable[[Connection], PairingConfig]:
return self.smp_manager.pairing_config_factory
@pairing_config_factory.setter
def pairing_config_factory(self, pairing_config_factory):
def pairing_config_factory(
self, pairing_config_factory: Callable[[Connection], PairingConfig]
) -> None:
self.smp_manager.pairing_config_factory = pairing_config_factory
@property
def smp_session_proxy(self) -> Type[smp.Session]:
return self.smp_manager.session_proxy
@smp_session_proxy.setter
def smp_session_proxy(self, session_proxy: Type[smp.Session]) -> None:
self.smp_manager.session_proxy = session_proxy
async def pair(self, connection):
return await self.smp_manager.pair(connection)
@@ -2232,7 +2253,7 @@ class Device(CompositeEventEmitter):
if connection.role == BT_PERIPHERAL_ROLE and keys.ltk_peripheral:
return keys.ltk_peripheral.value
async def get_link_key(self, address):
async def get_link_key(self, address: Address) -> Optional[bytes]:
# Look for the key in the keystore
if self.keystore is not None:
keys = await self.keystore.get(str(address))
@@ -2243,6 +2264,7 @@ class Device(CompositeEventEmitter):
return None
return keys.link_key.value
return None
# [Classic only]
async def authenticate(self, connection):
@@ -2423,7 +2445,7 @@ class Device(CompositeEventEmitter):
if result.status != HCI_COMMAND_STATUS_PENDING:
logger.warning(
'HCI_Set_Connection_Encryption_Command failed: '
'HCI_Remote_Name_Request_Command failed: '
f'{HCI_Constant.error_name(result.status)}'
)
raise HCI_StatusError(result)
@@ -2772,90 +2794,112 @@ class Device(CompositeEventEmitter):
# [Classic only]
@host_event_handler
@with_connection_from_address
def on_authentication_user_confirmation_request(self, connection, code):
def on_authentication_user_confirmation_request(self, connection, code) -> None:
# Ask what the pairing config should be for this connection
pairing_config = self.pairing_config_factory(connection)
io_capability = pairing_config.delegate.classic_io_capability
peer_io_capability = connection.peer_pairing_io_capability
# Respond
if io_capability == HCI_DISPLAY_YES_NO_IO_CAPABILITY:
if connection.peer_pairing_io_capability in (
HCI_DISPLAY_YES_NO_IO_CAPABILITY,
HCI_DISPLAY_ONLY_IO_CAPABILITY,
):
# Display the code and ask the user to compare
async def prompt():
return (
await pairing_config.delegate.compare_numbers(code, digits=6),
)
async def confirm() -> bool:
# Ask the user to confirm the pairing, without display
return await pairing_config.delegate.confirm()
else:
# Ask the user to confirm the pairing, without showing a code
async def prompt():
return await pairing_config.delegate.confirm()
async def auto_confirm() -> bool:
# Ask the user to auto-confirm the pairing, without display
return await pairing_config.delegate.confirm(auto=True)
async def confirm():
if await prompt():
async def display_confirm() -> bool:
# Display the code and ask the user to compare
return await pairing_config.delegate.compare_numbers(code, digits=6)
async def display_auto_confirm() -> bool:
# Display the code to the user and ask the delegate to auto-confirm
await pairing_config.delegate.display_number(code, digits=6)
return await pairing_config.delegate.confirm(auto=True)
async def na() -> bool:
assert False, "N/A: unreachable"
# See Bluetooth spec @ Vol 3, Part C 5.2.2.6
methods = {
HCI_DISPLAY_ONLY_IO_CAPABILITY: {
HCI_DISPLAY_ONLY_IO_CAPABILITY: display_auto_confirm,
HCI_DISPLAY_YES_NO_IO_CAPABILITY: display_confirm,
HCI_KEYBOARD_ONLY_IO_CAPABILITY: na,
HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: auto_confirm,
},
HCI_DISPLAY_YES_NO_IO_CAPABILITY: {
HCI_DISPLAY_ONLY_IO_CAPABILITY: display_auto_confirm,
HCI_DISPLAY_YES_NO_IO_CAPABILITY: display_confirm,
HCI_KEYBOARD_ONLY_IO_CAPABILITY: na,
HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: auto_confirm,
},
HCI_KEYBOARD_ONLY_IO_CAPABILITY: {
HCI_DISPLAY_ONLY_IO_CAPABILITY: na,
HCI_DISPLAY_YES_NO_IO_CAPABILITY: na,
HCI_KEYBOARD_ONLY_IO_CAPABILITY: na,
HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: auto_confirm,
},
HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: {
HCI_DISPLAY_ONLY_IO_CAPABILITY: confirm,
HCI_DISPLAY_YES_NO_IO_CAPABILITY: confirm,
HCI_KEYBOARD_ONLY_IO_CAPABILITY: auto_confirm,
HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: auto_confirm,
},
}
method = methods[peer_io_capability][io_capability]
async def reply() -> None:
try:
if await connection.abort_on('disconnection', method()):
await self.host.send_command(
HCI_User_Confirmation_Request_Reply_Command(
bd_addr=connection.peer_address
)
)
else:
await self.host.send_command(
HCI_User_Confirmation_Request_Negative_Reply_Command(
HCI_User_Confirmation_Request_Reply_Command( # type: ignore[call-arg]
bd_addr=connection.peer_address
)
)
return
except Exception as error:
logger.warning(f'exception while confirming: {error}')
AsyncRunner.spawn(connection.abort_on('disconnection', confirm()))
return
await self.host.send_command(
HCI_User_Confirmation_Request_Negative_Reply_Command( # type: ignore[call-arg]
bd_addr=connection.peer_address
)
)
if io_capability == HCI_DISPLAY_ONLY_IO_CAPABILITY:
# Display the code to the user
AsyncRunner.spawn(pairing_config.delegate.display_number(code, 6))
# Automatic confirmation
self.host.send_command_sync(
HCI_User_Confirmation_Request_Reply_Command(bd_addr=connection.peer_address)
)
AsyncRunner.spawn(reply())
# [Classic only]
@host_event_handler
@with_connection_from_address
def on_authentication_user_passkey_request(self, connection):
def on_authentication_user_passkey_request(self, connection) -> None:
# Ask what the pairing config should be for this connection
pairing_config = self.pairing_config_factory(connection)
io_capability = pairing_config.delegate.classic_io_capability
# Respond
if io_capability == HCI_KEYBOARD_ONLY_IO_CAPABILITY:
# Ask the user to input a number
async def get_number():
async def reply() -> None:
try:
number = await connection.abort_on(
'disconnection', pairing_config.delegate.get_number()
)
if number is not None:
await self.host.send_command(
HCI_User_Passkey_Request_Reply_Command(
HCI_User_Passkey_Request_Reply_Command( # type: ignore[call-arg]
bd_addr=connection.peer_address, numeric_value=number
)
)
else:
await self.host.send_command(
HCI_User_Passkey_Request_Negative_Reply_Command(
bd_addr=connection.peer_address
)
)
return
except Exception as error:
logger.warning(f'exception while asking for pass-key: {error}')
asyncio.create_task(get_number())
else:
self.host.send_command_sync(
HCI_User_Passkey_Request_Negative_Reply_Command(
await self.host.send_command(
HCI_User_Passkey_Request_Negative_Reply_Command( # type: ignore[call-arg]
bd_addr=connection.peer_address
)
)
AsyncRunner.spawn(reply())
# [Classic only]
@host_event_handler
@with_connection_from_address
@@ -3059,18 +3103,24 @@ class Device(CompositeEventEmitter):
connection.emit('role_change_failure', error)
self.emit('role_change_failure', address, error)
@with_connection_from_handle
def on_pairing_start(self, connection):
def on_pairing_start(self, connection: Connection) -> None:
connection.emit('pairing_start')
@with_connection_from_handle
def on_pairing(self, connection, keys, sc):
def on_pairing(
self,
connection: Connection,
identity_address: Optional[Address],
keys: PairingKeys,
sc: bool,
) -> None:
if identity_address is not None:
connection.peer_resolvable_address = connection.peer_address
connection.peer_address = identity_address
connection.sc = sc
connection.authenticated = True
connection.emit('pairing', keys)
@with_connection_from_handle
def on_pairing_failure(self, connection, reason):
def on_pairing_failure(self, connection: Connection, reason: int) -> None:
connection.emit('pairing_failure', reason)
@with_connection_from_handle

View File

@@ -0,0 +1,68 @@
# Copyright 2021-2022 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Drivers that can be used to customize the interaction between a host and a controller,
like loading firmware after a cold start.
"""
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
import abc
import logging
from . import rtk
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Classes
# -----------------------------------------------------------------------------
class Driver(abc.ABC):
"""Base class for drivers."""
@staticmethod
async def for_host(_host):
"""Return a driver instance for a host.
Args:
host: Host object for which a driver should be created.
Returns:
A Driver instance if a driver should be instantiated for this host, or
None if no driver instance of this class is needed.
"""
return None
@abc.abstractmethod
async def init_controller(self):
"""Initialize the controller."""
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
async def get_driver_for_host(host):
"""Probe all known diver classes until one returns a valid instance for a host,
or none is found.
"""
if driver := await rtk.Driver.for_host(host):
logger.debug("Instantiated RTK driver")
return driver
return None

647
bumble/drivers/rtk.py Normal file
View File

@@ -0,0 +1,647 @@
# Copyright 2021-2023 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Support for Realtek USB dongles.
Based on various online bits of information, including the Linux kernel.
(see `drivers/bluetooth/btrtl.c`)
"""
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
from dataclasses import dataclass
import asyncio
import enum
import logging
import math
import os
import pathlib
import platform
import struct
from typing import Tuple
import weakref
from bumble.hci import (
hci_command_op_code,
STATUS_SPEC,
HCI_SUCCESS,
HCI_COMMAND_NAMES,
HCI_Command,
HCI_Reset_Command,
HCI_Read_Local_Version_Information_Command,
)
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
RTK_ROM_LMP_8723A = 0x1200
RTK_ROM_LMP_8723B = 0x8723
RTK_ROM_LMP_8821A = 0x8821
RTK_ROM_LMP_8761A = 0x8761
RTK_ROM_LMP_8822B = 0x8822
RTK_ROM_LMP_8852A = 0x8852
RTK_CONFIG_MAGIC = 0x8723AB55
RTK_EPATCH_SIGNATURE = b"Realtech"
RTK_FRAGMENT_LENGTH = 252
RTK_FIRMWARE_DIR_ENV = "BUMBLE_RTK_FIRMWARE_DIR"
RTK_LINUX_FIRMWARE_DIR = "/lib/firmware/rtl_bt"
class RtlProjectId(enum.IntEnum):
PROJECT_ID_8723A = 0
PROJECT_ID_8723B = 1
PROJECT_ID_8821A = 2
PROJECT_ID_8761A = 3
PROJECT_ID_8822B = 8
PROJECT_ID_8723D = 9
PROJECT_ID_8821C = 10
PROJECT_ID_8822C = 13
PROJECT_ID_8761B = 14
PROJECT_ID_8852A = 18
PROJECT_ID_8852B = 20
PROJECT_ID_8852C = 25
RTK_PROJECT_ID_TO_ROM = {
0: RTK_ROM_LMP_8723A,
1: RTK_ROM_LMP_8723B,
2: RTK_ROM_LMP_8821A,
3: RTK_ROM_LMP_8761A,
8: RTK_ROM_LMP_8822B,
9: RTK_ROM_LMP_8723B,
10: RTK_ROM_LMP_8821A,
13: RTK_ROM_LMP_8822B,
14: RTK_ROM_LMP_8761A,
18: RTK_ROM_LMP_8852A,
20: RTK_ROM_LMP_8852A,
25: RTK_ROM_LMP_8852A,
}
# List of USB (VendorID, ProductID) for Realtek-based devices.
RTK_USB_PRODUCTS = {
# Realtek 8723AE
(0x0930, 0x021D),
(0x13D3, 0x3394),
# Realtek 8723BE
(0x0489, 0xE085),
(0x0489, 0xE08B),
(0x04F2, 0xB49F),
(0x13D3, 0x3410),
(0x13D3, 0x3416),
(0x13D3, 0x3459),
(0x13D3, 0x3494),
# Realtek 8723BU
(0x7392, 0xA611),
# Realtek 8723DE
(0x0BDA, 0xB009),
(0x2FF8, 0xB011),
# Realtek 8761BUV
(0x0B05, 0x190E),
(0x0BDA, 0x8771),
(0x2230, 0x0016),
(0x2357, 0x0604),
(0x2550, 0x8761),
(0x2B89, 0x8761),
(0x7392, 0xC611),
# Realtek 8821AE
(0x0B05, 0x17DC),
(0x13D3, 0x3414),
(0x13D3, 0x3458),
(0x13D3, 0x3461),
(0x13D3, 0x3462),
# Realtek 8821CE
(0x0BDA, 0xB00C),
(0x0BDA, 0xC822),
(0x13D3, 0x3529),
# Realtek 8822BE
(0x0B05, 0x185C),
(0x13D3, 0x3526),
# Realtek 8822CE
(0x04C5, 0x161F),
(0x04CA, 0x4005),
(0x0B05, 0x18EF),
(0x0BDA, 0xB00C),
(0x0BDA, 0xC123),
(0x0BDA, 0xC822),
(0x0CB5, 0xC547),
(0x1358, 0xC123),
(0x13D3, 0x3548),
(0x13D3, 0x3549),
(0x13D3, 0x3553),
(0x13D3, 0x3555),
(0x2FF8, 0x3051),
# Realtek 8822CU
(0x13D3, 0x3549),
# Realtek 8852AE
(0x04C5, 0x165C),
(0x04CA, 0x4006),
(0x0BDA, 0x2852),
(0x0BDA, 0x385A),
(0x0BDA, 0x4852),
(0x0BDA, 0xC852),
(0x0CB8, 0xC549),
# Realtek 8852BE
(0x0BDA, 0x887B),
(0x0CB8, 0xC559),
(0x13D3, 0x3571),
# Realtek 8852CE
(0x04C5, 0x1675),
(0x04CA, 0x4007),
(0x0CB8, 0xC558),
(0x13D3, 0x3586),
(0x13D3, 0x3587),
(0x13D3, 0x3592),
}
# -----------------------------------------------------------------------------
# HCI Commands
# -----------------------------------------------------------------------------
HCI_RTK_READ_ROM_VERSION_COMMAND = hci_command_op_code(0x3F, 0x6D)
HCI_COMMAND_NAMES[HCI_RTK_READ_ROM_VERSION_COMMAND] = "HCI_RTK_READ_ROM_VERSION_COMMAND"
@HCI_Command.command(return_parameters_fields=[("status", STATUS_SPEC), ("version", 1)])
class HCI_RTK_Read_ROM_Version_Command(HCI_Command):
pass
HCI_RTK_DOWNLOAD_COMMAND = hci_command_op_code(0x3F, 0x20)
HCI_COMMAND_NAMES[HCI_RTK_DOWNLOAD_COMMAND] = "HCI_RTK_DOWNLOAD_COMMAND"
@HCI_Command.command(
fields=[("index", 1), ("payload", RTK_FRAGMENT_LENGTH)],
return_parameters_fields=[("status", STATUS_SPEC), ("index", 1)],
)
class HCI_RTK_Download_Command(HCI_Command):
pass
HCI_RTK_DROP_FIRMWARE_COMMAND = hci_command_op_code(0x3F, 0x66)
HCI_COMMAND_NAMES[HCI_RTK_DROP_FIRMWARE_COMMAND] = "HCI_RTK_DROP_FIRMWARE_COMMAND"
@HCI_Command.command()
class HCI_RTK_Drop_Firmware_Command(HCI_Command):
pass
# -----------------------------------------------------------------------------
class Firmware:
def __init__(self, firmware):
extension_sig = bytes([0x51, 0x04, 0xFD, 0x77])
if not firmware.startswith(RTK_EPATCH_SIGNATURE):
raise ValueError("Firmware does not start with epatch signature")
if not firmware.endswith(extension_sig):
raise ValueError("Firmware does not end with extension sig")
# The firmware should start with a 14 byte header.
epatch_header_size = 14
if len(firmware) < epatch_header_size:
raise ValueError("Firmware too short")
# Look for the "project ID", starting from the end.
offset = len(firmware) - len(extension_sig)
project_id = -1
while offset >= epatch_header_size:
length, opcode = firmware[offset - 2 : offset]
offset -= 2
if opcode == 0xFF:
# End
break
if length == 0:
raise ValueError("Invalid 0-length instruction")
if opcode == 0 and length == 1:
project_id = firmware[offset - 1]
break
offset -= length
if project_id < 0:
raise ValueError("Project ID not found")
self.project_id = project_id
# Read the patch tables info.
self.version, num_patches = struct.unpack("<IH", firmware[8:14])
self.patches = []
# The patches tables are laid out as:
# <ChipID_1><ChipID_2>...<ChipID_N> (16 bits each)
# <PatchLength_1><PatchLength_2>...<PatchLength_N> (16 bits each)
# <PatchOffset_1><PatchOffset_2>...<PatchOffset_N> (32 bits each)
if epatch_header_size + 8 * num_patches > len(firmware):
raise ValueError("Firmware too short")
chip_id_table_offset = epatch_header_size
patch_length_table_offset = chip_id_table_offset + 2 * num_patches
patch_offset_table_offset = chip_id_table_offset + 4 * num_patches
for patch_index in range(num_patches):
chip_id_offset = chip_id_table_offset + 2 * patch_index
(chip_id,) = struct.unpack_from("<H", firmware, chip_id_offset)
(patch_length,) = struct.unpack_from(
"<H", firmware, patch_length_table_offset + 2 * patch_index
)
(patch_offset,) = struct.unpack_from(
"<I", firmware, patch_offset_table_offset + 4 * patch_index
)
if patch_offset + patch_length > len(firmware):
raise ValueError("Firmware too short")
# Get the SVN version for the patch
(svn_version,) = struct.unpack_from(
"<I", firmware, patch_offset + patch_length - 8
)
# Create a payload with the patch, replacing the last 4 bytes with
# the firmware version.
self.patches.append(
(
chip_id,
firmware[patch_offset : patch_offset + patch_length - 4]
+ struct.pack("<I", self.version),
svn_version,
)
)
class Driver:
@dataclass
class DriverInfo:
rom: int
hci: Tuple[int, int]
config_needed: bool
has_rom_version: bool
has_msft_ext: bool = False
fw_name: str = ""
config_name: str = ""
DRIVER_INFOS = [
# 8723A
DriverInfo(
rom=RTK_ROM_LMP_8723A,
hci=(0x0B, 0x06),
config_needed=False,
has_rom_version=False,
fw_name="rtl8723a_fw.bin",
config_name="",
),
# 8723B
DriverInfo(
rom=RTK_ROM_LMP_8723B,
hci=(0x0B, 0x06),
config_needed=False,
has_rom_version=True,
fw_name="rtl8723b_fw.bin",
config_name="rtl8723b_config.bin",
),
# 8723D
DriverInfo(
rom=RTK_ROM_LMP_8723B,
hci=(0x0D, 0x08),
config_needed=True,
has_rom_version=True,
fw_name="rtl8723d_fw.bin",
config_name="rtl8723d_config.bin",
),
# 8821A
DriverInfo(
rom=RTK_ROM_LMP_8821A,
hci=(0x0A, 0x06),
config_needed=False,
has_rom_version=True,
fw_name="rtl8821a_fw.bin",
config_name="rtl8821a_config.bin",
),
# 8821C
DriverInfo(
rom=RTK_ROM_LMP_8821A,
hci=(0x0C, 0x08),
config_needed=False,
has_rom_version=True,
has_msft_ext=True,
fw_name="rtl8821c_fw.bin",
config_name="rtl8821c_config.bin",
),
# 8761A
DriverInfo(
rom=RTK_ROM_LMP_8761A,
hci=(0x0A, 0x06),
config_needed=False,
has_rom_version=True,
fw_name="rtl8761a_fw.bin",
config_name="rtl8761a_config.bin",
),
# 8761BU
DriverInfo(
rom=RTK_ROM_LMP_8761A,
hci=(0x0B, 0x0A),
config_needed=False,
has_rom_version=True,
fw_name="rtl8761bu_fw.bin",
config_name="rtl8761bu_config.bin",
),
# 8822C
DriverInfo(
rom=RTK_ROM_LMP_8822B,
hci=(0x0C, 0x0A),
config_needed=False,
has_rom_version=True,
has_msft_ext=True,
fw_name="rtl8822cu_fw.bin",
config_name="rtl8822cu_config.bin",
),
# 8822B
DriverInfo(
rom=RTK_ROM_LMP_8822B,
hci=(0x0B, 0x07),
config_needed=True,
has_rom_version=True,
has_msft_ext=True,
fw_name="rtl8822b_fw.bin",
config_name="rtl8822b_config.bin",
),
# 8852A
DriverInfo(
rom=RTK_ROM_LMP_8852A,
hci=(0x0A, 0x0B),
config_needed=False,
has_rom_version=True,
has_msft_ext=True,
fw_name="rtl8852au_fw.bin",
config_name="rtl8852au_config.bin",
),
# 8852B
DriverInfo(
rom=RTK_ROM_LMP_8852A,
hci=(0xB, 0xB),
config_needed=False,
has_rom_version=True,
has_msft_ext=True,
fw_name="rtl8852bu_fw.bin",
config_name="rtl8852bu_config.bin",
),
# 8852C
DriverInfo(
rom=RTK_ROM_LMP_8852A,
hci=(0x0C, 0x0C),
config_needed=False,
has_rom_version=True,
has_msft_ext=True,
fw_name="rtl8852cu_fw.bin",
config_name="rtl8852cu_config.bin",
),
]
POST_DROP_DELAY = 0.2
@staticmethod
def find_driver_info(hci_version, hci_subversion, lmp_subversion):
for driver_info in Driver.DRIVER_INFOS:
if driver_info.rom == lmp_subversion and driver_info.hci == (
hci_subversion,
hci_version,
):
return driver_info
return None
@staticmethod
def find_binary_path(file_name):
# First check if an environment variable is set
if RTK_FIRMWARE_DIR_ENV in os.environ:
if (
path := pathlib.Path(os.environ[RTK_FIRMWARE_DIR_ENV]) / file_name
).is_file():
logger.debug(f"{file_name} found in env dir")
return path
# When the environment variable is set, don't look elsewhere
return None
# Then, look in the package's driver directory
if (path := pathlib.Path(__file__).parent / "rtk_fw" / file_name).is_file():
logger.debug(f"{file_name} found in package dir")
return path
# On Linux, check the system's FW directory
if (
platform.system() == "Linux"
and (path := pathlib.Path(RTK_LINUX_FIRMWARE_DIR) / file_name).is_file()
):
logger.debug(f"{file_name} found in Linux system FW dir")
return path
# Finally look in the current directory
if (path := pathlib.Path.cwd() / file_name).is_file():
logger.debug(f"{file_name} found in CWD")
return path
return None
@staticmethod
def check(host):
if not host.hci_metadata:
logger.debug("USB metadata not found")
return False
vendor_id = host.hci_metadata.get("vendor_id", None)
product_id = host.hci_metadata.get("product_id", None)
if vendor_id is None or product_id is None:
logger.debug("USB metadata not sufficient")
return False
if (vendor_id, product_id) not in RTK_USB_PRODUCTS:
logger.debug(
f"USB device ({vendor_id:04X}, {product_id:04X}) " "not in known list"
)
return False
return True
@classmethod
async def driver_info_for_host(cls, host):
response = await host.send_command(
HCI_Read_Local_Version_Information_Command(), check_result=True
)
local_version = response.return_parameters
logger.debug(
f"looking for a driver: 0x{local_version.lmp_subversion:04X} "
f"(0x{local_version.hci_version:02X}, "
f"0x{local_version.hci_subversion:04X})"
)
driver_info = cls.find_driver_info(
local_version.hci_version,
local_version.hci_subversion,
local_version.lmp_subversion,
)
if driver_info is None:
# TODO: it seems that the Linux driver will send command (0x3f, 0x66)
# in this case and then re-read the local version, then re-match.
logger.debug("firmware already loaded or no known driver for this device")
return driver_info
@classmethod
async def for_host(cls, host, force=False):
# Check that a driver is needed for this host
if not force and not cls.check(host):
return None
# Get the driver info
driver_info = await cls.driver_info_for_host(host)
if driver_info is None:
return None
# Load the firmware
firmware_path = cls.find_binary_path(driver_info.fw_name)
if not firmware_path:
logger.warning(f"Firmware file {driver_info.fw_name} not found")
logger.warning("See https://google.github.io/bumble/drivers/realtek.html")
return None
with open(firmware_path, "rb") as firmware_file:
firmware = firmware_file.read()
# Load the config
config = None
if driver_info.config_name:
config_path = cls.find_binary_path(driver_info.config_name)
if config_path:
with open(config_path, "rb") as config_file:
config = config_file.read()
if driver_info.config_needed and not config:
logger.warning("Config needed, but no config file available")
return None
return cls(host, driver_info, firmware, config)
def __init__(self, host, driver_info, firmware, config):
self.host = weakref.proxy(host)
self.driver_info = driver_info
self.firmware = firmware
self.config = config
@staticmethod
async def drop_firmware(host):
host.send_hci_packet(HCI_RTK_Drop_Firmware_Command())
# Wait for the command to be effective (no response is sent)
await asyncio.sleep(Driver.POST_DROP_DELAY)
async def download_for_rtl8723a(self):
# Check that the firmware image does not include an epatch signature.
if RTK_EPATCH_SIGNATURE in self.firmware:
logger.warning(
"epatch signature found in firmware, it is probably the wrong firmware"
)
return
# TODO: load the firmware
async def download_for_rtl8723b(self):
if self.driver_info.has_rom_version:
response = await self.host.send_command(
HCI_RTK_Read_ROM_Version_Command(), check_result=True
)
if response.return_parameters.status != HCI_SUCCESS:
logger.warning("can't get ROM version")
return
rom_version = response.return_parameters.version
logger.debug(f"ROM version before download: {rom_version:04X}")
else:
rom_version = 0
firmware = Firmware(self.firmware)
logger.debug(f"firmware: project_id=0x{firmware.project_id:04X}")
for patch in firmware.patches:
if patch[0] == rom_version + 1:
logger.debug(f"using patch {patch[0]}")
break
else:
logger.warning("no valid patch found for rom version {rom_version}")
return
# Append the config if there is one.
if self.config:
payload = patch[1] + self.config
else:
payload = patch[1]
# Download the payload, one fragment at a time.
fragment_count = math.ceil(len(payload) / RTK_FRAGMENT_LENGTH)
for fragment_index in range(fragment_count):
# NOTE: the Linux driver somehow adds 1 to the index after it wraps around.
# That's odd, but we"ll do the same here.
download_index = fragment_index & 0x7F
if download_index >= 0x80:
download_index += 1
if fragment_index == fragment_count - 1:
download_index |= 0x80 # End marker.
fragment_offset = fragment_index * RTK_FRAGMENT_LENGTH
fragment = payload[fragment_offset : fragment_offset + RTK_FRAGMENT_LENGTH]
logger.debug(f"downloading fragment {fragment_index}")
await self.host.send_command(
HCI_RTK_Download_Command(
index=download_index, payload=fragment, check_result=True
)
)
logger.debug("download complete!")
# Read the version again
response = await self.host.send_command(
HCI_RTK_Read_ROM_Version_Command(), check_result=True
)
if response.return_parameters.status != HCI_SUCCESS:
logger.warning("can't get ROM version")
else:
rom_version = response.return_parameters.version
logger.debug(f"ROM version after download: {rom_version:04X}")
async def download_firmware(self):
if self.driver_info.rom == RTK_ROM_LMP_8723A:
return await self.download_for_rtl8723a()
if self.driver_info.rom in (
RTK_ROM_LMP_8723B,
RTK_ROM_LMP_8821A,
RTK_ROM_LMP_8761A,
RTK_ROM_LMP_8822B,
RTK_ROM_LMP_8852A,
):
return await self.download_for_rtl8723b()
raise ValueError("ROM not supported")
async def init_controller(self):
await self.download_firmware()
await self.host.send_command(HCI_Reset_Command(), check_result=True)
logger.info(f"loaded FW image {self.driver_info.fw_name}")

View File

@@ -205,8 +205,16 @@ class Service(Attribute):
'''
uuid: UUID
characteristics: List[Characteristic]
included_services: List[Service]
def __init__(self, uuid, characteristics: list[Characteristic], primary=True):
def __init__(
self,
uuid,
characteristics: List[Characteristic],
primary=True,
included_services: List[Service] = [],
):
# Convert the uuid to a UUID object if it isn't already
if isinstance(uuid, str):
uuid = UUID(uuid)
@@ -219,7 +227,7 @@ class Service(Attribute):
uuid.to_pdu_bytes(),
)
self.uuid = uuid
# self.included_services = []
self.included_services = included_services[:]
self.characteristics = characteristics[:]
self.primary = primary
@@ -247,12 +255,38 @@ class TemplateService(Service):
to expose their UUID as a class property
'''
UUID = None
UUID: Optional[UUID] = None
def __init__(self, characteristics, primary=True):
super().__init__(self.UUID, characteristics, primary)
# -----------------------------------------------------------------------------
class IncludedServiceDeclaration(Attribute):
'''
See Vol 3, Part G - 3.2 INCLUDE DEFINITION
'''
service: Service
def __init__(self, service):
declaration_bytes = struct.pack(
'<HH2s', service.handle, service.end_group_handle, service.uuid.to_bytes()
)
super().__init__(
GATT_INCLUDE_ATTRIBUTE_TYPE, Attribute.READABLE, declaration_bytes
)
self.service = service
def __str__(self):
return (
f'IncludedServiceDefinition(handle=0x{self.handle:04X}, '
f'group_starting_handle=0x{self.service.handle:04X}, '
f'group_ending_handle=0x{self.service.end_group_handle:04X}, '
f'uuid={self.service.uuid})'
)
# -----------------------------------------------------------------------------
class Characteristic(Attribute):
'''
@@ -274,31 +308,33 @@ class Characteristic(Attribute):
AUTHENTICATED_SIGNED_WRITES = 0x40
EXTENDED_PROPERTIES = 0x80
@staticmethod
def from_string(properties_str: str) -> Characteristic.Properties:
property_names: List[str] = []
for property in Characteristic.Properties:
if property.name is None:
raise TypeError()
property_names.append(property.name)
def string_to_property(property_string) -> Characteristic.Properties:
for property in zip(Characteristic.Properties, property_names):
if property_string == property[1]:
return property[0]
raise TypeError(f"Unable to convert {property_string} to Property")
@classmethod
def from_string(cls, properties_str: str) -> Characteristic.Properties:
try:
return functools.reduce(
lambda x, y: x | string_to_property(y),
properties_str.split(","),
lambda x, y: x | cls[y],
properties_str.replace("|", ",").split(","),
Characteristic.Properties(0),
)
except TypeError:
except (TypeError, KeyError):
# The check for `p.name is not None` here is needed because for InFlag
# enums, the .name property can be None, when the enum value is 0,
# so the type hint for .name is Optional[str].
enum_list: List[str] = [p.name for p in cls if p.name is not None]
enum_list_str = ",".join(enum_list)
raise TypeError(
f"Characteristic.Properties::from_string() error:\nExpected a string containing any of the keys, separated by commas: {','.join(property_names)}\nGot: {properties_str}"
f"Characteristic.Properties::from_string() error:\nExpected a string containing any of the keys, separated by , or |: {enum_list_str}\nGot: {properties_str}"
)
def __str__(self):
# NOTE: we override this method to offer a consistent result between python
# versions: the value returned by IntFlag.__str__() changed in version 11.
return '|'.join(
flag.name
for flag in Characteristic.Properties
if self.value & flag.value and flag.name is not None
)
# For backwards compatibility these are defined here
# For new code, please use Characteristic.Properties.X
BROADCAST = Properties.BROADCAST
@@ -338,7 +374,7 @@ class Characteristic(Attribute):
f'Characteristic(handle=0x{self.handle:04X}, '
f'end=0x{self.end_group_handle:04X}, '
f'uuid={self.uuid}, '
f'{self.properties!s})'
f'{self.properties})'
)
@@ -366,7 +402,7 @@ class CharacteristicDeclaration(Attribute):
f'CharacteristicDeclaration(handle=0x{self.handle:04X}, '
f'value_handle=0x{self.value_handle:04X}, '
f'uuid={self.characteristic.uuid}, '
f'{self.characteristic.properties!s})'
f'{self.characteristic.properties})'
)

View File

@@ -63,6 +63,7 @@ from .gatt import (
GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE,
GATT_REQUEST_TIMEOUT,
GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE,
GATT_INCLUDE_ATTRIBUTE_TYPE,
Characteristic,
ClientCharacteristicConfigurationBits,
)
@@ -109,6 +110,7 @@ class AttributeProxy(EventEmitter):
class ServiceProxy(AttributeProxy):
uuid: UUID
characteristics: List[CharacteristicProxy]
included_services: List[ServiceProxy]
@staticmethod
def from_client(service_class, client, service_uuid):
@@ -502,12 +504,69 @@ class Client:
return services
async def discover_included_services(self, _service):
async def discover_included_services(
self, service: ServiceProxy
) -> List[ServiceProxy]:
'''
See Vol 3, Part G - 4.5.1 Find Included Services
'''
# TODO
return []
starting_handle = service.handle
ending_handle = service.end_group_handle
included_services: List[ServiceProxy] = []
while starting_handle <= ending_handle:
response = await self.send_request(
ATT_Read_By_Type_Request(
starting_handle=starting_handle,
ending_handle=ending_handle,
attribute_type=GATT_INCLUDE_ATTRIBUTE_TYPE,
)
)
if response is None:
# TODO raise appropriate exception
return []
# Check if we reached the end of the iteration
if response.op_code == ATT_ERROR_RESPONSE:
if response.error_code != ATT_ATTRIBUTE_NOT_FOUND_ERROR:
# Unexpected end
logger.warning(
'!!! unexpected error while discovering included services: '
f'{HCI_Constant.error_name(response.error_code)}'
)
raise ATT_Error(
error_code=response.error_code,
message='Unexpected error while discovering included services',
)
break
# Stop if for some reason the list was empty
if not response.attributes:
break
# Process all included services returned in this iteration
for attribute_handle, attribute_value in response.attributes:
if attribute_handle < starting_handle:
# Something's not right
logger.warning(f'bogus handle value: {attribute_handle}')
return []
group_starting_handle, group_ending_handle = struct.unpack_from(
'<HH', attribute_value
)
service_uuid = UUID.from_bytes(attribute_value[4:])
included_service = ServiceProxy(
self, group_starting_handle, group_ending_handle, service_uuid, True
)
included_services.append(included_service)
# Move on to the next included services
starting_handle = response.attributes[-1][0] + 1
service.included_services = included_services
return included_services
async def discover_characteristics(
self, uuids, service: Optional[ServiceProxy]

View File

@@ -68,6 +68,7 @@ from .gatt import (
Characteristic,
CharacteristicDeclaration,
CharacteristicValue,
IncludedServiceDeclaration,
Descriptor,
Service,
)
@@ -94,6 +95,7 @@ class Server(EventEmitter):
def __init__(self, device):
super().__init__()
self.device = device
self.services = []
self.attributes = [] # Attributes, ordered by increasing handle values
self.attributes_by_handle = {} # Map for fast attribute access by handle
self.max_mtu = (
@@ -222,7 +224,14 @@ class Server(EventEmitter):
# Add the service attribute to the DB
self.add_attribute(service)
# TODO: add included services
# Add all included service
for included_service in service.included_services:
# Not registered yet, register the included service first.
if included_service not in self.services:
self.add_service(included_service)
# TODO: Handle circular service reference
include_declaration = IncludedServiceDeclaration(included_service)
self.add_attribute(include_declaration)
# Add all characteristics
for characteristic in service.characteristics:
@@ -274,6 +283,7 @@ class Server(EventEmitter):
# Update the service group end
service.end_group_handle = self.attributes[-1].handle
self.services.append(service)
def add_services(self, services):
for service in services:

View File

@@ -62,7 +62,7 @@ def map_null_terminated_utf8_string(utf8_bytes):
try:
terminator = utf8_bytes.find(0)
if terminator < 0:
return utf8_bytes
terminator = len(utf8_bytes)
return utf8_bytes[0:terminator].decode('utf8')
except UnicodeDecodeError:
return utf8_bytes
@@ -185,7 +185,7 @@ HCI_IO_CAPABILITY_REQUEST_EVENT = 0x31
HCI_IO_CAPABILITY_RESPONSE_EVENT = 0x32
HCI_USER_CONFIRMATION_REQUEST_EVENT = 0x33
HCI_USER_PASSKEY_REQUEST_EVENT = 0x34
HCI_REMOTE_OOB_DATA_REQUEST = 0x35
HCI_REMOTE_OOB_DATA_REQUEST_EVENT = 0x35
HCI_SIMPLE_PAIRING_COMPLETE_EVENT = 0x36
HCI_LINK_SUPERVISION_TIMEOUT_CHANGED_EVENT = 0x38
HCI_ENHANCED_FLUSH_COMPLETE_EVENT = 0x39
@@ -1641,9 +1641,11 @@ class HCI_Object:
# Get the value for the field
value = hci_object[key]
# Map the value if needed
# Check if there's a matching mapper passed
if value_mappers:
value_mapper = value_mappers.get(key, value_mapper)
# Map the value if we have a mapper
if value_mapper is not None:
value = value_mapper(value)
@@ -1795,6 +1797,16 @@ class Address:
def to_bytes(self):
return self.address_bytes
def to_string(self, with_type_qualifier=True):
'''
String representation of the address, MSB first, with an optional type
qualifier.
'''
result = ':'.join([f'{x:02X}' for x in reversed(self.address_bytes)])
if not with_type_qualifier or not self.is_public:
return result
return result + '/P'
def __bytes__(self):
return self.to_bytes()
@@ -1808,13 +1820,7 @@ class Address:
)
def __str__(self):
'''
String representation of the address, MSB first
'''
result = ':'.join([f'{x:02X}' for x in reversed(self.address_bytes)])
if not self.is_public:
return result
return result + '/P'
return self.to_string()
# Predefined address values
@@ -2282,6 +2288,55 @@ class HCI_User_Passkey_Request_Negative_Reply_Command(HCI_Command):
'''
# -----------------------------------------------------------------------------
@HCI_Command.command(
fields=[
('bd_addr', Address.parse_address),
('c', 16),
('r', 16),
],
return_parameters_fields=[
('status', STATUS_SPEC),
('bd_addr', Address.parse_address),
],
)
class HCI_Remote_OOB_Data_Request_Reply_Command(HCI_Command):
'''
See Bluetooth spec @ 7.1.34 Remote OOB Data Request Reply Command
'''
# -----------------------------------------------------------------------------
@HCI_Command.command(
fields=[('bd_addr', Address.parse_address)],
return_parameters_fields=[
('status', STATUS_SPEC),
('bd_addr', Address.parse_address),
],
)
class HCI_Remote_OOB_Data_Request_Negative_Reply_Command(HCI_Command):
'''
See Bluetooth spec @ 7.1.35 Remote OOB Data Request Negative Reply Command
'''
# -----------------------------------------------------------------------------
@HCI_Command.command(
fields=[
('bd_addr', Address.parse_address),
('reason', 1),
],
return_parameters_fields=[
('status', STATUS_SPEC),
('bd_addr', Address.parse_address),
],
)
class HCI_IO_Capability_Request_Negative_Reply_Command(HCI_Command):
'''
See Bluetooth spec @ 7.1.36 IO Capability Request Negative Reply Command
'''
# -----------------------------------------------------------------------------
@HCI_Command.command(
[
@@ -2317,6 +2372,161 @@ class HCI_Enhanced_Setup_Synchronous_Connection_Command(HCI_Command):
'''
# -----------------------------------------------------------------------------
@HCI_Command.command(
[
('bd_addr', Address.parse_address),
('transmit_bandwidth', 4),
('receive_bandwidth', 4),
('transmit_coding_format', 5),
('receive_coding_format', 5),
('transmit_codec_frame_size', 2),
('receive_codec_frame_size', 2),
('input_bandwidth', 4),
('output_bandwidth', 4),
('input_coding_format', 5),
('output_coding_format', 5),
('input_coded_data_size', 2),
('output_coded_data_size', 2),
('input_pcm_data_format', 1),
('output_pcm_data_format', 1),
('input_pcm_sample_payload_msb_position', 1),
('output_pcm_sample_payload_msb_position', 1),
('input_data_path', 1),
('output_data_path', 1),
('input_transport_unit_size', 1),
('output_transport_unit_size', 1),
('max_latency', 2),
('packet_type', 2),
('retransmission_effort', 1),
]
)
class HCI_Enhanced_Accept_Synchronous_Connection_Request_Command(HCI_Command):
'''
See Bluetooth spec @ 7.1.46 Enhanced Accept Synchronous Connection Request Command
'''
# -----------------------------------------------------------------------------
@HCI_Command.command(
fields=[
('bd_addr', Address.parse_address),
('page_scan_repetition_mode', 1),
('clock_offset', 2),
]
)
class HCI_Truncated_Page_Command(HCI_Command):
'''
See Bluetooth spec @ 7.1.47 Truncated Page Command
'''
# -----------------------------------------------------------------------------
@HCI_Command.command(
fields=[('bd_addr', Address.parse_address)],
return_parameters_fields=[
('status', STATUS_SPEC),
('bd_addr', Address.parse_address),
],
)
class HCI_Truncated_Page_Cancel_Command(HCI_Command):
'''
See Bluetooth spec @ 7.1.48 Truncated Page Cancel Command
'''
# -----------------------------------------------------------------------------
@HCI_Command.command(
fields=[
('enable', 1),
('lt_addr', 1),
('lpo_allowed', 1),
('packet_type', 2),
('interval_min', 2),
('interval_max', 2),
('supervision_timeout', 2),
],
return_parameters_fields=[
('status', STATUS_SPEC),
('lt_addr', 1),
('interval', 2),
],
)
class HCI_Set_Connectionless_Peripheral_Broadcast_Command(HCI_Command):
'''
See Bluetooth spec @ 7.1.49 Set Connectionless Peripheral Broadcast Command
'''
# -----------------------------------------------------------------------------
@HCI_Command.command(
fields=[
('enable', 1),
('bd_addr', Address.parse_address),
('lt_addr', 1),
('interval', 2),
('clock_offset', 4),
('next_connectionless_peripheral_broadcast_clock', 4),
('supervision_timeout', 2),
('remote_timing_accuracy', 1),
('skip', 1),
('packet_type', 2),
('afh_channel_map', 10),
],
return_parameters_fields=[
('status', STATUS_SPEC),
('bd_addr', Address.parse_address),
('lt_addr', 1),
],
)
class HCI_Set_Connectionless_Peripheral_Broadcast_Receive_Command(HCI_Command):
'''
See Bluetooth spec @ 7.1.50 Set Connectionless Peripheral Broadcast Receive Command
'''
# -----------------------------------------------------------------------------
class HCI_Start_Synchronization_Train_Command(HCI_Command):
'''
See Bluetooth spec @ 7.1.51 Start Synchronization Train Command
'''
# -----------------------------------------------------------------------------
@HCI_Command.command(
fields=[
('bd_addr', Address.parse_address),
('sync_scan_timeout', 2),
('sync_scan_window', 2),
('sync_scan_interval', 2),
],
)
class HCI_Receive_Synchronization_Train_Command(HCI_Command):
'''
See Bluetooth spec @ 7.1.52 Receive Synchronization Train Command
'''
# -----------------------------------------------------------------------------
@HCI_Command.command(
fields=[
('bd_addr', Address.parse_address),
('c_192', 16),
('r_192', 16),
('c_256', 16),
('r_256', 16),
],
return_parameters_fields=[
('status', STATUS_SPEC),
('bd_addr', Address.parse_address),
],
)
class HCI_Remote_OOB_Extended_Data_Request_Reply_Command(HCI_Command):
'''
See Bluetooth spec @ 7.1.53 Remote OOB Extended Data Request Reply Command
'''
# -----------------------------------------------------------------------------
@HCI_Command.command(
[
@@ -2683,6 +2893,20 @@ class HCI_Write_Simple_Pairing_Mode_Command(HCI_Command):
'''
# -----------------------------------------------------------------------------
@HCI_Command.command(
return_parameters_fields=[
('status', STATUS_SPEC),
('c', 16),
('r', 16),
]
)
class HCI_Read_Local_OOB_Data_Command(HCI_Command):
'''
See Bluetooth spec @ 7.3.60 Read Local OOB Data Command
'''
# -----------------------------------------------------------------------------
@HCI_Command.command(
return_parameters_fields=[('status', STATUS_SPEC), ('tx_power', -1)]
@@ -2743,6 +2967,22 @@ class HCI_Write_Authenticated_Payload_Timeout_Command(HCI_Command):
'''
# -----------------------------------------------------------------------------
@HCI_Command.command(
return_parameters_fields=[
('status', STATUS_SPEC),
('c_192', 16),
('r_192', 16),
('c_256', 16),
('r_256', 16),
]
)
class HCI_Read_Local_OOB_Extended_Data_Command(HCI_Command):
'''
See Bluetooth spec @ 7.3.95 Read Local OOB Extended Data Command
'''
# -----------------------------------------------------------------------------
@HCI_Command.command(
return_parameters_fields=[
@@ -5299,6 +5539,14 @@ class HCI_User_Passkey_Request_Event(HCI_Event):
'''
# -----------------------------------------------------------------------------
@HCI_Event.event([('bd_addr', Address.parse_address)])
class HCI_Remote_OOB_Data_Request_Event(HCI_Event):
'''
See Bluetooth spec @ 7.7.44 Remote OOB Data Request Event
'''
# -----------------------------------------------------------------------------
@HCI_Event.event([('status', STATUS_SPEC), ('bd_addr', Address.parse_address)])
class HCI_Simple_Pairing_Complete_Event(HCI_Event):
@@ -5315,6 +5563,14 @@ class HCI_Link_Supervision_Timeout_Changed_Event(HCI_Event):
'''
# -----------------------------------------------------------------------------
@HCI_Event.event([('handle', 2)])
class HCI_Enhanced_Flush_Complete_Event(HCI_Event):
'''
See Bluetooth spec @ 7.7.47 Enhanced Flush Complete Event
'''
# -----------------------------------------------------------------------------
@HCI_Event.event([('bd_addr', Address.parse_address), ('passkey', 4)])
class HCI_User_Passkey_Notification_Event(HCI_Event):
@@ -5323,6 +5579,14 @@ class HCI_User_Passkey_Notification_Event(HCI_Event):
'''
# -----------------------------------------------------------------------------
@HCI_Event.event([('bd_addr', Address.parse_address), ('notification_type', 1)])
class HCI_Keypress_Notification_Event(HCI_Event):
'''
See Bluetooth spec @ 7.7.49 Keypress Notification Event
'''
# -----------------------------------------------------------------------------
@HCI_Event.event([('bd_addr', Address.parse_address), ('host_supported_features', 8)])
class HCI_Remote_Host_Supported_Features_Notification_Event(HCI_Event):
@@ -5373,7 +5637,7 @@ class HCI_AclDataPacket:
def __str__(self):
return (
f'{color("ACL", "blue")}: '
f'handle=0x{self.connection_handle:04x}'
f'handle=0x{self.connection_handle:04x}, '
f'pb={self.pb_flag}, bc={self.bc_flag}, '
f'data_total_length={self.data_total_length}, '
f'data={self.data.hex()}'

View File

@@ -18,10 +18,11 @@
import logging
import asyncio
import collections
from typing import Union
from . import rfcomm
from .colors import color
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
@@ -34,7 +35,12 @@ logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
class HfpProtocol:
def __init__(self, dlc):
dlc: rfcomm.DLC
buffer: str
lines: collections.deque
lines_available: asyncio.Event
def __init__(self, dlc: rfcomm.DLC) -> None:
self.dlc = dlc
self.buffer = ''
self.lines = collections.deque()
@@ -42,7 +48,7 @@ class HfpProtocol:
dlc.sink = self.feed
def feed(self, data):
def feed(self, data: Union[bytes, str]) -> None:
# Convert the data to a string if needed
if isinstance(data, bytes):
data = data.decode('utf-8')
@@ -57,19 +63,19 @@ class HfpProtocol:
if len(line) > 0:
self.on_line(line)
def on_line(self, line):
def on_line(self, line: str) -> None:
self.lines.append(line)
self.lines_available.set()
def send_command_line(self, line):
def send_command_line(self, line: str) -> None:
logger.debug(color(f'>>> {line}', 'yellow'))
self.dlc.write(line + '\r')
def send_response_line(self, line):
def send_response_line(self, line: str) -> None:
logger.debug(color(f'>>> {line}', 'yellow'))
self.dlc.write('\r\n' + line + '\r\n')
async def next_line(self):
async def next_line(self) -> str:
await self.lines_available.wait()
line = self.lines.popleft()
if not self.lines:
@@ -77,7 +83,7 @@ class HfpProtocol:
logger.debug(color(f'<<< {line}', 'green'))
return line
async def initialize_service(self):
async def initialize_service(self) -> None:
# Perform Service Level Connection Initialization
self.send_command_line('AT+BRSF=2072') # Retrieve Supported Features
await (self.next_line())

View File

@@ -23,6 +23,7 @@ import struct
from bumble.colors import color
from bumble.l2cap import L2CAP_PDU
from bumble.snoop import Snooper
from bumble import drivers
from typing import Optional
@@ -62,6 +63,7 @@ from .hci import (
HCI_Read_Local_Version_Information_Command,
HCI_Reset_Command,
HCI_Set_Event_Mask_Command,
map_null_terminated_utf8_string,
)
from .core import (
BT_BR_EDR_TRANSPORT,
@@ -115,6 +117,7 @@ class Host(AbortableEventEmitter):
super().__init__()
self.hci_sink = None
self.hci_metadata = None
self.ready = False # True when we can accept incoming packets
self.reset_done = False
self.connections = {} # Connections, by connection handle
@@ -140,6 +143,9 @@ class Host(AbortableEventEmitter):
# Connect to the source and sink if specified
if controller_source:
controller_source.set_packet_sink(self)
self.hci_metadata = getattr(
controller_source, 'metadata', self.hci_metadata
)
if controller_sink:
self.set_packet_sink(controller_sink)
@@ -169,7 +175,7 @@ class Host(AbortableEventEmitter):
self.emit('flush')
self.command_semaphore.release()
async def reset(self):
async def reset(self, driver_factory=drivers.get_driver_for_host):
if self.ready:
self.ready = False
await self.flush()
@@ -177,6 +183,15 @@ class Host(AbortableEventEmitter):
await self.send_command(HCI_Reset_Command(), check_result=True)
self.ready = True
# Instantiate and init a driver for the host if needed.
# NOTE: we don't keep a reference to the driver here, because we don't
# currently have a need for the driver later on. But if the driver interface
# evolves, it may be required, then, to store a reference to the driver in
# an object property.
if driver_factory is not None:
if driver := await driver_factory(self):
await driver.init_controller()
response = await self.send_command(
HCI_Read_Local_Supported_Commands_Command(), check_result=True
)
@@ -297,7 +312,7 @@ class Host(AbortableEventEmitter):
if self.snooper:
self.snooper.snoop(bytes(packet), Snooper.Direction.HOST_TO_CONTROLLER)
self.hci_sink.on_packet(packet.to_bytes())
self.hci_sink.on_packet(bytes(packet))
async def send_command(self, command, check_result=False):
logger.debug(f'{color("### HOST -> CONTROLLER", "blue")}: {command}')
@@ -349,7 +364,7 @@ class Host(AbortableEventEmitter):
asyncio.create_task(send_command(command))
def send_l2cap_pdu(self, connection_handle, cid, pdu):
l2cap_pdu = L2CAP_PDU(cid, pdu).to_bytes()
l2cap_pdu = bytes(L2CAP_PDU(cid, pdu))
# Send the data to the controller via ACL packets
bytes_remaining = len(l2cap_pdu)
@@ -887,7 +902,12 @@ class Host(AbortableEventEmitter):
if event.status != HCI_SUCCESS:
self.emit('remote_name_failure', event.bd_addr, event.status)
else:
self.emit('remote_name', event.bd_addr, event.remote_name)
utf8_name = event.remote_name
terminator = utf8_name.find(0)
if terminator >= 0:
utf8_name = utf8_name[0:terminator]
self.emit('remote_name', event.bd_addr, utf8_name)
def on_hci_remote_host_supported_features_notification_event(self, event):
self.emit(

View File

@@ -25,7 +25,7 @@ import asyncio
import logging
import os
import json
from typing import TYPE_CHECKING, Optional
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
from .colors import color
from .hci import Address
@@ -139,19 +139,19 @@ class PairingKeys:
# -----------------------------------------------------------------------------
class KeyStore:
async def delete(self, name):
async def delete(self, name: str):
pass
async def update(self, name, keys):
async def update(self, name: str, keys: PairingKeys) -> None:
pass
async def get(self, _name):
return PairingKeys()
async def get(self, _name: str) -> Optional[PairingKeys]:
return None
async def get_all(self):
async def get_all(self) -> List[Tuple[str, PairingKeys]]:
return []
async def delete_all(self):
async def delete_all(self) -> None:
all_keys = await self.get_all()
await asyncio.gather(*(self.delete(name) for (name, _) in all_keys))
@@ -177,23 +177,57 @@ class KeyStore:
separator = '\n'
@staticmethod
def create_for_device(device: Device) -> Optional[KeyStore]:
def create_for_device(device: Device) -> KeyStore:
if device.config.keystore is None:
return None
return MemoryKeyStore()
keystore_type = device.config.keystore.split(':', 1)[0]
if keystore_type == 'JsonKeyStore':
return JsonKeyStore.from_device(device)
return None
return MemoryKeyStore()
# -----------------------------------------------------------------------------
class JsonKeyStore(KeyStore):
"""
KeyStore implementation that is backed by a JSON file.
This implementation supports storing a hierarchy of key sets in a single file.
A key set is a representation of a PairingKeys object. Each key set is stored
in a map, with the address of paired peer as the key. Maps are themselves grouped
into namespaces, grouping pairing keys by controller addresses.
The JSON object model looks like:
{
"<namespace>": {
"peer-address": {
"address_type": <n>,
"irk" : {
"authenticated": <true/false>,
"value": "hex-encoded-key"
},
... other keys ...
},
... other peers ...
}
... other namespaces ...
}
A namespace is typically the BD_ADDR of a controller, since that is a convenient
unique identifier, but it may be something else.
A special namespace, called the "default" namespace, is used when instantiating this
class without a namespace. With the default namespace, reading from a file will
load an existing namespace if there is only one, which may be convenient for reading
from a file with a single key set and for which the namespace isn't known. If the
file does not include any existing key set, or if there are more than one and none
has the default name, a new one will be created with the name "__DEFAULT__".
"""
APP_NAME = 'Bumble'
APP_AUTHOR = 'Google'
KEYS_DIR = 'Pairing'
DEFAULT_NAMESPACE = '__DEFAULT__'
DEFAULT_BASE_NAME = "keys"
def __init__(self, namespace, filename=None):
self.namespace = namespace if namespace is not None else self.DEFAULT_NAMESPACE
@@ -208,8 +242,9 @@ class JsonKeyStore(KeyStore):
self.directory_name = os.path.join(
appdirs.user_data_dir(self.APP_NAME, self.APP_AUTHOR), self.KEYS_DIR
)
base_name = self.DEFAULT_BASE_NAME if namespace is None else self.namespace
json_filename = (
f'{self.namespace}.json'.lower().replace(':', '-').replace('/p', '-p')
f'{base_name}.json'.lower().replace(':', '-').replace('/p', '-p')
)
self.filename = os.path.join(self.directory_name, json_filename)
else:
@@ -219,11 +254,13 @@ class JsonKeyStore(KeyStore):
logger.debug(f'JSON keystore: {self.filename}')
@staticmethod
def from_device(device: Device) -> Optional[JsonKeyStore]:
if not device.config.keystore:
return None
params = device.config.keystore.split(':', 1)[1:]
def from_device(device: Device, filename=None) -> Optional[JsonKeyStore]:
if not filename:
# Extract the filename from the config if there is one
if device.config.keystore is not None:
params = device.config.keystore.split(':', 1)[1:]
if params:
filename = params[0]
# Use a namespace based on the device address
if device.public_address not in (Address.ANY, Address.ANY_RANDOM):
@@ -232,19 +269,31 @@ class JsonKeyStore(KeyStore):
namespace = str(device.random_address)
else:
namespace = JsonKeyStore.DEFAULT_NAMESPACE
if params:
filename = params[0]
else:
filename = None
return JsonKeyStore(namespace, filename)
async def load(self):
# Try to open the file, without failing. If the file does not exist, it
# will be created upon saving.
try:
with open(self.filename, 'r', encoding='utf-8') as json_file:
return json.load(json_file)
db = json.load(json_file)
except FileNotFoundError:
return {}
db = {}
# First, look for a namespace match
if self.namespace in db:
return (db, db[self.namespace])
# Then, if the namespace is the default namespace, and there's
# only one entry in the db, use that
if self.namespace == self.DEFAULT_NAMESPACE and len(db) == 1:
return next(iter(db.items()))
# Finally, just create an empty key map for the namespace
key_map = {}
db[self.namespace] = key_map
return (db, key_map)
async def save(self, db):
# Create the directory if it doesn't exist
@@ -257,53 +306,51 @@ class JsonKeyStore(KeyStore):
json.dump(db, output, sort_keys=True, indent=4)
# Atomically replace the previous file
os.rename(temp_filename, self.filename)
os.replace(temp_filename, self.filename)
async def delete(self, name: str) -> None:
db = await self.load()
namespace = db.get(self.namespace)
if namespace is None:
raise KeyError(name)
del namespace[name]
db, key_map = await self.load()
del key_map[name]
await self.save(db)
async def update(self, name, keys):
db = await self.load()
namespace = db.setdefault(self.namespace, {})
namespace.setdefault(name, {}).update(keys.to_dict())
db, key_map = await self.load()
key_map.setdefault(name, {}).update(keys.to_dict())
await self.save(db)
async def get_all(self):
db = await self.load()
namespace = db.get(self.namespace)
if namespace is None:
return []
return [
(name, PairingKeys.from_dict(keys)) for (name, keys) in namespace.items()
]
_, key_map = await self.load()
return [(name, PairingKeys.from_dict(keys)) for (name, keys) in key_map.items()]
async def delete_all(self):
db = await self.load()
db.pop(self.namespace, None)
db, key_map = await self.load()
key_map.clear()
await self.save(db)
async def get(self, name: str) -> Optional[PairingKeys]:
db = await self.load()
namespace = db.get(self.namespace)
if namespace is None:
_, key_map = await self.load()
if name not in key_map:
return None
keys = namespace.get(name)
if keys is None:
return None
return PairingKeys.from_dict(key_map[name])
return PairingKeys.from_dict(keys)
# -----------------------------------------------------------------------------
class MemoryKeyStore(KeyStore):
all_keys: Dict[str, PairingKeys]
def __init__(self) -> None:
self.all_keys = {}
async def delete(self, name: str) -> None:
if name in self.all_keys:
del self.all_keys[name]
async def update(self, name: str, keys: PairingKeys) -> None:
self.all_keys[name] = keys
async def get(self, name: str) -> Optional[PairingKeys]:
return self.all_keys.get(name)
async def get_all(self) -> List[Tuple[str, PairingKeys]]:
return list(self.all_keys.items())

View File

@@ -22,7 +22,19 @@ import struct
from collections import deque
from pyee import EventEmitter
from typing import Dict, Type
from typing import (
Dict,
Type,
List,
Optional,
Tuple,
Callable,
Any,
Union,
Deque,
Iterable,
TYPE_CHECKING,
)
from .colors import color
from .core import BT_CENTRAL_ROLE, InvalidStateError, ProtocolError
@@ -33,6 +45,9 @@ from .hci import (
name_or_number,
)
if TYPE_CHECKING:
from bumble.device import Connection
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
@@ -155,7 +170,7 @@ class L2CAP_PDU:
'''
@staticmethod
def from_bytes(data):
def from_bytes(data: bytes) -> L2CAP_PDU:
# Sanity check
if len(data) < 4:
raise ValueError('not enough data for L2CAP header')
@@ -165,18 +180,18 @@ class L2CAP_PDU:
return L2CAP_PDU(l2cap_pdu_cid, l2cap_pdu_payload)
def to_bytes(self):
def to_bytes(self) -> bytes:
header = struct.pack('<HH', len(self.payload), self.cid)
return header + self.payload
def __init__(self, cid, payload):
def __init__(self, cid: int, payload: bytes) -> None:
self.cid = cid
self.payload = payload
def __bytes__(self):
def __bytes__(self) -> bytes:
return self.to_bytes()
def __str__(self):
def __str__(self) -> str:
return f'{color("L2CAP", "green")} [CID={self.cid}]: {self.payload.hex()}'
@@ -188,10 +203,10 @@ class L2CAP_Control_Frame:
classes: Dict[int, Type[L2CAP_Control_Frame]] = {}
code = 0
name = None
name: str
@staticmethod
def from_bytes(pdu):
def from_bytes(pdu: bytes) -> L2CAP_Control_Frame:
code = pdu[0]
cls = L2CAP_Control_Frame.classes.get(code)
@@ -216,11 +231,11 @@ class L2CAP_Control_Frame:
return self
@staticmethod
def code_name(code):
def code_name(code: int) -> str:
return name_or_number(L2CAP_CONTROL_FRAME_NAMES, code)
@staticmethod
def decode_configuration_options(data):
def decode_configuration_options(data: bytes) -> List[Tuple[int, bytes]]:
options = []
while len(data) >= 2:
value_type = data[0]
@@ -232,7 +247,7 @@ class L2CAP_Control_Frame:
return options
@staticmethod
def encode_configuration_options(options):
def encode_configuration_options(options: List[Tuple[int, bytes]]) -> bytes:
return b''.join(
[bytes([option[0], len(option[1])]) + option[1] for option in options]
)
@@ -256,29 +271,30 @@ class L2CAP_Control_Frame:
return inner
def __init__(self, pdu=None, **kwargs):
def __init__(self, pdu=None, **kwargs) -> None:
self.identifier = kwargs.get('identifier', 0)
if hasattr(self, 'fields') and kwargs:
HCI_Object.init_from_fields(self, self.fields, kwargs)
if pdu is None:
data = HCI_Object.dict_to_bytes(kwargs, self.fields)
pdu = (
bytes([self.code, self.identifier])
+ struct.pack('<H', len(data))
+ data
)
if hasattr(self, 'fields'):
if kwargs:
HCI_Object.init_from_fields(self, self.fields, kwargs)
if pdu is None:
data = HCI_Object.dict_to_bytes(kwargs, self.fields)
pdu = (
bytes([self.code, self.identifier])
+ struct.pack('<H', len(data))
+ data
)
self.pdu = pdu
def init_from_bytes(self, pdu, offset):
return HCI_Object.init_from_bytes(self, pdu, offset, self.fields)
def to_bytes(self):
def to_bytes(self) -> bytes:
return self.pdu
def __bytes__(self):
def __bytes__(self) -> bytes:
return self.to_bytes()
def __str__(self):
def __str__(self) -> str:
result = f'{color(self.name, "yellow")} [ID={self.identifier}]'
if fields := getattr(self, 'fields', None):
result += ':\n' + HCI_Object.format_fields(self.__dict__, fields, ' ')
@@ -315,7 +331,7 @@ class L2CAP_Command_Reject(L2CAP_Control_Frame):
}
@staticmethod
def reason_name(reason):
def reason_name(reason: int) -> str:
return name_or_number(L2CAP_Command_Reject.REASON_NAMES, reason)
@@ -343,7 +359,7 @@ class L2CAP_Connection_Request(L2CAP_Control_Frame):
'''
@staticmethod
def parse_psm(data, offset=0):
def parse_psm(data: bytes, offset: int = 0) -> Tuple[int, int]:
psm_length = 2
psm = data[offset] | data[offset + 1] << 8
@@ -355,7 +371,7 @@ class L2CAP_Connection_Request(L2CAP_Control_Frame):
return offset + psm_length, psm
@staticmethod
def serialize_psm(psm):
def serialize_psm(psm: int) -> bytes:
serialized = struct.pack('<H', psm & 0xFFFF)
psm >>= 16
while psm:
@@ -405,7 +421,7 @@ class L2CAP_Connection_Response(L2CAP_Control_Frame):
}
@staticmethod
def result_name(result):
def result_name(result: int) -> str:
return name_or_number(L2CAP_Connection_Response.RESULT_NAMES, result)
@@ -452,7 +468,7 @@ class L2CAP_Configure_Response(L2CAP_Control_Frame):
}
@staticmethod
def result_name(result):
def result_name(result: int) -> str:
return name_or_number(L2CAP_Configure_Response.RESULT_NAMES, result)
@@ -529,7 +545,7 @@ class L2CAP_Information_Request(L2CAP_Control_Frame):
}
@staticmethod
def info_type_name(info_type):
def info_type_name(info_type: int) -> str:
return name_or_number(L2CAP_Information_Request.INFO_TYPE_NAMES, info_type)
@@ -556,7 +572,7 @@ class L2CAP_Information_Response(L2CAP_Control_Frame):
RESULT_NAMES = {SUCCESS: 'SUCCESS', NOT_SUPPORTED: 'NOT_SUPPORTED'}
@staticmethod
def result_name(result):
def result_name(result: int) -> str:
return name_or_number(L2CAP_Information_Response.RESULT_NAMES, result)
@@ -588,6 +604,8 @@ class L2CAP_LE_Credit_Based_Connection_Request(L2CAP_Control_Frame):
(CODE 0x14)
'''
source_cid: int
# -----------------------------------------------------------------------------
@L2CAP_Control_Frame.subclass(
@@ -640,7 +658,7 @@ class L2CAP_LE_Credit_Based_Connection_Response(L2CAP_Control_Frame):
}
@staticmethod
def result_name(result):
def result_name(result: int) -> str:
return name_or_number(
L2CAP_LE_Credit_Based_Connection_Response.RESULT_NAMES, result
)
@@ -701,7 +719,22 @@ class Channel(EventEmitter):
WAIT_CONTROL_IND: 'WAIT_CONTROL_IND',
}
def __init__(self, manager, connection, signaling_cid, psm, source_cid, mtu):
connection_result: Optional[asyncio.Future[None]]
disconnection_result: Optional[asyncio.Future[None]]
response: Optional[asyncio.Future[bytes]]
sink: Optional[Callable[[bytes], Any]]
state: int
connection: Connection
def __init__(
self,
manager: 'ChannelManager',
connection: Connection,
signaling_cid: int,
psm: int,
source_cid: int,
mtu: int,
) -> None:
super().__init__()
self.manager = manager
self.connection = connection
@@ -716,19 +749,19 @@ class Channel(EventEmitter):
self.disconnection_result = None
self.sink = None
def change_state(self, new_state):
def change_state(self, new_state: int) -> None:
logger.debug(
f'{self} state change -> {color(Channel.STATE_NAMES[new_state], "cyan")}'
)
self.state = new_state
def send_pdu(self, pdu):
def send_pdu(self, pdu) -> None:
self.manager.send_pdu(self.connection, self.destination_cid, pdu)
def send_control_frame(self, frame):
def send_control_frame(self, frame) -> None:
self.manager.send_control_frame(self.connection, self.signaling_cid, frame)
async def send_request(self, request):
async def send_request(self, request) -> bytes:
# Check that there isn't already a request pending
if self.response:
raise InvalidStateError('request already pending')
@@ -739,7 +772,7 @@ class Channel(EventEmitter):
self.send_pdu(request)
return await self.response
def on_pdu(self, pdu):
def on_pdu(self, pdu) -> None:
if self.response:
self.response.set_result(pdu)
self.response = None
@@ -751,7 +784,7 @@ class Channel(EventEmitter):
color('received pdu without a pending request or sink', 'red')
)
async def connect(self):
async def connect(self) -> None:
if self.state != Channel.CLOSED:
raise InvalidStateError('invalid state')
@@ -778,7 +811,7 @@ class Channel(EventEmitter):
finally:
self.connection_result = None
async def disconnect(self):
async def disconnect(self) -> None:
if self.state != Channel.OPEN:
raise InvalidStateError('invalid state')
@@ -796,12 +829,12 @@ class Channel(EventEmitter):
self.disconnection_result = asyncio.get_running_loop().create_future()
return await self.disconnection_result
def abort(self):
def abort(self) -> None:
if self.state == self.OPEN:
self.change_state(self.CLOSED)
self.emit('close')
def send_configure_request(self):
def send_configure_request(self) -> None:
options = L2CAP_Control_Frame.encode_configuration_options(
[
(
@@ -819,7 +852,7 @@ class Channel(EventEmitter):
)
)
def on_connection_request(self, request):
def on_connection_request(self, request) -> None:
self.destination_cid = request.source_cid
self.change_state(Channel.WAIT_CONNECT)
self.send_control_frame(
@@ -858,7 +891,7 @@ class Channel(EventEmitter):
)
self.connection_result = None
def on_configure_request(self, request):
def on_configure_request(self, request) -> None:
if self.state not in (
Channel.WAIT_CONFIG,
Channel.WAIT_CONFIG_REQ,
@@ -896,7 +929,7 @@ class Channel(EventEmitter):
elif self.state == Channel.WAIT_CONFIG_REQ_RSP:
self.change_state(Channel.WAIT_CONFIG_RSP)
def on_configure_response(self, response):
def on_configure_response(self, response) -> None:
if response.result == L2CAP_Configure_Response.SUCCESS:
if self.state == Channel.WAIT_CONFIG_REQ_RSP:
self.change_state(Channel.WAIT_CONFIG_REQ)
@@ -930,7 +963,7 @@ class Channel(EventEmitter):
)
# TODO: decide how to fail gracefully
def on_disconnection_request(self, request):
def on_disconnection_request(self, request) -> None:
if self.state in (Channel.OPEN, Channel.WAIT_DISCONNECT):
self.send_control_frame(
L2CAP_Disconnection_Response(
@@ -945,7 +978,7 @@ class Channel(EventEmitter):
else:
logger.warning(color('invalid state', 'red'))
def on_disconnection_response(self, response):
def on_disconnection_response(self, response) -> None:
if self.state != Channel.WAIT_DISCONNECT:
logger.warning(color('invalid state', 'red'))
return
@@ -964,7 +997,7 @@ class Channel(EventEmitter):
self.emit('close')
self.manager.on_channel_closed(self)
def __str__(self):
def __str__(self) -> str:
return (
f'Channel({self.source_cid}->{self.destination_cid}, '
f'PSM={self.psm}, '
@@ -995,25 +1028,32 @@ class LeConnectionOrientedChannel(EventEmitter):
CONNECTION_ERROR: 'CONNECTION_ERROR',
}
out_queue: Deque[bytes]
connection_result: Optional[asyncio.Future[LeConnectionOrientedChannel]]
disconnection_result: Optional[asyncio.Future[None]]
out_sdu: Optional[bytes]
state: int
connection: Connection
@staticmethod
def state_name(state):
def state_name(state: int) -> str:
return name_or_number(LeConnectionOrientedChannel.STATE_NAMES, state)
def __init__(
self,
manager,
connection,
le_psm,
source_cid,
destination_cid,
mtu,
mps,
credits, # pylint: disable=redefined-builtin
peer_mtu,
peer_mps,
peer_credits,
connected,
):
manager: 'ChannelManager',
connection: Connection,
le_psm: int,
source_cid: int,
destination_cid: int,
mtu: int,
mps: int,
credits: int, # pylint: disable=redefined-builtin
peer_mtu: int,
peer_mps: int,
peer_credits: int,
connected: bool,
) -> None:
super().__init__()
self.manager = manager
self.connection = connection
@@ -1045,7 +1085,7 @@ class LeConnectionOrientedChannel(EventEmitter):
else:
self.state = LeConnectionOrientedChannel.INIT
def change_state(self, new_state):
def change_state(self, new_state: int) -> None:
logger.debug(
f'{self} state change -> {color(self.state_name(new_state), "cyan")}'
)
@@ -1056,13 +1096,13 @@ class LeConnectionOrientedChannel(EventEmitter):
elif new_state == self.DISCONNECTED:
self.emit('close')
def send_pdu(self, pdu):
def send_pdu(self, pdu) -> None:
self.manager.send_pdu(self.connection, self.destination_cid, pdu)
def send_control_frame(self, frame):
def send_control_frame(self, frame) -> None:
self.manager.send_control_frame(self.connection, L2CAP_LE_SIGNALING_CID, frame)
async def connect(self):
async def connect(self) -> LeConnectionOrientedChannel:
# Check that we're in the right state
if self.state != self.INIT:
raise InvalidStateError('not in a connectable state')
@@ -1090,7 +1130,7 @@ class LeConnectionOrientedChannel(EventEmitter):
# Wait for the connection to succeed or fail
return await self.connection_result
async def disconnect(self):
async def disconnect(self) -> None:
# Check that we're connected
if self.state != self.CONNECTED:
raise InvalidStateError('not connected')
@@ -1110,11 +1150,11 @@ class LeConnectionOrientedChannel(EventEmitter):
self.disconnection_result = asyncio.get_running_loop().create_future()
return await self.disconnection_result
def abort(self):
def abort(self) -> None:
if self.state == self.CONNECTED:
self.change_state(self.DISCONNECTED)
def on_pdu(self, pdu):
def on_pdu(self, pdu) -> None:
if self.sink is None:
logger.warning('received pdu without a sink')
return
@@ -1180,7 +1220,7 @@ class LeConnectionOrientedChannel(EventEmitter):
self.in_sdu = None
self.in_sdu_length = 0
def on_connection_response(self, response):
def on_connection_response(self, response) -> None:
# Look for a matching pending response result
if self.connection_result is None:
logger.warning(
@@ -1214,14 +1254,14 @@ class LeConnectionOrientedChannel(EventEmitter):
# Cleanup
self.connection_result = None
def on_credits(self, credits): # pylint: disable=redefined-builtin
def on_credits(self, credits: int) -> None: # pylint: disable=redefined-builtin
self.credits += credits
logger.debug(f'received {credits} credits, total = {self.credits}')
# Try to send more data if we have any queued up
self.process_output()
def on_disconnection_request(self, request):
def on_disconnection_request(self, request) -> None:
self.send_control_frame(
L2CAP_Disconnection_Response(
identifier=request.identifier,
@@ -1232,7 +1272,7 @@ class LeConnectionOrientedChannel(EventEmitter):
self.change_state(self.DISCONNECTED)
self.flush_output()
def on_disconnection_response(self, response):
def on_disconnection_response(self, response) -> None:
if self.state != self.DISCONNECTING:
logger.warning(color('invalid state', 'red'))
return
@@ -1249,11 +1289,11 @@ class LeConnectionOrientedChannel(EventEmitter):
self.disconnection_result.set_result(None)
self.disconnection_result = None
def flush_output(self):
def flush_output(self) -> None:
self.out_queue.clear()
self.out_sdu = None
def process_output(self):
def process_output(self) -> None:
while self.credits > 0:
if self.out_sdu is not None:
# Finish the current SDU
@@ -1296,7 +1336,7 @@ class LeConnectionOrientedChannel(EventEmitter):
self.drained.set()
return
def write(self, data):
def write(self, data: bytes) -> None:
if self.state != self.CONNECTED:
logger.warning('not connected, dropping data')
return
@@ -1311,18 +1351,18 @@ class LeConnectionOrientedChannel(EventEmitter):
# Send what we can
self.process_output()
async def drain(self):
async def drain(self) -> None:
await self.drained.wait()
def pause_reading(self):
def pause_reading(self) -> None:
# TODO: not implemented yet
pass
def resume_reading(self):
def resume_reading(self) -> None:
# TODO: not implemented yet
pass
def __str__(self):
def __str__(self) -> str:
return (
f'CoC({self.source_cid}->{self.destination_cid}, '
f'State={self.state_name(self.state)}, '
@@ -1335,9 +1375,21 @@ class LeConnectionOrientedChannel(EventEmitter):
# -----------------------------------------------------------------------------
class ChannelManager:
identifiers: Dict[int, int]
channels: Dict[int, Dict[int, Union[Channel, LeConnectionOrientedChannel]]]
servers: Dict[int, Callable[[Channel], Any]]
le_coc_channels: Dict[int, Dict[int, LeConnectionOrientedChannel]]
le_coc_servers: Dict[
int, Tuple[Callable[[LeConnectionOrientedChannel], Any], int, int, int]
]
le_coc_requests: Dict[int, L2CAP_LE_Credit_Based_Connection_Request]
fixed_channels: Dict[int, Optional[Callable[[int, bytes], Any]]]
def __init__(
self, extended_features=(), connectionless_mtu=L2CAP_DEFAULT_CONNECTIONLESS_MTU
):
self,
extended_features: Iterable[int] = (),
connectionless_mtu: int = L2CAP_DEFAULT_CONNECTIONLESS_MTU,
) -> None:
self._host = None
self.identifiers = {} # Incrementing identifier values by connection
self.channels = {} # All channels, mapped by connection and source cid
@@ -1366,20 +1418,20 @@ class ChannelManager:
if host is not None:
host.on('disconnection', self.on_disconnection)
def find_channel(self, connection_handle, cid):
def find_channel(self, connection_handle: int, cid: int):
if connection_channels := self.channels.get(connection_handle):
return connection_channels.get(cid)
return None
def find_le_coc_channel(self, connection_handle, cid):
def find_le_coc_channel(self, connection_handle: int, cid: int):
if connection_channels := self.le_coc_channels.get(connection_handle):
return connection_channels.get(cid)
return None
@staticmethod
def find_free_br_edr_cid(channels):
def find_free_br_edr_cid(channels: Iterable[int]) -> int:
# Pick the smallest valid CID that's not already in the list
# (not necessarily the most efficient algorithm, but the list of CID is
# very small in practice)
@@ -1392,7 +1444,7 @@ class ChannelManager:
raise RuntimeError('no free CID available')
@staticmethod
def find_free_le_cid(channels):
def find_free_le_cid(channels: Iterable[int]) -> int:
# Pick the smallest valid CID that's not already in the list
# (not necessarily the most efficient algorithm, but the list of CID is
# very small in practice)
@@ -1405,7 +1457,7 @@ class ChannelManager:
raise RuntimeError('no free CID')
@staticmethod
def check_le_coc_parameters(max_credits, mtu, mps):
def check_le_coc_parameters(max_credits: int, mtu: int, mps: int) -> None:
if (
max_credits < 1
or max_credits > L2CAP_LE_CREDIT_BASED_CONNECTION_MAX_CREDITS
@@ -1419,19 +1471,21 @@ class ChannelManager:
):
raise ValueError('MPS out of range')
def next_identifier(self, connection):
def next_identifier(self, connection: Connection) -> int:
identifier = (self.identifiers.setdefault(connection.handle, 0) + 1) % 256
self.identifiers[connection.handle] = identifier
return identifier
def register_fixed_channel(self, cid, handler):
def register_fixed_channel(
self, cid: int, handler: Callable[[int, bytes], Any]
) -> None:
self.fixed_channels[cid] = handler
def deregister_fixed_channel(self, cid):
def deregister_fixed_channel(self, cid: int) -> None:
if cid in self.fixed_channels:
del self.fixed_channels[cid]
def register_server(self, psm, server):
def register_server(self, psm: int, server: Callable[[Channel], Any]) -> int:
if psm == 0:
# Find a free PSM
for candidate in range(
@@ -1465,12 +1519,12 @@ class ChannelManager:
def register_le_coc_server(
self,
psm,
server,
max_credits=L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_INITIAL_CREDITS,
mtu=L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU,
mps=L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS,
):
psm: int,
server: Callable[[LeConnectionOrientedChannel], Any],
max_credits: int = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_INITIAL_CREDITS,
mtu: int = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MTU,
mps: int = L2CAP_LE_CREDIT_BASED_CONNECTION_DEFAULT_MPS,
) -> int:
self.check_le_coc_parameters(max_credits, mtu, mps)
if psm == 0:
@@ -1498,7 +1552,7 @@ class ChannelManager:
return psm
def on_disconnection(self, connection_handle, _reason):
def on_disconnection(self, connection_handle: int, _reason: int) -> None:
logger.debug(f'disconnection from {connection_handle}, cleaning up channels')
if connection_handle in self.channels:
for _, channel in self.channels[connection_handle].items():
@@ -1511,7 +1565,7 @@ class ChannelManager:
if connection_handle in self.identifiers:
del self.identifiers[connection_handle]
def send_pdu(self, connection, cid, pdu):
def send_pdu(self, connection, cid: int, pdu) -> None:
pdu_str = pdu.hex() if isinstance(pdu, bytes) else str(pdu)
logger.debug(
f'{color(">>> Sending L2CAP PDU", "blue")} '
@@ -1520,14 +1574,16 @@ class ChannelManager:
)
self.host.send_l2cap_pdu(connection.handle, cid, bytes(pdu))
def on_pdu(self, connection, cid, pdu):
def on_pdu(self, connection: Connection, cid: int, pdu) -> None:
if cid in (L2CAP_SIGNALING_CID, L2CAP_LE_SIGNALING_CID):
# Parse the L2CAP payload into a Control Frame object
control_frame = L2CAP_Control_Frame.from_bytes(pdu)
self.on_control_frame(connection, cid, control_frame)
elif cid in self.fixed_channels:
self.fixed_channels[cid](connection.handle, pdu)
handler = self.fixed_channels[cid]
assert handler is not None
handler(connection.handle, pdu)
else:
if (channel := self.find_channel(connection.handle, cid)) is None:
logger.warning(
@@ -1539,7 +1595,9 @@ class ChannelManager:
channel.on_pdu(pdu)
def send_control_frame(self, connection, cid, control_frame):
def send_control_frame(
self, connection: Connection, cid: int, control_frame
) -> None:
logger.debug(
f'{color(">>> Sending L2CAP Signaling Control Frame", "blue")} '
f'on connection [0x{connection.handle:04X}] (CID={cid}) '
@@ -1547,7 +1605,7 @@ class ChannelManager:
)
self.host.send_l2cap_pdu(connection.handle, cid, bytes(control_frame))
def on_control_frame(self, connection, cid, control_frame):
def on_control_frame(self, connection: Connection, cid: int, control_frame) -> None:
logger.debug(
f'{color("<<< Received L2CAP Signaling Control Frame", "green")} '
f'on connection [0x{connection.handle:04X}] (CID={cid}) '
@@ -1584,10 +1642,14 @@ class ChannelManager:
),
)
def on_l2cap_command_reject(self, _connection, _cid, packet):
def on_l2cap_command_reject(
self, _connection: Connection, _cid: int, packet
) -> None:
logger.warning(f'{color("!!! Command rejected:", "red")} {packet.reason}')
def on_l2cap_connection_request(self, connection, cid, request):
def on_l2cap_connection_request(
self, connection: Connection, cid: int, request
) -> None:
# Check if there's a server for this PSM
server = self.servers.get(request.psm)
if server:
@@ -1639,7 +1701,9 @@ class ChannelManager:
),
)
def on_l2cap_connection_response(self, connection, cid, response):
def on_l2cap_connection_response(
self, connection: Connection, cid: int, response
) -> None:
if (
channel := self.find_channel(connection.handle, response.source_cid)
) is None:
@@ -1654,7 +1718,9 @@ class ChannelManager:
channel.on_connection_response(response)
def on_l2cap_configure_request(self, connection, cid, request):
def on_l2cap_configure_request(
self, connection: Connection, cid: int, request
) -> None:
if (
channel := self.find_channel(connection.handle, request.destination_cid)
) is None:
@@ -1669,7 +1735,9 @@ class ChannelManager:
channel.on_configure_request(request)
def on_l2cap_configure_response(self, connection, cid, response):
def on_l2cap_configure_response(
self, connection: Connection, cid: int, response
) -> None:
if (
channel := self.find_channel(connection.handle, response.source_cid)
) is None:
@@ -1684,7 +1752,9 @@ class ChannelManager:
channel.on_configure_response(response)
def on_l2cap_disconnection_request(self, connection, cid, request):
def on_l2cap_disconnection_request(
self, connection: Connection, cid: int, request
) -> None:
if (
channel := self.find_channel(connection.handle, request.destination_cid)
) is None:
@@ -1699,7 +1769,9 @@ class ChannelManager:
channel.on_disconnection_request(request)
def on_l2cap_disconnection_response(self, connection, cid, response):
def on_l2cap_disconnection_response(
self, connection: Connection, cid: int, response
) -> None:
if (
channel := self.find_channel(connection.handle, response.source_cid)
) is None:
@@ -1714,7 +1786,7 @@ class ChannelManager:
channel.on_disconnection_response(response)
def on_l2cap_echo_request(self, connection, cid, request):
def on_l2cap_echo_request(self, connection: Connection, cid: int, request) -> None:
logger.debug(f'<<< Echo request: data={request.data.hex()}')
self.send_control_frame(
connection,
@@ -1722,11 +1794,15 @@ class ChannelManager:
L2CAP_Echo_Response(identifier=request.identifier, data=request.data),
)
def on_l2cap_echo_response(self, _connection, _cid, response):
def on_l2cap_echo_response(
self, _connection: Connection, _cid: int, response
) -> None:
logger.debug(f'<<< Echo response: data={response.data.hex()}')
# TODO notify listeners
def on_l2cap_information_request(self, connection, cid, request):
def on_l2cap_information_request(
self, connection: Connection, cid: int, request
) -> None:
if request.info_type == L2CAP_Information_Request.CONNECTIONLESS_MTU:
result = L2CAP_Information_Response.SUCCESS
data = self.connectionless_mtu.to_bytes(2, 'little')
@@ -1750,7 +1826,9 @@ class ChannelManager:
),
)
def on_l2cap_connection_parameter_update_request(self, connection, cid, request):
def on_l2cap_connection_parameter_update_request(
self, connection: Connection, cid: int, request
):
if connection.role == BT_CENTRAL_ROLE:
self.send_control_frame(
connection,
@@ -1769,7 +1847,7 @@ class ChannelManager:
supervision_timeout=request.timeout,
min_ce_length=0,
max_ce_length=0,
)
) # type: ignore[call-arg]
)
else:
self.send_control_frame(
@@ -1781,11 +1859,15 @@ class ChannelManager:
),
)
def on_l2cap_connection_parameter_update_response(self, connection, cid, response):
def on_l2cap_connection_parameter_update_response(
self, connection: Connection, cid: int, response
) -> None:
# TODO: check response
pass
def on_l2cap_le_credit_based_connection_request(self, connection, cid, request):
def on_l2cap_le_credit_based_connection_request(
self, connection: Connection, cid: int, request
) -> None:
if request.le_psm in self.le_coc_servers:
(server, max_credits, mtu, mps) = self.le_coc_servers[request.le_psm]
@@ -1887,7 +1969,9 @@ class ChannelManager:
),
)
def on_l2cap_le_credit_based_connection_response(self, connection, _cid, response):
def on_l2cap_le_credit_based_connection_response(
self, connection: Connection, _cid: int, response
) -> None:
# Find the pending request by identifier
request = self.le_coc_requests.get(response.identifier)
if request is None:
@@ -1910,7 +1994,9 @@ class ChannelManager:
# Process the response
channel.on_connection_response(response)
def on_l2cap_le_flow_control_credit(self, connection, _cid, credit):
def on_l2cap_le_flow_control_credit(
self, connection: Connection, _cid: int, credit
) -> None:
channel = self.find_le_coc_channel(connection.handle, credit.cid)
if channel is None:
logger.warning(f'received credits for an unknown channel (cid={credit.cid}')
@@ -1918,13 +2004,15 @@ class ChannelManager:
channel.on_credits(credit.credits)
def on_channel_closed(self, channel):
def on_channel_closed(self, channel: Channel) -> None:
connection_channels = self.channels.get(channel.connection.handle)
if connection_channels:
if channel.source_cid in connection_channels:
del connection_channels[channel.source_cid]
async def open_le_coc(self, connection, psm, max_credits, mtu, mps):
async def open_le_coc(
self, connection: Connection, psm: int, max_credits: int, mtu: int, mps: int
) -> LeConnectionOrientedChannel:
self.check_le_coc_parameters(max_credits, mtu, mps)
# Find a free CID for the new channel
@@ -1965,7 +2053,7 @@ class ChannelManager:
return channel
async def connect(self, connection, psm):
async def connect(self, connection: Connection, psm: int) -> Channel:
# NOTE: this implementation hard-codes BR/EDR
# Find a free CID for a new channel

View File

@@ -65,8 +65,9 @@ class PairingDelegate:
DISTRIBUTE_SIGNING_KEY = SMP_SIGN_KEY_DISTRIBUTION_FLAG
DISTRIBUTE_LINK_KEY = SMP_LINK_KEY_DISTRIBUTION_FLAG
DEFAULT_KEY_DISTRIBUTION: int = (
SMP_ENC_KEY_DISTRIBUTION_FLAG | SMP_ID_KEY_DISTRIBUTION_FLAG
DEFAULT_KEY_DISTRIBUTION: KeyDistribution = (
KeyDistribution.DISTRIBUTE_ENCRYPTION_KEY
| KeyDistribution.DISTRIBUTE_IDENTITY_KEY
)
# Default mapping from abstract to Classic I/O capabilities.
@@ -85,9 +86,9 @@ class PairingDelegate:
def __init__(
self,
io_capability=NO_OUTPUT_NO_INPUT,
local_initiator_key_distribution=DEFAULT_KEY_DISTRIBUTION,
local_responder_key_distribution=DEFAULT_KEY_DISTRIBUTION,
io_capability: IoCapability = NO_OUTPUT_NO_INPUT,
local_initiator_key_distribution: KeyDistribution = DEFAULT_KEY_DISTRIBUTION,
local_responder_key_distribution: KeyDistribution = DEFAULT_KEY_DISTRIBUTION,
) -> None:
self.io_capability = io_capability
self.local_initiator_key_distribution = local_initiator_key_distribution
@@ -113,8 +114,11 @@ class PairingDelegate:
"""Accept or reject a Pairing request."""
return True
async def confirm(self) -> bool:
"""Respond yes or no to a Pairing confirmation question."""
async def confirm(self, auto: bool = False) -> bool:
"""
Respond yes or no to a Pairing confirmation question.
The `auto` parameter stands for automatic confirmation.
"""
return True
# pylint: disable-next=unused-argument
@@ -129,7 +133,7 @@ class PairingDelegate:
"""
return 0
async def get_string(self, max_length) -> Optional[str]:
async def get_string(self, max_length: int) -> Optional[str]:
"""
Return a string whose utf-8 encoding is up to max_length bytes.
"""

105
bumble/pandora/__init__.py Normal file
View File

@@ -0,0 +1,105 @@
# Copyright 2022 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Bumble Pandora server.
This module implement the Pandora Bluetooth test APIs for the Bumble stack.
"""
__version__ = "0.0.1"
import grpc
import grpc.aio
from .config import Config
from .device import PandoraDevice
from .host import HostService
from .security import SecurityService, SecurityStorageService
from pandora.host_grpc_aio import add_HostServicer_to_server
from pandora.security_grpc_aio import (
add_SecurityServicer_to_server,
add_SecurityStorageServicer_to_server,
)
from typing import Callable, List, Optional
# public symbols
__all__ = [
'register_servicer_hook',
'serve',
'Config',
'PandoraDevice',
]
# Add servicers hooks.
_SERVICERS_HOOKS: List[Callable[[PandoraDevice, Config, grpc.aio.Server], None]] = []
def register_servicer_hook(
hook: Callable[[PandoraDevice, Config, grpc.aio.Server], None]
) -> None:
_SERVICERS_HOOKS.append(hook)
async def serve(
bumble: PandoraDevice,
config: Config = Config(),
grpc_server: Optional[grpc.aio.Server] = None,
port: int = 0,
) -> None:
# initialize a gRPC server if not provided.
server = grpc_server if grpc_server is not None else grpc.aio.server()
port = server.add_insecure_port(f'localhost:{port}')
try:
while True:
# load server config from dict.
config.load_from_dict(bumble.config.get('server', {}))
# add Pandora services to the gRPC server.
add_HostServicer_to_server(
HostService(server, bumble.device, config), server
)
add_SecurityServicer_to_server(
SecurityService(bumble.device, config), server
)
add_SecurityStorageServicer_to_server(
SecurityStorageService(bumble.device, config), server
)
# call hooks if any.
for hook in _SERVICERS_HOOKS:
hook(bumble, config, server)
# open device.
await bumble.open()
try:
# Pandora require classic devices to be discoverable & connectable.
if bumble.device.classic_enabled:
await bumble.device.set_discoverable(True)
await bumble.device.set_connectable(True)
# start & serve gRPC server.
await server.start()
await server.wait_for_termination()
finally:
# close device.
await bumble.close()
# re-initialize the gRPC server.
server = grpc.aio.server()
server.add_insecure_port(f'localhost:{port}')
finally:
# stop server.
await server.stop(None)

48
bumble/pandora/config.py Normal file
View File

@@ -0,0 +1,48 @@
# Copyright 2022 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from bumble.pairing import PairingDelegate
from dataclasses import dataclass
from typing import Any, Dict
@dataclass
class Config:
io_capability: PairingDelegate.IoCapability = PairingDelegate.NO_OUTPUT_NO_INPUT
pairing_sc_enable: bool = True
pairing_mitm_enable: bool = True
pairing_bonding_enable: bool = True
smp_local_initiator_key_distribution: PairingDelegate.KeyDistribution = (
PairingDelegate.DEFAULT_KEY_DISTRIBUTION
)
smp_local_responder_key_distribution: PairingDelegate.KeyDistribution = (
PairingDelegate.DEFAULT_KEY_DISTRIBUTION
)
def load_from_dict(self, config: Dict[str, Any]) -> None:
io_capability_name: str = config.get(
'io_capability', 'no_output_no_input'
).upper()
self.io_capability = getattr(PairingDelegate, io_capability_name)
self.pairing_sc_enable = config.get('pairing_sc_enable', True)
self.pairing_mitm_enable = config.get('pairing_mitm_enable', True)
self.pairing_bonding_enable = config.get('pairing_bonding_enable', True)
self.smp_local_initiator_key_distribution = config.get(
'smp_local_initiator_key_distribution',
PairingDelegate.DEFAULT_KEY_DISTRIBUTION,
)
self.smp_local_responder_key_distribution = config.get(
'smp_local_responder_key_distribution',
PairingDelegate.DEFAULT_KEY_DISTRIBUTION,
)

157
bumble/pandora/device.py Normal file
View File

@@ -0,0 +1,157 @@
# Copyright 2022 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Generic & dependency free Bumble (reference) device."""
from bumble import transport
from bumble.core import (
BT_GENERIC_AUDIO_SERVICE,
BT_HANDSFREE_SERVICE,
BT_L2CAP_PROTOCOL_ID,
BT_RFCOMM_PROTOCOL_ID,
)
from bumble.device import Device, DeviceConfiguration
from bumble.host import Host
from bumble.sdp import (
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
DataElement,
ServiceAttribute,
)
from typing import Any, Dict, List, Optional
class PandoraDevice:
"""
Small wrapper around a Bumble device and it's HCI transport.
Notes:
- The Bumble device is idle by default.
- Repetitive calls to `open`/`close` will result on new Bumble device instances.
"""
# Bumble device instance & configuration.
device: Device
config: Dict[str, Any]
# HCI transport name & instance.
_hci_name: str
_hci: Optional[transport.Transport] # type: ignore[name-defined]
def __init__(self, config: Dict[str, Any]) -> None:
self.config = config
self.device = _make_device(config)
self._hci_name = config.get('transport', '')
self._hci = None
@property
def idle(self) -> bool:
return self._hci is None
async def open(self) -> None:
if self._hci is not None:
return
# open HCI transport & set device host.
self._hci = await transport.open_transport(self._hci_name)
self.device.host = Host(controller_source=self._hci.source, controller_sink=self._hci.sink) # type: ignore[no-untyped-call]
# power-on.
await self.device.power_on()
async def close(self) -> None:
if self._hci is None:
return
# flush & re-initialize device.
await self.device.host.flush()
self.device.host = None # type: ignore[assignment]
self.device = _make_device(self.config)
# close HCI transport.
await self._hci.close()
self._hci = None
async def reset(self) -> None:
await self.close()
await self.open()
def info(self) -> Optional[Dict[str, str]]:
return {
'public_bd_address': str(self.device.public_address),
'random_address': str(self.device.random_address),
}
def _make_device(config: Dict[str, Any]) -> Device:
"""Initialize an idle Bumble device instance."""
# initialize bumble device.
device_config = DeviceConfiguration()
device_config.load_from_dict(config)
device = Device(config=device_config, host=None)
# Add fake a2dp service to avoid Android disconnect
device.sdp_service_records = _make_sdp_records(1)
return device
# TODO(b/267540823): remove when Pandora A2dp is supported
def _make_sdp_records(rfcomm_channel: int) -> Dict[int, List[ServiceAttribute]]:
return {
0x00010001: [
ServiceAttribute(
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
DataElement.unsigned_integer_32(0x00010001),
),
ServiceAttribute(
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
DataElement.sequence(
[
DataElement.uuid(BT_HANDSFREE_SERVICE),
DataElement.uuid(BT_GENERIC_AUDIO_SERVICE),
]
),
),
ServiceAttribute(
SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
DataElement.sequence(
[
DataElement.sequence([DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]),
DataElement.sequence(
[
DataElement.uuid(BT_RFCOMM_PROTOCOL_ID),
DataElement.unsigned_integer_8(rfcomm_channel),
]
),
]
),
),
ServiceAttribute(
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
DataElement.sequence(
[
DataElement.sequence(
[
DataElement.uuid(BT_HANDSFREE_SERVICE),
DataElement.unsigned_integer_16(0x0105),
]
)
]
),
),
]
}

857
bumble/pandora/host.py Normal file
View File

@@ -0,0 +1,857 @@
# Copyright 2022 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
import bumble.device
import grpc
import grpc.aio
import logging
import struct
from . import utils
from .config import Config
from bumble.core import (
BT_BR_EDR_TRANSPORT,
BT_LE_TRANSPORT,
BT_PERIPHERAL_ROLE,
UUID,
AdvertisingData,
ConnectionError,
)
from bumble.device import (
DEVICE_DEFAULT_SCAN_INTERVAL,
DEVICE_DEFAULT_SCAN_WINDOW,
Advertisement,
AdvertisingType,
Device,
)
from bumble.gatt import Service
from bumble.hci import (
HCI_CONNECTION_ALREADY_EXISTS_ERROR,
HCI_PAGE_TIMEOUT_ERROR,
HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
Address,
)
from google.protobuf import any_pb2 # pytype: disable=pyi-error
from google.protobuf import empty_pb2 # pytype: disable=pyi-error
from pandora.host_grpc_aio import HostServicer
from pandora.host_pb2 import (
NOT_CONNECTABLE,
NOT_DISCOVERABLE,
PRIMARY_1M,
PRIMARY_CODED,
SECONDARY_1M,
SECONDARY_2M,
SECONDARY_CODED,
SECONDARY_NONE,
AdvertiseRequest,
AdvertiseResponse,
Connection,
ConnectLERequest,
ConnectLEResponse,
ConnectRequest,
ConnectResponse,
DataTypes,
DisconnectRequest,
InquiryResponse,
PrimaryPhy,
ReadLocalAddressResponse,
ScanningResponse,
ScanRequest,
SecondaryPhy,
SetConnectabilityModeRequest,
SetDiscoverabilityModeRequest,
WaitConnectionRequest,
WaitConnectionResponse,
WaitDisconnectionRequest,
)
from typing import AsyncGenerator, Dict, List, Optional, Set, Tuple, cast
PRIMARY_PHY_MAP: Dict[int, PrimaryPhy] = {
# Default value reported by Bumble for legacy Advertising reports.
# FIXME(uael): `None` might be a better value, but Bumble need to change accordingly.
0: PRIMARY_1M,
1: PRIMARY_1M,
3: PRIMARY_CODED,
}
SECONDARY_PHY_MAP: Dict[int, SecondaryPhy] = {
0: SECONDARY_NONE,
1: SECONDARY_1M,
2: SECONDARY_2M,
3: SECONDARY_CODED,
}
class HostService(HostServicer):
waited_connections: Set[int]
def __init__(
self, grpc_server: grpc.aio.Server, device: Device, config: Config
) -> None:
self.log = utils.BumbleServerLoggerAdapter(
logging.getLogger(), {'service_name': 'Host', 'device': device}
)
self.grpc_server = grpc_server
self.device = device
self.config = config
self.waited_connections = set()
@utils.rpc
async def FactoryReset(
self, request: empty_pb2.Empty, context: grpc.ServicerContext
) -> empty_pb2.Empty:
self.log.info('FactoryReset')
# delete all bonds
if self.device.keystore is not None:
await self.device.keystore.delete_all()
# trigger gRCP server stop then return
asyncio.create_task(self.grpc_server.stop(None))
return empty_pb2.Empty()
@utils.rpc
async def Reset(
self, request: empty_pb2.Empty, context: grpc.ServicerContext
) -> empty_pb2.Empty:
self.log.info('Reset')
# clear service.
self.waited_connections.clear()
# (re) power device on
await self.device.power_on()
return empty_pb2.Empty()
@utils.rpc
async def ReadLocalAddress(
self, request: empty_pb2.Empty, context: grpc.ServicerContext
) -> ReadLocalAddressResponse:
self.log.info('ReadLocalAddress')
return ReadLocalAddressResponse(
address=bytes(reversed(bytes(self.device.public_address)))
)
@utils.rpc
async def Connect(
self, request: ConnectRequest, context: grpc.ServicerContext
) -> ConnectResponse:
# Need to reverse bytes order since Bumble Address is using MSB.
address = Address(
bytes(reversed(request.address)), address_type=Address.PUBLIC_DEVICE_ADDRESS
)
self.log.info(f"Connect to {address}")
try:
connection = await self.device.connect(
address, transport=BT_BR_EDR_TRANSPORT
)
except ConnectionError as e:
if e.error_code == HCI_PAGE_TIMEOUT_ERROR:
self.log.warning(f"Peer not found: {e}")
return ConnectResponse(peer_not_found=empty_pb2.Empty())
if e.error_code == HCI_CONNECTION_ALREADY_EXISTS_ERROR:
self.log.warning(f"Connection already exists: {e}")
return ConnectResponse(connection_already_exists=empty_pb2.Empty())
raise e
self.log.info(f"Connect to {address} done (handle={connection.handle})")
cookie = any_pb2.Any(value=connection.handle.to_bytes(4, 'big'))
return ConnectResponse(connection=Connection(cookie=cookie))
@utils.rpc
async def WaitConnection(
self, request: WaitConnectionRequest, context: grpc.ServicerContext
) -> WaitConnectionResponse:
if not request.address:
raise ValueError('Request address field must be set')
# Need to reverse bytes order since Bumble Address is using MSB.
address = Address(
bytes(reversed(request.address)), address_type=Address.PUBLIC_DEVICE_ADDRESS
)
if address in (Address.NIL, Address.ANY):
raise ValueError('Invalid address')
self.log.info(f"WaitConnection from {address}...")
connection = self.device.find_connection_by_bd_addr(
address, transport=BT_BR_EDR_TRANSPORT
)
if connection and id(connection) in self.waited_connections:
# this connection was already returned: wait for a new one.
connection = None
if not connection:
connection = await self.device.accept(address)
# save connection has waited and respond.
self.waited_connections.add(id(connection))
self.log.info(
f"WaitConnection from {address} done (handle={connection.handle})"
)
cookie = any_pb2.Any(value=connection.handle.to_bytes(4, 'big'))
return WaitConnectionResponse(connection=Connection(cookie=cookie))
@utils.rpc
async def ConnectLE(
self, request: ConnectLERequest, context: grpc.ServicerContext
) -> ConnectLEResponse:
address = utils.address_from_request(request, request.WhichOneof("address"))
if address in (Address.NIL, Address.ANY):
raise ValueError('Invalid address')
self.log.info(f"ConnectLE to {address}...")
try:
connection = await self.device.connect(
address,
transport=BT_LE_TRANSPORT,
own_address_type=request.own_address_type,
)
except ConnectionError as e:
if e.error_code == HCI_PAGE_TIMEOUT_ERROR:
self.log.warning(f"Peer not found: {e}")
return ConnectLEResponse(peer_not_found=empty_pb2.Empty())
if e.error_code == HCI_CONNECTION_ALREADY_EXISTS_ERROR:
self.log.warning(f"Connection already exists: {e}")
return ConnectLEResponse(connection_already_exists=empty_pb2.Empty())
raise e
self.log.info(f"ConnectLE to {address} done (handle={connection.handle})")
cookie = any_pb2.Any(value=connection.handle.to_bytes(4, 'big'))
return ConnectLEResponse(connection=Connection(cookie=cookie))
@utils.rpc
async def Disconnect(
self, request: DisconnectRequest, context: grpc.ServicerContext
) -> empty_pb2.Empty:
connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
self.log.info(f"Disconnect: {connection_handle}")
self.log.info("Disconnecting...")
if connection := self.device.lookup_connection(connection_handle):
await connection.disconnect(HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR)
self.log.info("Disconnected")
return empty_pb2.Empty()
@utils.rpc
async def WaitDisconnection(
self, request: WaitDisconnectionRequest, context: grpc.ServicerContext
) -> empty_pb2.Empty:
connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
self.log.info(f"WaitDisconnection: {connection_handle}")
if connection := self.device.lookup_connection(connection_handle):
disconnection_future: asyncio.Future[
None
] = asyncio.get_running_loop().create_future()
def on_disconnection(_: None) -> None:
disconnection_future.set_result(None)
connection.on('disconnection', on_disconnection)
try:
await disconnection_future
self.log.info("Disconnected")
finally:
connection.remove_listener('disconnection', on_disconnection) # type: ignore
return empty_pb2.Empty()
@utils.rpc
async def Advertise(
self, request: AdvertiseRequest, context: grpc.ServicerContext
) -> AsyncGenerator[AdvertiseResponse, None]:
if not request.legacy:
raise NotImplementedError(
"TODO: add support for extended advertising in Bumble"
)
if request.interval:
raise NotImplementedError("TODO: add support for `request.interval`")
if request.interval_range:
raise NotImplementedError("TODO: add support for `request.interval_range`")
if request.primary_phy:
raise NotImplementedError("TODO: add support for `request.primary_phy`")
if request.secondary_phy:
raise NotImplementedError("TODO: add support for `request.secondary_phy`")
if self.device.is_advertising:
raise NotImplementedError('TODO: add support for advertising sets')
if data := request.data:
self.device.advertising_data = bytes(self.unpack_data_types(data))
if scan_response_data := request.scan_response_data:
self.device.scan_response_data = bytes(
self.unpack_data_types(scan_response_data)
)
scannable = True
else:
scannable = False
# Retrieve services data
for service in self.device.gatt_server.attributes:
if isinstance(service, Service) and (
service_data := service.get_advertising_data()
):
service_uuid = service.uuid.to_hex_str('-')
if (
service_uuid in request.data.incomplete_service_class_uuids16
or service_uuid in request.data.complete_service_class_uuids16
or service_uuid in request.data.incomplete_service_class_uuids32
or service_uuid in request.data.complete_service_class_uuids32
or service_uuid
in request.data.incomplete_service_class_uuids128
or service_uuid in request.data.complete_service_class_uuids128
):
self.device.advertising_data += service_data
if (
service_uuid
in scan_response_data.incomplete_service_class_uuids16
or service_uuid
in scan_response_data.complete_service_class_uuids16
or service_uuid
in scan_response_data.incomplete_service_class_uuids32
or service_uuid
in scan_response_data.complete_service_class_uuids32
or service_uuid
in scan_response_data.incomplete_service_class_uuids128
or service_uuid
in scan_response_data.complete_service_class_uuids128
):
self.device.scan_response_data += service_data
target = None
if request.connectable and scannable:
advertising_type = AdvertisingType.UNDIRECTED_CONNECTABLE_SCANNABLE
elif scannable:
advertising_type = AdvertisingType.UNDIRECTED_SCANNABLE
else:
advertising_type = AdvertisingType.UNDIRECTED
else:
target = None
advertising_type = AdvertisingType.UNDIRECTED
if request.target:
# Need to reverse bytes order since Bumble Address is using MSB.
target_bytes = bytes(reversed(request.target))
if request.target_variant() == "public":
target = Address(target_bytes, Address.PUBLIC_DEVICE_ADDRESS)
advertising_type = (
AdvertisingType.DIRECTED_CONNECTABLE_HIGH_DUTY
) # FIXME: HIGH_DUTY ?
else:
target = Address(target_bytes, Address.RANDOM_DEVICE_ADDRESS)
advertising_type = (
AdvertisingType.DIRECTED_CONNECTABLE_HIGH_DUTY
) # FIXME: HIGH_DUTY ?
if request.connectable:
def on_connection(connection: bumble.device.Connection) -> None:
if (
connection.transport == BT_LE_TRANSPORT
and connection.role == BT_PERIPHERAL_ROLE
):
pending_connection.set_result(connection)
self.device.on('connection', on_connection)
try:
while True:
if not self.device.is_advertising:
self.log.info('Advertise')
await self.device.start_advertising(
target=target,
advertising_type=advertising_type,
own_address_type=request.own_address_type,
)
if not request.connectable:
await asyncio.sleep(1)
continue
pending_connection: asyncio.Future[
bumble.device.Connection
] = asyncio.get_running_loop().create_future()
self.log.info('Wait for LE connection...')
connection = await pending_connection
self.log.info(
f"Advertise: Connected to {connection.peer_address} (handle={connection.handle})"
)
cookie = any_pb2.Any(value=connection.handle.to_bytes(4, 'big'))
yield AdvertiseResponse(connection=Connection(cookie=cookie))
# wait a small delay before restarting the advertisement.
await asyncio.sleep(1)
finally:
if request.connectable:
self.device.remove_listener('connection', on_connection) # type: ignore
try:
self.log.info('Stop advertising')
await self.device.abort_on('flush', self.device.stop_advertising())
except:
pass
@utils.rpc
async def Scan(
self, request: ScanRequest, context: grpc.ServicerContext
) -> AsyncGenerator[ScanningResponse, None]:
# TODO: modify `start_scanning` to accept floats instead of int for ms values
if request.phys:
raise NotImplementedError("TODO: add support for `request.phys`")
self.log.info('Scan')
scan_queue: asyncio.Queue[Advertisement] = asyncio.Queue()
handler = self.device.on('advertisement', scan_queue.put_nowait)
await self.device.start_scanning(
legacy=request.legacy,
active=not request.passive,
own_address_type=request.own_address_type,
scan_interval=int(request.interval)
if request.interval
else DEVICE_DEFAULT_SCAN_INTERVAL,
scan_window=int(request.window)
if request.window
else DEVICE_DEFAULT_SCAN_WINDOW,
)
try:
# TODO: add support for `direct_address` in Bumble
# TODO: add support for `periodic_advertising_interval` in Bumble
while adv := await scan_queue.get():
sr = ScanningResponse(
legacy=adv.is_legacy,
connectable=adv.is_connectable,
scannable=adv.is_scannable,
truncated=adv.is_truncated,
sid=adv.sid,
primary_phy=PRIMARY_PHY_MAP[adv.primary_phy],
secondary_phy=SECONDARY_PHY_MAP[adv.secondary_phy],
tx_power=adv.tx_power,
rssi=adv.rssi,
data=self.pack_data_types(adv.data),
)
if adv.address.address_type == Address.PUBLIC_DEVICE_ADDRESS:
sr.public = bytes(reversed(bytes(adv.address)))
elif adv.address.address_type == Address.RANDOM_DEVICE_ADDRESS:
sr.random = bytes(reversed(bytes(adv.address)))
elif adv.address.address_type == Address.PUBLIC_IDENTITY_ADDRESS:
sr.public_identity = bytes(reversed(bytes(adv.address)))
else:
sr.random_static_identity = bytes(reversed(bytes(adv.address)))
yield sr
finally:
self.device.remove_listener('advertisement', handler) # type: ignore
try:
self.log.info('Stop scanning')
await self.device.abort_on('flush', self.device.stop_scanning())
except:
pass
@utils.rpc
async def Inquiry(
self, request: empty_pb2.Empty, context: grpc.ServicerContext
) -> AsyncGenerator[InquiryResponse, None]:
self.log.info('Inquiry')
inquiry_queue: asyncio.Queue[
Optional[Tuple[Address, int, AdvertisingData, int]]
] = asyncio.Queue()
complete_handler = self.device.on(
'inquiry_complete', lambda: inquiry_queue.put_nowait(None)
)
result_handler = self.device.on( # type: ignore
'inquiry_result',
lambda address, class_of_device, eir_data, rssi: inquiry_queue.put_nowait( # type: ignore
(address, class_of_device, eir_data, rssi) # type: ignore
),
)
await self.device.start_discovery(auto_restart=False)
try:
while inquiry_result := await inquiry_queue.get():
(address, class_of_device, eir_data, rssi) = inquiry_result
# FIXME: if needed, add support for `page_scan_repetition_mode` and `clock_offset` in Bumble
yield InquiryResponse(
address=bytes(reversed(bytes(address))),
class_of_device=class_of_device,
rssi=rssi,
data=self.pack_data_types(eir_data),
)
finally:
self.device.remove_listener('inquiry_complete', complete_handler) # type: ignore
self.device.remove_listener('inquiry_result', result_handler) # type: ignore
try:
self.log.info('Stop inquiry')
await self.device.abort_on('flush', self.device.stop_discovery())
except:
pass
@utils.rpc
async def SetDiscoverabilityMode(
self, request: SetDiscoverabilityModeRequest, context: grpc.ServicerContext
) -> empty_pb2.Empty:
self.log.info("SetDiscoverabilityMode")
await self.device.set_discoverable(request.mode != NOT_DISCOVERABLE)
return empty_pb2.Empty()
@utils.rpc
async def SetConnectabilityMode(
self, request: SetConnectabilityModeRequest, context: grpc.ServicerContext
) -> empty_pb2.Empty:
self.log.info("SetConnectabilityMode")
await self.device.set_connectable(request.mode != NOT_CONNECTABLE)
return empty_pb2.Empty()
def unpack_data_types(self, dt: DataTypes) -> AdvertisingData:
ad_structures: List[Tuple[int, bytes]] = []
uuids: List[str]
datas: Dict[str, bytes]
def uuid128_from_str(uuid: str) -> bytes:
"""Decode a 128-bit uuid encoded as XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
to byte format."""
return bytes(reversed(bytes.fromhex(uuid.replace('-', ''))))
def uuid32_from_str(uuid: str) -> bytes:
"""Decode a 32-bit uuid encoded as XXXXXXXX to byte format."""
return bytes(reversed(bytes.fromhex(uuid)))
def uuid16_from_str(uuid: str) -> bytes:
"""Decode a 16-bit uuid encoded as XXXX to byte format."""
return bytes(reversed(bytes.fromhex(uuid)))
if uuids := dt.incomplete_service_class_uuids16:
ad_structures.append(
(
AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
b''.join([uuid16_from_str(uuid) for uuid in uuids]),
)
)
if uuids := dt.complete_service_class_uuids16:
ad_structures.append(
(
AdvertisingData.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
b''.join([uuid16_from_str(uuid) for uuid in uuids]),
)
)
if uuids := dt.incomplete_service_class_uuids32:
ad_structures.append(
(
AdvertisingData.INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS,
b''.join([uuid32_from_str(uuid) for uuid in uuids]),
)
)
if uuids := dt.complete_service_class_uuids32:
ad_structures.append(
(
AdvertisingData.COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS,
b''.join([uuid32_from_str(uuid) for uuid in uuids]),
)
)
if uuids := dt.incomplete_service_class_uuids128:
ad_structures.append(
(
AdvertisingData.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS,
b''.join([uuid128_from_str(uuid) for uuid in uuids]),
)
)
if uuids := dt.complete_service_class_uuids128:
ad_structures.append(
(
AdvertisingData.COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS,
b''.join([uuid128_from_str(uuid) for uuid in uuids]),
)
)
if dt.HasField('include_shortened_local_name'):
ad_structures.append(
(
AdvertisingData.SHORTENED_LOCAL_NAME,
bytes(self.device.name[:8], 'utf-8'),
)
)
elif dt.shortened_local_name:
ad_structures.append(
(
AdvertisingData.SHORTENED_LOCAL_NAME,
bytes(dt.shortened_local_name, 'utf-8'),
)
)
if dt.HasField('include_complete_local_name'):
ad_structures.append(
(AdvertisingData.COMPLETE_LOCAL_NAME, bytes(self.device.name, 'utf-8'))
)
elif dt.complete_local_name:
ad_structures.append(
(
AdvertisingData.COMPLETE_LOCAL_NAME,
bytes(dt.complete_local_name, 'utf-8'),
)
)
if dt.HasField('include_tx_power_level'):
raise ValueError('unsupported data type')
elif dt.tx_power_level:
ad_structures.append(
(
AdvertisingData.TX_POWER_LEVEL,
bytes(struct.pack('<I', dt.tx_power_level)[:1]),
)
)
if dt.HasField('include_class_of_device'):
ad_structures.append(
(
AdvertisingData.CLASS_OF_DEVICE,
bytes(struct.pack('<I', self.device.class_of_device)[:-1]),
)
)
elif dt.class_of_device:
ad_structures.append(
(
AdvertisingData.CLASS_OF_DEVICE,
bytes(struct.pack('<I', dt.class_of_device)[:-1]),
)
)
if dt.peripheral_connection_interval_min:
ad_structures.append(
(
AdvertisingData.PERIPHERAL_CONNECTION_INTERVAL_RANGE,
bytes(
[
*struct.pack('<H', dt.peripheral_connection_interval_min),
*struct.pack(
'<H',
dt.peripheral_connection_interval_max
if dt.peripheral_connection_interval_max
else dt.peripheral_connection_interval_min,
),
]
),
)
)
if uuids := dt.service_solicitation_uuids16:
ad_structures.append(
(
AdvertisingData.LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS,
b''.join([uuid16_from_str(uuid) for uuid in uuids]),
)
)
if uuids := dt.service_solicitation_uuids32:
ad_structures.append(
(
AdvertisingData.LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS,
b''.join([uuid32_from_str(uuid) for uuid in uuids]),
)
)
if uuids := dt.service_solicitation_uuids128:
ad_structures.append(
(
AdvertisingData.LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS,
b''.join([uuid128_from_str(uuid) for uuid in uuids]),
)
)
if datas := dt.service_data_uuid16:
ad_structures.extend(
[
(
AdvertisingData.SERVICE_DATA_16_BIT_UUID,
uuid16_from_str(uuid) + data,
)
for uuid, data in datas.items()
]
)
if datas := dt.service_data_uuid32:
ad_structures.extend(
[
(
AdvertisingData.SERVICE_DATA_32_BIT_UUID,
uuid32_from_str(uuid) + data,
)
for uuid, data in datas.items()
]
)
if datas := dt.service_data_uuid128:
ad_structures.extend(
[
(
AdvertisingData.SERVICE_DATA_128_BIT_UUID,
uuid128_from_str(uuid) + data,
)
for uuid, data in datas.items()
]
)
if dt.appearance:
ad_structures.append(
(AdvertisingData.APPEARANCE, struct.pack('<H', dt.appearance))
)
if dt.advertising_interval:
ad_structures.append(
(
AdvertisingData.ADVERTISING_INTERVAL,
struct.pack('<H', dt.advertising_interval),
)
)
if dt.uri:
ad_structures.append((AdvertisingData.URI, bytes(dt.uri, 'utf-8')))
if dt.le_supported_features:
ad_structures.append(
(AdvertisingData.LE_SUPPORTED_FEATURES, dt.le_supported_features)
)
if dt.manufacturer_specific_data:
ad_structures.append(
(
AdvertisingData.MANUFACTURER_SPECIFIC_DATA,
dt.manufacturer_specific_data,
)
)
return AdvertisingData(ad_structures)
def pack_data_types(self, ad: AdvertisingData) -> DataTypes:
dt = DataTypes()
uuids: List[UUID]
s: str
i: int
ij: Tuple[int, int]
uuid_data: Tuple[UUID, bytes]
data: bytes
if uuids := cast(
List[UUID],
ad.get(AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS),
):
dt.incomplete_service_class_uuids16.extend(
list(map(lambda x: x.to_hex_str('-'), uuids))
)
if uuids := cast(
List[UUID],
ad.get(AdvertisingData.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS),
):
dt.complete_service_class_uuids16.extend(
list(map(lambda x: x.to_hex_str('-'), uuids))
)
if uuids := cast(
List[UUID],
ad.get(AdvertisingData.INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS),
):
dt.incomplete_service_class_uuids32.extend(
list(map(lambda x: x.to_hex_str('-'), uuids))
)
if uuids := cast(
List[UUID],
ad.get(AdvertisingData.COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS),
):
dt.complete_service_class_uuids32.extend(
list(map(lambda x: x.to_hex_str('-'), uuids))
)
if uuids := cast(
List[UUID],
ad.get(AdvertisingData.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS),
):
dt.incomplete_service_class_uuids128.extend(
list(map(lambda x: x.to_hex_str('-'), uuids))
)
if uuids := cast(
List[UUID],
ad.get(AdvertisingData.COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS),
):
dt.complete_service_class_uuids128.extend(
list(map(lambda x: x.to_hex_str('-'), uuids))
)
if s := cast(str, ad.get(AdvertisingData.SHORTENED_LOCAL_NAME)):
dt.shortened_local_name = s
if s := cast(str, ad.get(AdvertisingData.COMPLETE_LOCAL_NAME)):
dt.complete_local_name = s
if i := cast(int, ad.get(AdvertisingData.TX_POWER_LEVEL)):
dt.tx_power_level = i
if i := cast(int, ad.get(AdvertisingData.CLASS_OF_DEVICE)):
dt.class_of_device = i
if ij := cast(
Tuple[int, int],
ad.get(AdvertisingData.PERIPHERAL_CONNECTION_INTERVAL_RANGE),
):
dt.peripheral_connection_interval_min = ij[0]
dt.peripheral_connection_interval_max = ij[1]
if uuids := cast(
List[UUID],
ad.get(AdvertisingData.LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS),
):
dt.service_solicitation_uuids16.extend(
list(map(lambda x: x.to_hex_str('-'), uuids))
)
if uuids := cast(
List[UUID],
ad.get(AdvertisingData.LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS),
):
dt.service_solicitation_uuids32.extend(
list(map(lambda x: x.to_hex_str('-'), uuids))
)
if uuids := cast(
List[UUID],
ad.get(AdvertisingData.LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS),
):
dt.service_solicitation_uuids128.extend(
list(map(lambda x: x.to_hex_str('-'), uuids))
)
if uuid_data := cast(
Tuple[UUID, bytes], ad.get(AdvertisingData.SERVICE_DATA_16_BIT_UUID)
):
dt.service_data_uuid16[uuid_data[0].to_hex_str('-')] = uuid_data[1]
if uuid_data := cast(
Tuple[UUID, bytes], ad.get(AdvertisingData.SERVICE_DATA_32_BIT_UUID)
):
dt.service_data_uuid32[uuid_data[0].to_hex_str('-')] = uuid_data[1]
if uuid_data := cast(
Tuple[UUID, bytes], ad.get(AdvertisingData.SERVICE_DATA_128_BIT_UUID)
):
dt.service_data_uuid128[uuid_data[0].to_hex_str('-')] = uuid_data[1]
if data := cast(bytes, ad.get(AdvertisingData.PUBLIC_TARGET_ADDRESS, raw=True)):
dt.public_target_addresses.extend(
[data[i * 6 :: i * 6 + 6] for i in range(int(len(data) / 6))]
)
if data := cast(bytes, ad.get(AdvertisingData.RANDOM_TARGET_ADDRESS, raw=True)):
dt.random_target_addresses.extend(
[data[i * 6 :: i * 6 + 6] for i in range(int(len(data) / 6))]
)
if i := cast(int, ad.get(AdvertisingData.APPEARANCE)):
dt.appearance = i
if i := cast(int, ad.get(AdvertisingData.ADVERTISING_INTERVAL)):
dt.advertising_interval = i
if s := cast(str, ad.get(AdvertisingData.URI)):
dt.uri = s
if data := cast(bytes, ad.get(AdvertisingData.LE_SUPPORTED_FEATURES, raw=True)):
dt.le_supported_features = data
if data := cast(
bytes, ad.get(AdvertisingData.MANUFACTURER_SPECIFIC_DATA, raw=True)
):
dt.manufacturer_specific_data = data
return dt

0
bumble/pandora/py.typed Normal file
View File

526
bumble/pandora/security.py Normal file
View File

@@ -0,0 +1,526 @@
# Copyright 2022 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
import grpc
import logging
from . import utils
from .config import Config
from bumble import hci
from bumble.core import (
BT_BR_EDR_TRANSPORT,
BT_LE_TRANSPORT,
BT_PERIPHERAL_ROLE,
ProtocolError,
)
from bumble.device import Connection as BumbleConnection, Device
from bumble.hci import HCI_Error
from bumble.pairing import PairingConfig, PairingDelegate as BasePairingDelegate
from contextlib import suppress
from google.protobuf import any_pb2 # pytype: disable=pyi-error
from google.protobuf import empty_pb2 # pytype: disable=pyi-error
from google.protobuf import wrappers_pb2 # pytype: disable=pyi-error
from pandora.host_pb2 import Connection
from pandora.security_grpc_aio import SecurityServicer, SecurityStorageServicer
from pandora.security_pb2 import (
LE_LEVEL1,
LE_LEVEL2,
LE_LEVEL3,
LE_LEVEL4,
LEVEL0,
LEVEL1,
LEVEL2,
LEVEL3,
LEVEL4,
DeleteBondRequest,
IsBondedRequest,
LESecurityLevel,
PairingEvent,
PairingEventAnswer,
SecureRequest,
SecureResponse,
SecurityLevel,
WaitSecurityRequest,
WaitSecurityResponse,
)
from typing import Any, AsyncGenerator, AsyncIterator, Callable, Dict, Optional, Union
class PairingDelegate(BasePairingDelegate):
def __init__(
self,
connection: BumbleConnection,
service: "SecurityService",
io_capability: BasePairingDelegate.IoCapability = BasePairingDelegate.NO_OUTPUT_NO_INPUT,
local_initiator_key_distribution: BasePairingDelegate.KeyDistribution = BasePairingDelegate.DEFAULT_KEY_DISTRIBUTION,
local_responder_key_distribution: BasePairingDelegate.KeyDistribution = BasePairingDelegate.DEFAULT_KEY_DISTRIBUTION,
) -> None:
self.log = utils.BumbleServerLoggerAdapter(
logging.getLogger(),
{'service_name': 'Security', 'device': connection.device},
)
self.connection = connection
self.service = service
super().__init__(
io_capability,
local_initiator_key_distribution,
local_responder_key_distribution,
)
async def accept(self) -> bool:
return True
def add_origin(self, ev: PairingEvent) -> PairingEvent:
if not self.connection.is_incomplete:
assert ev.connection
ev.connection.CopyFrom(
Connection(
cookie=any_pb2.Any(value=self.connection.handle.to_bytes(4, 'big'))
)
)
else:
# In BR/EDR, connection may not be complete,
# use address instead
assert self.connection.transport == BT_BR_EDR_TRANSPORT
ev.address = bytes(reversed(bytes(self.connection.peer_address)))
return ev
async def confirm(self, auto: bool = False) -> bool:
self.log.info(
f"Pairing event: `just_works` (io_capability: {self.io_capability})"
)
if self.service.event_queue is None or self.service.event_answer is None:
return True
event = self.add_origin(PairingEvent(just_works=empty_pb2.Empty()))
self.service.event_queue.put_nowait(event)
answer = await anext(self.service.event_answer) # pytype: disable=name-error
assert answer.event == event
assert answer.answer_variant() == 'confirm' and answer.confirm is not None
return answer.confirm
async def compare_numbers(self, number: int, digits: int = 6) -> bool:
self.log.info(
f"Pairing event: `numeric_comparison` (io_capability: {self.io_capability})"
)
if self.service.event_queue is None or self.service.event_answer is None:
raise RuntimeError('security: unhandled number comparison request')
event = self.add_origin(PairingEvent(numeric_comparison=number))
self.service.event_queue.put_nowait(event)
answer = await anext(self.service.event_answer) # pytype: disable=name-error
assert answer.event == event
assert answer.answer_variant() == 'confirm' and answer.confirm is not None
return answer.confirm
async def get_number(self) -> Optional[int]:
self.log.info(
f"Pairing event: `passkey_entry_request` (io_capability: {self.io_capability})"
)
if self.service.event_queue is None or self.service.event_answer is None:
raise RuntimeError('security: unhandled number request')
event = self.add_origin(PairingEvent(passkey_entry_request=empty_pb2.Empty()))
self.service.event_queue.put_nowait(event)
answer = await anext(self.service.event_answer) # pytype: disable=name-error
assert answer.event == event
if answer.answer_variant() is None:
return None
assert answer.answer_variant() == 'passkey'
return answer.passkey
async def get_string(self, max_length: int) -> Optional[str]:
self.log.info(
f"Pairing event: `pin_code_request` (io_capability: {self.io_capability})"
)
if self.service.event_queue is None or self.service.event_answer is None:
raise RuntimeError('security: unhandled pin_code request')
event = self.add_origin(PairingEvent(pin_code_request=empty_pb2.Empty()))
self.service.event_queue.put_nowait(event)
answer = await anext(self.service.event_answer) # pytype: disable=name-error
assert answer.event == event
if answer.answer_variant() is None:
return None
assert answer.answer_variant() == 'pin'
if answer.pin is None:
return None
pin = answer.pin.decode('utf-8')
if not pin or len(pin) > max_length:
raise ValueError(f'Pin must be utf-8 encoded up to {max_length} bytes')
return pin
async def display_number(self, number: int, digits: int = 6) -> None:
if (
self.connection.transport == BT_BR_EDR_TRANSPORT
and self.io_capability == BasePairingDelegate.DISPLAY_OUTPUT_ONLY
):
return
self.log.info(
f"Pairing event: `passkey_entry_notification` (io_capability: {self.io_capability})"
)
if self.service.event_queue is None:
raise RuntimeError('security: unhandled number display request')
event = self.add_origin(PairingEvent(passkey_entry_notification=number))
self.service.event_queue.put_nowait(event)
BR_LEVEL_REACHED: Dict[SecurityLevel, Callable[[BumbleConnection], bool]] = {
LEVEL0: lambda connection: True,
LEVEL1: lambda connection: connection.encryption == 0 or connection.authenticated,
LEVEL2: lambda connection: connection.encryption != 0 and connection.authenticated,
LEVEL3: lambda connection: connection.encryption != 0
and connection.authenticated
and connection.link_key_type
in (
hci.HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_192_TYPE,
hci.HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE,
),
LEVEL4: lambda connection: connection.encryption
== hci.HCI_Encryption_Change_Event.AES_CCM
and connection.authenticated
and connection.link_key_type
== hci.HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE,
}
LE_LEVEL_REACHED: Dict[LESecurityLevel, Callable[[BumbleConnection], bool]] = {
LE_LEVEL1: lambda connection: True,
LE_LEVEL2: lambda connection: connection.encryption != 0,
LE_LEVEL3: lambda connection: connection.encryption != 0
and connection.authenticated,
LE_LEVEL4: lambda connection: connection.encryption != 0
and connection.authenticated
and connection.sc,
}
class SecurityService(SecurityServicer):
def __init__(self, device: Device, config: Config) -> None:
self.log = utils.BumbleServerLoggerAdapter(
logging.getLogger(), {'service_name': 'Security', 'device': device}
)
self.event_queue: Optional[asyncio.Queue[PairingEvent]] = None
self.event_answer: Optional[AsyncIterator[PairingEventAnswer]] = None
self.device = device
self.config = config
def pairing_config_factory(connection: BumbleConnection) -> PairingConfig:
return PairingConfig(
sc=config.pairing_sc_enable,
mitm=config.pairing_mitm_enable,
bonding=config.pairing_bonding_enable,
delegate=PairingDelegate(
connection,
self,
io_capability=config.io_capability,
local_initiator_key_distribution=config.smp_local_initiator_key_distribution,
local_responder_key_distribution=config.smp_local_responder_key_distribution,
),
)
self.device.pairing_config_factory = pairing_config_factory
@utils.rpc
async def OnPairing(
self, request: AsyncIterator[PairingEventAnswer], context: grpc.ServicerContext
) -> AsyncGenerator[PairingEvent, None]:
self.log.info('OnPairing')
if self.event_queue is not None:
raise RuntimeError('already streaming pairing events')
if len(self.device.connections):
raise RuntimeError(
'the `OnPairing` method shall be initiated before establishing any connections.'
)
self.event_queue = asyncio.Queue()
self.event_answer = request
try:
while event := await self.event_queue.get():
yield event
finally:
self.event_queue = None
self.event_answer = None
@utils.rpc
async def Secure(
self, request: SecureRequest, context: grpc.ServicerContext
) -> SecureResponse:
connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
self.log.info(f"Secure: {connection_handle}")
connection = self.device.lookup_connection(connection_handle)
assert connection
oneof = request.WhichOneof('level')
level = getattr(request, oneof)
assert {BT_BR_EDR_TRANSPORT: 'classic', BT_LE_TRANSPORT: 'le'}[
connection.transport
] == oneof
# security level already reached
if self.reached_security_level(connection, level):
return SecureResponse(success=empty_pb2.Empty())
# trigger pairing if needed
if self.need_pairing(connection, level):
try:
self.log.info('Pair...')
if (
connection.transport == BT_LE_TRANSPORT
and connection.role == BT_PERIPHERAL_ROLE
):
wait_for_security: asyncio.Future[
bool
] = asyncio.get_running_loop().create_future()
connection.on("pairing", lambda *_: wait_for_security.set_result(True)) # type: ignore
connection.on("pairing_failure", wait_for_security.set_exception)
connection.request_pairing()
await wait_for_security
else:
await connection.pair()
self.log.info('Paired')
except asyncio.CancelledError:
self.log.warning("Connection died during encryption")
return SecureResponse(connection_died=empty_pb2.Empty())
except (HCI_Error, ProtocolError) as e:
self.log.warning(f"Pairing failure: {e}")
return SecureResponse(pairing_failure=empty_pb2.Empty())
# trigger authentication if needed
if self.need_authentication(connection, level):
try:
self.log.info('Authenticate...')
await connection.authenticate()
self.log.info('Authenticated')
except asyncio.CancelledError:
self.log.warning("Connection died during authentication")
return SecureResponse(connection_died=empty_pb2.Empty())
except (HCI_Error, ProtocolError) as e:
self.log.warning(f"Authentication failure: {e}")
return SecureResponse(authentication_failure=empty_pb2.Empty())
# trigger encryption if needed
if self.need_encryption(connection, level):
try:
self.log.info('Encrypt...')
await connection.encrypt()
self.log.info('Encrypted')
except asyncio.CancelledError:
self.log.warning("Connection died during encryption")
return SecureResponse(connection_died=empty_pb2.Empty())
except (HCI_Error, ProtocolError) as e:
self.log.warning(f"Encryption failure: {e}")
return SecureResponse(encryption_failure=empty_pb2.Empty())
# security level has been reached ?
if self.reached_security_level(connection, level):
return SecureResponse(success=empty_pb2.Empty())
return SecureResponse(not_reached=empty_pb2.Empty())
@utils.rpc
async def WaitSecurity(
self, request: WaitSecurityRequest, context: grpc.ServicerContext
) -> WaitSecurityResponse:
connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
self.log.info(f"WaitSecurity: {connection_handle}")
connection = self.device.lookup_connection(connection_handle)
assert connection
assert request.level
level = request.level
assert {BT_BR_EDR_TRANSPORT: 'classic', BT_LE_TRANSPORT: 'le'}[
connection.transport
] == request.level_variant()
wait_for_security: asyncio.Future[
str
] = asyncio.get_running_loop().create_future()
authenticate_task: Optional[asyncio.Future[None]] = None
async def authenticate() -> None:
assert connection
if (encryption := connection.encryption) != 0:
self.log.debug('Disable encryption...')
try:
await connection.encrypt(enable=False)
except:
pass
self.log.debug('Disable encryption: done')
self.log.debug('Authenticate...')
await connection.authenticate()
self.log.debug('Authenticate: done')
if encryption != 0 and connection.encryption != encryption:
self.log.debug('Re-enable encryption...')
await connection.encrypt()
self.log.debug('Re-enable encryption: done')
def set_failure(name: str) -> Callable[..., None]:
def wrapper(*args: Any) -> None:
self.log.info(f'Wait for security: error `{name}`: {args}')
wait_for_security.set_result(name)
return wrapper
def try_set_success(*_: Any) -> None:
assert connection
if self.reached_security_level(connection, level):
self.log.info('Wait for security: done')
wait_for_security.set_result('success')
def on_encryption_change(*_: Any) -> None:
assert connection
if self.reached_security_level(connection, level):
self.log.info('Wait for security: done')
wait_for_security.set_result('success')
elif (
connection.transport == BT_BR_EDR_TRANSPORT
and self.need_authentication(connection, level)
):
nonlocal authenticate_task
if authenticate_task is None:
authenticate_task = asyncio.create_task(authenticate())
listeners: Dict[str, Callable[..., None]] = {
'disconnection': set_failure('connection_died'),
'pairing_failure': set_failure('pairing_failure'),
'connection_authentication_failure': set_failure('authentication_failure'),
'connection_encryption_failure': set_failure('encryption_failure'),
'pairing': try_set_success,
'connection_authentication': try_set_success,
'connection_encryption_change': on_encryption_change,
}
# register event handlers
for event, listener in listeners.items():
connection.on(event, listener)
# security level already reached
if self.reached_security_level(connection, level):
return WaitSecurityResponse(success=empty_pb2.Empty())
self.log.info('Wait for security...')
kwargs = {}
kwargs[await wait_for_security] = empty_pb2.Empty()
# remove event handlers
for event, listener in listeners.items():
connection.remove_listener(event, listener) # type: ignore
# wait for `authenticate` to finish if any
if authenticate_task is not None:
self.log.info('Wait for authentication...')
try:
await authenticate_task # type: ignore
except:
pass
self.log.info('Authenticated')
return WaitSecurityResponse(**kwargs)
def reached_security_level(
self, connection: BumbleConnection, level: Union[SecurityLevel, LESecurityLevel]
) -> bool:
self.log.debug(
str(
{
'level': level,
'encryption': connection.encryption,
'authenticated': connection.authenticated,
'sc': connection.sc,
'link_key_type': connection.link_key_type,
}
)
)
if isinstance(level, LESecurityLevel):
return LE_LEVEL_REACHED[level](connection)
return BR_LEVEL_REACHED[level](connection)
def need_pairing(self, connection: BumbleConnection, level: int) -> bool:
if connection.transport == BT_LE_TRANSPORT:
return level >= LE_LEVEL3 and not connection.authenticated
return False
def need_authentication(self, connection: BumbleConnection, level: int) -> bool:
if connection.transport == BT_LE_TRANSPORT:
return False
if level == LEVEL2 and connection.encryption != 0:
return not connection.authenticated
return level >= LEVEL2 and not connection.authenticated
def need_encryption(self, connection: BumbleConnection, level: int) -> bool:
# TODO(abel): need to support MITM
if connection.transport == BT_LE_TRANSPORT:
return level == LE_LEVEL2 and not connection.encryption
return level >= LEVEL2 and not connection.encryption
class SecurityStorageService(SecurityStorageServicer):
def __init__(self, device: Device, config: Config) -> None:
self.log = utils.BumbleServerLoggerAdapter(
logging.getLogger(), {'service_name': 'SecurityStorage', 'device': device}
)
self.device = device
self.config = config
@utils.rpc
async def IsBonded(
self, request: IsBondedRequest, context: grpc.ServicerContext
) -> wrappers_pb2.BoolValue:
address = utils.address_from_request(request, request.WhichOneof("address"))
self.log.info(f"IsBonded: {address}")
if self.device.keystore is not None:
is_bonded = await self.device.keystore.get(str(address)) is not None
else:
is_bonded = False
return wrappers_pb2.BoolValue(value=is_bonded)
@utils.rpc
async def DeleteBond(
self, request: DeleteBondRequest, context: grpc.ServicerContext
) -> empty_pb2.Empty:
address = utils.address_from_request(request, request.WhichOneof("address"))
self.log.info(f"DeleteBond: {address}")
if self.device.keystore is not None:
with suppress(KeyError):
await self.device.keystore.delete(str(address))
return empty_pb2.Empty()

112
bumble/pandora/utils.py Normal file
View File

@@ -0,0 +1,112 @@
# Copyright 2022 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import contextlib
import functools
import grpc
import inspect
import logging
from bumble.device import Device
from bumble.hci import Address
from google.protobuf.message import Message # pytype: disable=pyi-error
from typing import Any, Dict, Generator, MutableMapping, Optional, Tuple
ADDRESS_TYPES: Dict[str, int] = {
"public": Address.PUBLIC_DEVICE_ADDRESS,
"random": Address.RANDOM_DEVICE_ADDRESS,
"public_identity": Address.PUBLIC_IDENTITY_ADDRESS,
"random_static_identity": Address.RANDOM_IDENTITY_ADDRESS,
}
def address_from_request(request: Message, field: Optional[str]) -> Address:
if field is None:
return Address.ANY
return Address(bytes(reversed(getattr(request, field))), ADDRESS_TYPES[field])
class BumbleServerLoggerAdapter(logging.LoggerAdapter): # type: ignore
"""Formats logs from the PandoraClient."""
def process(
self, msg: str, kwargs: MutableMapping[str, Any]
) -> Tuple[str, MutableMapping[str, Any]]:
assert self.extra
service_name = self.extra['service_name']
assert isinstance(service_name, str)
device = self.extra['device']
assert isinstance(device, Device)
addr_bytes = bytes(
reversed(bytes(device.public_address))
) # pytype: disable=attribute-error
addr = ':'.join([f'{x:02X}' for x in addr_bytes[4:]])
return (f'[bumble.{service_name}:{addr}] {msg}', kwargs)
@contextlib.contextmanager
def exception_to_rpc_error(
context: grpc.ServicerContext,
) -> Generator[None, None, None]:
try:
yield None
except NotImplementedError as e:
context.set_code(grpc.StatusCode.UNIMPLEMENTED) # type: ignore
context.set_details(str(e)) # type: ignore
except ValueError as e:
context.set_code(grpc.StatusCode.INVALID_ARGUMENT) # type: ignore
context.set_details(str(e)) # type: ignore
except RuntimeError as e:
context.set_code(grpc.StatusCode.ABORTED) # type: ignore
context.set_details(str(e)) # type: ignore
# Decorate an RPC servicer method with a wrapper that transform exceptions to gRPC errors.
def rpc(func: Any) -> Any:
@functools.wraps(func)
async def asyncgen_wrapper(
self: Any, request: Any, context: grpc.ServicerContext
) -> Any:
with exception_to_rpc_error(context):
async for v in func(self, request, context):
yield v
@functools.wraps(func)
async def async_wrapper(
self: Any, request: Any, context: grpc.ServicerContext
) -> Any:
with exception_to_rpc_error(context):
return await func(self, request, context)
@functools.wraps(func)
def gen_wrapper(self: Any, request: Any, context: grpc.ServicerContext) -> Any:
with exception_to_rpc_error(context):
for v in func(self, request, context):
yield v
@functools.wraps(func)
def wrapper(self: Any, request: Any, context: grpc.ServicerContext) -> Any:
with exception_to_rpc_error(context):
return func(self, request, context)
if inspect.isasyncgenfunction(func):
return asyncgen_wrapper
if inspect.iscoroutinefunction(func):
return async_wrapper
if inspect.isgenerator(func):
return gen_wrapper
return wrapper

View File

@@ -19,8 +19,9 @@ import logging
import asyncio
from pyee import EventEmitter
from typing import Optional, Tuple, Callable, Dict, Union
from . import core
from . import core, l2cap
from .colors import color
from .core import BT_BR_EDR_TRANSPORT, InvalidStateError, ProtocolError
@@ -105,7 +106,7 @@ RFCOMM_DYNAMIC_CHANNEL_NUMBER_END = 30
# -----------------------------------------------------------------------------
def compute_fcs(buffer):
def compute_fcs(buffer: bytes) -> int:
result = 0xFF
for byte in buffer:
result = CRC_TABLE[result ^ byte]
@@ -114,7 +115,15 @@ def compute_fcs(buffer):
# -----------------------------------------------------------------------------
class RFCOMM_Frame:
def __init__(self, frame_type, c_r, dlci, p_f, information=b'', with_credits=False):
def __init__(
self,
frame_type: int,
c_r: int,
dlci: int,
p_f: int,
information: bytes = b'',
with_credits: bool = False,
) -> None:
self.type = frame_type
self.c_r = c_r
self.dlci = dlci
@@ -136,11 +145,11 @@ class RFCOMM_Frame:
else:
self.fcs = compute_fcs(bytes([self.address, self.control]) + self.length)
def type_name(self):
def type_name(self) -> str:
return RFCOMM_FRAME_TYPE_NAMES[self.type]
@staticmethod
def parse_mcc(data):
def parse_mcc(data) -> Tuple[int, int, bytes]:
mcc_type = data[0] >> 2
c_r = (data[0] >> 1) & 1
length = data[1]
@@ -154,36 +163,36 @@ class RFCOMM_Frame:
return (mcc_type, c_r, value)
@staticmethod
def make_mcc(mcc_type, c_r, data):
def make_mcc(mcc_type: int, c_r: int, data: bytes) -> bytes:
return (
bytes([(mcc_type << 2 | c_r << 1 | 1) & 0xFF, (len(data) & 0x7F) << 1 | 1])
+ data
)
@staticmethod
def sabm(c_r, dlci):
def sabm(c_r: int, dlci: int):
return RFCOMM_Frame(RFCOMM_SABM_FRAME, c_r, dlci, 1)
@staticmethod
def ua(c_r, dlci):
def ua(c_r: int, dlci: int):
return RFCOMM_Frame(RFCOMM_UA_FRAME, c_r, dlci, 1)
@staticmethod
def dm(c_r, dlci):
def dm(c_r: int, dlci: int):
return RFCOMM_Frame(RFCOMM_DM_FRAME, c_r, dlci, 1)
@staticmethod
def disc(c_r, dlci):
def disc(c_r: int, dlci: int):
return RFCOMM_Frame(RFCOMM_DISC_FRAME, c_r, dlci, 1)
@staticmethod
def uih(c_r, dlci, information, p_f=0):
def uih(c_r: int, dlci: int, information: bytes, p_f: int = 0):
return RFCOMM_Frame(
RFCOMM_UIH_FRAME, c_r, dlci, p_f, information, with_credits=(p_f == 1)
)
@staticmethod
def from_bytes(data):
def from_bytes(data: bytes):
# Extract fields
dlci = (data[0] >> 2) & 0x3F
c_r = (data[0] >> 1) & 0x01
@@ -227,15 +236,23 @@ class RFCOMM_Frame:
# -----------------------------------------------------------------------------
class RFCOMM_MCC_PN:
dlci: int
cl: int
priority: int
ack_timer: int
max_frame_size: int
max_retransmissions: int
window_size: int
def __init__(
self,
dlci,
cl,
priority,
ack_timer,
max_frame_size,
max_retransmissions,
window_size,
dlci: int,
cl: int,
priority: int,
ack_timer: int,
max_frame_size: int,
max_retransmissions: int,
window_size: int,
):
self.dlci = dlci
self.cl = cl
@@ -246,7 +263,7 @@ class RFCOMM_MCC_PN:
self.window_size = window_size
@staticmethod
def from_bytes(data):
def from_bytes(data: bytes):
return RFCOMM_MCC_PN(
dlci=data[0],
cl=data[1],
@@ -285,7 +302,14 @@ class RFCOMM_MCC_PN:
# -----------------------------------------------------------------------------
class RFCOMM_MCC_MSC:
def __init__(self, dlci, fc, rtc, rtr, ic, dv):
dlci: int
fc: int
rtc: int
rtr: int
ic: int
dv: int
def __init__(self, dlci: int, fc: int, rtc: int, rtr: int, ic: int, dv: int):
self.dlci = dlci
self.fc = fc
self.rtc = rtc
@@ -294,7 +318,7 @@ class RFCOMM_MCC_MSC:
self.dv = dv
@staticmethod
def from_bytes(data):
def from_bytes(data: bytes):
return RFCOMM_MCC_MSC(
dlci=data[0] >> 2,
fc=data[1] >> 1 & 1,
@@ -347,7 +371,12 @@ class DLC(EventEmitter):
RESET: 'RESET',
}
def __init__(self, multiplexer, dlci, max_frame_size, initial_tx_credits):
connection_result: Optional[asyncio.Future]
sink: Optional[Callable[[bytes], None]]
def __init__(
self, multiplexer, dlci: int, max_frame_size: int, initial_tx_credits: int
):
super().__init__()
self.multiplexer = multiplexer
self.dlci = dlci
@@ -368,23 +397,23 @@ class DLC(EventEmitter):
)
@staticmethod
def state_name(state):
def state_name(state: int) -> str:
return DLC.STATE_NAMES[state]
def change_state(self, new_state):
def change_state(self, new_state: int) -> None:
logger.debug(
f'{self} state change -> {color(self.state_name(new_state), "magenta")}'
)
self.state = new_state
def send_frame(self, frame):
def send_frame(self, frame: RFCOMM_Frame) -> None:
self.multiplexer.send_frame(frame)
def on_frame(self, frame):
def on_frame(self, frame: RFCOMM_Frame) -> None:
handler = getattr(self, f'on_{frame.type_name()}_frame'.lower())
handler(frame)
def on_sabm_frame(self, _frame):
def on_sabm_frame(self, _frame) -> None:
if self.state != DLC.CONNECTING:
logger.warning(
color('!!! received SABM when not in CONNECTING state', 'red')
@@ -404,7 +433,7 @@ class DLC(EventEmitter):
self.change_state(DLC.CONNECTED)
self.emit('open')
def on_ua_frame(self, _frame):
def on_ua_frame(self, _frame) -> None:
if self.state != DLC.CONNECTING:
logger.warning(
color('!!! received SABM when not in CONNECTING state', 'red')
@@ -422,15 +451,15 @@ class DLC(EventEmitter):
self.change_state(DLC.CONNECTED)
self.multiplexer.on_dlc_open_complete(self)
def on_dm_frame(self, frame):
def on_dm_frame(self, frame) -> None:
# TODO: handle all states
pass
def on_disc_frame(self, _frame):
def on_disc_frame(self, _frame) -> None:
# TODO: handle all states
self.send_frame(RFCOMM_Frame.ua(c_r=1 - self.c_r, dlci=self.dlci))
def on_uih_frame(self, frame):
def on_uih_frame(self, frame: RFCOMM_Frame) -> None:
data = frame.information
if frame.p_f == 1:
# With credits
@@ -460,10 +489,10 @@ class DLC(EventEmitter):
# Check if there's anything to send (including credits)
self.process_tx()
def on_ui_frame(self, frame):
def on_ui_frame(self, frame) -> None:
pass
def on_mcc_msc(self, c_r, msc):
def on_mcc_msc(self, c_r, msc) -> None:
if c_r:
# Command
logger.debug(f'<<< MCC MSC Command: {msc}')
@@ -477,7 +506,7 @@ class DLC(EventEmitter):
# Response
logger.debug(f'<<< MCC MSC Response: {msc}')
def connect(self):
def connect(self) -> None:
if self.state != DLC.INIT:
raise InvalidStateError('invalid state')
@@ -485,7 +514,7 @@ class DLC(EventEmitter):
self.connection_result = asyncio.get_running_loop().create_future()
self.send_frame(RFCOMM_Frame.sabm(c_r=self.c_r, dlci=self.dlci))
def accept(self):
def accept(self) -> None:
if self.state != DLC.INIT:
raise InvalidStateError('invalid state')
@@ -503,13 +532,13 @@ class DLC(EventEmitter):
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
self.change_state(DLC.CONNECTING)
def rx_credits_needed(self):
def rx_credits_needed(self) -> int:
if self.rx_credits <= self.rx_threshold:
return RFCOMM_DEFAULT_INITIAL_RX_CREDITS - self.rx_credits
return 0
def process_tx(self):
def process_tx(self) -> None:
# Send anything we can (or an empty frame if we need to send rx credits)
rx_credits_needed = self.rx_credits_needed()
while (self.tx_buffer and self.tx_credits > 0) or rx_credits_needed > 0:
@@ -547,7 +576,7 @@ class DLC(EventEmitter):
rx_credits_needed = 0
# Stream protocol
def write(self, data):
def write(self, data: Union[bytes, str]) -> None:
# We can only send bytes
if not isinstance(data, bytes):
if isinstance(data, str):
@@ -559,7 +588,7 @@ class DLC(EventEmitter):
self.tx_buffer += data
self.process_tx()
def drain(self):
def drain(self) -> None:
# TODO
pass
@@ -592,7 +621,13 @@ class Multiplexer(EventEmitter):
RESET: 'RESET',
}
def __init__(self, l2cap_channel, role):
connection_result: Optional[asyncio.Future]
disconnection_result: Optional[asyncio.Future]
open_result: Optional[asyncio.Future]
acceptor: Optional[Callable[[int], bool]]
dlcs: Dict[int, DLC]
def __init__(self, l2cap_channel: l2cap.Channel, role: int) -> None:
super().__init__()
self.role = role
self.l2cap_channel = l2cap_channel
@@ -607,20 +642,20 @@ class Multiplexer(EventEmitter):
l2cap_channel.sink = self.on_pdu
@staticmethod
def state_name(state):
def state_name(state: int):
return Multiplexer.STATE_NAMES[state]
def change_state(self, new_state):
def change_state(self, new_state: int) -> None:
logger.debug(
f'{self} state change -> {color(self.state_name(new_state), "cyan")}'
)
self.state = new_state
def send_frame(self, frame):
def send_frame(self, frame: RFCOMM_Frame) -> None:
logger.debug(f'>>> Multiplexer sending {frame}')
self.l2cap_channel.send_pdu(frame)
def on_pdu(self, pdu):
def on_pdu(self, pdu: bytes) -> None:
frame = RFCOMM_Frame.from_bytes(pdu)
logger.debug(f'<<< Multiplexer received {frame}')
@@ -640,18 +675,18 @@ class Multiplexer(EventEmitter):
return
dlc.on_frame(frame)
def on_frame(self, frame):
def on_frame(self, frame: RFCOMM_Frame) -> None:
handler = getattr(self, f'on_{frame.type_name()}_frame'.lower())
handler(frame)
def on_sabm_frame(self, _frame):
def on_sabm_frame(self, _frame) -> None:
if self.state != Multiplexer.INIT:
logger.debug('not in INIT state, ignoring SABM')
return
self.change_state(Multiplexer.CONNECTED)
self.send_frame(RFCOMM_Frame.ua(c_r=1, dlci=0))
def on_ua_frame(self, _frame):
def on_ua_frame(self, _frame) -> None:
if self.state == Multiplexer.CONNECTING:
self.change_state(Multiplexer.CONNECTED)
if self.connection_result:
@@ -663,7 +698,7 @@ class Multiplexer(EventEmitter):
self.disconnection_result.set_result(None)
self.disconnection_result = None
def on_dm_frame(self, _frame):
def on_dm_frame(self, _frame) -> None:
if self.state == Multiplexer.OPENING:
self.change_state(Multiplexer.CONNECTED)
if self.open_result:
@@ -678,13 +713,13 @@ class Multiplexer(EventEmitter):
else:
logger.warning(f'unexpected state for DM: {self}')
def on_disc_frame(self, _frame):
def on_disc_frame(self, _frame) -> None:
self.change_state(Multiplexer.DISCONNECTED)
self.send_frame(
RFCOMM_Frame.ua(c_r=0 if self.role == Multiplexer.INITIATOR else 1, dlci=0)
)
def on_uih_frame(self, frame):
def on_uih_frame(self, frame: RFCOMM_Frame) -> None:
(mcc_type, c_r, value) = RFCOMM_Frame.parse_mcc(frame.information)
if mcc_type == RFCOMM_MCC_PN_TYPE:
@@ -694,10 +729,10 @@ class Multiplexer(EventEmitter):
mcs = RFCOMM_MCC_MSC.from_bytes(value)
self.on_mcc_msc(c_r, mcs)
def on_ui_frame(self, frame):
def on_ui_frame(self, frame) -> None:
pass
def on_mcc_pn(self, c_r, pn):
def on_mcc_pn(self, c_r, pn) -> None:
if c_r == 1:
# Command
logger.debug(f'<<< PN Command: {pn}')
@@ -736,14 +771,14 @@ class Multiplexer(EventEmitter):
else:
logger.warning('ignoring PN response')
def on_mcc_msc(self, c_r, msc):
def on_mcc_msc(self, c_r, msc) -> None:
dlc = self.dlcs.get(msc.dlci)
if dlc is None:
logger.warning(f'no dlc for DLCI {msc.dlci}')
return
dlc.on_mcc_msc(c_r, msc)
async def connect(self):
async def connect(self) -> None:
if self.state != Multiplexer.INIT:
raise InvalidStateError('invalid state')
@@ -752,7 +787,7 @@ class Multiplexer(EventEmitter):
self.send_frame(RFCOMM_Frame.sabm(c_r=1, dlci=0))
return await self.connection_result
async def disconnect(self):
async def disconnect(self) -> None:
if self.state != Multiplexer.CONNECTED:
return
@@ -765,7 +800,7 @@ class Multiplexer(EventEmitter):
)
await self.disconnection_result
async def open_dlc(self, channel):
async def open_dlc(self, channel: int) -> DLC:
if self.state != Multiplexer.CONNECTED:
if self.state == Multiplexer.OPENING:
raise InvalidStateError('open already in progress')
@@ -796,7 +831,7 @@ class Multiplexer(EventEmitter):
self.open_result = None
return result
def on_dlc_open_complete(self, dlc):
def on_dlc_open_complete(self, dlc: DLC):
logger.debug(f'DLC [{dlc.dlci}] open complete')
self.change_state(Multiplexer.CONNECTED)
if self.open_result:
@@ -808,13 +843,16 @@ class Multiplexer(EventEmitter):
# -----------------------------------------------------------------------------
class Client:
def __init__(self, device, connection):
multiplexer: Optional[Multiplexer]
l2cap_channel: Optional[l2cap.Channel]
def __init__(self, device, connection) -> None:
self.device = device
self.connection = connection
self.l2cap_channel = None
self.multiplexer = None
async def start(self):
async def start(self) -> Multiplexer:
# Create a new L2CAP connection
try:
self.l2cap_channel = await self.device.l2cap_channel_manager.connect(
@@ -824,6 +862,7 @@ class Client:
logger.warning(f'L2CAP connection failed: {error}')
raise
assert self.l2cap_channel is not None
# Create a mutliplexer to manage DLCs with the server
self.multiplexer = Multiplexer(self.l2cap_channel, Multiplexer.INITIATOR)
@@ -832,7 +871,9 @@ class Client:
return self.multiplexer
async def shutdown(self):
async def shutdown(self) -> None:
if self.multiplexer is None:
return
# Disconnect the multiplexer
await self.multiplexer.disconnect()
self.multiplexer = None
@@ -843,7 +884,9 @@ class Client:
# -----------------------------------------------------------------------------
class Server(EventEmitter):
def __init__(self, device):
acceptors: Dict[int, Callable[[DLC], None]]
def __init__(self, device) -> None:
super().__init__()
self.device = device
self.multiplexer = None
@@ -852,7 +895,7 @@ class Server(EventEmitter):
# Register ourselves with the L2CAP channel manager
device.register_l2cap_server(RFCOMM_PSM, self.on_connection)
def listen(self, acceptor, channel=0):
def listen(self, acceptor: Callable[[DLC], None], channel: int = 0) -> int:
if channel:
if channel in self.acceptors:
# Busy
@@ -874,11 +917,11 @@ class Server(EventEmitter):
self.acceptors[channel] = acceptor
return channel
def on_connection(self, l2cap_channel):
def on_connection(self, l2cap_channel: l2cap.Channel) -> None:
logger.debug(f'+++ new L2CAP connection: {l2cap_channel}')
l2cap_channel.on('open', lambda: self.on_l2cap_channel_open(l2cap_channel))
def on_l2cap_channel_open(self, l2cap_channel):
def on_l2cap_channel_open(self, l2cap_channel: l2cap.Channel) -> None:
logger.debug(f'$$$ L2CAP channel open: {l2cap_channel}')
# Create a new multiplexer for the channel
@@ -889,10 +932,10 @@ class Server(EventEmitter):
# Notify
self.emit('start', multiplexer)
def accept_dlc(self, channel_number):
def accept_dlc(self, channel_number: int) -> bool:
return channel_number in self.acceptors
def on_dlc(self, dlc):
def on_dlc(self, dlc: DLC) -> None:
logger.debug(f'@@@ new DLC connected: {dlc}')
# Let the acceptor know

File diff suppressed because it is too large Load Diff

View File

@@ -206,10 +206,11 @@ async def open_usb_transport(spec):
logger.debug('OUT transfer likely already completed')
class UsbPacketSource(asyncio.Protocol, ParserSource):
def __init__(self, context, device, acl_in, events_in):
def __init__(self, context, device, metadata, acl_in, events_in):
super().__init__()
self.context = context
self.device = device
self.metadata = metadata
self.acl_in = acl_in
self.events_in = events_in
self.loop = asyncio.get_running_loop()
@@ -510,6 +511,10 @@ async def open_usb_transport(spec):
f'events_in=0x{events_in:02X}, '
)
device_metadata = {
'vendor_id': found.getVendorID(),
'product_id': found.getProductID(),
}
device = found.open()
# Auto-detach the kernel driver if supported
@@ -535,7 +540,7 @@ async def open_usb_transport(spec):
except usb1.USBError:
logger.warning('failed to set configuration')
source = UsbPacketSource(context, device, acl_in, events_in)
source = UsbPacketSource(context, device, device_metadata, acl_in, events_in)
sink = UsbPacketSink(device, acl_out)
return UsbTransport(context, device, interface, setting, source, sink)
except usb1.USBError as error:

View File

@@ -36,6 +36,9 @@ nav:
- HCI Socket: transports/hci_socket.md
- Android Emulator: transports/android_emulator.md
- File: transports/file.md
- Drivers:
- Overview: drivers/index.md
- Realtek: drivers/realtek.md
- API:
- Guide: api/guide.md
- Examples: api/examples.md
@@ -44,6 +47,7 @@ nav:
- Overview: apps_and_tools/index.md
- Console: apps_and_tools/console.md
- Bench: apps_and_tools/bench.md
- Speaker: apps_and_tools/speaker.md
- HCI Bridge: apps_and_tools/hci_bridge.md
- Golden Gate Bridge: apps_and_tools/gg_bridge.md
- Show: apps_and_tools/show.md

View File

@@ -2,5 +2,5 @@
mkdocs == 1.4.0
mkdocs-material == 8.5.6
mkdocs-material-extensions == 1.0.3
pymdown-extensions == 9.6
pymdown-extensions == 10.0
mkdocstrings-python == 0.7.1

View File

@@ -11,4 +11,5 @@ These include:
* [HCI Bridge](hci_bridge.md) - a HCI transport bridge to connect two HCI transports and filter/snoop the HCI packets
* [Golden Gate Bridge](gg_bridge.md) - a bridge between GATT and UDP to use with the Golden Gate "stack tool"
* [Show](show.md) - Parse a file with HCI packets and print the details of each packet in a human readable form
* [Speaker](speaker.md) - Virtual Bluetooth speaker, with a command line and browser-based UI.
* [Link Relay](link_relay.md) - WebSocket relay for virtual RemoteLink instances to communicate with each other.

View File

@@ -0,0 +1,86 @@
SPEAKER APP
===========
![logo](../images/speaker_screenshot.png){ width=400 height=320 }
The Speaker app is virtual Bluetooth speaker (A2DP sink).
The app runs as a command-line executable, but also offers an optional simple
web-browser-based user interface.
# General Usage
You can invoke the app either as `bumble-speaker` when installed as command
from `pip`, or `python3 apps/speaker/speaker.py` when running from a source
distribution.
```
Usage: speaker.py [OPTIONS] TRANSPORT
Run the speaker.
Options:
--codec [sbc|aac] [default: aac]
--discover Discover remote endpoints once connected
--output NAME Send audio to this named output (may be used more
than once for multiple outputs)
--ui-port HTTP_PORT HTTP port for the UI server [default: 7654]
--connect ADDRESS_OR_NAME Address or name to connect to
--device-config FILENAME Device configuration file
--help Show this message and exit.
```
# Connection
By default, the virtual speaker will wait for another device (like a phone or
computer) to connect to it (and possibly pair). Alternatively, the speaker can
be told to initiate a connection to a remote device, using the `--connect`
option.
# Outputs
The speaker can have one or more outputs. By default, the only output is a text
display on the console, as well as a browser-based user interface if connected.
In addition, a file output can be used, in which case the received audio data is
saved to a specified file.
Finally, if the host computer on which your are running the application has `ffplay`
as an available command line executable, the `@ffplay` output can be selected, in
which case the received audio will be played on the computer's builtin speakers via
a pipe to `ffplay`. (see the [ffplay documentation](https://www.ffmpeg.org/ffplay.html)
for details)
# Web User Interface
When the speaker app starts, it prints out on the console the local URL at which you
may point a browser (Chrome recommended for full functionality). The console line
specifying the local UI URL will look like:
```
UI HTTP server at http://127.0.0.1:7654
```
By default, the web UI will show the status of the connection, as well as a realtime
graph of the received audio bandwidth.
In order to also hear the received audio, you need to click the `Audio on` button
(this is due to the fact that most browsers will require some user interface with the
page before granting access to the audio output APIs).
# Examples
In the following examples, we use a single USB Bluetooth controllers `usb:0`. Other
transports can be used of course.
!!! example "Start the speaker and wait for a connection"
```
$ bumble-speaker usb:0
```
!!! example "Start the speaker and save the AAC audio to a file named `audio.aac`."
```
$ bumble-speaker --output audio.aac usb:0
```
!!! example "Start the speaker and save the SBC audio to a file named `audio.sbc`."
```
$ bumble-speaker --codec sbc --output audio.sbc usb:0
```
!!! example "Start the speaker and connect it to a phone at address `B8:7B:C5:05:57:ED`."
```
$ bumble-speaker --connect B8:7B:C5:05:57:ED usb:0
```

View File

@@ -0,0 +1,10 @@
DRIVERS
=======
Some Bluetooth controllers require a driver to function properly.
This may include, for instance, loading a Firmware image or patch,
loading a configuration.
Drivers included in the module are:
* [Realtek](realtek.md): Loading of Firmware and Config for Realtek USB dongles.

View File

@@ -0,0 +1,62 @@
REALTEK DRIVER
==============
This driver supports loading firmware images and optional config data to
USB dongles with a Realtek chipset.
A number of USB dongles are supported, but likely not all.
When using a USB dongle, the USB product ID and manufacturer ID are used
to find whether a matching set of firmware image and config data
is needed for that specific model. If a match exists, the driver will try
load the firmware image and, if needed, config data.
The driver will look for those files by name, in order, in:
* The directory specified by the environment variable `BUMBLE_RTK_FIRMWARE_DIR`
if set.
* The directory `<package-dir>/drivers/rtk_fw` where `<package-dir>` is the directory
where the `bumble` package is installed.
* The current directory.
Obtaining Firmware Images and Config Data
-----------------------------------------
Firmware images and config data may be obtained from a variety of online
sources.
To facilitate finding a downloading the, the utility program `bumble-rtk-fw-download`
may be used.
```
Usage: bumble-rtk-fw-download [OPTIONS]
Download RTK firmware images and configs.
Options:
--output-dir TEXT Output directory where the files will be
saved [default: .]
--source [linux-kernel|realtek-opensource|linux-from-scratch]
[default: linux-kernel]
--single TEXT Only download a single image set, by its
base name
--force Overwrite files if they already exist
--parse Parse the FW image after saving
--help Show this message and exit.
```
Utility
-------
The `bumble-rtk-util` utility may be used to interact with a Realtek USB dongle
and/or firmware images.
```
Usage: bumble-rtk-util [OPTIONS] COMMAND [ARGS]...
Options:
--help Show this message and exit.
Commands:
drop Drop a firmware image from the USB dongle.
info Get the firmware info from a USB dongle.
load Load a firmware image into the USB dongle.
parse Parse a firmware image.
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

View File

@@ -1,11 +1,11 @@
UDP TRANSPORT
=============
WEBSOCKET CLIENT TRANSPORT
==========================
The UDP transport is a UDP socket, receiving packets on a specified port number, and sending packets to a specified host and port number.
The WebSocket Client transport is WebSocket connection to a WebSocket server over which HCI packets
are sent and received.
## Moniker
The moniker syntax for a UDP transport is: `udp:<local-host>:<local-port>,<remote-host>:<remote-port>`.
The moniker syntax for a WebSocket Client transport is: `ws-client:<ws-url>`
!!! example
`udp:0.0.0.0:9000,127.0.0.1:9001`
UDP transport where packets are received on port `9000` and sent to `127.0.0.1` on port `9001`
`ws-client:ws://localhost:1234/some/path`

View File

@@ -1,11 +1,13 @@
UDP TRANSPORT
=============
WEBSOCKET SERVER TRANSPORT
==========================
The UDP transport is a UDP socket, receiving packets on a specified port number, and sending packets to a specified host and port number.
The WebSocket Server transport is WebSocket server that accepts connections from a WebSocket
client. HCI packets are sent and received over the connection.
## Moniker
The moniker syntax for a UDP transport is: `udp:<local-host>:<local-port>,<remote-host>:<remote-port>`.
The moniker syntax for a WebSocket Server transport is: `ws-server:<host>:<port>`,
where `<host>` may be the address of a local network interface, or `_`to accept connections on all local network interfaces. `<port>` is the TCP port number on which to accept connections.
!!! example
`udp:0.0.0.0:9000,127.0.0.1:9001`
UDP transport where packets are received on port `9000` and sent to `127.0.0.1` on port `9001`
`ws-server:_:9001`

View File

@@ -23,7 +23,7 @@ from bumble.colors import color
from bumble.device import Device
from bumble.transport import open_transport_or_link
from bumble.core import BT_BR_EDR_TRANSPORT, BT_L2CAP_PROTOCOL_ID
from bumble.core import BT_BR_EDR_TRANSPORT, BT_L2CAP_PROTOCOL_ID, CommandTimeoutError
from bumble.sdp import (
Client as SDP_Client,
SDP_PUBLIC_BROWSE_ROOT,
@@ -48,62 +48,70 @@ async def main():
# Create a device
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
device.classic_enabled = True
device.le_enabled = False
await device.power_on()
async def connect(target_address):
print(f'=== Connecting to {target_address}...')
connection = await device.connect(target_address, transport=BT_BR_EDR_TRANSPORT)
print(f'=== Connected to {connection.peer_address}!')
# Connect to the SDP Server
sdp_client = SDP_Client(device)
await sdp_client.connect(connection)
# List all services in the root browse group
service_record_handles = await sdp_client.search_services(
[SDP_PUBLIC_BROWSE_ROOT]
)
print(color('\n==================================', 'blue'))
print(color('SERVICES:', 'yellow'), service_record_handles)
# For each service in the root browse group, get all its attributes
for service_record_handle in service_record_handles:
attributes = await sdp_client.get_attributes(
service_record_handle, [SDP_ALL_ATTRIBUTES_RANGE]
)
print(color(f'SERVICE {service_record_handle:04X} attributes:', 'yellow'))
for attribute in attributes:
print(' ', attribute.to_string(with_colors=True))
# Search for services with an L2CAP service attribute
search_result = await sdp_client.search_attributes(
[BT_L2CAP_PROTOCOL_ID], [SDP_ALL_ATTRIBUTES_RANGE]
)
print(color('\n==================================', 'blue'))
print(color('SEARCH RESULTS:', 'yellow'))
for attribute_list in search_result:
print(color('SERVICE:', 'green'))
print(
' '
+ '\n '.join(
[
attribute.to_string(with_colors=True)
for attribute in attribute_list
]
async def connect(target_address):
print(f'=== Connecting to {target_address}...')
try:
connection = await device.connect(
target_address, transport=BT_BR_EDR_TRANSPORT
)
except CommandTimeoutError:
print('!!! Connection timed out')
return
print(f'=== Connected to {connection.peer_address}!')
# Connect to the SDP Server
sdp_client = SDP_Client(device)
await sdp_client.connect(connection)
# List all services in the root browse group
service_record_handles = await sdp_client.search_services(
[SDP_PUBLIC_BROWSE_ROOT]
)
print(color('\n==================================', 'blue'))
print(color('SERVICES:', 'yellow'), service_record_handles)
await sdp_client.disconnect()
await hci_source.wait_for_termination()
# For each service in the root browse group, get all its attributes
for service_record_handle in service_record_handles:
attributes = await sdp_client.get_attributes(
service_record_handle, [SDP_ALL_ATTRIBUTES_RANGE]
)
print(
color(f'SERVICE {service_record_handle:04X} attributes:', 'yellow')
)
for attribute in attributes:
print(' ', attribute.to_string(with_colors=True))
# Connect to a peer
target_addresses = sys.argv[3:]
await asyncio.wait(
[
asyncio.create_task(connect(target_address))
for target_address in target_addresses
]
)
# Search for services with an L2CAP service attribute
search_result = await sdp_client.search_attributes(
[BT_L2CAP_PROTOCOL_ID], [SDP_ALL_ATTRIBUTES_RANGE]
)
print(color('\n==================================', 'blue'))
print(color('SEARCH RESULTS:', 'yellow'))
for attribute_list in search_result:
print(color('SERVICE:', 'green'))
print(
' '
+ '\n '.join(
[
attribute.to_string(with_colors=True)
for attribute in attribute_list
]
)
)
await sdp_client.disconnect()
# Connect to a peer
target_addresses = sys.argv[3:]
await asyncio.wait(
[
asyncio.create_task(connect(target_address))
for target_address in target_addresses
]
)
# -----------------------------------------------------------------------------

View File

@@ -62,7 +62,7 @@ async def main():
print(
f'>>> {color(advertisement.address, address_color)} '
f'[{color(address_type_string, type_color)}]'
f'{address_qualifier}:{separator}RSSI:{advertisement.rssi}'
f'{address_qualifier}:{separator}RSSI: {advertisement.rssi}'
f'{separator}'
f'{advertisement.data.to_string(separator)}'
)

5
examples/speaker.json Normal file
View File

@@ -0,0 +1,5 @@
{
"name": "Bumble Speaker",
"class_of_device": 2360324,
"keystore": "JsonKeyStore"
}

View File

@@ -40,6 +40,9 @@ disable = [
"too-many-statements",
]
[tool.pylint.main]
ignore="pandora" # FIXME: pylint does not support stubs yet:
[tool.pylint.typecheck]
signature-mutators="AsyncRunner.run_in_task"

View File

@@ -24,27 +24,30 @@ url = https://github.com/google/bumble
[options]
python_requires = >=3.8
packages = bumble, bumble.transport, bumble.profiles, bumble.apps, bumble.apps.link_relay
packages = bumble, bumble.transport, bumble.drivers, bumble.profiles, bumble.apps, bumble.apps.link_relay, bumble.pandora, bumble.tools
package_dir =
bumble = bumble
bumble.apps = apps
include-package-data = True
bumble.tools = tools
include_package_data = True
install_requires =
aiohttp ~= 3.8; platform_system!='Emscripten'
appdirs >= 1.4
click >= 7.1.2; platform_system!='Emscripten'
bt-test-interfaces >= 0.0.2
click == 8.1.3; platform_system!='Emscripten'
cryptography == 35; platform_system!='Emscripten'
grpcio >= 1.46; platform_system!='Emscripten'
grpcio == 1.51.1; platform_system!='Emscripten'
humanize >= 4.6.0
libusb1 >= 2.0.1; platform_system!='Emscripten'
libusb-package == 1.0.26.1; platform_system!='Emscripten'
prompt_toolkit >= 3.0.16; platform_system!='Emscripten'
prettytable >= 3.6.0
protobuf >= 3.12.4
pyee >= 8.2.2
pyserial-asyncio >= 0.5; platform_system!='Emscripten'
pyserial >= 3.5; platform_system!='Emscripten'
pyusb >= 1.2; platform_system!='Emscripten'
websockets >= 8.1; platform_system!='Emscripten'
prettytable >= 3.6.0
humanize >= 4.6.0
[options.entry_points]
console_scripts =
@@ -60,6 +63,10 @@ console_scripts =
bumble-usb-probe = bumble.apps.usb_probe:main
bumble-link-relay = bumble.apps.link_relay.link_relay:main
bumble-bench = bumble.apps.bench:main
bumble-speaker = bumble.apps.speaker.speaker:main
bumble-pandora-server = bumble.apps.pandora_server:main
bumble-rtk-util = bumble.tools.rtk_util:main
bumble-rtk-fw-download = bumble.tools.rtk_fw_download:main
[options.package_data]
* = py.typed, *.pyi

28
speaker.html Normal file
View File

@@ -0,0 +1,28 @@
<!DOCTYPE html>
<html>
<head>
<title>Audio WAV Player</title>
</head>
<body>
<h1>Audio WAV Player</h1>
<audio id="audioPlayer" controls>
<source src="" type="audio/wav">
</audio>
<script>
const audioPlayer = document.getElementById('audioPlayer');
const ws = new WebSocket('ws://localhost:8080');
let mediaSource = new MediaSource();
audioPlayer.src = URL.createObjectURL(mediaSource);
mediaSource.addEventListener('sourceopen', function(event) {
const sourceBuffer = mediaSource.addSourceBuffer('audio/wav');
ws.onmessage = function(event) {
sourceBuffer.appendBuffer(event.data);
};
});
</script>
</body>
</html>

67
tests/codecs_test.py Normal file
View File

@@ -0,0 +1,67 @@
# Copyright 2021-2023 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
import pytest
from bumble.codecs import AacAudioRtpPacket, BitReader
# -----------------------------------------------------------------------------
def test_reader():
reader = BitReader(b'')
with pytest.raises(ValueError):
reader.read(1)
reader = BitReader(b'hello')
with pytest.raises(ValueError):
reader.read(40)
reader = BitReader(bytes([0xFF]))
assert reader.read(1) == 1
with pytest.raises(ValueError):
reader.read(10)
reader = BitReader(bytes([0x78]))
value = 0
for _ in range(8):
value = (value << 1) | reader.read(1)
assert value == 0x78
data = bytes([x & 0xFF for x in range(66 * 100)])
reader = BitReader(data)
value = 0
for _ in range(100):
for bits in range(1, 33):
value = value << bits | reader.read(bits)
assert value == int.from_bytes(data, byteorder='big')
def test_aac_rtp():
# pylint: disable=line-too-long
packet_data = bytes.fromhex(
'47fc0000b090800300202066000198000de120000000000000000000000000000000000000000000001c'
)
packet = AacAudioRtpPacket(packet_data)
adts = packet.to_adts()
assert adts == bytes.fromhex(
'fff1508004fffc2066000198000de120000000000000000000000000000000000000000000001c'
)
# -----------------------------------------------------------------------------
if __name__ == '__main__':
test_reader()
test_aac_rtp()

View File

@@ -15,7 +15,7 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
from bumble.core import AdvertisingData, get_dict_key_by_value
from bumble.core import AdvertisingData, UUID, get_dict_key_by_value
# -----------------------------------------------------------------------------
def test_ad_data():
@@ -49,6 +49,24 @@ def test_get_dict_key_by_value():
assert get_dict_key_by_value(dictionary, 3) is None
# -----------------------------------------------------------------------------
def test_uuid_to_hex_str() -> None:
assert UUID("b5ea").to_hex_str() == "B5EA"
assert UUID("df5ce654").to_hex_str() == "DF5CE654"
assert (
UUID("df5ce654-e059-11ed-b5ea-0242ac120002").to_hex_str()
== "DF5CE654E05911EDB5EA0242AC120002"
)
assert UUID("b5ea").to_hex_str('-') == "B5EA"
assert UUID("df5ce654").to_hex_str('-') == "DF5CE654"
assert (
UUID("df5ce654-e059-11ed-b5ea-0242ac120002").to_hex_str('-')
== "DF5CE654-E059-11ED-B5EA-0242AC120002"
)
# -----------------------------------------------------------------------------
if __name__ == '__main__':
test_ad_data()
test_get_dict_key_by_value()
test_uuid_to_hex_str()

View File

@@ -803,14 +803,14 @@ async def test_mtu_exchange():
# -----------------------------------------------------------------------------
def test_char_property_to_string():
# single
assert str(Characteristic.Properties(0x01)) == "Properties.BROADCAST"
assert str(Characteristic.Properties.BROADCAST) == "Properties.BROADCAST"
assert str(Characteristic.Properties(0x01)) == "BROADCAST"
assert str(Characteristic.Properties.BROADCAST) == "BROADCAST"
# double
assert str(Characteristic.Properties(0x03)) == "Properties.READ|BROADCAST"
assert str(Characteristic.Properties(0x03)) == "BROADCAST|READ"
assert (
str(Characteristic.Properties.BROADCAST | Characteristic.Properties.READ)
== "Properties.READ|BROADCAST"
== "BROADCAST|READ"
)
@@ -831,6 +831,10 @@ def test_characteristic_property_from_string():
Characteristic.Properties.from_string("READ,BROADCAST")
== Characteristic.Properties.BROADCAST | Characteristic.Properties.READ
)
assert (
Characteristic.Properties.from_string("BROADCAST|READ")
== Characteristic.Properties.BROADCAST | Characteristic.Properties.READ
)
# -----------------------------------------------------------------------------
@@ -841,7 +845,7 @@ def test_characteristic_property_from_string_assert():
assert (
str(e_info.value)
== """Characteristic.Properties::from_string() error:
Expected a string containing any of the keys, separated by commas: BROADCAST,READ,WRITE_WITHOUT_RESPONSE,WRITE,NOTIFY,INDICATE,AUTHENTICATED_SIGNED_WRITES,EXTENDED_PROPERTIES
Expected a string containing any of the keys, separated by , or |: BROADCAST,READ,WRITE_WITHOUT_RESPONSE,WRITE,NOTIFY,INDICATE,AUTHENTICATED_SIGNED_WRITES,EXTENDED_PROPERTIES
Got: BROADCAST,HELLO"""
)
@@ -866,13 +870,13 @@ async def test_server_string():
assert (
str(server.gatt_server)
== """Service(handle=0x0001, end=0x0005, uuid=UUID-16:1800 (Generic Access))
CharacteristicDeclaration(handle=0x0002, value_handle=0x0003, uuid=UUID-16:2A00 (Device Name), Properties.READ)
Characteristic(handle=0x0003, end=0x0003, uuid=UUID-16:2A00 (Device Name), Properties.READ)
CharacteristicDeclaration(handle=0x0004, value_handle=0x0005, uuid=UUID-16:2A01 (Appearance), Properties.READ)
Characteristic(handle=0x0005, end=0x0005, uuid=UUID-16:2A01 (Appearance), Properties.READ)
CharacteristicDeclaration(handle=0x0002, value_handle=0x0003, uuid=UUID-16:2A00 (Device Name), READ)
Characteristic(handle=0x0003, end=0x0003, uuid=UUID-16:2A00 (Device Name), READ)
CharacteristicDeclaration(handle=0x0004, value_handle=0x0005, uuid=UUID-16:2A01 (Appearance), READ)
Characteristic(handle=0x0005, end=0x0005, uuid=UUID-16:2A01 (Appearance), READ)
Service(handle=0x0006, end=0x0009, uuid=3A657F47-D34F-46B3-B1EC-698E29B6B829)
CharacteristicDeclaration(handle=0x0007, value_handle=0x0008, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, Properties.NOTIFY|WRITE|READ)
Characteristic(handle=0x0008, end=0x0009, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, Properties.NOTIFY|WRITE|READ)
CharacteristicDeclaration(handle=0x0007, value_handle=0x0008, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, READ|WRITE|NOTIFY)
Characteristic(handle=0x0008, end=0x0009, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, READ|WRITE|NOTIFY)
Descriptor(handle=0x0009, type=UUID-16:2902 (Client Characteristic Configuration), value=0000)"""
)

179
tests/keystore_test.py Normal file
View File

@@ -0,0 +1,179 @@
# Copyright 2021-2022 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
import asyncio
import json
import logging
import tempfile
import os
from bumble.keys import JsonKeyStore, PairingKeys
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Tests
# -----------------------------------------------------------------------------
JSON1 = """
{
"my_namespace": {
"14:7D:DA:4E:53:A8/P": {
"address_type": 0,
"irk": {
"authenticated": false,
"value": "e7b2543b206e4e46b44f9e51dad22bd1"
},
"link_key": {
"authenticated": false,
"value": "0745dd9691e693d9dca740f7d8dfea75"
},
"ltk": {
"authenticated": false,
"value": "d1897ee10016eb1a08e4e037fd54c683"
}
}
}
}
"""
JSON2 = """
{
"my_namespace1": {
},
"my_namespace2": {
}
}
"""
JSON3 = """
{
"my_namespace1": {
},
"__DEFAULT__": {
"14:7D:DA:4E:53:A8/P": {
"address_type": 0,
"irk": {
"authenticated": false,
"value": "e7b2543b206e4e46b44f9e51dad22bd1"
}
}
}
}
"""
# -----------------------------------------------------------------------------
async def test_basic():
with tempfile.NamedTemporaryFile(mode="r+", encoding='utf-8') as file:
keystore = JsonKeyStore('my_namespace', file.name)
file.write("{}")
file.flush()
keys = await keystore.get_all()
assert len(keys) == 0
keys = PairingKeys()
await keystore.update('foo', keys)
foo = await keystore.get('foo')
assert foo is not None
assert foo.ltk is None
ltk = bytes([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15])
keys.ltk = PairingKeys.Key(ltk)
await keystore.update('foo', keys)
foo = await keystore.get('foo')
assert foo is not None
assert foo.ltk is not None
assert foo.ltk.value == ltk
file.flush()
with open(file.name, "r", encoding="utf-8") as json_file:
json_data = json.load(json_file)
assert 'my_namespace' in json_data
assert 'foo' in json_data['my_namespace']
assert 'ltk' in json_data['my_namespace']['foo']
# -----------------------------------------------------------------------------
async def test_parsing():
with tempfile.NamedTemporaryFile(mode="w", encoding='utf-8') as file:
keystore = JsonKeyStore('my_namespace', file.name)
file.write(JSON1)
file.flush()
foo = await keystore.get('14:7D:DA:4E:53:A8/P')
assert foo is not None
assert foo.ltk.value == bytes.fromhex('d1897ee10016eb1a08e4e037fd54c683')
# -----------------------------------------------------------------------------
async def test_default_namespace():
with tempfile.NamedTemporaryFile(mode="w", encoding='utf-8') as file:
keystore = JsonKeyStore(None, file.name)
file.write(JSON1)
file.flush()
all_keys = await keystore.get_all()
assert len(all_keys) == 1
name, keys = all_keys[0]
assert name == '14:7D:DA:4E:53:A8/P'
assert keys.irk.value == bytes.fromhex('e7b2543b206e4e46b44f9e51dad22bd1')
with tempfile.NamedTemporaryFile(mode="w", encoding='utf-8') as file:
keystore = JsonKeyStore(None, file.name)
file.write(JSON2)
file.flush()
keys = PairingKeys()
ltk = bytes([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15])
keys.ltk = PairingKeys.Key(ltk)
await keystore.update('foo', keys)
file.flush()
with open(file.name, "r", encoding="utf-8") as json_file:
json_data = json.load(json_file)
assert '__DEFAULT__' in json_data
assert 'foo' in json_data['__DEFAULT__']
assert 'ltk' in json_data['__DEFAULT__']['foo']
with tempfile.NamedTemporaryFile(mode="w", encoding='utf-8') as file:
keystore = JsonKeyStore(None, file.name)
file.write(JSON3)
file.flush()
all_keys = await keystore.get_all()
assert len(all_keys) == 1
name, keys = all_keys[0]
assert name == '14:7D:DA:4E:53:A8/P'
assert keys.irk.value == bytes.fromhex('e7b2543b206e4e46b44f9e51dad22bd1')
# -----------------------------------------------------------------------------
async def run_tests():
await test_basic()
await test_parsing()
await test_default_namespace()
# -----------------------------------------------------------------------------
if __name__ == '__main__':
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
asyncio.run(run_tests())

View File

@@ -21,6 +21,8 @@ import logging
import os
import pytest
from unittest.mock import MagicMock, patch
from bumble.controller import Controller
from bumble.core import BT_BR_EDR_TRANSPORT, BT_PERIPHERAL_ROLE, BT_CENTRAL_ROLE
from bumble.link import LocalLink
@@ -34,6 +36,8 @@ from bumble.smp import (
SMP_CONFIRM_VALUE_FAILED_ERROR,
)
from bumble.core import ProtocolError
from bumble.hci import HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE
from bumble.keys import PairingKeys
# -----------------------------------------------------------------------------
@@ -190,7 +194,9 @@ async def test_self_gatt():
s1 = Service('8140E247-04F0-42C1-BC34-534C344DAFCA', [c1, c2, c3])
s2 = Service('97210A0F-1875-4D05-9E5D-326EB171257A', [c4])
two_devices.devices[1].add_services([s1, s2])
s3 = Service('1853', [])
s4 = Service('3A12C182-14E2-4FE0-8C5B-65D7C569F9DB', [], included_services=[s2, s3])
two_devices.devices[1].add_services([s1, s2, s4])
# Start
await two_devices.devices[0].power_on()
@@ -225,6 +231,13 @@ async def test_self_gatt():
assert result is not None
assert result == c1.value
result = await peer.discover_service(s4.uuid)
assert len(result) == 1
result = await peer.discover_included_services(result[0])
assert len(result) == 2
# Service UUID is only present when the UUID is 16-bit Bluetooth UUID
assert result[1].uuid.to_bytes() == s3.uuid.to_bytes()
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
@@ -464,6 +477,85 @@ async def test_self_smp_wrong_pin():
assert not paired
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_self_smp_over_classic():
# Create two devices, each with a controller, attached to the same link
two_devices = TwoDevices()
# Attach listeners
two_devices.devices[0].on(
'connection', lambda connection: two_devices.on_connection(0, connection)
)
two_devices.devices[1].on(
'connection', lambda connection: two_devices.on_connection(1, connection)
)
# Enable Classic connections
two_devices.devices[0].classic_enabled = True
two_devices.devices[1].classic_enabled = True
# Start
await two_devices.devices[0].power_on()
await two_devices.devices[1].power_on()
# Connect the two devices
await asyncio.gather(
two_devices.devices[0].connect(
two_devices.devices[1].public_address, transport=BT_BR_EDR_TRANSPORT
),
two_devices.devices[1].accept(two_devices.devices[0].public_address),
)
# Check the post conditions
assert two_devices.connections[0] is not None
assert two_devices.connections[1] is not None
# Mock connection
# TODO: Implement Classic SSP and encryption in link relayer
LINK_KEY = bytes.fromhex('287ad379dca402530a39f1f43047b835')
two_devices.devices[0].on_link_key(
two_devices.devices[1].public_address,
LINK_KEY,
HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE,
)
two_devices.devices[1].on_link_key(
two_devices.devices[0].public_address,
LINK_KEY,
HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE,
)
two_devices.connections[0].encryption = 1
two_devices.connections[1].encryption = 1
paired = [
asyncio.get_event_loop().create_future(),
asyncio.get_event_loop().create_future(),
]
def on_pairing(which: int, keys: PairingKeys):
paired[which].set_result(keys)
two_devices.connections[0].on('pairing', lambda keys: on_pairing(0, keys))
two_devices.connections[1].on('pairing', lambda keys: on_pairing(1, keys))
# Mock SMP
with patch('bumble.smp.Session', spec=True) as MockSmpSession:
MockSmpSession.send_pairing_confirm_command = MagicMock()
MockSmpSession.send_pairing_dhkey_check_command = MagicMock()
MockSmpSession.send_public_key_command = MagicMock()
MockSmpSession.send_pairing_random_command = MagicMock()
# Start CTKD
await two_devices.connections[0].pair()
await asyncio.gather(*paired)
# Phase 2 commands should not be invoked
MockSmpSession.send_pairing_confirm_command.assert_not_called()
MockSmpSession.send_pairing_dhkey_check_command.assert_not_called()
MockSmpSession.send_public_key_command.assert_not_called()
MockSmpSession.send_pairing_random_command.assert_not_called()
# -----------------------------------------------------------------------------
async def run_test_self():
await test_self_connection()
@@ -472,6 +564,7 @@ async def run_test_self():
await test_self_smp()
await test_self_smp_reject()
await test_self_smp_wrong_pin()
await test_self_smp_over_classic()
# -----------------------------------------------------------------------------

0
tools/__init__.py Normal file
View File

149
tools/rtk_fw_download.py Normal file
View File

@@ -0,0 +1,149 @@
# Copyright 2021-2023 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
import logging
import pathlib
import urllib.request
import urllib.error
import click
from bumble.colors import color
from bumble.drivers import rtk
from bumble.tools import rtk_util
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
# Constants
# -----------------------------------------------------------------------------
LINUX_KERNEL_GIT_SOURCE = (
"https://git.kernel.org/pub/scm/linux/kernel/git/firmware/linux-firmware.git/plain/rtl_bt",
False,
)
REALTEK_OPENSOURCE_SOURCE = (
"https://github.com/Realtek-OpenSource/android_hardware_realtek/raw/rtk1395/bt/rtkbt/Firmware/BT",
True,
)
LINUX_FROM_SCRATCH_SOURCE = (
"https://anduin.linuxfromscratch.org/sources/linux-firmware/rtl_bt",
False,
)
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
def download_file(base_url, name, remove_suffix):
if remove_suffix:
name = name.replace(".bin", "")
url = f"{base_url}/{name}"
with urllib.request.urlopen(url) as file:
data = file.read()
print(f"Downloaded {name}: {len(data)} bytes")
return data
# -----------------------------------------------------------------------------
@click.command
@click.option(
"--output-dir",
default=".",
help="Output directory where the files will be saved",
show_default=True,
)
@click.option(
"--source",
type=click.Choice(["linux-kernel", "realtek-opensource", "linux-from-scratch"]),
default="linux-kernel",
show_default=True,
)
@click.option("--single", help="Only download a single image set, by its base name")
@click.option("--force", is_flag=True, help="Overwrite files if they already exist")
@click.option("--parse", is_flag=True, help="Parse the FW image after saving")
def main(output_dir, source, single, force, parse):
"""Download RTK firmware images and configs."""
# Check that the output dir exists
output_dir = pathlib.Path(output_dir)
if not output_dir.is_dir():
print("Output dir does not exist or is not a directory")
return
base_url, remove_suffix = {
"linux-kernel": LINUX_KERNEL_GIT_SOURCE,
"realtek-opensource": REALTEK_OPENSOURCE_SOURCE,
"linux-from-scratch": LINUX_FROM_SCRATCH_SOURCE,
}[source]
print("Downloading")
print(color("FROM:", "green"), base_url)
print(color("TO:", "green"), output_dir)
if single:
images = [(f"{single}_fw.bin", f"{single}_config.bin", True)]
else:
images = [
(driver_info.fw_name, driver_info.config_name, driver_info.config_needed)
for driver_info in rtk.Driver.DRIVER_INFOS
]
for (fw_name, config_name, config_needed) in images:
print(color("---", "yellow"))
fw_image_out = output_dir / fw_name
if not force and fw_image_out.exists():
print(color(f"{fw_image_out} already exists, skipping", "red"))
continue
if config_name:
config_image_out = output_dir / config_name
if not force and config_image_out.exists():
print(color("f{config_out} already exists, skipping", "red"))
continue
try:
fw_image = download_file(base_url, fw_name, remove_suffix)
except urllib.error.HTTPError as error:
print(f"Failed to download {fw_name}: {error}")
continue
config_image = None
if config_name:
try:
config_image = download_file(base_url, config_name, remove_suffix)
except urllib.error.HTTPError as error:
if config_needed:
print(f"Failed to download {config_name}: {error}")
continue
else:
print(f"No config available as {config_name}")
fw_image_out.write_bytes(fw_image)
if parse and config_name:
print(color("Parsing:", "cyan"), fw_name)
rtk_util.do_parse(fw_image_out)
if config_image:
config_image_out.write_bytes(config_image)
# -----------------------------------------------------------------------------
if __name__ == '__main__':
main()

161
tools/rtk_util.py Normal file
View File

@@ -0,0 +1,161 @@
# Copyright 2021-2023 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
import logging
import asyncio
import os
import click
from bumble import transport
from bumble.host import Host
from bumble.drivers import rtk
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
def do_parse(firmware_path):
with open(firmware_path, 'rb') as firmware_file:
firmware_data = firmware_file.read()
firmware = rtk.Firmware(firmware_data)
print(
f"Firmware: version=0x{firmware.version:08X} "
f"project_id=0x{firmware.project_id:04X}"
)
for patch in firmware.patches:
print(
f" Patch: chip_id=0x{patch[0]:04X}, "
f"{len(patch[1])} bytes, "
f"SVN Version={patch[2]:08X}"
)
# -----------------------------------------------------------------------------
async def do_load(usb_transport, force):
async with await transport.open_transport_or_link(usb_transport) as (
hci_source,
hci_sink,
):
# Create a host to communicate with the device
host = Host(hci_source, hci_sink)
await host.reset(driver_factory=None)
# Get the driver.
driver = await rtk.Driver.for_host(host, force)
if driver is None:
if not force:
print("Firmware already loaded or no supported driver for this device.")
return
await driver.download_firmware()
# -----------------------------------------------------------------------------
async def do_drop(usb_transport):
async with await transport.open_transport_or_link(usb_transport) as (
hci_source,
hci_sink,
):
# Create a host to communicate with the device
host = Host(hci_source, hci_sink)
await host.reset(driver_factory=None)
# Tell the device to reset/drop any loaded patch
await rtk.Driver.drop_firmware(host)
# -----------------------------------------------------------------------------
async def do_info(usb_transport, force):
async with await transport.open_transport(usb_transport) as (
hci_source,
hci_sink,
):
# Create a host to communicate with the device
host = Host(hci_source, hci_sink)
await host.reset(driver_factory=None)
# Check if this is a supported device.
if not force and not rtk.Driver.check(host):
print("USB device not supported by this RTK driver")
return
# Get the driver info.
driver_info = await rtk.Driver.driver_info_for_host(host)
if driver_info:
print(
"Driver:\n"
f" ROM: {driver_info.rom:04X}\n"
f" Firmware: {driver_info.fw_name}\n"
f" Config: {driver_info.config_name}\n"
)
else:
print("Firmware already loaded or no supported driver for this device.")
# -----------------------------------------------------------------------------
@click.group()
def main():
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
@main.command
@click.argument("firmware_path")
def parse(firmware_path):
"""Parse a firmware image."""
do_parse(firmware_path)
@main.command
@click.argument("usb_transport")
@click.option(
"--force",
is_flag=True,
default=False,
help="Load even if the USB info doesn't match",
)
def load(usb_transport, force):
"""Load a firmware image into the USB dongle."""
asyncio.run(do_load(usb_transport, force))
@main.command
@click.argument("usb_transport")
def drop(usb_transport):
"""Drop a firmware image from the USB dongle."""
asyncio.run(do_drop(usb_transport))
@main.command
@click.argument("usb_transport")
@click.option(
"--force",
is_flag=True,
default=False,
help="Try to get the device info even if the USB info doesn't match",
)
def info(usb_transport, force):
"""Get the firmware info from a USB dongle."""
asyncio.run(do_info(usb_transport, force))
# -----------------------------------------------------------------------------
if __name__ == '__main__':
main()