aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAndroid Build Coastguard Worker <android-build-coastguard-worker@google.com>2023-11-06 20:17:46 +0000
committerAndroid Build Coastguard Worker <android-build-coastguard-worker@google.com>2023-11-06 20:17:46 +0000
commit93e5f03344e1f343f20061351e99b446b6a28c03 (patch)
treeaa98f29f308756c4a060711d20036a9e31d819fa
parent70af600bd92c1001536b712df852d0a35d039a45 (diff)
parent62618d11a2e7875c438c5131abb6847da26aa6f4 (diff)
downloadbumble-android14-mainline-permission-release.tar.gz
Snap for 11028996 from 62618d11a2e7875c438c5131abb6847da26aa6f4 to mainline-permission-releaseaml_per_341614000aml_per_341510010aml_per_341410020aml_per_341311000android14-mainline-permission-release
Change-Id: I0394646245c98d8367ec7691d4a145d63e7f219a
-rw-r--r--.github/workflows/code-check.yml4
-rw-r--r--.github/workflows/python-build-test.yml24
-rw-r--r--.vscode/settings.json2
-rw-r--r--METADATA4
-rw-r--r--apps/console.py2
-rw-r--r--apps/controller_info.py3
-rw-r--r--apps/l2cap_bridge.py3
-rw-r--r--apps/pandora_server.py29
-rw-r--r--apps/show.py15
-rw-r--r--apps/speaker/speaker.css4
-rw-r--r--apps/speaker/speaker.html2
-rw-r--r--apps/speaker/speaker.py24
-rw-r--r--bumble/at.py85
-rw-r--r--bumble/att.py128
-rw-r--r--bumble/controller.py15
-rw-r--r--bumble/core.py24
-rw-r--r--bumble/crypto.py28
-rw-r--r--bumble/device.py210
-rw-r--r--bumble/drivers/__init__.py23
-rw-r--r--bumble/drivers/rtk.py36
-rw-r--r--bumble/gatt.py42
-rw-r--r--bumble/gatt_client.py99
-rw-r--r--bumble/gatt_server.py106
-rw-r--r--bumble/hci.py224
-rw-r--r--bumble/hfp.py754
-rw-r--r--bumble/host.py89
-rw-r--r--bumble/l2cap.py296
-rw-r--r--bumble/pandora/config.py9
-rw-r--r--bumble/pandora/security.py67
-rw-r--r--bumble/rfcomm.py297
-rw-r--r--bumble/sdp.py115
-rw-r--r--bumble/smp.py43
-rw-r--r--bumble/transport/__init__.py12
-rw-r--r--bumble/transport/android_emulator.py20
-rw-r--r--bumble/transport/android_netsim.py136
-rw-r--r--bumble/transport/common.py196
-rw-r--r--bumble/transport/file.py2
-rw-r--r--bumble/transport/hci_socket.py12
-rw-r--r--bumble/transport/pty.py4
-rw-r--r--bumble/transport/pyusb.py2
-rw-r--r--bumble/transport/serial.py2
-rw-r--r--bumble/transport/tcp_client.py4
-rw-r--r--bumble/transport/tcp_server.py5
-rw-r--r--bumble/transport/udp.py2
-rw-r--r--bumble/transport/usb.py2
-rw-r--r--bumble/transport/vhci.py11
-rw-r--r--bumble/transport/ws_client.py22
-rw-r--r--bumble/transport/ws_server.py17
-rw-r--r--bumble/utils.py110
-rw-r--r--bumble/vendor/__init__.py0
-rw-r--r--bumble/vendor/android/__init__.py0
-rw-r--r--bumble/vendor/android/hci.py318
-rw-r--r--bumble/vendor/zephyr/__init__.py0
-rw-r--r--bumble/vendor/zephyr/hci.py88
-rw-r--r--docs/mkdocs/mkdocs.yml1
-rw-r--r--docs/mkdocs/src/downloads/zephyr/hci_usb.zipbin0 -> 127126 bytes
-rw-r--r--docs/mkdocs/src/platforms/index.md1
-rw-r--r--docs/mkdocs/src/platforms/zephyr.md51
-rw-r--r--environment.yml2
-rw-r--r--examples/run_hfp_gateway.py10
-rw-r--r--examples/run_hfp_handsfree.py115
-rw-r--r--examples/run_rfcomm_server.py151
-rw-r--r--rust/CHANGELOG.md7
-rw-r--r--rust/Cargo.lock1033
-rw-r--r--rust/Cargo.toml47
-rw-r--r--rust/README.md26
-rw-r--r--rust/pytests/assigned_numbers.rs44
-rw-r--r--rust/pytests/pytests.rs1
-rw-r--r--rust/pytests/wrapper.rs8
-rw-r--r--rust/resources/test/firmware/realtek/README.md4
-rw-r--r--rust/resources/test/firmware/realtek/rtl8723b_fw_structure.binbin0 -> 45048 bytes
-rw-r--r--rust/resources/test/firmware/realtek/rtl8761bu_fw_structure.binbin0 -> 44484 bytes
-rw-r--r--rust/src/adv.rs14
-rw-r--r--rust/src/cli/firmware/mod.rs15
-rw-r--r--rust/src/cli/firmware/rtk.rs265
-rw-r--r--rust/src/cli/l2cap/client_bridge.rs191
-rw-r--r--rust/src/cli/l2cap/mod.rs190
-rw-r--r--rust/src/cli/l2cap/server_bridge.rs205
-rw-r--r--rust/src/cli/mod.rs19
-rw-r--r--rust/src/cli/usb/mod.rs (renamed from rust/examples/usb_probe.rs)16
-rw-r--r--rust/src/internal/drivers/mod.rs17
-rw-r--r--rust/src/internal/drivers/rtk.rs253
-rw-r--r--rust/src/internal/mod.rs20
-rw-r--r--rust/src/lib.rs2
-rw-r--r--rust/src/main.rs271
-rw-r--r--rust/src/wrapper/assigned_numbers/company_ids.rs2715
-rw-r--r--rust/src/wrapper/assigned_numbers/mod.rs36
-rw-r--r--rust/src/wrapper/core.rs2
-rw-r--r--rust/src/wrapper/device.rs157
-rw-r--r--rust/src/wrapper/drivers/mod.rs17
-rw-r--r--rust/src/wrapper/drivers/rtk.rs141
-rw-r--r--rust/src/wrapper/hci.rs35
-rw-r--r--rust/src/wrapper/host.rs71
-rw-r--r--rust/src/wrapper/l2cap.rs92
-rw-r--r--rust/src/wrapper/logging.rs14
-rw-r--r--rust/src/wrapper/mod.rs31
-rw-r--r--rust/src/wrapper/profile.rs13
-rw-r--r--rust/tools/file_header.rs78
-rw-r--r--rust/tools/gen_assigned_numbers.rs97
-rw-r--r--setup.cfg23
-rw-r--r--speaker.html28
-rw-r--r--tasks.py30
-rw-r--r--tests/__init__.py13
-rw-r--r--tests/at_test.py35
-rw-r--r--tests/gatt_test.py8
-rw-r--r--tests/hfp_test.py100
-rw-r--r--tests/keystore_test.py126
-rw-r--r--tests/l2cap_test.py69
-rw-r--r--tests/sdp_test.py128
-rw-r--r--tests/self_test.py31
-rw-r--r--tests/test_utils.py73
-rw-r--r--tests/utils_test.py77
-rw-r--r--tools/rtk_fw_download.py10
-rw-r--r--tools/rtk_util.py5
-rw-r--r--web/README.md48
-rw-r--r--web/bumble.js91
-rw-r--r--web/index.html131
-rw-r--r--web/scanner/scanner.html129
-rw-r--r--web/scanner/scanner.py (renamed from web/scanner.py)52
-rw-r--r--web/speaker/logo.svg42
-rw-r--r--web/speaker/speaker.css76
-rw-r--r--web/speaker/speaker.html34
-rw-r--r--web/speaker/speaker.js289
-rw-r--r--web/speaker/speaker.py321
124 files changed, 10591 insertions, 1627 deletions
diff --git a/.github/workflows/code-check.yml b/.github/workflows/code-check.yml
index b6cf8fd..021b1e4 100644
--- a/.github/workflows/code-check.yml
+++ b/.github/workflows/code-check.yml
@@ -14,6 +14,10 @@ jobs:
check:
name: Check Code
runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ python-version: ["3.8", "3.9", "3.10", "3.11"]
+ fail-fast: false
steps:
- name: Check out from Git
diff --git a/.github/workflows/python-build-test.yml b/.github/workflows/python-build-test.yml
index 3f20093..4cc3e73 100644
--- a/.github/workflows/python-build-test.yml
+++ b/.github/workflows/python-build-test.yml
@@ -12,10 +12,10 @@ permissions:
jobs:
build:
-
- runs-on: ubuntu-latest
+ runs-on: ${{ matrix.os }}
strategy:
matrix:
+ os: ['ubuntu-latest', 'macos-latest', 'windows-latest']
python-version: ["3.8", "3.9", "3.10", "3.11"]
fail-fast: false
@@ -41,11 +41,13 @@ jobs:
run: |
inv build
inv build.mkdocs
+
build-rust:
runs-on: ubuntu-latest
strategy:
matrix:
- python-version: [ "3.8", "3.9", "3.10" ]
+ python-version: [ "3.8", "3.9", "3.10", "3.11" ]
+ rust-version: [ "1.70.0", "stable" ]
fail-fast: false
steps:
- name: Check out from Git
@@ -62,9 +64,17 @@ jobs:
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
components: clippy,rustfmt
- - name: Rust Lints
- run: cd rust && cargo fmt --check && cargo clippy --all-targets -- --deny warnings
+ toolchain: ${{ matrix.rust-version }}
+ - name: Check License Headers
+ run: cd rust && cargo run --features dev-tools --bin file-header check-all
- name: Rust Build
- run: cd rust && cargo build --all-targets
+ run: cd rust && cargo build --all-targets && cargo build --all-features --all-targets
+ # Lints after build so what clippy needs is already built
+ - name: Rust Lints
+ run: cd rust && cargo fmt --check && cargo clippy --all-targets -- --deny warnings && cargo clippy --all-features --all-targets -- --deny warnings
- name: Rust Tests
- run: cd rust && cargo test \ No newline at end of file
+ run: cd rust && cargo test
+ # At some point, hook up publishing the binary. For now, just make sure it builds.
+ # Once we're ready to publish binaries, this should be built with `--release`.
+ - name: Build Bumble CLI
+ run: cd rust && cargo build --features bumble-tools --bin bumble \ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 864fe69..57e682a 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -39,10 +39,12 @@
"libusb",
"MITM",
"NDIS",
+ "netsim",
"NONBLOCK",
"NONCONN",
"OXIMETER",
"popleft",
+ "protobuf",
"psms",
"pyee",
"pyusb",
diff --git a/METADATA b/METADATA
index 30444f1..48086d7 100644
--- a/METADATA
+++ b/METADATA
@@ -11,7 +11,7 @@ third_party {
type: GIT
value: "https://github.com/google/bumble"
}
- version: "c66b357de6908cf3680d83a73c6744451e2d0fa0"
- last_upgrade_date { year: 2022 month: 7 day: 25 }
+ version: "783b2d70a517a4c5fd828a0f6b8b2a46fe8750c5"
+ last_upgrade_date { year: 2023 month: 9 day: 12 }
license_type: NOTICE
}
diff --git a/apps/console.py b/apps/console.py
index 0ea9e5b..9a529dd 100644
--- a/apps/console.py
+++ b/apps/console.py
@@ -1172,7 +1172,7 @@ class ScanResult:
name = ''
# Remove any '/P' qualifier suffix from the address string
- address_str = str(self.address).replace('/P', '')
+ address_str = self.address.to_string(with_type_qualifier=False)
# RSSI bar
bar_string = rssi_bar(self.rssi)
diff --git a/apps/controller_info.py b/apps/controller_info.py
index 4707983..5be4f3d 100644
--- a/apps/controller_info.py
+++ b/apps/controller_info.py
@@ -63,7 +63,8 @@ async def get_classic_info(host):
if command_succeeded(response):
print()
print(
- color('Classic Address:', 'yellow'), response.return_parameters.bd_addr
+ color('Classic Address:', 'yellow'),
+ response.return_parameters.bd_addr.to_string(False),
)
if host.supports_command(HCI_READ_LOCAL_NAME_COMMAND):
diff --git a/apps/l2cap_bridge.py b/apps/l2cap_bridge.py
index 17623e4..83379a0 100644
--- a/apps/l2cap_bridge.py
+++ b/apps/l2cap_bridge.py
@@ -105,7 +105,7 @@ class ServerBridge:
asyncio.create_task(self.pipe.l2cap_channel.disconnect())
def data_received(self, data):
- print(f'<<< Received on TCP: {len(data)}')
+ print(color(f'<<< [TCP DATA]: {len(data)} bytes', 'blue'))
self.pipe.l2cap_channel.write(data)
try:
@@ -123,6 +123,7 @@ class ServerBridge:
await self.l2cap_channel.disconnect()
def on_l2cap_close(self):
+ print(color('*** L2CAP channel closed', 'red'))
self.l2cap_channel = None
if self.tcp_transport is not None:
self.tcp_transport.close()
diff --git a/apps/pandora_server.py b/apps/pandora_server.py
index 5f92309..16bc211 100644
--- a/apps/pandora_server.py
+++ b/apps/pandora_server.py
@@ -1,8 +1,10 @@
import asyncio
import click
import logging
+import json
-from bumble.pandora import PandoraDevice, serve
+from bumble.pandora import PandoraDevice, Config, serve
+from typing import Dict, Any
BUMBLE_SERVER_GRPC_PORT = 7999
ROOTCANAL_PORT_CUTTLEFISH = 7300
@@ -18,12 +20,31 @@ ROOTCANAL_PORT_CUTTLEFISH = 7300
help='HCI transport',
default=f'tcp-client:127.0.0.1:<rootcanal-port>',
)
-def main(grpc_port: int, rootcanal_port: int, transport: str) -> None:
+@click.option(
+ '--config',
+ help='Bumble json configuration file',
+)
+def main(grpc_port: int, rootcanal_port: int, transport: str, config: str) -> None:
if '<rootcanal-port>' in transport:
transport = transport.replace('<rootcanal-port>', str(rootcanal_port))
- device = PandoraDevice({'transport': transport})
+
+ bumble_config = retrieve_config(config)
+ bumble_config.setdefault('transport', transport)
+ device = PandoraDevice(bumble_config)
+
+ server_config = Config()
+ server_config.load_from_dict(bumble_config.get('server', {}))
+
logging.basicConfig(level=logging.DEBUG)
- asyncio.run(serve(device, port=grpc_port))
+ asyncio.run(serve(device, config=server_config, port=grpc_port))
+
+
+def retrieve_config(config: str) -> Dict[str, Any]:
+ if not config:
+ return {}
+
+ with open(config, 'r') as f:
+ return json.load(f)
if __name__ == '__main__':
diff --git a/apps/show.py b/apps/show.py
index bf01ead..f849e3a 100644
--- a/apps/show.py
+++ b/apps/show.py
@@ -102,9 +102,21 @@ class SnoopPacketReader:
default='h4',
help='Format of the input file',
)
+@click.option(
+ '--vendors',
+ type=click.Choice(['android', 'zephyr']),
+ multiple=True,
+ help='Support vendor-specific commands (list one or more)',
+)
@click.argument('filename')
# pylint: disable=redefined-builtin
-def main(format, filename):
+def main(format, vendors, filename):
+ for vendor in vendors:
+ if vendor == 'android':
+ import bumble.vendor.android.hci
+ elif vendor == 'zephyr':
+ import bumble.vendor.zephyr.hci
+
input = open(filename, 'rb')
if format == 'h4':
packet_reader = PacketReader(input)
@@ -124,7 +136,6 @@ def main(format, filename):
if packet is None:
break
tracer.trace(hci.HCI_Packet.from_bytes(packet), direction)
-
except Exception as error:
print(color(f'!!! {error}', 'red'))
diff --git a/apps/speaker/speaker.css b/apps/speaker/speaker.css
index 075068b..dd4c799 100644
--- a/apps/speaker/speaker.css
+++ b/apps/speaker/speaker.css
@@ -56,7 +56,7 @@ body, h1, h2, h3, h4, h5, h6 {
border-radius: 4px;
padding: 4px;
margin: 6px;
- margin-left: 0px;
+ margin-left: 0;
}
th, td {
@@ -65,7 +65,7 @@ th, td {
}
.properties td:nth-child(even) {
- background-color: #D6EEEE;
+ background-color: #d6eeee;
font-family: monospace;
}
diff --git a/apps/speaker/speaker.html b/apps/speaker/speaker.html
index f68abcc..550049b 100644
--- a/apps/speaker/speaker.html
+++ b/apps/speaker/speaker.html
@@ -2,7 +2,7 @@
<html>
<head>
<title>Bumble Speaker</title>
- <script type="text/javascript" src="speaker.js"></script>
+ <script src="speaker.js"></script>
<link rel="stylesheet" href="speaker.css">
</head>
<body>
diff --git a/apps/speaker/speaker.py b/apps/speaker/speaker.py
index a2907d4..e451c04 100644
--- a/apps/speaker/speaker.py
+++ b/apps/speaker/speaker.py
@@ -195,7 +195,7 @@ class WebSocketOutput(QueuedOutput):
except HCI_StatusError:
pass
peer_name = '' if connection.peer_name is None else connection.peer_name
- peer_address = str(connection.peer_address).replace('/P', '')
+ peer_address = connection.peer_address.to_string(False)
await self.send_message(
'connection',
peer_address=peer_address,
@@ -228,10 +228,11 @@ class FfplayOutput(QueuedOutput):
subprocess: Optional[asyncio.subprocess.Process]
ffplay_task: Optional[asyncio.Task]
- def __init__(self) -> None:
- super().__init__(AacAudioExtractor())
+ def __init__(self, codec: str) -> None:
+ super().__init__(AudioExtractor.create(codec))
self.subprocess = None
self.ffplay_task = None
+ self.codec = codec
async def start(self):
if self.started:
@@ -240,7 +241,7 @@ class FfplayOutput(QueuedOutput):
await super().start()
self.subprocess = await asyncio.create_subprocess_shell(
- 'ffplay -acodec aac pipe:0',
+ f'ffplay -f {self.codec} pipe:0',
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
@@ -375,7 +376,7 @@ class UiServer:
if connection := self.speaker().connection:
await self.send_message(
'connection',
- peer_address=str(connection.peer_address).replace('/P', ''),
+ peer_address=connection.peer_address.to_string(False),
peer_name=connection.peer_name,
)
@@ -419,7 +420,7 @@ class Speaker:
self.outputs = []
for output in outputs:
if output == '@ffplay':
- self.outputs.append(FfplayOutput())
+ self.outputs.append(FfplayOutput(codec))
continue
# Default to FileOutput
@@ -708,17 +709,6 @@ def speaker(
):
"""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:
diff --git a/bumble/at.py b/bumble/at.py
new file mode 100644
index 0000000..78a4b08
--- /dev/null
+++ b/bumble/at.py
@@ -0,0 +1,85 @@
+# 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.
+
+from typing import List, Union
+
+
+def tokenize_parameters(buffer: bytes) -> List[bytes]:
+ """Split input parameters into tokens.
+ Removes space characters outside of double quote blocks:
+ T-rec-V-25 - 5.2.1 Command line general format: "Space characters (IA5 2/0)
+ are ignored [..], unless they are embedded in numeric or string constants"
+ Raises ValueError in case of invalid input string."""
+
+ tokens = []
+ in_quotes = False
+ token = bytearray()
+ for b in buffer:
+ char = bytearray([b])
+
+ if in_quotes:
+ token.extend(char)
+ if char == b'\"':
+ in_quotes = False
+ tokens.append(token[1:-1])
+ token = bytearray()
+ else:
+ if char == b' ':
+ pass
+ elif char == b',' or char == b')':
+ tokens.append(token)
+ tokens.append(char)
+ token = bytearray()
+ elif char == b'(':
+ if len(token) > 0:
+ raise ValueError("open_paren following regular character")
+ tokens.append(char)
+ elif char == b'"':
+ if len(token) > 0:
+ raise ValueError("quote following regular character")
+ in_quotes = True
+ token.extend(char)
+ else:
+ token.extend(char)
+
+ tokens.append(token)
+ return [bytes(token) for token in tokens if len(token) > 0]
+
+
+def parse_parameters(buffer: bytes) -> List[Union[bytes, list]]:
+ """Parse the parameters using the comma and parenthesis separators.
+ Raises ValueError in case of invalid input string."""
+
+ tokens = tokenize_parameters(buffer)
+ accumulator: List[list] = [[]]
+ current: Union[bytes, list] = bytes()
+
+ for token in tokens:
+ if token == b',':
+ accumulator[-1].append(current)
+ current = bytes()
+ elif token == b'(':
+ accumulator.append([])
+ elif token == b')':
+ if len(accumulator) < 2:
+ raise ValueError("close_paren without matching open_paren")
+ accumulator[-1].append(current)
+ current = accumulator.pop()
+ else:
+ current = token
+
+ accumulator[-1].append(current)
+ if len(accumulator) > 1:
+ raise ValueError("missing close_paren")
+ return accumulator[0]
diff --git a/bumble/att.py b/bumble/att.py
index 55ae8a5..db8d2ba 100644
--- a/bumble/att.py
+++ b/bumble/att.py
@@ -23,13 +23,14 @@
# Imports
# -----------------------------------------------------------------------------
from __future__ import annotations
+import enum
import functools
import struct
from pyee import EventEmitter
-from typing import Dict, Type, TYPE_CHECKING
+from typing import Dict, Type, List, Protocol, Union, Optional, Any, TYPE_CHECKING
-from bumble.core import UUID, name_or_number, get_dict_key_by_value, ProtocolError
-from bumble.hci import HCI_Object, key_with_value, HCI_Constant
+from bumble.core import UUID, name_or_number, ProtocolError
+from bumble.hci import HCI_Object, key_with_value
from bumble.colors import color
if TYPE_CHECKING:
@@ -182,6 +183,7 @@ UUID_2_FIELD_SPEC = lambda x, y: UUID.parse_uuid_2(x, y) # noqa: E731
# pylint: enable=line-too-long
# pylint: disable=invalid-name
+
# -----------------------------------------------------------------------------
# Exceptions
# -----------------------------------------------------------------------------
@@ -209,7 +211,7 @@ class ATT_PDU:
pdu_classes: Dict[int, Type[ATT_PDU]] = {}
op_code = 0
- name = None
+ name: str
@staticmethod
def from_bytes(pdu):
@@ -720,47 +722,67 @@ class ATT_Handle_Value_Confirmation(ATT_PDU):
# -----------------------------------------------------------------------------
-class Attribute(EventEmitter):
- # Permission flags
- READABLE = 0x01
- WRITEABLE = 0x02
- READ_REQUIRES_ENCRYPTION = 0x04
- WRITE_REQUIRES_ENCRYPTION = 0x08
- READ_REQUIRES_AUTHENTICATION = 0x10
- WRITE_REQUIRES_AUTHENTICATION = 0x20
- READ_REQUIRES_AUTHORIZATION = 0x40
- WRITE_REQUIRES_AUTHORIZATION = 0x80
-
- PERMISSION_NAMES = {
- READABLE: 'READABLE',
- WRITEABLE: 'WRITEABLE',
- READ_REQUIRES_ENCRYPTION: 'READ_REQUIRES_ENCRYPTION',
- WRITE_REQUIRES_ENCRYPTION: 'WRITE_REQUIRES_ENCRYPTION',
- READ_REQUIRES_AUTHENTICATION: 'READ_REQUIRES_AUTHENTICATION',
- WRITE_REQUIRES_AUTHENTICATION: 'WRITE_REQUIRES_AUTHENTICATION',
- READ_REQUIRES_AUTHORIZATION: 'READ_REQUIRES_AUTHORIZATION',
- WRITE_REQUIRES_AUTHORIZATION: 'WRITE_REQUIRES_AUTHORIZATION',
- }
+class ConnectionValue(Protocol):
+ def read(self, connection) -> bytes:
+ ...
- @staticmethod
- def string_to_permissions(permissions_str: str):
- try:
- return functools.reduce(
- lambda x, y: x | get_dict_key_by_value(Attribute.PERMISSION_NAMES, y),
- permissions_str.split(","),
- 0,
- )
- except TypeError as exc:
- raise TypeError(
- f"Attribute::permissions error:\nExpected a string containing any of the keys, separated by commas: {','.join(Attribute.PERMISSION_NAMES.values())}\nGot: {permissions_str}"
- ) from exc
+ def write(self, connection, value: bytes) -> None:
+ ...
- def __init__(self, attribute_type, permissions, value=b''):
+
+# -----------------------------------------------------------------------------
+class Attribute(EventEmitter):
+ class Permissions(enum.IntFlag):
+ READABLE = 0x01
+ WRITEABLE = 0x02
+ READ_REQUIRES_ENCRYPTION = 0x04
+ WRITE_REQUIRES_ENCRYPTION = 0x08
+ READ_REQUIRES_AUTHENTICATION = 0x10
+ WRITE_REQUIRES_AUTHENTICATION = 0x20
+ READ_REQUIRES_AUTHORIZATION = 0x40
+ WRITE_REQUIRES_AUTHORIZATION = 0x80
+
+ @classmethod
+ def from_string(cls, permissions_str: str) -> Attribute.Permissions:
+ try:
+ return functools.reduce(
+ lambda x, y: x | Attribute.Permissions[y],
+ permissions_str.replace('|', ',').split(","),
+ Attribute.Permissions(0),
+ )
+ except TypeError as exc:
+ # 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"Attribute::permissions error:\nExpected a string containing any of the keys, separated by commas: {enum_list_str }\nGot: {permissions_str}"
+ ) from exc
+
+ # Permission flags(legacy-use only)
+ READABLE = Permissions.READABLE
+ WRITEABLE = Permissions.WRITEABLE
+ READ_REQUIRES_ENCRYPTION = Permissions.READ_REQUIRES_ENCRYPTION
+ WRITE_REQUIRES_ENCRYPTION = Permissions.WRITE_REQUIRES_ENCRYPTION
+ READ_REQUIRES_AUTHENTICATION = Permissions.READ_REQUIRES_AUTHENTICATION
+ WRITE_REQUIRES_AUTHENTICATION = Permissions.WRITE_REQUIRES_AUTHENTICATION
+ READ_REQUIRES_AUTHORIZATION = Permissions.READ_REQUIRES_AUTHORIZATION
+ WRITE_REQUIRES_AUTHORIZATION = Permissions.WRITE_REQUIRES_AUTHORIZATION
+
+ value: Union[str, bytes, ConnectionValue]
+
+ def __init__(
+ self,
+ attribute_type: Union[str, bytes, UUID],
+ permissions: Union[str, Attribute.Permissions],
+ value: Union[str, bytes, ConnectionValue] = b'',
+ ) -> None:
EventEmitter.__init__(self)
self.handle = 0
self.end_group_handle = 0
if isinstance(permissions, str):
- self.permissions = self.string_to_permissions(permissions)
+ self.permissions = Attribute.Permissions.from_string(permissions)
else:
self.permissions = permissions
@@ -778,22 +800,26 @@ class Attribute(EventEmitter):
else:
self.value = value
- def encode_value(self, value):
+ def encode_value(self, value: Any) -> bytes:
return value
- def decode_value(self, value_bytes):
+ def decode_value(self, value_bytes: bytes) -> Any:
return value_bytes
- def read_value(self, connection: Connection):
+ def read_value(self, connection: Optional[Connection]) -> bytes:
if (
- self.permissions & self.READ_REQUIRES_ENCRYPTION
- ) and not connection.encryption:
+ (self.permissions & self.READ_REQUIRES_ENCRYPTION)
+ and connection is not None
+ and not connection.encryption
+ ):
raise ATT_Error(
error_code=ATT_INSUFFICIENT_ENCRYPTION_ERROR, att_handle=self.handle
)
if (
- self.permissions & self.READ_REQUIRES_AUTHENTICATION
- ) and not connection.authenticated:
+ (self.permissions & self.READ_REQUIRES_AUTHENTICATION)
+ and connection is not None
+ and not connection.authenticated
+ ):
raise ATT_Error(
error_code=ATT_INSUFFICIENT_AUTHENTICATION_ERROR, att_handle=self.handle
)
@@ -803,9 +829,9 @@ class Attribute(EventEmitter):
error_code=ATT_INSUFFICIENT_AUTHORIZATION_ERROR, att_handle=self.handle
)
- if read := getattr(self.value, 'read', None):
+ if hasattr(self.value, 'read'):
try:
- value = read(connection) # pylint: disable=not-callable
+ value = self.value.read(connection)
except ATT_Error as error:
raise ATT_Error(
error_code=error.error_code, att_handle=self.handle
@@ -815,7 +841,7 @@ class Attribute(EventEmitter):
return self.encode_value(value)
- def write_value(self, connection: Connection, value_bytes):
+ def write_value(self, connection: Connection, value_bytes: bytes) -> None:
if (
self.permissions & self.WRITE_REQUIRES_ENCRYPTION
) and not connection.encryption:
@@ -836,9 +862,9 @@ class Attribute(EventEmitter):
value = self.decode_value(value_bytes)
- if write := getattr(self.value, 'write', None):
+ if hasattr(self.value, 'write'):
try:
- write(connection, value) # pylint: disable=not-callable
+ self.value.write(connection, value) # pylint: disable=not-callable
except ATT_Error as error:
raise ATT_Error(
error_code=error.error_code, att_handle=self.handle
diff --git a/bumble/controller.py b/bumble/controller.py
index 688fcd7..9b2960a 100644
--- a/bumble/controller.py
+++ b/bumble/controller.py
@@ -15,6 +15,8 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
+from __future__ import annotations
+
import logging
import asyncio
import itertools
@@ -58,8 +60,10 @@ from bumble.hci import (
HCI_Packet,
HCI_Role_Change_Event,
)
-from typing import Optional, Union, Dict
+from typing import Optional, Union, Dict, TYPE_CHECKING
+if TYPE_CHECKING:
+ from bumble.transport.common import TransportSink, TransportSource
# -----------------------------------------------------------------------------
# Logging
@@ -104,7 +108,7 @@ class Controller:
self,
name,
host_source=None,
- host_sink=None,
+ host_sink: Optional[TransportSink] = None,
link=None,
public_address: Optional[Union[bytes, str, Address]] = None,
):
@@ -188,6 +192,8 @@ class Controller:
if link:
link.add_controller(self)
+ self.terminated = asyncio.get_running_loop().create_future()
+
@property
def host(self):
return self.hci_sink
@@ -288,10 +294,9 @@ class Controller:
if self.host:
self.host.on_packet(packet.to_bytes())
- # This method allow the controller to emulate the same API as a transport source
+ # This method allows the controller to emulate the same API as a transport source
async def wait_for_termination(self):
- # For now, just wait forever
- await asyncio.get_running_loop().create_future()
+ await self.terminated
############################################################
# Link connections
diff --git a/bumble/core.py b/bumble/core.py
index 2e3f4af..4a67d6e 100644
--- a/bumble/core.py
+++ b/bumble/core.py
@@ -78,7 +78,13 @@ def get_dict_key_by_value(dictionary, value):
class BaseError(Exception):
"""Base class for errors with an error code, error name and namespace"""
- def __init__(self, error_code, error_namespace='', error_name='', details=''):
+ def __init__(
+ self,
+ error_code: Optional[int],
+ error_namespace: str = '',
+ error_name: str = '',
+ details: str = '',
+ ):
super().__init__()
self.error_code = error_code
self.error_namespace = error_namespace
@@ -90,12 +96,14 @@ class BaseError(Exception):
namespace = f'{self.error_namespace}/'
else:
namespace = ''
- if self.error_name:
- name = f'{self.error_name} [0x{self.error_code:X}]'
- else:
- name = f'0x{self.error_code:X}'
+ error_text = {
+ (True, True): f'{self.error_name} [0x{self.error_code:X}]',
+ (True, False): self.error_name,
+ (False, True): f'0x{self.error_code:X}',
+ (False, False): '',
+ }[(self.error_name != '', self.error_code is not None)]
- return f'{type(self).__name__}({namespace}{name})'
+ return f'{type(self).__name__}({namespace}{error_text})'
class ProtocolError(BaseError):
@@ -134,6 +142,10 @@ class ConnectionError(BaseError): # pylint: disable=redefined-builtin
self.peer_address = peer_address
+class ConnectionParameterUpdateError(BaseError):
+ """Connection Parameter Update Error"""
+
+
# -----------------------------------------------------------------------------
# UUID
#
diff --git a/bumble/crypto.py b/bumble/crypto.py
index 757594f..852c675 100644
--- a/bumble/crypto.py
+++ b/bumble/crypto.py
@@ -23,22 +23,18 @@
# -----------------------------------------------------------------------------
import logging
import operator
-import platform
-
-if platform.system() != 'Emscripten':
- import secrets
- from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
- from cryptography.hazmat.primitives.asymmetric.ec import (
- generate_private_key,
- ECDH,
- EllipticCurvePublicNumbers,
- EllipticCurvePrivateNumbers,
- SECP256R1,
- )
- from cryptography.hazmat.primitives import cmac
-else:
- # TODO: implement stubs
- pass
+
+import secrets
+from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
+from cryptography.hazmat.primitives.asymmetric.ec import (
+ generate_private_key,
+ ECDH,
+ EllipticCurvePublicNumbers,
+ EllipticCurvePrivateNumbers,
+ SECP256R1,
+)
+from cryptography.hazmat.primitives import cmac
+
# -----------------------------------------------------------------------------
# Logging
diff --git a/bumble/device.py b/bumble/device.py
index 031c071..b01dc58 100644
--- a/bumble/device.py
+++ b/bumble/device.py
@@ -23,7 +23,18 @@ import asyncio
import logging
from contextlib import asynccontextmanager, AsyncExitStack
from dataclasses import dataclass
-from typing import Any, Callable, ClassVar, Dict, List, Optional, Tuple, Type, Union
+from typing import (
+ Any,
+ Callable,
+ ClassVar,
+ Dict,
+ List,
+ Optional,
+ Tuple,
+ Type,
+ Union,
+ TYPE_CHECKING,
+)
from .colors import color
from .att import ATT_CID, ATT_DEFAULT_MTU, ATT_PDU
@@ -86,6 +97,7 @@ from .hci import (
HCI_LE_Extended_Create_Connection_Command,
HCI_LE_Rand_Command,
HCI_LE_Read_PHY_Command,
+ HCI_LE_Set_Address_Resolution_Enable_Command,
HCI_LE_Set_Advertising_Data_Command,
HCI_LE_Set_Advertising_Enable_Command,
HCI_LE_Set_Advertising_Parameters_Command,
@@ -129,6 +141,7 @@ from .core import (
BT_LE_TRANSPORT,
BT_PERIPHERAL_ROLE,
AdvertisingData,
+ ConnectionParameterUpdateError,
CommandTimeoutError,
ConnectionPHY,
InvalidStateError,
@@ -151,6 +164,9 @@ from . import sdp
from . import l2cap
from . import core
+if TYPE_CHECKING:
+ from .transport.common import TransportSource, TransportSink
+
# -----------------------------------------------------------------------------
# Logging
@@ -651,7 +667,7 @@ class Connection(CompositeEventEmitter):
def is_incomplete(self) -> bool:
return self.handle is None
- def send_l2cap_pdu(self, cid, pdu):
+ def send_l2cap_pdu(self, cid: int, pdu: bytes) -> None:
self.device.send_l2cap_pdu(self.handle, cid, pdu)
def create_l2cap_connector(self, psm):
@@ -708,6 +724,7 @@ class Connection(CompositeEventEmitter):
connection_interval_max,
max_latency,
supervision_timeout,
+ use_l2cap=False,
):
return await self.device.update_connection_parameters(
self,
@@ -715,6 +732,7 @@ class Connection(CompositeEventEmitter):
connection_interval_max,
max_latency,
supervision_timeout,
+ use_l2cap=use_l2cap,
)
async def set_phy(self, tx_phys=None, rx_phys=None, phy_options=None):
@@ -778,6 +796,7 @@ class DeviceConfiguration:
self.irk = bytes(16) # This really must be changed for any level of security
self.keystore = None
self.gatt_services: List[Dict[str, Any]] = []
+ self.address_resolution_offload = False
def load_from_dict(self, config: Dict[str, Any]) -> None:
# Load simple properties
@@ -940,7 +959,13 @@ class Device(CompositeEventEmitter):
pass
@classmethod
- def with_hci(cls, name, address, hci_source, hci_sink):
+ def with_hci(
+ cls,
+ name: str,
+ address: Address,
+ hci_source: TransportSource,
+ hci_sink: TransportSink,
+ ) -> Device:
'''
Create a Device instance with a Host configured to communicate with a controller
through an HCI source/sink
@@ -949,18 +974,25 @@ class Device(CompositeEventEmitter):
return cls(name=name, address=address, host=host)
@classmethod
- def from_config_file(cls, filename):
+ def from_config_file(cls, filename: str) -> Device:
config = DeviceConfiguration()
config.load_from_file(filename)
return cls(config=config)
@classmethod
- def from_config_with_hci(cls, config, hci_source, hci_sink):
+ def from_config_with_hci(
+ cls,
+ config: DeviceConfiguration,
+ hci_source: TransportSource,
+ hci_sink: TransportSink,
+ ) -> Device:
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):
+ def from_config_file_with_hci(
+ cls, filename: str, hci_source: TransportSource, hci_sink: TransportSink
+ ) -> Device:
config = DeviceConfiguration()
config.load_from_file(filename)
return cls.from_config_with_hci(config, hci_source, hci_sink)
@@ -1029,6 +1061,7 @@ class Device(CompositeEventEmitter):
self.discoverable = config.discoverable
self.connectable = config.connectable
self.classic_accept_any = config.classic_accept_any
+ self.address_resolution_offload = config.address_resolution_offload
for service in config.gatt_services:
characteristics = []
@@ -1093,7 +1126,7 @@ class Device(CompositeEventEmitter):
return self._host
@host.setter
- def host(self, host):
+ def host(self, host: Host) -> None:
# Unsubscribe from events from the current host
if self._host:
for event_name in device_host_event_handlers:
@@ -1153,8 +1186,8 @@ class Device(CompositeEventEmitter):
def create_l2cap_registrar(self, psm):
return lambda handler: self.register_l2cap_server(psm, handler)
- def register_l2cap_server(self, psm, server):
- self.l2cap_channel_manager.register_server(psm, server)
+ def register_l2cap_server(self, psm, server) -> int:
+ return self.l2cap_channel_manager.register_server(psm, server)
def register_l2cap_channel_server(
self,
@@ -1180,7 +1213,7 @@ class Device(CompositeEventEmitter):
connection, psm, max_credits, mtu, mps
)
- def send_l2cap_pdu(self, connection_handle, cid, pdu):
+ def send_l2cap_pdu(self, connection_handle: int, cid: int, pdu: bytes) -> None:
self.host.send_l2cap_pdu(connection_handle, cid, pdu)
async def send_command(self, command, check_result=False):
@@ -1256,31 +1289,16 @@ class Device(CompositeEventEmitter):
)
# Load the address resolving list
- if self.keystore and self.host.supports_command(
- HCI_LE_CLEAR_RESOLVING_LIST_COMMAND
- ):
- await self.send_command(HCI_LE_Clear_Resolving_List_Command()) # type: ignore[call-arg]
-
- resolving_keys = await self.keystore.get_resolving_keys()
- for irk, address in resolving_keys:
- await self.send_command(
- HCI_LE_Add_Device_To_Resolving_List_Command(
- peer_identity_address_type=address.address_type,
- peer_identity_address=address,
- peer_irk=irk,
- local_irk=self.irk,
- ) # type: ignore[call-arg]
- )
-
- # Enable address resolution
- # await self.send_command(
- # HCI_LE_Set_Address_Resolution_Enable_Command(
- # address_resolution_enable=1)
- # )
- # )
+ if self.keystore:
+ await self.refresh_resolving_list()
- # Create a host-side address resolver
- self.address_resolver = smp.AddressResolver(resolving_keys)
+ # Enable address resolution
+ if self.address_resolution_offload:
+ await self.send_command(
+ HCI_LE_Set_Address_Resolution_Enable_Command(
+ address_resolution_enable=1
+ ) # type: ignore[call-arg]
+ )
if self.classic_enabled:
await self.send_command(
@@ -1310,6 +1328,26 @@ class Device(CompositeEventEmitter):
await self.host.flush()
self.powered_on = False
+ async def refresh_resolving_list(self) -> None:
+ assert self.keystore is not None
+
+ resolving_keys = await self.keystore.get_resolving_keys()
+ # Create a host-side address resolver
+ self.address_resolver = smp.AddressResolver(resolving_keys)
+
+ if self.address_resolution_offload:
+ await self.send_command(HCI_LE_Clear_Resolving_List_Command()) # type: ignore[call-arg]
+
+ for irk, address in resolving_keys:
+ await self.send_command(
+ HCI_LE_Add_Device_To_Resolving_List_Command(
+ peer_identity_address_type=address.address_type,
+ peer_identity_address=address,
+ peer_irk=irk,
+ local_irk=self.irk,
+ ) # type: ignore[call-arg]
+ )
+
def supports_le_feature(self, feature):
return self.host.supports_le_feature(feature)
@@ -2075,11 +2113,30 @@ class Device(CompositeEventEmitter):
supervision_timeout,
min_ce_length=0,
max_ce_length=0,
- ):
+ use_l2cap=False,
+ ) -> None:
'''
NOTE: the name of the parameters may look odd, but it just follows the names
used in the Bluetooth spec.
'''
+
+ if use_l2cap:
+ if connection.role != BT_PERIPHERAL_ROLE:
+ raise InvalidStateError(
+ 'only peripheral can update connection parameters with l2cap'
+ )
+ l2cap_result = (
+ await self.l2cap_channel_manager.update_connection_parameters(
+ connection,
+ connection_interval_min,
+ connection_interval_max,
+ max_latency,
+ supervision_timeout,
+ )
+ )
+ if l2cap_result != l2cap.L2CAP_CONNECTION_PARAMETERS_ACCEPTED_RESULT:
+ raise ConnectionParameterUpdateError(l2cap_result)
+
result = await self.send_command(
HCI_LE_Connection_Update_Command(
connection_handle=connection.handle,
@@ -2089,7 +2146,7 @@ class Device(CompositeEventEmitter):
supervision_timeout=supervision_timeout,
min_ce_length=min_ce_length,
max_ce_length=max_ce_length,
- )
+ ) # type: ignore[call-arg]
)
if result.status != HCI_Command_Status_Event.PENDING:
raise HCI_StatusError(result)
@@ -2230,9 +2287,11 @@ class Device(CompositeEventEmitter):
def request_pairing(self, connection):
return self.smp_manager.request_pairing(connection)
- async def get_long_term_key(self, connection_handle, rand, ediv):
+ async def get_long_term_key(
+ self, connection_handle: int, rand: bytes, ediv: int
+ ) -> Optional[bytes]:
if (connection := self.lookup_connection(connection_handle)) is None:
- return
+ return None
# Start by looking for the key in an SMP session
ltk = self.smp_manager.get_long_term_key(connection, rand, ediv)
@@ -2252,19 +2311,24 @@ class Device(CompositeEventEmitter):
if connection.role == BT_PERIPHERAL_ROLE and keys.ltk_peripheral:
return keys.ltk_peripheral.value
+ return None
async def get_link_key(self, address: Address) -> Optional[bytes]:
+ if self.keystore is None:
+ return None
+
# Look for the key in the keystore
- if self.keystore is not None:
- keys = await self.keystore.get(str(address))
- if keys is not None:
- logger.debug('found keys in the key store')
- if keys.link_key is None:
- logger.warning('no link key')
- return None
+ keys = await self.keystore.get(str(address))
+ if keys is None:
+ logger.debug(f'no keys found for {address}')
+ return None
- return keys.link_key.value
- return None
+ logger.debug('found keys in the key store')
+ if keys.link_key is None:
+ logger.warning('no link key')
+ return None
+
+ return keys.link_key.value
# [Classic only]
async def authenticate(self, connection):
@@ -2383,6 +2447,18 @@ class Device(CompositeEventEmitter):
'connection_encryption_failure', on_encryption_failure
)
+ async def update_keys(self, address: str, keys: PairingKeys) -> None:
+ if self.keystore is None:
+ return
+
+ try:
+ await self.keystore.update(address, keys)
+ await self.refresh_resolving_list()
+ except Exception as error:
+ logger.warning(f'!!! error while storing keys: {error}')
+ else:
+ self.emit('key_store_update')
+
# [Classic only]
async def switch_role(self, connection: Connection, role: int):
pending_role_change = asyncio.get_running_loop().create_future()
@@ -2477,13 +2553,7 @@ class Device(CompositeEventEmitter):
value=link_key, authenticated=authenticated
)
- async def store_keys():
- try:
- await self.keystore.update(str(bd_addr), pairing_keys)
- except Exception as error:
- logger.warning(f'!!! error while storing keys: {error}')
-
- self.abort_on('flush', store_keys())
+ self.abort_on('flush', self.update_keys(str(bd_addr), pairing_keys))
if connection := self.find_connection_by_bd_addr(
bd_addr, transport=BT_BR_EDR_TRANSPORT
@@ -2688,7 +2758,9 @@ class Device(CompositeEventEmitter):
self.abort_on(
'flush',
self.start_advertising(
- advertising_type=self.advertising_type, auto_restart=True
+ advertising_type=self.advertising_type,
+ own_address_type=self.advertising_own_address_type,
+ auto_restart=True,
),
)
@@ -2735,20 +2807,6 @@ class Device(CompositeEventEmitter):
)
connection.emit('connection_authentication_failure', error)
- @host_event_handler
- @with_connection_from_address
- def on_ssp_complete(self, connection):
- # On Secure Simple Pairing complete, in case:
- # - Connection isn't already authenticated
- # - AND we are not the initiator of the authentication
- # We must trigger authentication to know if we are truly authenticated
- if not connection.authenticating and not connection.authenticated:
- logger.debug(
- f'*** Trigger Connection Authentication: [0x{connection.handle:04X}] '
- f'{connection.peer_address}'
- )
- asyncio.create_task(connection.authenticate())
-
# [Classic only]
@host_event_handler
@with_connection_from_address
@@ -3103,6 +3161,18 @@ class Device(CompositeEventEmitter):
connection.emit('role_change_failure', error)
self.emit('role_change_failure', address, error)
+ # [Classic only]
+ @host_event_handler
+ @with_connection_from_address
+ def on_classic_pairing(self, connection: Connection) -> None:
+ connection.emit('classic_pairing')
+
+ # [Classic only]
+ @host_event_handler
+ @with_connection_from_address
+ def on_classic_pairing_failure(self, connection: Connection, status) -> None:
+ connection.emit('classic_pairing_failure', status)
+
def on_pairing_start(self, connection: Connection) -> None:
connection.emit('pairing_start')
@@ -3151,7 +3221,7 @@ class Device(CompositeEventEmitter):
@host_event_handler
@with_connection_from_handle
- def on_l2cap_pdu(self, connection, cid, pdu):
+ def on_l2cap_pdu(self, connection: Connection, cid: int, pdu: bytes):
self.l2cap_channel_manager.on_pdu(connection, cid, pdu)
def __str__(self):
diff --git a/bumble/drivers/__init__.py b/bumble/drivers/__init__.py
index 2decab7..d8ea06e 100644
--- a/bumble/drivers/__init__.py
+++ b/bumble/drivers/__init__.py
@@ -21,6 +21,8 @@ like loading firmware after a cold start.
# -----------------------------------------------------------------------------
import abc
import logging
+import pathlib
+import platform
from . import rtk
@@ -66,3 +68,24 @@ async def get_driver_for_host(host):
return driver
return None
+
+
+def project_data_dir() -> pathlib.Path:
+ """
+ Returns:
+ A path to an OS-specific directory for bumble data. The directory is created if
+ it doesn't exist.
+ """
+ import platformdirs
+
+ if platform.system() == 'Darwin':
+ # platformdirs doesn't handle macOS right: it doesn't assemble a bundle id
+ # out of author & project
+ return platformdirs.user_data_path(
+ appname='com.google.bumble', ensure_exists=True
+ )
+ else:
+ # windows and linux don't use the com qualifier
+ return platformdirs.user_data_path(
+ appname='bumble', appauthor='google', ensure_exists=True
+ )
diff --git a/bumble/drivers/rtk.py b/bumble/drivers/rtk.py
index c0bccc9..f78a14d 100644
--- a/bumble/drivers/rtk.py
+++ b/bumble/drivers/rtk.py
@@ -34,10 +34,9 @@ import weakref
from bumble.hci import (
- hci_command_op_code,
+ hci_vendor_command_op_code,
STATUS_SPEC,
HCI_SUCCESS,
- HCI_COMMAND_NAMES,
HCI_Command,
HCI_Reset_Command,
HCI_Read_Local_Version_Information_Command,
@@ -125,6 +124,7 @@ RTK_USB_PRODUCTS = {
(0x2550, 0x8761),
(0x2B89, 0x8761),
(0x7392, 0xC611),
+ (0x0BDA, 0x877B),
# Realtek 8821AE
(0x0B05, 0x17DC),
(0x13D3, 0x3414),
@@ -178,8 +178,10 @@ RTK_USB_PRODUCTS = {
# -----------------------------------------------------------------------------
# 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_RTK_READ_ROM_VERSION_COMMAND = hci_vendor_command_op_code(0x6D)
+HCI_RTK_DOWNLOAD_COMMAND = hci_vendor_command_op_code(0x20)
+HCI_RTK_DROP_FIRMWARE_COMMAND = hci_vendor_command_op_code(0x66)
+HCI_Command.register_commands(globals())
@HCI_Command.command(return_parameters_fields=[("status", STATUS_SPEC), ("version", 1)])
@@ -187,10 +189,6 @@ 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)],
@@ -199,10 +197,6 @@ 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
@@ -445,6 +439,11 @@ class Driver:
# When the environment variable is set, don't look elsewhere
return None
+ # Then, look where the firmware download tool writes by default
+ if (path := rtk_firmware_dir() / file_name).is_file():
+ logger.debug(f"{file_name} found in project data dir")
+ return path
+
# 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")
@@ -645,3 +644,16 @@ class Driver:
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}")
+
+
+def rtk_firmware_dir() -> pathlib.Path:
+ """
+ Returns:
+ A path to a subdir of the project data dir for Realtek firmware.
+ The directory is created if it doesn't exist.
+ """
+ from bumble.drivers import project_data_dir
+
+ p = project_data_dir() / "firmware" / "realtek"
+ p.mkdir(parents=True, exist_ok=True)
+ return p
diff --git a/bumble/gatt.py b/bumble/gatt.py
index 067f31d..fe3e85c 100644
--- a/bumble/gatt.py
+++ b/bumble/gatt.py
@@ -28,7 +28,7 @@ import enum
import functools
import logging
import struct
-from typing import Optional, Sequence, List
+from typing import Optional, Sequence, Iterable, List, Union
from .colors import color
from .core import UUID, get_dict_key_by_value
@@ -187,7 +187,7 @@ GATT_CENTRAL_ADDRESS_RESOLUTION__CHARACTERISTIC = UUID.from_16_bi
# -----------------------------------------------------------------------------
-def show_services(services):
+def show_services(services: Iterable[Service]) -> None:
for service in services:
print(color(str(service), 'cyan'))
@@ -210,11 +210,11 @@ class Service(Attribute):
def __init__(
self,
- uuid,
+ uuid: Union[str, UUID],
characteristics: List[Characteristic],
primary=True,
included_services: List[Service] = [],
- ):
+ ) -> None:
# Convert the uuid to a UUID object if it isn't already
if isinstance(uuid, str):
uuid = UUID(uuid)
@@ -239,7 +239,7 @@ class Service(Attribute):
"""
return None
- def __str__(self):
+ def __str__(self) -> str:
return (
f'Service(handle=0x{self.handle:04X}, '
f'end=0x{self.end_group_handle:04X}, '
@@ -255,9 +255,11 @@ class TemplateService(Service):
to expose their UUID as a class property
'''
- UUID: Optional[UUID] = None
+ UUID: UUID
- def __init__(self, characteristics, primary=True):
+ def __init__(
+ self, characteristics: List[Characteristic], primary: bool = True
+ ) -> None:
super().__init__(self.UUID, characteristics, primary)
@@ -269,7 +271,7 @@ class IncludedServiceDeclaration(Attribute):
service: Service
- def __init__(self, service):
+ def __init__(self, service: Service) -> None:
declaration_bytes = struct.pack(
'<HH2s', service.handle, service.end_group_handle, service.uuid.to_bytes()
)
@@ -278,7 +280,7 @@ class IncludedServiceDeclaration(Attribute):
)
self.service = service
- def __str__(self):
+ def __str__(self) -> str:
return (
f'IncludedServiceDefinition(handle=0x{self.handle:04X}, '
f'group_starting_handle=0x{self.service.handle:04X}, '
@@ -326,7 +328,7 @@ class Characteristic(Attribute):
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):
+ def __str__(self) -> str:
# 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(
@@ -348,10 +350,10 @@ class Characteristic(Attribute):
def __init__(
self,
- uuid,
+ uuid: Union[str, bytes, UUID],
properties: Characteristic.Properties,
- permissions,
- value=b'',
+ permissions: Union[str, Attribute.Permissions],
+ value: Union[str, bytes, CharacteristicValue] = b'',
descriptors: Sequence[Descriptor] = (),
):
super().__init__(uuid, permissions, value)
@@ -369,7 +371,7 @@ class Characteristic(Attribute):
def has_properties(self, properties: Characteristic.Properties) -> bool:
return self.properties & properties == properties
- def __str__(self):
+ def __str__(self) -> str:
return (
f'Characteristic(handle=0x{self.handle:04X}, '
f'end=0x{self.end_group_handle:04X}, '
@@ -386,7 +388,7 @@ class CharacteristicDeclaration(Attribute):
characteristic: Characteristic
- def __init__(self, characteristic, value_handle):
+ def __init__(self, characteristic: Characteristic, value_handle: int) -> None:
declaration_bytes = (
struct.pack('<BH', characteristic.properties, value_handle)
+ characteristic.uuid.to_pdu_bytes()
@@ -397,7 +399,7 @@ class CharacteristicDeclaration(Attribute):
self.value_handle = value_handle
self.characteristic = characteristic
- def __str__(self):
+ def __str__(self) -> str:
return (
f'CharacteristicDeclaration(handle=0x{self.handle:04X}, '
f'value_handle=0x{self.value_handle:04X}, '
@@ -520,7 +522,7 @@ class CharacteristicAdapter:
return self.wrapped_characteristic.unsubscribe(subscriber)
- def __str__(self):
+ def __str__(self) -> str:
wrapped = str(self.wrapped_characteristic)
return f'{self.__class__.__name__}({wrapped})'
@@ -600,10 +602,10 @@ class UTF8CharacteristicAdapter(CharacteristicAdapter):
Adapter that converts strings to/from bytes using UTF-8 encoding
'''
- def encode_value(self, value):
+ def encode_value(self, value: str) -> bytes:
return value.encode('utf-8')
- def decode_value(self, value):
+ def decode_value(self, value: bytes) -> str:
return value.decode('utf-8')
@@ -613,7 +615,7 @@ class Descriptor(Attribute):
See Vol 3, Part G - 3.3.3 Characteristic Descriptor Declarations
'''
- def __str__(self):
+ def __str__(self) -> str:
return (
f'Descriptor(handle=0x{self.handle:04X}, '
f'type={self.type}, '
diff --git a/bumble/gatt_client.py b/bumble/gatt_client.py
index a33039e..e3b8bb2 100644
--- a/bumble/gatt_client.py
+++ b/bumble/gatt_client.py
@@ -28,7 +28,18 @@ import asyncio
import logging
import struct
from datetime import datetime
-from typing import List, Optional, Dict, Tuple, Callable, Union, Any
+from typing import (
+ List,
+ Optional,
+ Dict,
+ Tuple,
+ Callable,
+ Union,
+ Any,
+ Iterable,
+ Type,
+ TYPE_CHECKING,
+)
from pyee import EventEmitter
@@ -66,8 +77,12 @@ from .gatt import (
GATT_INCLUDE_ATTRIBUTE_TYPE,
Characteristic,
ClientCharacteristicConfigurationBits,
+ TemplateService,
)
+if TYPE_CHECKING:
+ from bumble.device import Connection
+
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
@@ -78,16 +93,16 @@ logger = logging.getLogger(__name__)
# Proxies
# -----------------------------------------------------------------------------
class AttributeProxy(EventEmitter):
- client: Client
-
- def __init__(self, client, handle, end_group_handle, attribute_type):
+ def __init__(
+ self, client: Client, handle: int, end_group_handle: int, attribute_type: UUID
+ ) -> None:
EventEmitter.__init__(self)
self.client = client
self.handle = handle
self.end_group_handle = end_group_handle
self.type = attribute_type
- async def read_value(self, no_long_read=False):
+ async def read_value(self, no_long_read: bool = False) -> bytes:
return self.decode_value(
await self.client.read_value(self.handle, no_long_read)
)
@@ -97,13 +112,13 @@ class AttributeProxy(EventEmitter):
self.handle, self.encode_value(value), with_response
)
- def encode_value(self, value):
+ def encode_value(self, value: Any) -> bytes:
return value
- def decode_value(self, value_bytes):
+ def decode_value(self, value_bytes: bytes) -> Any:
return value_bytes
- def __str__(self):
+ def __str__(self) -> str:
return f'Attribute(handle=0x{self.handle:04X}, type={self.type})'
@@ -136,14 +151,14 @@ class ServiceProxy(AttributeProxy):
def get_characteristics_by_uuid(self, uuid):
return self.client.get_characteristics_by_uuid(uuid, self)
- def __str__(self):
+ def __str__(self) -> str:
return f'Service(handle=0x{self.handle:04X}, uuid={self.uuid})'
class CharacteristicProxy(AttributeProxy):
properties: Characteristic.Properties
descriptors: List[DescriptorProxy]
- subscribers: Dict[Any, Callable]
+ subscribers: Dict[Any, Callable[[bytes], Any]]
def __init__(
self,
@@ -171,7 +186,9 @@ class CharacteristicProxy(AttributeProxy):
return await self.client.discover_descriptors(self)
async def subscribe(
- self, subscriber: Optional[Callable] = None, prefer_notify=True
+ self,
+ subscriber: Optional[Callable[[bytes], Any]] = None,
+ prefer_notify: bool = True,
):
if subscriber is not None:
if subscriber in self.subscribers:
@@ -195,7 +212,7 @@ class CharacteristicProxy(AttributeProxy):
return await self.client.unsubscribe(self, subscriber)
- def __str__(self):
+ def __str__(self) -> str:
return (
f'Characteristic(handle=0x{self.handle:04X}, '
f'uuid={self.uuid}, '
@@ -207,7 +224,7 @@ class DescriptorProxy(AttributeProxy):
def __init__(self, client, handle, descriptor_type):
super().__init__(client, handle, 0, descriptor_type)
- def __str__(self):
+ def __str__(self) -> str:
return f'Descriptor(handle=0x{self.handle:04X}, type={self.type})'
@@ -216,8 +233,10 @@ class ProfileServiceProxy:
Base class for profile-specific service proxies
'''
+ SERVICE_CLASS: Type[TemplateService]
+
@classmethod
- def from_client(cls, client):
+ def from_client(cls, client: Client) -> ProfileServiceProxy:
return ServiceProxy.from_client(cls, client, cls.SERVICE_CLASS.UUID)
@@ -227,8 +246,12 @@ class ProfileServiceProxy:
class Client:
services: List[ServiceProxy]
cached_values: Dict[int, Tuple[datetime, bytes]]
+ notification_subscribers: Dict[int, Callable[[bytes], Any]]
+ indication_subscribers: Dict[int, Callable[[bytes], Any]]
+ pending_response: Optional[asyncio.futures.Future[ATT_PDU]]
+ pending_request: Optional[ATT_PDU]
- def __init__(self, connection):
+ def __init__(self, connection: Connection) -> None:
self.connection = connection
self.mtu_exchange_done = False
self.request_semaphore = asyncio.Semaphore(1)
@@ -241,16 +264,16 @@ class Client:
self.services = []
self.cached_values = {}
- def send_gatt_pdu(self, pdu):
+ def send_gatt_pdu(self, pdu: bytes) -> None:
self.connection.send_l2cap_pdu(ATT_CID, pdu)
- async def send_command(self, command):
+ async def send_command(self, command: ATT_PDU) -> None:
logger.debug(
f'GATT Command from client: [0x{self.connection.handle:04X}] {command}'
)
self.send_gatt_pdu(command.to_bytes())
- async def send_request(self, request):
+ async def send_request(self, request: ATT_PDU):
logger.debug(
f'GATT Request from client: [0x{self.connection.handle:04X}] {request}'
)
@@ -279,14 +302,14 @@ class Client:
return response
- def send_confirmation(self, confirmation):
+ def send_confirmation(self, confirmation: ATT_Handle_Value_Confirmation) -> None:
logger.debug(
f'GATT Confirmation from client: [0x{self.connection.handle:04X}] '
f'{confirmation}'
)
self.send_gatt_pdu(confirmation.to_bytes())
- async def request_mtu(self, mtu):
+ async def request_mtu(self, mtu: int) -> int:
# Check the range
if mtu < ATT_DEFAULT_MTU:
raise ValueError(f'MTU must be >= {ATT_DEFAULT_MTU}')
@@ -313,10 +336,12 @@ class Client:
return self.connection.att_mtu
- def get_services_by_uuid(self, uuid):
+ def get_services_by_uuid(self, uuid: UUID) -> List[ServiceProxy]:
return [service for service in self.services if service.uuid == uuid]
- def get_characteristics_by_uuid(self, uuid, service=None):
+ def get_characteristics_by_uuid(
+ self, uuid: UUID, service: Optional[ServiceProxy] = None
+ ) -> List[CharacteristicProxy]:
services = [service] if service else self.services
return [
c
@@ -363,7 +388,7 @@ class Client:
if not already_known:
self.services.append(service)
- async def discover_services(self, uuids=None) -> List[ServiceProxy]:
+ async def discover_services(self, uuids: Iterable[UUID] = []) -> List[ServiceProxy]:
'''
See Vol 3, Part G - 4.4.1 Discover All Primary Services
'''
@@ -435,7 +460,7 @@ class Client:
return services
- async def discover_service(self, uuid):
+ async def discover_service(self, uuid: Union[str, UUID]) -> List[ServiceProxy]:
'''
See Vol 3, Part G - 4.4.2 Discover Primary Service by Service UUID
'''
@@ -468,7 +493,7 @@ class Client:
f'{HCI_Constant.error_name(response.error_code)}'
)
# TODO raise appropriate exception
- return
+ return []
break
for attribute_handle, end_group_handle in response.handles_information:
@@ -480,7 +505,7 @@ class Client:
logger.warning(
f'bogus handle values: {attribute_handle} {end_group_handle}'
)
- return
+ return []
# Create a service proxy for this service
service = ServiceProxy(
@@ -721,7 +746,7 @@ class Client:
return descriptors
- async def discover_attributes(self):
+ async def discover_attributes(self) -> List[AttributeProxy]:
'''
Discover all attributes, regardless of type
'''
@@ -844,7 +869,9 @@ class Client:
# No more subscribers left
await self.write_value(cccd, b'\x00\x00', with_response=True)
- async def read_value(self, attribute, no_long_read=False):
+ async def read_value(
+ self, attribute: Union[int, AttributeProxy], no_long_read: bool = False
+ ) -> Any:
'''
See Vol 3, Part G - 4.8.1 Read Characteristic Value
@@ -905,7 +932,9 @@ class Client:
# Return the value as bytes
return attribute_value
- async def read_characteristics_by_uuid(self, uuid, service):
+ async def read_characteristics_by_uuid(
+ self, uuid: UUID, service: Optional[ServiceProxy]
+ ) -> List[bytes]:
'''
See Vol 3, Part G - 4.8.2 Read Using Characteristic UUID
'''
@@ -960,7 +989,12 @@ class Client:
return characteristics_values
- async def write_value(self, attribute, value, with_response=False):
+ async def write_value(
+ self,
+ attribute: Union[int, AttributeProxy],
+ value: bytes,
+ with_response: bool = False,
+ ) -> None:
'''
See Vol 3, Part G - 4.9.1 Write Without Response & 4.9.3 Write Characteristic
Value
@@ -990,7 +1024,7 @@ class Client:
)
)
- def on_gatt_pdu(self, att_pdu):
+ def on_gatt_pdu(self, att_pdu: ATT_PDU) -> None:
logger.debug(
f'GATT Response to client: [0x{self.connection.handle:04X}] {att_pdu}'
)
@@ -1013,6 +1047,7 @@ class Client:
return
# Return the response to the coroutine that is waiting for it
+ assert self.pending_response is not None
self.pending_response.set_result(att_pdu)
else:
handler_name = f'on_{att_pdu.name.lower()}'
@@ -1060,7 +1095,7 @@ class Client:
# Confirm that we received the indication
self.send_confirmation(ATT_Handle_Value_Confirmation())
- def cache_value(self, attribute_handle: int, value: bytes):
+ def cache_value(self, attribute_handle: int, value: bytes) -> None:
self.cached_values[attribute_handle] = (
datetime.now(),
value,
diff --git a/bumble/gatt_server.py b/bumble/gatt_server.py
index 3624905..cdf1b5e 100644
--- a/bumble/gatt_server.py
+++ b/bumble/gatt_server.py
@@ -23,11 +23,12 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
+from __future__ import annotations
import asyncio
import logging
from collections import defaultdict
import struct
-from typing import List, Tuple, Optional, TypeVar, Type
+from typing import List, Tuple, Optional, TypeVar, Type, Dict, Iterable, TYPE_CHECKING
from pyee import EventEmitter
from .colors import color
@@ -42,6 +43,7 @@ from .att import (
ATT_INVALID_OFFSET_ERROR,
ATT_REQUEST_NOT_SUPPORTED_ERROR,
ATT_REQUESTS,
+ ATT_PDU,
ATT_UNLIKELY_ERROR_ERROR,
ATT_UNSUPPORTED_GROUP_TYPE_ERROR,
ATT_Error,
@@ -73,6 +75,8 @@ from .gatt import (
Service,
)
+if TYPE_CHECKING:
+ from bumble.device import Device, Connection
# -----------------------------------------------------------------------------
# Logging
@@ -91,8 +95,13 @@ GATT_SERVER_DEFAULT_MAX_MTU = 517
# -----------------------------------------------------------------------------
class Server(EventEmitter):
attributes: List[Attribute]
+ services: List[Service]
+ attributes_by_handle: Dict[int, Attribute]
+ subscribers: Dict[int, Dict[int, bytes]]
+ indication_semaphores: defaultdict[int, asyncio.Semaphore]
+ pending_confirmations: defaultdict[int, Optional[asyncio.futures.Future]]
- def __init__(self, device):
+ def __init__(self, device: Device) -> None:
super().__init__()
self.device = device
self.services = []
@@ -107,16 +116,16 @@ class Server(EventEmitter):
self.indication_semaphores = defaultdict(lambda: asyncio.Semaphore(1))
self.pending_confirmations = defaultdict(lambda: None)
- def __str__(self):
+ def __str__(self) -> str:
return "\n".join(map(str, self.attributes))
- def send_gatt_pdu(self, connection_handle, pdu):
+ def send_gatt_pdu(self, connection_handle: int, pdu: bytes) -> None:
self.device.send_l2cap_pdu(connection_handle, ATT_CID, pdu)
- def next_handle(self):
+ def next_handle(self) -> int:
return 1 + len(self.attributes)
- def get_advertising_service_data(self):
+ def get_advertising_service_data(self) -> Dict[Attribute, bytes]:
return {
attribute: data
for attribute in self.attributes
@@ -124,7 +133,7 @@ class Server(EventEmitter):
and (data := attribute.get_advertising_data())
}
- def get_attribute(self, handle):
+ def get_attribute(self, handle: int) -> Optional[Attribute]:
attribute = self.attributes_by_handle.get(handle)
if attribute:
return attribute
@@ -173,12 +182,17 @@ class Server(EventEmitter):
return next(
(
- (attribute, self.get_attribute(attribute.characteristic.handle))
+ (
+ attribute,
+ self.get_attribute(attribute.characteristic.handle),
+ ) # type: ignore
for attribute in map(
self.get_attribute,
range(service_handle.handle, service_handle.end_group_handle + 1),
)
- if attribute.type == GATT_CHARACTERISTIC_ATTRIBUTE_TYPE
+ if attribute is not None
+ and attribute.type == GATT_CHARACTERISTIC_ATTRIBUTE_TYPE
+ and isinstance(attribute, CharacteristicDeclaration)
and attribute.characteristic.uuid == characteristic_uuid
),
None,
@@ -197,7 +211,7 @@ class Server(EventEmitter):
return next(
(
- attribute
+ attribute # type: ignore
for attribute in map(
self.get_attribute,
range(
@@ -205,12 +219,12 @@ class Server(EventEmitter):
characteristic_value.end_group_handle + 1,
),
)
- if attribute.type == descriptor_uuid
+ if attribute is not None and attribute.type == descriptor_uuid
),
None,
)
- def add_attribute(self, attribute):
+ def add_attribute(self, attribute: Attribute) -> None:
# Assign a handle to this attribute
attribute.handle = self.next_handle()
attribute.end_group_handle = (
@@ -220,7 +234,7 @@ class Server(EventEmitter):
# Add this attribute to the list
self.attributes.append(attribute)
- def add_service(self, service: Service):
+ def add_service(self, service: Service) -> None:
# Add the service attribute to the DB
self.add_attribute(service)
@@ -285,11 +299,13 @@ class Server(EventEmitter):
service.end_group_handle = self.attributes[-1].handle
self.services.append(service)
- def add_services(self, services):
+ def add_services(self, services: Iterable[Service]) -> None:
for service in services:
self.add_service(service)
- def read_cccd(self, connection, characteristic):
+ def read_cccd(
+ self, connection: Optional[Connection], characteristic: Characteristic
+ ) -> bytes:
if connection is None:
return bytes([0, 0])
@@ -300,7 +316,12 @@ class Server(EventEmitter):
return cccd or bytes([0, 0])
- def write_cccd(self, connection, characteristic, value):
+ def write_cccd(
+ self,
+ connection: Connection,
+ characteristic: Characteristic,
+ value: bytes,
+ ) -> None:
logger.debug(
f'Subscription update for connection=0x{connection.handle:04X}, '
f'handle=0x{characteristic.handle:04X}: {value.hex()}'
@@ -327,13 +348,19 @@ class Server(EventEmitter):
indicate_enabled,
)
- def send_response(self, connection, response):
+ def send_response(self, connection: Connection, response: ATT_PDU) -> None:
logger.debug(
f'GATT Response from server: [0x{connection.handle:04X}] {response}'
)
self.send_gatt_pdu(connection.handle, response.to_bytes())
- async def notify_subscriber(self, connection, attribute, value=None, force=False):
+ async def notify_subscriber(
+ self,
+ connection: Connection,
+ attribute: Attribute,
+ value: Optional[bytes] = None,
+ force: bool = False,
+ ) -> None:
# Check if there's a subscriber
if not force:
subscribers = self.subscribers.get(connection.handle)
@@ -370,7 +397,13 @@ class Server(EventEmitter):
)
self.send_gatt_pdu(connection.handle, bytes(notification))
- async def indicate_subscriber(self, connection, attribute, value=None, force=False):
+ async def indicate_subscriber(
+ self,
+ connection: Connection,
+ attribute: Attribute,
+ value: Optional[bytes] = None,
+ force: bool = False,
+ ) -> None:
# Check if there's a subscriber
if not force:
subscribers = self.subscribers.get(connection.handle)
@@ -411,15 +444,13 @@ class Server(EventEmitter):
assert self.pending_confirmations[connection.handle] is None
# Create a future value to hold the eventual response
- self.pending_confirmations[
+ pending_confirmation = self.pending_confirmations[
connection.handle
] = asyncio.get_running_loop().create_future()
try:
self.send_gatt_pdu(connection.handle, indication.to_bytes())
- await asyncio.wait_for(
- self.pending_confirmations[connection.handle], GATT_REQUEST_TIMEOUT
- )
+ await asyncio.wait_for(pending_confirmation, GATT_REQUEST_TIMEOUT)
except asyncio.TimeoutError as error:
logger.warning(color('!!! GATT Indicate timeout', 'red'))
raise TimeoutError(f'GATT timeout for {indication.name}') from error
@@ -427,8 +458,12 @@ class Server(EventEmitter):
self.pending_confirmations[connection.handle] = None
async def notify_or_indicate_subscribers(
- self, indicate, attribute, value=None, force=False
- ):
+ self,
+ indicate: bool,
+ attribute: Attribute,
+ value: Optional[bytes] = None,
+ force: bool = False,
+ ) -> None:
# Get all the connections for which there's at least one subscription
connections = [
connection
@@ -450,13 +485,23 @@ class Server(EventEmitter):
]
)
- async def notify_subscribers(self, attribute, value=None, force=False):
+ async def notify_subscribers(
+ self,
+ attribute: Attribute,
+ value: Optional[bytes] = None,
+ force: bool = False,
+ ):
return await self.notify_or_indicate_subscribers(False, attribute, value, force)
- async def indicate_subscribers(self, attribute, value=None, force=False):
+ async def indicate_subscribers(
+ self,
+ attribute: Attribute,
+ value: Optional[bytes] = None,
+ force: bool = False,
+ ):
return await self.notify_or_indicate_subscribers(True, attribute, value, force)
- def on_disconnection(self, connection):
+ def on_disconnection(self, connection: Connection) -> None:
if connection.handle in self.subscribers:
del self.subscribers[connection.handle]
if connection.handle in self.indication_semaphores:
@@ -464,7 +509,7 @@ class Server(EventEmitter):
if connection.handle in self.pending_confirmations:
del self.pending_confirmations[connection.handle]
- def on_gatt_pdu(self, connection, att_pdu):
+ def on_gatt_pdu(self, connection: Connection, att_pdu: ATT_PDU) -> None:
logger.debug(f'GATT Request to server: [0x{connection.handle:04X}] {att_pdu}')
handler_name = f'on_{att_pdu.name.lower()}'
handler = getattr(self, handler_name, None)
@@ -506,7 +551,7 @@ class Server(EventEmitter):
#######################################################
# ATT handlers
#######################################################
- def on_att_request(self, connection, pdu):
+ def on_att_request(self, connection: Connection, pdu: ATT_PDU) -> None:
'''
Handler for requests without a more specific handler
'''
@@ -679,7 +724,6 @@ class Server(EventEmitter):
and attribute.handle <= request.ending_handle
and pdu_space_available
):
-
try:
attribute_value = attribute.read_value(connection)
except ATT_Error as error:
diff --git a/bumble/hci.py b/bumble/hci.py
index 0dbb127..41deed2 100644
--- a/bumble/hci.py
+++ b/bumble/hci.py
@@ -16,11 +16,11 @@
# Imports
# -----------------------------------------------------------------------------
from __future__ import annotations
-import struct
import collections
-import logging
import functools
-from typing import Dict, Type, Union
+import logging
+import struct
+from typing import Any, Dict, Callable, Optional, Type, Union
from .colors import color
from .core import (
@@ -47,6 +47,10 @@ def hci_command_op_code(ogf, ocf):
return ogf << 10 | ocf
+def hci_vendor_command_op_code(ocf):
+ return hci_command_op_code(HCI_VENDOR_OGF, ocf)
+
+
def key_with_value(dictionary, target_value):
for key, value in dictionary.items():
if value == target_value:
@@ -101,6 +105,8 @@ def phy_list_to_bits(phys):
# fmt: off
# pylint: disable=line-too-long
+HCI_VENDOR_OGF = 0x3F
+
# HCI Version
HCI_VERSION_BLUETOOTH_CORE_1_0B = 0
HCI_VERSION_BLUETOOTH_CORE_1_1 = 1
@@ -206,10 +212,8 @@ HCI_INQUIRY_RESPONSE_NOTIFICATION_EVENT = 0X56
HCI_AUTHENTICATED_PAYLOAD_TIMEOUT_EXPIRED_EVENT = 0X57
HCI_SAM_STATUS_CHANGE_EVENT = 0X58
-HCI_EVENT_NAMES = {
- event_code: event_name for (event_name, event_code) in globals().items()
- if event_name.startswith('HCI_') and event_name.endswith('_EVENT')
-}
+HCI_VENDOR_EVENT = 0xFF
+
# HCI Subevent Codes
HCI_LE_CONNECTION_COMPLETE_EVENT = 0x01
@@ -248,10 +252,6 @@ HCI_LE_TRANSMIT_POWER_REPORTING_EVENT = 0X21
HCI_LE_BIGINFO_ADVERTISING_REPORT_EVENT = 0X22
HCI_LE_SUBRATE_CHANGE_EVENT = 0X23
-HCI_SUBEVENT_NAMES = {
- event_code: event_name for (event_name, event_code) in globals().items()
- if event_name.startswith('HCI_LE_') and event_name.endswith('_EVENT') and event_code != HCI_LE_META_EVENT
-}
# HCI Command
HCI_INQUIRY_COMMAND = hci_command_op_code(0x01, 0x0001)
@@ -557,10 +557,6 @@ HCI_LE_SET_DATA_RELATED_ADDRESS_CHANGES_COMMAND = hci_c
HCI_LE_SET_DEFAULT_SUBRATE_COMMAND = hci_command_op_code(0x08, 0x007D)
HCI_LE_SUBRATE_REQUEST_COMMAND = hci_command_op_code(0x08, 0x007E)
-HCI_COMMAND_NAMES = {
- command_code: command_name for (command_name, command_code) in globals().items()
- if command_name.startswith('HCI_') and command_name.endswith('_COMMAND')
-}
# HCI Error Codes
# See Bluetooth spec Vol 2, Part D - 1.3 LIST OF ERROR CODES
@@ -1918,7 +1914,7 @@ class HCI_Packet:
hci_packet_type: int
@staticmethod
- def from_bytes(packet):
+ def from_bytes(packet: bytes) -> HCI_Packet:
packet_type = packet[0]
if packet_type == HCI_COMMAND_PACKET:
@@ -1960,6 +1956,7 @@ class HCI_Command(HCI_Packet):
'''
hci_packet_type = HCI_COMMAND_PACKET
+ command_names: Dict[int, str] = {}
command_classes: Dict[int, Type[HCI_Command]] = {}
@staticmethod
@@ -1970,9 +1967,9 @@ class HCI_Command(HCI_Packet):
def inner(cls):
cls.name = cls.__name__.upper()
- cls.op_code = key_with_value(HCI_COMMAND_NAMES, cls.name)
+ cls.op_code = key_with_value(cls.command_names, cls.name)
if cls.op_code is None:
- raise KeyError(f'command {cls.name} not found in HCI_COMMAND_NAMES')
+ raise KeyError(f'command {cls.name} not found in command_names')
cls.fields = fields
cls.return_parameters_fields = return_parameters_fields
@@ -1992,7 +1989,19 @@ class HCI_Command(HCI_Packet):
return inner
@staticmethod
- def from_bytes(packet):
+ def command_map(symbols: Dict[str, Any]) -> Dict[int, str]:
+ return {
+ command_code: command_name
+ for (command_name, command_code) in symbols.items()
+ if command_name.startswith('HCI_') and command_name.endswith('_COMMAND')
+ }
+
+ @classmethod
+ def register_commands(cls, symbols: Dict[str, Any]) -> None:
+ cls.command_names.update(cls.command_map(symbols))
+
+ @staticmethod
+ def from_bytes(packet: bytes) -> HCI_Command:
op_code, length = struct.unpack_from('<HB', packet, 1)
parameters = packet[4:]
if len(parameters) != length:
@@ -2011,11 +2020,11 @@ class HCI_Command(HCI_Packet):
HCI_Object.init_from_bytes(self, parameters, 0, fields)
return self
- return cls.from_parameters(parameters)
+ return cls.from_parameters(parameters) # type: ignore
@staticmethod
def command_name(op_code):
- name = HCI_COMMAND_NAMES.get(op_code)
+ name = HCI_Command.command_names.get(op_code)
if name is not None:
return name
return f'[OGF=0x{op_code >> 10:02x}, OCF=0x{op_code & 0x3FF:04x}]'
@@ -2024,6 +2033,16 @@ class HCI_Command(HCI_Packet):
def create_return_parameters(cls, **kwargs):
return HCI_Object(cls.return_parameters_fields, **kwargs)
+ @classmethod
+ def parse_return_parameters(cls, parameters):
+ if not cls.return_parameters_fields:
+ return None
+ return_parameters = HCI_Object.from_bytes(
+ parameters, 0, cls.return_parameters_fields
+ )
+ return_parameters.fields = cls.return_parameters_fields
+ return return_parameters
+
def __init__(self, op_code, parameters=None, **kwargs):
super().__init__(HCI_Command.command_name(op_code))
if (fields := getattr(self, 'fields', None)) and kwargs:
@@ -2053,6 +2072,9 @@ class HCI_Command(HCI_Packet):
return result
+HCI_Command.register_commands(globals())
+
+
# -----------------------------------------------------------------------------
@HCI_Command.command(
[
@@ -4308,8 +4330,8 @@ class HCI_Event(HCI_Packet):
'''
hci_packet_type = HCI_EVENT_PACKET
+ event_names: Dict[int, str] = {}
event_classes: Dict[int, Type[HCI_Event]] = {}
- meta_event_classes: Dict[int, Type[HCI_LE_Meta_Event]] = {}
@staticmethod
def event(fields=()):
@@ -4319,9 +4341,9 @@ class HCI_Event(HCI_Packet):
def inner(cls):
cls.name = cls.__name__.upper()
- cls.event_code = key_with_value(HCI_EVENT_NAMES, cls.name)
+ cls.event_code = key_with_value(cls.event_names, cls.name)
if cls.event_code is None:
- raise KeyError('event not found in HCI_EVENT_NAMES')
+ raise KeyError(f'event {cls.name} not found in event_names')
cls.fields = fields
# Patch the __init__ method to fix the event_code
@@ -4338,11 +4360,29 @@ class HCI_Event(HCI_Packet):
return inner
@staticmethod
+ def event_map(symbols: Dict[str, Any]) -> Dict[int, str]:
+ return {
+ event_code: event_name
+ for (event_name, event_code) in symbols.items()
+ if event_name.startswith('HCI_')
+ and not event_name.startswith('HCI_LE_')
+ and event_name.endswith('_EVENT')
+ }
+
+ @staticmethod
+ def event_name(event_code):
+ return name_or_number(HCI_Event.event_names, event_code)
+
+ @staticmethod
+ def register_events(symbols: Dict[str, Any]) -> None:
+ HCI_Event.event_names.update(HCI_Event.event_map(symbols))
+
+ @staticmethod
def registered(event_class):
event_class.name = event_class.__name__.upper()
- event_class.event_code = key_with_value(HCI_EVENT_NAMES, event_class.name)
+ event_class.event_code = key_with_value(HCI_Event.event_names, event_class.name)
if event_class.event_code is None:
- raise KeyError('event not found in HCI_EVENT_NAMES')
+ raise KeyError(f'event {event_class.name} not found in event_names')
# Register a factory for this class
HCI_Event.event_classes[event_class.event_code] = event_class
@@ -4350,22 +4390,28 @@ class HCI_Event(HCI_Packet):
return event_class
@staticmethod
- def from_bytes(packet):
+ def from_bytes(packet: bytes) -> HCI_Event:
event_code = packet[1]
length = packet[2]
parameters = packet[3:]
if len(parameters) != length:
raise ValueError('invalid packet length')
+ cls: Any
if event_code == HCI_LE_META_EVENT:
# We do this dispatch here and not in the subclass in order to avoid call
# loops
subevent_code = parameters[0]
- cls = HCI_Event.meta_event_classes.get(subevent_code)
+ cls = HCI_LE_Meta_Event.subevent_classes.get(subevent_code)
if cls is None:
# No class registered, just use a generic class instance
return HCI_LE_Meta_Event(subevent_code, parameters)
-
+ elif event_code == HCI_VENDOR_EVENT:
+ subevent_code = parameters[0]
+ cls = HCI_Vendor_Event.subevent_classes.get(subevent_code)
+ if cls is None:
+ # No class registered, just use a generic class instance
+ return HCI_Vendor_Event(subevent_code, parameters)
else:
cls = HCI_Event.event_classes.get(event_code)
if cls is None:
@@ -4373,7 +4419,7 @@ class HCI_Event(HCI_Packet):
return HCI_Event(event_code, parameters)
# Invoke the factory to create a new instance
- return cls.from_parameters(parameters)
+ return cls.from_parameters(parameters) # type: ignore
@classmethod
def from_parameters(cls, parameters):
@@ -4383,10 +4429,6 @@ class HCI_Event(HCI_Packet):
HCI_Object.init_from_bytes(self, parameters, 0, fields)
return self
- @staticmethod
- def event_name(event_code):
- return name_or_number(HCI_EVENT_NAMES, event_code)
-
def __init__(self, event_code, parameters=None, **kwargs):
super().__init__(HCI_Event.event_name(event_code))
if (fields := getattr(self, 'fields', None)) and kwargs:
@@ -4413,71 +4455,111 @@ class HCI_Event(HCI_Packet):
return result
+HCI_Event.register_events(globals())
+
+
# -----------------------------------------------------------------------------
-class HCI_LE_Meta_Event(HCI_Event):
+class HCI_Extended_Event(HCI_Event):
'''
- See Bluetooth spec @ 7.7.65 LE Meta Event
+ HCI_Event subclass for events that has a subevent code.
'''
- @staticmethod
- def event(fields=()):
+ subevent_names: Dict[int, str] = {}
+ subevent_classes: Dict[int, Type[HCI_Extended_Event]]
+
+ @classmethod
+ def event(cls, fields=()):
'''
Decorator used to declare and register subclasses
'''
def inner(cls):
cls.name = cls.__name__.upper()
- cls.subevent_code = key_with_value(HCI_SUBEVENT_NAMES, cls.name)
+ cls.subevent_code = key_with_value(cls.subevent_names, cls.name)
if cls.subevent_code is None:
- raise KeyError('subevent not found in HCI_SUBEVENT_NAMES')
+ raise KeyError(f'subevent {cls.name} not found in subevent_names')
cls.fields = fields
# Patch the __init__ method to fix the subevent_code
+ original_init = cls.__init__
+
def init(self, parameters=None, **kwargs):
- return HCI_LE_Meta_Event.__init__(
- self, cls.subevent_code, parameters, **kwargs
- )
+ return original_init(self, cls.subevent_code, parameters, **kwargs)
cls.__init__ = init
# Register a factory for this class
- HCI_Event.meta_event_classes[cls.subevent_code] = cls
+ cls.subevent_classes[cls.subevent_code] = cls
return cls
return inner
@classmethod
+ def subevent_name(cls, subevent_code):
+ subevent_name = cls.subevent_names.get(subevent_code)
+ if subevent_name is not None:
+ return subevent_name
+
+ return f'{cls.__name__.upper()}[0x{subevent_code:02X}]'
+
+ @staticmethod
+ def subevent_map(symbols: Dict[str, Any]) -> Dict[int, str]:
+ return {
+ subevent_code: subevent_name
+ for (subevent_name, subevent_code) in symbols.items()
+ if subevent_name.startswith('HCI_') and subevent_name.endswith('_EVENT')
+ }
+
+ @classmethod
+ def register_subevents(cls, symbols: Dict[str, Any]) -> None:
+ cls.subevent_names.update(cls.subevent_map(symbols))
+
+ @classmethod
def from_parameters(cls, parameters):
self = cls.__new__(cls)
- HCI_LE_Meta_Event.__init__(self, self.subevent_code, parameters)
+ HCI_Extended_Event.__init__(self, self.subevent_code, parameters)
if fields := getattr(self, 'fields', None):
HCI_Object.init_from_bytes(self, parameters, 1, fields)
return self
- @staticmethod
- def subevent_name(subevent_code):
- return name_or_number(HCI_SUBEVENT_NAMES, subevent_code)
-
def __init__(self, subevent_code, parameters, **kwargs):
self.subevent_code = subevent_code
if parameters is None and (fields := getattr(self, 'fields', None)) and kwargs:
parameters = bytes([subevent_code]) + HCI_Object.dict_to_bytes(
kwargs, fields
)
- super().__init__(HCI_LE_META_EVENT, parameters, **kwargs)
+ super().__init__(self.event_code, parameters, **kwargs)
# Override the name in order to adopt the subevent name instead
self.name = self.subevent_name(subevent_code)
- def __str__(self):
- result = color(self.subevent_name(self.subevent_code), 'magenta')
- if fields := getattr(self, 'fields', None):
- result += ':\n' + HCI_Object.format_fields(self.__dict__, fields, ' ')
- else:
- if self.parameters:
- result += f': {self.parameters.hex()}'
- return result
+
+# -----------------------------------------------------------------------------
+class HCI_LE_Meta_Event(HCI_Extended_Event):
+ '''
+ See Bluetooth spec @ 7.7.65 LE Meta Event
+ '''
+
+ event_code: int = HCI_LE_META_EVENT
+ subevent_classes = {}
+
+ @staticmethod
+ def subevent_map(symbols: Dict[str, Any]) -> Dict[int, str]:
+ return {
+ subevent_code: subevent_name
+ for (subevent_name, subevent_code) in symbols.items()
+ if subevent_name.startswith('HCI_LE_') and subevent_name.endswith('_EVENT')
+ }
+
+
+HCI_LE_Meta_Event.register_subevents(globals())
+
+
+# -----------------------------------------------------------------------------
+class HCI_Vendor_Event(HCI_Extended_Event):
+ event_code: int = HCI_VENDOR_EVENT
+ subevent_classes = {}
# -----------------------------------------------------------------------------
@@ -4591,7 +4673,7 @@ class HCI_LE_Advertising_Report_Event(HCI_LE_Meta_Event):
return f'{color(self.subevent_name(self.subevent_code), "magenta")}:\n{reports}'
-HCI_Event.meta_event_classes[
+HCI_LE_Meta_Event.subevent_classes[
HCI_LE_ADVERTISING_REPORT_EVENT
] = HCI_LE_Advertising_Report_Event
@@ -4845,7 +4927,7 @@ class HCI_LE_Extended_Advertising_Report_Event(HCI_LE_Meta_Event):
return f'{color(self.subevent_name(self.subevent_code), "magenta")}:\n{reports}'
-HCI_Event.meta_event_classes[
+HCI_LE_Meta_Event.subevent_classes[
HCI_LE_EXTENDED_ADVERTISING_REPORT_EVENT
] = HCI_LE_Extended_Advertising_Report_Event
@@ -5086,6 +5168,7 @@ class HCI_Command_Complete_Event(HCI_Event):
'''
return_parameters = b''
+ command_opcode: int
def map_return_parameters(self, return_parameters):
'''Map simple 'status' return parameters to their named constant form'''
@@ -5118,11 +5201,11 @@ class HCI_Command_Complete_Event(HCI_Event):
self.return_parameters = self.return_parameters[0]
else:
cls = HCI_Command.command_classes.get(self.command_opcode)
- if cls and cls.return_parameters_fields:
- self.return_parameters = HCI_Object.from_bytes(
- self.return_parameters, 0, cls.return_parameters_fields
- )
- self.return_parameters.fields = cls.return_parameters_fields
+ if cls:
+ # Try to parse the return parameters bytes into an object.
+ return_parameters = cls.parse_return_parameters(self.return_parameters)
+ if return_parameters is not None:
+ self.return_parameters = return_parameters
return self
@@ -5605,7 +5688,7 @@ class HCI_Remote_Host_Supported_Features_Notification_Event(HCI_Event):
# -----------------------------------------------------------------------------
-class HCI_AclDataPacket:
+class HCI_AclDataPacket(HCI_Packet):
'''
See Bluetooth spec @ 5.4.2 HCI ACL Data Packets
'''
@@ -5613,7 +5696,7 @@ class HCI_AclDataPacket:
hci_packet_type = HCI_ACL_DATA_PACKET
@staticmethod
- def from_bytes(packet):
+ def from_bytes(packet: bytes) -> HCI_AclDataPacket:
# Read the header
h, data_total_length = struct.unpack_from('<HH', packet, 1)
connection_handle = h & 0xFFF
@@ -5655,12 +5738,14 @@ class HCI_AclDataPacket:
# -----------------------------------------------------------------------------
class HCI_AclDataPacketAssembler:
- def __init__(self, callback):
+ current_data: Optional[bytes]
+
+ def __init__(self, callback: Callable[[bytes], Any]) -> None:
self.callback = callback
self.current_data = None
self.l2cap_pdu_length = 0
- def feed_packet(self, packet):
+ def feed_packet(self, packet: HCI_AclDataPacket) -> None:
if packet.pb_flag in (
HCI_ACL_PB_FIRST_NON_FLUSHABLE,
HCI_ACL_PB_FIRST_FLUSHABLE,
@@ -5674,6 +5759,7 @@ class HCI_AclDataPacketAssembler:
return
self.current_data += packet.data
+ assert self.current_data is not None
if len(self.current_data) == self.l2cap_pdu_length + 4:
# The packet is complete, invoke the callback
logger.debug(f'<<< ACL PDU: {self.current_data.hex()}')
diff --git a/bumble/hfp.py b/bumble/hfp.py
index 9080a55..bb00920 100644
--- a/bumble/hfp.py
+++ b/bumble/hfp.py
@@ -1,4 +1,4 @@
-# Copyright 2021-2022 Google LLC
+# 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.
@@ -15,19 +15,51 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
+import collections.abc
import logging
import asyncio
-import collections
-from typing import Union
+import dataclasses
+import enum
+import traceback
+import warnings
+from typing import Dict, List, Union, Set, TYPE_CHECKING
+from . import at
from . import rfcomm
-from .colors import color
+
+from bumble.colors import color
+from bumble.core import (
+ ProtocolError,
+ BT_GENERIC_AUDIO_SERVICE,
+ BT_HANDSFREE_SERVICE,
+ BT_L2CAP_PROTOCOL_ID,
+ BT_RFCOMM_PROTOCOL_ID,
+)
+from bumble.sdp import (
+ DataElement,
+ ServiceAttribute,
+ SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
+ SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
+ SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+ SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+ SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID,
+)
+
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
logger = logging.getLogger(__name__)
+# -----------------------------------------------------------------------------
+# Error
+# -----------------------------------------------------------------------------
+
+
+class HfpProtocolError(ProtocolError):
+ def __init__(self, error_name: str = '', details: str = ''):
+ super().__init__(None, 'hfp', error_name, details)
+
# -----------------------------------------------------------------------------
# Protocol Support
@@ -41,6 +73,7 @@ class HfpProtocol:
lines_available: asyncio.Event
def __init__(self, dlc: rfcomm.DLC) -> None:
+ warnings.warn("See HfProtocol", DeprecationWarning)
self.dlc = dlc
self.buffer = ''
self.lines = collections.deque()
@@ -83,19 +116,706 @@ class HfpProtocol:
logger.debug(color(f'<<< {line}', 'green'))
return line
- 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())
- await (self.next_line())
- self.send_command_line('AT+CIND=?')
- await (self.next_line())
- await (self.next_line())
+# -----------------------------------------------------------------------------
+# Normative protocol definitions
+# -----------------------------------------------------------------------------
+
+
+# HF supported features (AT+BRSF=) (normative).
+# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
+# and 3GPP 27.007
+class HfFeature(enum.IntFlag):
+ EC_NR = 0x001 # Echo Cancel & Noise reduction
+ THREE_WAY_CALLING = 0x002
+ CLI_PRESENTATION_CAPABILITY = 0x004
+ VOICE_RECOGNITION_ACTIVATION = 0x008
+ REMOTE_VOLUME_CONTROL = 0x010
+ ENHANCED_CALL_STATUS = 0x020
+ ENHANCED_CALL_CONTROL = 0x040
+ CODEC_NEGOTIATION = 0x080
+ HF_INDICATORS = 0x100
+ ESCO_S4_SETTINGS_SUPPORTED = 0x200
+ ENHANCED_VOICE_RECOGNITION_STATUS = 0x400
+ VOICE_RECOGNITION_TEST = 0x800
+
+
+# AG supported features (+BRSF:) (normative).
+# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
+# and 3GPP 27.007
+class AgFeature(enum.IntFlag):
+ THREE_WAY_CALLING = 0x001
+ EC_NR = 0x002 # Echo Cancel & Noise reduction
+ VOICE_RECOGNITION_FUNCTION = 0x004
+ IN_BAND_RING_TONE_CAPABILITY = 0x008
+ VOICE_TAG = 0x010 # Attach a number to voice tag
+ REJECT_CALL = 0x020 # Ability to reject a call
+ ENHANCED_CALL_STATUS = 0x040
+ ENHANCED_CALL_CONTROL = 0x080
+ EXTENDED_ERROR_RESULT_CODES = 0x100
+ CODEC_NEGOTIATION = 0x200
+ HF_INDICATORS = 0x400
+ ESCO_S4_SETTINGS_SUPPORTED = 0x800
+ ENHANCED_VOICE_RECOGNITION_STATUS = 0x1000
+ VOICE_RECOGNITION_TEST = 0x2000
+
+
+# Audio Codec IDs (normative).
+# Hands-Free Profile v1.8, 10 Appendix B
+class AudioCodec(enum.IntEnum):
+ CVSD = 0x01 # Support for CVSD audio codec
+ MSBC = 0x02 # Support for mSBC audio codec
+
+
+# HF Indicators (normative).
+# Bluetooth Assigned Numbers, 6.10.1 HF Indicators
+class HfIndicator(enum.IntEnum):
+ ENHANCED_SAFETY = 0x01 # Enhanced safety feature
+ BATTERY_LEVEL = 0x02 # Battery level feature
+
+
+# Call Hold supported operations (normative).
+# AT Commands Reference Guide, 3.5.2.3.12 +CHLD - Call Holding Services
+class CallHoldOperation(enum.IntEnum):
+ RELEASE_ALL_HELD_CALLS = 0 # Release all held calls
+ RELEASE_ALL_ACTIVE_CALLS = 1 # Release all active calls, accept other
+ HOLD_ALL_ACTIVE_CALLS = 2 # Place all active calls on hold, accept other
+ ADD_HELD_CALL = 3 # Adds a held call to conversation
+
+
+# Response Hold status (normative).
+# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
+# and 3GPP 27.007
+class ResponseHoldStatus(enum.IntEnum):
+ INC_CALL_HELD = 0 # Put incoming call on hold
+ HELD_CALL_ACC = 1 # Accept a held incoming call
+ HELD_CALL_REJ = 2 # Reject a held incoming call
+
+
+# Values for the Call Setup AG indicator (normative).
+# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
+# and 3GPP 27.007
+class CallSetupAgIndicator(enum.IntEnum):
+ NOT_IN_CALL_SETUP = 0
+ INCOMING_CALL_PROCESS = 1
+ OUTGOING_CALL_SETUP = 2
+ REMOTE_ALERTED = 3 # Remote party alerted in an outgoing call
+
+
+# Values for the Call Held AG indicator (normative).
+# Hands-Free Profile v1.8, 4.34.2, AT Capabilities Re-Used from GSM 07.07
+# and 3GPP 27.007
+class CallHeldAgIndicator(enum.IntEnum):
+ NO_CALLS_HELD = 0
+ # Call is placed on hold or active/held calls swapped
+ # (The AG has both an active AND a held call)
+ CALL_ON_HOLD_AND_ACTIVE_CALL = 1
+ CALL_ON_HOLD_NO_ACTIVE_CALL = 2 # Call on hold, no active call
+
+
+# Call Info direction (normative).
+# AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls
+class CallInfoDirection(enum.IntEnum):
+ MOBILE_ORIGINATED_CALL = 0
+ MOBILE_TERMINATED_CALL = 1
+
+
+# Call Info status (normative).
+# AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls
+class CallInfoStatus(enum.IntEnum):
+ ACTIVE = 0
+ HELD = 1
+ DIALING = 2
+ ALERTING = 3
+ INCOMING = 4
+ WAITING = 5
+
+
+# Call Info mode (normative).
+# AT Commands Reference Guide, 3.5.2.3.15 +CLCC - List Current Calls
+class CallInfoMode(enum.IntEnum):
+ VOICE = 0
+ DATA = 1
+ FAX = 2
+ UNKNOWN = 9
+
+
+# -----------------------------------------------------------------------------
+# Hands-Free Control Interoperability Requirements
+# -----------------------------------------------------------------------------
+
+# Response codes.
+RESPONSE_CODES = [
+ "+APLSIRI",
+ "+BAC",
+ "+BCC",
+ "+BCS",
+ "+BIA",
+ "+BIEV",
+ "+BIND",
+ "+BINP",
+ "+BLDN",
+ "+BRSF",
+ "+BTRH",
+ "+BVRA",
+ "+CCWA",
+ "+CHLD",
+ "+CHUP",
+ "+CIND",
+ "+CLCC",
+ "+CLIP",
+ "+CMEE",
+ "+CMER",
+ "+CNUM",
+ "+COPS",
+ "+IPHONEACCEV",
+ "+NREC",
+ "+VGM",
+ "+VGS",
+ "+VTS",
+ "+XAPL",
+ "A",
+ "D",
+]
+
+# Unsolicited responses and statuses.
+UNSOLICITED_CODES = [
+ "+APLSIRI",
+ "+BCS",
+ "+BIND",
+ "+BSIR",
+ "+BTRH",
+ "+BVRA",
+ "+CCWA",
+ "+CIEV",
+ "+CLIP",
+ "+VGM",
+ "+VGS",
+ "BLACKLISTED",
+ "BUSY",
+ "DELAYED",
+ "NO ANSWER",
+ "NO CARRIER",
+ "RING",
+]
+
+# Status codes
+STATUS_CODES = [
+ "+CME ERROR",
+ "BLACKLISTED",
+ "BUSY",
+ "DELAYED",
+ "ERROR",
+ "NO ANSWER",
+ "NO CARRIER",
+ "OK",
+]
+
+
+@dataclasses.dataclass
+class Configuration:
+ supported_hf_features: List[HfFeature]
+ supported_hf_indicators: List[HfIndicator]
+ supported_audio_codecs: List[AudioCodec]
+
+
+class AtResponseType(enum.Enum):
+ """Indicate if a response is expected from an AT command, and if multiple
+ responses are accepted."""
+
+ NONE = 0
+ SINGLE = 1
+ MULTIPLE = 2
+
+
+class AtResponse:
+ code: str
+ parameters: list
+
+ def __init__(self, response: bytearray):
+ code_and_parameters = response.split(b':')
+ parameters = (
+ code_and_parameters[1] if len(code_and_parameters) > 1 else bytearray()
+ )
+ self.code = code_and_parameters[0].decode()
+ self.parameters = at.parse_parameters(parameters)
+
+
+@dataclasses.dataclass
+class AgIndicatorState:
+ description: str
+ index: int
+ supported_values: Set[int]
+ current_status: int
+
+
+@dataclasses.dataclass
+class HfIndicatorState:
+ supported: bool = False
+ enabled: bool = False
+
+
+class HfProtocol:
+ """Implementation for the Hands-Free side of the Hands-Free profile.
+ Reference specification Hands-Free Profile v1.8"""
+
+ supported_hf_features: int
+ supported_audio_codecs: List[AudioCodec]
+
+ supported_ag_features: int
+ supported_ag_call_hold_operations: List[CallHoldOperation]
+
+ ag_indicators: List[AgIndicatorState]
+ hf_indicators: Dict[HfIndicator, HfIndicatorState]
+
+ dlc: rfcomm.DLC
+ command_lock: asyncio.Lock
+ if TYPE_CHECKING:
+ response_queue: asyncio.Queue[AtResponse]
+ unsolicited_queue: asyncio.Queue[AtResponse]
+ else:
+ response_queue: asyncio.Queue
+ unsolicited_queue: asyncio.Queue
+ read_buffer: bytearray
+
+ def __init__(self, dlc: rfcomm.DLC, configuration: Configuration):
+ # Configure internal state.
+ self.dlc = dlc
+ self.command_lock = asyncio.Lock()
+ self.response_queue = asyncio.Queue()
+ self.unsolicited_queue = asyncio.Queue()
+ self.read_buffer = bytearray()
+
+ # Build local features.
+ self.supported_hf_features = sum(configuration.supported_hf_features)
+ self.supported_audio_codecs = configuration.supported_audio_codecs
+
+ self.hf_indicators = {
+ indicator: HfIndicatorState()
+ for indicator in configuration.supported_hf_indicators
+ }
+
+ # Clear remote features.
+ self.supported_ag_features = 0
+ self.supported_ag_call_hold_operations = []
+ self.ag_indicators = []
+
+ # Bind the AT reader to the RFCOMM channel.
+ self.dlc.sink = self._read_at
+
+ def supports_hf_feature(self, feature: HfFeature) -> bool:
+ return (self.supported_hf_features & feature) != 0
+
+ def supports_ag_feature(self, feature: AgFeature) -> bool:
+ return (self.supported_ag_features & feature) != 0
+
+ # Read AT messages from the RFCOMM channel.
+ # Enqueue AT commands, responses, unsolicited responses to their
+ # respective queues, and set the corresponding event.
+ def _read_at(self, data: bytes):
+ # Append to the read buffer.
+ self.read_buffer.extend(data)
+
+ # Locate header and trailer.
+ header = self.read_buffer.find(b'\r\n')
+ trailer = self.read_buffer.find(b'\r\n', header + 2)
+ if header == -1 or trailer == -1:
+ return
+
+ # Isolate the AT response code and parameters.
+ raw_response = self.read_buffer[header + 2 : trailer]
+ response = AtResponse(raw_response)
+ logger.debug(f"<<< {raw_response.decode()}")
+
+ # Consume the response bytes.
+ self.read_buffer = self.read_buffer[trailer + 2 :]
+
+ # Forward the received code to the correct queue.
+ if self.command_lock.locked() and (
+ response.code in STATUS_CODES or response.code in RESPONSE_CODES
+ ):
+ self.response_queue.put_nowait(response)
+ elif response.code in UNSOLICITED_CODES:
+ self.unsolicited_queue.put_nowait(response)
+ else:
+ logger.warning(f"dropping unexpected response with code '{response.code}'")
+
+ # Send an AT command and wait for the peer response.
+ # Wait for the AT responses sent by the peer, to the status code.
+ # Raises asyncio.TimeoutError if the status is not received
+ # after a timeout (default 1 second).
+ # Raises ProtocolError if the status is not OK.
+ async def execute_command(
+ self,
+ cmd: str,
+ timeout: float = 1.0,
+ response_type: AtResponseType = AtResponseType.NONE,
+ ) -> Union[None, AtResponse, List[AtResponse]]:
+ async with self.command_lock:
+ logger.debug(f">>> {cmd}")
+ self.dlc.write(cmd + '\r')
+ responses: List[AtResponse] = []
+
+ while True:
+ result = await asyncio.wait_for(
+ self.response_queue.get(), timeout=timeout
+ )
+ if result.code == 'OK':
+ if response_type == AtResponseType.SINGLE and len(responses) != 1:
+ raise HfpProtocolError("NO ANSWER")
+
+ if response_type == AtResponseType.MULTIPLE:
+ return responses
+ if response_type == AtResponseType.SINGLE:
+ return responses[0]
+ return None
+ if result.code in STATUS_CODES:
+ raise HfpProtocolError(result.code)
+ responses.append(result)
+
+ # 4.2.1 Service Level Connection Initialization.
+ async def initiate_slc(self):
+ # 4.2.1.1 Supported features exchange
+ # First, in the initialization procedure, the HF shall send the
+ # AT+BRSF=<HF supported features> command to the AG to both notify
+ # the AG of the supported features in the HF, as well as to retrieve the
+ # supported features in the AG using the +BRSF result code.
+ response = await self.execute_command(
+ f"AT+BRSF={self.supported_hf_features}", response_type=AtResponseType.SINGLE
+ )
+
+ self.supported_ag_features = int(response.parameters[0])
+ logger.info(f"supported AG features: {self.supported_ag_features}")
+ for feature in AgFeature:
+ if self.supports_ag_feature(feature):
+ logger.info(f" - {feature.name}")
+
+ # 4.2.1.2 Codec Negotiation
+ # Secondly, in the initialization procedure, if the HF supports the
+ # Codec Negotiation feature, it shall check if the AT+BRSF command
+ # response from the AG has indicated that it supports the Codec
+ # Negotiation feature.
+ if self.supports_hf_feature(
+ HfFeature.CODEC_NEGOTIATION
+ ) and self.supports_ag_feature(AgFeature.CODEC_NEGOTIATION):
+ # If both the HF and AG do support the Codec Negotiation feature
+ # then the HF shall send the AT+BAC=<HF available codecs> command to
+ # the AG to notify the AG of the available codecs in the HF.
+ codecs = [str(c) for c in self.supported_audio_codecs]
+ await self.execute_command(f"AT+BAC={','.join(codecs)}")
+
+ # 4.2.1.3 AG Indicators
+ # After having retrieved the supported features in the AG, the HF shall
+ # determine which indicators are supported by the AG, as well as the
+ # ordering of the supported indicators. This is because, according to
+ # the 3GPP 27.007 specification [2], the AG may support additional
+ # indicators not provided for by the Hands-Free Profile, and because the
+ # ordering of the indicators is implementation specific. The HF uses
+ # the AT+CIND=? Test command to retrieve information about the supported
+ # indicators and their ordering.
+ response = await self.execute_command(
+ "AT+CIND=?", response_type=AtResponseType.SINGLE
+ )
+
+ self.ag_indicators = []
+ for index, indicator in enumerate(response.parameters):
+ description = indicator[0].decode()
+ supported_values = []
+ for value in indicator[1]:
+ value = value.split(b'-')
+ value = [int(v) for v in value]
+ value_min = value[0]
+ value_max = value[1] if len(value) > 1 else value[0]
+ supported_values.extend([v for v in range(value_min, value_max + 1)])
+
+ self.ag_indicators.append(
+ AgIndicatorState(description, index, set(supported_values), 0)
+ )
+
+ # Once the HF has the necessary supported indicator and ordering
+ # information, it shall retrieve the current status of the indicators
+ # in the AG using the AT+CIND? Read command.
+ response = await self.execute_command(
+ "AT+CIND?", response_type=AtResponseType.SINGLE
+ )
+
+ for index, indicator in enumerate(response.parameters):
+ self.ag_indicators[index].current_status = int(indicator)
+
+ # After having retrieved the status of the indicators in the AG, the HF
+ # shall then enable the "Indicators status update" function in the AG by
+ # issuing the AT+CMER command, to which the AG shall respond with OK.
+ await self.execute_command("AT+CMER=3,,,1")
+
+ if self.supports_hf_feature(
+ HfFeature.THREE_WAY_CALLING
+ ) and self.supports_ag_feature(HfFeature.THREE_WAY_CALLING):
+ # After the HF has enabled the “Indicators status update” function in
+ # the AG, and if the “Call waiting and 3-way calling” bit was set in the
+ # supported features bitmap by both the HF and the AG, the HF shall
+ # issue the AT+CHLD=? test command to retrieve the information about how
+ # the call hold and multiparty services are supported in the AG. The HF
+ # shall not issue the AT+CHLD=? test command in case either the HF or
+ # the AG does not support the "Three-way calling" feature.
+ response = await self.execute_command(
+ "AT+CHLD=?", response_type=AtResponseType.SINGLE
+ )
+
+ self.supported_ag_call_hold_operations = [
+ CallHoldOperation(int(operation))
+ for operation in response.parameters[0]
+ if not b'x' in operation
+ ]
+
+ # 4.2.1.4 HF Indicators
+ # If the HF supports the HF indicator feature, it shall check the +BRSF
+ # response to see if the AG also supports the HF Indicator feature.
+ if self.supports_hf_feature(
+ HfFeature.HF_INDICATORS
+ ) and self.supports_ag_feature(AgFeature.HF_INDICATORS):
+ # If both the HF and AG support the HF Indicator feature, then the HF
+ # shall send the AT+BIND=<HF supported HF indicators> command to the AG
+ # to notify the AG of the supported indicators’ assigned numbers in the
+ # HF. The AG shall respond with OK
+ indicators = [str(i) for i in self.hf_indicators.keys()]
+ await self.execute_command(f"AT+BIND={','.join(indicators)}")
+
+ # After having provided the AG with the HF indicators it supports,
+ # the HF shall send the AT+BIND=? to request HF indicators supported
+ # by the AG. The AG shall reply with the +BIND response listing all
+ # HF indicators that it supports followed by an OK.
+ response = await self.execute_command(
+ "AT+BIND=?", response_type=AtResponseType.SINGLE
+ )
+
+ logger.info("supported HF indicators:")
+ for indicator in response.parameters[0]:
+ indicator = HfIndicator(int(indicator))
+ logger.info(f" - {indicator.name}")
+ if indicator in self.hf_indicators:
+ self.hf_indicators[indicator].supported = True
+
+ # Once the HF receives the supported HF indicators list from the AG,
+ # the HF shall send the AT+BIND? command to determine which HF
+ # indicators are enabled. The AG shall respond with one or more
+ # +BIND responses. The AG shall terminate the list with OK.
+ # (See Section 4.36.1.3).
+ responses = await self.execute_command(
+ "AT+BIND?", response_type=AtResponseType.MULTIPLE
+ )
+
+ logger.info("enabled HF indicators:")
+ for response in responses:
+ indicator = HfIndicator(int(response.parameters[0]))
+ enabled = int(response.parameters[1]) != 0
+ logger.info(f" - {indicator.name}: {enabled}")
+ if indicator in self.hf_indicators:
+ self.hf_indicators[indicator].enabled = True
+
+ logger.info("SLC setup completed")
+
+ # 4.11.2 Audio Connection Setup by HF
+ async def setup_audio_connection(self):
+ # When the HF triggers the establishment of the Codec Connection it
+ # shall send the AT command AT+BCC to the AG. The AG shall respond with
+ # OK if it will start the Codec Connection procedure, and with ERROR
+ # if it cannot start the Codec Connection procedure.
+ await self.execute_command("AT+BCC")
+
+ # 4.11.3 Codec Connection Setup
+ async def setup_codec_connection(self, codec_id: int):
+ # The AG shall send a +BCS=<Codec ID> unsolicited response to the HF.
+ # The HF shall then respond to the incoming unsolicited response with
+ # the AT command AT+BCS=<Codec ID>. The ID shall be the same as in the
+ # unsolicited response code as long as the ID is supported.
+ # If the received ID is not available, the HF shall respond with
+ # AT+BAC with its available codecs.
+ if codec_id not in self.supported_audio_codecs:
+ codecs = [str(c) for c in self.supported_audio_codecs]
+ await self.execute_command(f"AT+BAC={','.join(codecs)}")
+ return
+
+ await self.execute_command(f"AT+BCS={codec_id}")
+
+ # After sending the OK response, the AG shall open the
+ # Synchronous Connection with the settings that are determined by the
+ # ID. The HF shall be ready to accept the synchronous connection
+ # establishment as soon as it has sent the AT commands AT+BCS=<Codec ID>.
+
+ logger.info("codec connection setup completed")
+
+ # 4.13.1 Answer Incoming Call from the HF – In-Band Ringing
+ async def answer_incoming_call(self):
+ # The user accepts the incoming voice call by using the proper means
+ # provided by the HF. The HF shall then send the ATA command
+ # (see Section 4.34) to the AG. The AG shall then begin the procedure for
+ # accepting the incoming call.
+ await self.execute_command("ATA")
+
+ # 4.14.1 Reject an Incoming Call from the HF
+ async def reject_incoming_call(self):
+ # The user rejects the incoming call by using the User Interface on the
+ # Hands-Free unit. The HF shall then send the AT+CHUP command
+ # (see Section 4.34) to the AG. This may happen at any time during the
+ # procedures described in Sections 4.13.1 and 4.13.2.
+ await self.execute_command("AT+CHUP")
+
+ # 4.15.1 Terminate a Call Process from the HF
+ async def terminate_call(self):
+ # The user may abort the ongoing call process using whatever means
+ # provided by the Hands-Free unit. The HF shall send AT+CHUP command
+ # (see Section 4.34) to the AG, and the AG shall then start the
+ # procedure to terminate or interrupt the current call procedure.
+ # The AG shall then send the OK indication followed by the +CIEV result
+ # code, with the value indicating (call=0).
+ await self.execute_command("AT+CHUP")
+
+ async def update_ag_indicator(self, index: int, value: int):
+ self.ag_indicators[index].current_status = value
+ logger.info(
+ f"AG indicator updated: {self.ag_indicators[index].description}, {value}"
+ )
+
+ async def handle_unsolicited(self):
+ """Handle unsolicited result codes sent by the audio gateway."""
+ result = await self.unsolicited_queue.get()
+ if result.code == "+BCS":
+ await self.setup_codec_connection(int(result.parameters[0]))
+ elif result.code == "+CIEV":
+ await self.update_ag_indicator(
+ int(result.parameters[0]), int(result.parameters[1])
+ )
+ else:
+ logging.info(f"unhandled unsolicited response {result.code}")
+
+ async def run(self):
+ """Main rountine for the Hands-Free side of the HFP protocol.
+ Initiates the service level connection then loops handling
+ unsolicited AG responses."""
+
+ try:
+ await self.initiate_slc()
+ while True:
+ await self.handle_unsolicited()
+ except Exception:
+ logger.error("HFP-HF protocol failed with the following error:")
+ logger.error(traceback.format_exc())
+
+
+# -----------------------------------------------------------------------------
+# Normative SDP definitions
+# -----------------------------------------------------------------------------
+
+
+# Profile version (normative).
+# Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements
+class ProfileVersion(enum.IntEnum):
+ V1_5 = 0x0105
+ V1_6 = 0x0106
+ V1_7 = 0x0107
+ V1_8 = 0x0108
+ V1_9 = 0x0109
+
+
+# HF supported features (normative).
+# Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements
+class HfSdpFeature(enum.IntFlag):
+ EC_NR = 0x01 # Echo Cancel & Noise reduction
+ THREE_WAY_CALLING = 0x02
+ CLI_PRESENTATION_CAPABILITY = 0x04
+ VOICE_RECOGNITION_ACTIVATION = 0x08
+ REMOTE_VOLUME_CONTROL = 0x10
+ WIDE_BAND = 0x20 # Wide band speech
+ ENHANCED_VOICE_RECOGNITION_STATUS = 0x40
+ VOICE_RECOGNITION_TEST = 0x80
+
+
+# AG supported features (normative).
+# Hands-Free Profile v1.8, 5.3 SDP Interoperability Requirements
+class AgSdpFeature(enum.IntFlag):
+ THREE_WAY_CALLING = 0x01
+ EC_NR = 0x02 # Echo Cancel & Noise reduction
+ VOICE_RECOGNITION_FUNCTION = 0x04
+ IN_BAND_RING_TONE_CAPABILITY = 0x08
+ VOICE_TAG = 0x10 # Attach a number to voice tag
+ WIDE_BAND = 0x20 # Wide band speech
+ ENHANCED_VOICE_RECOGNITION_STATUS = 0x40
+ VOICE_RECOGNITION_TEST = 0x80
+
+
+def sdp_records(
+ service_record_handle: int, rfcomm_channel: int, configuration: Configuration
+) -> List[ServiceAttribute]:
+ """Generate the SDP record for HFP Hands-Free support.
+ The record exposes the features supported in the input configuration,
+ and the allocated RFCOMM channel."""
+
+ hf_supported_features = 0
+
+ if HfFeature.EC_NR in configuration.supported_hf_features:
+ hf_supported_features |= HfSdpFeature.EC_NR
+ if HfFeature.THREE_WAY_CALLING in configuration.supported_hf_features:
+ hf_supported_features |= HfSdpFeature.THREE_WAY_CALLING
+ if HfFeature.CLI_PRESENTATION_CAPABILITY in configuration.supported_hf_features:
+ hf_supported_features |= HfSdpFeature.CLI_PRESENTATION_CAPABILITY
+ if HfFeature.VOICE_RECOGNITION_ACTIVATION in configuration.supported_hf_features:
+ hf_supported_features |= HfSdpFeature.VOICE_RECOGNITION_ACTIVATION
+ if HfFeature.REMOTE_VOLUME_CONTROL in configuration.supported_hf_features:
+ hf_supported_features |= HfSdpFeature.REMOTE_VOLUME_CONTROL
+ if (
+ HfFeature.ENHANCED_VOICE_RECOGNITION_STATUS
+ in configuration.supported_hf_features
+ ):
+ hf_supported_features |= HfSdpFeature.ENHANCED_VOICE_RECOGNITION_STATUS
+ if HfFeature.VOICE_RECOGNITION_TEST in configuration.supported_hf_features:
+ hf_supported_features |= HfSdpFeature.VOICE_RECOGNITION_TEST
- self.send_command_line('AT+CIND?')
- await (self.next_line())
- await (self.next_line())
+ if AudioCodec.MSBC in configuration.supported_audio_codecs:
+ hf_supported_features |= HfSdpFeature.WIDE_BAND
- self.send_command_line('AT+CMER=3,0,0,1')
- await (self.next_line())
+ return [
+ ServiceAttribute(
+ SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
+ DataElement.unsigned_integer_32(service_record_handle),
+ ),
+ 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(ProfileVersion.V1_8),
+ ]
+ )
+ ]
+ ),
+ ),
+ ServiceAttribute(
+ SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID,
+ DataElement.unsigned_integer_16(hf_supported_features),
+ ),
+ ]
diff --git a/bumble/host.py b/bumble/host.py
index e41fd02..02caa46 100644
--- a/bumble/host.py
+++ b/bumble/host.py
@@ -15,23 +15,24 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
+from __future__ import annotations
import asyncio
import collections
import logging
import struct
+from typing import Optional, TYPE_CHECKING, Dict, Callable, Awaitable
+
from bumble.colors import color
from bumble.l2cap import L2CAP_PDU
from bumble.snoop import Snooper
from bumble import drivers
-from typing import Optional
-
from .hci import (
Address,
HCI_ACL_DATA_PACKET,
- HCI_COMMAND_COMPLETE_EVENT,
HCI_COMMAND_PACKET,
+ HCI_COMMAND_COMPLETE_EVENT,
HCI_EVENT_PACKET,
HCI_LE_READ_BUFFER_SIZE_COMMAND,
HCI_LE_READ_LOCAL_SUPPORTED_FEATURES_COMMAND,
@@ -45,8 +46,11 @@ from .hci import (
HCI_VERSION_BLUETOOTH_CORE_4_0,
HCI_AclDataPacket,
HCI_AclDataPacketAssembler,
+ HCI_Command,
+ HCI_Command_Complete_Event,
HCI_Constant,
HCI_Error,
+ HCI_Event,
HCI_LE_Long_Term_Key_Request_Negative_Reply_Command,
HCI_LE_Long_Term_Key_Request_Reply_Command,
HCI_LE_Read_Buffer_Size_Command,
@@ -63,16 +67,19 @@ 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,
- BT_CENTRAL_ROLE,
BT_LE_TRANSPORT,
ConnectionPHY,
ConnectionParameters,
+ InvalidStateError,
)
from .utils import AbortableEventEmitter
+from .transport.common import TransportLostError
+
+if TYPE_CHECKING:
+ from .transport.common import TransportSink, TransportSource
# -----------------------------------------------------------------------------
@@ -96,27 +103,38 @@ HOST_HC_TOTAL_NUM_ACL_DATA_PACKETS = 1
# -----------------------------------------------------------------------------
class Connection:
- def __init__(self, host, handle, peer_address, transport):
+ def __init__(self, host: Host, handle: int, peer_address: Address, transport: int):
self.host = host
self.handle = handle
self.peer_address = peer_address
self.assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu)
self.transport = transport
- def on_hci_acl_data_packet(self, packet):
+ def on_hci_acl_data_packet(self, packet: HCI_AclDataPacket) -> None:
self.assembler.feed_packet(packet)
- def on_acl_pdu(self, pdu):
+ def on_acl_pdu(self, pdu: bytes) -> None:
l2cap_pdu = L2CAP_PDU.from_bytes(pdu)
self.host.on_l2cap_pdu(self, l2cap_pdu.cid, l2cap_pdu.payload)
# -----------------------------------------------------------------------------
class Host(AbortableEventEmitter):
- def __init__(self, controller_source=None, controller_sink=None):
+ connections: Dict[int, Connection]
+ acl_packet_queue: collections.deque[HCI_AclDataPacket]
+ hci_sink: TransportSink
+ long_term_key_provider: Optional[
+ Callable[[int, bytes, int], Awaitable[Optional[bytes]]]
+ ]
+ link_key_provider: Optional[Callable[[Address], Awaitable[Optional[bytes]]]]
+
+ def __init__(
+ self,
+ controller_source: Optional[TransportSource] = None,
+ controller_sink: Optional[TransportSink] = None,
+ ) -> None:
super().__init__()
- self.hci_sink = None
self.hci_metadata = None
self.ready = False # True when we can accept incoming packets
self.reset_done = False
@@ -296,7 +314,7 @@ class Host(AbortableEventEmitter):
self.reset_done = True
@property
- def controller(self):
+ def controller(self) -> TransportSink:
return self.hci_sink
@controller.setter
@@ -305,13 +323,12 @@ class Host(AbortableEventEmitter):
if controller:
controller.set_packet_sink(self)
- def set_packet_sink(self, sink):
+ def set_packet_sink(self, sink: TransportSink) -> None:
self.hci_sink = sink
- def send_hci_packet(self, packet):
+ def send_hci_packet(self, packet: HCI_Packet) -> None:
if self.snooper:
self.snooper.snoop(bytes(packet), Snooper.Direction.HOST_TO_CONTROLLER)
-
self.hci_sink.on_packet(bytes(packet))
async def send_command(self, command, check_result=False):
@@ -349,7 +366,7 @@ class Host(AbortableEventEmitter):
return response
except Exception as error:
logger.warning(
- f'{color("!!! Exception while sending HCI packet:", "red")} {error}'
+ f'{color("!!! Exception while sending command:", "red")} {error}'
)
raise error
finally:
@@ -357,13 +374,13 @@ class Host(AbortableEventEmitter):
self.pending_response = None
# Use this method to send a command from a task
- def send_command_sync(self, command):
- async def send_command(command):
+ def send_command_sync(self, command: HCI_Command) -> None:
+ async def send_command(command: HCI_Command) -> None:
await self.send_command(command)
asyncio.create_task(send_command(command))
- def send_l2cap_pdu(self, connection_handle, cid, pdu):
+ def send_l2cap_pdu(self, connection_handle: int, cid: int, pdu: bytes) -> None:
l2cap_pdu = bytes(L2CAP_PDU(cid, pdu))
# Send the data to the controller via ACL packets
@@ -388,7 +405,7 @@ class Host(AbortableEventEmitter):
offset += data_total_length
bytes_remaining -= data_total_length
- def queue_acl_packet(self, acl_packet):
+ def queue_acl_packet(self, acl_packet: HCI_AclDataPacket) -> None:
self.acl_packet_queue.appendleft(acl_packet)
self.check_acl_packet_queue()
@@ -398,7 +415,7 @@ class Host(AbortableEventEmitter):
f'{len(self.acl_packet_queue)} in queue'
)
- def check_acl_packet_queue(self):
+ def check_acl_packet_queue(self) -> None:
# Send all we can (TODO: support different LE/Classic limits)
while (
len(self.acl_packet_queue) > 0
@@ -444,47 +461,53 @@ class Host(AbortableEventEmitter):
]
# Packet Sink protocol (packets coming from the controller via HCI)
- def on_packet(self, packet):
+ def on_packet(self, packet: bytes) -> None:
hci_packet = HCI_Packet.from_bytes(packet)
if self.ready or (
- hci_packet.hci_packet_type == HCI_EVENT_PACKET
- and hci_packet.event_code == HCI_COMMAND_COMPLETE_EVENT
+ isinstance(hci_packet, HCI_Command_Complete_Event)
and hci_packet.command_opcode == HCI_RESET_COMMAND
):
self.on_hci_packet(hci_packet)
else:
logger.debug('reset not done, ignoring packet from controller')
- def on_hci_packet(self, packet):
+ def on_transport_lost(self):
+ # Called by the source when the transport has been lost.
+ if self.pending_response:
+ self.pending_response.set_exception(TransportLostError('transport lost'))
+
+ self.emit('flush')
+
+ def on_hci_packet(self, packet: HCI_Packet) -> None:
logger.debug(f'{color("### CONTROLLER -> HOST", "green")}: {packet}')
if self.snooper:
self.snooper.snoop(bytes(packet), Snooper.Direction.CONTROLLER_TO_HOST)
# If the packet is a command, invoke the handler for this packet
- if packet.hci_packet_type == HCI_COMMAND_PACKET:
+ if isinstance(packet, HCI_Command):
self.on_hci_command_packet(packet)
- elif packet.hci_packet_type == HCI_EVENT_PACKET:
+ elif isinstance(packet, HCI_Event):
self.on_hci_event_packet(packet)
- elif packet.hci_packet_type == HCI_ACL_DATA_PACKET:
+ elif isinstance(packet, HCI_AclDataPacket):
self.on_hci_acl_data_packet(packet)
else:
logger.warning(f'!!! unknown packet type {packet.hci_packet_type}')
- def on_hci_command_packet(self, command):
+ def on_hci_command_packet(self, command: HCI_Command) -> None:
logger.warning(f'!!! unexpected command packet: {command}')
- def on_hci_event_packet(self, event):
+ def on_hci_event_packet(self, event: HCI_Event) -> None:
handler_name = f'on_{event.name.lower()}'
handler = getattr(self, handler_name, self.on_hci_event)
handler(event)
- def on_hci_acl_data_packet(self, packet):
+ def on_hci_acl_data_packet(self, packet: HCI_AclDataPacket) -> None:
# Look for the connection to which this data belongs
if connection := self.connections.get(packet.connection_handle):
connection.on_hci_acl_data_packet(packet)
- def on_l2cap_pdu(self, connection, cid, pdu):
+ def on_l2cap_pdu(self, connection: Connection, cid: int, pdu: bytes) -> None:
self.emit('l2cap_pdu', connection.handle, cid, pdu)
def on_command_processed(self, event):
@@ -822,6 +845,10 @@ class Host(AbortableEventEmitter):
f'simple pairing complete for {event.bd_addr}: '
f'status={HCI_Constant.status_name(event.status)}'
)
+ if event.status == HCI_SUCCESS:
+ self.emit('classic_pairing', event.bd_addr)
+ else:
+ self.emit('classic_pairing_failure', event.bd_addr, event.status)
def on_hci_pin_code_request_event(self, event):
self.emit('pin_code_request', event.bd_addr)
diff --git a/bumble/l2cap.py b/bumble/l2cap.py
index 4464afc..cccb172 100644
--- a/bumble/l2cap.py
+++ b/bumble/l2cap.py
@@ -17,6 +17,7 @@
# -----------------------------------------------------------------------------
from __future__ import annotations
import asyncio
+import enum
import logging
import struct
@@ -33,6 +34,7 @@ from typing import (
Union,
Deque,
Iterable,
+ SupportsBytes,
TYPE_CHECKING,
)
@@ -47,6 +49,7 @@ from .hci import (
if TYPE_CHECKING:
from bumble.device import Connection
+ from bumble.host import Host
# -----------------------------------------------------------------------------
# Logging
@@ -674,61 +677,40 @@ class L2CAP_LE_Flow_Control_Credit(L2CAP_Control_Frame):
# -----------------------------------------------------------------------------
class Channel(EventEmitter):
- # States
- CLOSED = 0x00
- WAIT_CONNECT = 0x01
- WAIT_CONNECT_RSP = 0x02
- OPEN = 0x03
- WAIT_DISCONNECT = 0x04
- WAIT_CREATE = 0x05
- WAIT_CREATE_RSP = 0x06
- WAIT_MOVE = 0x07
- WAIT_MOVE_RSP = 0x08
- WAIT_MOVE_CONFIRM = 0x09
- WAIT_CONFIRM_RSP = 0x0A
-
- # CONFIG substates
- WAIT_CONFIG = 0x10
- WAIT_SEND_CONFIG = 0x11
- WAIT_CONFIG_REQ_RSP = 0x12
- WAIT_CONFIG_RSP = 0x13
- WAIT_CONFIG_REQ = 0x14
- WAIT_IND_FINAL_RSP = 0x15
- WAIT_FINAL_RSP = 0x16
- WAIT_CONTROL_IND = 0x17
-
- STATE_NAMES = {
- CLOSED: 'CLOSED',
- WAIT_CONNECT: 'WAIT_CONNECT',
- WAIT_CONNECT_RSP: 'WAIT_CONNECT_RSP',
- OPEN: 'OPEN',
- WAIT_DISCONNECT: 'WAIT_DISCONNECT',
- WAIT_CREATE: 'WAIT_CREATE',
- WAIT_CREATE_RSP: 'WAIT_CREATE_RSP',
- WAIT_MOVE: 'WAIT_MOVE',
- WAIT_MOVE_RSP: 'WAIT_MOVE_RSP',
- WAIT_MOVE_CONFIRM: 'WAIT_MOVE_CONFIRM',
- WAIT_CONFIRM_RSP: 'WAIT_CONFIRM_RSP',
- WAIT_CONFIG: 'WAIT_CONFIG',
- WAIT_SEND_CONFIG: 'WAIT_SEND_CONFIG',
- WAIT_CONFIG_REQ_RSP: 'WAIT_CONFIG_REQ_RSP',
- WAIT_CONFIG_RSP: 'WAIT_CONFIG_RSP',
- WAIT_CONFIG_REQ: 'WAIT_CONFIG_REQ',
- WAIT_IND_FINAL_RSP: 'WAIT_IND_FINAL_RSP',
- WAIT_FINAL_RSP: 'WAIT_FINAL_RSP',
- WAIT_CONTROL_IND: 'WAIT_CONTROL_IND',
- }
+ class State(enum.IntEnum):
+ # States
+ CLOSED = 0x00
+ WAIT_CONNECT = 0x01
+ WAIT_CONNECT_RSP = 0x02
+ OPEN = 0x03
+ WAIT_DISCONNECT = 0x04
+ WAIT_CREATE = 0x05
+ WAIT_CREATE_RSP = 0x06
+ WAIT_MOVE = 0x07
+ WAIT_MOVE_RSP = 0x08
+ WAIT_MOVE_CONFIRM = 0x09
+ WAIT_CONFIRM_RSP = 0x0A
+
+ # CONFIG substates
+ WAIT_CONFIG = 0x10
+ WAIT_SEND_CONFIG = 0x11
+ WAIT_CONFIG_REQ_RSP = 0x12
+ WAIT_CONFIG_RSP = 0x13
+ WAIT_CONFIG_REQ = 0x14
+ WAIT_IND_FINAL_RSP = 0x15
+ WAIT_FINAL_RSP = 0x16
+ WAIT_CONTROL_IND = 0x17
connection_result: Optional[asyncio.Future[None]]
disconnection_result: Optional[asyncio.Future[None]]
response: Optional[asyncio.Future[bytes]]
sink: Optional[Callable[[bytes], Any]]
- state: int
+ state: State
connection: Connection
def __init__(
self,
- manager: 'ChannelManager',
+ manager: ChannelManager,
connection: Connection,
signaling_cid: int,
psm: int,
@@ -739,7 +721,7 @@ class Channel(EventEmitter):
self.manager = manager
self.connection = connection
self.signaling_cid = signaling_cid
- self.state = Channel.CLOSED
+ self.state = self.State.CLOSED
self.mtu = mtu
self.psm = psm
self.source_cid = source_cid
@@ -749,30 +731,28 @@ class Channel(EventEmitter):
self.disconnection_result = None
self.sink = None
- def change_state(self, new_state: int) -> None:
- logger.debug(
- f'{self} state change -> {color(Channel.STATE_NAMES[new_state], "cyan")}'
- )
+ def _change_state(self, new_state: State) -> None:
+ logger.debug(f'{self} state change -> {color(new_state.name, "cyan")}')
self.state = new_state
- def send_pdu(self, pdu) -> None:
+ def send_pdu(self, pdu: Union[SupportsBytes, bytes]) -> None:
self.manager.send_pdu(self.connection, self.destination_cid, pdu)
- def send_control_frame(self, frame) -> None:
+ def send_control_frame(self, frame: L2CAP_Control_Frame) -> None:
self.manager.send_control_frame(self.connection, self.signaling_cid, frame)
- async def send_request(self, request) -> bytes:
+ async def send_request(self, request: SupportsBytes) -> bytes:
# Check that there isn't already a request pending
if self.response:
raise InvalidStateError('request already pending')
- if self.state != Channel.OPEN:
+ if self.state != self.State.OPEN:
raise InvalidStateError('channel not open')
self.response = asyncio.get_running_loop().create_future()
self.send_pdu(request)
return await self.response
- def on_pdu(self, pdu) -> None:
+ def on_pdu(self, pdu: bytes) -> None:
if self.response:
self.response.set_result(pdu)
self.response = None
@@ -785,14 +765,14 @@ class Channel(EventEmitter):
)
async def connect(self) -> None:
- if self.state != Channel.CLOSED:
+ if self.state != self.State.CLOSED:
raise InvalidStateError('invalid state')
# Check that we can start a new connection
if self.connection_result:
raise RuntimeError('connection already pending')
- self.change_state(Channel.WAIT_CONNECT_RSP)
+ self._change_state(self.State.WAIT_CONNECT_RSP)
self.send_control_frame(
L2CAP_Connection_Request(
identifier=self.manager.next_identifier(self.connection),
@@ -812,10 +792,10 @@ class Channel(EventEmitter):
self.connection_result = None
async def disconnect(self) -> None:
- if self.state != Channel.OPEN:
+ if self.state != self.State.OPEN:
raise InvalidStateError('invalid state')
- self.change_state(Channel.WAIT_DISCONNECT)
+ self._change_state(self.State.WAIT_DISCONNECT)
self.send_control_frame(
L2CAP_Disconnection_Request(
identifier=self.manager.next_identifier(self.connection),
@@ -830,8 +810,8 @@ class Channel(EventEmitter):
return await self.disconnection_result
def abort(self) -> None:
- if self.state == self.OPEN:
- self.change_state(self.CLOSED)
+ if self.state == self.State.OPEN:
+ self._change_state(self.State.CLOSED)
self.emit('close')
def send_configure_request(self) -> None:
@@ -854,7 +834,7 @@ class Channel(EventEmitter):
def on_connection_request(self, request) -> None:
self.destination_cid = request.source_cid
- self.change_state(Channel.WAIT_CONNECT)
+ self._change_state(self.State.WAIT_CONNECT)
self.send_control_frame(
L2CAP_Connection_Response(
identifier=request.identifier,
@@ -864,24 +844,24 @@ class Channel(EventEmitter):
status=0x0000,
)
)
- self.change_state(Channel.WAIT_CONFIG)
+ self._change_state(self.State.WAIT_CONFIG)
self.send_configure_request()
- self.change_state(Channel.WAIT_CONFIG_REQ_RSP)
+ self._change_state(self.State.WAIT_CONFIG_REQ_RSP)
def on_connection_response(self, response):
- if self.state != Channel.WAIT_CONNECT_RSP:
+ if self.state != self.State.WAIT_CONNECT_RSP:
logger.warning(color('invalid state', 'red'))
return
if response.result == L2CAP_Connection_Response.CONNECTION_SUCCESSFUL:
self.destination_cid = response.destination_cid
- self.change_state(Channel.WAIT_CONFIG)
+ self._change_state(self.State.WAIT_CONFIG)
self.send_configure_request()
- self.change_state(Channel.WAIT_CONFIG_REQ_RSP)
+ self._change_state(self.State.WAIT_CONFIG_REQ_RSP)
elif response.result == L2CAP_Connection_Response.CONNECTION_PENDING:
pass
else:
- self.change_state(Channel.CLOSED)
+ self._change_state(self.State.CLOSED)
self.connection_result.set_exception(
ProtocolError(
response.result,
@@ -893,9 +873,9 @@ class Channel(EventEmitter):
def on_configure_request(self, request) -> None:
if self.state not in (
- Channel.WAIT_CONFIG,
- Channel.WAIT_CONFIG_REQ,
- Channel.WAIT_CONFIG_REQ_RSP,
+ self.State.WAIT_CONFIG,
+ self.State.WAIT_CONFIG_REQ,
+ self.State.WAIT_CONFIG_REQ_RSP,
):
logger.warning(color('invalid state', 'red'))
return
@@ -916,25 +896,28 @@ class Channel(EventEmitter):
options=request.options, # TODO: don't accept everything blindly
)
)
- if self.state == Channel.WAIT_CONFIG:
- self.change_state(Channel.WAIT_SEND_CONFIG)
+ if self.state == self.State.WAIT_CONFIG:
+ self._change_state(self.State.WAIT_SEND_CONFIG)
self.send_configure_request()
- self.change_state(Channel.WAIT_CONFIG_RSP)
- elif self.state == Channel.WAIT_CONFIG_REQ:
- self.change_state(Channel.OPEN)
+ self._change_state(self.State.WAIT_CONFIG_RSP)
+ elif self.state == self.State.WAIT_CONFIG_REQ:
+ self._change_state(self.State.OPEN)
if self.connection_result:
self.connection_result.set_result(None)
self.connection_result = None
self.emit('open')
- elif self.state == Channel.WAIT_CONFIG_REQ_RSP:
- self.change_state(Channel.WAIT_CONFIG_RSP)
+ elif self.state == self.State.WAIT_CONFIG_REQ_RSP:
+ self._change_state(self.State.WAIT_CONFIG_RSP)
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)
- elif self.state in (Channel.WAIT_CONFIG_RSP, Channel.WAIT_CONTROL_IND):
- self.change_state(Channel.OPEN)
+ if self.state == self.State.WAIT_CONFIG_REQ_RSP:
+ self._change_state(self.State.WAIT_CONFIG_REQ)
+ elif self.state in (
+ self.State.WAIT_CONFIG_RSP,
+ self.State.WAIT_CONTROL_IND,
+ ):
+ self._change_state(self.State.OPEN)
if self.connection_result:
self.connection_result.set_result(None)
self.connection_result = None
@@ -964,7 +947,7 @@ class Channel(EventEmitter):
# TODO: decide how to fail gracefully
def on_disconnection_request(self, request) -> None:
- if self.state in (Channel.OPEN, Channel.WAIT_DISCONNECT):
+ if self.state in (self.State.OPEN, self.State.WAIT_DISCONNECT):
self.send_control_frame(
L2CAP_Disconnection_Response(
identifier=request.identifier,
@@ -972,14 +955,14 @@ class Channel(EventEmitter):
source_cid=request.source_cid,
)
)
- self.change_state(Channel.CLOSED)
+ self._change_state(self.State.CLOSED)
self.emit('close')
self.manager.on_channel_closed(self)
else:
logger.warning(color('invalid state', 'red'))
def on_disconnection_response(self, response) -> None:
- if self.state != Channel.WAIT_DISCONNECT:
+ if self.state != self.State.WAIT_DISCONNECT:
logger.warning(color('invalid state', 'red'))
return
@@ -990,7 +973,7 @@ class Channel(EventEmitter):
logger.warning('unexpected source or destination CID')
return
- self.change_state(Channel.CLOSED)
+ self._change_state(self.State.CLOSED)
if self.disconnection_result:
self.disconnection_result.set_result(None)
self.disconnection_result = None
@@ -1002,7 +985,7 @@ class Channel(EventEmitter):
f'Channel({self.source_cid}->{self.destination_cid}, '
f'PSM={self.psm}, '
f'MTU={self.mtu}, '
- f'state={Channel.STATE_NAMES[self.state]})'
+ f'state={self.state.name})'
)
@@ -1012,36 +995,24 @@ class LeConnectionOrientedChannel(EventEmitter):
LE Credit-based Connection Oriented Channel
"""
- INIT = 0
- CONNECTED = 1
- CONNECTING = 2
- DISCONNECTING = 3
- DISCONNECTED = 4
- CONNECTION_ERROR = 5
-
- STATE_NAMES = {
- INIT: 'INIT',
- CONNECTED: 'CONNECTED',
- CONNECTING: 'CONNECTING',
- DISCONNECTING: 'DISCONNECTING',
- DISCONNECTED: 'DISCONNECTED',
- CONNECTION_ERROR: 'CONNECTION_ERROR',
- }
+ class State(enum.IntEnum):
+ INIT = 0
+ CONNECTED = 1
+ CONNECTING = 2
+ DISCONNECTING = 3
+ DISCONNECTED = 4
+ CONNECTION_ERROR = 5
out_queue: Deque[bytes]
connection_result: Optional[asyncio.Future[LeConnectionOrientedChannel]]
disconnection_result: Optional[asyncio.Future[None]]
out_sdu: Optional[bytes]
- state: int
+ state: State
connection: Connection
- @staticmethod
- def state_name(state: int) -> str:
- return name_or_number(LeConnectionOrientedChannel.STATE_NAMES, state)
-
def __init__(
self,
- manager: 'ChannelManager',
+ manager: ChannelManager,
connection: Connection,
le_psm: int,
source_cid: int,
@@ -1081,30 +1052,28 @@ class LeConnectionOrientedChannel(EventEmitter):
self.drained.set()
if connected:
- self.state = LeConnectionOrientedChannel.CONNECTED
+ self.state = self.State.CONNECTED
else:
- self.state = LeConnectionOrientedChannel.INIT
+ self.state = self.State.INIT
- def change_state(self, new_state: int) -> None:
- logger.debug(
- f'{self} state change -> {color(self.state_name(new_state), "cyan")}'
- )
+ def _change_state(self, new_state: State) -> None:
+ logger.debug(f'{self} state change -> {color(new_state.name, "cyan")}')
self.state = new_state
- if new_state == self.CONNECTED:
+ if new_state == self.State.CONNECTED:
self.emit('open')
- elif new_state == self.DISCONNECTED:
+ elif new_state == self.State.DISCONNECTED:
self.emit('close')
- def send_pdu(self, pdu) -> None:
+ def send_pdu(self, pdu: Union[SupportsBytes, bytes]) -> None:
self.manager.send_pdu(self.connection, self.destination_cid, pdu)
- def send_control_frame(self, frame) -> None:
+ def send_control_frame(self, frame: L2CAP_Control_Frame) -> None:
self.manager.send_control_frame(self.connection, L2CAP_LE_SIGNALING_CID, frame)
async def connect(self) -> LeConnectionOrientedChannel:
# Check that we're in the right state
- if self.state != self.INIT:
+ if self.state != self.State.INIT:
raise InvalidStateError('not in a connectable state')
# Check that we can start a new connection
@@ -1112,7 +1081,7 @@ class LeConnectionOrientedChannel(EventEmitter):
if identifier in self.manager.le_coc_requests:
raise RuntimeError('too many concurrent connection requests')
- self.change_state(self.CONNECTING)
+ self._change_state(self.State.CONNECTING)
request = L2CAP_LE_Credit_Based_Connection_Request(
identifier=identifier,
le_psm=self.le_psm,
@@ -1132,10 +1101,10 @@ class LeConnectionOrientedChannel(EventEmitter):
async def disconnect(self) -> None:
# Check that we're connected
- if self.state != self.CONNECTED:
+ if self.state != self.State.CONNECTED:
raise InvalidStateError('not connected')
- self.change_state(self.DISCONNECTING)
+ self._change_state(self.State.DISCONNECTING)
self.flush_output()
self.send_control_frame(
L2CAP_Disconnection_Request(
@@ -1151,15 +1120,15 @@ class LeConnectionOrientedChannel(EventEmitter):
return await self.disconnection_result
def abort(self) -> None:
- if self.state == self.CONNECTED:
- self.change_state(self.DISCONNECTED)
+ if self.state == self.State.CONNECTED:
+ self._change_state(self.State.DISCONNECTED)
- def on_pdu(self, pdu) -> None:
+ def on_pdu(self, pdu: bytes) -> None:
if self.sink is None:
logger.warning('received pdu without a sink')
return
- if self.state != self.CONNECTED:
+ if self.state != self.State.CONNECTED:
logger.warning('received PDU while not connected, dropping')
# Manage the peer credits
@@ -1238,7 +1207,7 @@ class LeConnectionOrientedChannel(EventEmitter):
self.credits = response.initial_credits
self.connected = True
self.connection_result.set_result(self)
- self.change_state(self.CONNECTED)
+ self._change_state(self.State.CONNECTED)
else:
self.connection_result.set_exception(
ProtocolError(
@@ -1249,7 +1218,7 @@ class LeConnectionOrientedChannel(EventEmitter):
),
)
)
- self.change_state(self.CONNECTION_ERROR)
+ self._change_state(self.State.CONNECTION_ERROR)
# Cleanup
self.connection_result = None
@@ -1269,11 +1238,11 @@ class LeConnectionOrientedChannel(EventEmitter):
source_cid=request.source_cid,
)
)
- self.change_state(self.DISCONNECTED)
+ self._change_state(self.State.DISCONNECTED)
self.flush_output()
def on_disconnection_response(self, response) -> None:
- if self.state != self.DISCONNECTING:
+ if self.state != self.State.DISCONNECTING:
logger.warning(color('invalid state', 'red'))
return
@@ -1284,7 +1253,7 @@ class LeConnectionOrientedChannel(EventEmitter):
logger.warning('unexpected source or destination CID')
return
- self.change_state(self.DISCONNECTED)
+ self._change_state(self.State.DISCONNECTED)
if self.disconnection_result:
self.disconnection_result.set_result(None)
self.disconnection_result = None
@@ -1337,7 +1306,7 @@ class LeConnectionOrientedChannel(EventEmitter):
return
def write(self, data: bytes) -> None:
- if self.state != self.CONNECTED:
+ if self.state != self.State.CONNECTED:
logger.warning('not connected, dropping data')
return
@@ -1365,7 +1334,7 @@ class LeConnectionOrientedChannel(EventEmitter):
def __str__(self) -> str:
return (
f'CoC({self.source_cid}->{self.destination_cid}, '
- f'State={self.state_name(self.state)}, '
+ f'State={self.state.name}, '
f'PSM={self.le_psm}, '
f'MTU={self.mtu}/{self.peer_mtu}, '
f'MPS={self.mps}/{self.peer_mps}, '
@@ -1384,6 +1353,8 @@ class ChannelManager:
]
le_coc_requests: Dict[int, L2CAP_LE_Credit_Based_Connection_Request]
fixed_channels: Dict[int, Optional[Callable[[int, bytes], Any]]]
+ _host: Optional[Host]
+ connection_parameters_update_response: Optional[asyncio.Future[int]]
def __init__(
self,
@@ -1405,13 +1376,15 @@ class ChannelManager:
self.le_coc_requests = {} # LE CoC connection requests, by identifier
self.extended_features = extended_features
self.connectionless_mtu = connectionless_mtu
+ self.connection_parameters_update_response = None
@property
- def host(self):
+ def host(self) -> Host:
+ assert self._host
return self._host
@host.setter
- def host(self, host):
+ def host(self, host: Host) -> None:
if self._host is not None:
self._host.remove_listener('disconnection', self.on_disconnection)
self._host = host
@@ -1565,7 +1538,7 @@ class ChannelManager:
if connection_handle in self.identifiers:
del self.identifiers[connection_handle]
- def send_pdu(self, connection, cid: int, pdu) -> None:
+ def send_pdu(self, connection, cid: int, pdu: Union[SupportsBytes, bytes]) -> None:
pdu_str = pdu.hex() if isinstance(pdu, bytes) else str(pdu)
logger.debug(
f'{color(">>> Sending L2CAP PDU", "blue")} '
@@ -1574,7 +1547,7 @@ class ChannelManager:
)
self.host.send_l2cap_pdu(connection.handle, cid, bytes(pdu))
- def on_pdu(self, connection: Connection, cid: int, pdu) -> None:
+ def on_pdu(self, connection: Connection, cid: int, pdu: bytes) -> 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)
@@ -1596,7 +1569,7 @@ class ChannelManager:
channel.on_pdu(pdu)
def send_control_frame(
- self, connection: Connection, cid: int, control_frame
+ self, connection: Connection, cid: int, control_frame: L2CAP_Control_Frame
) -> None:
logger.debug(
f'{color(">>> Sending L2CAP Signaling Control Frame", "blue")} '
@@ -1605,7 +1578,9 @@ class ChannelManager:
)
self.host.send_l2cap_pdu(connection.handle, cid, bytes(control_frame))
- def on_control_frame(self, connection: Connection, cid: int, control_frame) -> None:
+ def on_control_frame(
+ self, connection: Connection, cid: int, control_frame: L2CAP_Control_Frame
+ ) -> None:
logger.debug(
f'{color("<<< Received L2CAP Signaling Control Frame", "green")} '
f'on connection [0x{connection.handle:04X}] (CID={cid}) '
@@ -1859,11 +1834,45 @@ class ChannelManager:
),
)
+ async def update_connection_parameters(
+ self,
+ connection: Connection,
+ interval_min: int,
+ interval_max: int,
+ latency: int,
+ timeout: int,
+ ) -> int:
+ # Check that there isn't already a request pending
+ if self.connection_parameters_update_response:
+ raise InvalidStateError('request already pending')
+ self.connection_parameters_update_response = (
+ asyncio.get_running_loop().create_future()
+ )
+ self.send_control_frame(
+ connection,
+ L2CAP_LE_SIGNALING_CID,
+ L2CAP_Connection_Parameter_Update_Request(
+ interval_min=interval_min,
+ interval_max=interval_max,
+ latency=latency,
+ timeout=timeout,
+ ),
+ )
+ return await self.connection_parameters_update_response
+
def on_l2cap_connection_parameter_update_response(
self, connection: Connection, cid: int, response
) -> None:
- # TODO: check response
- pass
+ if self.connection_parameters_update_response:
+ self.connection_parameters_update_response.set_result(response.result)
+ self.connection_parameters_update_response = None
+ else:
+ logger.warning(
+ color(
+ 'received l2cap_connection_parameter_update_response without a pending request',
+ 'red',
+ )
+ )
def on_l2cap_le_credit_based_connection_request(
self, connection: Connection, cid: int, request
@@ -2072,7 +2081,8 @@ class ChannelManager:
# Connect
try:
await channel.connect()
- except Exception:
+ except Exception as e:
del connection_channels[source_cid]
+ raise e
return channel
diff --git a/bumble/pandora/config.py b/bumble/pandora/config.py
index 5edba55..fa448b8 100644
--- a/bumble/pandora/config.py
+++ b/bumble/pandora/config.py
@@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-from bumble.pairing import PairingDelegate
+from bumble.pairing import PairingConfig, PairingDelegate
from dataclasses import dataclass
from typing import Any, Dict
@@ -20,6 +20,7 @@ from typing import Any, Dict
@dataclass
class Config:
io_capability: PairingDelegate.IoCapability = PairingDelegate.NO_OUTPUT_NO_INPUT
+ identity_address_type: PairingConfig.AddressType = PairingConfig.AddressType.RANDOM
pairing_sc_enable: bool = True
pairing_mitm_enable: bool = True
pairing_bonding_enable: bool = True
@@ -35,6 +36,12 @@ class Config:
'io_capability', 'no_output_no_input'
).upper()
self.io_capability = getattr(PairingDelegate, io_capability_name)
+ identity_address_type_name: str = config.get(
+ 'identity_address_type', 'random'
+ ).upper()
+ self.identity_address_type = getattr(
+ PairingConfig.AddressType, identity_address_type_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)
diff --git a/bumble/pandora/security.py b/bumble/pandora/security.py
index 9f98f3f..0f31512 100644
--- a/bumble/pandora/security.py
+++ b/bumble/pandora/security.py
@@ -13,6 +13,7 @@
# limitations under the License.
import asyncio
+import contextlib
import grpc
import logging
@@ -27,8 +28,8 @@ from bumble.core import (
)
from bumble.device import Connection as BumbleConnection, Device
from bumble.hci import HCI_Error
+from bumble.utils import EventWatcher
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
@@ -232,6 +233,11 @@ class SecurityService(SecurityServicer):
sc=config.pairing_sc_enable,
mitm=config.pairing_mitm_enable,
bonding=config.pairing_bonding_enable,
+ identity_address_type=(
+ PairingConfig.AddressType.PUBLIC
+ if connection.self_address.is_public
+ else config.identity_address_type
+ ),
delegate=PairingDelegate(
connection,
self,
@@ -293,23 +299,35 @@ class SecurityService(SecurityServicer):
try:
self.log.debug('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)
+ security_result = asyncio.get_running_loop().create_future()
+
+ with contextlib.closing(EventWatcher()) as watcher:
+
+ @watcher.on(connection, 'pairing')
+ def on_pairing(*_: Any) -> None:
+ security_result.set_result('success')
- connection.request_pairing()
+ @watcher.on(connection, 'pairing_failure')
+ def on_pairing_failure(*_: Any) -> None:
+ security_result.set_result('pairing_failure')
- await wait_for_security
- else:
- await connection.pair()
+ @watcher.on(connection, 'disconnection')
+ def on_disconnection(*_: Any) -> None:
+ security_result.set_result('connection_died')
- self.log.debug('Paired')
+ if (
+ connection.transport == BT_LE_TRANSPORT
+ and connection.role == BT_PERIPHERAL_ROLE
+ ):
+ connection.request_pairing()
+ else:
+ await connection.pair()
+
+ result = await security_result
+
+ self.log.debug(f'Pairing session complete, status={result}')
+ if result != 'success':
+ return SecureResponse(**{result: empty_pb2.Empty()})
except asyncio.CancelledError:
self.log.warning("Connection died during encryption")
return SecureResponse(connection_died=empty_pb2.Empty())
@@ -368,6 +386,7 @@ class SecurityService(SecurityServicer):
str
] = asyncio.get_running_loop().create_future()
authenticate_task: Optional[asyncio.Future[None]] = None
+ pair_task: Optional[asyncio.Future[None]] = None
async def authenticate() -> None:
assert connection
@@ -414,6 +433,10 @@ class SecurityService(SecurityServicer):
if authenticate_task is None:
authenticate_task = asyncio.create_task(authenticate())
+ def pair(*_: Any) -> None:
+ if self.need_pairing(connection, level):
+ pair_task = asyncio.create_task(connection.pair())
+
listeners: Dict[str, Callable[..., None]] = {
'disconnection': set_failure('connection_died'),
'pairing_failure': set_failure('pairing_failure'),
@@ -422,6 +445,9 @@ class SecurityService(SecurityServicer):
'pairing': try_set_success,
'connection_authentication': try_set_success,
'connection_encryption_change': on_encryption_change,
+ 'classic_pairing': try_set_success,
+ 'classic_pairing_failure': set_failure('pairing_failure'),
+ 'security_request': pair,
}
# register event handlers
@@ -449,6 +475,15 @@ class SecurityService(SecurityServicer):
pass
self.log.debug('Authenticated')
+ # wait for `pair` to finish if any
+ if pair_task is not None:
+ self.log.debug('Wait for authentication...')
+ try:
+ await pair_task # type: ignore
+ except:
+ pass
+ self.log.debug('paired')
+
return WaitSecurityResponse(**kwargs)
def reached_security_level(
@@ -520,7 +555,7 @@ class SecurityStorageService(SecurityStorageServicer):
self.log.debug(f"DeleteBond: {address}")
if self.device.keystore is not None:
- with suppress(KeyError):
+ with contextlib.suppress(KeyError):
await self.device.keystore.delete(str(address))
return empty_pb2.Empty()
diff --git a/bumble/rfcomm.py b/bumble/rfcomm.py
index 0176a78..02c18fa 100644
--- a/bumble/rfcomm.py
+++ b/bumble/rfcomm.py
@@ -15,15 +15,37 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
+from __future__ import annotations
+
import logging
import asyncio
+import enum
+from typing import Callable, Dict, List, Optional, Tuple, Union, TYPE_CHECKING
from pyee import EventEmitter
-from typing import Optional, Tuple, Callable, Dict, Union
from . import core, l2cap
from .colors import color
-from .core import BT_BR_EDR_TRANSPORT, InvalidStateError, ProtocolError
+from .core import (
+ UUID,
+ BT_RFCOMM_PROTOCOL_ID,
+ BT_BR_EDR_TRANSPORT,
+ BT_L2CAP_PROTOCOL_ID,
+ InvalidStateError,
+ ProtocolError,
+)
+from .sdp import (
+ SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
+ SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
+ SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
+ SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+ SDP_PUBLIC_BROWSE_ROOT,
+ DataElement,
+ ServiceAttribute,
+)
+
+if TYPE_CHECKING:
+ from bumble.device import Device, Connection
# -----------------------------------------------------------------------------
# Logging
@@ -106,6 +128,50 @@ RFCOMM_DYNAMIC_CHANNEL_NUMBER_END = 30
# -----------------------------------------------------------------------------
+def make_service_sdp_records(
+ service_record_handle: int, channel: int, uuid: Optional[UUID] = None
+) -> List[ServiceAttribute]:
+ """
+ Create SDP records for an RFComm service given a channel number and an
+ optional UUID. A Service Class Attribute is included only if the UUID is not None.
+ """
+ records = [
+ ServiceAttribute(
+ SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
+ DataElement.unsigned_integer_32(service_record_handle),
+ ),
+ ServiceAttribute(
+ SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
+ DataElement.sequence([DataElement.uuid(SDP_PUBLIC_BROWSE_ROOT)]),
+ ),
+ 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(channel),
+ ]
+ ),
+ ]
+ ),
+ ),
+ ]
+
+ if uuid:
+ records.append(
+ ServiceAttribute(
+ SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
+ DataElement.sequence([DataElement.uuid(uuid)]),
+ )
+ )
+
+ return records
+
+
+# -----------------------------------------------------------------------------
def compute_fcs(buffer: bytes) -> int:
result = 0xFF
for byte in buffer:
@@ -149,9 +215,9 @@ class RFCOMM_Frame:
return RFCOMM_FRAME_TYPE_NAMES[self.type]
@staticmethod
- def parse_mcc(data) -> Tuple[int, int, bytes]:
+ def parse_mcc(data) -> Tuple[int, bool, bytes]:
mcc_type = data[0] >> 2
- c_r = (data[0] >> 1) & 1
+ c_r = bool((data[0] >> 1) & 1)
length = data[1]
if data[1] & 1:
length >>= 1
@@ -192,7 +258,7 @@ class RFCOMM_Frame:
)
@staticmethod
- def from_bytes(data: bytes):
+ def from_bytes(data: bytes) -> RFCOMM_Frame:
# Extract fields
dlci = (data[0] >> 2) & 0x3F
c_r = (data[0] >> 1) & 0x01
@@ -215,7 +281,7 @@ class RFCOMM_Frame:
return frame
- def __bytes__(self):
+ def __bytes__(self) -> bytes:
return (
bytes([self.address, self.control])
+ self.length
@@ -223,7 +289,7 @@ class RFCOMM_Frame:
+ bytes([self.fcs])
)
- def __str__(self):
+ def __str__(self) -> str:
return (
f'{color(self.type_name(), "yellow")}'
f'(c/r={self.c_r},'
@@ -253,7 +319,7 @@ class RFCOMM_MCC_PN:
max_frame_size: int,
max_retransmissions: int,
window_size: int,
- ):
+ ) -> None:
self.dlci = dlci
self.cl = cl
self.priority = priority
@@ -263,7 +329,7 @@ class RFCOMM_MCC_PN:
self.window_size = window_size
@staticmethod
- def from_bytes(data: bytes):
+ def from_bytes(data: bytes) -> RFCOMM_MCC_PN:
return RFCOMM_MCC_PN(
dlci=data[0],
cl=data[1],
@@ -274,7 +340,7 @@ class RFCOMM_MCC_PN:
window_size=data[7],
)
- def __bytes__(self):
+ def __bytes__(self) -> bytes:
return bytes(
[
self.dlci & 0xFF,
@@ -288,7 +354,7 @@ class RFCOMM_MCC_PN:
]
)
- def __str__(self):
+ def __str__(self) -> str:
return (
f'PN(dlci={self.dlci},'
f'cl={self.cl},'
@@ -309,7 +375,9 @@ class RFCOMM_MCC_MSC:
ic: int
dv: int
- def __init__(self, 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
+ ) -> None:
self.dlci = dlci
self.fc = fc
self.rtc = rtc
@@ -318,7 +386,7 @@ class RFCOMM_MCC_MSC:
self.dv = dv
@staticmethod
- def from_bytes(data: bytes):
+ def from_bytes(data: bytes) -> RFCOMM_MCC_MSC:
return RFCOMM_MCC_MSC(
dlci=data[0] >> 2,
fc=data[1] >> 1 & 1,
@@ -328,7 +396,7 @@ class RFCOMM_MCC_MSC:
dv=data[1] >> 7 & 1,
)
- def __bytes__(self):
+ def __bytes__(self) -> bytes:
return bytes(
[
(self.dlci << 2) | 3,
@@ -341,7 +409,7 @@ class RFCOMM_MCC_MSC:
]
)
- def __str__(self):
+ def __str__(self) -> str:
return (
f'MSC(dlci={self.dlci},'
f'fc={self.fc},'
@@ -354,29 +422,24 @@ class RFCOMM_MCC_MSC:
# -----------------------------------------------------------------------------
class DLC(EventEmitter):
- # States
- INIT = 0x00
- CONNECTING = 0x01
- CONNECTED = 0x02
- DISCONNECTING = 0x03
- DISCONNECTED = 0x04
- RESET = 0x05
-
- STATE_NAMES = {
- INIT: 'INIT',
- CONNECTING: 'CONNECTING',
- CONNECTED: 'CONNECTED',
- DISCONNECTING: 'DISCONNECTING',
- DISCONNECTED: 'DISCONNECTED',
- RESET: 'RESET',
- }
+ class State(enum.IntEnum):
+ INIT = 0x00
+ CONNECTING = 0x01
+ CONNECTED = 0x02
+ DISCONNECTING = 0x03
+ DISCONNECTED = 0x04
+ RESET = 0x05
connection_result: Optional[asyncio.Future]
sink: Optional[Callable[[bytes], None]]
def __init__(
- self, multiplexer, dlci: int, max_frame_size: int, initial_tx_credits: int
- ):
+ self,
+ multiplexer: Multiplexer,
+ dlci: int,
+ max_frame_size: int,
+ initial_tx_credits: int,
+ ) -> None:
super().__init__()
self.multiplexer = multiplexer
self.dlci = dlci
@@ -384,9 +447,9 @@ class DLC(EventEmitter):
self.rx_threshold = self.rx_credits // 2
self.tx_credits = initial_tx_credits
self.tx_buffer = b''
- self.state = DLC.INIT
+ self.state = DLC.State.INIT
self.role = multiplexer.role
- self.c_r = 1 if self.role == Multiplexer.INITIATOR else 0
+ self.c_r = 1 if self.role == Multiplexer.Role.INITIATOR else 0
self.sink = None
self.connection_result = None
@@ -396,14 +459,8 @@ class DLC(EventEmitter):
max_frame_size, self.multiplexer.l2cap_channel.mtu - max_overhead
)
- @staticmethod
- def state_name(state: int) -> str:
- return DLC.STATE_NAMES[state]
-
- def change_state(self, new_state: int) -> None:
- logger.debug(
- f'{self} state change -> {color(self.state_name(new_state), "magenta")}'
- )
+ def change_state(self, new_state: State) -> None:
+ logger.debug(f'{self} state change -> {color(new_state.name, "magenta")}')
self.state = new_state
def send_frame(self, frame: RFCOMM_Frame) -> None:
@@ -413,8 +470,8 @@ class DLC(EventEmitter):
handler = getattr(self, f'on_{frame.type_name()}_frame'.lower())
handler(frame)
- def on_sabm_frame(self, _frame) -> None:
- if self.state != DLC.CONNECTING:
+ def on_sabm_frame(self, _frame: RFCOMM_Frame) -> None:
+ if self.state != DLC.State.CONNECTING:
logger.warning(
color('!!! received SABM when not in CONNECTING state', 'red')
)
@@ -430,11 +487,11 @@ class DLC(EventEmitter):
logger.debug(f'>>> MCC MSC Command: {msc}')
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
- self.change_state(DLC.CONNECTED)
+ self.change_state(DLC.State.CONNECTED)
self.emit('open')
- def on_ua_frame(self, _frame) -> None:
- if self.state != DLC.CONNECTING:
+ def on_ua_frame(self, _frame: RFCOMM_Frame) -> None:
+ if self.state != DLC.State.CONNECTING:
logger.warning(
color('!!! received SABM when not in CONNECTING state', 'red')
)
@@ -448,14 +505,14 @@ class DLC(EventEmitter):
logger.debug(f'>>> MCC MSC Command: {msc}')
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
- self.change_state(DLC.CONNECTED)
+ self.change_state(DLC.State.CONNECTED)
self.multiplexer.on_dlc_open_complete(self)
- def on_dm_frame(self, frame) -> None:
+ def on_dm_frame(self, frame: RFCOMM_Frame) -> None:
# TODO: handle all states
pass
- def on_disc_frame(self, _frame) -> None:
+ def on_disc_frame(self, _frame: RFCOMM_Frame) -> None:
# TODO: handle all states
self.send_frame(RFCOMM_Frame.ua(c_r=1 - self.c_r, dlci=self.dlci))
@@ -489,10 +546,10 @@ class DLC(EventEmitter):
# Check if there's anything to send (including credits)
self.process_tx()
- def on_ui_frame(self, frame) -> None:
+ def on_ui_frame(self, frame: RFCOMM_Frame) -> None:
pass
- def on_mcc_msc(self, c_r, msc) -> None:
+ def on_mcc_msc(self, c_r: bool, msc: RFCOMM_MCC_MSC) -> None:
if c_r:
# Command
logger.debug(f'<<< MCC MSC Command: {msc}')
@@ -507,15 +564,15 @@ class DLC(EventEmitter):
logger.debug(f'<<< MCC MSC Response: {msc}')
def connect(self) -> None:
- if self.state != DLC.INIT:
+ if self.state != DLC.State.INIT:
raise InvalidStateError('invalid state')
- self.change_state(DLC.CONNECTING)
+ self.change_state(DLC.State.CONNECTING)
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) -> None:
- if self.state != DLC.INIT:
+ if self.state != DLC.State.INIT:
raise InvalidStateError('invalid state')
pn = RFCOMM_MCC_PN(
@@ -530,7 +587,7 @@ class DLC(EventEmitter):
mcc = RFCOMM_Frame.make_mcc(mcc_type=RFCOMM_MCC_PN_TYPE, c_r=0, data=bytes(pn))
logger.debug(f'>>> PN Response: {pn}')
self.send_frame(RFCOMM_Frame.uih(c_r=self.c_r, dlci=0, information=mcc))
- self.change_state(DLC.CONNECTING)
+ self.change_state(DLC.State.CONNECTING)
def rx_credits_needed(self) -> int:
if self.rx_credits <= self.rx_threshold:
@@ -592,34 +649,24 @@ class DLC(EventEmitter):
# TODO
pass
- def __str__(self):
- return f'DLC(dlci={self.dlci},state={self.state_name(self.state)})'
+ def __str__(self) -> str:
+ return f'DLC(dlci={self.dlci},state={self.state.name})'
# -----------------------------------------------------------------------------
class Multiplexer(EventEmitter):
- # Roles
- INITIATOR = 0x00
- RESPONDER = 0x01
-
- # States
- INIT = 0x00
- CONNECTING = 0x01
- CONNECTED = 0x02
- OPENING = 0x03
- DISCONNECTING = 0x04
- DISCONNECTED = 0x05
- RESET = 0x06
-
- STATE_NAMES = {
- INIT: 'INIT',
- CONNECTING: 'CONNECTING',
- CONNECTED: 'CONNECTED',
- OPENING: 'OPENING',
- DISCONNECTING: 'DISCONNECTING',
- DISCONNECTED: 'DISCONNECTED',
- RESET: 'RESET',
- }
+ class Role(enum.IntEnum):
+ INITIATOR = 0x00
+ RESPONDER = 0x01
+
+ class State(enum.IntEnum):
+ INIT = 0x00
+ CONNECTING = 0x01
+ CONNECTED = 0x02
+ OPENING = 0x03
+ DISCONNECTING = 0x04
+ DISCONNECTED = 0x05
+ RESET = 0x06
connection_result: Optional[asyncio.Future]
disconnection_result: Optional[asyncio.Future]
@@ -627,11 +674,11 @@ class Multiplexer(EventEmitter):
acceptor: Optional[Callable[[int], bool]]
dlcs: Dict[int, DLC]
- def __init__(self, l2cap_channel: l2cap.Channel, role: int) -> None:
+ def __init__(self, l2cap_channel: l2cap.Channel, role: Role) -> None:
super().__init__()
self.role = role
self.l2cap_channel = l2cap_channel
- self.state = Multiplexer.INIT
+ self.state = Multiplexer.State.INIT
self.dlcs = {} # DLCs, by DLCI
self.connection_result = None
self.disconnection_result = None
@@ -641,14 +688,8 @@ class Multiplexer(EventEmitter):
# Become a sink for the L2CAP channel
l2cap_channel.sink = self.on_pdu
- @staticmethod
- def state_name(state: int):
- return Multiplexer.STATE_NAMES[state]
-
- def change_state(self, new_state: int) -> None:
- logger.debug(
- f'{self} state change -> {color(self.state_name(new_state), "cyan")}'
- )
+ def change_state(self, new_state: State) -> None:
+ logger.debug(f'{self} state change -> {color(new_state.name, "cyan")}')
self.state = new_state
def send_frame(self, frame: RFCOMM_Frame) -> None:
@@ -679,28 +720,28 @@ class Multiplexer(EventEmitter):
handler = getattr(self, f'on_{frame.type_name()}_frame'.lower())
handler(frame)
- def on_sabm_frame(self, _frame) -> None:
- if self.state != Multiplexer.INIT:
+ def on_sabm_frame(self, _frame: RFCOMM_Frame) -> None:
+ if self.state != Multiplexer.State.INIT:
logger.debug('not in INIT state, ignoring SABM')
return
- self.change_state(Multiplexer.CONNECTED)
+ self.change_state(Multiplexer.State.CONNECTED)
self.send_frame(RFCOMM_Frame.ua(c_r=1, dlci=0))
- def on_ua_frame(self, _frame) -> None:
- if self.state == Multiplexer.CONNECTING:
- self.change_state(Multiplexer.CONNECTED)
+ def on_ua_frame(self, _frame: RFCOMM_Frame) -> None:
+ if self.state == Multiplexer.State.CONNECTING:
+ self.change_state(Multiplexer.State.CONNECTED)
if self.connection_result:
self.connection_result.set_result(0)
self.connection_result = None
- elif self.state == Multiplexer.DISCONNECTING:
- self.change_state(Multiplexer.DISCONNECTED)
+ elif self.state == Multiplexer.State.DISCONNECTING:
+ self.change_state(Multiplexer.State.DISCONNECTED)
if self.disconnection_result:
self.disconnection_result.set_result(None)
self.disconnection_result = None
- def on_dm_frame(self, _frame) -> None:
- if self.state == Multiplexer.OPENING:
- self.change_state(Multiplexer.CONNECTED)
+ def on_dm_frame(self, _frame: RFCOMM_Frame) -> None:
+ if self.state == Multiplexer.State.OPENING:
+ self.change_state(Multiplexer.State.CONNECTED)
if self.open_result:
self.open_result.set_exception(
core.ConnectionError(
@@ -713,10 +754,12 @@ class Multiplexer(EventEmitter):
else:
logger.warning(f'unexpected state for DM: {self}')
- def on_disc_frame(self, _frame) -> None:
- self.change_state(Multiplexer.DISCONNECTED)
+ def on_disc_frame(self, _frame: RFCOMM_Frame) -> None:
+ self.change_state(Multiplexer.State.DISCONNECTED)
self.send_frame(
- RFCOMM_Frame.ua(c_r=0 if self.role == Multiplexer.INITIATOR else 1, dlci=0)
+ RFCOMM_Frame.ua(
+ c_r=0 if self.role == Multiplexer.Role.INITIATOR else 1, dlci=0
+ )
)
def on_uih_frame(self, frame: RFCOMM_Frame) -> None:
@@ -729,11 +772,11 @@ class Multiplexer(EventEmitter):
mcs = RFCOMM_MCC_MSC.from_bytes(value)
self.on_mcc_msc(c_r, mcs)
- def on_ui_frame(self, frame) -> None:
+ def on_ui_frame(self, frame: RFCOMM_Frame) -> None:
pass
- def on_mcc_pn(self, c_r, pn) -> None:
- if c_r == 1:
+ def on_mcc_pn(self, c_r: bool, pn: RFCOMM_MCC_PN) -> None:
+ if c_r:
# Command
logger.debug(f'<<< PN Command: {pn}')
@@ -764,14 +807,14 @@ class Multiplexer(EventEmitter):
else:
# Response
logger.debug(f'>>> PN Response: {pn}')
- if self.state == Multiplexer.OPENING:
+ if self.state == Multiplexer.State.OPENING:
dlc = DLC(self, pn.dlci, pn.max_frame_size, pn.window_size)
self.dlcs[pn.dlci] = dlc
dlc.connect()
else:
logger.warning('ignoring PN response')
- def on_mcc_msc(self, c_r, msc) -> None:
+ def on_mcc_msc(self, c_r: bool, msc: RFCOMM_MCC_MSC) -> None:
dlc = self.dlcs.get(msc.dlci)
if dlc is None:
logger.warning(f'no dlc for DLCI {msc.dlci}')
@@ -779,30 +822,30 @@ class Multiplexer(EventEmitter):
dlc.on_mcc_msc(c_r, msc)
async def connect(self) -> None:
- if self.state != Multiplexer.INIT:
+ if self.state != Multiplexer.State.INIT:
raise InvalidStateError('invalid state')
- self.change_state(Multiplexer.CONNECTING)
+ self.change_state(Multiplexer.State.CONNECTING)
self.connection_result = asyncio.get_running_loop().create_future()
self.send_frame(RFCOMM_Frame.sabm(c_r=1, dlci=0))
return await self.connection_result
async def disconnect(self) -> None:
- if self.state != Multiplexer.CONNECTED:
+ if self.state != Multiplexer.State.CONNECTED:
return
self.disconnection_result = asyncio.get_running_loop().create_future()
- self.change_state(Multiplexer.DISCONNECTING)
+ self.change_state(Multiplexer.State.DISCONNECTING)
self.send_frame(
RFCOMM_Frame.disc(
- c_r=1 if self.role == Multiplexer.INITIATOR else 0, dlci=0
+ c_r=1 if self.role == Multiplexer.Role.INITIATOR else 0, dlci=0
)
)
await self.disconnection_result
async def open_dlc(self, channel: int) -> DLC:
- if self.state != Multiplexer.CONNECTED:
- if self.state == Multiplexer.OPENING:
+ if self.state != Multiplexer.State.CONNECTED:
+ if self.state == Multiplexer.State.OPENING:
raise InvalidStateError('open already in progress')
raise InvalidStateError('not connected')
@@ -819,10 +862,10 @@ class Multiplexer(EventEmitter):
mcc = RFCOMM_Frame.make_mcc(mcc_type=RFCOMM_MCC_PN_TYPE, c_r=1, data=bytes(pn))
logger.debug(f'>>> Sending MCC: {pn}')
self.open_result = asyncio.get_running_loop().create_future()
- self.change_state(Multiplexer.OPENING)
+ self.change_state(Multiplexer.State.OPENING)
self.send_frame(
RFCOMM_Frame.uih(
- c_r=1 if self.role == Multiplexer.INITIATOR else 0,
+ c_r=1 if self.role == Multiplexer.Role.INITIATOR else 0,
dlci=0,
information=mcc,
)
@@ -831,14 +874,14 @@ class Multiplexer(EventEmitter):
self.open_result = None
return result
- def on_dlc_open_complete(self, dlc: DLC):
+ def on_dlc_open_complete(self, dlc: DLC) -> None:
logger.debug(f'DLC [{dlc.dlci}] open complete')
- self.change_state(Multiplexer.CONNECTED)
+ self.change_state(Multiplexer.State.CONNECTED)
if self.open_result:
self.open_result.set_result(dlc)
- def __str__(self):
- return f'Multiplexer(state={self.state_name(self.state)})'
+ def __str__(self) -> str:
+ return f'Multiplexer(state={self.state.name})'
# -----------------------------------------------------------------------------
@@ -846,7 +889,7 @@ class Client:
multiplexer: Optional[Multiplexer]
l2cap_channel: Optional[l2cap.Channel]
- def __init__(self, device, connection) -> None:
+ def __init__(self, device: Device, connection: Connection) -> None:
self.device = device
self.connection = connection
self.l2cap_channel = None
@@ -864,7 +907,7 @@ class Client:
assert self.l2cap_channel is not None
# Create a mutliplexer to manage DLCs with the server
- self.multiplexer = Multiplexer(self.l2cap_channel, Multiplexer.INITIATOR)
+ self.multiplexer = Multiplexer(self.l2cap_channel, Multiplexer.Role.INITIATOR)
# Connect the multiplexer
await self.multiplexer.connect()
@@ -886,7 +929,7 @@ class Client:
class Server(EventEmitter):
acceptors: Dict[int, Callable[[DLC], None]]
- def __init__(self, device) -> None:
+ def __init__(self, device: Device) -> None:
super().__init__()
self.device = device
self.multiplexer = None
@@ -925,7 +968,7 @@ class Server(EventEmitter):
logger.debug(f'$$$ L2CAP channel open: {l2cap_channel}')
# Create a new multiplexer for the channel
- multiplexer = Multiplexer(l2cap_channel, Multiplexer.RESPONDER)
+ multiplexer = Multiplexer(l2cap_channel, Multiplexer.Role.RESPONDER)
multiplexer.acceptor = self.accept_dlc
multiplexer.on('dlc', self.on_dlc)
diff --git a/bumble/sdp.py b/bumble/sdp.py
index 019b8e6..6428187 100644
--- a/bumble/sdp.py
+++ b/bumble/sdp.py
@@ -18,13 +18,16 @@
from __future__ import annotations
import logging
import struct
-from typing import Dict, List, Type
+from typing import Dict, List, Type, Optional, Tuple, Union, NewType, TYPE_CHECKING
-from . import core
+from . import core, l2cap
from .colors import color
from .core import InvalidStateError
from .hci import HCI_Object, name_or_number, key_with_value
+if TYPE_CHECKING:
+ from .device import Device, Connection
+
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
@@ -94,6 +97,10 @@ SDP_CLIENT_EXECUTABLE_URL_ATTRIBUTE_ID = 0X000B
SDP_ICON_URL_ATTRIBUTE_ID = 0X000C
SDP_ADDITIONAL_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID = 0X000D
+# Attribute Identifier (cf. Assigned Numbers for Service Discovery)
+# used by AVRCP, HFP and A2DP
+SDP_SUPPORTED_FEATURES_ATTRIBUTE_ID = 0x0311
+
SDP_ATTRIBUTE_ID_NAMES = {
SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID: 'SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID',
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID: 'SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID',
@@ -462,7 +469,7 @@ class ServiceAttribute:
self.value = value
@staticmethod
- def list_from_data_elements(elements):
+ def list_from_data_elements(elements: List[DataElement]) -> List[ServiceAttribute]:
attribute_list = []
for i in range(0, len(elements) // 2):
attribute_id, attribute_value = elements[2 * i : 2 * (i + 1)]
@@ -474,7 +481,9 @@ class ServiceAttribute:
return attribute_list
@staticmethod
- def find_attribute_in_list(attribute_list, attribute_id):
+ def find_attribute_in_list(
+ attribute_list: List[ServiceAttribute], attribute_id: int
+ ) -> Optional[DataElement]:
return next(
(
attribute.value
@@ -489,7 +498,7 @@ class ServiceAttribute:
return name_or_number(SDP_ATTRIBUTE_ID_NAMES, id_code)
@staticmethod
- def is_uuid_in_value(uuid, value):
+ def is_uuid_in_value(uuid: core.UUID, value: DataElement) -> bool:
# Find if a uuid matches a value, either directly or recursing into sequences
if value.type == DataElement.UUID:
return value.value == uuid
@@ -543,7 +552,9 @@ class SDP_PDU:
return self
@staticmethod
- def parse_service_record_handle_list_preceded_by_count(data, offset):
+ def parse_service_record_handle_list_preceded_by_count(
+ data: bytes, offset: int
+ ) -> Tuple[int, List[int]]:
count = struct.unpack_from('>H', data, offset - 2)[0]
handle_list = [
struct.unpack_from('>I', data, offset + x * 4)[0] for x in range(count)
@@ -641,6 +652,10 @@ class SDP_ServiceSearchRequest(SDP_PDU):
See Bluetooth spec @ Vol 3, Part B - 4.5.1 SDP_ServiceSearchRequest PDU
'''
+ service_search_pattern: DataElement
+ maximum_service_record_count: int
+ continuation_state: bytes
+
# -----------------------------------------------------------------------------
@SDP_PDU.subclass(
@@ -659,6 +674,11 @@ class SDP_ServiceSearchResponse(SDP_PDU):
See Bluetooth spec @ Vol 3, Part B - 4.5.2 SDP_ServiceSearchResponse PDU
'''
+ service_record_handle_list: List[int]
+ total_service_record_count: int
+ current_service_record_count: int
+ continuation_state: bytes
+
# -----------------------------------------------------------------------------
@SDP_PDU.subclass(
@@ -674,6 +694,11 @@ class SDP_ServiceAttributeRequest(SDP_PDU):
See Bluetooth spec @ Vol 3, Part B - 4.6.1 SDP_ServiceAttributeRequest PDU
'''
+ service_record_handle: int
+ maximum_attribute_byte_count: int
+ attribute_id_list: DataElement
+ continuation_state: bytes
+
# -----------------------------------------------------------------------------
@SDP_PDU.subclass(
@@ -688,6 +713,10 @@ class SDP_ServiceAttributeResponse(SDP_PDU):
See Bluetooth spec @ Vol 3, Part B - 4.6.2 SDP_ServiceAttributeResponse PDU
'''
+ attribute_list_byte_count: int
+ attribute_list: bytes
+ continuation_state: bytes
+
# -----------------------------------------------------------------------------
@SDP_PDU.subclass(
@@ -703,6 +732,11 @@ class SDP_ServiceSearchAttributeRequest(SDP_PDU):
See Bluetooth spec @ Vol 3, Part B - 4.7.1 SDP_ServiceSearchAttributeRequest PDU
'''
+ service_search_pattern: DataElement
+ maximum_attribute_byte_count: int
+ attribute_id_list: DataElement
+ continuation_state: bytes
+
# -----------------------------------------------------------------------------
@SDP_PDU.subclass(
@@ -717,26 +751,34 @@ class SDP_ServiceSearchAttributeResponse(SDP_PDU):
See Bluetooth spec @ Vol 3, Part B - 4.7.2 SDP_ServiceSearchAttributeResponse PDU
'''
+ attribute_list_byte_count: int
+ attribute_list: bytes
+ continuation_state: bytes
+
# -----------------------------------------------------------------------------
class Client:
- def __init__(self, device):
+ channel: Optional[l2cap.Channel]
+
+ def __init__(self, device: Device) -> None:
self.device = device
self.pending_request = None
self.channel = None
- async def connect(self, connection):
+ async def connect(self, connection: Connection) -> None:
result = await self.device.l2cap_channel_manager.connect(connection, SDP_PSM)
self.channel = result
- async def disconnect(self):
+ async def disconnect(self) -> None:
if self.channel:
await self.channel.disconnect()
self.channel = None
- async def search_services(self, uuids):
+ async def search_services(self, uuids: List[core.UUID]) -> List[int]:
if self.pending_request is not None:
raise InvalidStateError('request already pending')
+ if self.channel is None:
+ raise InvalidStateError('L2CAP not connected')
service_search_pattern = DataElement.sequence(
[DataElement.uuid(uuid) for uuid in uuids]
@@ -766,9 +808,13 @@ class Client:
return service_record_handle_list
- async def search_attributes(self, uuids, attribute_ids):
+ async def search_attributes(
+ self, uuids: List[core.UUID], attribute_ids: List[Union[int, Tuple[int, int]]]
+ ) -> List[List[ServiceAttribute]]:
if self.pending_request is not None:
raise InvalidStateError('request already pending')
+ if self.channel is None:
+ raise InvalidStateError('L2CAP not connected')
service_search_pattern = DataElement.sequence(
[DataElement.uuid(uuid) for uuid in uuids]
@@ -819,9 +865,15 @@ class Client:
if sequence.type == DataElement.SEQUENCE
]
- async def get_attributes(self, service_record_handle, attribute_ids):
+ async def get_attributes(
+ self,
+ service_record_handle: int,
+ attribute_ids: List[Union[int, Tuple[int, int]]],
+ ) -> List[ServiceAttribute]:
if self.pending_request is not None:
raise InvalidStateError('request already pending')
+ if self.channel is None:
+ raise InvalidStateError('L2CAP not connected')
attribute_id_list = DataElement.sequence(
[
@@ -869,21 +921,25 @@ class Client:
# -----------------------------------------------------------------------------
class Server:
CONTINUATION_STATE = bytes([0x01, 0x43])
+ channel: Optional[l2cap.Channel]
+ Service = NewType('Service', List[ServiceAttribute])
+ service_records: Dict[int, Service]
+ current_response: Union[None, bytes, Tuple[int, List[int]]]
- def __init__(self, device):
+ def __init__(self, device: Device) -> None:
self.device = device
self.service_records = {} # Service records maps, by record handle
self.channel = None
self.current_response = None
- def register(self, l2cap_channel_manager):
+ def register(self, l2cap_channel_manager: l2cap.ChannelManager) -> None:
l2cap_channel_manager.register_server(SDP_PSM, self.on_connection)
def send_response(self, response):
logger.debug(f'{color(">>> Sending SDP Response", "blue")}: {response}')
self.channel.send_pdu(response)
- def match_services(self, search_pattern):
+ def match_services(self, search_pattern: DataElement) -> Dict[int, Service]:
# Find the services for which the attributes in the pattern is a subset of the
# service's attribute values (NOTE: the value search recurses into sequences)
matching_services = {}
@@ -953,7 +1009,9 @@ class Server:
return (payload, continuation_state)
@staticmethod
- def get_service_attributes(service, attribute_ids):
+ def get_service_attributes(
+ service: Service, attribute_ids: List[DataElement]
+ ) -> DataElement:
attributes = []
for attribute_id in attribute_ids:
if attribute_id.value_size == 4:
@@ -978,10 +1036,10 @@ class Server:
return attribute_list
- def on_sdp_service_search_request(self, request):
+ def on_sdp_service_search_request(self, request: SDP_ServiceSearchRequest) -> None:
# Check if this is a continuation
if len(request.continuation_state) > 1:
- if not self.current_response:
+ if self.current_response is None:
self.send_response(
SDP_ErrorResponse(
transaction_id=request.transaction_id,
@@ -1010,6 +1068,7 @@ class Server:
)
# Respond, keeping any unsent handles for later
+ assert isinstance(self.current_response, tuple)
service_record_handles = self.current_response[1][
: request.maximum_service_record_count
]
@@ -1033,10 +1092,12 @@ class Server:
)
)
- def on_sdp_service_attribute_request(self, request):
+ def on_sdp_service_attribute_request(
+ self, request: SDP_ServiceAttributeRequest
+ ) -> None:
# Check if this is a continuation
if len(request.continuation_state) > 1:
- if not self.current_response:
+ if self.current_response is None:
self.send_response(
SDP_ErrorResponse(
transaction_id=request.transaction_id,
@@ -1069,22 +1130,24 @@ class Server:
self.current_response = bytes(attribute_list)
# Respond, keeping any pending chunks for later
- attribute_list, continuation_state = self.get_next_response_payload(
+ attribute_list_response, continuation_state = self.get_next_response_payload(
request.maximum_attribute_byte_count
)
self.send_response(
SDP_ServiceAttributeResponse(
transaction_id=request.transaction_id,
- attribute_list_byte_count=len(attribute_list),
+ attribute_list_byte_count=len(attribute_list_response),
attribute_list=attribute_list,
continuation_state=continuation_state,
)
)
- def on_sdp_service_search_attribute_request(self, request):
+ def on_sdp_service_search_attribute_request(
+ self, request: SDP_ServiceSearchAttributeRequest
+ ) -> None:
# Check if this is a continuation
if len(request.continuation_state) > 1:
- if not self.current_response:
+ if self.current_response is None:
self.send_response(
SDP_ErrorResponse(
transaction_id=request.transaction_id,
@@ -1114,13 +1177,13 @@ class Server:
self.current_response = bytes(attribute_lists)
# Respond, keeping any pending chunks for later
- attribute_lists, continuation_state = self.get_next_response_payload(
+ attribute_lists_response, continuation_state = self.get_next_response_payload(
request.maximum_attribute_byte_count
)
self.send_response(
SDP_ServiceSearchAttributeResponse(
transaction_id=request.transaction_id,
- attribute_lists_byte_count=len(attribute_lists),
+ attribute_lists_byte_count=len(attribute_lists_response),
attribute_lists=attribute_lists,
continuation_state=continuation_state,
)
diff --git a/bumble/smp.py b/bumble/smp.py
index c93ee9c..f8bba40 100644
--- a/bumble/smp.py
+++ b/bumble/smp.py
@@ -37,6 +37,7 @@ from typing import (
Optional,
Tuple,
Type,
+ cast,
)
from pyee import EventEmitter
@@ -1272,7 +1273,7 @@ class Session:
keys.link_key = PairingKeys.Key(
value=self.link_key, authenticated=authenticated
)
- self.manager.on_pairing(self, peer_address, keys)
+ await self.manager.on_pairing(self, peer_address, keys)
def on_pairing_failure(self, reason: int) -> None:
logger.warning(f'pairing failure ({error_name(reason)})')
@@ -1771,7 +1772,26 @@ class Manager(EventEmitter):
cid = SMP_BR_CID if connection.transport == BT_BR_EDR_TRANSPORT else SMP_CID
connection.send_l2cap_pdu(cid, command.to_bytes())
+ def on_smp_security_request_command(
+ self, connection: Connection, request: SMP_Security_Request_Command
+ ) -> None:
+ connection.emit('security_request', request.auth_req)
+
def on_smp_pdu(self, connection: Connection, pdu: bytes) -> None:
+ # Parse the L2CAP payload into an SMP Command object
+ command = SMP_Command.from_bytes(pdu)
+ logger.debug(
+ f'<<< Received SMP Command on connection [0x{connection.handle:04X}] '
+ f'{connection.peer_address}: {command}'
+ )
+
+ # Security request is more than just pairing, so let applications handle them
+ if command.code == SMP_SECURITY_REQUEST_COMMAND:
+ self.on_smp_security_request_command(
+ connection, cast(SMP_Security_Request_Command, command)
+ )
+ return
+
# Look for a session with this connection, and create one if none exists
if not (session := self.sessions.get(connection.handle)):
if connection.role == BT_CENTRAL_ROLE:
@@ -1782,13 +1802,6 @@ class Manager(EventEmitter):
)
self.sessions[connection.handle] = session
- # Parse the L2CAP payload into an SMP Command object
- command = SMP_Command.from_bytes(pdu)
- logger.debug(
- f'<<< Received SMP Command on connection [0x{connection.handle:04X}] '
- f'{connection.peer_address}: {command}'
- )
-
# Delegate the handling of the command to the session
session.on_smp_command(command)
@@ -1827,20 +1840,14 @@ class Manager(EventEmitter):
def on_session_start(self, session: Session) -> None:
self.device.on_pairing_start(session.connection)
- def on_pairing(
+ async def on_pairing(
self, session: Session, identity_address: Optional[Address], keys: PairingKeys
) -> None:
# Store the keys in the key store
if self.device.keystore and identity_address is not None:
-
- async def store_keys():
- try:
- assert self.device.keystore
- await self.device.keystore.update(str(identity_address), keys)
- except Exception as error:
- logger.warning(f'!!! error while storing keys: {error}')
-
- self.device.abort_on('flush', store_keys())
+ self.device.abort_on(
+ 'flush', self.device.update_keys(str(identity_address), keys)
+ )
# Notify the device
self.device.on_pairing(session.connection, identity_address, keys, session.sc)
diff --git a/bumble/transport/__init__.py b/bumble/transport/__init__.py
index 840b3e5..bc0766b 100644
--- a/bumble/transport/__init__.py
+++ b/bumble/transport/__init__.py
@@ -20,7 +20,6 @@ import logging
import os
from .common import Transport, AsyncPipeSink, SnoopingTransport
-from ..controller import Controller
from ..snoop import create_snooper
# -----------------------------------------------------------------------------
@@ -69,6 +68,7 @@ async def open_transport(name: str) -> Transport:
* usb
* pyusb
* android-emulator
+ * android-netsim
"""
return _wrap_transport(await _open_transport(name))
@@ -118,7 +118,8 @@ async def _open_transport(name: str) -> Transport:
if scheme == 'file':
from .file import open_file_transport
- return await open_file_transport(spec[0] if spec else None)
+ assert spec is not None
+ return await open_file_transport(spec[0])
if scheme == 'vhci':
from .vhci import open_vhci_transport
@@ -133,12 +134,14 @@ async def _open_transport(name: str) -> Transport:
if scheme == 'usb':
from .usb import open_usb_transport
- return await open_usb_transport(spec[0] if spec else None)
+ assert spec is not None
+ return await open_usb_transport(spec[0])
if scheme == 'pyusb':
from .pyusb import open_pyusb_transport
- return await open_pyusb_transport(spec[0] if spec else None)
+ assert spec is not None
+ return await open_pyusb_transport(spec[0])
if scheme == 'android-emulator':
from .android_emulator import open_android_emulator_transport
@@ -167,6 +170,7 @@ async def open_transport_or_link(name: str) -> Transport:
"""
if name.startswith('link-relay:'):
+ from ..controller import Controller
from ..link import RemoteLink # lazy import
link = RemoteLink(name[11:])
diff --git a/bumble/transport/android_emulator.py b/bumble/transport/android_emulator.py
index b78e263..8d19a9e 100644
--- a/bumble/transport/android_emulator.py
+++ b/bumble/transport/android_emulator.py
@@ -18,7 +18,9 @@
import logging
import grpc.aio
-from .common import PumpedTransport, PumpedPacketSource, PumpedPacketSink
+from typing import Optional, Union
+
+from .common import PumpedTransport, PumpedPacketSource, PumpedPacketSink, Transport
# pylint: disable=no-name-in-module
from .grpc_protobuf.emulated_bluetooth_pb2_grpc import EmulatedBluetoothServiceStub
@@ -33,7 +35,7 @@ logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
-async def open_android_emulator_transport(spec):
+async def open_android_emulator_transport(spec: Optional[str]) -> Transport:
'''
Open a transport connection to an Android emulator via its gRPC interface.
The parameter string has this syntax:
@@ -66,7 +68,7 @@ async def open_android_emulator_transport(spec):
# Parse the parameters
mode = 'host'
server_host = 'localhost'
- server_port = 8554
+ server_port = '8554'
if spec is not None:
params = spec.split(',')
for param in params:
@@ -82,6 +84,7 @@ async def open_android_emulator_transport(spec):
logger.debug(f'connecting to gRPC server at {server_address}')
channel = grpc.aio.insecure_channel(server_address)
+ service: Union[EmulatedBluetoothServiceStub, VhciForwardingServiceStub]
if mode == 'host':
# Connect as a host
service = EmulatedBluetoothServiceStub(channel)
@@ -94,10 +97,13 @@ async def open_android_emulator_transport(spec):
raise ValueError('invalid mode')
# Create the transport object
- transport = PumpedTransport(
- PumpedPacketSource(hci_device.read),
- PumpedPacketSink(hci_device.write),
- channel.close,
+ class EmulatorTransport(PumpedTransport):
+ async def close(self):
+ await super().close()
+ await channel.close()
+
+ transport = EmulatorTransport(
+ PumpedPacketSource(hci_device.read), PumpedPacketSink(hci_device.write)
)
transport.start()
diff --git a/bumble/transport/android_netsim.py b/bumble/transport/android_netsim.py
index 99ebf87..e9d36cd 100644
--- a/bumble/transport/android_netsim.py
+++ b/bumble/transport/android_netsim.py
@@ -18,11 +18,12 @@
import asyncio
import atexit
import logging
-import grpc.aio
import os
import pathlib
import sys
-from typing import Optional
+from typing import Dict, Optional
+
+import grpc.aio
from .common import (
ParserSource,
@@ -33,8 +34,8 @@ from .common import (
)
# pylint: disable=no-name-in-module
-from .grpc_protobuf.packet_streamer_pb2_grpc import PacketStreamerStub
from .grpc_protobuf.packet_streamer_pb2_grpc import (
+ PacketStreamerStub,
PacketStreamerServicer,
add_PacketStreamerServicer_to_server,
)
@@ -43,6 +44,7 @@ from .grpc_protobuf.hci_packet_pb2 import HCIPacket
from .grpc_protobuf.startup_pb2 import Chip, ChipInfo
from .grpc_protobuf.common_pb2 import ChipKind
+
# -----------------------------------------------------------------------------
# Logging
# -----------------------------------------------------------------------------
@@ -74,14 +76,20 @@ def get_ini_dir() -> Optional[pathlib.Path]:
# -----------------------------------------------------------------------------
-def find_grpc_port() -> int:
+def ini_file_name(instance_number: int) -> str:
+ suffix = f'_{instance_number}' if instance_number > 0 else ''
+ return f'netsim{suffix}.ini'
+
+
+# -----------------------------------------------------------------------------
+def find_grpc_port(instance_number: int) -> int:
if not (ini_dir := get_ini_dir()):
logger.debug('no known directory for .ini file')
return 0
- ini_file = ini_dir / 'netsim.ini'
+ ini_file = ini_dir / ini_file_name(instance_number)
+ logger.debug(f'Looking for .ini file at {ini_file}')
if ini_file.is_file():
- logger.debug(f'Found .ini file at {ini_file}')
with open(ini_file, 'r') as ini_file_data:
for line in ini_file_data.readlines():
if '=' in line:
@@ -90,12 +98,14 @@ def find_grpc_port() -> int:
logger.debug(f'gRPC port = {value}')
return int(value)
+ logger.debug('no grpc.port property found in .ini file')
+
# Not found
return 0
# -----------------------------------------------------------------------------
-def publish_grpc_port(grpc_port) -> bool:
+def publish_grpc_port(grpc_port: int, instance_number: int) -> bool:
if not (ini_dir := get_ini_dir()):
logger.debug('no known directory for .ini file')
return False
@@ -104,7 +114,7 @@ def publish_grpc_port(grpc_port) -> bool:
logger.debug('ini directory does not exist')
return False
- ini_file = ini_dir / 'netsim.ini'
+ ini_file = ini_dir / ini_file_name(instance_number)
try:
ini_file.write_text(f'grpc.port={grpc_port}\n')
logger.debug(f"published gRPC port at {ini_file}")
@@ -121,13 +131,16 @@ def publish_grpc_port(grpc_port) -> bool:
# -----------------------------------------------------------------------------
-async def open_android_netsim_controller_transport(server_host, server_port):
+async def open_android_netsim_controller_transport(
+ server_host: Optional[str], server_port: int, options: Dict[str, str]
+) -> Transport:
if not server_port:
raise ValueError('invalid port')
if server_host == '_' or not server_host:
server_host = 'localhost'
- if not publish_grpc_port(server_port):
+ instance_number = int(options.get('instance', "0"))
+ if not publish_grpc_port(server_port, instance_number):
logger.warning("unable to publish gRPC port")
class HciDevice:
@@ -184,15 +197,12 @@ async def open_android_netsim_controller_transport(server_host, server_port):
logger.debug(f'<<< PACKET: {data.hex()}')
self.on_data_received(data)
- def send_packet(self, data):
- async def send():
- await self.context.write(
- PacketResponse(
- hci_packet=HCIPacket(packet_type=data[0], packet=data[1:])
- )
+ async def send_packet(self, data):
+ return await self.context.write(
+ PacketResponse(
+ hci_packet=HCIPacket(packet_type=data[0], packet=data[1:])
)
-
- self.loop.create_task(send())
+ )
def terminate(self):
self.task.cancel()
@@ -226,17 +236,17 @@ async def open_android_netsim_controller_transport(server_host, server_port):
logger.debug('gRPC server cancelled')
await self.grpc_server.stop(None)
- def on_packet(self, packet):
+ async def send_packet(self, packet):
if not self.device:
logger.debug('no device, dropping packet')
return
- self.device.send_packet(packet)
+ return await self.device.send_packet(packet)
async def StreamPackets(self, _request_iterator, context):
logger.debug('StreamPackets request')
- # Check that we won't already have a device
+ # Check that we don't already have a device
if self.device:
logger.debug('busy, already serving a device')
return PacketResponse(error='Busy')
@@ -259,15 +269,42 @@ async def open_android_netsim_controller_transport(server_host, server_port):
await server.start()
asyncio.get_running_loop().create_task(server.serve())
- class GrpcServerTransport(Transport):
- async def close(self):
- await super().close()
+ sink = PumpedPacketSink(server.send_packet)
+ sink.start()
+ return Transport(server, sink)
+
+
+# -----------------------------------------------------------------------------
+async def open_android_netsim_host_transport_with_address(
+ server_host: Optional[str],
+ server_port: int,
+ options: Optional[Dict[str, str]] = None,
+):
+ if server_host == '_' or not server_host:
+ server_host = 'localhost'
+
+ if not server_port:
+ # Look for the gRPC config in a .ini file
+ instance_number = 0 if options is None else int(options.get('instance', '0'))
+ server_port = find_grpc_port(instance_number)
+ if not server_port:
+ raise RuntimeError('gRPC server port not found')
+
+ # Connect to the gRPC server
+ server_address = f'{server_host}:{server_port}'
+ logger.debug(f'Connecting to gRPC server at {server_address}')
+ channel = grpc.aio.insecure_channel(server_address)
- return GrpcServerTransport(server, server)
+ return await open_android_netsim_host_transport_with_channel(
+ channel,
+ options,
+ )
# -----------------------------------------------------------------------------
-async def open_android_netsim_host_transport(server_host, server_port, options):
+async def open_android_netsim_host_transport_with_channel(
+ channel, options: Optional[Dict[str, str]] = None
+):
# Wrapper for I/O operations
class HciDevice:
def __init__(self, name, manufacturer, hci_device):
@@ -286,10 +323,12 @@ async def open_android_netsim_host_transport(server_host, server_port, options):
async def read(self):
response = await self.hci_device.read()
response_type = response.WhichOneof('response_type')
+
if response_type == 'error':
logger.warning(f'received error: {response.error}')
raise RuntimeError(response.error)
- elif response_type == 'hci_packet':
+
+ if response_type == 'hci_packet':
return (
bytes([response.hci_packet.packet_type])
+ response.hci_packet.packet
@@ -304,24 +343,9 @@ async def open_android_netsim_host_transport(server_host, server_port, options):
)
)
- name = options.get('name', DEFAULT_NAME)
+ name = DEFAULT_NAME if options is None else options.get('name', DEFAULT_NAME)
manufacturer = DEFAULT_MANUFACTURER
- if server_host == '_' or not server_host:
- server_host = 'localhost'
-
- if not server_port:
- # Look for the gRPC config in a .ini file
- server_host = 'localhost'
- server_port = find_grpc_port()
- if not server_port:
- raise RuntimeError('gRPC server port not found')
-
- # Connect to the gRPC server
- server_address = f'{server_host}:{server_port}'
- logger.debug(f'Connecting to gRPC server at {server_address}')
- channel = grpc.aio.insecure_channel(server_address)
-
# Connect as a host
service = PacketStreamerStub(channel)
hci_device = HciDevice(
@@ -332,10 +356,14 @@ async def open_android_netsim_host_transport(server_host, server_port, options):
await hci_device.start()
# Create the transport object
- transport = PumpedTransport(
+ class GrpcTransport(PumpedTransport):
+ async def close(self):
+ await super().close()
+ await channel.close()
+
+ transport = GrpcTransport(
PumpedPacketSource(hci_device.read),
PumpedPacketSink(hci_device.write),
- channel.close,
)
transport.start()
@@ -343,7 +371,7 @@ async def open_android_netsim_host_transport(server_host, server_port, options):
# -----------------------------------------------------------------------------
-async def open_android_netsim_transport(spec):
+async def open_android_netsim_transport(spec: Optional[str]) -> Transport:
'''
Open a transport connection as a client or server, implementing Android's `netsim`
simulator protocol over gRPC.
@@ -357,6 +385,11 @@ async def open_android_netsim_transport(spec):
to connect *to* a netsim server (netsim is the controller), or accept
connections *as* a netsim-compatible server.
+ instance=<n>
+ Specifies an instance number, with <n> > 0. This is used to determine which
+ .init file to use. In `host` mode, it is ignored when the <host>:<port>
+ specifier is present, since in that case no .ini file is used.
+
In `host` mode:
The <host>:<port> part is optional. When not specified, the transport
looks for a netsim .ini file, from which it will read the `grpc.backend.port`
@@ -385,14 +418,15 @@ async def open_android_netsim_transport(spec):
params = spec.split(',') if spec else []
if params and ':' in params[0]:
# Explicit <host>:<port>
- host, port = params[0].split(':')
+ host, port_str = params[0].split(':')
+ port = int(port_str)
params_offset = 1
else:
host = None
port = 0
params_offset = 0
- options = {}
+ options: Dict[str, str] = {}
for param in params[params_offset:]:
if '=' not in param:
raise ValueError('invalid parameter, expected <name>=<value>')
@@ -401,10 +435,12 @@ async def open_android_netsim_transport(spec):
mode = options.get('mode', 'host')
if mode == 'host':
- return await open_android_netsim_host_transport(host, port, options)
+ return await open_android_netsim_host_transport_with_address(
+ host, port, options
+ )
if mode == 'controller':
if host is None:
raise ValueError('<host>:<port> missing')
- return await open_android_netsim_controller_transport(host, port)
+ return await open_android_netsim_controller_transport(host, port, options)
raise ValueError('invalid mode option')
diff --git a/bumble/transport/common.py b/bumble/transport/common.py
index 05a1fb5..2786a75 100644
--- a/bumble/transport/common.py
+++ b/bumble/transport/common.py
@@ -20,11 +20,12 @@ import contextlib
import struct
import asyncio
import logging
-from typing import ContextManager
+import io
+from typing import ContextManager, Tuple, Optional, Protocol, Dict
-from .. import hci
-from ..colors import color
-from ..snoop import Snooper
+from bumble import hci
+from bumble.colors import color
+from bumble.snoop import Snooper
# -----------------------------------------------------------------------------
@@ -36,7 +37,7 @@ logger = logging.getLogger(__name__)
# Information needed to parse HCI packets with a generic parser:
# For each packet type, the info represents:
# (length-size, length-offset, unpack-type)
-HCI_PACKET_INFO = {
+HCI_PACKET_INFO: Dict[int, Tuple[int, int, str]] = {
hci.HCI_COMMAND_PACKET: (1, 2, 'B'),
hci.HCI_ACL_DATA_PACKET: (2, 2, 'H'),
hci.HCI_SYNCHRONOUS_DATA_PACKET: (1, 2, 'B'),
@@ -45,33 +46,54 @@ HCI_PACKET_INFO = {
# -----------------------------------------------------------------------------
+# Errors
+# -----------------------------------------------------------------------------
+class TransportLostError(Exception):
+ """
+ The Transport has been lost/disconnected.
+ """
+
+
+# -----------------------------------------------------------------------------
+# Typing Protocols
+# -----------------------------------------------------------------------------
+class TransportSink(Protocol):
+ def on_packet(self, packet: bytes) -> None:
+ ...
+
+
+class TransportSource(Protocol):
+ terminated: asyncio.Future[None]
+
+ def set_packet_sink(self, sink: TransportSink) -> None:
+ ...
+
+
+# -----------------------------------------------------------------------------
class PacketPump:
- '''
- Pump HCI packets from a reader to a sink
- '''
+ """
+ Pump HCI packets from a reader to a sink.
+ """
- def __init__(self, reader, sink):
+ def __init__(self, reader: AsyncPacketReader, sink: TransportSink) -> None:
self.reader = reader
self.sink = sink
- async def run(self):
+ async def run(self) -> None:
while True:
try:
- # Get a packet from the source
- packet = hci.HCI_Packet.from_bytes(await self.reader.next_packet())
-
# Deliver the packet to the sink
- self.sink.on_packet(packet)
+ self.sink.on_packet(await self.reader.next_packet())
except Exception as error:
logger.warning(f'!!! {error}')
# -----------------------------------------------------------------------------
class PacketParser:
- '''
+ """
In-line parser that accepts data and emits 'on_packet' when a full packet has been
- parsed
- '''
+ parsed.
+ """
# pylint: disable=attribute-defined-outside-init
@@ -79,18 +101,22 @@ class PacketParser:
NEED_LENGTH = 1
NEED_BODY = 2
- def __init__(self, sink=None):
+ sink: Optional[TransportSink]
+ extended_packet_info: Dict[int, Tuple[int, int, str]]
+ packet_info: Optional[Tuple[int, int, str]] = None
+
+ def __init__(self, sink: Optional[TransportSink] = None) -> None:
self.sink = sink
self.extended_packet_info = {}
self.reset()
- def reset(self):
+ def reset(self) -> None:
self.state = PacketParser.NEED_TYPE
self.bytes_needed = 1
self.packet = bytearray()
self.packet_info = None
- def feed_data(self, data):
+ def feed_data(self, data: bytes) -> None:
data_offset = 0
data_left = len(data)
while data_left and self.bytes_needed:
@@ -111,6 +137,7 @@ class PacketParser:
self.state = PacketParser.NEED_LENGTH
self.bytes_needed = self.packet_info[0] + self.packet_info[1]
elif self.state == PacketParser.NEED_LENGTH:
+ assert self.packet_info is not None
body_length = struct.unpack_from(
self.packet_info[2], self.packet, 1 + self.packet_info[1]
)[0]
@@ -128,20 +155,20 @@ class PacketParser:
)
self.reset()
- def set_packet_sink(self, sink):
+ def set_packet_sink(self, sink: TransportSink) -> None:
self.sink = sink
# -----------------------------------------------------------------------------
class PacketReader:
- '''
- Reader that reads HCI packets from a sync source
- '''
+ """
+ Reader that reads HCI packets from a sync source.
+ """
- def __init__(self, source):
+ def __init__(self, source: io.BufferedReader) -> None:
self.source = source
- def next_packet(self):
+ def next_packet(self) -> Optional[bytes]:
# Get the packet type
packet_type = self.source.read(1)
if len(packet_type) != 1:
@@ -150,7 +177,7 @@ class PacketReader:
# Get the packet info based on its type
packet_info = HCI_PACKET_INFO.get(packet_type[0])
if packet_info is None:
- raise ValueError(f'invalid packet type {packet_type} found')
+ raise ValueError(f'invalid packet type {packet_type[0]} found')
# Read the header (that includes the length)
header_size = packet_info[0] + packet_info[1]
@@ -169,21 +196,21 @@ class PacketReader:
# -----------------------------------------------------------------------------
class AsyncPacketReader:
- '''
- Reader that reads HCI packets from an async source
- '''
+ """
+ Reader that reads HCI packets from an async source.
+ """
- def __init__(self, source):
+ def __init__(self, source: asyncio.StreamReader) -> None:
self.source = source
- async def next_packet(self):
+ async def next_packet(self) -> bytes:
# Get the packet type
packet_type = await self.source.readexactly(1)
# Get the packet info based on its type
packet_info = HCI_PACKET_INFO.get(packet_type[0])
if packet_info is None:
- raise ValueError(f'invalid packet type {packet_type} found')
+ raise ValueError(f'invalid packet type {packet_type[0]} found')
# Read the header (that includes the length)
header_size = packet_info[0] + packet_info[1]
@@ -198,15 +225,15 @@ class AsyncPacketReader:
# -----------------------------------------------------------------------------
class AsyncPipeSink:
- '''
- Sink that forwards packets asynchronously to another sink
- '''
+ """
+ Sink that forwards packets asynchronously to another sink.
+ """
- def __init__(self, sink):
+ def __init__(self, sink: TransportSink) -> None:
self.sink = sink
self.loop = asyncio.get_running_loop()
- def on_packet(self, packet):
+ def on_packet(self, packet: bytes) -> None:
self.loop.call_soon(self.sink.on_packet, packet)
@@ -216,35 +243,48 @@ class ParserSource:
Base class designed to be subclassed by transport-specific source classes
"""
- def __init__(self):
+ terminated: asyncio.Future[None]
+ parser: PacketParser
+
+ def __init__(self) -> None:
self.parser = PacketParser()
self.terminated = asyncio.get_running_loop().create_future()
- def set_packet_sink(self, sink):
+ def set_packet_sink(self, sink: TransportSink) -> None:
self.parser.set_packet_sink(sink)
- async def wait_for_termination(self):
+ def on_transport_lost(self) -> None:
+ self.terminated.set_result(None)
+ if self.parser.sink:
+ if hasattr(self.parser.sink, 'on_transport_lost'):
+ self.parser.sink.on_transport_lost()
+
+ async def wait_for_termination(self) -> None:
+ """
+ Convenience method for backward compatibility. Prefer using the `terminated`
+ attribute instead.
+ """
return await self.terminated
- def close(self):
+ def close(self) -> None:
pass
# -----------------------------------------------------------------------------
class StreamPacketSource(asyncio.Protocol, ParserSource):
- def data_received(self, data):
+ def data_received(self, data: bytes) -> None:
self.parser.feed_data(data)
# -----------------------------------------------------------------------------
class StreamPacketSink:
- def __init__(self, transport):
+ def __init__(self, transport: asyncio.WriteTransport) -> None:
self.transport = transport
- def on_packet(self, packet):
+ def on_packet(self, packet: bytes) -> None:
self.transport.write(packet)
- def close(self):
+ def close(self) -> None:
self.transport.close()
@@ -264,7 +304,7 @@ class Transport:
...
"""
- def __init__(self, source, sink):
+ def __init__(self, source: TransportSource, sink: TransportSink) -> None:
self.source = source
self.sink = sink
@@ -278,34 +318,39 @@ class Transport:
return iter((self.source, self.sink))
async def close(self) -> None:
- self.source.close()
- self.sink.close()
+ if hasattr(self.source, 'close'):
+ self.source.close()
+ if hasattr(self.sink, 'close'):
+ self.sink.close()
# -----------------------------------------------------------------------------
class PumpedPacketSource(ParserSource):
- def __init__(self, receive):
+ pump_task: Optional[asyncio.Task[None]]
+
+ def __init__(self, receive) -> None:
super().__init__()
self.receive_function = receive
self.pump_task = None
- def start(self):
- async def pump_packets():
+ def start(self) -> None:
+ async def pump_packets() -> None:
while True:
try:
packet = await self.receive_function()
self.parser.feed_data(packet)
- except asyncio.exceptions.CancelledError:
+ except asyncio.CancelledError:
logger.debug('source pump task done')
+ self.terminated.set_result(None)
break
except Exception as error:
logger.warning(f'exception while waiting for packet: {error}')
- self.terminated.set_result(error)
+ self.terminated.set_exception(error)
break
self.pump_task = asyncio.create_task(pump_packets())
- def close(self):
+ def close(self) -> None:
if self.pump_task:
self.pump_task.cancel()
@@ -317,7 +362,7 @@ class PumpedPacketSink:
self.packet_queue = asyncio.Queue()
self.pump_task = None
- def on_packet(self, packet):
+ def on_packet(self, packet: bytes) -> None:
self.packet_queue.put_nowait(packet)
def start(self):
@@ -326,7 +371,7 @@ class PumpedPacketSink:
try:
packet = await self.packet_queue.get()
await self.send_function(packet)
- except asyncio.exceptions.CancelledError:
+ except asyncio.CancelledError:
logger.debug('sink pump task done')
break
except Exception as error:
@@ -342,18 +387,20 @@ class PumpedPacketSink:
# -----------------------------------------------------------------------------
class PumpedTransport(Transport):
- def __init__(self, source, sink, close_function):
+ source: PumpedPacketSource
+ sink: PumpedPacketSink
+
+ def __init__(
+ self,
+ source: PumpedPacketSource,
+ sink: PumpedPacketSink,
+ ) -> None:
super().__init__(source, sink)
- self.close_function = close_function
- def start(self):
+ def start(self) -> None:
self.source.start()
self.sink.start()
- async def close(self):
- await super().close()
- await self.close_function()
-
# -----------------------------------------------------------------------------
class SnoopingTransport(Transport):
@@ -375,31 +422,38 @@ class SnoopingTransport(Transport):
raise RuntimeError('unexpected code path') # Satisfy the type checker
class Source:
- def __init__(self, source, snooper):
+ sink: TransportSink
+
+ def __init__(self, source: TransportSource, snooper: Snooper):
self.source = source
self.snooper = snooper
- self.sink = None
+ self.terminated = source.terminated
- def set_packet_sink(self, sink):
+ def set_packet_sink(self, sink: TransportSink) -> None:
self.sink = sink
self.source.set_packet_sink(self)
- def on_packet(self, packet):
+ def on_packet(self, packet: bytes) -> None:
self.snooper.snoop(packet, Snooper.Direction.CONTROLLER_TO_HOST)
if self.sink:
self.sink.on_packet(packet)
class Sink:
- def __init__(self, sink, snooper):
+ def __init__(self, sink: TransportSink, snooper: Snooper) -> None:
self.sink = sink
self.snooper = snooper
- def on_packet(self, packet):
+ def on_packet(self, packet: bytes) -> None:
self.snooper.snoop(packet, Snooper.Direction.HOST_TO_CONTROLLER)
if self.sink:
self.sink.on_packet(packet)
- def __init__(self, transport, snooper, close_snooper=None):
+ def __init__(
+ self,
+ transport: Transport,
+ snooper: Snooper,
+ close_snooper=None,
+ ) -> None:
super().__init__(
self.Source(transport.source, snooper), self.Sink(transport.sink, snooper)
)
diff --git a/bumble/transport/file.py b/bumble/transport/file.py
index 9c073d2..dee1c23 100644
--- a/bumble/transport/file.py
+++ b/bumble/transport/file.py
@@ -28,7 +28,7 @@ logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
-async def open_file_transport(spec):
+async def open_file_transport(spec: str) -> Transport:
'''
Open a File transport (typically not for a real file, but for a PTY or other unix
virtual files).
diff --git a/bumble/transport/hci_socket.py b/bumble/transport/hci_socket.py
index 4e1ad99..df9e885 100644
--- a/bumble/transport/hci_socket.py
+++ b/bumble/transport/hci_socket.py
@@ -23,6 +23,8 @@ import socket
import ctypes
import collections
+from typing import Optional
+
from .common import Transport, ParserSource
@@ -33,7 +35,7 @@ logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
-async def open_hci_socket_transport(spec):
+async def open_hci_socket_transport(spec: Optional[str]) -> Transport:
'''
Open an HCI Socket (only available on some platforms).
The parameter string is either empty (to use the first/default Bluetooth adapter)
@@ -45,9 +47,9 @@ async def open_hci_socket_transport(spec):
# Create a raw HCI socket
try:
hci_socket = socket.socket(
- socket.AF_BLUETOOTH,
- socket.SOCK_RAW | socket.SOCK_NONBLOCK,
- socket.BTPROTO_HCI,
+ socket.AF_BLUETOOTH, # type: ignore[attr-defined]
+ socket.SOCK_RAW | socket.SOCK_NONBLOCK, # type: ignore[attr-defined]
+ socket.BTPROTO_HCI, # type: ignore[attr-defined]
)
except AttributeError as error:
# Not supported on this platform
@@ -78,7 +80,7 @@ async def open_hci_socket_transport(spec):
bind_address = struct.pack(
# pylint: disable=no-member
'<HHH',
- socket.AF_BLUETOOTH,
+ socket.AF_BLUETOOTH, # type: ignore[attr-defined]
adapter_index,
HCI_CHANNEL_USER,
)
diff --git a/bumble/transport/pty.py b/bumble/transport/pty.py
index e6e2ab5..2f46e75 100644
--- a/bumble/transport/pty.py
+++ b/bumble/transport/pty.py
@@ -23,6 +23,8 @@ import atexit
import os
import logging
+from typing import Optional
+
from .common import Transport, StreamPacketSource, StreamPacketSink
# -----------------------------------------------------------------------------
@@ -32,7 +34,7 @@ logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
-async def open_pty_transport(spec):
+async def open_pty_transport(spec: Optional[str]) -> Transport:
'''
Open a PTY transport.
The parameter string may be empty, or a path name where a symbolic link
diff --git a/bumble/transport/pyusb.py b/bumble/transport/pyusb.py
index 8ad8598..5e686d1 100644
--- a/bumble/transport/pyusb.py
+++ b/bumble/transport/pyusb.py
@@ -35,7 +35,7 @@ logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
-async def open_pyusb_transport(spec):
+async def open_pyusb_transport(spec: str) -> Transport:
'''
Open a USB transport. [Implementation based on PyUSB]
The parameter string has this syntax:
diff --git a/bumble/transport/serial.py b/bumble/transport/serial.py
index c83b605..c48cdc6 100644
--- a/bumble/transport/serial.py
+++ b/bumble/transport/serial.py
@@ -28,7 +28,7 @@ logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
-async def open_serial_transport(spec):
+async def open_serial_transport(spec: str) -> Transport:
'''
Open a serial port transport.
The parameter string has this syntax:
diff --git a/bumble/transport/tcp_client.py b/bumble/transport/tcp_client.py
index 934a521..4fb268a 100644
--- a/bumble/transport/tcp_client.py
+++ b/bumble/transport/tcp_client.py
@@ -27,7 +27,7 @@ logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
-async def open_tcp_client_transport(spec):
+async def open_tcp_client_transport(spec: str) -> Transport:
'''
Open a TCP client transport.
The parameter string has this syntax:
@@ -39,7 +39,7 @@ async def open_tcp_client_transport(spec):
class TcpPacketSource(StreamPacketSource):
def connection_lost(self, exc):
logger.debug(f'connection lost: {exc}')
- self.terminated.set_result(exc)
+ self.on_transport_lost()
remote_host, remote_port = spec.split(':')
tcp_transport, packet_source = await asyncio.get_running_loop().create_connection(
diff --git a/bumble/transport/tcp_server.py b/bumble/transport/tcp_server.py
index 11b0453..77d0304 100644
--- a/bumble/transport/tcp_server.py
+++ b/bumble/transport/tcp_server.py
@@ -15,6 +15,7 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
+from __future__ import annotations
import asyncio
import logging
@@ -27,7 +28,7 @@ logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
-async def open_tcp_server_transport(spec):
+async def open_tcp_server_transport(spec: str) -> Transport:
'''
Open a TCP server transport.
The parameter string has this syntax:
@@ -42,7 +43,7 @@ async def open_tcp_server_transport(spec):
async def close(self):
await super().close()
- class TcpServerProtocol:
+ class TcpServerProtocol(asyncio.BaseProtocol):
def __init__(self, packet_source, packet_sink):
self.packet_source = packet_source
self.packet_sink = packet_sink
diff --git a/bumble/transport/udp.py b/bumble/transport/udp.py
index e5e26fa..faa9bf0 100644
--- a/bumble/transport/udp.py
+++ b/bumble/transport/udp.py
@@ -27,7 +27,7 @@ logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
-async def open_udp_transport(spec):
+async def open_udp_transport(spec: str) -> Transport:
'''
Open a UDP transport.
The parameter string has this syntax:
diff --git a/bumble/transport/usb.py b/bumble/transport/usb.py
index 13cad60..ccc82c1 100644
--- a/bumble/transport/usb.py
+++ b/bumble/transport/usb.py
@@ -60,7 +60,7 @@ def load_libusb():
usb1.loadLibrary(libusb_dll)
-async def open_usb_transport(spec):
+async def open_usb_transport(spec: str) -> Transport:
'''
Open a USB transport.
The moniker string has this syntax:
diff --git a/bumble/transport/vhci.py b/bumble/transport/vhci.py
index ec61ab4..2b19085 100644
--- a/bumble/transport/vhci.py
+++ b/bumble/transport/vhci.py
@@ -17,6 +17,9 @@
# -----------------------------------------------------------------------------
import logging
+from typing import Optional
+
+from .common import Transport
from .file import open_file_transport
# -----------------------------------------------------------------------------
@@ -26,7 +29,7 @@ logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
-async def open_vhci_transport(spec):
+async def open_vhci_transport(spec: Optional[str]) -> Transport:
'''
Open a VHCI transport (only available on some platforms).
The parameter string is either empty (to use the default VHCI device
@@ -42,15 +45,15 @@ async def open_vhci_transport(spec):
# Override the source's `data_received` method so that we can
# filter out the vendor packet that is received just after the
# initial open
- def vhci_data_received(data):
+ def vhci_data_received(data: bytes) -> None:
if len(data) > 0 and data[0] == HCI_VENDOR_PKT:
if len(data) == 4:
hci_index = data[2] << 8 | data[3]
logger.info(f'HCI index {hci_index}')
else:
- transport.source.parser.feed_data(data)
+ transport.source.parser.feed_data(data) # type: ignore
- transport.source.data_received = vhci_data_received
+ transport.source.data_received = vhci_data_received # type: ignore
# Write the initial config
transport.sink.on_packet(bytes([HCI_VENDOR_PKT, HCI_BREDR]))
diff --git a/bumble/transport/ws_client.py b/bumble/transport/ws_client.py
index 85f6e88..902001e 100644
--- a/bumble/transport/ws_client.py
+++ b/bumble/transport/ws_client.py
@@ -16,9 +16,9 @@
# Imports
# -----------------------------------------------------------------------------
import logging
-import websockets
+import websockets.client
-from .common import PumpedPacketSource, PumpedPacketSink, PumpedTransport
+from .common import PumpedPacketSource, PumpedPacketSink, PumpedTransport, Transport
# -----------------------------------------------------------------------------
# Logging
@@ -27,23 +27,25 @@ logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
-async def open_ws_client_transport(spec):
+async def open_ws_client_transport(spec: str) -> Transport:
'''
Open a WebSocket client transport.
The parameter string has this syntax:
- <remote-host>:<remote-port>
+ <websocket-url>
- Example: 127.0.0.1:9001
+ Example: ws://localhost:7681/v1/websocket/bt
'''
- remote_host, remote_port = spec.split(':')
- uri = f'ws://{remote_host}:{remote_port}'
- websocket = await websockets.connect(uri)
+ websocket = await websockets.client.connect(spec)
- transport = PumpedTransport(
+ class WsTransport(PumpedTransport):
+ async def close(self):
+ await super().close()
+ await websocket.close()
+
+ transport = WsTransport(
PumpedPacketSource(websocket.recv),
PumpedPacketSink(websocket.send),
- websocket.close,
)
transport.start()
return transport
diff --git a/bumble/transport/ws_server.py b/bumble/transport/ws_server.py
index c7b7c6e..3c72c36 100644
--- a/bumble/transport/ws_server.py
+++ b/bumble/transport/ws_server.py
@@ -15,7 +15,6 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
-import asyncio
import logging
import websockets
@@ -28,7 +27,7 @@ logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
-async def open_ws_server_transport(spec):
+async def open_ws_server_transport(spec: str) -> Transport:
'''
Open a WebSocket server transport.
The parameter string has this syntax:
@@ -43,7 +42,7 @@ async def open_ws_server_transport(spec):
def __init__(self):
source = ParserSource()
sink = PumpedPacketSink(self.send_packet)
- self.connection = asyncio.get_running_loop().create_future()
+ self.connection = None
self.server = None
super().__init__(source, sink)
@@ -63,7 +62,7 @@ async def open_ws_server_transport(spec):
f'new connection on {connection.local_address} '
f'from {connection.remote_address}'
)
- self.connection.set_result(connection)
+ self.connection = connection
# pylint: disable=no-member
try:
async for packet in connection:
@@ -74,12 +73,14 @@ async def open_ws_server_transport(spec):
except websockets.WebSocketException as error:
logger.debug(f'exception while receiving packet: {error}')
- # Wait for a new connection
- self.connection = asyncio.get_running_loop().create_future()
+ # We're now disconnected
+ self.connection = None
async def send_packet(self, packet):
- connection = await self.connection
- return await connection.send(packet)
+ if self.connection is None:
+ logger.debug('no connection, dropping packet')
+ return
+ return await self.connection.send(packet)
local_host, local_port = spec.split(':')
transport = WsServerTransport()
diff --git a/bumble/utils.py b/bumble/utils.py
index 8a55684..dc03725 100644
--- a/bumble/utils.py
+++ b/bumble/utils.py
@@ -15,12 +15,24 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
+from __future__ import annotations
import asyncio
import logging
import traceback
import collections
import sys
-from typing import Awaitable, Set, TypeVar
+from typing import (
+ Awaitable,
+ Set,
+ TypeVar,
+ List,
+ Tuple,
+ Callable,
+ Any,
+ Optional,
+ Union,
+ overload,
+)
from functools import wraps
from pyee import EventEmitter
@@ -65,6 +77,102 @@ def composite_listener(cls):
# -----------------------------------------------------------------------------
+_Handler = TypeVar('_Handler', bound=Callable)
+
+
+class EventWatcher:
+ '''A wrapper class to control the lifecycle of event handlers better.
+
+ Usage:
+ ```
+ watcher = EventWatcher()
+
+ def on_foo():
+ ...
+ watcher.on(emitter, 'foo', on_foo)
+
+ @watcher.on(emitter, 'bar')
+ def on_bar():
+ ...
+
+ # Close all event handlers watching through this watcher
+ watcher.close()
+ ```
+
+ As context:
+ ```
+ with contextlib.closing(EventWatcher()) as context:
+ @context.on(emitter, 'foo')
+ def on_foo():
+ ...
+ # on_foo() has been removed here!
+ ```
+ '''
+
+ handlers: List[Tuple[EventEmitter, str, Callable[..., Any]]]
+
+ def __init__(self) -> None:
+ self.handlers = []
+
+ @overload
+ def on(self, emitter: EventEmitter, event: str) -> Callable[[_Handler], _Handler]:
+ ...
+
+ @overload
+ def on(self, emitter: EventEmitter, event: str, handler: _Handler) -> _Handler:
+ ...
+
+ def on(
+ self, emitter: EventEmitter, event: str, handler: Optional[_Handler] = None
+ ) -> Union[_Handler, Callable[[_Handler], _Handler]]:
+ '''Watch an event until the context is closed.
+
+ Args:
+ emitter: EventEmitter to watch
+ event: Event name
+ handler: (Optional) Event handler. When nothing is passed, this method works as a decorator.
+ '''
+
+ def wrapper(f: _Handler) -> _Handler:
+ self.handlers.append((emitter, event, f))
+ emitter.on(event, f)
+ return f
+
+ return wrapper if handler is None else wrapper(handler)
+
+ @overload
+ def once(self, emitter: EventEmitter, event: str) -> Callable[[_Handler], _Handler]:
+ ...
+
+ @overload
+ def once(self, emitter: EventEmitter, event: str, handler: _Handler) -> _Handler:
+ ...
+
+ def once(
+ self, emitter: EventEmitter, event: str, handler: Optional[_Handler] = None
+ ) -> Union[_Handler, Callable[[_Handler], _Handler]]:
+ '''Watch an event for once.
+
+ Args:
+ emitter: EventEmitter to watch
+ event: Event name
+ handler: (Optional) Event handler. When nothing passed, this method works as a decorator.
+ '''
+
+ def wrapper(f: _Handler) -> _Handler:
+ self.handlers.append((emitter, event, f))
+ emitter.once(event, f)
+ return f
+
+ return wrapper if handler is None else wrapper(handler)
+
+ def close(self) -> None:
+ for emitter, event, handler in self.handlers:
+ if handler in emitter.listeners(event):
+ emitter.remove_listener(event, handler)
+
+
+# -----------------------------------------------------------------------------
_T = TypeVar('_T')
diff --git a/bumble/vendor/__init__.py b/bumble/vendor/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/bumble/vendor/__init__.py
diff --git a/bumble/vendor/android/__init__.py b/bumble/vendor/android/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/bumble/vendor/android/__init__.py
diff --git a/bumble/vendor/android/hci.py b/bumble/vendor/android/hci.py
new file mode 100644
index 0000000..c411ecf
--- /dev/null
+++ b/bumble/vendor/android/hci.py
@@ -0,0 +1,318 @@
+# 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 struct
+
+from bumble.hci import (
+ name_or_number,
+ hci_vendor_command_op_code,
+ Address,
+ HCI_Constant,
+ HCI_Object,
+ HCI_Command,
+ HCI_Vendor_Event,
+ STATUS_SPEC,
+)
+
+
+# -----------------------------------------------------------------------------
+# Constants
+# -----------------------------------------------------------------------------
+
+# Android Vendor Specific Commands and Events.
+# Only a subset of the commands are implemented here currently.
+#
+# pylint: disable-next=line-too-long
+# See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#chip-capabilities-and-configuration
+HCI_LE_GET_VENDOR_CAPABILITIES_COMMAND = hci_vendor_command_op_code(0x153)
+HCI_LE_APCF_COMMAND = hci_vendor_command_op_code(0x157)
+HCI_GET_CONTROLLER_ACTIVITY_ENERGY_INFO_COMMAND = hci_vendor_command_op_code(0x159)
+HCI_A2DP_HARDWARE_OFFLOAD_COMMAND = hci_vendor_command_op_code(0x15D)
+HCI_BLUETOOTH_QUALITY_REPORT_COMMAND = hci_vendor_command_op_code(0x15E)
+HCI_DYNAMIC_AUDIO_BUFFER_COMMAND = hci_vendor_command_op_code(0x15F)
+
+HCI_BLUETOOTH_QUALITY_REPORT_EVENT = 0x58
+
+HCI_Command.register_commands(globals())
+HCI_Vendor_Event.register_subevents(globals())
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+ return_parameters_fields=[
+ ('status', STATUS_SPEC),
+ ('max_advt_instances', 1),
+ ('offloaded_resolution_of_private_address', 1),
+ ('total_scan_results_storage', 2),
+ ('max_irk_list_sz', 1),
+ ('filtering_support', 1),
+ ('max_filter', 1),
+ ('activity_energy_info_support', 1),
+ ('version_supported', 2),
+ ('total_num_of_advt_tracked', 2),
+ ('extended_scan_support', 1),
+ ('debug_logging_supported', 1),
+ ('le_address_generation_offloading_support', 1),
+ ('a2dp_source_offload_capability_mask', 4),
+ ('bluetooth_quality_report_support', 1),
+ ('dynamic_audio_buffer_support', 4),
+ ]
+)
+class HCI_LE_Get_Vendor_Capabilities_Command(HCI_Command):
+ # pylint: disable=line-too-long
+ '''
+ See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#vendor-specific-capabilities
+ '''
+
+ @classmethod
+ def parse_return_parameters(cls, parameters):
+ # There are many versions of this data structure, so we need to parse until
+ # there are no more bytes to parse, and leave un-signal parameters set to
+ # None (older versions)
+ nones = {field: None for field, _ in cls.return_parameters_fields}
+ return_parameters = HCI_Object(cls.return_parameters_fields, **nones)
+
+ try:
+ offset = 0
+ for field in cls.return_parameters_fields:
+ field_name, field_type = field
+ field_value, field_size = HCI_Object.parse_field(
+ parameters, offset, field_type
+ )
+ setattr(return_parameters, field_name, field_value)
+ offset += field_size
+ except struct.error:
+ pass
+
+ return return_parameters
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+ fields=[
+ (
+ 'opcode',
+ {
+ 'size': 1,
+ 'mapper': lambda x: HCI_LE_APCF_Command.opcode_name(x),
+ },
+ ),
+ ('payload', '*'),
+ ],
+ return_parameters_fields=[
+ ('status', STATUS_SPEC),
+ (
+ 'opcode',
+ {
+ 'size': 1,
+ 'mapper': lambda x: HCI_LE_APCF_Command.opcode_name(x),
+ },
+ ),
+ ('payload', '*'),
+ ],
+)
+class HCI_LE_APCF_Command(HCI_Command):
+ # pylint: disable=line-too-long
+ '''
+ See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#le_apcf_command
+
+ NOTE: the subcommand-specific payloads are left as opaque byte arrays in this
+ implementation. A future enhancement may define subcommand-specific data structures.
+ '''
+
+ # APCF Subcommands
+ # TODO: use the OpenIntEnum class (when upcoming PR is merged)
+ APCF_ENABLE = 0x00
+ APCF_SET_FILTERING_PARAMETERS = 0x01
+ APCF_BROADCASTER_ADDRESS = 0x02
+ APCF_SERVICE_UUID = 0x03
+ APCF_SERVICE_SOLICITATION_UUID = 0x04
+ APCF_LOCAL_NAME = 0x05
+ APCF_MANUFACTURER_DATA = 0x06
+ APCF_SERVICE_DATA = 0x07
+ APCF_TRANSPORT_DISCOVERY_SERVICE = 0x08
+ APCF_AD_TYPE_FILTER = 0x09
+ APCF_READ_EXTENDED_FEATURES = 0xFF
+
+ OPCODE_NAMES = {
+ APCF_ENABLE: 'APCF_ENABLE',
+ APCF_SET_FILTERING_PARAMETERS: 'APCF_SET_FILTERING_PARAMETERS',
+ APCF_BROADCASTER_ADDRESS: 'APCF_BROADCASTER_ADDRESS',
+ APCF_SERVICE_UUID: 'APCF_SERVICE_UUID',
+ APCF_SERVICE_SOLICITATION_UUID: 'APCF_SERVICE_SOLICITATION_UUID',
+ APCF_LOCAL_NAME: 'APCF_LOCAL_NAME',
+ APCF_MANUFACTURER_DATA: 'APCF_MANUFACTURER_DATA',
+ APCF_SERVICE_DATA: 'APCF_SERVICE_DATA',
+ APCF_TRANSPORT_DISCOVERY_SERVICE: 'APCF_TRANSPORT_DISCOVERY_SERVICE',
+ APCF_AD_TYPE_FILTER: 'APCF_AD_TYPE_FILTER',
+ APCF_READ_EXTENDED_FEATURES: 'APCF_READ_EXTENDED_FEATURES',
+ }
+
+ @classmethod
+ def opcode_name(cls, opcode):
+ return name_or_number(cls.OPCODE_NAMES, opcode)
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+ return_parameters_fields=[
+ ('status', STATUS_SPEC),
+ ('total_tx_time_ms', 4),
+ ('total_rx_time_ms', 4),
+ ('total_idle_time_ms', 4),
+ ('total_energy_used', 4),
+ ],
+)
+class HCI_Get_Controller_Activity_Energy_Info_Command(HCI_Command):
+ # pylint: disable=line-too-long
+ '''
+ See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#le_get_controller_activity_energy_info
+ '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+ fields=[
+ (
+ 'opcode',
+ {
+ 'size': 1,
+ 'mapper': lambda x: HCI_A2DP_Hardware_Offload_Command.opcode_name(x),
+ },
+ ),
+ ('payload', '*'),
+ ],
+ return_parameters_fields=[
+ ('status', STATUS_SPEC),
+ (
+ 'opcode',
+ {
+ 'size': 1,
+ 'mapper': lambda x: HCI_A2DP_Hardware_Offload_Command.opcode_name(x),
+ },
+ ),
+ ('payload', '*'),
+ ],
+)
+class HCI_A2DP_Hardware_Offload_Command(HCI_Command):
+ # pylint: disable=line-too-long
+ '''
+ See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#a2dp-hardware-offload-support
+
+ NOTE: the subcommand-specific payloads are left as opaque byte arrays in this
+ implementation. A future enhancement may define subcommand-specific data structures.
+ '''
+
+ # A2DP Hardware Offload Subcommands
+ # TODO: use the OpenIntEnum class (when upcoming PR is merged)
+ START_A2DP_OFFLOAD = 0x01
+ STOP_A2DP_OFFLOAD = 0x02
+
+ OPCODE_NAMES = {
+ START_A2DP_OFFLOAD: 'START_A2DP_OFFLOAD',
+ STOP_A2DP_OFFLOAD: 'STOP_A2DP_OFFLOAD',
+ }
+
+ @classmethod
+ def opcode_name(cls, opcode):
+ return name_or_number(cls.OPCODE_NAMES, opcode)
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+ fields=[
+ (
+ 'opcode',
+ {
+ 'size': 1,
+ 'mapper': lambda x: HCI_Dynamic_Audio_Buffer_Command.opcode_name(x),
+ },
+ ),
+ ('payload', '*'),
+ ],
+ return_parameters_fields=[
+ ('status', STATUS_SPEC),
+ (
+ 'opcode',
+ {
+ 'size': 1,
+ 'mapper': lambda x: HCI_Dynamic_Audio_Buffer_Command.opcode_name(x),
+ },
+ ),
+ ('payload', '*'),
+ ],
+)
+class HCI_Dynamic_Audio_Buffer_Command(HCI_Command):
+ # pylint: disable=line-too-long
+ '''
+ See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#dynamic-audio-buffer-command
+
+ NOTE: the subcommand-specific payloads are left as opaque byte arrays in this
+ implementation. A future enhancement may define subcommand-specific data structures.
+ '''
+
+ # Dynamic Audio Buffer Subcommands
+ # TODO: use the OpenIntEnum class (when upcoming PR is merged)
+ GET_AUDIO_BUFFER_TIME_CAPABILITY = 0x01
+
+ OPCODE_NAMES = {
+ GET_AUDIO_BUFFER_TIME_CAPABILITY: 'GET_AUDIO_BUFFER_TIME_CAPABILITY',
+ }
+
+ @classmethod
+ def opcode_name(cls, opcode):
+ return name_or_number(cls.OPCODE_NAMES, opcode)
+
+
+# -----------------------------------------------------------------------------
+@HCI_Vendor_Event.event(
+ fields=[
+ ('quality_report_id', 1),
+ ('packet_types', 1),
+ ('connection_handle', 2),
+ ('connection_role', {'size': 1, 'mapper': HCI_Constant.role_name}),
+ ('tx_power_level', -1),
+ ('rssi', -1),
+ ('snr', 1),
+ ('unused_afh_channel_count', 1),
+ ('afh_select_unideal_channel_count', 1),
+ ('lsto', 2),
+ ('connection_piconet_clock', 4),
+ ('retransmission_count', 4),
+ ('no_rx_count', 4),
+ ('nak_count', 4),
+ ('last_tx_ack_timestamp', 4),
+ ('flow_off_count', 4),
+ ('last_flow_on_timestamp', 4),
+ ('buffer_overflow_bytes', 4),
+ ('buffer_underflow_bytes', 4),
+ ('bdaddr', Address.parse_address),
+ ('cal_failed_item_count', 1),
+ ('tx_total_packets', 4),
+ ('tx_unacked_packets', 4),
+ ('tx_flushed_packets', 4),
+ ('tx_last_subevent_packets', 4),
+ ('crc_error_packets', 4),
+ ('rx_duplicate_packets', 4),
+ ('vendor_specific_parameters', '*'),
+ ]
+)
+class HCI_Bluetooth_Quality_Report_Event(HCI_Vendor_Event):
+ # pylint: disable=line-too-long
+ '''
+ See https://source.android.com/docs/core/connect/bluetooth/hci_requirements#bluetooth-quality-report-sub-event
+ '''
diff --git a/bumble/vendor/zephyr/__init__.py b/bumble/vendor/zephyr/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/bumble/vendor/zephyr/__init__.py
diff --git a/bumble/vendor/zephyr/hci.py b/bumble/vendor/zephyr/hci.py
new file mode 100644
index 0000000..9ffb3c3
--- /dev/null
+++ b/bumble/vendor/zephyr/hci.py
@@ -0,0 +1,88 @@
+# 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 bumble.hci import (
+ hci_vendor_command_op_code,
+ HCI_Command,
+ STATUS_SPEC,
+)
+
+
+# -----------------------------------------------------------------------------
+# Constants
+# -----------------------------------------------------------------------------
+
+# Zephyr RTOS Vendor Specific Commands and Events.
+# Only a subset of the commands are implemented here currently.
+#
+# pylint: disable-next=line-too-long
+# See https://github.com/zephyrproject-rtos/zephyr/blob/main/include/zephyr/bluetooth/hci_vs.h
+HCI_WRITE_TX_POWER_LEVEL_COMMAND = hci_vendor_command_op_code(0x000E)
+HCI_READ_TX_POWER_LEVEL_COMMAND = hci_vendor_command_op_code(0x000F)
+
+HCI_Command.register_commands(globals())
+
+
+# -----------------------------------------------------------------------------
+class TX_Power_Level_Command:
+ '''
+ Base class for read and write TX power level HCI commands
+ '''
+
+ TX_POWER_HANDLE_TYPE_ADV = 0x00
+ TX_POWER_HANDLE_TYPE_SCAN = 0x01
+ TX_POWER_HANDLE_TYPE_CONN = 0x02
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+ fields=[('handle_type', 1), ('connection_handle', 2), ('tx_power_level', -1)],
+ return_parameters_fields=[
+ ('status', STATUS_SPEC),
+ ('handle_type', 1),
+ ('connection_handle', 2),
+ ('selected_tx_power_level', -1),
+ ],
+)
+class HCI_Write_Tx_Power_Level_Command(HCI_Command, TX_Power_Level_Command):
+ '''
+ Write TX power level. See BT_HCI_OP_VS_WRITE_TX_POWER_LEVEL in
+ https://github.com/zephyrproject-rtos/zephyr/blob/main/include/zephyr/bluetooth/hci_vs.h
+
+ Power level is in dB. Connection handle for TX_POWER_HANDLE_TYPE_ADV and
+ TX_POWER_HANDLE_TYPE_SCAN should be zero.
+ '''
+
+
+# -----------------------------------------------------------------------------
+@HCI_Command.command(
+ fields=[('handle_type', 1), ('connection_handle', 2)],
+ return_parameters_fields=[
+ ('status', STATUS_SPEC),
+ ('handle_type', 1),
+ ('connection_handle', 2),
+ ('tx_power_level', -1),
+ ],
+)
+class HCI_Read_Tx_Power_Level_Command(HCI_Command, TX_Power_Level_Command):
+ '''
+ Read TX power level. See BT_HCI_OP_VS_READ_TX_POWER_LEVEL in
+ https://github.com/zephyrproject-rtos/zephyr/blob/main/include/zephyr/bluetooth/hci_vs.h
+
+ Power level is in dB. Connection handle for TX_POWER_HANDLE_TYPE_ADV and
+ TX_POWER_HANDLE_TYPE_SCAN should be zero.
+ '''
diff --git a/docs/mkdocs/mkdocs.yml b/docs/mkdocs/mkdocs.yml
index 0cf65f1..82a6f41 100644
--- a/docs/mkdocs/mkdocs.yml
+++ b/docs/mkdocs/mkdocs.yml
@@ -64,6 +64,7 @@ nav:
- Linux: platforms/linux.md
- Windows: platforms/windows.md
- Android: platforms/android.md
+ - Zephyr: platforms/zephyr.md
- Examples:
- Overview: examples/index.md
diff --git a/docs/mkdocs/src/downloads/zephyr/hci_usb.zip b/docs/mkdocs/src/downloads/zephyr/hci_usb.zip
new file mode 100644
index 0000000..5e1dfc9
--- /dev/null
+++ b/docs/mkdocs/src/downloads/zephyr/hci_usb.zip
Binary files differ
diff --git a/docs/mkdocs/src/platforms/index.md b/docs/mkdocs/src/platforms/index.md
index a93e947..858785f 100644
--- a/docs/mkdocs/src/platforms/index.md
+++ b/docs/mkdocs/src/platforms/index.md
@@ -9,3 +9,4 @@ For platform-specific information, see the following pages:
* :material-linux: Linux - see the [Linux platform page](linux.md)
* :material-microsoft-windows: Windows - see the [Windows platform page](windows.md)
* :material-android: Android - see the [Android platform page](android.md)
+ * :material-memory: Zephyr - see the [Zephyr platform page](zephyr.md)
diff --git a/docs/mkdocs/src/platforms/zephyr.md b/docs/mkdocs/src/platforms/zephyr.md
new file mode 100644
index 0000000..0e68247
--- /dev/null
+++ b/docs/mkdocs/src/platforms/zephyr.md
@@ -0,0 +1,51 @@
+:material-memory: ZEPHYR PLATFORM
+=================================
+
+Set TX Power on nRF52840
+------------------------
+
+The Nordic nRF52840 supports Zephyr's vendor specific HCI command for setting TX
+power during advertising, connection, or scanning. With the example [HCI
+USB](https://docs.zephyrproject.org/latest/samples/bluetooth/hci_usb/README.html)
+application, an [nRF52840
+dongle](https://www.nordicsemi.com/Products/Development-
+hardware/nRF52840-Dongle) can be used as a Bumble controller.
+
+To add dynamic TX power support to the HCI USB application, add the following to
+`zephyr/samples/bluetooth/hci_usb/prj.conf` and build.
+
+```
+CONFIG_BT_CTLR_ADVANCED_FEATURES=y
+CONFIG_BT_CTLR_CONN_RSSI=y
+CONFIG_BT_CTLR_TX_PWR_DYNAMIC_CONTROL=y
+```
+
+Alternatively, a prebuilt firmware application can be downloaded here:
+[hci_usb.zip](../downloads/zephyr/hci_usb.zip).
+
+Put the nRF52840 dongle into bootloader mode by pressing the RESET button. The
+LED should pulse red. Load the firmware application with the `nrfutil` tool:
+
+```
+nrfutil dfu usb-serial -pkg hci_usb.zip -p /dev/ttyACM0
+```
+
+The vendor specific HCI commands to read and write TX power are defined in
+`bumble/vendor/zephyr/hci.py` and may be used as such:
+
+```python
+from bumble.vendor.zephyr.hci import HCI_Write_Tx_Power_Level_Command
+
+# set advertising power to -4 dB
+response = await host.send_command(
+ HCI_Write_Tx_Power_Level_Command(
+ handle_type=HCI_Write_Tx_Power_Level_Command.TX_POWER_HANDLE_TYPE_ADV,
+ connection_handle=0,
+ tx_power_level=-4,
+ )
+)
+
+if response.return_parameters.status == HCI_SUCCESS:
+ print(f"TX power set to {response.return_parameters.selected_tx_power_level}")
+
+```
diff --git a/environment.yml b/environment.yml
index 17b040c..2e927cb 100644
--- a/environment.yml
+++ b/environment.yml
@@ -3,7 +3,7 @@ channels:
- defaults
- conda-forge
dependencies:
- - pip=20
+ - pip=23
- python=3.8
- pip:
- --editable .[development,documentation,test]
diff --git a/examples/run_hfp_gateway.py b/examples/run_hfp_gateway.py
index 63a2a7c..13a2ed9 100644
--- a/examples/run_hfp_gateway.py
+++ b/examples/run_hfp_gateway.py
@@ -30,7 +30,7 @@ from bumble.core import (
BT_RFCOMM_PROTOCOL_ID,
BT_BR_EDR_TRANSPORT,
)
-from bumble.rfcomm import Client
+from bumble import rfcomm, hfp
from bumble.sdp import (
Client as SDP_Client,
DataElement,
@@ -39,7 +39,9 @@ from bumble.sdp import (
SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
)
-from bumble.hfp import HfpProtocol
+
+
+logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
@@ -181,7 +183,7 @@ async def main():
# Create a client and start it
print('@@@ Starting to RFCOMM client...')
- rfcomm_client = Client(device, connection)
+ rfcomm_client = rfcomm.Client(device, connection)
rfcomm_mux = await rfcomm_client.start()
print('@@@ Started')
@@ -196,7 +198,7 @@ async def main():
return
# Protocol loop (just for testing at this point)
- protocol = HfpProtocol(session)
+ protocol = hfp.HfpProtocol(session)
while True:
line = await protocol.next_line()
diff --git a/examples/run_hfp_handsfree.py b/examples/run_hfp_handsfree.py
index cef29c0..5f747fc 100644
--- a/examples/run_hfp_handsfree.py
+++ b/examples/run_hfp_handsfree.py
@@ -21,82 +21,22 @@ import os
import logging
import json
import websockets
-
+from typing import Optional
from bumble.device import Device
from bumble.transport import open_transport_or_link
from bumble.rfcomm import Server as RfcommServer
-from bumble.sdp import (
- DataElement,
- ServiceAttribute,
- SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
- SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
- SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
- SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
-)
-from bumble.core import (
- BT_GENERIC_AUDIO_SERVICE,
- BT_HANDSFREE_SERVICE,
- BT_L2CAP_PROTOCOL_ID,
- BT_RFCOMM_PROTOCOL_ID,
-)
-from bumble.hfp import HfpProtocol
-
-
-# -----------------------------------------------------------------------------
-def make_sdp_records(rfcomm_channel):
- 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),
- ]
- )
- ]
- ),
- ),
- ]
- }
+from bumble import hfp
+from bumble.hfp import HfProtocol
# -----------------------------------------------------------------------------
class UiServer:
- protocol = None
+ protocol: Optional[HfProtocol] = None
async def start(self):
- # Start a Websocket server to receive events from a web page
+ """Start a Websocket server to receive events from a web page."""
+
async def serve(websocket, _path):
while True:
try:
@@ -107,7 +47,7 @@ class UiServer:
message_type = parsed['type']
if message_type == 'at_command':
if self.protocol is not None:
- self.protocol.send_command_line(parsed['command'])
+ await self.protocol.execute_command(parsed['command'])
except websockets.exceptions.ConnectionClosedOK:
pass
@@ -117,19 +57,11 @@ class UiServer:
# -----------------------------------------------------------------------------
-async def protocol_loop(protocol):
- await protocol.initialize_service()
-
- while True:
- await (protocol.next_line())
-
-
-# -----------------------------------------------------------------------------
-def on_dlc(dlc):
+def on_dlc(dlc, configuration: hfp.Configuration):
print('*** DLC connected', dlc)
- protocol = HfpProtocol(dlc)
+ protocol = HfProtocol(dlc, configuration)
UiServer.protocol = protocol
- asyncio.create_task(protocol_loop(protocol))
+ asyncio.create_task(protocol.run())
# -----------------------------------------------------------------------------
@@ -143,6 +75,27 @@ async def main():
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
print('<<< connected')
+ # Hands-Free profile configuration.
+ # TODO: load configuration from file.
+ configuration = hfp.Configuration(
+ supported_hf_features=[
+ hfp.HfFeature.THREE_WAY_CALLING,
+ hfp.HfFeature.REMOTE_VOLUME_CONTROL,
+ hfp.HfFeature.ENHANCED_CALL_STATUS,
+ hfp.HfFeature.ENHANCED_CALL_CONTROL,
+ hfp.HfFeature.CODEC_NEGOTIATION,
+ hfp.HfFeature.HF_INDICATORS,
+ hfp.HfFeature.ESCO_S4_SETTINGS_SUPPORTED,
+ ],
+ supported_hf_indicators=[
+ hfp.HfIndicator.BATTERY_LEVEL,
+ ],
+ supported_audio_codecs=[
+ hfp.AudioCodec.CVSD,
+ hfp.AudioCodec.MSBC,
+ ],
+ )
+
# Create a device
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
device.classic_enabled = True
@@ -151,11 +104,13 @@ async def main():
rfcomm_server = RfcommServer(device)
# Listen for incoming DLC connections
- channel_number = rfcomm_server.listen(on_dlc)
+ channel_number = rfcomm_server.listen(lambda dlc: on_dlc(dlc, configuration))
print(f'### Listening for connection on channel {channel_number}')
# Advertise the HFP RFComm channel in the SDP
- device.sdp_service_records = make_sdp_records(channel_number)
+ device.sdp_service_records = {
+ 0x00010001: hfp.sdp_records(0x00010001, channel_number, configuration)
+ }
# Let's go!
await device.power_on()
diff --git a/examples/run_rfcomm_server.py b/examples/run_rfcomm_server.py
index 71feca9..41915a4 100644
--- a/examples/run_rfcomm_server.py
+++ b/examples/run_rfcomm_server.py
@@ -20,83 +20,109 @@ import sys
import os
import logging
+from bumble.core import UUID
from bumble.device import Device
from bumble.transport import open_transport_or_link
-from bumble.core import BT_L2CAP_PROTOCOL_ID, BT_RFCOMM_PROTOCOL_ID, UUID
from bumble.rfcomm import Server
-from bumble.sdp import (
- DataElement,
- ServiceAttribute,
- SDP_PUBLIC_BROWSE_ROOT,
- SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
- SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
- SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
- SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
-)
+from bumble.utils import AsyncRunner
+from bumble.rfcomm import make_service_sdp_records
# -----------------------------------------------------------------------------
-def sdp_records(channel):
+def sdp_records(channel, uuid):
+ service_record_handle = 0x00010001
return {
- 0x00010001: [
- ServiceAttribute(
- SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
- DataElement.unsigned_integer_32(0x00010001),
- ),
- ServiceAttribute(
- SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
- DataElement.sequence([DataElement.uuid(SDP_PUBLIC_BROWSE_ROOT)]),
- ),
- ServiceAttribute(
- SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
- DataElement.sequence(
- [DataElement.uuid(UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'))]
- ),
- ),
- 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(channel),
- ]
- ),
- ]
- ),
- ),
- ]
+ service_record_handle: make_service_sdp_records(
+ service_record_handle, channel, UUID(uuid)
+ )
}
# -----------------------------------------------------------------------------
-def on_dlc(dlc):
- print('*** DLC connected', dlc)
- dlc.sink = lambda data: on_rfcomm_data_received(dlc, data)
+def on_rfcomm_session(rfcomm_session, tcp_server):
+ print('*** RFComm session connected', rfcomm_session)
+ tcp_server.attach_session(rfcomm_session)
# -----------------------------------------------------------------------------
-def on_rfcomm_data_received(dlc, data):
- print(f'<<< Data received: {data.hex()}')
- try:
- message = data.decode('utf-8')
- print(f'<<< Message = {message}')
- except Exception:
- pass
+class TcpServerProtocol(asyncio.Protocol):
+ def __init__(self, server):
+ self.server = server
- # Echo everything back
- dlc.write(data)
+ def connection_made(self, transport):
+ peer_name = transport.get_extra_info('peer_name')
+ print(f'<<< TCP Server: connection from {peer_name}')
+ if self.server:
+ self.server.tcp_transport = transport
+ else:
+ transport.close()
+
+ def connection_lost(self, exc):
+ print('<<< TCP Server: connection lost')
+ if self.server:
+ self.server.tcp_transport = None
+
+ def data_received(self, data):
+ print(f'<<< TCP Server: data received: {len(data)} bytes - {data.hex()}')
+ if self.server:
+ self.server.tcp_data_received(data)
+
+
+# -----------------------------------------------------------------------------
+class TcpServer:
+ def __init__(self, port):
+ self.rfcomm_session = None
+ self.tcp_transport = None
+ AsyncRunner.spawn(self.run(port))
+
+ def attach_session(self, rfcomm_session):
+ if self.rfcomm_session:
+ self.rfcomm_session.sink = None
+
+ self.rfcomm_session = rfcomm_session
+ rfcomm_session.sink = self.rfcomm_data_received
+
+ def rfcomm_data_received(self, data):
+ print(f'<<< RFCOMM Data: {data.hex()}')
+ if self.tcp_transport:
+ self.tcp_transport.write(data)
+ else:
+ print('!!! no TCP connection, dropping data')
+
+ def tcp_data_received(self, data):
+ if self.rfcomm_session:
+ self.rfcomm_session.write(data)
+ else:
+ print('!!! no RFComm session, dropping data')
+
+ async def run(self, port):
+ print(f'$$$ Starting TCP server on port {port}')
+
+ server = await asyncio.get_running_loop().create_server(
+ lambda: TcpServerProtocol(self), '127.0.0.1', port
+ )
+
+ async with server:
+ await server.serve_forever()
# -----------------------------------------------------------------------------
async def main():
- if len(sys.argv) < 3:
- print('Usage: run_rfcomm_server.py <device-config> <transport-spec>')
- print('example: run_rfcomm_server.py classic2.json usb:04b4:f901')
+ if len(sys.argv) < 4:
+ print(
+ 'Usage: run_rfcomm_server.py <device-config> <transport-spec> '
+ '<tcp-port> [<uuid>]'
+ )
+ print('example: run_rfcomm_server.py classic2.json usb:0 8888')
return
+ tcp_port = int(sys.argv[3])
+
+ if len(sys.argv) >= 5:
+ uuid = sys.argv[4]
+ else:
+ uuid = 'E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'
+
print('<<< connecting to HCI...')
async with await open_transport_or_link(sys.argv[2]) as (hci_source, hci_sink):
print('<<< connected')
@@ -105,15 +131,20 @@ async def main():
device = Device.from_config_file_with_hci(sys.argv[1], hci_source, hci_sink)
device.classic_enabled = True
- # Create and register a server
+ # Create a TCP server
+ tcp_server = TcpServer(tcp_port)
+
+ # Create and register an RFComm server
rfcomm_server = Server(device)
# Listen for incoming DLC connections
- channel_number = rfcomm_server.listen(on_dlc)
- print(f'### Listening for connection on channel {channel_number}')
+ channel_number = rfcomm_server.listen(
+ lambda session: on_rfcomm_session(session, tcp_server)
+ )
+ print(f'### Listening for RFComm connections on channel {channel_number}')
# Setup the SDP to advertise this channel
- device.sdp_service_records = sdp_records(channel_number)
+ device.sdp_service_records = sdp_records(channel_number, uuid)
# Start the controller
await device.power_on()
diff --git a/rust/CHANGELOG.md b/rust/CHANGELOG.md
new file mode 100644
index 0000000..2cfed4e
--- /dev/null
+++ b/rust/CHANGELOG.md
@@ -0,0 +1,7 @@
+# Next
+
+- Code-gen company ID table
+
+# 0.1.0
+
+- Initial release \ No newline at end of file
diff --git a/rust/Cargo.lock b/rust/Cargo.lock
index 1492cfb..c2d0cd3 100644
--- a/rust/Cargo.lock
+++ b/rust/Cargo.lock
@@ -4,9 +4,9 @@ version = 3
[[package]]
name = "addr2line"
-version = "0.20.0"
+version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f4fa78e18c64fce05e902adecd7a5eed15a5e0a3439f7b0e169f0252214865e3"
+checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb"
dependencies = [
"gimli",
]
@@ -19,33 +19,32 @@ checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
[[package]]
name = "aho-corasick"
-version = "1.0.2"
+version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "43f6cb1bf222025340178f382c426f13757b2960e89779dfcb319c32542a5a41"
+checksum = "0c378d78423fdad8089616f827526ee33c19f2fddbd5de1629152c9593ba4783"
dependencies = [
"memchr",
]
[[package]]
name = "anstream"
-version = "0.3.2"
+version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0ca84f3628370c59db74ee214b3263d58f9aadd9b4fe7e711fd87dc452b7f163"
+checksum = "b1f58811cfac344940f1a400b6e6231ce35171f614f26439e80f8c1465c5cc0c"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
- "is-terminal",
"utf8parse",
]
[[package]]
name = "anstyle"
-version = "1.0.1"
+version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3a30da5c5f2d5e72842e00bcb57657162cdabef0931f40e2deb9b4140440cecd"
+checksum = "15c4c2c83f81532e5845a733998b6971faca23490340a418e9b72a3ec9de12ea"
[[package]]
name = "anstyle-parse"
@@ -67,9 +66,9 @@ dependencies = [
[[package]]
name = "anstyle-wincon"
-version = "1.0.1"
+version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "180abfa45703aebe0093f79badacc01b8fd4ea2e35118747e5811127f926e188"
+checksum = "58f54d10c6dfa51283a066ceab3ec1ab78d13fae00aa49243a45e4571fb79dfd"
dependencies = [
"anstyle",
"windows-sys",
@@ -77,9 +76,9 @@ dependencies = [
[[package]]
name = "anyhow"
-version = "1.0.72"
+version = "1.0.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b13c32d80ecc7ab747b80c3784bce54ee8a7a0cc4fbda9bf4cda2cf6fe90854"
+checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6"
[[package]]
name = "atty"
@@ -100,9 +99,9 @@ checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
[[package]]
name = "backtrace"
-version = "0.3.68"
+version = "0.3.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4319208da049c43661739c5fade2ba182f09d1dc2299b32298d3a31692b17e12"
+checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837"
dependencies = [
"addr2line",
"cc",
@@ -114,6 +113,12 @@ dependencies = [
]
[[package]]
+name = "base64"
+version = "0.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "414dcefbc63d77c526a76b3afcf6fbb9b5e2791c19c3aa2297733208750c6e53"
+
+[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -121,17 +126,31 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
-version = "2.3.3"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4682ae6287fcf752ecaabbfcc7b6f9b72aa33933dc23a554d853aea8eea8635"
+
+[[package]]
+name = "bstr"
+version = "1.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "630be753d4e58660abd17930c71b647fe46c27ea6b63cc59e1e3851406972e42"
+checksum = "4c2f7349907b712260e64b0afe2f84692af14a454be26187d9df565c7f69266a"
+dependencies = [
+ "memchr",
+ "serde",
+]
[[package]]
name = "bumble"
version = "0.1.0"
dependencies = [
"anyhow",
- "clap 4.3.17",
+ "clap 4.4.1",
+ "directories",
"env_logger",
+ "file-header",
+ "futures",
+ "globset",
"hex",
"itertools",
"lazy_static",
@@ -142,6 +161,7 @@ dependencies = [
"pyo3",
"pyo3-asyncio",
"rand",
+ "reqwest",
"rusb",
"strum",
"strum_macros",
@@ -151,6 +171,12 @@ dependencies = [
]
[[package]]
+name = "bumpalo"
+version = "3.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3e2c3daef883ecc1b5d58c15adae93470a91d425f3532ba1695849656af3fc1"
+
+[[package]]
name = "bytes"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -158,9 +184,12 @@ checksum = "89b2fd2a0dcf38d7971e2194b6b6eebab45ae01067456a7fd93d5547a61b70be"
[[package]]
name = "cc"
-version = "1.0.79"
+version = "1.0.83"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "50d30906286121d95be3d479533b458f87493b30a4b5f79a607db8f5d11aa91f"
+checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0"
+dependencies = [
+ "libc",
+]
[[package]]
name = "cfg-if"
@@ -185,9 +214,9 @@ dependencies = [
[[package]]
name = "clap"
-version = "4.3.17"
+version = "4.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5b0827b011f6f8ab38590295339817b0d26f344aa4932c3ced71b45b0c54b4a9"
+checksum = "7c8d502cbaec4595d2e7d5f61e318f05417bd2b66fdc3809498f0d3fdf0bea27"
dependencies = [
"clap_builder",
"clap_derive",
@@ -196,26 +225,26 @@ dependencies = [
[[package]]
name = "clap_builder"
-version = "4.3.17"
+version = "4.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9441b403be87be858db6a23edb493e7f694761acdc3343d5a0fcaafd304cbc9e"
+checksum = "5891c7bc0edb3e1c2204fc5e94009affabeb1821c9e5fdc3959536c5c0bb984d"
dependencies = [
"anstream",
"anstyle",
- "clap_lex 0.5.0",
+ "clap_lex 0.5.1",
"strsim",
]
[[package]]
name = "clap_derive"
-version = "4.3.12"
+version = "4.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "54a9bb5758fc5dfe728d1019941681eccaf0cf8a4189b692a0ee2f2ecf90a050"
+checksum = "c9fd1a5729c4548118d7d70ff234a44868d00489a4b6597b0b020918a0e91a1a"
dependencies = [
"heck",
"proc-macro2",
"quote",
- "syn 2.0.26",
+ "syn 2.0.29",
]
[[package]]
@@ -229,9 +258,9 @@ dependencies = [
[[package]]
name = "clap_lex"
-version = "0.5.0"
+version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2da6da31387c7e4ef160ffab6d5e7f00c42626fe39aea70a7b0f1773f7dd6c1b"
+checksum = "cd7cc57abe963c6d3b9d8be5b06ba7c8957a930305ca90304f24ef040aa6f961"
[[package]]
name = "colorchoice"
@@ -240,10 +269,123 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7"
[[package]]
+name = "core-foundation"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "194a7a9e6de53fa55116934067c844d9d749312f75c6f6d0980e8c252f8c2146"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa"
+
+[[package]]
+name = "crossbeam"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2801af0d36612ae591caa9568261fddce32ce6e08a7275ea334a06a4ad021a2c"
+dependencies = [
+ "cfg-if",
+ "crossbeam-channel",
+ "crossbeam-deque",
+ "crossbeam-epoch",
+ "crossbeam-queue",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a33c2bf77f2df06183c3aa30d1e96c0695a313d4f9c453cc3762a6db39f99200"
+dependencies = [
+ "cfg-if",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-deque"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce6fd6f855243022dcecf8702fef0c297d4338e226845fe067f6341ad9fa0cef"
+dependencies = [
+ "cfg-if",
+ "crossbeam-epoch",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-epoch"
+version = "0.9.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae211234986c545741a7dc064309f67ee1e5ad243d0e48335adc0484d960bcc7"
+dependencies = [
+ "autocfg",
+ "cfg-if",
+ "crossbeam-utils",
+ "memoffset 0.9.0",
+ "scopeguard",
+]
+
+[[package]]
+name = "crossbeam-queue"
+version = "0.3.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1cfb3ea8a53f37c40dea2c7bedcbd88bdfae54f5e2175d6ecaff1c988353add"
+dependencies = [
+ "cfg-if",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a22b2d63d4d1dc0b7f1b6b2747dd0088008a9be28b6ddf0b1e7d335e3037294"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "directories"
+version = "5.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35"
+dependencies = [
+ "dirs-sys",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
+dependencies = [
+ "libc",
+ "option-ext",
+ "redox_users",
+ "windows-sys",
+]
+
+[[package]]
name = "either"
-version = "1.8.1"
+version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91"
+checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07"
+
+[[package]]
+name = "encoding_rs"
+version = "0.8.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7268b386296a025e474d5140678f75d6de9493ae55a5d709eeb9dd08149945e1"
+dependencies = [
+ "cfg-if",
+]
[[package]]
name = "env_logger"
@@ -260,9 +402,9 @@ dependencies = [
[[package]]
name = "errno"
-version = "0.3.1"
+version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4bcfec3a70f97c962c307b2d2c56e358cf1d00b558d74262b5f929ee8cc7e73a"
+checksum = "136526188508e25c6fef639d7927dfb3e0e3084488bf202267829cf7fc23dbdd"
dependencies = [
"errno-dragonfly",
"libc",
@@ -281,11 +423,51 @@ dependencies = [
[[package]]
name = "fastrand"
-version = "1.9.0"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6999dc1837253364c2ebb0704ba97994bd874e8f195d665c50b7548f6ea92764"
+
+[[package]]
+name = "file-header"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5568149106e77ae33bc3a2c3ef3839cbe63ffa4a8dd4a81612a6f9dfdbc2e9f"
+dependencies = [
+ "crossbeam",
+ "lazy_static",
+ "license",
+ "thiserror",
+ "walkdir",
+]
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "foreign-types"
+version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be"
+checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
- "instant",
+ "foreign-types-shared",
+]
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a62bc1cf6f830c2ec14a513a9fb124d0a213a629668a4186f329db21fe045652"
+dependencies = [
+ "percent-encoding",
]
[[package]]
@@ -344,7 +526,7 @@ checksum = "89ca545a94061b6365f2c7355b4b32bd20df3ff95f02da9329b34ccc3bd6ee72"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.26",
+ "syn 2.0.29",
]
[[package]]
@@ -390,9 +572,41 @@ dependencies = [
[[package]]
name = "gimli"
-version = "0.27.3"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6fb8d784f27acf97159b40fc4db5ecd8aa23b9ad5ef69cdd136d3bc80665f0c0"
+
+[[package]]
+name = "globset"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "759c97c1e17c55525b57192c06a267cda0ac5210b222d6b82189a2338fa1c13d"
+dependencies = [
+ "aho-corasick",
+ "bstr",
+ "fnv",
+ "log",
+ "regex",
+]
+
+[[package]]
+name = "h2"
+version = "0.3.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6c80984affa11d98d1b88b66ac8853f143217b399d3c74116778ff8fdb4ed2e"
+checksum = "91fc23aa11be92976ef4729127f1a74adf36d8436f7816b185d18df956790833"
+dependencies = [
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "futures-util",
+ "http",
+ "indexmap",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
[[package]]
name = "hashbrown"
@@ -428,12 +642,93 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
+name = "http"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd6effc99afb63425aff9b05836f029929e345a6148a14b7ecd5ab67af944482"
+dependencies = [
+ "bytes",
+ "fnv",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1"
+dependencies = [
+ "bytes",
+ "http",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904"
+
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
+[[package]]
name = "humantime"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
[[package]]
+name = "hyper"
+version = "0.14.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ffb1cfd654a8219eaef89881fdb3bb3b1cdc5fa75ded05d6933b2b382e395468"
+dependencies = [
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "futures-util",
+ "h2",
+ "http",
+ "http-body",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "socket2 0.4.9",
+ "tokio",
+ "tower-service",
+ "tracing",
+ "want",
+]
+
+[[package]]
+name = "hyper-tls"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
+dependencies = [
+ "bytes",
+ "hyper",
+ "native-tls",
+ "tokio",
+ "tokio-native-tls",
+]
+
+[[package]]
+name = "idna"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c"
+dependencies = [
+ "unicode-bidi",
+ "unicode-normalization",
+]
+
+[[package]]
name = "indexmap"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -450,30 +745,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa799dd5ed20a7e349f3b4639aa80d74549c81716d9ec4f994c9b5815598306"
[[package]]
-name = "instant"
-version = "0.1.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c"
-dependencies = [
- "cfg-if",
-]
-
-[[package]]
name = "inventory"
-version = "0.3.9"
+version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "25b1d6b4b9fb75fc419bdef998b689df5080a32931cb3395b86202046b56a9ea"
+checksum = "e1be380c410bf0595e94992a648ea89db4dd3f3354ba54af206fd2a68cf5ac8e"
[[package]]
-name = "io-lifetimes"
-version = "1.0.11"
+name = "ipnet"
+version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eae7b9aee968036d54dce06cebaefd919e4472e753296daccd6d344e3e2df0c2"
-dependencies = [
- "hermit-abi 0.3.2",
- "libc",
- "windows-sys",
-]
+checksum = "28b29a3cd74f0f4598934efe3aeba42bae0eb4680554128851ebbecb02af14e6"
[[package]]
name = "is-terminal"
@@ -482,7 +763,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b"
dependencies = [
"hermit-abi 0.3.2",
- "rustix 0.38.4",
+ "rustix",
"windows-sys",
]
@@ -496,6 +777,21 @@ dependencies = [
]
[[package]]
+name = "itoa"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38"
+
+[[package]]
+name = "js-sys"
+version = "0.3.64"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c5f195fe497f702db0f318b07fdd68edb16955aed830df8363d837542f8f935a"
+dependencies = [
+ "wasm-bindgen",
+]
+
+[[package]]
name = "lazy_static"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -520,16 +816,21 @@ dependencies = [
]
[[package]]
-name = "linux-raw-sys"
-version = "0.3.8"
+name = "license"
+version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ef53942eb7bf7ff43a617b3e2c1c4a5ecf5944a7c1bc12d7ee39bbb15e5c1519"
+checksum = "b66615d42e949152327c402e03cd29dab8bff91ce470381ac2ca6d380d8d9946"
+dependencies = [
+ "reword",
+ "serde",
+ "serde_json",
+]
[[package]]
name = "linux-raw-sys"
-version = "0.4.3"
+version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "09fc20d2ca12cb9f044c93e3bd6d32d523e6e2ec3db4f7b2939cd99026ecd3f0"
+checksum = "57bcfdad1b858c2db7c38303a6d2ad4dfaf5eb53dfeb0910128b2c26d6158503"
[[package]]
name = "lock_api"
@@ -543,15 +844,15 @@ dependencies = [
[[package]]
name = "log"
-version = "0.4.19"
+version = "0.4.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b06a4cde4c0f271a446782e3eff8de789548ce57dbc8eca9292c27f4a42004b4"
+checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f"
[[package]]
name = "memchr"
-version = "2.5.0"
+version = "2.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d"
+checksum = "5486aed0026218e61b8a01d5fbd5a0a134649abb71a0e53b7bc088529dced86e"
[[package]]
name = "memoffset"
@@ -572,6 +873,21 @@ dependencies = [
]
[[package]]
+name = "memoffset"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a634b1c61a95585bd15607c6ab0c4e5b226e695ff2800ba0cdccddf208c406c"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -598,17 +914,34 @@ dependencies = [
]
[[package]]
+name = "native-tls"
+version = "0.2.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07226173c32f2926027b63cce4bcd8076c3552846cbe7925f3aaffeac0a3b92e"
+dependencies = [
+ "lazy_static",
+ "libc",
+ "log",
+ "openssl",
+ "openssl-probe",
+ "openssl-sys",
+ "schannel",
+ "security-framework",
+ "security-framework-sys",
+ "tempfile",
+]
+
+[[package]]
name = "nix"
-version = "0.26.2"
+version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bfdda3d196821d6af13126e40375cdf7da646a96114af134d5f417a9a1dc8e1a"
+checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b"
dependencies = [
"bitflags 1.3.2",
"cfg-if",
"libc",
"memoffset 0.7.1",
"pin-utils",
- "static_assertions",
]
[[package]]
@@ -633,9 +966,9 @@ dependencies = [
[[package]]
name = "object"
-version = "0.31.1"
+version = "0.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8bda667d9f2b5051b8833f59f3bf748b28ef54f850f4fcb389a252aa383866d1"
+checksum = "77ac5bbd07aea88c60a577a1ce218075ffd59208b2d7ca97adf9bfc5aeb21ebe"
dependencies = [
"memchr",
]
@@ -647,6 +980,56 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d"
[[package]]
+name = "openssl"
+version = "0.10.57"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bac25ee399abb46215765b1cb35bc0212377e58a061560d8b29b024fd0430e7c"
+dependencies = [
+ "bitflags 2.4.0",
+ "cfg-if",
+ "foreign-types",
+ "libc",
+ "once_cell",
+ "openssl-macros",
+ "openssl-sys",
+]
+
+[[package]]
+name = "openssl-macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.29",
+]
+
+[[package]]
+name = "openssl-probe"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf"
+
+[[package]]
+name = "openssl-sys"
+version = "0.9.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db7e971c2c2bba161b2d2fdf37080177eff520b3bc044787c7f1f5f9e78d869b"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "option-ext"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+
+[[package]]
name = "os_str_bytes"
version = "6.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -676,16 +1059,22 @@ checksum = "93f00c865fe7cabf650081affecd3871070f26767e7b2070a3ffae14c654b447"
dependencies = [
"cfg-if",
"libc",
- "redox_syscall",
+ "redox_syscall 0.3.5",
"smallvec",
"windows-targets",
]
[[package]]
+name = "percent-encoding"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94"
+
+[[package]]
name = "pin-project-lite"
-version = "0.2.10"
+version = "0.2.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4c40d25201921e5ff0c862a505c6557ea88568a4e3ace775ab55e93f2f4f9d57"
+checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58"
[[package]]
name = "pin-utils"
@@ -804,9 +1193,9 @@ dependencies = [
[[package]]
name = "quote"
-version = "1.0.31"
+version = "1.0.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5fe8a65d69dd0808184ebb5f836ab526bb259db23c657efa38711b1072ee47f0"
+checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae"
dependencies = [
"proc-macro2",
]
@@ -843,6 +1232,15 @@ dependencies = [
[[package]]
name = "redox_syscall"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a"
+dependencies = [
+ "bitflags 1.3.2",
+]
+
+[[package]]
+name = "redox_syscall"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29"
@@ -851,10 +1249,21 @@ dependencies = [
]
[[package]]
+name = "redox_users"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b"
+dependencies = [
+ "getrandom",
+ "redox_syscall 0.2.16",
+ "thiserror",
+]
+
+[[package]]
name = "regex"
-version = "1.9.1"
+version = "1.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b2eae68fc220f7cf2532e4494aded17545fce192d59cd996e0fe7887f4ceb575"
+checksum = "12de2eff854e5fa4b1295edd650e227e9d8fb0c9e90b12e7f36d6a6811791a29"
dependencies = [
"aho-corasick",
"memchr",
@@ -864,9 +1273,9 @@ dependencies = [
[[package]]
name = "regex-automata"
-version = "0.3.3"
+version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "39354c10dd07468c2e73926b23bb9c2caca74c5501e38a35da70406f1d923310"
+checksum = "49530408a136e16e5b486e883fbb6ba058e8e4e8ae6621a77b048b314336e629"
dependencies = [
"aho-corasick",
"memchr",
@@ -875,15 +1284,61 @@ dependencies = [
[[package]]
name = "regex-syntax"
-version = "0.7.4"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbb5fb1acd8a1a18b3dd5be62d25485eb770e05afb408a9627d14d451bae12da"
+
+[[package]]
+name = "reqwest"
+version = "0.11.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e5ea92a5b6195c6ef2a0295ea818b312502c6fc94dde986c5553242e18fd4ce2"
+checksum = "3e9ad3fe7488d7e34558a2033d45a0c90b72d97b4f80705666fea71472e2e6a1"
+dependencies = [
+ "base64",
+ "bytes",
+ "encoding_rs",
+ "futures-core",
+ "futures-util",
+ "h2",
+ "http",
+ "http-body",
+ "hyper",
+ "hyper-tls",
+ "ipnet",
+ "js-sys",
+ "log",
+ "mime",
+ "native-tls",
+ "once_cell",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "tokio",
+ "tokio-native-tls",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "winreg",
+]
+
+[[package]]
+name = "reword"
+version = "7.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe272098dce9ed76b479995953f748d1851261390b08f8a0ff619c885a1f0765"
+dependencies = [
+ "unicode-segmentation",
+]
[[package]]
name = "rusb"
-version = "0.9.2"
+version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "44a8c36914f9b1a3be712c1dfa48c9b397131f9a75707e570a391735f785c5d1"
+checksum = "45fff149b6033f25e825cbb7b2c625a11ee8e6dac09264d49beb125e39aa97bf"
dependencies = [
"libc",
"libusb1-sys",
@@ -897,36 +1352,46 @@ checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76"
[[package]]
name = "rustix"
-version = "0.37.23"
+version = "0.38.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4d69718bf81c6127a49dc64e44a742e8bb9213c0ff8869a22c308f84c1d4ab06"
+checksum = "ed6248e1caa625eb708e266e06159f135e8c26f2bb7ceb72dc4b2766d0340964"
dependencies = [
- "bitflags 1.3.2",
+ "bitflags 2.4.0",
"errno",
- "io-lifetimes",
"libc",
- "linux-raw-sys 0.3.8",
+ "linux-raw-sys",
"windows-sys",
]
[[package]]
-name = "rustix"
-version = "0.38.4"
+name = "rustversion"
+version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0a962918ea88d644592894bc6dc55acc6c0956488adcebbfb6e273506b7fd6e5"
+checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4"
+
+[[package]]
+name = "ryu"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ad4cc8da4ef723ed60bced201181d83791ad433213d8c24efffda1eec85d741"
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
- "bitflags 2.3.3",
- "errno",
- "libc",
- "linux-raw-sys 0.4.3",
- "windows-sys",
+ "winapi-util",
]
[[package]]
-name = "rustversion"
-version = "1.0.14"
+name = "schannel"
+version = "0.1.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4"
+checksum = "0c3733bf4cf7ea0880754e19cb5a462007c4a8c1914bff372ccc95b464f1df88"
+dependencies = [
+ "windows-sys",
+]
[[package]]
name = "scopeguard"
@@ -935,6 +1400,72 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
+name = "security-framework"
+version = "2.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05b64fb303737d99b81884b2c63433e9ae28abebe5eb5045dcdd175dc2ecf4de"
+dependencies = [
+ "bitflags 1.3.2",
+ "core-foundation",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e932934257d3b408ed8f30db49d85ea163bfe74961f017f405b025af298f0c7a"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.188"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf9e0fcba69a370eed61bcf2b728575f726b50b55cba78064753d708ddc7549e"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.188"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4eca7ac642d82aa35b60049a6eccb4be6be75e599bd2e9adb5f875a737654af2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.29",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.105"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "693151e1ac27563d6dbcec9dee9fbd5da8539b20fa14ad3752b2e6d363ace360"
+dependencies = [
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
name = "signal-hook-registry"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -945,9 +1476,9 @@ dependencies = [
[[package]]
name = "slab"
-version = "0.4.8"
+version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6528351c9bc8ab22353f9d776db39a20288e8d6c37ef8cfe3317cf875eecfc2d"
+checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67"
dependencies = [
"autocfg",
]
@@ -969,10 +1500,14 @@ dependencies = [
]
[[package]]
-name = "static_assertions"
-version = "1.1.0"
+name = "socket2"
+version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+checksum = "2538b18701741680e0322a2302176d3253a35388e2e62f172f64f4f16605f877"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
[[package]]
name = "strsim"
@@ -988,15 +1523,15 @@ checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125"
[[package]]
name = "strum_macros"
-version = "0.25.1"
+version = "0.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6069ca09d878a33f883cc06aaa9718ede171841d3832450354410b718b097232"
+checksum = "ad8d03b598d3d0fff69bf533ee3ef19b8eeb342729596df84bcc7e1f96ec4059"
dependencies = [
"heck",
"proc-macro2",
"quote",
"rustversion",
- "syn 2.0.26",
+ "syn 2.0.29",
]
[[package]]
@@ -1012,9 +1547,9 @@ dependencies = [
[[package]]
name = "syn"
-version = "2.0.26"
+version = "2.0.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "45c3457aacde3c65315de5031ec191ce46604304d2446e803d71ade03308d970"
+checksum = "c324c494eba9d92503e6f1ef2e6df781e78f6a7705a0202d9801b198807d518a"
dependencies = [
"proc-macro2",
"quote",
@@ -1023,21 +1558,20 @@ dependencies = [
[[package]]
name = "target-lexicon"
-version = "0.12.10"
+version = "0.12.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1d2faeef5759ab89935255b1a4cd98e0baf99d1085e37d36599c625dac49ae8e"
+checksum = "9d0e916b1148c8e263850e1ebcbd046f333e0683c724876bb0da63ea4373dc8a"
[[package]]
name = "tempfile"
-version = "3.6.0"
+version = "3.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "31c0432476357e58790aaa47a8efb0c5138f137343f3b5f23bd36a27e3b0a6d6"
+checksum = "cb94d2f3cc536af71caac6b6fcebf65860b347e7ce0cc9ebe8f70d3e521054ef"
dependencies = [
- "autocfg",
"cfg-if",
"fastrand",
- "redox_syscall",
- "rustix 0.37.23",
+ "redox_syscall 0.3.5",
+ "rustix",
"windows-sys",
]
@@ -1058,31 +1592,45 @@ checksum = "222a222a5bfe1bba4a77b45ec488a741b3cb8872e5e499451fd7d0129c9c7c3d"
[[package]]
name = "thiserror"
-version = "1.0.43"
+version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a35fc5b8971143ca348fa6df4f024d4d55264f3468c71ad1c2f365b0a4d58c42"
+checksum = "97a802ec30afc17eee47b2855fc72e0c4cd62be9b4efe6591edde0ec5bd68d8f"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
-version = "1.0.43"
+version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "463fe12d7993d3b327787537ce8dd4dfa058de32fc2b195ef3cde03dc4771e8f"
+checksum = "6bb623b56e39ab7dcd4b1b98bb6c8f8d907ed255b18de254088016b27a8ee19b"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.26",
+ "syn 2.0.29",
]
[[package]]
+name = "tinyvec"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
name = "tokio"
-version = "1.29.1"
+version = "1.32.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "532826ff75199d5833b9d2c5fe410f29235e25704ee5f0ef599fb51c21f4a4da"
+checksum = "17ed6077ed6cd6c74735e21f37eb16dc3935f96878b1fe961074089cc80893f9"
dependencies = [
- "autocfg",
"backtrace",
"bytes",
"libc",
@@ -1091,7 +1639,7 @@ dependencies = [
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
- "socket2",
+ "socket2 0.5.3",
"tokio-macros",
"windows-sys",
]
@@ -1104,22 +1652,110 @@ checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e"
dependencies = [
"proc-macro2",
"quote",
- "syn 2.0.26",
+ "syn 2.0.29",
+]
+
+[[package]]
+name = "tokio-native-tls"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
+dependencies = [
+ "native-tls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "806fe8c2c87eccc8b3267cbae29ed3ab2d0bd37fca70ab622e46aaa9375ddb7d"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+ "tracing",
+]
+
+[[package]]
+name = "tower-service"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52"
+
+[[package]]
+name = "tracing"
+version = "0.1.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ce8c33a8d48bd45d624a6e523445fd21ec13d3653cd51f681abf67418f54eb8"
+dependencies = [
+ "cfg-if",
+ "pin-project-lite",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0955b8137a1df6f1a2e9a37d8a6656291ff0297c1a97c24e0d8425fe2312f79a"
+dependencies = [
+ "once_cell",
]
[[package]]
+name = "try-lock"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed"
+
+[[package]]
+name = "unicode-bidi"
+version = "0.3.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92888ba5573ff080736b3648696b70cafad7d250551175acbaa4e0385b3e1460"
+
+[[package]]
name = "unicode-ident"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "301abaae475aa91687eb82514b328ab47a211a533026cb25fc3e519b86adfc3c"
[[package]]
+name = "unicode-normalization"
+version = "0.1.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36"
+
+[[package]]
name = "unindent"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1766d682d402817b5ac4490b3c3002d91dfa0d22812f341609f97b08757359c"
[[package]]
+name = "url"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "143b538f18257fac9cad154828a57c6bf5157e1aa604d4816b5995bf6de87ae5"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+]
+
+[[package]]
name = "utf8parse"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1132,12 +1768,107 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
+name = "walkdir"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d71d857dc86794ca4c280d616f7da00d2dbfd8cd788846559a6813e6aa4b54ee"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
name = "wasi"
version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]]
+name = "wasm-bindgen"
+version = "0.2.87"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7706a72ab36d8cb1f80ffbf0e071533974a60d0a308d01a5d0375bf60499a342"
+dependencies = [
+ "cfg-if",
+ "wasm-bindgen-macro",
+]
+
+[[package]]
+name = "wasm-bindgen-backend"
+version = "0.2.87"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ef2b6d3c510e9625e5fe6f509ab07d66a760f0885d858736483c32ed7809abd"
+dependencies = [
+ "bumpalo",
+ "log",
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.29",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c02dbc21516f9f1f04f187958890d7e6026df8d16540b7ad9492bc34a67cea03"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.87"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dee495e55982a3bd48105a7b947fd2a9b4a8ae3010041b9e0faab3f9cd028f1d"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.87"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "54681b18a46765f095758388f2d0cf16eb8d4169b639ab575a8f5693af210c7b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.29",
+ "wasm-bindgen-backend",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.87"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca6ad05a4870b2bf5fe995117d3728437bd27d7cd5f06f13c17443ef369775a1"
+
+[[package]]
+name = "web-sys"
+version = "0.3.64"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b85cbef8c220a6abc02aefd892dfc0fc23afb1c6a426316ec33253a3877249b"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1179,9 +1910,9 @@ dependencies = [
[[package]]
name = "windows-targets"
-version = "0.48.1"
+version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "05d4b17490f70499f20b9e791dcf6a299785ce8af4d709018206dc5b4953e95f"
+checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
@@ -1194,42 +1925,52 @@ dependencies = [
[[package]]
name = "windows_aarch64_gnullvm"
-version = "0.48.0"
+version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "91ae572e1b79dba883e0d315474df7305d12f569b400fcf90581b06062f7e1bc"
+checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_msvc"
-version = "0.48.0"
+version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b2ef27e0d7bdfcfc7b868b317c1d32c641a6fe4629c171b8928c7b08d98d7cf3"
+checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_i686_gnu"
-version = "0.48.0"
+version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "622a1962a7db830d6fd0a69683c80a18fda201879f0f447f065a3b7467daa241"
+checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_msvc"
-version = "0.48.0"
+version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4542c6e364ce21bf45d69fdd2a8e455fa38d316158cfd43b3ac1c5b1b19f8e00"
+checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_x86_64_gnu"
-version = "0.48.0"
+version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ca2b8a661f7628cbd23440e50b05d705db3686f894fc9580820623656af974b1"
+checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnullvm"
-version = "0.48.0"
+version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7896dbc1f41e08872e9d5e8f8baa8fdd2677f29468c4e156210174edc7f7b953"
+checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_msvc"
-version = "0.48.0"
+version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1a515f5799fe4961cb532f983ce2b23082366b898e52ffbce459c86f67c8378a"
+checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
+
+[[package]]
+name = "winreg"
+version = "0.50.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1"
+dependencies = [
+ "cfg-if",
+ "windows-sys",
+]
diff --git a/rust/Cargo.toml b/rust/Cargo.toml
index 8b7c723..a553afd 100644
--- a/rust/Cargo.toml
+++ b/rust/Cargo.toml
@@ -10,12 +10,12 @@ documentation = "https://docs.rs/crate/bumble"
authors = ["Marshall Pierce <marshallpierce@google.com>"]
keywords = ["bluetooth", "ble"]
categories = ["api-bindings", "network-programming"]
-rust-version = "1.69.0"
+rust-version = "1.70.0"
[dependencies]
pyo3 = { version = "0.18.3", features = ["macros"] }
pyo3-asyncio = { version = "0.18.0", features = ["tokio-runtime"] }
-tokio = { version = "1.28.2" }
+tokio = { version = "1.28.2", features = ["macros", "signal"] }
nom = "7.1.3"
strum = "0.25.0"
strum_macros = "0.25.0"
@@ -24,6 +24,21 @@ itertools = "0.11.0"
lazy_static = "1.4.0"
thiserror = "1.0.41"
+# Dev tools
+file-header = { version = "0.1.2", optional = true }
+globset = { version = "0.4.13", optional = true }
+
+# CLI
+anyhow = { version = "1.0.71", optional = true }
+clap = { version = "4.3.3", features = ["derive"], optional = true }
+directories = { version = "5.0.1", optional = true }
+env_logger = { version = "0.10.0", optional = true }
+futures = { version = "0.3.28", optional = true }
+log = { version = "0.4.19", optional = true }
+owo-colors = { version = "3.5.0", optional = true }
+reqwest = { version = "0.11.20", features = ["blocking"], optional = true }
+rusb = { version = "0.9.2", optional = true }
+
[dev-dependencies]
tokio = { version = "1.28.2", features = ["full"] }
tempfile = "3.6.0"
@@ -31,12 +46,30 @@ nix = "0.26.2"
anyhow = "1.0.71"
pyo3 = { version = "0.18.3", features = ["macros", "anyhow"] }
pyo3-asyncio = { version = "0.18.0", features = ["tokio-runtime", "attributes", "testing"] }
+rusb = "0.9.2"
+rand = "0.8.5"
clap = { version = "4.3.3", features = ["derive"] }
owo-colors = "3.5.0"
log = "0.4.19"
env_logger = "0.10.0"
-rusb = "0.9.2"
-rand = "0.8.5"
+
+[package.metadata.docs.rs]
+rustdoc-args = ["--generate-link-to-definition"]
+
+[[bin]]
+name = "file-header"
+path = "tools/file_header.rs"
+required-features = ["dev-tools"]
+
+[[bin]]
+name = "gen-assigned-numbers"
+path = "tools/gen_assigned_numbers.rs"
+required-features = ["dev-tools"]
+
+[[bin]]
+name = "bumble"
+path = "src/main.rs"
+required-features = ["bumble-tools"]
# test entry point that uses pyo3_asyncio's test harness
[[test]]
@@ -46,4 +79,8 @@ harness = false
[features]
anyhow = ["pyo3/anyhow"]
-pyo3-asyncio-attributes = ["pyo3-asyncio/attributes"] \ No newline at end of file
+pyo3-asyncio-attributes = ["pyo3-asyncio/attributes"]
+dev-tools = ["dep:anyhow", "dep:clap", "dep:file-header", "dep:globset"]
+# separate feature for CLI so that dependencies don't spend time building these
+bumble-tools = ["dep:clap", "anyhow", "dep:anyhow", "dep:directories", "pyo3-asyncio-attributes", "dep:owo-colors", "dep:reqwest", "dep:rusb", "dep:log", "dep:env_logger", "dep:futures"]
+default = []
diff --git a/rust/README.md b/rust/README.md
index fd591c7..15a19b9 100644
--- a/rust/README.md
+++ b/rust/README.md
@@ -5,7 +5,8 @@ Rust wrappers around the [Bumble](https://github.com/google/bumble) Python API.
Method calls are mapped to the equivalent Python, and return types adapted where
relevant.
-See the `examples` directory for usage.
+See the CLI in `src/main.rs` or the `examples` directory for how to use the
+Bumble API.
# Usage
@@ -27,6 +28,15 @@ PYTHONPATH=..:~/.virtualenvs/bumble/lib/python3.10/site-packages/ \
Run the corresponding `battery_server` Python example, and launch an emulator in
Android Studio (currently, Canary is required) to run netsim.
+# CLI
+
+Explore the available subcommands:
+
+```
+PYTHONPATH=..:[virtualenv site-packages] \
+ cargo run --features bumble-tools --bin bumble -- --help
+```
+
# Development
Run the tests:
@@ -39,4 +49,18 @@ Check lints:
```
cargo clippy --all-targets
+```
+
+## Code gen
+
+To have the fastest startup while keeping the build simple, code gen for
+assigned numbers is done with the `gen_assigned_numbers` tool. It should
+be re-run whenever the Python assigned numbers are changed. To ensure that the
+generated code is kept up to date, the Rust data is compared to the Python
+in tests at `pytests/assigned_numbers.rs`.
+
+To regenerate the assigned number tables based on the Python codebase:
+
+```
+PYTHONPATH=.. cargo run --bin gen-assigned-numbers --features dev-tools
``` \ No newline at end of file
diff --git a/rust/pytests/assigned_numbers.rs b/rust/pytests/assigned_numbers.rs
new file mode 100644
index 0000000..7f8f1d1
--- /dev/null
+++ b/rust/pytests/assigned_numbers.rs
@@ -0,0 +1,44 @@
+// 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
+//
+// http://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.
+
+use bumble::wrapper::{self, core::Uuid16};
+use pyo3::{intern, prelude::*, types::PyDict};
+use std::collections;
+
+#[pyo3_asyncio::tokio::test]
+async fn company_ids_matches_python() -> PyResult<()> {
+ let ids_from_python = Python::with_gil(|py| {
+ PyModule::import(py, intern!(py, "bumble.company_ids"))?
+ .getattr(intern!(py, "COMPANY_IDENTIFIERS"))?
+ .downcast::<PyDict>()?
+ .into_iter()
+ .map(|(k, v)| {
+ Ok((
+ Uuid16::from_be_bytes(k.extract::<u16>()?.to_be_bytes()),
+ v.str()?.to_str()?.to_string(),
+ ))
+ })
+ .collect::<PyResult<collections::HashMap<_, _>>>()
+ })?;
+
+ assert_eq!(
+ wrapper::assigned_numbers::COMPANY_IDS
+ .iter()
+ .map(|(id, name)| (*id, name.to_string()))
+ .collect::<collections::HashMap<_, _>>(),
+ ids_from_python,
+ "Company ids do not match -- re-run gen_assigned_numbers?"
+ );
+ Ok(())
+}
diff --git a/rust/pytests/pytests.rs b/rust/pytests/pytests.rs
index da331f3..4a30e8d 100644
--- a/rust/pytests/pytests.rs
+++ b/rust/pytests/pytests.rs
@@ -17,4 +17,5 @@ async fn main() -> pyo3::PyResult<()> {
pyo3_asyncio::testing::main().await
}
+mod assigned_numbers;
mod wrapper;
diff --git a/rust/pytests/wrapper.rs b/rust/pytests/wrapper.rs
index 1c1f9d0..8f69dd7 100644
--- a/rust/pytests/wrapper.rs
+++ b/rust/pytests/wrapper.rs
@@ -12,9 +12,9 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-use bumble::{wrapper, wrapper::transport::Transport};
+use bumble::wrapper::{drivers::rtk::DriverInfo, transport::Transport};
use nix::sys::stat::Mode;
-use pyo3::prelude::*;
+use pyo3::PyResult;
#[pyo3_asyncio::tokio::test]
async fn fifo_transport_can_open() -> PyResult<()> {
@@ -31,7 +31,7 @@ async fn fifo_transport_can_open() -> PyResult<()> {
}
#[pyo3_asyncio::tokio::test]
-async fn company_ids() -> PyResult<()> {
- assert!(wrapper::assigned_numbers::COMPANY_IDS.len() > 2000);
+async fn realtek_driver_info_all_drivers() -> PyResult<()> {
+ assert_eq!(12, DriverInfo::all_drivers()?.len());
Ok(())
}
diff --git a/rust/resources/test/firmware/realtek/README.md b/rust/resources/test/firmware/realtek/README.md
new file mode 100644
index 0000000..4c49608
--- /dev/null
+++ b/rust/resources/test/firmware/realtek/README.md
@@ -0,0 +1,4 @@
+This dir contains samples firmware images in the format used for Realtek chips,
+but with repetitions of the length of the section as a little-endian 32-bit int
+for the patch data instead of actual firmware, since we only need the structure
+to test parsing. \ No newline at end of file
diff --git a/rust/resources/test/firmware/realtek/rtl8723b_fw_structure.bin b/rust/resources/test/firmware/realtek/rtl8723b_fw_structure.bin
new file mode 100644
index 0000000..077cdc3
--- /dev/null
+++ b/rust/resources/test/firmware/realtek/rtl8723b_fw_structure.bin
Binary files differ
diff --git a/rust/resources/test/firmware/realtek/rtl8761bu_fw_structure.bin b/rust/resources/test/firmware/realtek/rtl8761bu_fw_structure.bin
new file mode 100644
index 0000000..94df0ba
--- /dev/null
+++ b/rust/resources/test/firmware/realtek/rtl8761bu_fw_structure.bin
Binary files differ
diff --git a/rust/src/adv.rs b/rust/src/adv.rs
index 8a4c979..6f84cc5 100644
--- a/rust/src/adv.rs
+++ b/rust/src/adv.rs
@@ -1,3 +1,17 @@
+// 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
+//
+// http://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.
+
//! BLE advertisements.
use crate::wrapper::assigned_numbers::{COMPANY_IDS, SERVICE_IDS};
diff --git a/rust/src/cli/firmware/mod.rs b/rust/src/cli/firmware/mod.rs
new file mode 100644
index 0000000..1fa1417
--- /dev/null
+++ b/rust/src/cli/firmware/mod.rs
@@ -0,0 +1,15 @@
+// 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
+//
+// http://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.
+
+pub(crate) mod rtk;
diff --git a/rust/src/cli/firmware/rtk.rs b/rust/src/cli/firmware/rtk.rs
new file mode 100644
index 0000000..f5524a4
--- /dev/null
+++ b/rust/src/cli/firmware/rtk.rs
@@ -0,0 +1,265 @@
+// 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
+//
+// http://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.
+
+//! Realtek firmware tools
+
+use crate::{Download, Source};
+use anyhow::anyhow;
+use bumble::wrapper::{
+ drivers::rtk::{Driver, DriverInfo, Firmware},
+ host::{DriverFactory, Host},
+ transport::Transport,
+};
+use owo_colors::{colors::css, OwoColorize};
+use pyo3::PyResult;
+use std::{fs, path};
+
+pub(crate) async fn download(dl: Download) -> PyResult<()> {
+ let data_dir = dl
+ .output_dir
+ .or_else(|| {
+ directories::ProjectDirs::from("com", "google", "bumble")
+ .map(|pd| pd.data_local_dir().join("firmware").join("realtek"))
+ })
+ .unwrap_or_else(|| {
+ eprintln!("Could not determine standard data directory");
+ path::PathBuf::from(".")
+ });
+ fs::create_dir_all(&data_dir)?;
+
+ let (base_url, uses_bin_suffix) = match dl.source {
+ Source::LinuxKernel => ("https://git.kernel.org/pub/scm/linux/kernel/git/firmware/linux-firmware.git/plain/rtl_bt", true),
+ Source::RealtekOpensource => ("https://github.com/Realtek-OpenSource/android_hardware_realtek/raw/rtk1395/bt/rtkbt/Firmware/BT", false),
+ Source::LinuxFromScratch => ("https://anduin.linuxfromscratch.org/sources/linux-firmware/rtl_bt", true),
+ };
+
+ println!("Downloading");
+ println!("{} {}", "FROM:".green(), base_url);
+ println!("{} {}", "TO:".green(), data_dir.to_string_lossy());
+
+ let url_for_file = |file_name: &str| {
+ let url_suffix = if uses_bin_suffix {
+ file_name
+ } else {
+ file_name.trim_end_matches(".bin")
+ };
+
+ let mut url = base_url.to_string();
+ url.push('/');
+ url.push_str(url_suffix);
+ url
+ };
+
+ let to_download = if let Some(single) = dl.single {
+ vec![(
+ format!("{single}_fw.bin"),
+ Some(format!("{single}_config.bin")),
+ false,
+ )]
+ } else {
+ DriverInfo::all_drivers()?
+ .iter()
+ .map(|di| Ok((di.firmware_name()?, di.config_name()?, di.config_needed()?)))
+ .collect::<PyResult<Vec<_>>>()?
+ };
+
+ let client = SimpleClient::new();
+
+ for (fw_filename, config_filename, config_needed) in to_download {
+ println!("{}", "---".yellow());
+ let fw_path = data_dir.join(&fw_filename);
+ let config_path = config_filename.as_ref().map(|f| data_dir.join(f));
+
+ if fw_path.exists() && !dl.overwrite {
+ println!(
+ "{}",
+ format!("{} already exists, skipping", fw_path.to_string_lossy())
+ .fg::<css::Orange>()
+ );
+ continue;
+ }
+ if let Some(cp) = config_path.as_ref() {
+ if cp.exists() && !dl.overwrite {
+ println!(
+ "{}",
+ format!("{} already exists, skipping", cp.to_string_lossy())
+ .fg::<css::Orange>()
+ );
+ continue;
+ }
+ }
+
+ let fw_contents = match client.get(&url_for_file(&fw_filename)).await {
+ Ok(data) => {
+ println!("Downloaded {}: {} bytes", fw_filename, data.len());
+ data
+ }
+ Err(e) => {
+ eprintln!(
+ "{} {} {:?}",
+ "Failed to download".red(),
+ fw_filename.red(),
+ e
+ );
+ continue;
+ }
+ };
+
+ let config_contents = if let Some(cn) = &config_filename {
+ match client.get(&url_for_file(cn)).await {
+ Ok(data) => {
+ println!("Downloaded {}: {} bytes", cn, data.len());
+ Some(data)
+ }
+ Err(e) => {
+ if config_needed {
+ eprintln!("{} {} {:?}", "Failed to download".red(), cn.red(), e);
+ continue;
+ } else {
+ eprintln!(
+ "{}",
+ format!("No config available as {cn}").fg::<css::Orange>()
+ );
+ None
+ }
+ }
+ }
+ } else {
+ None
+ };
+
+ fs::write(&fw_path, &fw_contents)?;
+ if !dl.no_parse && config_filename.is_some() {
+ println!("{} {}", "Parsing:".cyan(), &fw_filename);
+ match Firmware::parse(&fw_contents).map_err(|e| anyhow!("Parse error: {:?}", e)) {
+ Ok(fw) => dump_firmware_desc(&fw),
+ Err(e) => {
+ eprintln!(
+ "{} {:?}",
+ "Could not parse firmware:".fg::<css::Orange>(),
+ e
+ );
+ }
+ }
+ }
+ if let Some((cp, cd)) = config_path
+ .as_ref()
+ .and_then(|p| config_contents.map(|c| (p, c)))
+ {
+ fs::write(cp, &cd)?;
+ }
+ }
+
+ Ok(())
+}
+
+pub(crate) fn parse(firmware_path: &path::Path) -> PyResult<()> {
+ let contents = fs::read(firmware_path)?;
+ let fw = Firmware::parse(&contents)
+ // squish the error into a string to avoid the error type requiring that the input be
+ // 'static
+ .map_err(|e| anyhow!("Parse error: {:?}", e))?;
+
+ dump_firmware_desc(&fw);
+
+ Ok(())
+}
+
+pub(crate) async fn info(transport: &str, force: bool) -> PyResult<()> {
+ let transport = Transport::open(transport).await?;
+
+ let mut host = Host::new(transport.source()?, transport.sink()?)?;
+ host.reset(DriverFactory::None).await?;
+
+ if !force && !Driver::check(&host).await? {
+ println!("USB device not supported by this RTK driver");
+ } else if let Some(driver_info) = Driver::driver_info_for_host(&host).await? {
+ println!("Driver:");
+ println!(" {:10} {:04X}", "ROM:", driver_info.rom()?);
+ println!(" {:10} {}", "Firmware:", driver_info.firmware_name()?);
+ println!(
+ " {:10} {}",
+ "Config:",
+ driver_info.config_name()?.unwrap_or_default()
+ );
+ } else {
+ println!("Firmware already loaded or no supported driver for this device.")
+ }
+
+ Ok(())
+}
+
+pub(crate) async fn load(transport: &str, force: bool) -> PyResult<()> {
+ let transport = Transport::open(transport).await?;
+
+ let mut host = Host::new(transport.source()?, transport.sink()?)?;
+ host.reset(DriverFactory::None).await?;
+
+ match Driver::for_host(&host, force).await? {
+ None => {
+ eprintln!("Firmware already loaded or no supported driver for this device.");
+ }
+ Some(mut d) => d.download_firmware().await?,
+ };
+
+ Ok(())
+}
+
+pub(crate) async fn drop(transport: &str) -> PyResult<()> {
+ let transport = Transport::open(transport).await?;
+
+ let mut host = Host::new(transport.source()?, transport.sink()?)?;
+ host.reset(DriverFactory::None).await?;
+
+ Driver::drop_firmware(&mut host).await?;
+
+ Ok(())
+}
+
+fn dump_firmware_desc(fw: &Firmware) {
+ println!(
+ "Firmware: version=0x{:08X} project_id=0x{:04X}",
+ fw.version(),
+ fw.project_id()
+ );
+ for p in fw.patches() {
+ println!(
+ " Patch: chip_id=0x{:04X}, {} bytes, SVN Version={:08X}",
+ p.chip_id(),
+ p.contents().len(),
+ p.svn_version()
+ )
+ }
+}
+
+struct SimpleClient {
+ client: reqwest::Client,
+}
+
+impl SimpleClient {
+ fn new() -> Self {
+ Self {
+ client: reqwest::Client::new(),
+ }
+ }
+
+ async fn get(&self, url: &str) -> anyhow::Result<Vec<u8>> {
+ let resp = self.client.get(url).send().await?;
+ if !resp.status().is_success() {
+ return Err(anyhow!("Bad status: {}", resp.status()));
+ }
+ let bytes = resp.bytes().await?;
+ Ok(bytes.as_ref().to_vec())
+ }
+}
diff --git a/rust/src/cli/l2cap/client_bridge.rs b/rust/src/cli/l2cap/client_bridge.rs
new file mode 100644
index 0000000..37606fc
--- /dev/null
+++ b/rust/src/cli/l2cap/client_bridge.rs
@@ -0,0 +1,191 @@
+// 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
+//
+// http://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.
+
+/// L2CAP CoC client bridge: connects to a BLE device, then waits for an inbound
+/// TCP connection on a specified port number. When a TCP client connects, an
+/// L2CAP CoC channel connection to the BLE device is established, and the data
+/// is bridged in both directions, with flow control.
+/// When the TCP connection is closed by the client, the L2CAP CoC channel is
+/// disconnected, but the connection to the BLE device remains, ready for a new
+/// TCP client to connect.
+/// When the L2CAP CoC channel is closed, the TCP connection is closed as well.
+use crate::cli::l2cap::{
+ proxy_l2cap_rx_to_tcp_tx, proxy_tcp_rx_to_l2cap_tx, run_future_with_current_task_locals,
+ BridgeData,
+};
+use bumble::wrapper::{
+ device::{Connection, Device},
+ hci::HciConstant,
+};
+use futures::executor::block_on;
+use owo_colors::OwoColorize;
+use pyo3::{PyResult, Python};
+use std::{net::SocketAddr, sync::Arc};
+use tokio::{
+ join,
+ net::{TcpListener, TcpStream},
+ sync::{mpsc, Mutex},
+};
+
+pub struct Args {
+ pub psm: u16,
+ pub max_credits: Option<u16>,
+ pub mtu: Option<u16>,
+ pub mps: Option<u16>,
+ pub bluetooth_address: String,
+ pub tcp_host: String,
+ pub tcp_port: u16,
+}
+
+pub async fn start(args: &Args, device: &mut Device) -> PyResult<()> {
+ println!(
+ "{}",
+ format!("### Connecting to {}...", args.bluetooth_address).yellow()
+ );
+ let mut ble_connection = device.connect(&args.bluetooth_address).await?;
+ ble_connection.on_disconnection(|_py, reason| {
+ let disconnection_info = match HciConstant::error_name(reason) {
+ Ok(info_string) => info_string,
+ Err(py_err) => format!("failed to get disconnection error name ({})", py_err),
+ };
+ println!(
+ "{} {}",
+ "@@@ Bluetooth disconnection: ".red(),
+ disconnection_info,
+ );
+ Ok(())
+ })?;
+
+ // Start the TCP server.
+ let listener = TcpListener::bind(format!("{}:{}", args.tcp_host, args.tcp_port))
+ .await
+ .expect("failed to bind tcp to address");
+ println!(
+ "{}",
+ format!(
+ "### Listening for TCP connections on port {}",
+ args.tcp_port
+ )
+ .magenta()
+ );
+
+ let psm = args.psm;
+ let max_credits = args.max_credits;
+ let mtu = args.mtu;
+ let mps = args.mps;
+ let ble_connection = Arc::new(Mutex::new(ble_connection));
+ // Ensure Python event loop is available to l2cap `disconnect`
+ let _ = run_future_with_current_task_locals(async move {
+ while let Ok((tcp_stream, addr)) = listener.accept().await {
+ let ble_connection = ble_connection.clone();
+ let _ = run_future_with_current_task_locals(proxy_data_between_tcp_and_l2cap(
+ ble_connection,
+ tcp_stream,
+ addr,
+ psm,
+ max_credits,
+ mtu,
+ mps,
+ ));
+ }
+ Ok(())
+ });
+ Ok(())
+}
+
+async fn proxy_data_between_tcp_and_l2cap(
+ ble_connection: Arc<Mutex<Connection>>,
+ tcp_stream: TcpStream,
+ addr: SocketAddr,
+ psm: u16,
+ max_credits: Option<u16>,
+ mtu: Option<u16>,
+ mps: Option<u16>,
+) -> PyResult<()> {
+ println!("{}", format!("<<< TCP connection from {}", addr).magenta());
+ println!(
+ "{}",
+ format!(">>> Opening L2CAP channel on PSM = {}", psm).yellow()
+ );
+
+ let mut l2cap_channel = match ble_connection
+ .lock()
+ .await
+ .open_l2cap_channel(psm, max_credits, mtu, mps)
+ .await
+ {
+ Ok(channel) => channel,
+ Err(e) => {
+ println!("{}", format!("!!! Connection failed: {e}").red());
+ // TCP stream will get dropped after returning, automatically shutting it down.
+ return Err(e);
+ }
+ };
+ let channel_info = l2cap_channel
+ .debug_string()
+ .unwrap_or_else(|e| format!("failed to get l2cap channel info ({e})"));
+
+ println!("{}{}", "*** L2CAP channel: ".cyan(), channel_info);
+
+ let (l2cap_to_tcp_tx, l2cap_to_tcp_rx) = mpsc::channel::<BridgeData>(10);
+
+ // Set l2cap callback (`set_sink`) for when data is received.
+ let l2cap_to_tcp_tx_clone = l2cap_to_tcp_tx.clone();
+ l2cap_channel
+ .set_sink(move |_py, sdu| {
+ block_on(l2cap_to_tcp_tx_clone.send(BridgeData::Data(sdu.into())))
+ .expect("failed to channel data to tcp");
+ Ok(())
+ })
+ .expect("failed to set sink for l2cap connection");
+
+ // Set l2cap callback for when the channel is closed.
+ l2cap_channel
+ .on_close(move |_py| {
+ println!("{}", "*** L2CAP channel closed".red());
+ block_on(l2cap_to_tcp_tx.send(BridgeData::CloseSignal))
+ .expect("failed to channel close signal to tcp");
+ Ok(())
+ })
+ .expect("failed to set on_close callback for l2cap channel");
+
+ let l2cap_channel = Arc::new(Mutex::new(Some(l2cap_channel)));
+ let (tcp_reader, tcp_writer) = tcp_stream.into_split();
+
+ // Do tcp stuff when something happens on the l2cap channel.
+ let handle_l2cap_data_future =
+ proxy_l2cap_rx_to_tcp_tx(l2cap_to_tcp_rx, tcp_writer, l2cap_channel.clone());
+
+ // Do l2cap stuff when something happens on tcp.
+ let handle_tcp_data_future = proxy_tcp_rx_to_l2cap_tx(tcp_reader, l2cap_channel.clone(), true);
+
+ let (handle_l2cap_result, handle_tcp_result) =
+ join!(handle_l2cap_data_future, handle_tcp_data_future);
+
+ if let Err(e) = handle_l2cap_result {
+ println!("!!! Error: {e}");
+ }
+
+ if let Err(e) = handle_tcp_result {
+ println!("!!! Error: {e}");
+ }
+
+ Python::with_gil(|_| {
+ // Must hold GIL at least once while/after dropping for Python heap object to ensure
+ // de-allocation.
+ drop(l2cap_channel);
+ });
+
+ Ok(())
+}
diff --git a/rust/src/cli/l2cap/mod.rs b/rust/src/cli/l2cap/mod.rs
new file mode 100644
index 0000000..31097ed
--- /dev/null
+++ b/rust/src/cli/l2cap/mod.rs
@@ -0,0 +1,190 @@
+// 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
+//
+// http://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.
+
+//! Rust version of the Python `l2cap_bridge.py` found under the `apps` folder.
+
+use crate::L2cap;
+use anyhow::anyhow;
+use bumble::wrapper::{device::Device, l2cap::LeConnectionOrientedChannel, transport::Transport};
+use owo_colors::{colors::css::Orange, OwoColorize};
+use pyo3::{PyObject, PyResult, Python};
+use std::{future::Future, path::PathBuf, sync::Arc};
+use tokio::{
+ io::{AsyncReadExt, AsyncWriteExt},
+ net::tcp::{OwnedReadHalf, OwnedWriteHalf},
+ sync::{mpsc::Receiver, Mutex},
+};
+
+mod client_bridge;
+mod server_bridge;
+
+pub(crate) async fn run(
+ command: L2cap,
+ device_config: PathBuf,
+ transport: String,
+ psm: u16,
+ max_credits: Option<u16>,
+ mtu: Option<u16>,
+ mps: Option<u16>,
+) -> PyResult<()> {
+ println!("<<< connecting to HCI...");
+ let transport = Transport::open(transport).await?;
+ println!("<<< connected");
+
+ let mut device =
+ Device::from_config_file_with_hci(&device_config, transport.source()?, transport.sink()?)?;
+
+ device.power_on().await?;
+
+ match command {
+ L2cap::Server { tcp_host, tcp_port } => {
+ let args = server_bridge::Args {
+ psm,
+ max_credits,
+ mtu,
+ mps,
+ tcp_host,
+ tcp_port,
+ };
+
+ server_bridge::start(&args, &mut device).await?
+ }
+ L2cap::Client {
+ bluetooth_address,
+ tcp_host,
+ tcp_port,
+ } => {
+ let args = client_bridge::Args {
+ psm,
+ max_credits,
+ mtu,
+ mps,
+ bluetooth_address,
+ tcp_host,
+ tcp_port,
+ };
+
+ client_bridge::start(&args, &mut device).await?
+ }
+ };
+
+ // wait until user kills the process
+ tokio::signal::ctrl_c().await?;
+
+ Ok(())
+}
+
+/// Used for channeling data from Python callbacks to a Rust consumer.
+enum BridgeData {
+ Data(Vec<u8>),
+ CloseSignal,
+}
+
+async fn proxy_l2cap_rx_to_tcp_tx(
+ mut l2cap_data_receiver: Receiver<BridgeData>,
+ mut tcp_writer: OwnedWriteHalf,
+ l2cap_channel: Arc<Mutex<Option<LeConnectionOrientedChannel>>>,
+) -> anyhow::Result<()> {
+ while let Some(bridge_data) = l2cap_data_receiver.recv().await {
+ match bridge_data {
+ BridgeData::Data(sdu) => {
+ println!("{}", format!("<<< [L2CAP SDU]: {} bytes", sdu.len()).cyan());
+ tcp_writer
+ .write_all(sdu.as_ref())
+ .await
+ .map_err(|_| anyhow!("Failed to write to tcp stream"))?;
+ tcp_writer
+ .flush()
+ .await
+ .map_err(|_| anyhow!("Failed to flush tcp stream"))?;
+ }
+ BridgeData::CloseSignal => {
+ l2cap_channel.lock().await.take();
+ tcp_writer
+ .shutdown()
+ .await
+ .map_err(|_| anyhow!("Failed to shut down write half of tcp stream"))?;
+ return Ok(());
+ }
+ }
+ }
+ Ok(())
+}
+
+async fn proxy_tcp_rx_to_l2cap_tx(
+ mut tcp_reader: OwnedReadHalf,
+ l2cap_channel: Arc<Mutex<Option<LeConnectionOrientedChannel>>>,
+ drain_l2cap_after_write: bool,
+) -> PyResult<()> {
+ let mut buf = [0; 4096];
+ loop {
+ match tcp_reader.read(&mut buf).await {
+ Ok(len) => {
+ if len == 0 {
+ println!("{}", "!!! End of stream".fg::<Orange>());
+
+ if let Some(mut channel) = l2cap_channel.lock().await.take() {
+ channel.disconnect().await.map_err(|e| {
+ eprintln!("Failed to call disconnect on l2cap channel: {e}");
+ e
+ })?;
+ }
+ return Ok(());
+ }
+
+ println!("{}", format!("<<< [TCP DATA]: {len} bytes").blue());
+ match l2cap_channel.lock().await.as_mut() {
+ None => {
+ println!("{}", "!!! L2CAP channel not connected, dropping".red());
+ return Ok(());
+ }
+ Some(channel) => {
+ channel.write(&buf[..len])?;
+ if drain_l2cap_after_write {
+ channel.drain().await?;
+ }
+ }
+ }
+ }
+ Err(e) => {
+ println!("{}", format!("!!! TCP connection lost: {}", e).red());
+ if let Some(mut channel) = l2cap_channel.lock().await.take() {
+ let _ = channel.disconnect().await.map_err(|e| {
+ eprintln!("Failed to call disconnect on l2cap channel: {e}");
+ });
+ }
+ return Err(e.into());
+ }
+ }
+ }
+}
+
+/// Copies the current thread's TaskLocals into a Python "awaitable" and encapsulates it in a Rust
+/// future, running it as a Python Task.
+/// `TaskLocals` stores the current event loop, and allows the user to copy the current Python
+/// context if necessary. In this case, the python event loop is used when calling `disconnect` on
+/// an l2cap connection, or else the call will fail.
+pub fn run_future_with_current_task_locals<F>(
+ fut: F,
+) -> PyResult<impl Future<Output = PyResult<PyObject>> + Send>
+where
+ F: Future<Output = PyResult<()>> + Send + 'static,
+{
+ Python::with_gil(|py| {
+ let locals = pyo3_asyncio::tokio::get_current_locals(py)?;
+ let future = pyo3_asyncio::tokio::scope(locals.clone(), fut);
+ pyo3_asyncio::tokio::future_into_py_with_locals(py, locals, future)
+ .and_then(pyo3_asyncio::tokio::into_future)
+ })
+}
diff --git a/rust/src/cli/l2cap/server_bridge.rs b/rust/src/cli/l2cap/server_bridge.rs
new file mode 100644
index 0000000..3a32db9
--- /dev/null
+++ b/rust/src/cli/l2cap/server_bridge.rs
@@ -0,0 +1,205 @@
+// 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
+//
+// http://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.
+
+/// L2CAP CoC server bridge: waits for a peer to connect an L2CAP CoC channel
+/// on a specified PSM. When the connection is made, the bridge connects a TCP
+/// socket to a remote host and bridges the data in both directions, with flow
+/// control.
+/// When the L2CAP CoC channel is closed, the bridge disconnects the TCP socket
+/// and waits for a new L2CAP CoC channel to be connected.
+/// When the TCP connection is closed by the TCP server, the L2CAP connection is closed as well.
+use crate::cli::l2cap::{
+ proxy_l2cap_rx_to_tcp_tx, proxy_tcp_rx_to_l2cap_tx, run_future_with_current_task_locals,
+ BridgeData,
+};
+use bumble::wrapper::{device::Device, hci::HciConstant, l2cap::LeConnectionOrientedChannel};
+use futures::executor::block_on;
+use owo_colors::OwoColorize;
+use pyo3::{PyResult, Python};
+use std::{sync::Arc, time::Duration};
+use tokio::{
+ join,
+ net::TcpStream,
+ select,
+ sync::{mpsc, Mutex},
+};
+
+pub struct Args {
+ pub psm: u16,
+ pub max_credits: Option<u16>,
+ pub mtu: Option<u16>,
+ pub mps: Option<u16>,
+ pub tcp_host: String,
+ pub tcp_port: u16,
+}
+
+pub async fn start(args: &Args, device: &mut Device) -> PyResult<()> {
+ let host = args.tcp_host.clone();
+ let port = args.tcp_port;
+ device.register_l2cap_channel_server(
+ args.psm,
+ move |_py, l2cap_channel| {
+ let channel_info = l2cap_channel
+ .debug_string()
+ .unwrap_or_else(|e| format!("failed to get l2cap channel info ({e})"));
+ println!("{} {channel_info}", "*** L2CAP channel:".cyan());
+
+ let host = host.clone();
+ // Ensure Python event loop is available to l2cap `disconnect`
+ let _ = run_future_with_current_task_locals(proxy_data_between_l2cap_and_tcp(
+ l2cap_channel,
+ host,
+ port,
+ ));
+ Ok(())
+ },
+ args.max_credits,
+ args.mtu,
+ args.mps,
+ )?;
+
+ println!(
+ "{}",
+ format!("### Listening for CoC connection on PSM {}", args.psm).yellow()
+ );
+
+ device.on_connection(|_py, mut connection| {
+ let connection_info = connection
+ .debug_string()
+ .unwrap_or_else(|e| format!("failed to get connection info ({e})"));
+ println!(
+ "{} {}",
+ "@@@ Bluetooth connection: ".green(),
+ connection_info,
+ );
+ connection.on_disconnection(|_py, reason| {
+ let disconnection_info = match HciConstant::error_name(reason) {
+ Ok(info_string) => info_string,
+ Err(py_err) => format!("failed to get disconnection error name ({})", py_err),
+ };
+ println!(
+ "{} {}",
+ "@@@ Bluetooth disconnection: ".red(),
+ disconnection_info,
+ );
+ Ok(())
+ })?;
+ Ok(())
+ })?;
+
+ device.start_advertising(false).await?;
+
+ Ok(())
+}
+
+async fn proxy_data_between_l2cap_and_tcp(
+ mut l2cap_channel: LeConnectionOrientedChannel,
+ tcp_host: String,
+ tcp_port: u16,
+) -> PyResult<()> {
+ let (l2cap_to_tcp_tx, mut l2cap_to_tcp_rx) = mpsc::channel::<BridgeData>(10);
+
+ // Set callback (`set_sink`) for when l2cap data is received.
+ let l2cap_to_tcp_tx_clone = l2cap_to_tcp_tx.clone();
+ l2cap_channel
+ .set_sink(move |_py, sdu| {
+ block_on(l2cap_to_tcp_tx_clone.send(BridgeData::Data(sdu.into())))
+ .expect("failed to channel data to tcp");
+ Ok(())
+ })
+ .expect("failed to set sink for l2cap connection");
+
+ // Set l2cap callback for when the channel is closed.
+ l2cap_channel
+ .on_close(move |_py| {
+ println!("{}", "*** L2CAP channel closed".red());
+ block_on(l2cap_to_tcp_tx.send(BridgeData::CloseSignal))
+ .expect("failed to channel close signal to tcp");
+ Ok(())
+ })
+ .expect("failed to set on_close callback for l2cap channel");
+
+ println!(
+ "{}",
+ format!("### Connecting to TCP {tcp_host}:{tcp_port}...").yellow()
+ );
+
+ let l2cap_channel = Arc::new(Mutex::new(Some(l2cap_channel)));
+ let tcp_stream = match TcpStream::connect(format!("{tcp_host}:{tcp_port}")).await {
+ Ok(stream) => {
+ println!("{}", "### Connected".green());
+ Some(stream)
+ }
+ Err(err) => {
+ println!("{}", format!("!!! Connection failed: {err}").red());
+ if let Some(mut channel) = l2cap_channel.lock().await.take() {
+ // Bumble might enter an invalid state if disconnection request is received from
+ // l2cap client before receiving a disconnection response from the same client,
+ // blocking this async call from returning.
+ // See: https://github.com/google/bumble/issues/257
+ select! {
+ res = channel.disconnect() => {
+ let _ = res.map_err(|e| eprintln!("Failed to call disconnect on l2cap channel: {e}"));
+ },
+ _ = tokio::time::sleep(Duration::from_secs(1)) => eprintln!("Timed out while calling disconnect on l2cap channel."),
+ }
+ }
+ None
+ }
+ };
+
+ match tcp_stream {
+ None => {
+ while let Some(bridge_data) = l2cap_to_tcp_rx.recv().await {
+ match bridge_data {
+ BridgeData::Data(sdu) => {
+ println!("{}", format!("<<< [L2CAP SDU]: {} bytes", sdu.len()).cyan());
+ println!("{}", "!!! TCP socket not open, dropping".red())
+ }
+ BridgeData::CloseSignal => break,
+ }
+ }
+ }
+ Some(tcp_stream) => {
+ let (tcp_reader, tcp_writer) = tcp_stream.into_split();
+
+ // Do tcp stuff when something happens on the l2cap channel.
+ let handle_l2cap_data_future =
+ proxy_l2cap_rx_to_tcp_tx(l2cap_to_tcp_rx, tcp_writer, l2cap_channel.clone());
+
+ // Do l2cap stuff when something happens on tcp.
+ let handle_tcp_data_future =
+ proxy_tcp_rx_to_l2cap_tx(tcp_reader, l2cap_channel.clone(), false);
+
+ let (handle_l2cap_result, handle_tcp_result) =
+ join!(handle_l2cap_data_future, handle_tcp_data_future);
+
+ if let Err(e) = handle_l2cap_result {
+ println!("!!! Error: {e}");
+ }
+
+ if let Err(e) = handle_tcp_result {
+ println!("!!! Error: {e}");
+ }
+ }
+ };
+
+ Python::with_gil(|_| {
+ // Must hold GIL at least once while/after dropping for Python heap object to ensure
+ // de-allocation.
+ drop(l2cap_channel);
+ });
+
+ Ok(())
+}
diff --git a/rust/src/cli/mod.rs b/rust/src/cli/mod.rs
new file mode 100644
index 0000000..e58f88c
--- /dev/null
+++ b/rust/src/cli/mod.rs
@@ -0,0 +1,19 @@
+// 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
+//
+// http://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.
+
+pub(crate) mod firmware;
+
+pub(crate) mod usb;
+
+pub(crate) mod l2cap;
diff --git a/rust/examples/usb_probe.rs b/rust/src/cli/usb/mod.rs
index 3ba3b61..7adbd75 100644
--- a/rust/examples/usb_probe.rs
+++ b/rust/src/cli/usb/mod.rs
@@ -23,7 +23,6 @@
//! whether it is a Bluetooth device that uses a non-standard Class, or some other
//! type of device (there's no way to tell).
-use clap::Parser as _;
use itertools::Itertools as _;
use owo_colors::{OwoColorize, Style};
use rusb::{Device, DeviceDescriptor, Direction, TransferType, UsbContext};
@@ -31,15 +30,12 @@ use std::{
collections::{HashMap, HashSet},
time::Duration,
};
-
const USB_DEVICE_CLASS_DEVICE: u8 = 0x00;
const USB_DEVICE_CLASS_WIRELESS_CONTROLLER: u8 = 0xE0;
const USB_DEVICE_SUBCLASS_RF_CONTROLLER: u8 = 0x01;
const USB_DEVICE_PROTOCOL_BLUETOOTH_PRIMARY_CONTROLLER: u8 = 0x01;
-fn main() -> anyhow::Result<()> {
- let cli = Cli::parse();
-
+pub(crate) fn probe(verbose: bool) -> anyhow::Result<()> {
let mut bt_dev_count = 0;
let mut device_serials_by_id: HashMap<(u16, u16), HashSet<String>> = HashMap::new();
for device in rusb::devices()?.iter() {
@@ -159,7 +155,7 @@ fn main() -> anyhow::Result<()> {
println!("{:26}{}", " Product:".green(), p);
}
- if cli.verbose {
+ if verbose {
print_device_details(&device, &device_desc)?;
}
@@ -332,11 +328,3 @@ impl From<&DeviceDescriptor> for ClassInfo {
)
}
}
-
-#[derive(clap::Parser)]
-#[command(author, version, about, long_about = None)]
-struct Cli {
- /// Show additional info for each USB device
- #[arg(long, default_value_t = false)]
- verbose: bool,
-}
diff --git a/rust/src/internal/drivers/mod.rs b/rust/src/internal/drivers/mod.rs
new file mode 100644
index 0000000..5e72c59
--- /dev/null
+++ b/rust/src/internal/drivers/mod.rs
@@ -0,0 +1,17 @@
+// 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
+//
+// http://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.
+
+//! Device drivers
+
+pub(crate) mod rtk;
diff --git a/rust/src/internal/drivers/rtk.rs b/rust/src/internal/drivers/rtk.rs
new file mode 100644
index 0000000..2d4e685
--- /dev/null
+++ b/rust/src/internal/drivers/rtk.rs
@@ -0,0 +1,253 @@
+// 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
+//
+// http://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 for Realtek controllers
+
+use nom::{bytes, combinator, error, multi, number, sequence};
+
+/// Realtek firmware file contents
+pub struct Firmware {
+ version: u32,
+ project_id: u8,
+ patches: Vec<Patch>,
+}
+
+impl Firmware {
+ /// Parse a `*_fw.bin` file
+ pub fn parse(input: &[u8]) -> Result<Self, nom::Err<error::Error<&[u8]>>> {
+ let extension_sig = [0x51, 0x04, 0xFD, 0x77];
+
+ let (_rem, (_tag, fw_version, patch_count, payload)) =
+ combinator::all_consuming(combinator::map_parser(
+ // ignore the sig suffix
+ sequence::terminated(
+ bytes::complete::take(
+ // underflow will show up as parse failure
+ input.len().saturating_sub(extension_sig.len()),
+ ),
+ bytes::complete::tag(extension_sig.as_slice()),
+ ),
+ sequence::tuple((
+ bytes::complete::tag(b"Realtech"),
+ // version
+ number::complete::le_u32,
+ // patch count
+ combinator::map(number::complete::le_u16, |c| c as usize),
+ // everything else except suffix
+ combinator::rest,
+ )),
+ ))(input)?;
+
+ // ignore remaining input, since patch offsets are relative to the complete input
+ let (_rem, (chip_ids, patch_lengths, patch_offsets)) = sequence::tuple((
+ // chip id
+ multi::many_m_n(patch_count, patch_count, number::complete::le_u16),
+ // patch length
+ multi::many_m_n(patch_count, patch_count, number::complete::le_u16),
+ // patch offset
+ multi::many_m_n(patch_count, patch_count, number::complete::le_u32),
+ ))(payload)?;
+
+ let patches = chip_ids
+ .into_iter()
+ .zip(patch_lengths.into_iter())
+ .zip(patch_offsets.into_iter())
+ .map(|((chip_id, patch_length), patch_offset)| {
+ combinator::map(
+ sequence::preceded(
+ bytes::complete::take(patch_offset),
+ // ignore trailing 4-byte suffix
+ sequence::terminated(
+ // patch including svn version, but not suffix
+ combinator::consumed(sequence::preceded(
+ // patch before svn version or version suffix
+ // prefix length underflow will show up as parse failure
+ bytes::complete::take(patch_length.saturating_sub(8)),
+ // svn version
+ number::complete::le_u32,
+ )),
+ // dummy suffix, overwritten with firmware version
+ bytes::complete::take(4_usize),
+ ),
+ ),
+ |(patch_contents_before_version, svn_version): (&[u8], u32)| {
+ let mut contents = patch_contents_before_version.to_vec();
+ // replace what would have been the trailing dummy suffix with fw version
+ contents.extend_from_slice(&fw_version.to_le_bytes());
+
+ Patch {
+ contents,
+ svn_version,
+ chip_id,
+ }
+ },
+ )(input)
+ .map(|(_rem, output)| output)
+ })
+ .collect::<Result<Vec<_>, _>>()?;
+
+ // look for project id from the end
+ let mut offset = payload.len();
+ let mut project_id: Option<u8> = None;
+ while offset >= 2 {
+ // Won't panic, since offset >= 2
+ let chunk = &payload[offset - 2..offset];
+ let length: usize = chunk[0].into();
+ let opcode = chunk[1];
+ offset -= 2;
+
+ if opcode == 0xFF {
+ break;
+ }
+ if length == 0 {
+ // report what nom likely would have done, if nom was good at parsing backwards
+ return Err(nom::Err::Error(error::Error::new(
+ chunk,
+ error::ErrorKind::Verify,
+ )));
+ }
+ if opcode == 0 && length == 1 {
+ project_id = offset
+ .checked_sub(1)
+ .and_then(|index| payload.get(index))
+ .copied();
+ break;
+ }
+
+ offset -= length;
+ }
+
+ match project_id {
+ Some(project_id) => Ok(Firmware {
+ project_id,
+ version: fw_version,
+ patches,
+ }),
+ None => {
+ // we ran out of file without finding a project id
+ Err(nom::Err::Error(error::Error::new(
+ payload,
+ error::ErrorKind::Eof,
+ )))
+ }
+ }
+ }
+
+ /// Patch version
+ pub fn version(&self) -> u32 {
+ self.version
+ }
+
+ /// Project id
+ pub fn project_id(&self) -> u8 {
+ self.project_id
+ }
+
+ /// Patches
+ pub fn patches(&self) -> &[Patch] {
+ &self.patches
+ }
+}
+
+/// Patch in a [Firmware}
+pub struct Patch {
+ chip_id: u16,
+ contents: Vec<u8>,
+ svn_version: u32,
+}
+
+impl Patch {
+ /// Chip id
+ pub fn chip_id(&self) -> u16 {
+ self.chip_id
+ }
+ /// Contents of the patch, including the 4-byte firmware version suffix
+ pub fn contents(&self) -> &[u8] {
+ &self.contents
+ }
+ /// SVN version
+ pub fn svn_version(&self) -> u32 {
+ self.svn_version
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use anyhow::anyhow;
+ use std::{fs, io, path};
+
+ #[test]
+ fn parse_firmware_rtl8723b() -> anyhow::Result<()> {
+ let fw = Firmware::parse(&firmware_contents("rtl8723b_fw_structure.bin")?)
+ .map_err(|e| anyhow!("{:?}", e))?;
+
+ let fw_version = 0x0E2F9F73;
+ assert_eq!(fw_version, fw.version());
+ assert_eq!(0x0001, fw.project_id());
+ assert_eq!(
+ vec![(0x0001, 0x00002BBF, 22368,), (0x0002, 0x00002BBF, 22496,),],
+ patch_summaries(fw, fw_version)
+ );
+
+ Ok(())
+ }
+
+ #[test]
+ fn parse_firmware_rtl8761bu() -> anyhow::Result<()> {
+ let fw = Firmware::parse(&firmware_contents("rtl8761bu_fw_structure.bin")?)
+ .map_err(|e| anyhow!("{:?}", e))?;
+
+ let fw_version = 0xDFC6D922;
+ assert_eq!(fw_version, fw.version());
+ assert_eq!(0x000E, fw.project_id());
+ assert_eq!(
+ vec![(0x0001, 0x00005060, 14048,), (0x0002, 0xD6D525A4, 30204,),],
+ patch_summaries(fw, fw_version)
+ );
+
+ Ok(())
+ }
+
+ fn firmware_contents(filename: &str) -> io::Result<Vec<u8>> {
+ fs::read(
+ path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+ .join("resources/test/firmware/realtek")
+ .join(filename),
+ )
+ }
+
+ /// Return a tuple of (chip id, svn version, contents len, contents sha256)
+ fn patch_summaries(fw: Firmware, fw_version: u32) -> Vec<(u16, u32, usize)> {
+ fw.patches()
+ .iter()
+ .map(|p| {
+ let contents = p.contents();
+ let mut dummy_contents = dummy_contents(contents.len());
+ dummy_contents.extend_from_slice(&p.svn_version().to_le_bytes());
+ dummy_contents.extend_from_slice(&fw_version.to_le_bytes());
+ assert_eq!(&dummy_contents, contents);
+ (p.chip_id(), p.svn_version(), contents.len())
+ })
+ .collect::<Vec<_>>()
+ }
+
+ fn dummy_contents(len: usize) -> Vec<u8> {
+ let mut vec = (len as u32).to_le_bytes().as_slice().repeat(len / 4 + 1);
+ assert!(vec.len() >= len);
+ // leave room for svn version and firmware version
+ vec.truncate(len - 8);
+ vec
+ }
+}
diff --git a/rust/src/internal/mod.rs b/rust/src/internal/mod.rs
new file mode 100644
index 0000000..f474c2d
--- /dev/null
+++ b/rust/src/internal/mod.rs
@@ -0,0 +1,20 @@
+// 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
+//
+// http://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.
+
+//! It's not clear where to put Rust code that isn't simply a wrapper around Python. Until we have
+//! a good answer for what to do there, the idea is to put it in this (non-public) module, and
+//! `pub use` it into the relevant areas of the `wrapper` module so that it's still easy for users
+//! to discover.
+
+pub(crate) mod drivers;
diff --git a/rust/src/lib.rs b/rust/src/lib.rs
index 73001e6..2bcb398 100644
--- a/rust/src/lib.rs
+++ b/rust/src/lib.rs
@@ -29,3 +29,5 @@
pub mod wrapper;
pub mod adv;
+
+pub(crate) mod internal;
diff --git a/rust/src/main.rs b/rust/src/main.rs
new file mode 100644
index 0000000..c21f4c8
--- /dev/null
+++ b/rust/src/main.rs
@@ -0,0 +1,271 @@
+// 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
+//
+// http://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.
+
+//! CLI tools for Bumble
+
+#![deny(missing_docs, unsafe_code)]
+
+use bumble::wrapper::logging::{bumble_env_logging_level, py_logging_basic_config};
+use clap::Parser as _;
+use pyo3::PyResult;
+use std::{fmt, path};
+
+mod cli;
+
+#[pyo3_asyncio::tokio::main]
+async fn main() -> PyResult<()> {
+ env_logger::builder()
+ .filter_level(log::LevelFilter::Info)
+ .init();
+
+ py_logging_basic_config(bumble_env_logging_level("INFO"))?;
+
+ let cli: Cli = Cli::parse();
+
+ match cli.subcommand {
+ Subcommand::Firmware { subcommand: fw } => match fw {
+ Firmware::Realtek { subcommand: rtk } => match rtk {
+ Realtek::Download(dl) => {
+ cli::firmware::rtk::download(dl).await?;
+ }
+ Realtek::Drop { transport } => cli::firmware::rtk::drop(&transport).await?,
+ Realtek::Info { transport, force } => {
+ cli::firmware::rtk::info(&transport, force).await?;
+ }
+ Realtek::Load { transport, force } => {
+ cli::firmware::rtk::load(&transport, force).await?
+ }
+ Realtek::Parse { firmware_path } => cli::firmware::rtk::parse(&firmware_path)?,
+ },
+ },
+ Subcommand::L2cap {
+ subcommand,
+ device_config,
+ transport,
+ psm,
+ l2cap_coc_max_credits,
+ l2cap_coc_mtu,
+ l2cap_coc_mps,
+ } => {
+ cli::l2cap::run(
+ subcommand,
+ device_config,
+ transport,
+ psm,
+ l2cap_coc_max_credits,
+ l2cap_coc_mtu,
+ l2cap_coc_mps,
+ )
+ .await?
+ }
+ Subcommand::Usb { subcommand } => match subcommand {
+ Usb::Probe(probe) => cli::usb::probe(probe.verbose)?,
+ },
+ }
+
+ Ok(())
+}
+
+#[derive(clap::Parser)]
+struct Cli {
+ #[clap(subcommand)]
+ subcommand: Subcommand,
+}
+
+#[derive(clap::Subcommand, Debug, Clone)]
+enum Subcommand {
+ /// Manage device firmware
+ Firmware {
+ #[clap(subcommand)]
+ subcommand: Firmware,
+ },
+ /// L2cap client/server operations
+ L2cap {
+ #[command(subcommand)]
+ subcommand: L2cap,
+
+ /// Device configuration file.
+ ///
+ /// See, for instance, `examples/device1.json` in the Python project.
+ #[arg(long)]
+ device_config: path::PathBuf,
+ /// Bumble transport spec.
+ ///
+ /// <https://google.github.io/bumble/transports/index.html>
+ #[arg(long)]
+ transport: String,
+
+ /// PSM for L2CAP Connection-oriented Channel.
+ ///
+ /// Must be in the range [0, 65535].
+ #[arg(long)]
+ psm: u16,
+
+ /// Maximum L2CAP CoC Credits. When not specified, lets Bumble set the default.
+ ///
+ /// Must be in the range [1, 65535].
+ #[arg(long, value_parser = clap::value_parser!(u16).range(1..))]
+ l2cap_coc_max_credits: Option<u16>,
+
+ /// L2CAP CoC MTU. When not specified, lets Bumble set the default.
+ ///
+ /// Must be in the range [23, 65535].
+ #[arg(long, value_parser = clap::value_parser!(u16).range(23..))]
+ l2cap_coc_mtu: Option<u16>,
+
+ /// L2CAP CoC MPS. When not specified, lets Bumble set the default.
+ ///
+ /// Must be in the range [23, 65535].
+ #[arg(long, value_parser = clap::value_parser!(u16).range(23..))]
+ l2cap_coc_mps: Option<u16>,
+ },
+ /// USB operations
+ Usb {
+ #[clap(subcommand)]
+ subcommand: Usb,
+ },
+}
+
+#[derive(clap::Subcommand, Debug, Clone)]
+enum Firmware {
+ /// Manage Realtek chipset firmware
+ Realtek {
+ #[clap(subcommand)]
+ subcommand: Realtek,
+ },
+}
+
+#[derive(clap::Subcommand, Debug, Clone)]
+
+enum Realtek {
+ /// Download Realtek firmware
+ Download(Download),
+ /// Drop firmware from a USB device
+ Drop {
+ /// Bumble transport spec. Must be for a USB device.
+ ///
+ /// <https://google.github.io/bumble/transports/index.html>
+ #[arg(long)]
+ transport: String,
+ },
+ /// Show driver info for a USB device
+ Info {
+ /// Bumble transport spec. Must be for a USB device.
+ ///
+ /// <https://google.github.io/bumble/transports/index.html>
+ #[arg(long)]
+ transport: String,
+ /// Try to resolve driver info even if USB info is not available, or if the USB
+ /// (vendor,product) tuple is not in the list of known compatible RTK USB dongles.
+ #[arg(long, default_value_t = false)]
+ force: bool,
+ },
+ /// Load firmware onto a USB device
+ Load {
+ /// Bumble transport spec. Must be for a USB device.
+ ///
+ /// <https://google.github.io/bumble/transports/index.html>
+ #[arg(long)]
+ transport: String,
+ /// Load firmware even if the USB info doesn't match.
+ #[arg(long, default_value_t = false)]
+ force: bool,
+ },
+ /// Parse a firmware file
+ Parse {
+ /// Firmware file to parse
+ firmware_path: path::PathBuf,
+ },
+}
+
+#[derive(clap::Args, Debug, Clone)]
+struct Download {
+ /// Directory to download to. Defaults to an OS-specific path specific to the Bumble tool.
+ #[arg(long)]
+ output_dir: Option<path::PathBuf>,
+ /// Source to download from
+ #[arg(long, default_value_t = Source::LinuxKernel)]
+ source: Source,
+ /// Only download a single image
+ #[arg(long, value_name = "base name")]
+ single: Option<String>,
+ /// Overwrite existing files
+ #[arg(long, default_value_t = false)]
+ overwrite: bool,
+ /// Don't print the parse results for the downloaded file names
+ #[arg(long)]
+ no_parse: bool,
+}
+
+#[derive(Debug, Clone, clap::ValueEnum)]
+enum Source {
+ LinuxKernel,
+ RealtekOpensource,
+ LinuxFromScratch,
+}
+
+impl fmt::Display for Source {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Source::LinuxKernel => write!(f, "linux-kernel"),
+ Source::RealtekOpensource => write!(f, "realtek-opensource"),
+ Source::LinuxFromScratch => write!(f, "linux-from-scratch"),
+ }
+ }
+}
+
+#[derive(clap::Subcommand, Debug, Clone)]
+enum L2cap {
+ /// Starts an L2CAP server
+ Server {
+ /// TCP host that the l2cap server will connect to.
+ /// Data is bridged like so:
+ /// TCP server <-> (TCP client / **L2CAP server**) <-> (L2CAP client / TCP server) <-> TCP client
+ #[arg(long, default_value = "localhost")]
+ tcp_host: String,
+ /// TCP port that the server will connect to.
+ ///
+ /// Must be in the range [1, 65535].
+ #[arg(long, default_value_t = 9544)]
+ tcp_port: u16,
+ },
+ /// Starts an L2CAP client
+ Client {
+ /// L2cap server address that this l2cap client will connect to.
+ bluetooth_address: String,
+ /// TCP host that the l2cap client will bind to and listen for incoming TCP connections.
+ /// Data is bridged like so:
+ /// TCP client <-> (TCP server / **L2CAP client**) <-> (L2CAP server / TCP client) <-> TCP server
+ #[arg(long, default_value = "localhost")]
+ tcp_host: String,
+ /// TCP port that the client will connect to.
+ ///
+ /// Must be in the range [1, 65535].
+ #[arg(long, default_value_t = 9543)]
+ tcp_port: u16,
+ },
+}
+
+#[derive(clap::Subcommand, Debug, Clone)]
+enum Usb {
+ /// Probe the USB bus for Bluetooth devices
+ Probe(Probe),
+}
+
+#[derive(clap::Args, Debug, Clone)]
+struct Probe {
+ /// Show additional info for each USB device
+ #[arg(long, default_value_t = false)]
+ verbose: bool,
+}
diff --git a/rust/src/wrapper/assigned_numbers/company_ids.rs b/rust/src/wrapper/assigned_numbers/company_ids.rs
new file mode 100644
index 0000000..2eebcd5
--- /dev/null
+++ b/rust/src/wrapper/assigned_numbers/company_ids.rs
@@ -0,0 +1,2715 @@
+// 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
+//
+// http://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.
+
+// auto-generated by gen_assigned_numbers, do not edit
+
+use crate::wrapper::core::Uuid16;
+use lazy_static::lazy_static;
+use std::collections;
+
+lazy_static! {
+ /// Assigned company IDs
+ pub static ref COMPANY_IDS: collections::HashMap<Uuid16, &'static str> = [
+ (0_u16, r#"Ericsson Technology Licensing"#),
+ (1_u16, r#"Nokia Mobile Phones"#),
+ (2_u16, r#"Intel Corp."#),
+ (3_u16, r#"IBM Corp."#),
+ (4_u16, r#"Toshiba Corp."#),
+ (5_u16, r#"3Com"#),
+ (6_u16, r#"Microsoft"#),
+ (7_u16, r#"Lucent"#),
+ (8_u16, r#"Motorola"#),
+ (9_u16, r#"Infineon Technologies AG"#),
+ (10_u16, r#"Qualcomm Technologies International, Ltd. (QTIL)"#),
+ (11_u16, r#"Silicon Wave"#),
+ (12_u16, r#"Digianswer A/S"#),
+ (13_u16, r#"Texas Instruments Inc."#),
+ (14_u16, r#"Parthus Technologies Inc."#),
+ (15_u16, r#"Broadcom Corporation"#),
+ (16_u16, r#"Mitel Semiconductor"#),
+ (17_u16, r#"Widcomm, Inc."#),
+ (18_u16, r#"Zeevo, Inc."#),
+ (19_u16, r#"Atmel Corporation"#),
+ (20_u16, r#"Mitsubishi Electric Corporation"#),
+ (21_u16, r#"RTX Telecom A/S"#),
+ (22_u16, r#"KC Technology Inc."#),
+ (23_u16, r#"Newlogic"#),
+ (24_u16, r#"Transilica, Inc."#),
+ (25_u16, r#"Rohde & Schwarz GmbH & Co. KG"#),
+ (26_u16, r#"TTPCom Limited"#),
+ (27_u16, r#"Signia Technologies, Inc."#),
+ (28_u16, r#"Conexant Systems Inc."#),
+ (29_u16, r#"Qualcomm"#),
+ (30_u16, r#"Inventel"#),
+ (31_u16, r#"AVM Berlin"#),
+ (32_u16, r#"BandSpeed, Inc."#),
+ (33_u16, r#"Mansella Ltd"#),
+ (34_u16, r#"NEC Corporation"#),
+ (35_u16, r#"WavePlus Technology Co., Ltd."#),
+ (36_u16, r#"Alcatel"#),
+ (37_u16, r#"NXP Semiconductors (formerly Philips Semiconductors)"#),
+ (38_u16, r#"C Technologies"#),
+ (39_u16, r#"Open Interface"#),
+ (40_u16, r#"R F Micro Devices"#),
+ (41_u16, r#"Hitachi Ltd"#),
+ (42_u16, r#"Symbol Technologies, Inc."#),
+ (43_u16, r#"Tenovis"#),
+ (44_u16, r#"Macronix International Co. Ltd."#),
+ (45_u16, r#"GCT Semiconductor"#),
+ (46_u16, r#"Norwood Systems"#),
+ (47_u16, r#"MewTel Technology Inc."#),
+ (48_u16, r#"ST Microelectronics"#),
+ (49_u16, r#"Synopsys, Inc."#),
+ (50_u16, r#"Red-M (Communications) Ltd"#),
+ (51_u16, r#"Commil Ltd"#),
+ (52_u16, r#"Computer Access Technology Corporation (CATC)"#),
+ (53_u16, r#"Eclipse (HQ Espana) S.L."#),
+ (54_u16, r#"Renesas Electronics Corporation"#),
+ (55_u16, r#"Mobilian Corporation"#),
+ (56_u16, r#"Syntronix Corporation"#),
+ (57_u16, r#"Integrated System Solution Corp."#),
+ (58_u16, r#"Panasonic Corporation (formerly Matsushita Electric Industrial Co., Ltd.)"#),
+ (59_u16, r#"Gennum Corporation"#),
+ (60_u16, r#"BlackBerry Limited (formerly Research In Motion)"#),
+ (61_u16, r#"IPextreme, Inc."#),
+ (62_u16, r#"Systems and Chips, Inc"#),
+ (63_u16, r#"Bluetooth SIG, Inc"#),
+ (64_u16, r#"Seiko Epson Corporation"#),
+ (65_u16, r#"Integrated Silicon Solution Taiwan, Inc."#),
+ (66_u16, r#"CONWISE Technology Corporation Ltd"#),
+ (67_u16, r#"PARROT AUTOMOTIVE SAS"#),
+ (68_u16, r#"Socket Mobile"#),
+ (69_u16, r#"Atheros Communications, Inc."#),
+ (70_u16, r#"MediaTek, Inc."#),
+ (71_u16, r#"Bluegiga"#),
+ (72_u16, r#"Marvell Technology Group Ltd."#),
+ (73_u16, r#"3DSP Corporation"#),
+ (74_u16, r#"Accel Semiconductor Ltd."#),
+ (75_u16, r#"Continental Automotive Systems"#),
+ (76_u16, r#"Apple, Inc."#),
+ (77_u16, r#"Staccato Communications, Inc."#),
+ (78_u16, r#"Avago Technologies"#),
+ (79_u16, r#"APT Ltd."#),
+ (80_u16, r#"SiRF Technology, Inc."#),
+ (81_u16, r#"Tzero Technologies, Inc."#),
+ (82_u16, r#"J&M Corporation"#),
+ (83_u16, r#"Free2move AB"#),
+ (84_u16, r#"3DiJoy Corporation"#),
+ (85_u16, r#"Plantronics, Inc."#),
+ (86_u16, r#"Sony Ericsson Mobile Communications"#),
+ (87_u16, r#"Harman International Industries, Inc."#),
+ (88_u16, r#"Vizio, Inc."#),
+ (89_u16, r#"Nordic Semiconductor ASA"#),
+ (90_u16, r#"EM Microelectronic-Marin SA"#),
+ (91_u16, r#"Ralink Technology Corporation"#),
+ (92_u16, r#"Belkin International, Inc."#),
+ (93_u16, r#"Realtek Semiconductor Corporation"#),
+ (94_u16, r#"Stonestreet One, LLC"#),
+ (95_u16, r#"Wicentric, Inc."#),
+ (96_u16, r#"RivieraWaves S.A.S"#),
+ (97_u16, r#"RDA Microelectronics"#),
+ (98_u16, r#"Gibson Guitars"#),
+ (99_u16, r#"MiCommand Inc."#),
+ (100_u16, r#"Band XI International, LLC"#),
+ (101_u16, r#"Hewlett-Packard Company"#),
+ (102_u16, r#"9Solutions Oy"#),
+ (103_u16, r#"GN Netcom A/S"#),
+ (104_u16, r#"General Motors"#),
+ (105_u16, r#"A&D Engineering, Inc."#),
+ (106_u16, r#"MindTree Ltd."#),
+ (107_u16, r#"Polar Electro OY"#),
+ (108_u16, r#"Beautiful Enterprise Co., Ltd."#),
+ (109_u16, r#"BriarTek, Inc"#),
+ (110_u16, r#"Summit Data Communications, Inc."#),
+ (111_u16, r#"Sound ID"#),
+ (112_u16, r#"Monster, LLC"#),
+ (113_u16, r#"connectBlue AB"#),
+ (114_u16, r#"ShangHai Super Smart Electronics Co. Ltd."#),
+ (115_u16, r#"Group Sense Ltd."#),
+ (116_u16, r#"Zomm, LLC"#),
+ (117_u16, r#"Samsung Electronics Co. Ltd."#),
+ (118_u16, r#"Creative Technology Ltd."#),
+ (119_u16, r#"Laird Technologies"#),
+ (120_u16, r#"Nike, Inc."#),
+ (121_u16, r#"lesswire AG"#),
+ (122_u16, r#"MStar Semiconductor, Inc."#),
+ (123_u16, r#"Hanlynn Technologies"#),
+ (124_u16, r#"A & R Cambridge"#),
+ (125_u16, r#"Seers Technology Co., Ltd."#),
+ (126_u16, r#"Sports Tracking Technologies Ltd."#),
+ (127_u16, r#"Autonet Mobile"#),
+ (128_u16, r#"DeLorme Publishing Company, Inc."#),
+ (129_u16, r#"WuXi Vimicro"#),
+ (130_u16, r#"Sennheiser Communications A/S"#),
+ (131_u16, r#"TimeKeeping Systems, Inc."#),
+ (132_u16, r#"Ludus Helsinki Ltd."#),
+ (133_u16, r#"BlueRadios, Inc."#),
+ (134_u16, r#"Equinux AG"#),
+ (135_u16, r#"Garmin International, Inc."#),
+ (136_u16, r#"Ecotest"#),
+ (137_u16, r#"GN ReSound A/S"#),
+ (138_u16, r#"Jawbone"#),
+ (139_u16, r#"Topcon Positioning Systems, LLC"#),
+ (140_u16, r#"Gimbal Inc. (formerly Qualcomm Labs, Inc. and Qualcomm Retail Solutions, Inc.)"#),
+ (141_u16, r#"Zscan Software"#),
+ (142_u16, r#"Quintic Corp"#),
+ (143_u16, r#"Telit Wireless Solutions GmbH (formerly Stollmann E+V GmbH)"#),
+ (144_u16, r#"Funai Electric Co., Ltd."#),
+ (145_u16, r#"Advanced PANMOBIL systems GmbH & Co. KG"#),
+ (146_u16, r#"ThinkOptics, Inc."#),
+ (147_u16, r#"Universal Electronics, Inc."#),
+ (148_u16, r#"Airoha Technology Corp."#),
+ (149_u16, r#"NEC Lighting, Ltd."#),
+ (150_u16, r#"ODM Technology, Inc."#),
+ (151_u16, r#"ConnecteDevice Ltd."#),
+ (152_u16, r#"zero1.tv GmbH"#),
+ (153_u16, r#"i.Tech Dynamic Global Distribution Ltd."#),
+ (154_u16, r#"Alpwise"#),
+ (155_u16, r#"Jiangsu Toppower Automotive Electronics Co., Ltd."#),
+ (156_u16, r#"Colorfy, Inc."#),
+ (157_u16, r#"Geoforce Inc."#),
+ (158_u16, r#"Bose Corporation"#),
+ (159_u16, r#"Suunto Oy"#),
+ (160_u16, r#"Kensington Computer Products Group"#),
+ (161_u16, r#"SR-Medizinelektronik"#),
+ (162_u16, r#"Vertu Corporation Limited"#),
+ (163_u16, r#"Meta Watch Ltd."#),
+ (164_u16, r#"LINAK A/S"#),
+ (165_u16, r#"OTL Dynamics LLC"#),
+ (166_u16, r#"Panda Ocean Inc."#),
+ (167_u16, r#"Visteon Corporation"#),
+ (168_u16, r#"ARP Devices Limited"#),
+ (169_u16, r#"MARELLI EUROPE S.P.A. (formerly Magneti Marelli S.p.A.)"#),
+ (170_u16, r#"CAEN RFID srl"#),
+ (171_u16, r#"Ingenieur-Systemgruppe Zahn GmbH"#),
+ (172_u16, r#"Green Throttle Games"#),
+ (173_u16, r#"Peter Systemtechnik GmbH"#),
+ (174_u16, r#"Omegawave Oy"#),
+ (175_u16, r#"Cinetix"#),
+ (176_u16, r#"Passif Semiconductor Corp"#),
+ (177_u16, r#"Saris Cycling Group, Inc"#),
+ (178_u16, r#"Bekey A/S"#),
+ (179_u16, r#"Clarinox Technologies Pty. Ltd."#),
+ (180_u16, r#"BDE Technology Co., Ltd."#),
+ (181_u16, r#"Swirl Networks"#),
+ (182_u16, r#"Meso international"#),
+ (183_u16, r#"TreLab Ltd"#),
+ (184_u16, r#"Qualcomm Innovation Center, Inc. (QuIC)"#),
+ (185_u16, r#"Johnson Controls, Inc."#),
+ (186_u16, r#"Starkey Laboratories Inc."#),
+ (187_u16, r#"S-Power Electronics Limited"#),
+ (188_u16, r#"Ace Sensor Inc"#),
+ (189_u16, r#"Aplix Corporation"#),
+ (190_u16, r#"AAMP of America"#),
+ (191_u16, r#"Stalmart Technology Limited"#),
+ (192_u16, r#"AMICCOM Electronics Corporation"#),
+ (193_u16, r#"Shenzhen Excelsecu Data Technology Co.,Ltd"#),
+ (194_u16, r#"Geneq Inc."#),
+ (195_u16, r#"adidas AG"#),
+ (196_u16, r#"LG Electronics"#),
+ (197_u16, r#"Onset Computer Corporation"#),
+ (198_u16, r#"Selfly BV"#),
+ (199_u16, r#"Quuppa Oy."#),
+ (200_u16, r#"GeLo Inc"#),
+ (201_u16, r#"Evluma"#),
+ (202_u16, r#"MC10"#),
+ (203_u16, r#"Binauric SE"#),
+ (204_u16, r#"Beats Electronics"#),
+ (205_u16, r#"Microchip Technology Inc."#),
+ (206_u16, r#"Elgato Systems GmbH"#),
+ (207_u16, r#"ARCHOS SA"#),
+ (208_u16, r#"Dexcom, Inc."#),
+ (209_u16, r#"Polar Electro Europe B.V."#),
+ (210_u16, r#"Dialog Semiconductor B.V."#),
+ (211_u16, r#"Taixingbang Technology (HK) Co,. LTD."#),
+ (212_u16, r#"Kawantech"#),
+ (213_u16, r#"Austco Communication Systems"#),
+ (214_u16, r#"Timex Group USA, Inc."#),
+ (215_u16, r#"Qualcomm Technologies, Inc."#),
+ (216_u16, r#"Qualcomm Connected Experiences, Inc."#),
+ (217_u16, r#"Voyetra Turtle Beach"#),
+ (218_u16, r#"txtr GmbH"#),
+ (219_u16, r#"Biosentronics"#),
+ (220_u16, r#"Procter & Gamble"#),
+ (221_u16, r#"Hosiden Corporation"#),
+ (222_u16, r#"Muzik LLC"#),
+ (223_u16, r#"Misfit Wearables Corp"#),
+ (224_u16, r#"Google"#),
+ (225_u16, r#"Danlers Ltd"#),
+ (226_u16, r#"Semilink Inc"#),
+ (227_u16, r#"inMusic Brands, Inc"#),
+ (228_u16, r#"Laird Connectivity, Inc. formerly L.S. Research Inc."#),
+ (229_u16, r#"Eden Software Consultants Ltd."#),
+ (230_u16, r#"Freshtemp"#),
+ (231_u16, r#"KS Technologies"#),
+ (232_u16, r#"ACTS Technologies"#),
+ (233_u16, r#"Vtrack Systems"#),
+ (234_u16, r#"Nielsen-Kellerman Company"#),
+ (235_u16, r#"Server Technology Inc."#),
+ (236_u16, r#"BioResearch Associates"#),
+ (237_u16, r#"Jolly Logic, LLC"#),
+ (238_u16, r#"Above Average Outcomes, Inc."#),
+ (239_u16, r#"Bitsplitters GmbH"#),
+ (240_u16, r#"PayPal, Inc."#),
+ (241_u16, r#"Witron Technology Limited"#),
+ (242_u16, r#"Morse Project Inc."#),
+ (243_u16, r#"Kent Displays Inc."#),
+ (244_u16, r#"Nautilus Inc."#),
+ (245_u16, r#"Smartifier Oy"#),
+ (246_u16, r#"Elcometer Limited"#),
+ (247_u16, r#"VSN Technologies, Inc."#),
+ (248_u16, r#"AceUni Corp., Ltd."#),
+ (249_u16, r#"StickNFind"#),
+ (250_u16, r#"Crystal Code AB"#),
+ (251_u16, r#"KOUKAAM a.s."#),
+ (252_u16, r#"Delphi Corporation"#),
+ (253_u16, r#"ValenceTech Limited"#),
+ (254_u16, r#"Stanley Black and Decker"#),
+ (255_u16, r#"Typo Products, LLC"#),
+ (256_u16, r#"TomTom International BV"#),
+ (257_u16, r#"Fugoo, Inc."#),
+ (258_u16, r#"Keiser Corporation"#),
+ (259_u16, r#"Bang & Olufsen A/S"#),
+ (260_u16, r#"PLUS Location Systems Pty Ltd"#),
+ (261_u16, r#"Ubiquitous Computing Technology Corporation"#),
+ (262_u16, r#"Innovative Yachtter Solutions"#),
+ (263_u16, r#"William Demant Holding A/S"#),
+ (264_u16, r#"Chicony Electronics Co., Ltd."#),
+ (265_u16, r#"Atus BV"#),
+ (266_u16, r#"Codegate Ltd"#),
+ (267_u16, r#"ERi, Inc"#),
+ (268_u16, r#"Transducers Direct, LLC"#),
+ (269_u16, r#"DENSO TEN LIMITED (formerly Fujitsu Ten LImited)"#),
+ (270_u16, r#"Audi AG"#),
+ (271_u16, r#"HiSilicon Technologies CO., LIMITED"#),
+ (272_u16, r#"Nippon Seiki Co., Ltd."#),
+ (273_u16, r#"Steelseries ApS"#),
+ (274_u16, r#"Visybl Inc."#),
+ (275_u16, r#"Openbrain Technologies, Co., Ltd."#),
+ (276_u16, r#"Xensr"#),
+ (277_u16, r#"e.solutions"#),
+ (278_u16, r#"10AK Technologies"#),
+ (279_u16, r#"Wimoto Technologies Inc"#),
+ (280_u16, r#"Radius Networks, Inc."#),
+ (281_u16, r#"Wize Technology Co., Ltd."#),
+ (282_u16, r#"Qualcomm Labs, Inc."#),
+ (283_u16, r#"Hewlett Packard Enterprise"#),
+ (284_u16, r#"Baidu"#),
+ (285_u16, r#"Arendi AG"#),
+ (286_u16, r#"Skoda Auto a.s."#),
+ (287_u16, r#"Volkswagen AG"#),
+ (288_u16, r#"Porsche AG"#),
+ (289_u16, r#"Sino Wealth Electronic Ltd."#),
+ (290_u16, r#"AirTurn, Inc."#),
+ (291_u16, r#"Kinsa, Inc"#),
+ (292_u16, r#"HID Global"#),
+ (293_u16, r#"SEAT es"#),
+ (294_u16, r#"Promethean Ltd."#),
+ (295_u16, r#"Salutica Allied Solutions"#),
+ (296_u16, r#"GPSI Group Pty Ltd"#),
+ (297_u16, r#"Nimble Devices Oy"#),
+ (298_u16, r#"Changzhou Yongse Infotech Co., Ltd."#),
+ (299_u16, r#"SportIQ"#),
+ (300_u16, r#"TEMEC Instruments B.V."#),
+ (301_u16, r#"Sony Corporation"#),
+ (302_u16, r#"ASSA ABLOY"#),
+ (303_u16, r#"Clarion Co. Inc."#),
+ (304_u16, r#"Warehouse Innovations"#),
+ (305_u16, r#"Cypress Semiconductor"#),
+ (306_u16, r#"MADS Inc"#),
+ (307_u16, r#"Blue Maestro Limited"#),
+ (308_u16, r#"Resolution Products, Ltd."#),
+ (309_u16, r#"Aireware LLC"#),
+ (310_u16, r#"Silvair, Inc."#),
+ (311_u16, r#"Prestigio Plaza Ltd."#),
+ (312_u16, r#"NTEO Inc."#),
+ (313_u16, r#"Focus Systems Corporation"#),
+ (314_u16, r#"Tencent Holdings Ltd."#),
+ (315_u16, r#"Allegion"#),
+ (316_u16, r#"Murata Manufacturing Co., Ltd."#),
+ (317_u16, r#"WirelessWERX"#),
+ (318_u16, r#"Nod, Inc."#),
+ (319_u16, r#"B&B Manufacturing Company"#),
+ (320_u16, r#"Alpine Electronics (China) Co., Ltd"#),
+ (321_u16, r#"FedEx Services"#),
+ (322_u16, r#"Grape Systems Inc."#),
+ (323_u16, r#"Bkon Connect"#),
+ (324_u16, r#"Lintech GmbH"#),
+ (325_u16, r#"Novatel Wireless"#),
+ (326_u16, r#"Ciright"#),
+ (327_u16, r#"Mighty Cast, Inc."#),
+ (328_u16, r#"Ambimat Electronics"#),
+ (329_u16, r#"Perytons Ltd."#),
+ (330_u16, r#"Tivoli Audio, LLC"#),
+ (331_u16, r#"Master Lock"#),
+ (332_u16, r#"Mesh-Net Ltd"#),
+ (333_u16, r#"HUIZHOU DESAY SV AUTOMOTIVE CO., LTD."#),
+ (334_u16, r#"Tangerine, Inc."#),
+ (335_u16, r#"B&W Group Ltd."#),
+ (336_u16, r#"Pioneer Corporation"#),
+ (337_u16, r#"OnBeep"#),
+ (338_u16, r#"Vernier Software & Technology"#),
+ (339_u16, r#"ROL Ergo"#),
+ (340_u16, r#"Pebble Technology"#),
+ (341_u16, r#"NETATMO"#),
+ (342_u16, r#"Accumulate AB"#),
+ (343_u16, r#"Anhui Huami Information Technology Co., Ltd."#),
+ (344_u16, r#"Inmite s.r.o."#),
+ (345_u16, r#"ChefSteps, Inc."#),
+ (346_u16, r#"micas AG"#),
+ (347_u16, r#"Biomedical Research Ltd."#),
+ (348_u16, r#"Pitius Tec S.L."#),
+ (349_u16, r#"Estimote, Inc."#),
+ (350_u16, r#"Unikey Technologies, Inc."#),
+ (351_u16, r#"Timer Cap Co."#),
+ (352_u16, r#"Awox formerly AwoX"#),
+ (353_u16, r#"yikes"#),
+ (354_u16, r#"MADSGlobalNZ Ltd."#),
+ (355_u16, r#"PCH International"#),
+ (356_u16, r#"Qingdao Yeelink Information Technology Co., Ltd."#),
+ (357_u16, r#"Milwaukee Tool (Formally Milwaukee Electric Tools)"#),
+ (358_u16, r#"MISHIK Pte Ltd"#),
+ (359_u16, r#"Ascensia Diabetes Care US Inc."#),
+ (360_u16, r#"Spicebox LLC"#),
+ (361_u16, r#"emberlight"#),
+ (362_u16, r#"Cooper-Atkins Corporation"#),
+ (363_u16, r#"Qblinks"#),
+ (364_u16, r#"MYSPHERA"#),
+ (365_u16, r#"LifeScan Inc"#),
+ (366_u16, r#"Volantic AB"#),
+ (367_u16, r#"Podo Labs, Inc"#),
+ (368_u16, r#"Roche Diabetes Care AG"#),
+ (369_u16, r#"Amazon.com Services, LLC (formerly Amazon Fulfillment Service)"#),
+ (370_u16, r#"Connovate Technology Private Limited"#),
+ (371_u16, r#"Kocomojo, LLC"#),
+ (372_u16, r#"Everykey Inc."#),
+ (373_u16, r#"Dynamic Controls"#),
+ (374_u16, r#"SentriLock"#),
+ (375_u16, r#"I-SYST inc."#),
+ (376_u16, r#"CASIO COMPUTER CO., LTD."#),
+ (377_u16, r#"LAPIS Technology Co., Ltd. formerly LAPIS Semiconductor Co., Ltd."#),
+ (378_u16, r#"Telemonitor, Inc."#),
+ (379_u16, r#"taskit GmbH"#),
+ (380_u16, r#"Daimler AG"#),
+ (381_u16, r#"BatAndCat"#),
+ (382_u16, r#"BluDotz Ltd"#),
+ (383_u16, r#"XTel Wireless ApS"#),
+ (384_u16, r#"Gigaset Communications GmbH"#),
+ (385_u16, r#"Gecko Health Innovations, Inc."#),
+ (386_u16, r#"HOP Ubiquitous"#),
+ (387_u16, r#"Walt Disney"#),
+ (388_u16, r#"Nectar"#),
+ (389_u16, r#"bel'apps LLC"#),
+ (390_u16, r#"CORE Lighting Ltd"#),
+ (391_u16, r#"Seraphim Sense Ltd"#),
+ (392_u16, r#"Unico RBC"#),
+ (393_u16, r#"Physical Enterprises Inc."#),
+ (394_u16, r#"Able Trend Technology Limited"#),
+ (395_u16, r#"Konica Minolta, Inc."#),
+ (396_u16, r#"Wilo SE"#),
+ (397_u16, r#"Extron Design Services"#),
+ (398_u16, r#"Fitbit, Inc."#),
+ (399_u16, r#"Fireflies Systems"#),
+ (400_u16, r#"Intelletto Technologies Inc."#),
+ (401_u16, r#"FDK CORPORATION"#),
+ (402_u16, r#"Cloudleaf, Inc"#),
+ (403_u16, r#"Maveric Automation LLC"#),
+ (404_u16, r#"Acoustic Stream Corporation"#),
+ (405_u16, r#"Zuli"#),
+ (406_u16, r#"Paxton Access Ltd"#),
+ (407_u16, r#"WiSilica Inc."#),
+ (408_u16, r#"VENGIT Korlatolt Felelossegu Tarsasag"#),
+ (409_u16, r#"SALTO SYSTEMS S.L."#),
+ (410_u16, r#"TRON Forum (formerly T-Engine Forum)"#),
+ (411_u16, r#"CUBETECH s.r.o."#),
+ (412_u16, r#"Cokiya Incorporated"#),
+ (413_u16, r#"CVS Health"#),
+ (414_u16, r#"Ceruus"#),
+ (415_u16, r#"Strainstall Ltd"#),
+ (416_u16, r#"Channel Enterprises (HK) Ltd."#),
+ (417_u16, r#"FIAMM"#),
+ (418_u16, r#"GIGALANE.CO.,LTD"#),
+ (419_u16, r#"EROAD"#),
+ (420_u16, r#"Mine Safety Appliances"#),
+ (421_u16, r#"Icon Health and Fitness"#),
+ (422_u16, r#"Wille Engineering (formely as Asandoo GmbH)"#),
+ (423_u16, r#"ENERGOUS CORPORATION"#),
+ (424_u16, r#"Taobao"#),
+ (425_u16, r#"Canon Inc."#),
+ (426_u16, r#"Geophysical Technology Inc."#),
+ (427_u16, r#"Facebook, Inc."#),
+ (428_u16, r#"Trividia Health, Inc."#),
+ (429_u16, r#"FlightSafety International"#),
+ (430_u16, r#"Earlens Corporation"#),
+ (431_u16, r#"Sunrise Micro Devices, Inc."#),
+ (432_u16, r#"Star Micronics Co., Ltd."#),
+ (433_u16, r#"Netizens Sp. z o.o."#),
+ (434_u16, r#"Nymi Inc."#),
+ (435_u16, r#"Nytec, Inc."#),
+ (436_u16, r#"Trineo Sp. z o.o."#),
+ (437_u16, r#"Nest Labs Inc."#),
+ (438_u16, r#"LM Technologies Ltd"#),
+ (439_u16, r#"General Electric Company"#),
+ (440_u16, r#"i+D3 S.L."#),
+ (441_u16, r#"HANA Micron"#),
+ (442_u16, r#"Stages Cycling LLC"#),
+ (443_u16, r#"Cochlear Bone Anchored Solutions AB"#),
+ (444_u16, r#"SenionLab AB"#),
+ (445_u16, r#"Syszone Co., Ltd"#),
+ (446_u16, r#"Pulsate Mobile Ltd."#),
+ (447_u16, r#"Hong Kong HunterSun Electronic Limited"#),
+ (448_u16, r#"pironex GmbH"#),
+ (449_u16, r#"BRADATECH Corp."#),
+ (450_u16, r#"Transenergooil AG"#),
+ (451_u16, r#"Bunch"#),
+ (452_u16, r#"DME Microelectronics"#),
+ (453_u16, r#"Bitcraze AB"#),
+ (454_u16, r#"HASWARE Inc."#),
+ (455_u16, r#"Abiogenix Inc."#),
+ (456_u16, r#"Poly-Control ApS"#),
+ (457_u16, r#"Avi-on"#),
+ (458_u16, r#"Laerdal Medical AS"#),
+ (459_u16, r#"Fetch My Pet"#),
+ (460_u16, r#"Sam Labs Ltd."#),
+ (461_u16, r#"Chengdu Synwing Technology Ltd"#),
+ (462_u16, r#"HOUWA SYSTEM DESIGN, k.k."#),
+ (463_u16, r#"BSH"#),
+ (464_u16, r#"Primus Inter Pares Ltd"#),
+ (465_u16, r#"August Home, Inc"#),
+ (466_u16, r#"Gill Electronics"#),
+ (467_u16, r#"Sky Wave Design"#),
+ (468_u16, r#"Newlab S.r.l."#),
+ (469_u16, r#"ELAD srl"#),
+ (470_u16, r#"G-wearables inc."#),
+ (471_u16, r#"Squadrone Systems Inc."#),
+ (472_u16, r#"Code Corporation"#),
+ (473_u16, r#"Savant Systems LLC"#),
+ (474_u16, r#"Logitech International SA"#),
+ (475_u16, r#"Innblue Consulting"#),
+ (476_u16, r#"iParking Ltd."#),
+ (477_u16, r#"Koninklijke Philips Electronics N.V."#),
+ (478_u16, r#"Minelab Electronics Pty Limited"#),
+ (479_u16, r#"Bison Group Ltd."#),
+ (480_u16, r#"Widex A/S"#),
+ (481_u16, r#"Jolla Ltd"#),
+ (482_u16, r#"Lectronix, Inc."#),
+ (483_u16, r#"Caterpillar Inc"#),
+ (484_u16, r#"Freedom Innovations"#),
+ (485_u16, r#"Dynamic Devices Ltd"#),
+ (486_u16, r#"Technology Solutions (UK) Ltd"#),
+ (487_u16, r#"IPS Group Inc."#),
+ (488_u16, r#"STIR"#),
+ (489_u16, r#"Sano, Inc."#),
+ (490_u16, r#"Advanced Application Design, Inc."#),
+ (491_u16, r#"AutoMap LLC"#),
+ (492_u16, r#"Spreadtrum Communications Shanghai Ltd"#),
+ (493_u16, r#"CuteCircuit LTD"#),
+ (494_u16, r#"Valeo Service"#),
+ (495_u16, r#"Fullpower Technologies, Inc."#),
+ (496_u16, r#"KloudNation"#),
+ (497_u16, r#"Zebra Technologies Corporation"#),
+ (498_u16, r#"Itron, Inc."#),
+ (499_u16, r#"The University of Tokyo"#),
+ (500_u16, r#"UTC Fire and Security"#),
+ (501_u16, r#"Cool Webthings Limited"#),
+ (502_u16, r#"DJO Global"#),
+ (503_u16, r#"Gelliner Limited"#),
+ (504_u16, r#"Anyka (Guangzhou) Microelectronics Technology Co, LTD"#),
+ (505_u16, r#"Medtronic Inc."#),
+ (506_u16, r#"Gozio Inc."#),
+ (507_u16, r#"Form Lifting, LLC"#),
+ (508_u16, r#"Wahoo Fitness, LLC"#),
+ (509_u16, r#"Kontakt Micro-Location Sp. z o.o."#),
+ (510_u16, r#"Radio Systems Corporation"#),
+ (511_u16, r#"Freescale Semiconductor, Inc."#),
+ (512_u16, r#"Verifone Systems Pte Ltd. Taiwan Branch"#),
+ (513_u16, r#"AR Timing"#),
+ (514_u16, r#"Rigado LLC"#),
+ (515_u16, r#"Kemppi Oy"#),
+ (516_u16, r#"Tapcentive Inc."#),
+ (517_u16, r#"Smartbotics Inc."#),
+ (518_u16, r#"Otter Products, LLC"#),
+ (519_u16, r#"STEMP Inc."#),
+ (520_u16, r#"LumiGeek LLC"#),
+ (521_u16, r#"InvisionHeart Inc."#),
+ (522_u16, r#"Macnica Inc."#),
+ (523_u16, r#"Jaguar Land Rover Limited"#),
+ (524_u16, r#"CoroWare Technologies, Inc"#),
+ (525_u16, r#"Simplo Technology Co., LTD"#),
+ (526_u16, r#"Omron Healthcare Co., LTD"#),
+ (527_u16, r#"Comodule GMBH"#),
+ (528_u16, r#"ikeGPS"#),
+ (529_u16, r#"Telink Semiconductor Co. Ltd"#),
+ (530_u16, r#"Interplan Co., Ltd"#),
+ (531_u16, r#"Wyler AG"#),
+ (532_u16, r#"IK Multimedia Production srl"#),
+ (533_u16, r#"Lukoton Experience Oy"#),
+ (534_u16, r#"MTI Ltd"#),
+ (535_u16, r#"Tech4home, Lda"#),
+ (536_u16, r#"Hiotech AB"#),
+ (537_u16, r#"DOTT Limited"#),
+ (538_u16, r#"Blue Speck Labs, LLC"#),
+ (539_u16, r#"Cisco Systems, Inc"#),
+ (540_u16, r#"Mobicomm Inc"#),
+ (541_u16, r#"Edamic"#),
+ (542_u16, r#"Goodnet, Ltd"#),
+ (543_u16, r#"Luster Leaf Products Inc"#),
+ (544_u16, r#"Manus Machina BV"#),
+ (545_u16, r#"Mobiquity Networks Inc"#),
+ (546_u16, r#"Praxis Dynamics"#),
+ (547_u16, r#"Philip Morris Products S.A."#),
+ (548_u16, r#"Comarch SA"#),
+ (549_u16, r#"Nestlé Nespresso S.A."#),
+ (550_u16, r#"Merlinia A/S"#),
+ (551_u16, r#"LifeBEAM Technologies"#),
+ (552_u16, r#"Twocanoes Labs, LLC"#),
+ (553_u16, r#"Muoverti Limited"#),
+ (554_u16, r#"Stamer Musikanlagen GMBH"#),
+ (555_u16, r#"Tesla Motors"#),
+ (556_u16, r#"Pharynks Corporation"#),
+ (557_u16, r#"Lupine"#),
+ (558_u16, r#"Siemens AG"#),
+ (559_u16, r#"Huami (Shanghai) Culture Communication CO., LTD"#),
+ (560_u16, r#"Foster Electric Company, Ltd"#),
+ (561_u16, r#"ETA SA"#),
+ (562_u16, r#"x-Senso Solutions Kft"#),
+ (563_u16, r#"Shenzhen SuLong Communication Ltd"#),
+ (564_u16, r#"FengFan (BeiJing) Technology Co, Ltd"#),
+ (565_u16, r#"Qrio Inc"#),
+ (566_u16, r#"Pitpatpet Ltd"#),
+ (567_u16, r#"MSHeli s.r.l."#),
+ (568_u16, r#"Trakm8 Ltd"#),
+ (569_u16, r#"JIN CO, Ltd"#),
+ (570_u16, r#"Alatech Tehnology"#),
+ (571_u16, r#"Beijing CarePulse Electronic Technology Co, Ltd"#),
+ (572_u16, r#"Awarepoint"#),
+ (573_u16, r#"ViCentra B.V."#),
+ (574_u16, r#"Raven Industries"#),
+ (575_u16, r#"WaveWare Technologies Inc."#),
+ (576_u16, r#"Argenox Technologies"#),
+ (577_u16, r#"Bragi GmbH"#),
+ (578_u16, r#"16Lab Inc"#),
+ (579_u16, r#"Masimo Corp"#),
+ (580_u16, r#"Iotera Inc"#),
+ (581_u16, r#"Endress+Hauser "#),
+ (582_u16, r#"ACKme Networks, Inc."#),
+ (583_u16, r#"FiftyThree Inc."#),
+ (584_u16, r#"Parker Hannifin Corp"#),
+ (585_u16, r#"Transcranial Ltd"#),
+ (586_u16, r#"Uwatec AG"#),
+ (587_u16, r#"Orlan LLC"#),
+ (588_u16, r#"Blue Clover Devices"#),
+ (589_u16, r#"M-Way Solutions GmbH"#),
+ (590_u16, r#"Microtronics Engineering GmbH"#),
+ (591_u16, r#"Schneider Schreibgeräte GmbH"#),
+ (592_u16, r#"Sapphire Circuits LLC"#),
+ (593_u16, r#"Lumo Bodytech Inc."#),
+ (594_u16, r#"UKC Technosolution"#),
+ (595_u16, r#"Xicato Inc."#),
+ (596_u16, r#"Playbrush"#),
+ (597_u16, r#"Dai Nippon Printing Co., Ltd."#),
+ (598_u16, r#"G24 Power Limited"#),
+ (599_u16, r#"AdBabble Local Commerce Inc."#),
+ (600_u16, r#"Devialet SA"#),
+ (601_u16, r#"ALTYOR"#),
+ (602_u16, r#"University of Applied Sciences Valais/Haute Ecole Valaisanne"#),
+ (603_u16, r#"Five Interactive, LLC dba Zendo"#),
+ (604_u16, r#"NetEase(Hangzhou)Network co.Ltd."#),
+ (605_u16, r#"Lexmark International Inc."#),
+ (606_u16, r#"Fluke Corporation"#),
+ (607_u16, r#"Yardarm Technologies"#),
+ (608_u16, r#"SensaRx"#),
+ (609_u16, r#"SECVRE GmbH"#),
+ (610_u16, r#"Glacial Ridge Technologies"#),
+ (611_u16, r#"Identiv, Inc."#),
+ (612_u16, r#"DDS, Inc."#),
+ (613_u16, r#"SMK Corporation"#),
+ (614_u16, r#"Schawbel Technologies LLC"#),
+ (615_u16, r#"XMI Systems SA"#),
+ (616_u16, r#"Cerevo"#),
+ (617_u16, r#"Torrox GmbH & Co KG"#),
+ (618_u16, r#"Gemalto"#),
+ (619_u16, r#"DEKA Research & Development Corp."#),
+ (620_u16, r#"Domster Tadeusz Szydlowski"#),
+ (621_u16, r#"Technogym SPA"#),
+ (622_u16, r#"FLEURBAEY BVBA"#),
+ (623_u16, r#"Aptcode Solutions"#),
+ (624_u16, r#"LSI ADL Technology"#),
+ (625_u16, r#"Animas Corp"#),
+ (626_u16, r#"Alps Alpine Co., Ltd."#),
+ (627_u16, r#"OCEASOFT"#),
+ (628_u16, r#"Motsai Research"#),
+ (629_u16, r#"Geotab"#),
+ (630_u16, r#"E.G.O. Elektro-Geraetebau GmbH"#),
+ (631_u16, r#"bewhere inc"#),
+ (632_u16, r#"Johnson Outdoors Inc"#),
+ (633_u16, r#"steute Schaltgerate GmbH & Co. KG"#),
+ (634_u16, r#"Ekomini inc."#),
+ (635_u16, r#"DEFA AS"#),
+ (636_u16, r#"Aseptika Ltd"#),
+ (637_u16, r#"HUAWEI Technologies Co., Ltd."#),
+ (638_u16, r#"HabitAware, LLC"#),
+ (639_u16, r#"ruwido austria gmbh"#),
+ (640_u16, r#"ITEC corporation"#),
+ (641_u16, r#"StoneL"#),
+ (642_u16, r#"Sonova AG"#),
+ (643_u16, r#"Maven Machines, Inc."#),
+ (644_u16, r#"Synapse Electronics"#),
+ (645_u16, r#"Standard Innovation Inc."#),
+ (646_u16, r#"RF Code, Inc."#),
+ (647_u16, r#"Wally Ventures S.L."#),
+ (648_u16, r#"Willowbank Electronics Ltd"#),
+ (649_u16, r#"SK Telecom"#),
+ (650_u16, r#"Jetro AS"#),
+ (651_u16, r#"Code Gears LTD"#),
+ (652_u16, r#"NANOLINK APS"#),
+ (653_u16, r#"IF, LLC"#),
+ (654_u16, r#"RF Digital Corp"#),
+ (655_u16, r#"Church & Dwight Co., Inc"#),
+ (656_u16, r#"Multibit Oy"#),
+ (657_u16, r#"CliniCloud Inc"#),
+ (658_u16, r#"SwiftSensors"#),
+ (659_u16, r#"Blue Bite"#),
+ (660_u16, r#"ELIAS GmbH"#),
+ (661_u16, r#"Sivantos GmbH"#),
+ (662_u16, r#"Petzl"#),
+ (663_u16, r#"storm power ltd"#),
+ (664_u16, r#"EISST Ltd"#),
+ (665_u16, r#"Inexess Technology Simma KG"#),
+ (666_u16, r#"Currant, Inc."#),
+ (667_u16, r#"C2 Development, Inc."#),
+ (668_u16, r#"Blue Sky Scientific, LLC"#),
+ (669_u16, r#"ALOTTAZS LABS, LLC"#),
+ (670_u16, r#"Kupson spol. s r.o."#),
+ (671_u16, r#"Areus Engineering GmbH"#),
+ (672_u16, r#"Impossible Camera GmbH"#),
+ (673_u16, r#"InventureTrack Systems"#),
+ (674_u16, r#"LockedUp"#),
+ (675_u16, r#"Itude"#),
+ (676_u16, r#"Pacific Lock Company"#),
+ (677_u16, r#"Tendyron Corporation ( 天地融科技股份有限公司 )"#),
+ (678_u16, r#"Robert Bosch GmbH"#),
+ (679_u16, r#"Illuxtron international B.V."#),
+ (680_u16, r#"miSport Ltd."#),
+ (681_u16, r#"Chargelib"#),
+ (682_u16, r#"Doppler Lab"#),
+ (683_u16, r#"BBPOS Limited"#),
+ (684_u16, r#"RTB Elektronik GmbH & Co. KG"#),
+ (685_u16, r#"Rx Networks, Inc."#),
+ (686_u16, r#"WeatherFlow, Inc."#),
+ (687_u16, r#"Technicolor USA Inc."#),
+ (688_u16, r#"Bestechnic(Shanghai),Ltd"#),
+ (689_u16, r#"Raden Inc"#),
+ (690_u16, r#"JouZen Oy"#),
+ (691_u16, r#"CLABER S.P.A."#),
+ (692_u16, r#"Hyginex, Inc."#),
+ (693_u16, r#"HANSHIN ELECTRIC RAILWAY CO.,LTD."#),
+ (694_u16, r#"Schneider Electric"#),
+ (695_u16, r#"Oort Technologies LLC"#),
+ (696_u16, r#"Chrono Therapeutics"#),
+ (697_u16, r#"Rinnai Corporation"#),
+ (698_u16, r#"Swissprime Technologies AG"#),
+ (699_u16, r#"Koha.,Co.Ltd"#),
+ (700_u16, r#"Genevac Ltd"#),
+ (701_u16, r#"Chemtronics"#),
+ (702_u16, r#"Seguro Technology Sp. z o.o."#),
+ (703_u16, r#"Redbird Flight Simulations"#),
+ (704_u16, r#"Dash Robotics"#),
+ (705_u16, r#"LINE Corporation"#),
+ (706_u16, r#"Guillemot Corporation"#),
+ (707_u16, r#"Techtronic Power Tools Technology Limited"#),
+ (708_u16, r#"Wilson Sporting Goods"#),
+ (709_u16, r#"Lenovo (Singapore) Pte Ltd. ( 联想(新加坡) )"#),
+ (710_u16, r#"Ayatan Sensors"#),
+ (711_u16, r#"Electronics Tomorrow Limited"#),
+ (712_u16, r#"VASCO Data Security International, Inc."#),
+ (713_u16, r#"PayRange Inc."#),
+ (714_u16, r#"ABOV Semiconductor"#),
+ (715_u16, r#"AINA-Wireless Inc."#),
+ (716_u16, r#"Eijkelkamp Soil & Water"#),
+ (717_u16, r#"BMA ergonomics b.v."#),
+ (718_u16, r#"Teva Branded Pharmaceutical Products R&D, Inc."#),
+ (719_u16, r#"Anima"#),
+ (720_u16, r#"3M"#),
+ (721_u16, r#"Empatica Srl"#),
+ (722_u16, r#"Afero, Inc."#),
+ (723_u16, r#"Powercast Corporation"#),
+ (724_u16, r#"Secuyou ApS"#),
+ (725_u16, r#"OMRON Corporation"#),
+ (726_u16, r#"Send Solutions"#),
+ (727_u16, r#"NIPPON SYSTEMWARE CO.,LTD."#),
+ (728_u16, r#"Neosfar"#),
+ (729_u16, r#"Fliegl Agrartechnik GmbH"#),
+ (730_u16, r#"Gilvader"#),
+ (731_u16, r#"Digi International Inc (R)"#),
+ (732_u16, r#"DeWalch Technologies, Inc."#),
+ (733_u16, r#"Flint Rehabilitation Devices, LLC"#),
+ (734_u16, r#"Samsung SDS Co., Ltd."#),
+ (735_u16, r#"Blur Product Development"#),
+ (736_u16, r#"University of Michigan"#),
+ (737_u16, r#"Victron Energy BV"#),
+ (738_u16, r#"NTT docomo"#),
+ (739_u16, r#"Carmanah Technologies Corp."#),
+ (740_u16, r#"Bytestorm Ltd."#),
+ (741_u16, r#"Espressif Incorporated ( 乐鑫信息科技(上海)有限公司 )"#),
+ (742_u16, r#"Unwire"#),
+ (743_u16, r#"Connected Yard, Inc."#),
+ (744_u16, r#"American Music Environments"#),
+ (745_u16, r#"Sensogram Technologies, Inc."#),
+ (746_u16, r#"Fujitsu Limited"#),
+ (747_u16, r#"Ardic Technology"#),
+ (748_u16, r#"Delta Systems, Inc"#),
+ (749_u16, r#"HTC Corporation "#),
+ (750_u16, r#"Citizen Holdings Co., Ltd. "#),
+ (751_u16, r#"SMART-INNOVATION.inc"#),
+ (752_u16, r#"Blackrat Software "#),
+ (753_u16, r#"The Idea Cave, LLC"#),
+ (754_u16, r#"GoPro, Inc."#),
+ (755_u16, r#"AuthAir, Inc"#),
+ (756_u16, r#"Vensi, Inc."#),
+ (757_u16, r#"Indagem Tech LLC"#),
+ (758_u16, r#"Intemo Technologies"#),
+ (759_u16, r#"DreamVisions co., Ltd."#),
+ (760_u16, r#"Runteq Oy Ltd"#),
+ (761_u16, r#"IMAGINATION TECHNOLOGIES LTD "#),
+ (762_u16, r#"CoSTAR TEchnologies"#),
+ (763_u16, r#"Clarius Mobile Health Corp."#),
+ (764_u16, r#"Shanghai Frequen Microelectronics Co., Ltd."#),
+ (765_u16, r#"Uwanna, Inc."#),
+ (766_u16, r#"Lierda Science & Technology Group Co., Ltd."#),
+ (767_u16, r#"Silicon Laboratories"#),
+ (768_u16, r#"World Moto Inc."#),
+ (769_u16, r#"Giatec Scientific Inc."#),
+ (770_u16, r#"Loop Devices, Inc"#),
+ (771_u16, r#"IACA electronique"#),
+ (772_u16, r#"Proxy Technologies, Inc."#),
+ (773_u16, r#"Swipp ApS"#),
+ (774_u16, r#"Life Laboratory Inc. "#),
+ (775_u16, r#"FUJI INDUSTRIAL CO.,LTD."#),
+ (776_u16, r#"Surefire, LLC"#),
+ (777_u16, r#"Dolby Labs"#),
+ (778_u16, r#"Ellisys"#),
+ (779_u16, r#"Magnitude Lighting Converters"#),
+ (780_u16, r#"Hilti AG"#),
+ (781_u16, r#"Devdata S.r.l."#),
+ (782_u16, r#"Deviceworx"#),
+ (783_u16, r#"Shortcut Labs"#),
+ (784_u16, r#"SGL Italia S.r.l."#),
+ (785_u16, r#"PEEQ DATA"#),
+ (786_u16, r#"Ducere Technologies Pvt Ltd "#),
+ (787_u16, r#"DiveNav, Inc. "#),
+ (788_u16, r#"RIIG AI Sp. z o.o."#),
+ (789_u16, r#"Thermo Fisher Scientific "#),
+ (790_u16, r#"AG Measurematics Pvt. Ltd. "#),
+ (791_u16, r#"CHUO Electronics CO., LTD. "#),
+ (792_u16, r#"Aspenta International "#),
+ (793_u16, r#"Eugster Frismag AG "#),
+ (794_u16, r#"Amber wireless GmbH "#),
+ (795_u16, r#"HQ Inc "#),
+ (796_u16, r#"Lab Sensor Solutions "#),
+ (797_u16, r#"Enterlab ApS "#),
+ (798_u16, r#"Eyefi, Inc."#),
+ (799_u16, r#"MetaSystem S.p.A. "#),
+ (800_u16, r#"SONO ELECTRONICS. CO., LTD "#),
+ (801_u16, r#"Jewelbots "#),
+ (802_u16, r#"Compumedics Limited "#),
+ (803_u16, r#"Rotor Bike Components "#),
+ (804_u16, r#"Astro, Inc. "#),
+ (805_u16, r#"Amotus Solutions "#),
+ (806_u16, r#"Healthwear Technologies (Changzhou)Ltd "#),
+ (807_u16, r#"Essex Electronics "#),
+ (808_u16, r#"Grundfos A/S"#),
+ (809_u16, r#"Eargo, Inc. "#),
+ (810_u16, r#"Electronic Design Lab "#),
+ (811_u16, r#"ESYLUX "#),
+ (812_u16, r#"NIPPON SMT.CO.,Ltd"#),
+ (813_u16, r#"BM innovations GmbH "#),
+ (814_u16, r#"indoormap"#),
+ (815_u16, r#"OttoQ Inc "#),
+ (816_u16, r#"North Pole Engineering "#),
+ (817_u16, r#"3flares Technologies Inc."#),
+ (818_u16, r#"Electrocompaniet A.S. "#),
+ (819_u16, r#"Mul-T-Lock"#),
+ (820_u16, r#"Corentium AS "#),
+ (821_u16, r#"Enlighted Inc"#),
+ (822_u16, r#"GISTIC"#),
+ (823_u16, r#"AJP2 Holdings, LLC"#),
+ (824_u16, r#"COBI GmbH "#),
+ (825_u16, r#"Blue Sky Scientific, LLC "#),
+ (826_u16, r#"Appception, Inc."#),
+ (827_u16, r#"Courtney Thorne Limited "#),
+ (828_u16, r#"Virtuosys"#),
+ (829_u16, r#"TPV Technology Limited "#),
+ (830_u16, r#"Monitra SA"#),
+ (831_u16, r#"Automation Components, Inc. "#),
+ (832_u16, r#"Letsense s.r.l. "#),
+ (833_u16, r#"Etesian Technologies LLC "#),
+ (834_u16, r#"GERTEC BRASIL LTDA. "#),
+ (835_u16, r#"Drekker Development Pty. Ltd."#),
+ (836_u16, r#"Whirl Inc "#),
+ (837_u16, r#"Locus Positioning "#),
+ (838_u16, r#"Acuity Brands Lighting, Inc "#),
+ (839_u16, r#"Prevent Biometrics "#),
+ (840_u16, r#"Arioneo"#),
+ (841_u16, r#"VersaMe "#),
+ (842_u16, r#"Vaddio "#),
+ (843_u16, r#"Libratone A/S "#),
+ (844_u16, r#"HM Electronics, Inc. "#),
+ (845_u16, r#"TASER International, Inc."#),
+ (846_u16, r#"SafeTrust Inc. "#),
+ (847_u16, r#"Heartland Payment Systems "#),
+ (848_u16, r#"Bitstrata Systems Inc. "#),
+ (849_u16, r#"Pieps GmbH "#),
+ (850_u16, r#"iRiding(Xiamen)Technology Co.,Ltd."#),
+ (851_u16, r#"Alpha Audiotronics, Inc. "#),
+ (852_u16, r#"TOPPAN FORMS CO.,LTD. "#),
+ (853_u16, r#"Sigma Designs, Inc. "#),
+ (854_u16, r#"Spectrum Brands, Inc. "#),
+ (855_u16, r#"Polymap Wireless "#),
+ (856_u16, r#"MagniWare Ltd."#),
+ (857_u16, r#"Novotec Medical GmbH "#),
+ (858_u16, r#"Medicom Innovation Partner a/s "#),
+ (859_u16, r#"Matrix Inc. "#),
+ (860_u16, r#"Eaton Corporation "#),
+ (861_u16, r#"KYS"#),
+ (862_u16, r#"Naya Health, Inc. "#),
+ (863_u16, r#"Acromag "#),
+ (864_u16, r#"Insulet Corporation "#),
+ (865_u16, r#"Wellinks Inc. "#),
+ (866_u16, r#"ON Semiconductor"#),
+ (867_u16, r#"FREELAP SA "#),
+ (868_u16, r#"Favero Electronics Srl "#),
+ (869_u16, r#"BioMech Sensor LLC "#),
+ (870_u16, r#"BOLTT Sports technologies Private limited"#),
+ (871_u16, r#"Saphe International "#),
+ (872_u16, r#"Metormote AB "#),
+ (873_u16, r#"littleBits "#),
+ (874_u16, r#"SetPoint Medical "#),
+ (875_u16, r#"BRControls Products BV "#),
+ (876_u16, r#"Zipcar "#),
+ (877_u16, r#"AirBolt Pty Ltd "#),
+ (878_u16, r#"KeepTruckin Inc "#),
+ (879_u16, r#"Motiv, Inc. "#),
+ (880_u16, r#"Wazombi Labs OÜ "#),
+ (881_u16, r#"ORBCOMM"#),
+ (882_u16, r#"Nixie Labs, Inc."#),
+ (883_u16, r#"AppNearMe Ltd"#),
+ (884_u16, r#"Holman Industries"#),
+ (885_u16, r#"Expain AS"#),
+ (886_u16, r#"Electronic Temperature Instruments Ltd"#),
+ (887_u16, r#"Plejd AB"#),
+ (888_u16, r#"Propeller Health"#),
+ (889_u16, r#"Shenzhen iMCO Electronic Technology Co.,Ltd"#),
+ (890_u16, r#"Algoria"#),
+ (891_u16, r#"Apption Labs Inc."#),
+ (892_u16, r#"Cronologics Corporation"#),
+ (893_u16, r#"MICRODIA Ltd."#),
+ (894_u16, r#"lulabytes S.L."#),
+ (895_u16, r#"Société des Produits Nestlé S.A. (formerly Nestec S.A.)"#),
+ (896_u16, r#"LLC "MEGA-F service""#),
+ (897_u16, r#"Sharp Corporation"#),
+ (898_u16, r#"Precision Outcomes Ltd"#),
+ (899_u16, r#"Kronos Incorporated"#),
+ (900_u16, r#"OCOSMOS Co., Ltd."#),
+ (901_u16, r#"Embedded Electronic Solutions Ltd. dba e2Solutions"#),
+ (902_u16, r#"Aterica Inc."#),
+ (903_u16, r#"BluStor PMC, Inc."#),
+ (904_u16, r#"Kapsch TrafficCom AB"#),
+ (905_u16, r#"ActiveBlu Corporation"#),
+ (906_u16, r#"Kohler Mira Limited"#),
+ (907_u16, r#"Noke"#),
+ (908_u16, r#"Appion Inc."#),
+ (909_u16, r#"Resmed Ltd"#),
+ (910_u16, r#"Crownstone B.V."#),
+ (911_u16, r#"Xiaomi Inc."#),
+ (912_u16, r#"INFOTECH s.r.o."#),
+ (913_u16, r#"Thingsquare AB"#),
+ (914_u16, r#"T&D"#),
+ (915_u16, r#"LAVAZZA S.p.A."#),
+ (916_u16, r#"Netclearance Systems, Inc."#),
+ (917_u16, r#"SDATAWAY"#),
+ (918_u16, r#"BLOKS GmbH"#),
+ (919_u16, r#"LEGO System A/S"#),
+ (920_u16, r#"Thetatronics Ltd"#),
+ (921_u16, r#"Nikon Corporation"#),
+ (922_u16, r#"NeST"#),
+ (923_u16, r#"South Silicon Valley Microelectronics"#),
+ (924_u16, r#"ALE International"#),
+ (925_u16, r#"CareView Communications, Inc."#),
+ (926_u16, r#"SchoolBoard Limited"#),
+ (927_u16, r#"Molex Corporation"#),
+ (928_u16, r#"IVT Wireless Limited"#),
+ (929_u16, r#"Alpine Labs LLC"#),
+ (930_u16, r#"Candura Instruments"#),
+ (931_u16, r#"SmartMovt Technology Co., Ltd"#),
+ (932_u16, r#"Token Zero Ltd"#),
+ (933_u16, r#"ACE CAD Enterprise Co., Ltd. (ACECAD)"#),
+ (934_u16, r#"Medela, Inc"#),
+ (935_u16, r#"AeroScout"#),
+ (936_u16, r#"Esrille Inc."#),
+ (937_u16, r#"THINKERLY SRL"#),
+ (938_u16, r#"Exon Sp. z o.o."#),
+ (939_u16, r#"Meizu Technology Co., Ltd."#),
+ (940_u16, r#"Smablo LTD"#),
+ (941_u16, r#"XiQ"#),
+ (942_u16, r#"Allswell Inc."#),
+ (943_u16, r#"Comm-N-Sense Corp DBA Verigo"#),
+ (944_u16, r#"VIBRADORM GmbH"#),
+ (945_u16, r#"Otodata Wireless Network Inc."#),
+ (946_u16, r#"Propagation Systems Limited"#),
+ (947_u16, r#"Midwest Instruments & Controls"#),
+ (948_u16, r#"Alpha Nodus, inc."#),
+ (949_u16, r#"petPOMM, Inc"#),
+ (950_u16, r#"Mattel"#),
+ (951_u16, r#"Airbly Inc."#),
+ (952_u16, r#"A-Safe Limited"#),
+ (953_u16, r#"FREDERIQUE CONSTANT SA"#),
+ (954_u16, r#"Maxscend Microelectronics Company Limited"#),
+ (955_u16, r#"Abbott"#),
+ (956_u16, r#"ASB Bank Ltd"#),
+ (957_u16, r#"amadas"#),
+ (958_u16, r#"Applied Science, Inc."#),
+ (959_u16, r#"iLumi Solutions Inc."#),
+ (960_u16, r#"Arch Systems Inc."#),
+ (961_u16, r#"Ember Technologies, Inc."#),
+ (962_u16, r#"Snapchat Inc"#),
+ (963_u16, r#"Casambi Technologies Oy"#),
+ (964_u16, r#"Pico Technology Inc."#),
+ (965_u16, r#"St. Jude Medical, Inc."#),
+ (966_u16, r#"Intricon"#),
+ (967_u16, r#"Structural Health Systems, Inc."#),
+ (968_u16, r#"Avvel International"#),
+ (969_u16, r#"Gallagher Group"#),
+ (970_u16, r#"In2things Automation Pvt. Ltd."#),
+ (971_u16, r#"SYSDEV Srl"#),
+ (972_u16, r#"Vonkil Technologies Ltd"#),
+ (973_u16, r#"Wynd Technologies, Inc."#),
+ (974_u16, r#"CONTRINEX S.A."#),
+ (975_u16, r#"MIRA, Inc."#),
+ (976_u16, r#"Watteam Ltd"#),
+ (977_u16, r#"Density Inc."#),
+ (978_u16, r#"IOT Pot India Private Limited"#),
+ (979_u16, r#"Sigma Connectivity AB"#),
+ (980_u16, r#"PEG PEREGO SPA"#),
+ (981_u16, r#"Wyzelink Systems Inc."#),
+ (982_u16, r#"Yota Devices LTD"#),
+ (983_u16, r#"FINSECUR"#),
+ (984_u16, r#"Zen-Me Labs Ltd"#),
+ (985_u16, r#"3IWare Co., Ltd."#),
+ (986_u16, r#"EnOcean GmbH"#),
+ (987_u16, r#"Instabeat, Inc"#),
+ (988_u16, r#"Nima Labs"#),
+ (989_u16, r#"Andreas Stihl AG & Co. KG"#),
+ (990_u16, r#"Nathan Rhoades LLC"#),
+ (991_u16, r#"Grob Technologies, LLC"#),
+ (992_u16, r#"Actions (Zhuhai) Technology Co., Limited"#),
+ (993_u16, r#"SPD Development Company Ltd"#),
+ (994_u16, r#"Sensoan Oy"#),
+ (995_u16, r#"Qualcomm Life Inc"#),
+ (996_u16, r#"Chip-ing AG"#),
+ (997_u16, r#"ffly4u"#),
+ (998_u16, r#"IoT Instruments Oy"#),
+ (999_u16, r#"TRUE Fitness Technology"#),
+ (1000_u16, r#"Reiner Kartengeraete GmbH & Co. KG."#),
+ (1001_u16, r#"SHENZHEN LEMONJOY TECHNOLOGY CO., LTD."#),
+ (1002_u16, r#"Hello Inc."#),
+ (1003_u16, r#"Evollve Inc."#),
+ (1004_u16, r#"Jigowatts Inc."#),
+ (1005_u16, r#"BASIC MICRO.COM,INC."#),
+ (1006_u16, r#"CUBE TECHNOLOGIES"#),
+ (1007_u16, r#"foolography GmbH"#),
+ (1008_u16, r#"CLINK"#),
+ (1009_u16, r#"Hestan Smart Cooking Inc."#),
+ (1010_u16, r#"WindowMaster A/S"#),
+ (1011_u16, r#"Flowscape AB"#),
+ (1012_u16, r#"PAL Technologies Ltd"#),
+ (1013_u16, r#"WHERE, Inc."#),
+ (1014_u16, r#"Iton Technology Corp."#),
+ (1015_u16, r#"Owl Labs Inc."#),
+ (1016_u16, r#"Rockford Corp."#),
+ (1017_u16, r#"Becon Technologies Co.,Ltd."#),
+ (1018_u16, r#"Vyassoft Technologies Inc"#),
+ (1019_u16, r#"Nox Medical"#),
+ (1020_u16, r#"Kimberly-Clark"#),
+ (1021_u16, r#"Trimble Navigation Ltd."#),
+ (1022_u16, r#"Littelfuse"#),
+ (1023_u16, r#"Withings"#),
+ (1024_u16, r#"i-developer IT Beratung UG"#),
+ (1025_u16, r#"Relations Inc."#),
+ (1026_u16, r#"Sears Holdings Corporation"#),
+ (1027_u16, r#"Gantner Electronic GmbH"#),
+ (1028_u16, r#"Authomate Inc"#),
+ (1029_u16, r#"Vertex International, Inc."#),
+ (1030_u16, r#"Airtago"#),
+ (1031_u16, r#"Swiss Audio SA"#),
+ (1032_u16, r#"ToGetHome Inc."#),
+ (1033_u16, r#"AXIS"#),
+ (1034_u16, r#"Openmatics"#),
+ (1035_u16, r#"Jana Care Inc."#),
+ (1036_u16, r#"Senix Corporation"#),
+ (1037_u16, r#"NorthStar Battery Company, LLC"#),
+ (1038_u16, r#"SKF (U.K.) Limited"#),
+ (1039_u16, r#"CO-AX Technology, Inc."#),
+ (1040_u16, r#"Fender Musical Instruments"#),
+ (1041_u16, r#"Luidia Inc"#),
+ (1042_u16, r#"SEFAM"#),
+ (1043_u16, r#"Wireless Cables Inc"#),
+ (1044_u16, r#"Lightning Protection International Pty Ltd"#),
+ (1045_u16, r#"Uber Technologies Inc"#),
+ (1046_u16, r#"SODA GmbH"#),
+ (1047_u16, r#"Fatigue Science"#),
+ (1048_u16, r#"Reserved"#),
+ (1049_u16, r#"Novalogy LTD"#),
+ (1050_u16, r#"Friday Labs Limited"#),
+ (1051_u16, r#"OrthoAccel Technologies"#),
+ (1052_u16, r#"WaterGuru, Inc."#),
+ (1053_u16, r#"Benning Elektrotechnik und Elektronik GmbH & Co. KG"#),
+ (1054_u16, r#"Dell Computer Corporation"#),
+ (1055_u16, r#"Kopin Corporation"#),
+ (1056_u16, r#"TecBakery GmbH"#),
+ (1057_u16, r#"Backbone Labs, Inc."#),
+ (1058_u16, r#"DELSEY SA"#),
+ (1059_u16, r#"Chargifi Limited"#),
+ (1060_u16, r#"Trainesense Ltd."#),
+ (1061_u16, r#"Unify Software and Solutions GmbH & Co. KG"#),
+ (1062_u16, r#"Husqvarna AB"#),
+ (1063_u16, r#"Focus fleet and fuel management inc"#),
+ (1064_u16, r#"SmallLoop, LLC"#),
+ (1065_u16, r#"Prolon Inc."#),
+ (1066_u16, r#"BD Medical"#),
+ (1067_u16, r#"iMicroMed Incorporated"#),
+ (1068_u16, r#"Ticto N.V."#),
+ (1069_u16, r#"Meshtech AS"#),
+ (1070_u16, r#"MemCachier Inc."#),
+ (1071_u16, r#"Danfoss A/S"#),
+ (1072_u16, r#"SnapStyk Inc."#),
+ (1073_u16, r#"Amway Corporation"#),
+ (1074_u16, r#"Silk Labs, Inc."#),
+ (1075_u16, r#"Pillsy Inc."#),
+ (1076_u16, r#"Hatch Baby, Inc."#),
+ (1077_u16, r#"Blocks Wearables Ltd."#),
+ (1078_u16, r#"Drayson Technologies (Europe) Limited"#),
+ (1079_u16, r#"eBest IOT Inc."#),
+ (1080_u16, r#"Helvar Ltd"#),
+ (1081_u16, r#"Radiance Technologies"#),
+ (1082_u16, r#"Nuheara Limited"#),
+ (1083_u16, r#"Appside co., ltd."#),
+ (1084_u16, r#"DeLaval"#),
+ (1085_u16, r#"Coiler Corporation"#),
+ (1086_u16, r#"Thermomedics, Inc."#),
+ (1087_u16, r#"Tentacle Sync GmbH"#),
+ (1088_u16, r#"Valencell, Inc."#),
+ (1089_u16, r#"iProtoXi Oy"#),
+ (1090_u16, r#"SECOM CO., LTD."#),
+ (1091_u16, r#"Tucker International LLC"#),
+ (1092_u16, r#"Metanate Limited"#),
+ (1093_u16, r#"Kobian Canada Inc."#),
+ (1094_u16, r#"NETGEAR, Inc."#),
+ (1095_u16, r#"Fabtronics Australia Pty Ltd"#),
+ (1096_u16, r#"Grand Centrix GmbH"#),
+ (1097_u16, r#"1UP USA.com llc"#),
+ (1098_u16, r#"SHIMANO INC."#),
+ (1099_u16, r#"Nain Inc."#),
+ (1100_u16, r#"LifeStyle Lock, LLC"#),
+ (1101_u16, r#"VEGA Grieshaber KG"#),
+ (1102_u16, r#"Xtrava Inc."#),
+ (1103_u16, r#"TTS Tooltechnic Systems AG & Co. KG"#),
+ (1104_u16, r#"Teenage Engineering AB"#),
+ (1105_u16, r#"Tunstall Nordic AB"#),
+ (1106_u16, r#"Svep Design Center AB"#),
+ (1107_u16, r#"Qorvo Utrecht B.V. formerly GreenPeak Technologies BV"#),
+ (1108_u16, r#"Sphinx Electronics GmbH & Co KG"#),
+ (1109_u16, r#"Atomation"#),
+ (1110_u16, r#"Nemik Consulting Inc"#),
+ (1111_u16, r#"RF INNOVATION"#),
+ (1112_u16, r#"Mini Solution Co., Ltd."#),
+ (1113_u16, r#"Lumenetix, Inc"#),
+ (1114_u16, r#"2048450 Ontario Inc"#),
+ (1115_u16, r#"SPACEEK LTD"#),
+ (1116_u16, r#"Delta T Corporation"#),
+ (1117_u16, r#"Boston Scientific Corporation"#),
+ (1118_u16, r#"Nuviz, Inc."#),
+ (1119_u16, r#"Real Time Automation, Inc."#),
+ (1120_u16, r#"Kolibree"#),
+ (1121_u16, r#"vhf elektronik GmbH"#),
+ (1122_u16, r#"Bonsai Systems GmbH"#),
+ (1123_u16, r#"Fathom Systems Inc."#),
+ (1124_u16, r#"Bellman & Symfon"#),
+ (1125_u16, r#"International Forte Group LLC"#),
+ (1126_u16, r#"CycleLabs Solutions inc."#),
+ (1127_u16, r#"Codenex Oy"#),
+ (1128_u16, r#"Kynesim Ltd"#),
+ (1129_u16, r#"Palago AB"#),
+ (1130_u16, r#"INSIGMA INC."#),
+ (1131_u16, r#"PMD Solutions"#),
+ (1132_u16, r#"Qingdao Realtime Technology Co., Ltd."#),
+ (1133_u16, r#"BEGA Gantenbrink-Leuchten KG"#),
+ (1134_u16, r#"Pambor Ltd."#),
+ (1135_u16, r#"Develco Products A/S"#),
+ (1136_u16, r#"iDesign s.r.l."#),
+ (1137_u16, r#"TiVo Corp"#),
+ (1138_u16, r#"Control-J Pty Ltd"#),
+ (1139_u16, r#"Steelcase, Inc."#),
+ (1140_u16, r#"iApartment co., ltd."#),
+ (1141_u16, r#"Icom inc."#),
+ (1142_u16, r#"Oxstren Wearable Technologies Private Limited"#),
+ (1143_u16, r#"Blue Spark Technologies"#),
+ (1144_u16, r#"FarSite Communications Limited"#),
+ (1145_u16, r#"mywerk system GmbH"#),
+ (1146_u16, r#"Sinosun Technology Co., Ltd."#),
+ (1147_u16, r#"MIYOSHI ELECTRONICS CORPORATION"#),
+ (1148_u16, r#"POWERMAT LTD"#),
+ (1149_u16, r#"Occly LLC"#),
+ (1150_u16, r#"OurHub Dev IvS"#),
+ (1151_u16, r#"Pro-Mark, Inc."#),
+ (1152_u16, r#"Dynometrics Inc."#),
+ (1153_u16, r#"Quintrax Limited"#),
+ (1154_u16, r#"POS Tuning Udo Vosshenrich GmbH & Co. KG"#),
+ (1155_u16, r#"Multi Care Systems B.V."#),
+ (1156_u16, r#"Revol Technologies Inc"#),
+ (1157_u16, r#"SKIDATA AG"#),
+ (1158_u16, r#"DEV TECNOLOGIA INDUSTRIA, COMERCIO E MANUTENCAO DE EQUIPAMENTOS LTDA. - ME"#),
+ (1159_u16, r#"Centrica Connected Home"#),
+ (1160_u16, r#"Automotive Data Solutions Inc"#),
+ (1161_u16, r#"Igarashi Engineering"#),
+ (1162_u16, r#"Taelek Oy"#),
+ (1163_u16, r#"CP Electronics Limited"#),
+ (1164_u16, r#"Vectronix AG"#),
+ (1165_u16, r#"S-Labs Sp. z o.o."#),
+ (1166_u16, r#"Companion Medical, Inc."#),
+ (1167_u16, r#"BlueKitchen GmbH"#),
+ (1168_u16, r#"Matting AB"#),
+ (1169_u16, r#"SOREX - Wireless Solutions GmbH"#),
+ (1170_u16, r#"ADC Technology, Inc."#),
+ (1171_u16, r#"Lynxemi Pte Ltd"#),
+ (1172_u16, r#"SENNHEISER electronic GmbH & Co. KG"#),
+ (1173_u16, r#"LMT Mercer Group, Inc"#),
+ (1174_u16, r#"Polymorphic Labs LLC"#),
+ (1175_u16, r#"Cochlear Limited"#),
+ (1176_u16, r#"METER Group, Inc. USA"#),
+ (1177_u16, r#"Ruuvi Innovations Ltd."#),
+ (1178_u16, r#"Situne AS"#),
+ (1179_u16, r#"nVisti, LLC"#),
+ (1180_u16, r#"DyOcean"#),
+ (1181_u16, r#"Uhlmann & Zacher GmbH"#),
+ (1182_u16, r#"AND!XOR LLC"#),
+ (1183_u16, r#"tictote AB"#),
+ (1184_u16, r#"Vypin, LLC"#),
+ (1185_u16, r#"PNI Sensor Corporation"#),
+ (1186_u16, r#"ovrEngineered, LLC"#),
+ (1187_u16, r#"GT-tronics HK Ltd"#),
+ (1188_u16, r#"Herbert Waldmann GmbH & Co. KG"#),
+ (1189_u16, r#"Guangzhou FiiO Electronics Technology Co.,Ltd"#),
+ (1190_u16, r#"Vinetech Co., Ltd"#),
+ (1191_u16, r#"Dallas Logic Corporation"#),
+ (1192_u16, r#"BioTex, Inc."#),
+ (1193_u16, r#"DISCOVERY SOUND TECHNOLOGY, LLC"#),
+ (1194_u16, r#"LINKIO SAS"#),
+ (1195_u16, r#"Harbortronics, Inc."#),
+ (1196_u16, r#"Undagrid B.V."#),
+ (1197_u16, r#"Shure Inc"#),
+ (1198_u16, r#"ERM Electronic Systems LTD"#),
+ (1199_u16, r#"BIOROWER Handelsagentur GmbH"#),
+ (1200_u16, r#"Weba Sport und Med. Artikel GmbH"#),
+ (1201_u16, r#"Kartographers Technologies Pvt. Ltd."#),
+ (1202_u16, r#"The Shadow on the Moon"#),
+ (1203_u16, r#"mobike (Hong Kong) Limited"#),
+ (1204_u16, r#"Inuheat Group AB"#),
+ (1205_u16, r#"Swiftronix AB"#),
+ (1206_u16, r#"Diagnoptics Technologies"#),
+ (1207_u16, r#"Analog Devices, Inc."#),
+ (1208_u16, r#"Soraa Inc."#),
+ (1209_u16, r#"CSR Building Products Limited"#),
+ (1210_u16, r#"Crestron Electronics, Inc."#),
+ (1211_u16, r#"Neatebox Ltd"#),
+ (1212_u16, r#"Draegerwerk AG & Co. KGaA"#),
+ (1213_u16, r#"AlbynMedical"#),
+ (1214_u16, r#"Averos FZCO"#),
+ (1215_u16, r#"VIT Initiative, LLC"#),
+ (1216_u16, r#"Statsports International"#),
+ (1217_u16, r#"Sospitas, s.r.o."#),
+ (1218_u16, r#"Dmet Products Corp."#),
+ (1219_u16, r#"Mantracourt Electronics Limited"#),
+ (1220_u16, r#"TeAM Hutchins AB"#),
+ (1221_u16, r#"Seibert Williams Glass, LLC"#),
+ (1222_u16, r#"Insta GmbH"#),
+ (1223_u16, r#"Svantek Sp. z o.o."#),
+ (1224_u16, r#"Shanghai Flyco Electrical Appliance Co., Ltd."#),
+ (1225_u16, r#"Thornwave Labs Inc"#),
+ (1226_u16, r#"Steiner-Optik GmbH"#),
+ (1227_u16, r#"Novo Nordisk A/S"#),
+ (1228_u16, r#"Enflux Inc."#),
+ (1229_u16, r#"Safetech Products LLC"#),
+ (1230_u16, r#"GOOOLED S.R.L."#),
+ (1231_u16, r#"DOM Sicherheitstechnik GmbH & Co. KG"#),
+ (1232_u16, r#"Olympus Corporation"#),
+ (1233_u16, r#"KTS GmbH"#),
+ (1234_u16, r#"Anloq Technologies Inc."#),
+ (1235_u16, r#"Queercon, Inc"#),
+ (1236_u16, r#"5th Element Ltd"#),
+ (1237_u16, r#"Gooee Limited"#),
+ (1238_u16, r#"LUGLOC LLC"#),
+ (1239_u16, r#"Blincam, Inc."#),
+ (1240_u16, r#"FUJIFILM Corporation"#),
+ (1241_u16, r#"RandMcNally"#),
+ (1242_u16, r#"Franceschi Marina snc"#),
+ (1243_u16, r#"Engineered Audio, LLC."#),
+ (1244_u16, r#"IOTTIVE (OPC) PRIVATE LIMITED"#),
+ (1245_u16, r#"4MOD Technology"#),
+ (1246_u16, r#"Lutron Electronics Co., Inc."#),
+ (1247_u16, r#"Emerson"#),
+ (1248_u16, r#"Guardtec, Inc."#),
+ (1249_u16, r#"REACTEC LIMITED"#),
+ (1250_u16, r#"EllieGrid"#),
+ (1251_u16, r#"Under Armour"#),
+ (1252_u16, r#"Woodenshark"#),
+ (1253_u16, r#"Avack Oy"#),
+ (1254_u16, r#"Smart Solution Technology, Inc."#),
+ (1255_u16, r#"REHABTRONICS INC."#),
+ (1256_u16, r#"STABILO International"#),
+ (1257_u16, r#"Busch Jaeger Elektro GmbH"#),
+ (1258_u16, r#"Pacific Bioscience Laboratories, Inc"#),
+ (1259_u16, r#"Bird Home Automation GmbH"#),
+ (1260_u16, r#"Motorola Solutions"#),
+ (1261_u16, r#"R9 Technology, Inc."#),
+ (1262_u16, r#"Auxivia"#),
+ (1263_u16, r#"DaisyWorks, Inc"#),
+ (1264_u16, r#"Kosi Limited"#),
+ (1265_u16, r#"Theben AG"#),
+ (1266_u16, r#"InDreamer Techsol Private Limited"#),
+ (1267_u16, r#"Cerevast Medical"#),
+ (1268_u16, r#"ZanCompute Inc."#),
+ (1269_u16, r#"Pirelli Tyre S.P.A."#),
+ (1270_u16, r#"McLear Limited"#),
+ (1271_u16, r#"Shenzhen Huiding Technology Co.,Ltd."#),
+ (1272_u16, r#"Convergence Systems Limited"#),
+ (1273_u16, r#"Interactio"#),
+ (1274_u16, r#"Androtec GmbH"#),
+ (1275_u16, r#"Benchmark Drives GmbH & Co. KG"#),
+ (1276_u16, r#"SwingLync L. L. C."#),
+ (1277_u16, r#"Tapkey GmbH"#),
+ (1278_u16, r#"Woosim Systems Inc."#),
+ (1279_u16, r#"Microsemi Corporation"#),
+ (1280_u16, r#"Wiliot LTD."#),
+ (1281_u16, r#"Polaris IND"#),
+ (1282_u16, r#"Specifi-Kali LLC"#),
+ (1283_u16, r#"Locoroll, Inc"#),
+ (1284_u16, r#"PHYPLUS Inc"#),
+ (1285_u16, r#"Inplay Technologies LLC"#),
+ (1286_u16, r#"Hager"#),
+ (1287_u16, r#"Yellowcog"#),
+ (1288_u16, r#"Axes System sp. z o. o."#),
+ (1289_u16, r#"myLIFTER Inc."#),
+ (1290_u16, r#"Shake-on B.V."#),
+ (1291_u16, r#"Vibrissa Inc."#),
+ (1292_u16, r#"OSRAM GmbH"#),
+ (1293_u16, r#"TRSystems GmbH"#),
+ (1294_u16, r#"Yichip Microelectronics (Hangzhou) Co.,Ltd."#),
+ (1295_u16, r#"Foundation Engineering LLC"#),
+ (1296_u16, r#"UNI-ELECTRONICS, INC."#),
+ (1297_u16, r#"Brookfield Equinox LLC"#),
+ (1298_u16, r#"Soprod SA"#),
+ (1299_u16, r#"9974091 Canada Inc."#),
+ (1300_u16, r#"FIBRO GmbH"#),
+ (1301_u16, r#"RB Controls Co., Ltd."#),
+ (1302_u16, r#"Footmarks"#),
+ (1303_u16, r#"Amtronic Sverige AB (formerly Amcore AB)"#),
+ (1304_u16, r#"MAMORIO.inc"#),
+ (1305_u16, r#"Tyto Life LLC"#),
+ (1306_u16, r#"Leica Camera AG"#),
+ (1307_u16, r#"Angee Technologies Ltd."#),
+ (1308_u16, r#"EDPS"#),
+ (1309_u16, r#"OFF Line Co., Ltd."#),
+ (1310_u16, r#"Detect Blue Limited"#),
+ (1311_u16, r#"Setec Pty Ltd"#),
+ (1312_u16, r#"Target Corporation"#),
+ (1313_u16, r#"IAI Corporation"#),
+ (1314_u16, r#"NS Tech, Inc."#),
+ (1315_u16, r#"MTG Co., Ltd."#),
+ (1316_u16, r#"Hangzhou iMagic Technology Co., Ltd"#),
+ (1317_u16, r#"HONGKONG NANO IC TECHNOLOGIES CO., LIMITED"#),
+ (1318_u16, r#"Honeywell International Inc."#),
+ (1319_u16, r#"Albrecht JUNG"#),
+ (1320_u16, r#"Lunera Lighting Inc."#),
+ (1321_u16, r#"Lumen UAB"#),
+ (1322_u16, r#"Keynes Controls Ltd"#),
+ (1323_u16, r#"Novartis AG"#),
+ (1324_u16, r#"Geosatis SA"#),
+ (1325_u16, r#"EXFO, Inc."#),
+ (1326_u16, r#"LEDVANCE GmbH"#),
+ (1327_u16, r#"Center ID Corp."#),
+ (1328_u16, r#"Adolene, Inc."#),
+ (1329_u16, r#"D&M Holdings Inc."#),
+ (1330_u16, r#"CRESCO Wireless, Inc."#),
+ (1331_u16, r#"Nura Operations Pty Ltd"#),
+ (1332_u16, r#"Frontiergadget, Inc."#),
+ (1333_u16, r#"Smart Component Technologies Limited"#),
+ (1334_u16, r#"ZTR Control Systems LLC"#),
+ (1335_u16, r#"MetaLogics Corporation"#),
+ (1336_u16, r#"Medela AG"#),
+ (1337_u16, r#"OPPLE Lighting Co., Ltd"#),
+ (1338_u16, r#"Savitech Corp.,"#),
+ (1339_u16, r#"prodigy"#),
+ (1340_u16, r#"Screenovate Technologies Ltd"#),
+ (1341_u16, r#"TESA SA"#),
+ (1342_u16, r#"CLIM8 LIMITED"#),
+ (1343_u16, r#"Silergy Corp"#),
+ (1344_u16, r#"SilverPlus, Inc"#),
+ (1345_u16, r#"Sharknet srl"#),
+ (1346_u16, r#"Mist Systems, Inc."#),
+ (1347_u16, r#"MIWA LOCK CO.,Ltd"#),
+ (1348_u16, r#"OrthoSensor, Inc."#),
+ (1349_u16, r#"Candy Hoover Group s.r.l"#),
+ (1350_u16, r#"Apexar Technologies S.A."#),
+ (1351_u16, r#"LOGICDATA d.o.o."#),
+ (1352_u16, r#"Knick Elektronische Messgeraete GmbH & Co. KG"#),
+ (1353_u16, r#"Smart Technologies and Investment Limited"#),
+ (1354_u16, r#"Linough Inc."#),
+ (1355_u16, r#"Advanced Electronic Designs, Inc."#),
+ (1356_u16, r#"Carefree Scott Fetzer Co Inc"#),
+ (1357_u16, r#"Sensome"#),
+ (1358_u16, r#"FORTRONIK storitve d.o.o."#),
+ (1359_u16, r#"Sinnoz"#),
+ (1360_u16, r#"Versa Networks, Inc."#),
+ (1361_u16, r#"Sylero"#),
+ (1362_u16, r#"Avempace SARL"#),
+ (1363_u16, r#"Nintendo Co., Ltd."#),
+ (1364_u16, r#"National Instruments"#),
+ (1365_u16, r#"KROHNE Messtechnik GmbH"#),
+ (1366_u16, r#"Otodynamics Ltd"#),
+ (1367_u16, r#"Arwin Technology Limited"#),
+ (1368_u16, r#"benegear, inc."#),
+ (1369_u16, r#"Newcon Optik"#),
+ (1370_u16, r#"CANDY HOUSE, Inc."#),
+ (1371_u16, r#"FRANKLIN TECHNOLOGY INC"#),
+ (1372_u16, r#"Lely"#),
+ (1373_u16, r#"Valve Corporation"#),
+ (1374_u16, r#"Hekatron Vertriebs GmbH"#),
+ (1375_u16, r#"PROTECH S.A.S. DI GIRARDI ANDREA & C."#),
+ (1376_u16, r#"Sarita CareTech APS (formerly Sarita CareTech IVS)"#),
+ (1377_u16, r#"Finder S.p.A."#),
+ (1378_u16, r#"Thalmic Labs Inc."#),
+ (1379_u16, r#"Steinel Vertrieb GmbH"#),
+ (1380_u16, r#"Beghelli Spa"#),
+ (1381_u16, r#"Beijing Smartspace Technologies Inc."#),
+ (1382_u16, r#"CORE TRANSPORT TECHNOLOGIES NZ LIMITED"#),
+ (1383_u16, r#"Xiamen Everesports Goods Co., Ltd"#),
+ (1384_u16, r#"Bodyport Inc."#),
+ (1385_u16, r#"Audionics System, INC."#),
+ (1386_u16, r#"Flipnavi Co.,Ltd."#),
+ (1387_u16, r#"Rion Co., Ltd."#),
+ (1388_u16, r#"Long Range Systems, LLC"#),
+ (1389_u16, r#"Redmond Industrial Group LLC"#),
+ (1390_u16, r#"VIZPIN INC."#),
+ (1391_u16, r#"BikeFinder AS"#),
+ (1392_u16, r#"Consumer Sleep Solutions LLC"#),
+ (1393_u16, r#"PSIKICK, INC."#),
+ (1394_u16, r#"AntTail.com"#),
+ (1395_u16, r#"Lighting Science Group Corp."#),
+ (1396_u16, r#"AFFORDABLE ELECTRONICS INC"#),
+ (1397_u16, r#"Integral Memroy Plc"#),
+ (1398_u16, r#"Globalstar, Inc."#),
+ (1399_u16, r#"True Wearables, Inc."#),
+ (1400_u16, r#"Wellington Drive Technologies Ltd"#),
+ (1401_u16, r#"Ensemble Tech Private Limited"#),
+ (1402_u16, r#"OMNI Remotes"#),
+ (1403_u16, r#"Duracell U.S. Operations Inc."#),
+ (1404_u16, r#"Toor Technologies LLC"#),
+ (1405_u16, r#"Instinct Performance"#),
+ (1406_u16, r#"Beco, Inc"#),
+ (1407_u16, r#"Scuf Gaming International, LLC"#),
+ (1408_u16, r#"ARANZ Medical Limited"#),
+ (1409_u16, r#"LYS TECHNOLOGIES LTD"#),
+ (1410_u16, r#"Breakwall Analytics, LLC"#),
+ (1411_u16, r#"Code Blue Communications"#),
+ (1412_u16, r#"Gira Giersiepen GmbH & Co. KG"#),
+ (1413_u16, r#"Hearing Lab Technology"#),
+ (1414_u16, r#"LEGRAND"#),
+ (1415_u16, r#"Derichs GmbH"#),
+ (1416_u16, r#"ALT-TEKNIK LLC"#),
+ (1417_u16, r#"Star Technologies"#),
+ (1418_u16, r#"START TODAY CO.,LTD."#),
+ (1419_u16, r#"Maxim Integrated Products"#),
+ (1420_u16, r#"MERCK Kommanditgesellschaft auf Aktien"#),
+ (1421_u16, r#"Jungheinrich Aktiengesellschaft"#),
+ (1422_u16, r#"Oculus VR, LLC"#),
+ (1423_u16, r#"HENDON SEMICONDUCTORS PTY LTD"#),
+ (1424_u16, r#"Pur3 Ltd"#),
+ (1425_u16, r#"Viasat Group S.p.A."#),
+ (1426_u16, r#"IZITHERM"#),
+ (1427_u16, r#"Spaulding Clinical Research"#),
+ (1428_u16, r#"Kohler Company"#),
+ (1429_u16, r#"Inor Process AB"#),
+ (1430_u16, r#"My Smart Blinds"#),
+ (1431_u16, r#"RadioPulse Inc"#),
+ (1432_u16, r#"rapitag GmbH"#),
+ (1433_u16, r#"Lazlo326, LLC."#),
+ (1434_u16, r#"Teledyne Lecroy, Inc."#),
+ (1435_u16, r#"Dataflow Systems Limited"#),
+ (1436_u16, r#"Macrogiga Electronics"#),
+ (1437_u16, r#"Tandem Diabetes Care"#),
+ (1438_u16, r#"Polycom, Inc."#),
+ (1439_u16, r#"Fisher & Paykel Healthcare"#),
+ (1440_u16, r#"RCP Software Oy"#),
+ (1441_u16, r#"Shanghai Xiaoyi Technology Co.,Ltd."#),
+ (1442_u16, r#"ADHERIUM(NZ) LIMITED"#),
+ (1443_u16, r#"Axiomware Systems Incorporated"#),
+ (1444_u16, r#"O. E. M. Controls, Inc."#),
+ (1445_u16, r#"Kiiroo BV"#),
+ (1446_u16, r#"Telecon Mobile Limited"#),
+ (1447_u16, r#"Sonos Inc"#),
+ (1448_u16, r#"Tom Allebrandi Consulting"#),
+ (1449_u16, r#"Monidor"#),
+ (1450_u16, r#"Tramex Limited"#),
+ (1451_u16, r#"Nofence AS"#),
+ (1452_u16, r#"GoerTek Dynaudio Co., Ltd."#),
+ (1453_u16, r#"INIA"#),
+ (1454_u16, r#"CARMATE MFG.CO.,LTD"#),
+ (1455_u16, r#"OV LOOP, INC. (formerly ONvocal)"#),
+ (1456_u16, r#"NewTec GmbH"#),
+ (1457_u16, r#"Medallion Instrumentation Systems"#),
+ (1458_u16, r#"CAREL INDUSTRIES S.P.A."#),
+ (1459_u16, r#"Parabit Systems, Inc."#),
+ (1460_u16, r#"White Horse Scientific ltd"#),
+ (1461_u16, r#"verisilicon"#),
+ (1462_u16, r#"Elecs Industry Co.,Ltd."#),
+ (1463_u16, r#"Beijing Pinecone Electronics Co.,Ltd."#),
+ (1464_u16, r#"Ambystoma Labs Inc."#),
+ (1465_u16, r#"Suzhou Pairlink Network Technology"#),
+ (1466_u16, r#"igloohome"#),
+ (1467_u16, r#"Oxford Metrics plc"#),
+ (1468_u16, r#"Leviton Mfg. Co., Inc."#),
+ (1469_u16, r#"ULC Robotics Inc."#),
+ (1470_u16, r#"RFID Global by Softwork SrL"#),
+ (1471_u16, r#"Real-World-Systems Corporation"#),
+ (1472_u16, r#"Nalu Medical, Inc."#),
+ (1473_u16, r#"P.I.Engineering"#),
+ (1474_u16, r#"Grote Industries"#),
+ (1475_u16, r#"Runtime, Inc."#),
+ (1476_u16, r#"Codecoup sp. z o.o. sp. k."#),
+ (1477_u16, r#"SELVE GmbH & Co. KG"#),
+ (1478_u16, r#"Smart Animal Training Systems, LLC"#),
+ (1479_u16, r#"Lippert Components, INC"#),
+ (1480_u16, r#"SOMFY SAS"#),
+ (1481_u16, r#"TBS Electronics B.V."#),
+ (1482_u16, r#"MHL Custom Inc"#),
+ (1483_u16, r#"LucentWear LLC"#),
+ (1484_u16, r#"WATTS ELECTRONICS"#),
+ (1485_u16, r#"RJ Brands LLC"#),
+ (1486_u16, r#"V-ZUG Ltd"#),
+ (1487_u16, r#"Biowatch SA"#),
+ (1488_u16, r#"Anova Applied Electronics"#),
+ (1489_u16, r#"Lindab AB"#),
+ (1490_u16, r#"frogblue TECHNOLOGY GmbH"#),
+ (1491_u16, r#"Acurable Limited"#),
+ (1492_u16, r#"LAMPLIGHT Co., Ltd."#),
+ (1493_u16, r#"TEGAM, Inc."#),
+ (1494_u16, r#"Zhuhai Jieli technology Co.,Ltd"#),
+ (1495_u16, r#"modum.io AG"#),
+ (1496_u16, r#"Farm Jenny LLC"#),
+ (1497_u16, r#"Toyo Electronics Corporation"#),
+ (1498_u16, r#"Applied Neural Research Corp"#),
+ (1499_u16, r#"Avid Identification Systems, Inc."#),
+ (1500_u16, r#"Petronics Inc."#),
+ (1501_u16, r#"essentim GmbH"#),
+ (1502_u16, r#"QT Medical INC."#),
+ (1503_u16, r#"VIRTUALCLINIC.DIRECT LIMITED"#),
+ (1504_u16, r#"Viper Design LLC"#),
+ (1505_u16, r#"Human, Incorporated"#),
+ (1506_u16, r#"stAPPtronics GmbH"#),
+ (1507_u16, r#"Elemental Machines, Inc."#),
+ (1508_u16, r#"Taiyo Yuden Co., Ltd"#),
+ (1509_u16, r#"INEO ENERGY& SYSTEMS"#),
+ (1510_u16, r#"Motion Instruments Inc."#),
+ (1511_u16, r#"PressurePro"#),
+ (1512_u16, r#"COWBOY"#),
+ (1513_u16, r#"iconmobile GmbH"#),
+ (1514_u16, r#"ACS-Control-System GmbH"#),
+ (1515_u16, r#"Bayerische Motoren Werke AG"#),
+ (1516_u16, r#"Gycom Svenska AB"#),
+ (1517_u16, r#"Fuji Xerox Co., Ltd"#),
+ (1518_u16, r#"Glide Inc."#),
+ (1519_u16, r#"SIKOM AS"#),
+ (1520_u16, r#"beken"#),
+ (1521_u16, r#"The Linux Foundation"#),
+ (1522_u16, r#"Try and E CO.,LTD."#),
+ (1523_u16, r#"SeeScan"#),
+ (1524_u16, r#"Clearity, LLC"#),
+ (1525_u16, r#"GS TAG"#),
+ (1526_u16, r#"DPTechnics"#),
+ (1527_u16, r#"TRACMO, INC."#),
+ (1528_u16, r#"Anki Inc."#),
+ (1529_u16, r#"Hagleitner Hygiene International GmbH"#),
+ (1530_u16, r#"Konami Sports Life Co., Ltd."#),
+ (1531_u16, r#"Arblet Inc."#),
+ (1532_u16, r#"Masbando GmbH"#),
+ (1533_u16, r#"Innoseis"#),
+ (1534_u16, r#"Niko nv"#),
+ (1535_u16, r#"Wellnomics Ltd"#),
+ (1536_u16, r#"iRobot Corporation"#),
+ (1537_u16, r#"Schrader Electronics"#),
+ (1538_u16, r#"Geberit International AG"#),
+ (1539_u16, r#"Fourth Evolution Inc"#),
+ (1540_u16, r#"Cell2Jack LLC"#),
+ (1541_u16, r#"FMW electronic Futterer u. Maier-Wolf OHG"#),
+ (1542_u16, r#"John Deere"#),
+ (1543_u16, r#"Rookery Technology Ltd"#),
+ (1544_u16, r#"KeySafe-Cloud"#),
+ (1545_u16, r#"BUCHI Labortechnik AG"#),
+ (1546_u16, r#"IQAir AG"#),
+ (1547_u16, r#"Triax Technologies Inc"#),
+ (1548_u16, r#"Vuzix Corporation"#),
+ (1549_u16, r#"TDK Corporation"#),
+ (1550_u16, r#"Blueair AB"#),
+ (1551_u16, r#"Signify Netherlands"#),
+ (1552_u16, r#"ADH GUARDIAN USA LLC"#),
+ (1553_u16, r#"Beurer GmbH"#),
+ (1554_u16, r#"Playfinity AS"#),
+ (1555_u16, r#"Hans Dinslage GmbH"#),
+ (1556_u16, r#"OnAsset Intelligence, Inc."#),
+ (1557_u16, r#"INTER ACTION Corporation"#),
+ (1558_u16, r#"OS42 UG (haftungsbeschraenkt)"#),
+ (1559_u16, r#"WIZCONNECTED COMPANY LIMITED"#),
+ (1560_u16, r#"Audio-Technica Corporation"#),
+ (1561_u16, r#"Six Guys Labs, s.r.o."#),
+ (1562_u16, r#"R.W. Beckett Corporation"#),
+ (1563_u16, r#"silex technology, inc."#),
+ (1564_u16, r#"Univations Limited"#),
+ (1565_u16, r#"SENS Innovation ApS"#),
+ (1566_u16, r#"Diamond Kinetics, Inc."#),
+ (1567_u16, r#"Phrame Inc."#),
+ (1568_u16, r#"Forciot Oy"#),
+ (1569_u16, r#"Noordung d.o.o."#),
+ (1570_u16, r#"Beam Labs, LLC"#),
+ (1571_u16, r#"Philadelphia Scientific (U.K.) Limited"#),
+ (1572_u16, r#"Biovotion AG"#),
+ (1573_u16, r#"Square Panda, Inc."#),
+ (1574_u16, r#"Amplifico"#),
+ (1575_u16, r#"WEG S.A."#),
+ (1576_u16, r#"Ensto Oy"#),
+ (1577_u16, r#"PHONEPE PVT LTD"#),
+ (1578_u16, r#"Lunatico Astronomia SL"#),
+ (1579_u16, r#"MinebeaMitsumi Inc."#),
+ (1580_u16, r#"ASPion GmbH"#),
+ (1581_u16, r#"Vossloh-Schwabe Deutschland GmbH"#),
+ (1582_u16, r#"Procept"#),
+ (1583_u16, r#"ONKYO Corporation"#),
+ (1584_u16, r#"Asthrea D.O.O."#),
+ (1585_u16, r#"Fortiori Design LLC"#),
+ (1586_u16, r#"Hugo Muller GmbH & Co KG"#),
+ (1587_u16, r#"Wangi Lai PLT"#),
+ (1588_u16, r#"Fanstel Corp"#),
+ (1589_u16, r#"Crookwood"#),
+ (1590_u16, r#"ELECTRONICA INTEGRAL DE SONIDO S.A."#),
+ (1591_u16, r#"GiP Innovation Tools GmbH"#),
+ (1592_u16, r#"LX SOLUTIONS PTY LIMITED"#),
+ (1593_u16, r#"Shenzhen Minew Technologies Co., Ltd."#),
+ (1594_u16, r#"Prolojik Limited"#),
+ (1595_u16, r#"Kromek Group Plc"#),
+ (1596_u16, r#"Contec Medical Systems Co., Ltd."#),
+ (1597_u16, r#"Xradio Technology Co.,Ltd."#),
+ (1598_u16, r#"The Indoor Lab, LLC"#),
+ (1599_u16, r#"LDL TECHNOLOGY"#),
+ (1600_u16, r#"Parkifi"#),
+ (1601_u16, r#"Revenue Collection Systems FRANCE SAS"#),
+ (1602_u16, r#"Bluetrum Technology Co.,Ltd"#),
+ (1603_u16, r#"makita corporation"#),
+ (1604_u16, r#"Apogee Instruments"#),
+ (1605_u16, r#"BM3"#),
+ (1606_u16, r#"SGV Group Holding GmbH & Co. KG"#),
+ (1607_u16, r#"MED-EL"#),
+ (1608_u16, r#"Ultune Technologies"#),
+ (1609_u16, r#"Ryeex Technology Co.,Ltd."#),
+ (1610_u16, r#"Open Research Institute, Inc."#),
+ (1611_u16, r#"Scale-Tec, Ltd"#),
+ (1612_u16, r#"Zumtobel Group AG"#),
+ (1613_u16, r#"iLOQ Oy"#),
+ (1614_u16, r#"KRUXWorks Technologies Private Limited"#),
+ (1615_u16, r#"Digital Matter Pty Ltd"#),
+ (1616_u16, r#"Coravin, Inc."#),
+ (1617_u16, r#"Stasis Labs, Inc."#),
+ (1618_u16, r#"ITZ Innovations- und Technologiezentrum GmbH"#),
+ (1619_u16, r#"Meggitt SA"#),
+ (1620_u16, r#"Ledlenser GmbH & Co. KG"#),
+ (1621_u16, r#"Renishaw PLC"#),
+ (1622_u16, r#"ZhuHai AdvanPro Technology Company Limited"#),
+ (1623_u16, r#"Meshtronix Limited"#),
+ (1624_u16, r#"Payex Norge AS"#),
+ (1625_u16, r#"UnSeen Technologies Oy"#),
+ (1626_u16, r#"Zound Industries International AB"#),
+ (1627_u16, r#"Sesam Solutions BV"#),
+ (1628_u16, r#"PixArt Imaging Inc."#),
+ (1629_u16, r#"Panduit Corp."#),
+ (1630_u16, r#"Alo AB"#),
+ (1631_u16, r#"Ricoh Company Ltd"#),
+ (1632_u16, r#"RTC Industries, Inc."#),
+ (1633_u16, r#"Mode Lighting Limited"#),
+ (1634_u16, r#"Particle Industries, Inc."#),
+ (1635_u16, r#"Advanced Telemetry Systems, Inc."#),
+ (1636_u16, r#"RHA TECHNOLOGIES LTD"#),
+ (1637_u16, r#"Pure International Limited"#),
+ (1638_u16, r#"WTO Werkzeug-Einrichtungen GmbH"#),
+ (1639_u16, r#"Spark Technology Labs Inc."#),
+ (1640_u16, r#"Bleb Technology srl"#),
+ (1641_u16, r#"Livanova USA, Inc."#),
+ (1642_u16, r#"Brady Worldwide Inc."#),
+ (1643_u16, r#"DewertOkin GmbH"#),
+ (1644_u16, r#"Ztove ApS"#),
+ (1645_u16, r#"Venso EcoSolutions AB"#),
+ (1646_u16, r#"Eurotronik Kranj d.o.o."#),
+ (1647_u16, r#"Hug Technology Ltd"#),
+ (1648_u16, r#"Gema Switzerland GmbH"#),
+ (1649_u16, r#"Buzz Products Ltd."#),
+ (1650_u16, r#"Kopi"#),
+ (1651_u16, r#"Innova Ideas Limited"#),
+ (1652_u16, r#"BeSpoon"#),
+ (1653_u16, r#"Deco Enterprises, Inc."#),
+ (1654_u16, r#"Expai Solutions Private Limited"#),
+ (1655_u16, r#"Innovation First, Inc."#),
+ (1656_u16, r#"SABIK Offshore GmbH"#),
+ (1657_u16, r#"4iiii Innovations Inc."#),
+ (1658_u16, r#"The Energy Conservatory, Inc."#),
+ (1659_u16, r#"I.FARM, INC."#),
+ (1660_u16, r#"Tile, Inc."#),
+ (1661_u16, r#"Form Athletica Inc."#),
+ (1662_u16, r#"MbientLab Inc"#),
+ (1663_u16, r#"NETGRID S.N.C. DI BISSOLI MATTEO, CAMPOREALE SIMONE, TOGNETTI FEDERICO"#),
+ (1664_u16, r#"Mannkind Corporation"#),
+ (1665_u16, r#"Trade FIDES a.s."#),
+ (1666_u16, r#"Photron Limited"#),
+ (1667_u16, r#"Eltako GmbH"#),
+ (1668_u16, r#"Dermalapps, LLC"#),
+ (1669_u16, r#"Greenwald Industries"#),
+ (1670_u16, r#"inQs Co., Ltd."#),
+ (1671_u16, r#"Cherry GmbH"#),
+ (1672_u16, r#"Amsted Digital Solutions Inc."#),
+ (1673_u16, r#"Tacx b.v."#),
+ (1674_u16, r#"Raytac Corporation"#),
+ (1675_u16, r#"Jiangsu Teranovo Tech Co., Ltd."#),
+ (1676_u16, r#"Changzhou Sound Dragon Electronics and Acoustics Co., Ltd"#),
+ (1677_u16, r#"JetBeep Inc."#),
+ (1678_u16, r#"Razer Inc."#),
+ (1679_u16, r#"JRM Group Limited"#),
+ (1680_u16, r#"Eccrine Systems, Inc."#),
+ (1681_u16, r#"Curie Point AB"#),
+ (1682_u16, r#"Georg Fischer AG"#),
+ (1683_u16, r#"Hach - Danaher"#),
+ (1684_u16, r#"T&A Laboratories LLC"#),
+ (1685_u16, r#"Koki Holdings Co., Ltd."#),
+ (1686_u16, r#"Gunakar Private Limited"#),
+ (1687_u16, r#"Stemco Products Inc"#),
+ (1688_u16, r#"Wood IT Security, LLC"#),
+ (1689_u16, r#"RandomLab SAS"#),
+ (1690_u16, r#"Adero, Inc. (formerly as TrackR, Inc.)"#),
+ (1691_u16, r#"Dragonchip Limited"#),
+ (1692_u16, r#"Noomi AB"#),
+ (1693_u16, r#"Vakaros LLC"#),
+ (1694_u16, r#"Delta Electronics, Inc."#),
+ (1695_u16, r#"FlowMotion Technologies AS"#),
+ (1696_u16, r#"OBIQ Location Technology Inc."#),
+ (1697_u16, r#"Cardo Systems, Ltd"#),
+ (1698_u16, r#"Globalworx GmbH"#),
+ (1699_u16, r#"Nymbus, LLC"#),
+ (1700_u16, r#"Sanyo Techno Solutions Tottori Co., Ltd."#),
+ (1701_u16, r#"TEKZITEL PTY LTD"#),
+ (1702_u16, r#"Roambee Corporation"#),
+ (1703_u16, r#"Chipsea Technologies (ShenZhen) Corp."#),
+ (1704_u16, r#"GD Midea Air-Conditioning Equipment Co., Ltd."#),
+ (1705_u16, r#"Soundmax Electronics Limited"#),
+ (1706_u16, r#"Produal Oy"#),
+ (1707_u16, r#"HMS Industrial Networks AB"#),
+ (1708_u16, r#"Ingchips Technology Co., Ltd."#),
+ (1709_u16, r#"InnovaSea Systems Inc."#),
+ (1710_u16, r#"SenseQ Inc."#),
+ (1711_u16, r#"Shoof Technologies"#),
+ (1712_u16, r#"BRK Brands, Inc."#),
+ (1713_u16, r#"SimpliSafe, Inc."#),
+ (1714_u16, r#"Tussock Innovation 2013 Limited"#),
+ (1715_u16, r#"The Hablab ApS"#),
+ (1716_u16, r#"Sencilion Oy"#),
+ (1717_u16, r#"Wabilogic Ltd."#),
+ (1718_u16, r#"Sociometric Solutions, Inc."#),
+ (1719_u16, r#"iCOGNIZE GmbH"#),
+ (1720_u16, r#"ShadeCraft, Inc"#),
+ (1721_u16, r#"Beflex Inc."#),
+ (1722_u16, r#"Beaconzone Ltd"#),
+ (1723_u16, r#"Leaftronix Analogic Solutions Private Limited"#),
+ (1724_u16, r#"TWS Srl"#),
+ (1725_u16, r#"ABB Oy"#),
+ (1726_u16, r#"HitSeed Oy"#),
+ (1727_u16, r#"Delcom Products Inc."#),
+ (1728_u16, r#"CAME S.p.A."#),
+ (1729_u16, r#"Alarm.com Holdings, Inc"#),
+ (1730_u16, r#"Measurlogic Inc."#),
+ (1731_u16, r#"King I Electronics.Co.,Ltd"#),
+ (1732_u16, r#"Dream Labs GmbH"#),
+ (1733_u16, r#"Urban Compass, Inc"#),
+ (1734_u16, r#"Simm Tronic Limited"#),
+ (1735_u16, r#"Somatix Inc"#),
+ (1736_u16, r#"Storz & Bickel GmbH & Co. KG"#),
+ (1737_u16, r#"MYLAPS B.V."#),
+ (1738_u16, r#"Shenzhen Zhongguang Infotech Technology Development Co., Ltd"#),
+ (1739_u16, r#"Dyeware, LLC"#),
+ (1740_u16, r#"Dongguan SmartAction Technology Co.,Ltd."#),
+ (1741_u16, r#"DIG Corporation"#),
+ (1742_u16, r#"FIOR & GENTZ"#),
+ (1743_u16, r#"Belparts N.V."#),
+ (1744_u16, r#"Etekcity Corporation"#),
+ (1745_u16, r#"Meyer Sound Laboratories, Incorporated"#),
+ (1746_u16, r#"CeoTronics AG"#),
+ (1747_u16, r#"TriTeq Lock and Security, LLC"#),
+ (1748_u16, r#"DYNAKODE TECHNOLOGY PRIVATE LIMITED"#),
+ (1749_u16, r#"Sensirion AG"#),
+ (1750_u16, r#"JCT Healthcare Pty Ltd"#),
+ (1751_u16, r#"FUBA Automotive Electronics GmbH"#),
+ (1752_u16, r#"AW Company"#),
+ (1753_u16, r#"Shanghai Mountain View Silicon Co.,Ltd."#),
+ (1754_u16, r#"Zliide Technologies ApS"#),
+ (1755_u16, r#"Automatic Labs, Inc."#),
+ (1756_u16, r#"Industrial Network Controls, LLC"#),
+ (1757_u16, r#"Intellithings Ltd."#),
+ (1758_u16, r#"Navcast, Inc."#),
+ (1759_u16, r#"Hubbell Lighting, Inc."#),
+ (1760_u16, r#"Avaya "#),
+ (1761_u16, r#"Milestone AV Technologies LLC"#),
+ (1762_u16, r#"Alango Technologies Ltd"#),
+ (1763_u16, r#"Spinlock Ltd"#),
+ (1764_u16, r#"Aluna"#),
+ (1765_u16, r#"OPTEX CO.,LTD."#),
+ (1766_u16, r#"NIHON DENGYO KOUSAKU"#),
+ (1767_u16, r#"VELUX A/S"#),
+ (1768_u16, r#"Almendo Technologies GmbH"#),
+ (1769_u16, r#"Zmartfun Electronics, Inc."#),
+ (1770_u16, r#"SafeLine Sweden AB"#),
+ (1771_u16, r#"Houston Radar LLC"#),
+ (1772_u16, r#"Sigur"#),
+ (1773_u16, r#"J Neades Ltd"#),
+ (1774_u16, r#"Avantis Systems Limited"#),
+ (1775_u16, r#"ALCARE Co., Ltd."#),
+ (1776_u16, r#"Chargy Technologies, SL"#),
+ (1777_u16, r#"Shibutani Co., Ltd."#),
+ (1778_u16, r#"Trapper Data AB"#),
+ (1779_u16, r#"Alfred International Inc."#),
+ (1780_u16, r#"Near Field Solutions Ltd"#),
+ (1781_u16, r#"Vigil Technologies Inc."#),
+ (1782_u16, r#"Vitulo Plus BV"#),
+ (1783_u16, r#"WILKA Schliesstechnik GmbH"#),
+ (1784_u16, r#"BodyPlus Technology Co.,Ltd"#),
+ (1785_u16, r#"happybrush GmbH"#),
+ (1786_u16, r#"Enequi AB"#),
+ (1787_u16, r#"Sartorius AG"#),
+ (1788_u16, r#"Tom Communication Industrial Co.,Ltd."#),
+ (1789_u16, r#"ESS Embedded System Solutions Inc."#),
+ (1790_u16, r#"Mahr GmbH"#),
+ (1791_u16, r#"Redpine Signals Inc"#),
+ (1792_u16, r#"TraqFreq LLC"#),
+ (1793_u16, r#"PAFERS TECH"#),
+ (1794_u16, r#"Akciju sabiedriba "SAF TEHNIKA""#),
+ (1795_u16, r#"Beijing Jingdong Century Trading Co., Ltd."#),
+ (1796_u16, r#"JBX Designs Inc."#),
+ (1797_u16, r#"AB Electrolux"#),
+ (1798_u16, r#"Wernher von Braun Center for ASdvanced Research"#),
+ (1799_u16, r#"Essity Hygiene and Health Aktiebolag"#),
+ (1800_u16, r#"Be Interactive Co., Ltd"#),
+ (1801_u16, r#"Carewear Corp."#),
+ (1802_u16, r#"Huf Hülsbeck & Fürst GmbH & Co. KG"#),
+ (1803_u16, r#"Element Products, Inc."#),
+ (1804_u16, r#"Beijing Winner Microelectronics Co.,Ltd"#),
+ (1805_u16, r#"SmartSnugg Pty Ltd"#),
+ (1806_u16, r#"FiveCo Sarl"#),
+ (1807_u16, r#"California Things Inc."#),
+ (1808_u16, r#"Audiodo AB"#),
+ (1809_u16, r#"ABAX AS"#),
+ (1810_u16, r#"Bull Group Company Limited"#),
+ (1811_u16, r#"Respiri Limited"#),
+ (1812_u16, r#"MindPeace Safety LLC"#),
+ (1813_u16, r#"Vgyan Solutions"#),
+ (1814_u16, r#"Altonics"#),
+ (1815_u16, r#"iQsquare BV"#),
+ (1816_u16, r#"IDIBAIX enginneering"#),
+ (1817_u16, r#"ECSG"#),
+ (1818_u16, r#"REVSMART WEARABLE HK CO LTD"#),
+ (1819_u16, r#"Precor"#),
+ (1820_u16, r#"F5 Sports, Inc"#),
+ (1821_u16, r#"exoTIC Systems"#),
+ (1822_u16, r#"DONGGUAN HELE ELECTRONICS CO., LTD"#),
+ (1823_u16, r#"Dongguan Liesheng Electronic Co.Ltd"#),
+ (1824_u16, r#"Oculeve, Inc."#),
+ (1825_u16, r#"Clover Network, Inc."#),
+ (1826_u16, r#"Xiamen Eholder Electronics Co.Ltd"#),
+ (1827_u16, r#"Ford Motor Company"#),
+ (1828_u16, r#"Guangzhou SuperSound Information Technology Co.,Ltd"#),
+ (1829_u16, r#"Tedee Sp. z o.o."#),
+ (1830_u16, r#"PHC Corporation"#),
+ (1831_u16, r#"STALKIT AS"#),
+ (1832_u16, r#"Eli Lilly and Company"#),
+ (1833_u16, r#"SwaraLink Technologies"#),
+ (1834_u16, r#"JMR embedded systems GmbH"#),
+ (1835_u16, r#"Bitkey Inc."#),
+ (1836_u16, r#"GWA Hygiene GmbH"#),
+ (1837_u16, r#"Safera Oy"#),
+ (1838_u16, r#"Open Platform Systems LLC"#),
+ (1839_u16, r#"OnePlus Electronics (Shenzhen) Co., Ltd."#),
+ (1840_u16, r#"Wildlife Acoustics, Inc."#),
+ (1841_u16, r#"ABLIC Inc."#),
+ (1842_u16, r#"Dairy Tech, Inc."#),
+ (1843_u16, r#"Iguanavation, Inc."#),
+ (1844_u16, r#"DiUS Computing Pty Ltd"#),
+ (1845_u16, r#"UpRight Technologies LTD"#),
+ (1846_u16, r#"FrancisFund, LLC"#),
+ (1847_u16, r#"LLC Navitek"#),
+ (1848_u16, r#"Glass Security Pte Ltd"#),
+ (1849_u16, r#"Jiangsu Qinheng Co., Ltd."#),
+ (1850_u16, r#"Chandler Systems Inc."#),
+ (1851_u16, r#"Fantini Cosmi s.p.a."#),
+ (1852_u16, r#"Acubit ApS"#),
+ (1853_u16, r#"Beijing Hao Heng Tian Tech Co., Ltd."#),
+ (1854_u16, r#"Bluepack S.R.L."#),
+ (1855_u16, r#"Beijing Unisoc Technologies Co., Ltd."#),
+ (1856_u16, r#"HITIQ LIMITED"#),
+ (1857_u16, r#"MAC SRL"#),
+ (1858_u16, r#"DML LLC"#),
+ (1859_u16, r#"Sanofi"#),
+ (1860_u16, r#"SOCOMEC"#),
+ (1861_u16, r#"WIZNOVA, Inc."#),
+ (1862_u16, r#"Seitec Elektronik GmbH"#),
+ (1863_u16, r#"OR Technologies Pty Ltd"#),
+ (1864_u16, r#"GuangZhou KuGou Computer Technology Co.Ltd"#),
+ (1865_u16, r#"DIAODIAO (Beijing) Technology Co., Ltd."#),
+ (1866_u16, r#"Illusory Studios LLC"#),
+ (1867_u16, r#"Sarvavid Software Solutions LLP"#),
+ (1868_u16, r#"iopool s.a."#),
+ (1869_u16, r#"Amtech Systems, LLC"#),
+ (1870_u16, r#"EAGLE DETECTION SA"#),
+ (1871_u16, r#"MEDIATECH S.R.L."#),
+ (1872_u16, r#"Hamilton Professional Services of Canada Incorporated"#),
+ (1873_u16, r#"Changsha JEMO IC Design Co.,Ltd"#),
+ (1874_u16, r#"Elatec GmbH"#),
+ (1875_u16, r#"JLG Industries, Inc."#),
+ (1876_u16, r#"Michael Parkin"#),
+ (1877_u16, r#"Brother Industries, Ltd"#),
+ (1878_u16, r#"Lumens For Less, Inc"#),
+ (1879_u16, r#"ELA Innovation"#),
+ (1880_u16, r#"umanSense AB"#),
+ (1881_u16, r#"Shanghai InGeek Cyber Security Co., Ltd."#),
+ (1882_u16, r#"HARMAN CO.,LTD."#),
+ (1883_u16, r#"Smart Sensor Devices AB"#),
+ (1884_u16, r#"Antitronics Inc."#),
+ (1885_u16, r#"RHOMBUS SYSTEMS, INC."#),
+ (1886_u16, r#"Katerra Inc."#),
+ (1887_u16, r#"Remote Solution Co., LTD."#),
+ (1888_u16, r#"Vimar SpA"#),
+ (1889_u16, r#"Mantis Tech LLC"#),
+ (1890_u16, r#"TerOpta Ltd"#),
+ (1891_u16, r#"PIKOLIN S.L."#),
+ (1892_u16, r#"WWZN Information Technology Company Limited"#),
+ (1893_u16, r#"Voxx International"#),
+ (1894_u16, r#"ART AND PROGRAM, INC."#),
+ (1895_u16, r#"NITTO DENKO ASIA TECHNICAL CENTRE PTE. LTD."#),
+ (1896_u16, r#"Peloton Interactive Inc."#),
+ (1897_u16, r#"Force Impact Technologies"#),
+ (1898_u16, r#"Dmac Mobile Developments, LLC"#),
+ (1899_u16, r#"Engineered Medical Technologies"#),
+ (1900_u16, r#"Noodle Technology inc"#),
+ (1901_u16, r#"Graesslin GmbH"#),
+ (1902_u16, r#"WuQi technologies, Inc."#),
+ (1903_u16, r#"Successful Endeavours Pty Ltd"#),
+ (1904_u16, r#"InnoCon Medical ApS"#),
+ (1905_u16, r#"Corvex Connected Safety"#),
+ (1906_u16, r#"Thirdwayv Inc."#),
+ (1907_u16, r#"Echoflex Solutions Inc."#),
+ (1908_u16, r#"C-MAX Asia Limited"#),
+ (1909_u16, r#"4eBusiness GmbH"#),
+ (1910_u16, r#"Cyber Transport Control GmbH"#),
+ (1911_u16, r#"Cue"#),
+ (1912_u16, r#"KOAMTAC INC."#),
+ (1913_u16, r#"Loopshore Oy"#),
+ (1914_u16, r#"Niruha Systems Private Limited"#),
+ (1915_u16, r#"AmaterZ, Inc."#),
+ (1916_u16, r#"radius co., ltd."#),
+ (1917_u16, r#"Sensority, s.r.o."#),
+ (1918_u16, r#"Sparkage Inc."#),
+ (1919_u16, r#"Glenview Software Corporation"#),
+ (1920_u16, r#"Finch Technologies Ltd."#),
+ (1921_u16, r#"Qingping Technology (Beijing) Co., Ltd."#),
+ (1922_u16, r#"DeviceDrive AS"#),
+ (1923_u16, r#"ESEMBER LIMITED LIABILITY COMPANY"#),
+ (1924_u16, r#"audifon GmbH & Co. KG"#),
+ (1925_u16, r#"O2 Micro, Inc."#),
+ (1926_u16, r#"HLP Controls Pty Limited"#),
+ (1927_u16, r#"Pangaea Solution"#),
+ (1928_u16, r#"BubblyNet, LLC"#),
+ (1930_u16, r#"The Wildflower Foundation"#),
+ (1931_u16, r#"Optikam Tech Inc."#),
+ (1932_u16, r#"MINIBREW HOLDING B.V"#),
+ (1933_u16, r#"Cybex GmbH"#),
+ (1934_u16, r#"FUJIMIC NIIGATA, INC."#),
+ (1935_u16, r#"Hanna Instruments, Inc."#),
+ (1936_u16, r#"KOMPAN A/S"#),
+ (1937_u16, r#"Scosche Industries, Inc."#),
+ (1938_u16, r#"Provo Craft"#),
+ (1939_u16, r#"AEV spol. s r.o."#),
+ (1940_u16, r#"The Coca-Cola Company"#),
+ (1941_u16, r#"GASTEC CORPORATION"#),
+ (1942_u16, r#"StarLeaf Ltd"#),
+ (1943_u16, r#"Water-i.d. GmbH"#),
+ (1944_u16, r#"HoloKit, Inc."#),
+ (1945_u16, r#"PlantChoir Inc."#),
+ (1946_u16, r#"GuangDong Oppo Mobile Telecommunications Corp., Ltd."#),
+ (1947_u16, r#"CST ELECTRONICS (PROPRIETARY) LIMITED"#),
+ (1948_u16, r#"Sky UK Limited"#),
+ (1949_u16, r#"Digibale Pty Ltd"#),
+ (1950_u16, r#"Smartloxx GmbH"#),
+ (1951_u16, r#"Pune Scientific LLP"#),
+ (1952_u16, r#"Regent Beleuchtungskorper AG"#),
+ (1953_u16, r#"Apollo Neuroscience, Inc."#),
+ (1954_u16, r#"Roku, Inc."#),
+ (1955_u16, r#"Comcast Cable"#),
+ (1956_u16, r#"Xiamen Mage Information Technology Co., Ltd."#),
+ (1957_u16, r#"RAB Lighting, Inc."#),
+ (1958_u16, r#"Musen Connect, Inc."#),
+ (1959_u16, r#"Zume, Inc."#),
+ (1960_u16, r#"conbee GmbH"#),
+ (1961_u16, r#"Bruel & Kjaer Sound & Vibration"#),
+ (1962_u16, r#"The Kroger Co."#),
+ (1963_u16, r#"Granite River Solutions, Inc."#),
+ (1964_u16, r#"LoupeDeck Oy"#),
+ (1965_u16, r#"New H3C Technologies Co.,Ltd"#),
+ (1966_u16, r#"Aurea Solucoes Tecnologicas Ltda."#),
+ (1967_u16, r#"Hong Kong Bouffalo Lab Limited"#),
+ (1968_u16, r#"GV Concepts Inc."#),
+ (1969_u16, r#"Thomas Dynamics, LLC"#),
+ (1970_u16, r#"Moeco IOT Inc."#),
+ (1971_u16, r#"2N TELEKOMUNIKACE a.s."#),
+ (1972_u16, r#"Hormann KG Antriebstechnik"#),
+ (1973_u16, r#"CRONO CHIP, S.L."#),
+ (1974_u16, r#"Soundbrenner Limited"#),
+ (1975_u16, r#"ETABLISSEMENTS GEORGES RENAULT"#),
+ (1976_u16, r#"iSwip"#),
+ (1977_u16, r#"Epona Biotec Limited"#),
+ (1978_u16, r#"Battery-Biz Inc."#),
+ (1979_u16, r#"EPIC S.R.L."#),
+ (1980_u16, r#"KD CIRCUITS LLC"#),
+ (1981_u16, r#"Genedrive Diagnostics Ltd"#),
+ (1982_u16, r#"Axentia Technologies AB"#),
+ (1983_u16, r#"REGULA Ltd."#),
+ (1984_u16, r#"Biral AG"#),
+ (1985_u16, r#"A.W. Chesterton Company"#),
+ (1986_u16, r#"Radinn AB"#),
+ (1987_u16, r#"CIMTechniques, Inc."#),
+ (1988_u16, r#"Johnson Health Tech NA"#),
+ (1989_u16, r#"June Life, Inc."#),
+ (1990_u16, r#"Bluenetics GmbH"#),
+ (1991_u16, r#"iaconicDesign Inc."#),
+ (1992_u16, r#"WRLDS Creations AB"#),
+ (1993_u16, r#"Skullcandy, Inc."#),
+ (1994_u16, r#"Modul-System HH AB"#),
+ (1995_u16, r#"West Pharmaceutical Services, Inc."#),
+ (1996_u16, r#"Barnacle Systems Inc."#),
+ (1997_u16, r#"Smart Wave Technologies Canada Inc"#),
+ (1998_u16, r#"Shanghai Top-Chip Microelectronics Tech. Co., LTD"#),
+ (1999_u16, r#"NeoSensory, Inc."#),
+ (2000_u16, r#"Hangzhou Tuya Information Technology Co., Ltd"#),
+ (2001_u16, r#"Shanghai Panchip Microelectronics Co., Ltd"#),
+ (2002_u16, r#"React Accessibility Limited"#),
+ (2003_u16, r#"LIVNEX Co.,Ltd."#),
+ (2004_u16, r#"Kano Computing Limited"#),
+ (2005_u16, r#"hoots classic GmbH"#),
+ (2006_u16, r#"ecobee Inc."#),
+ (2007_u16, r#"Nanjing Qinheng Microelectronics Co., Ltd"#),
+ (2008_u16, r#"SOLUTIONS AMBRA INC."#),
+ (2009_u16, r#"Micro-Design, Inc."#),
+ (2010_u16, r#"STARLITE Co., Ltd."#),
+ (2011_u16, r#"Remedee Labs"#),
+ (2012_u16, r#"ThingOS GmbH"#),
+ (2013_u16, r#"Linear Circuits"#),
+ (2014_u16, r#"Unlimited Engineering SL"#),
+ (2015_u16, r#"Snap-on Incorporated"#),
+ (2016_u16, r#"Edifier International Limited"#),
+ (2017_u16, r#"Lucie Labs"#),
+ (2018_u16, r#"Alfred Kaercher SE & Co. KG"#),
+ (2019_u16, r#"Audiowise Technology Inc."#),
+ (2020_u16, r#"Geeksme S.L."#),
+ (2021_u16, r#"Minut, Inc."#),
+ (2022_u16, r#"Autogrow Systems Limited"#),
+ (2023_u16, r#"Komfort IQ, Inc."#),
+ (2024_u16, r#"Packetcraft, Inc."#),
+ (2025_u16, r#"Häfele GmbH & Co KG"#),
+ (2026_u16, r#"ShapeLog, Inc."#),
+ (2027_u16, r#"NOVABASE S.R.L."#),
+ (2028_u16, r#"Frecce LLC"#),
+ (2029_u16, r#"Joule IQ, INC."#),
+ (2030_u16, r#"KidzTek LLC"#),
+ (2031_u16, r#"Aktiebolaget Sandvik Coromant"#),
+ (2032_u16, r#"e-moola.com Pty Ltd"#),
+ (2033_u16, r#"GSM Innovations Pty Ltd"#),
+ (2034_u16, r#"SERENE GROUP, INC"#),
+ (2035_u16, r#"DIGISINE ENERGYTECH CO. LTD."#),
+ (2036_u16, r#"MEDIRLAB Orvosbiologiai Fejleszto Korlatolt Felelossegu Tarsasag"#),
+ (2037_u16, r#"Byton North America Corporation"#),
+ (2038_u16, r#"Shenzhen TonliScience and Technology Development Co.,Ltd"#),
+ (2039_u16, r#"Cesar Systems Ltd."#),
+ (2040_u16, r#"quip NYC Inc."#),
+ (2041_u16, r#"Direct Communication Solutions, Inc."#),
+ (2042_u16, r#"Klipsch Group, Inc."#),
+ (2043_u16, r#"Access Co., Ltd"#),
+ (2044_u16, r#"Renault SA"#),
+ (2045_u16, r#"JSK CO., LTD."#),
+ (2046_u16, r#"BIROTA"#),
+ (2047_u16, r#"maxon motor ltd."#),
+ (2048_u16, r#"Optek"#),
+ (2049_u16, r#"CRONUS ELECTRONICS LTD"#),
+ (2050_u16, r#"NantSound, Inc."#),
+ (2051_u16, r#"Domintell s.a."#),
+ (2052_u16, r#"Andon Health Co.,Ltd"#),
+ (2053_u16, r#"Urbanminded Ltd"#),
+ (2054_u16, r#"TYRI Sweden AB"#),
+ (2055_u16, r#"ECD Electronic Components GmbH Dresden"#),
+ (2056_u16, r#"SISTEMAS KERN, SOCIEDAD ANÓMINA"#),
+ (2057_u16, r#"Trulli Audio"#),
+ (2058_u16, r#"Altaneos"#),
+ (2059_u16, r#"Nanoleaf Canada Limited"#),
+ (2060_u16, r#"Ingy B.V."#),
+ (2061_u16, r#"Azbil Co."#),
+ (2062_u16, r#"TATTCOM LLC"#),
+ (2063_u16, r#"Paradox Engineering SA"#),
+ (2064_u16, r#"LECO Corporation"#),
+ (2065_u16, r#"Becker Antriebe GmbH"#),
+ (2066_u16, r#"Mstream Technologies., Inc."#),
+ (2067_u16, r#"Flextronics International USA Inc."#),
+ (2068_u16, r#"Ossur hf."#),
+ (2069_u16, r#"SKC Inc"#),
+ (2070_u16, r#"SPICA SYSTEMS LLC"#),
+ (2071_u16, r#"Wangs Alliance Corporation"#),
+ (2072_u16, r#"tatwah SA"#),
+ (2073_u16, r#"Hunter Douglas Inc"#),
+ (2074_u16, r#"Shenzhen Conex"#),
+ (2075_u16, r#"DIM3"#),
+ (2076_u16, r#"Bobrick Washroom Equipment, Inc."#),
+ (2077_u16, r#"Potrykus Holdings and Development LLC"#),
+ (2078_u16, r#"iNFORM Technology GmbH"#),
+ (2079_u16, r#"eSenseLab LTD"#),
+ (2080_u16, r#"Brilliant Home Technology, Inc."#),
+ (2081_u16, r#"INOVA Geophysical, Inc."#),
+ (2082_u16, r#"adafruit industries"#),
+ (2083_u16, r#"Nexite Ltd"#),
+ (2084_u16, r#"8Power Limited"#),
+ (2085_u16, r#"CME PTE. LTD."#),
+ (2086_u16, r#"Hyundai Motor Company"#),
+ (2087_u16, r#"Kickmaker"#),
+ (2088_u16, r#"Shanghai Suisheng Information Technology Co., Ltd."#),
+ (2089_u16, r#"HEXAGON"#),
+ (2090_u16, r#"Mitutoyo Corporation"#),
+ (2091_u16, r#"shenzhen fitcare electronics Co.,Ltd"#),
+ (2092_u16, r#"INGICS TECHNOLOGY CO., LTD."#),
+ (2093_u16, r#"INCUS PERFORMANCE LTD."#),
+ (2094_u16, r#"ABB S.p.A."#),
+ (2095_u16, r#"Blippit AB"#),
+ (2096_u16, r#"Core Health and Fitness LLC"#),
+ (2097_u16, r#"Foxble, LLC"#),
+ (2098_u16, r#"Intermotive,Inc."#),
+ (2099_u16, r#"Conneqtech B.V."#),
+ (2100_u16, r#"RIKEN KEIKI CO., LTD.,"#),
+ (2101_u16, r#"Canopy Growth Corporation"#),
+ (2102_u16, r#"Bitwards Oy"#),
+ (2103_u16, r#"vivo Mobile Communication Co., Ltd."#),
+ (2104_u16, r#"Etymotic Research, Inc."#),
+ (2105_u16, r#"A puissance 3"#),
+ (2106_u16, r#"BPW Bergische Achsen Kommanditgesellschaft"#),
+ (2107_u16, r#"Piaggio Fast Forward"#),
+ (2108_u16, r#"BeerTech LTD"#),
+ (2109_u16, r#"Tokenize, Inc."#),
+ (2110_u16, r#"Zorachka LTD"#),
+ (2111_u16, r#"D-Link Corp."#),
+ (2112_u16, r#"Down Range Systems LLC"#),
+ (2113_u16, r#"General Luminaire (Shanghai) Co., Ltd."#),
+ (2114_u16, r#"Tangshan HongJia electronic technology co., LTD."#),
+ (2115_u16, r#"FRAGRANCE DELIVERY TECHNOLOGIES LTD"#),
+ (2116_u16, r#"Pepperl + Fuchs GmbH"#),
+ (2117_u16, r#"Dometic Corporation"#),
+ (2118_u16, r#"USound GmbH"#),
+ (2119_u16, r#"DNANUDGE LIMITED"#),
+ (2120_u16, r#"JUJU JOINTS CANADA CORP."#),
+ (2121_u16, r#"Dopple Technologies B.V."#),
+ (2122_u16, r#"ARCOM"#),
+ (2123_u16, r#"Biotechware SRL"#),
+ (2124_u16, r#"ORSO Inc."#),
+ (2125_u16, r#"SafePort"#),
+ (2126_u16, r#"Carol Cole Company"#),
+ (2127_u16, r#"Embedded Fitness B.V."#),
+ (2128_u16, r#"Yealink (Xiamen) Network Technology Co.,LTD"#),
+ (2129_u16, r#"Subeca, Inc."#),
+ (2130_u16, r#"Cognosos, Inc."#),
+ (2131_u16, r#"Pektron Group Limited"#),
+ (2132_u16, r#"Tap Sound System"#),
+ (2133_u16, r#"Helios Hockey, Inc."#),
+ (2134_u16, r#"Canopy Growth Corporation"#),
+ (2135_u16, r#"Parsyl Inc"#),
+ (2136_u16, r#"SOUNDBOKS"#),
+ (2137_u16, r#"BlueUp"#),
+ (2138_u16, r#"DAKATECH"#),
+ (2139_u16, r#"RICOH ELECTRONIC DEVICES CO., LTD."#),
+ (2140_u16, r#"ACOS CO.,LTD."#),
+ (2141_u16, r#"Guilin Zhishen Information Technology Co.,Ltd."#),
+ (2142_u16, r#"Krog Systems LLC"#),
+ (2143_u16, r#"COMPEGPS TEAM,SOCIEDAD LIMITADA"#),
+ (2144_u16, r#"Alflex Products B.V."#),
+ (2145_u16, r#"SmartSensor Labs Ltd"#),
+ (2146_u16, r#"SmartDrive Inc."#),
+ (2147_u16, r#"Yo-tronics Technology Co., Ltd."#),
+ (2148_u16, r#"Rafaelmicro"#),
+ (2149_u16, r#"Emergency Lighting Products Limited"#),
+ (2150_u16, r#"LAONZ Co.,Ltd"#),
+ (2151_u16, r#"Western Digital Techologies, Inc."#),
+ (2152_u16, r#"WIOsense GmbH & Co. KG"#),
+ (2153_u16, r#"EVVA Sicherheitstechnologie GmbH"#),
+ (2154_u16, r#"Odic Incorporated"#),
+ (2155_u16, r#"Pacific Track, LLC"#),
+ (2156_u16, r#"Revvo Technologies, Inc."#),
+ (2157_u16, r#"Biometrika d.o.o."#),
+ (2158_u16, r#"Vorwerk Elektrowerke GmbH & Co. KG"#),
+ (2159_u16, r#"Trackunit A/S"#),
+ (2160_u16, r#"Wyze Labs, Inc"#),
+ (2161_u16, r#"Dension Elektronikai Kft. (formerly: Dension Audio Systems Ltd.)"#),
+ (2162_u16, r#"11 Health & Technologies Limited"#),
+ (2163_u16, r#"Innophase Incorporated"#),
+ (2164_u16, r#"Treegreen Limited"#),
+ (2165_u16, r#"Berner International LLC"#),
+ (2166_u16, r#"SmartResQ ApS"#),
+ (2167_u16, r#"Tome, Inc."#),
+ (2168_u16, r#"The Chamberlain Group, Inc."#),
+ (2169_u16, r#"MIZUNO Corporation"#),
+ (2170_u16, r#"ZRF, LLC"#),
+ (2171_u16, r#"BYSTAMP"#),
+ (2172_u16, r#"Crosscan GmbH"#),
+ (2173_u16, r#"Konftel AB"#),
+ (2174_u16, r#"1bar.net Limited"#),
+ (2175_u16, r#"Phillips Connect Technologies LLC"#),
+ (2176_u16, r#"imagiLabs AB"#),
+ (2177_u16, r#"Optalert"#),
+ (2178_u16, r#"PSYONIC, Inc."#),
+ (2179_u16, r#"Wintersteiger AG"#),
+ (2180_u16, r#"Controlid Industria, Comercio de Hardware e Servicos de Tecnologia Ltda"#),
+ (2181_u16, r#"LEVOLOR, INC."#),
+ (2182_u16, r#"Xsens Technologies B.V."#),
+ (2183_u16, r#"Hydro-Gear Limited Partnership"#),
+ (2184_u16, r#"EnPointe Fencing Pty Ltd"#),
+ (2185_u16, r#"XANTHIO"#),
+ (2186_u16, r#"sclak s.r.l."#),
+ (2187_u16, r#"Tricorder Arraay Technologies LLC"#),
+ (2188_u16, r#"GB Solution co.,Ltd"#),
+ (2189_u16, r#"Soliton Systems K.K."#),
+ (2190_u16, r#"GIGA-TMS INC"#),
+ (2191_u16, r#"Tait International Limited"#),
+ (2192_u16, r#"NICHIEI INTEC CO., LTD."#),
+ (2193_u16, r#"SmartWireless GmbH & Co. KG"#),
+ (2194_u16, r#"Ingenieurbuero Birnfeld UG (haftungsbeschraenkt)"#),
+ (2195_u16, r#"Maytronics Ltd"#),
+ (2196_u16, r#"EPIFIT"#),
+ (2197_u16, r#"Gimer medical"#),
+ (2198_u16, r#"Nokian Renkaat Oyj"#),
+ (2199_u16, r#"Current Lighting Solutions LLC"#),
+ (2200_u16, r#"Sensibo, Inc."#),
+ (2201_u16, r#"SFS unimarket AG"#),
+ (2202_u16, r#"Private limited company "Teltonika""#),
+ (2203_u16, r#"Saucon Technologies"#),
+ (2204_u16, r#"Embedded Devices Co. Company"#),
+ (2205_u16, r#"J-J.A.D.E. Enterprise LLC"#),
+ (2206_u16, r#"i-SENS, inc."#),
+ (2207_u16, r#"Witschi Electronic Ltd"#),
+ (2208_u16, r#"Aclara Technologies LLC"#),
+ (2209_u16, r#"EXEO TECH CORPORATION"#),
+ (2210_u16, r#"Epic Systems Co., Ltd."#),
+ (2211_u16, r#"Hoffmann SE"#),
+ (2212_u16, r#"Realme Chongqing Mobile Telecommunications Corp., Ltd."#),
+ (2213_u16, r#"UMEHEAL Ltd"#),
+ (2214_u16, r#"Intelligenceworks Inc."#),
+ (2215_u16, r#"TGR 1.618 Limited"#),
+ (2216_u16, r#"Shanghai Kfcube Inc"#),
+ (2217_u16, r#"Fraunhofer IIS"#),
+ (2218_u16, r#"SZ DJI TECHNOLOGY CO.,LTD"#),
+ (2219_u16, r#"Coburn Technology, LLC"#),
+ (2220_u16, r#"Topre Corporation"#),
+ (2221_u16, r#"Kayamatics Limited"#),
+ (2222_u16, r#"Moticon ReGo AG"#),
+ (2223_u16, r#"Polidea Sp. z o.o."#),
+ (2224_u16, r#"Trivedi Advanced Technologies LLC"#),
+ (2225_u16, r#"CORE|vision BV"#),
+ (2226_u16, r#"PF SCHWEISSTECHNOLOGIE GMBH"#),
+ (2227_u16, r#"IONIQ Skincare GmbH & Co. KG"#),
+ (2228_u16, r#"Sengled Co., Ltd."#),
+ (2229_u16, r#"TransferFi"#),
+ (2230_u16, r#"Boehringer Ingelheim Vetmedica GmbH"#),
+ (2231_u16, r#"ABB Inc"#),
+ (2232_u16, r#"Check Technology Solutions LLC"#),
+ (2233_u16, r#"U-Shin Ltd."#),
+ (2234_u16, r#"HYPER ICE, INC."#),
+ (2235_u16, r#"Tokai-rika co.,ltd."#),
+ (2236_u16, r#"Prevayl Limited"#),
+ (2237_u16, r#"bf1systems limited"#),
+ (2238_u16, r#"ubisys technologies GmbH"#),
+ (2239_u16, r#"SIRC Co., Ltd."#),
+ (2240_u16, r#"Accent Advanced Systems SLU"#),
+ (2241_u16, r#"Rayden.Earth LTD"#),
+ (2242_u16, r#"Lindinvent AB"#),
+ (2243_u16, r#"CHIPOLO d.o.o."#),
+ (2244_u16, r#"CellAssist, LLC"#),
+ (2245_u16, r#"J. Wagner GmbH"#),
+ (2246_u16, r#"Integra Optics Inc"#),
+ (2247_u16, r#"Monadnock Systems Ltd."#),
+ (2248_u16, r#"Liteboxer Technologies Inc."#),
+ (2249_u16, r#"Noventa AG"#),
+ (2250_u16, r#"Nubia Technology Co.,Ltd."#),
+ (2251_u16, r#"JT INNOVATIONS LIMITED"#),
+ (2252_u16, r#"TGM TECHNOLOGY CO., LTD."#),
+ (2253_u16, r#"ifly"#),
+ (2254_u16, r#"ZIMI CORPORATION"#),
+ (2255_u16, r#"betternotstealmybike UG (with limited liability)"#),
+ (2256_u16, r#"ESTOM Infotech Kft."#),
+ (2257_u16, r#"Sensovium Inc."#),
+ (2258_u16, r#"Virscient Limited"#),
+ (2259_u16, r#"Novel Bits, LLC"#),
+ (2260_u16, r#"ADATA Technology Co., LTD."#),
+ (2261_u16, r#"KEYes"#),
+ (2262_u16, r#"Nome Oy"#),
+ (2263_u16, r#"Inovonics Corp"#),
+ (2264_u16, r#"WARES"#),
+ (2265_u16, r#"Pointr Labs Limited"#),
+ (2266_u16, r#"Miridia Technology Incorporated"#),
+ (2267_u16, r#"Tertium Technology"#),
+ (2268_u16, r#"SHENZHEN AUKEY E BUSINESS CO., LTD"#),
+ (2269_u16, r#"code-Q"#),
+ (2270_u16, r#"Tyco Electronics Corporation a TE Connectivity Ltd Company"#),
+ (2271_u16, r#"IRIS OHYAMA CO.,LTD."#),
+ (2272_u16, r#"Philia Technology"#),
+ (2273_u16, r#"KOZO KEIKAKU ENGINEERING Inc."#),
+ (2274_u16, r#"Shenzhen Simo Technology co. LTD"#),
+ (2275_u16, r#"Republic Wireless, Inc."#),
+ (2276_u16, r#"Rashidov ltd"#),
+ (2277_u16, r#"Crowd Connected Ltd"#),
+ (2278_u16, r#"Eneso Tecnologia de Adaptacion S.L."#),
+ (2279_u16, r#"Barrot Technology Limited"#),
+ (2280_u16, r#"Naonext"#),
+ (2281_u16, r#"Taiwan Intelligent Home Corp."#),
+ (2282_u16, r#"COWBELL ENGINEERING CO.,LTD."#),
+ (2283_u16, r#"Beijing Big Moment Technology Co., Ltd."#),
+ (2284_u16, r#"Denso Corporation"#),
+ (2285_u16, r#"IMI Hydronic Engineering International SA"#),
+ (2286_u16, r#"ASKEY"#),
+ (2287_u16, r#"Cumulus Digital Systems, Inc"#),
+ (2288_u16, r#"Joovv, Inc."#),
+ (2289_u16, r#"The L.S. Starrett Company"#),
+ (2290_u16, r#"Microoled"#),
+ (2291_u16, r#"PSP - Pauli Services & Products GmbH"#),
+ (2292_u16, r#"Kodimo Technologies Company Limited"#),
+ (2293_u16, r#"Tymtix Technologies Private Limited"#),
+ (2294_u16, r#"Dermal Photonics Corporation"#),
+ (2295_u16, r#"MTD Products Inc & Affiliates"#),
+ (2296_u16, r#"instagrid GmbH"#),
+ (2297_u16, r#"Spacelabs Medical Inc."#),
+ (2298_u16, r#"Troo Corporation"#),
+ (2299_u16, r#"Darkglass Electronics Oy"#),
+ (2300_u16, r#"Hill-Rom"#),
+ (2301_u16, r#"BioIntelliSense, Inc."#),
+ (2302_u16, r#"Ketronixs Sdn Bhd"#),
+ (2303_u16, r#"Plastimold Products, Inc"#),
+ (2304_u16, r#"Beijing Zizai Technology Co., LTD."#),
+ (2305_u16, r#"Lucimed"#),
+ (2306_u16, r#"TSC Auto-ID Technology Co., Ltd."#),
+ (2307_u16, r#"DATAMARS, Inc."#),
+ (2308_u16, r#"SUNCORPORATION"#),
+ (2309_u16, r#"Yandex Services AG"#),
+ (2310_u16, r#"Scope Logistical Solutions"#),
+ (2311_u16, r#"User Hello, LLC"#),
+ (2312_u16, r#"Pinpoint Innovations Limited"#),
+ (2313_u16, r#"70mai Co.,Ltd."#),
+ (2314_u16, r#"Zhuhai Hoksi Technology CO.,LTD"#),
+ (2315_u16, r#"EMBR labs, INC"#),
+ (2316_u16, r#"Radiawave Technologies Co.,Ltd."#),
+ (2317_u16, r#"IOT Invent GmbH"#),
+ (2318_u16, r#"OPTIMUSIOT TECH LLP"#),
+ (2319_u16, r#"VC Inc."#),
+ (2320_u16, r#"ASR Microelectronics (Shanghai) Co., Ltd."#),
+ (2321_u16, r#"Douglas Lighting Controls Inc."#),
+ (2322_u16, r#"Nerbio Medical Software Platforms Inc"#),
+ (2323_u16, r#"Braveheart Wireless, Inc."#),
+ (2324_u16, r#"INEO-SENSE"#),
+ (2325_u16, r#"Honda Motor Co., Ltd."#),
+ (2326_u16, r#"Ambient Sensors LLC"#),
+ (2327_u16, r#"ASR Microelectronics(ShenZhen)Co., Ltd."#),
+ (2328_u16, r#"Technosphere Labs Pvt. Ltd."#),
+ (2329_u16, r#"NO SMD LIMITED"#),
+ (2330_u16, r#"Albertronic BV"#),
+ (2331_u16, r#"Luminostics, Inc."#),
+ (2332_u16, r#"Oblamatik AG"#),
+ (2333_u16, r#"Innokind, Inc."#),
+ (2334_u16, r#"Melbot Studios, Sociedad Limitada"#),
+ (2335_u16, r#"Myzee Technology"#),
+ (2336_u16, r#"Omnisense Limited"#),
+ (2337_u16, r#"KAHA PTE. LTD."#),
+ (2338_u16, r#"Shanghai MXCHIP Information Technology Co., Ltd."#),
+ (2339_u16, r#"JSB TECH PTE LTD"#),
+ (2340_u16, r#"Fundacion Tecnalia Research and Innovation"#),
+ (2341_u16, r#"Yukai Engineering Inc."#),
+ (2342_u16, r#"Gooligum Technologies Pty Ltd"#),
+ (2343_u16, r#"ROOQ GmbH"#),
+ (2344_u16, r#"AiRISTA"#),
+ (2345_u16, r#"Qingdao Haier Technology Co., Ltd."#),
+ (2346_u16, r#"Sappl Verwaltungs- und Betriebs GmbH"#),
+ (2347_u16, r#"TekHome"#),
+ (2348_u16, r#"PCI Private Limited"#),
+ (2349_u16, r#"Leggett & Platt, Incorporated"#),
+ (2350_u16, r#"PS GmbH"#),
+ (2351_u16, r#"C.O.B.O. SpA"#),
+ (2352_u16, r#"James Walker RotaBolt Limited"#),
+ (2353_u16, r#"BREATHINGS Co., Ltd."#),
+ (2354_u16, r#"BarVision, LLC"#),
+ (2355_u16, r#"SRAM"#),
+ (2356_u16, r#"KiteSpring Inc."#),
+ (2357_u16, r#"Reconnect, Inc."#),
+ (2358_u16, r#"Elekon AG"#),
+ (2359_u16, r#"RealThingks GmbH"#),
+ (2360_u16, r#"Henway Technologies, LTD."#),
+ (2361_u16, r#"ASTEM Co.,Ltd."#),
+ (2362_u16, r#"LinkedSemi Microelectronics (Xiamen) Co., Ltd"#),
+ (2363_u16, r#"ENSESO LLC"#),
+ (2364_u16, r#"Xenoma Inc."#),
+ (2365_u16, r#"Adolf Wuerth GmbH & Co KG"#),
+ (2366_u16, r#"Catalyft Labs, Inc."#),
+ (2367_u16, r#"JEPICO Corporation"#),
+ (2368_u16, r#"Hero Workout GmbH"#),
+ (2369_u16, r#"Rivian Automotive, LLC"#),
+ (2370_u16, r#"TRANSSION HOLDINGS LIMITED"#),
+ (2371_u16, r#"Inovonics Corp."#),
+ (2372_u16, r#"Agitron d.o.o."#),
+ (2373_u16, r#"Globe (Jiangsu) Co., Ltd"#),
+ (2374_u16, r#"AMC International Alfa Metalcraft Corporation AG"#),
+ (2375_u16, r#"First Light Technologies Ltd."#),
+ (2376_u16, r#"Wearable Link Limited"#),
+ (2377_u16, r#"Metronom Health Europe"#),
+ (2378_u16, r#"Zwift, Inc."#),
+ (2379_u16, r#"Kindeva Drug Delivery L.P."#),
+ (2380_u16, r#"GimmiSys GmbH"#),
+ (2381_u16, r#"tkLABS INC."#),
+ (2382_u16, r#"PassiveBolt, Inc."#),
+ (2383_u16, r#"Limited Liability Company "Mikrotikls""#),
+ (2384_u16, r#"Capetech"#),
+ (2385_u16, r#"PPRS"#),
+ (2386_u16, r#"Apptricity Corporation"#),
+ (2387_u16, r#"LogiLube, LLC"#),
+ (2388_u16, r#"Julbo"#),
+ (2389_u16, r#"Breville Group"#),
+ (2390_u16, r#"Kerlink"#),
+ (2391_u16, r#"Ohsung Electronics"#),
+ (2392_u16, r#"ZTE Corporation"#),
+ (2393_u16, r#"HerdDogg, Inc"#),
+ (2394_u16, r#"Selekt Bilgisayar, lletisim Urunleri lnsaat Sanayi ve Ticaret Limited Sirketi"#),
+ (2395_u16, r#"Lismore Instruments Limited"#),
+ (2396_u16, r#"LogiLube, LLC"#),
+ (2397_u16, r#"ETC"#),
+ (2398_u16, r#"BioEchoNet inc."#),
+ (2399_u16, r#"NUANCE HEARING LTD"#),
+ (2400_u16, r#"Sena Technologies Inc."#),
+ (2401_u16, r#"Linkura AB"#),
+ (2402_u16, r#"GL Solutions K.K."#),
+ (2403_u16, r#"Moonbird BV"#),
+ (2404_u16, r#"Countrymate Technology Limited"#),
+ (2405_u16, r#"Asahi Kasei Corporation"#),
+ (2406_u16, r#"PointGuard, LLC"#),
+ (2407_u16, r#"Neo Materials and Consulting Inc."#),
+ (2408_u16, r#"Actev Motors, Inc."#),
+ (2409_u16, r#"Woan Technology (Shenzhen) Co., Ltd."#),
+ (2410_u16, r#"dricos, Inc."#),
+ (2411_u16, r#"Guide ID B.V."#),
+ (2412_u16, r#"9374-7319 Quebec inc"#),
+ (2413_u16, r#"Gunwerks, LLC"#),
+ (2414_u16, r#"Band Industries, inc."#),
+ (2415_u16, r#"Lund Motion Products, Inc."#),
+ (2416_u16, r#"IBA Dosimetry GmbH"#),
+ (2417_u16, r#"GA"#),
+ (2418_u16, r#"Closed Joint Stock Company "Zavod Flometr" ("Zavod Flometr" CJSC)"#),
+ (2419_u16, r#"Popit Oy"#),
+ (2420_u16, r#"ABEYE"#),
+ (2421_u16, r#"BlueIOT(Beijing) Technology Co.,Ltd"#),
+ (2422_u16, r#"Fauna Audio GmbH"#),
+ (2423_u16, r#"TOYOTA motor corporation"#),
+ (2424_u16, r#"ZifferEins GmbH & Co. KG"#),
+ (2425_u16, r#"BIOTRONIK SE & Co. KG"#),
+ (2426_u16, r#"CORE CORPORATION"#),
+ (2427_u16, r#"CTEK Sweden AB"#),
+ (2428_u16, r#"Thorley Industries, LLC"#),
+ (2429_u16, r#"CLB B.V."#),
+ (2430_u16, r#"SonicSensory Inc"#),
+ (2431_u16, r#"ISEMAR S.R.L."#),
+ (2432_u16, r#"DEKRA TESTING AND CERTIFICATION, S.A.U."#),
+ (2433_u16, r#"Bernard Krone Holding SE & Co.KG"#),
+ (2434_u16, r#"ELPRO-BUCHS AG"#),
+ (2435_u16, r#"Feedback Sports LLC"#),
+ (2436_u16, r#"TeraTron GmbH"#),
+ (2437_u16, r#"Lumos Health Inc."#),
+ (2438_u16, r#"Cello Hill, LLC"#),
+ (2439_u16, r#"TSE BRAKES, INC."#),
+ (2440_u16, r#"BHM-Tech Produktionsgesellschaft m.b.H"#),
+ (2441_u16, r#"WIKA Alexander Wiegand SE & Co.KG"#),
+ (2442_u16, r#"Biovigil"#),
+ (2443_u16, r#"Mequonic Engineering, S.L."#),
+ (2444_u16, r#"bGrid B.V."#),
+ (2445_u16, r#"C3-WIRELESS, LLC"#),
+ (2446_u16, r#"ADVEEZ"#),
+ (2447_u16, r#"Aktiebolaget Regin"#),
+ (2448_u16, r#"Anton Paar GmbH"#),
+ (2449_u16, r#"Telenor ASA"#),
+ (2450_u16, r#"Big Kaiser Precision Tooling Ltd"#),
+ (2451_u16, r#"Absolute Audio Labs B.V."#),
+ (2452_u16, r#"VT42 Pty Ltd"#),
+ (2453_u16, r#"Bronkhorst High-Tech B.V."#),
+ (2454_u16, r#"C. & E. Fein GmbH"#),
+ (2455_u16, r#"NextMind"#),
+ (2456_u16, r#"Pixie Dust Technologies, Inc."#),
+ (2457_u16, r#"eTactica ehf"#),
+ (2458_u16, r#"New Audio LLC"#),
+ (2459_u16, r#"Sendum Wireless Corporation"#),
+ (2460_u16, r#"deister electronic GmbH"#),
+ (2461_u16, r#"YKK AP Inc."#),
+ (2462_u16, r#"Step One Limited"#),
+ (2463_u16, r#"Koya Medical, Inc."#),
+ (2464_u16, r#"Proof Diagnostics, Inc."#),
+ (2465_u16, r#"VOS Systems, LLC"#),
+ (2466_u16, r#"ENGAGENOW DATA SCIENCES PRIVATE LIMITED"#),
+ (2467_u16, r#"ARDUINO SA"#),
+ (2468_u16, r#"KUMHO ELECTRICS, INC"#),
+ (2469_u16, r#"Security Enhancement Systems, LLC"#),
+ (2470_u16, r#"BEIJING ELECTRIC VEHICLE CO.,LTD"#),
+ (2471_u16, r#"Paybuddy ApS"#),
+ (2472_u16, r#"KHN Solutions Inc"#),
+ (2473_u16, r#"Nippon Ceramic Co.,Ltd."#),
+ (2474_u16, r#"PHOTODYNAMIC INCORPORATED"#),
+ (2475_u16, r#"DashLogic, Inc."#),
+ (2476_u16, r#"Ambiq"#),
+ (2477_u16, r#"Narhwall Inc."#),
+ (2478_u16, r#"Pozyx NV"#),
+ (2479_u16, r#"ifLink Open Community"#),
+ (2480_u16, r#"Deublin Company, LLC"#),
+ (2481_u16, r#"BLINQY"#),
+ (2482_u16, r#"DYPHI"#),
+ (2483_u16, r#"BlueX Microelectronics Corp Ltd."#),
+ (2484_u16, r#"PentaLock Aps."#),
+ (2485_u16, r#"AUTEC Gesellschaft fuer Automationstechnik mbH"#),
+ (2486_u16, r#"Pegasus Technologies, Inc."#),
+ (2487_u16, r#"Bout Labs, LLC"#),
+ (2488_u16, r#"PlayerData Limited"#),
+ (2489_u16, r#"SAVOY ELECTRONIC LIGHTING"#),
+ (2490_u16, r#"Elimo Engineering Ltd"#),
+ (2491_u16, r#"SkyStream Corporation"#),
+ (2492_u16, r#"Aerosens LLC"#),
+ (2493_u16, r#"Centre Suisse d'Electronique et de Microtechnique SA"#),
+ (2494_u16, r#"Vessel Ltd."#),
+ (2495_u16, r#"Span.IO, Inc."#),
+ (2496_u16, r#"AnotherBrain inc."#),
+ (2497_u16, r#"Rosewill"#),
+ (2498_u16, r#"Universal Audio, Inc."#),
+ (2499_u16, r#"JAPAN TOBACCO INC."#),
+ (2500_u16, r#"UVISIO"#),
+ (2501_u16, r#"HungYi Microelectronics Co.,Ltd."#),
+ (2502_u16, r#"Honor Device Co., Ltd."#),
+ (2503_u16, r#"Combustion, LLC"#),
+ (2504_u16, r#"XUNTONG"#),
+ (2505_u16, r#"CrowdGlow Ltd"#),
+ (2506_u16, r#"Mobitrace"#),
+ (2507_u16, r#"Hx Engineering, LLC"#),
+ (2508_u16, r#"Senso4s d.o.o."#),
+ (2509_u16, r#"Blyott"#),
+ (2510_u16, r#"Julius Blum GmbH"#),
+ (2511_u16, r#"BlueStreak IoT, LLC"#),
+ (2512_u16, r#"Chess Wise B.V."#),
+ (2513_u16, r#"ABLEPAY TECHNOLOGIES AS"#),
+ (2514_u16, r#"Temperature Sensitive Solutions Systems Sweden AB"#),
+ (2515_u16, r#"HeartHero, inc."#),
+ (2516_u16, r#"ORBIS Inc."#),
+ (2517_u16, r#"GEAR RADIO ELECTRONICS CORP."#),
+ (2518_u16, r#"EAR TEKNIK ISITME VE ODIOMETRI CIHAZLARI SANAYI VE TICARET ANONIM SIRKETI"#),
+ (2519_u16, r#"Coyotta"#),
+ (2520_u16, r#"Synergy Tecnologia em Sistemas Ltda"#),
+ (2521_u16, r#"VivoSensMedical GmbH"#),
+ (2522_u16, r#"Nagravision SA"#),
+ (2523_u16, r#"Bionic Avionics Inc."#),
+ (2524_u16, r#"AON2 Ltd."#),
+ (2525_u16, r#"Innoware Development AB"#),
+ (2526_u16, r#"JLD Technology Solutions, LLC"#),
+ (2527_u16, r#"Magnus Technology Sdn Bhd"#),
+ (2528_u16, r#"Preddio Technologies Inc."#),
+ (2529_u16, r#"Tag-N-Trac Inc"#),
+ (2530_u16, r#"Wuhan Linptech Co.,Ltd."#),
+ (2531_u16, r#"Friday Home Aps"#),
+ (2532_u16, r#"CPS AS"#),
+ (2533_u16, r#"Mobilogix"#),
+ (2534_u16, r#"Masonite Corporation"#),
+ (2535_u16, r#"Kabushikigaisha HANERON"#),
+ (2536_u16, r#"Melange Systems Pvt. Ltd."#),
+ (2537_u16, r#"LumenRadio AB"#),
+ (2538_u16, r#"Athlos Oy"#),
+ (2539_u16, r#"KEAN ELECTRONICS PTY LTD"#),
+ (2540_u16, r#"Yukon advanced optics worldwide, UAB"#),
+ (2541_u16, r#"Sibel Inc."#),
+ (2542_u16, r#"OJMAR SA"#),
+ (2543_u16, r#"Steinel Solutions AG"#),
+ (2544_u16, r#"WatchGas B.V."#),
+ (2545_u16, r#"OM Digital Solutions Corporation"#),
+ (2546_u16, r#"Audeara Pty Ltd"#),
+ (2547_u16, r#"Beijing Zero Zero Infinity Technology Co.,Ltd."#),
+ (2548_u16, r#"Spectrum Technologies, Inc."#),
+ (2549_u16, r#"OKI Electric Industry Co., Ltd"#),
+ (2550_u16, r#"Mobile Action Technology Inc."#),
+ (2551_u16, r#"SENSATEC Co., Ltd."#),
+ (2552_u16, r#"R.O. S.R.L."#),
+ (2553_u16, r#"Hangzhou Yaguan Technology Co. LTD"#),
+ (2554_u16, r#"Listen Technologies Corporation"#),
+ (2555_u16, r#"TOITU CO., LTD."#),
+ (2556_u16, r#"Confidex"#),
+ (2557_u16, r#"Keep Technologies, Inc."#),
+ (2558_u16, r#"Lichtvision Engineering GmbH"#),
+ (2559_u16, r#"AIRSTAR"#),
+ (2560_u16, r#"Ampler Bikes OU"#),
+ (2561_u16, r#"Cleveron AS"#),
+ (2562_u16, r#"Ayxon-Dynamics GmbH"#),
+ (2563_u16, r#"donutrobotics Co., Ltd."#),
+ (2564_u16, r#"Flosonics Medical"#),
+ (2565_u16, r#"Southwire Company, LLC"#),
+ (2566_u16, r#"Shanghai wuqi microelectronics Co.,Ltd"#),
+ (2567_u16, r#"Reflow Pty Ltd"#),
+ (2568_u16, r#"Oras Oy"#),
+ (2569_u16, r#"ECCT"#),
+ (2570_u16, r#"Volan Technology Inc."#),
+ (2571_u16, r#"SIANA Systems"#),
+ (2572_u16, r#"Shanghai Yidian Intelligent Technology Co., Ltd."#),
+ (2573_u16, r#"Blue Peacock GmbH"#),
+ (2574_u16, r#"Roland Corporation"#),
+ (2575_u16, r#"LIXIL Corporation"#),
+ (2576_u16, r#"SUBARU Corporation"#),
+ (2577_u16, r#"Sensolus"#),
+ (2578_u16, r#"Dyson Technology Limited"#),
+ (2579_u16, r#"Tec4med LifeScience GmbH"#),
+ (2580_u16, r#"CROXEL, INC."#),
+ (2581_u16, r#"Syng Inc"#),
+ (2582_u16, r#"RIDE VISION LTD"#),
+ (2583_u16, r#"Plume Design Inc"#),
+ (2584_u16, r#"Cambridge Animal Technologies Ltd"#),
+ (2585_u16, r#"Maxell, Ltd."#),
+ (2586_u16, r#"Link Labs, Inc."#),
+ (2587_u16, r#"Embrava Pty Ltd"#),
+ (2588_u16, r#"INPEAK S.C."#),
+ (2589_u16, r#"API-K"#),
+ (2590_u16, r#"CombiQ AB"#),
+ (2591_u16, r#"DeVilbiss Healthcare LLC"#),
+ (2592_u16, r#"Jiangxi Innotech Technology Co., Ltd"#),
+ (2593_u16, r#"Apollogic Sp. z o.o."#),
+ (2594_u16, r#"DAIICHIKOSHO CO., LTD."#),
+ (2595_u16, r#"BIXOLON CO.,LTD"#),
+ (2596_u16, r#"Atmosic Technologies, Inc."#),
+ (2597_u16, r#"Eran Financial Services LLC"#),
+ (2598_u16, r#"Louis Vuitton"#),
+ (2599_u16, r#"AYU DEVICES PRIVATE LIMITED"#),
+ (2600_u16, r#"NanoFlex"#),
+ (2601_u16, r#"Worthcloud Technology Co.,Ltd"#),
+ (2602_u16, r#"Yamaha Corporation"#),
+ (2603_u16, r#"PaceBait IVS"#),
+ (2604_u16, r#"Shenzhen H&T Intelligent Control Co., Ltd"#),
+ (2605_u16, r#"Shenzhen Feasycom Technology Co., Ltd."#),
+ (2606_u16, r#"Zuma Array Limited"#),
+ (2607_u16, r#"Instamic, Inc."#),
+ (2608_u16, r#"Air-Weigh"#),
+ (2609_u16, r#"Nevro Corp."#),
+ (2610_u16, r#"Pinnacle Technology, Inc."#),
+ (2611_u16, r#"WMF AG"#),
+ (2612_u16, r#"Luxer Corporation"#),
+ (2613_u16, r#"safectory GmbH"#),
+ (2614_u16, r#"NGK SPARK PLUG CO., LTD."#),
+ (2615_u16, r#"2587702 Ontario Inc."#),
+ (2616_u16, r#"Bouffalo Lab (Nanjing)., Ltd."#),
+ (2617_u16, r#"BLUETICKETING SRL"#),
+ (2618_u16, r#"Incotex Co. Ltd."#),
+ (2619_u16, r#"Galileo Technology Limited"#),
+ (2620_u16, r#"Siteco GmbH"#),
+ (2621_u16, r#"DELABIE"#),
+ (2622_u16, r#"Hefei Yunlian Semiconductor Co., Ltd"#),
+ (2623_u16, r#"Shenzhen Yopeak Optoelectronics Technology Co., Ltd."#),
+ (2624_u16, r#"GEWISS S.p.A."#),
+ (2625_u16, r#"OPEX Corporation"#),
+ (2626_u16, r#"Motionalysis, Inc."#),
+ (2627_u16, r#"Busch Systems International Inc."#),
+ (2628_u16, r#"Novidan, Inc."#),
+ (2629_u16, r#"3SI Security Systems, Inc"#),
+ (2630_u16, r#"Beijing HC-Infinite Technology Limited"#),
+ (2631_u16, r#"The Wand Company Ltd"#),
+ (2632_u16, r#"JRC Mobility Inc."#),
+ (2633_u16, r#"Venture Research Inc."#),
+ (2634_u16, r#"Map Large, Inc."#),
+ (2635_u16, r#"MistyWest Energy and Transport Ltd."#),
+ (2636_u16, r#"SiFli Technologies (shanghai) Inc."#),
+ (2637_u16, r#"Lockn Technologies Private Limited"#),
+ (2638_u16, r#"Toytec Corporation"#),
+ (2639_u16, r#"VANMOOF Global Holding B.V."#),
+ (2640_u16, r#"Nextscape Inc."#),
+ (2641_u16, r#"CSIRO"#),
+ (2642_u16, r#"Follow Sense Europe B.V."#),
+ (2643_u16, r#"KKM COMPANY LIMITED"#),
+ (2644_u16, r#"SQL Technologies Corp."#),
+ (2645_u16, r#"Inugo Systems Limited"#),
+ (2646_u16, r#"ambie"#),
+ (2647_u16, r#"Meizhou Guo Wei Electronics Co., Ltd"#),
+ (2648_u16, r#"Indigo Diabetes"#),
+ (2649_u16, r#"TourBuilt, LLC"#),
+ (2650_u16, r#"Sontheim Industrie Elektronik GmbH"#),
+ (2651_u16, r#"LEGIC Identsystems AG"#),
+ (2652_u16, r#"Innovative Design Labs Inc."#),
+ (2653_u16, r#"MG Energy Systems B.V."#),
+ (2654_u16, r#"LaceClips llc"#),
+ (2655_u16, r#"stryker"#),
+ (2656_u16, r#"DATANG SEMICONDUCTOR TECHNOLOGY CO.,LTD"#),
+ (2657_u16, r#"Smart Parks B.V."#),
+ (2658_u16, r#"MOKO TECHNOLOGY Ltd"#),
+ (2659_u16, r#"Gremsy JSC"#),
+ (2660_u16, r#"Geopal system A/S"#),
+ (2661_u16, r#"Lytx, INC."#),
+ (2662_u16, r#"JUSTMORPH PTE. LTD."#),
+ (2663_u16, r#"Beijing SuperHexa Century Technology CO. Ltd"#),
+ (2664_u16, r#"Focus Ingenieria SRL"#),
+ (2665_u16, r#"HAPPIEST BABY, INC."#),
+ (2666_u16, r#"Scribble Design Inc."#),
+ (2667_u16, r#"Olympic Ophthalmics, Inc."#),
+ (2668_u16, r#"Pokkels"#),
+ (2669_u16, r#"KUUKANJYOKIN Co.,Ltd."#),
+ (2670_u16, r#"Pac Sane Limited"#),
+ (2671_u16, r#"Warner Bros."#),
+ (2672_u16, r#"Ooma"#),
+ (2673_u16, r#"Senquip Pty Ltd"#),
+ (2674_u16, r#"Jumo GmbH & Co. KG"#),
+ (2675_u16, r#"Innohome Oy"#),
+ (2676_u16, r#"MICROSON S.A."#),
+ (2677_u16, r#"Delta Cycle Corporation"#),
+ (2678_u16, r#"Synaptics Incorporated"#),
+ (2679_u16, r#"JMD PACIFIC PTE. LTD."#),
+ (2680_u16, r#"Shenzhen Sunricher Technology Limited"#),
+ (2681_u16, r#"Webasto SE"#),
+ (2682_u16, r#"Emlid Limited"#),
+ (2683_u16, r#"UniqAir Oy"#),
+ (2684_u16, r#"WAFERLOCK"#),
+ (2685_u16, r#"Freedman Electronics Pty Ltd"#),
+ (2686_u16, r#"Keba AG"#),
+ (2687_u16, r#"Intuity Medical"#),
+ ]
+ .into_iter()
+ .map(|(id, name)| (Uuid16::from_be_bytes(id.to_be_bytes()), name))
+ .collect();
+}
diff --git a/rust/src/wrapper/assigned_numbers/mod.rs b/rust/src/wrapper/assigned_numbers/mod.rs
index becdc11..2584718 100644
--- a/rust/src/wrapper/assigned_numbers/mod.rs
+++ b/rust/src/wrapper/assigned_numbers/mod.rs
@@ -14,40 +14,8 @@
//! Assigned numbers from the Bluetooth spec.
-use crate::wrapper::core::Uuid16;
-use lazy_static::lazy_static;
-use pyo3::{
- intern,
- types::{PyDict, PyModule},
- PyResult, Python,
-};
-use std::collections;
-
+mod company_ids;
mod services;
+pub use company_ids::COMPANY_IDS;
pub use services::SERVICE_IDS;
-
-lazy_static! {
- /// Assigned company IDs
- pub static ref COMPANY_IDS: collections::HashMap<Uuid16, String> = load_company_ids()
- .expect("Could not load company ids -- are Bumble's Python sources available?");
-
-}
-
-fn load_company_ids() -> PyResult<collections::HashMap<Uuid16, String>> {
- // this takes about 4ms on a fast machine -- slower than constructing in rust, but not slow
- // enough to worry about
- Python::with_gil(|py| {
- PyModule::import(py, intern!(py, "bumble.company_ids"))?
- .getattr(intern!(py, "COMPANY_IDENTIFIERS"))?
- .downcast::<PyDict>()?
- .into_iter()
- .map(|(k, v)| {
- Ok((
- Uuid16::from_be_bytes(k.extract::<u16>()?.to_be_bytes()),
- v.str()?.to_str()?.to_string(),
- ))
- })
- .collect::<PyResult<collections::HashMap<_, _>>>()
- })
-}
diff --git a/rust/src/wrapper/core.rs b/rust/src/wrapper/core.rs
index a55760d..bb171d1 100644
--- a/rust/src/wrapper/core.rs
+++ b/rust/src/wrapper/core.rs
@@ -59,7 +59,7 @@ impl AdvertisingData {
}
/// 16-bit UUID
-#[derive(PartialEq, Eq, Hash)]
+#[derive(PartialEq, Eq, Hash, Clone, Copy)]
pub struct Uuid16 {
/// Big-endian bytes
uuid: [u8; 2],
diff --git a/rust/src/wrapper/device.rs b/rust/src/wrapper/device.rs
index d635754..be5e4fa 100644
--- a/rust/src/wrapper/device.rs
+++ b/rust/src/wrapper/device.rs
@@ -19,13 +19,19 @@ use crate::{
wrapper::{
core::AdvertisingData,
gatt_client::{ProfileServiceProxy, ServiceProxy},
- hci::Address,
+ hci::{Address, HciErrorCode},
+ host::Host,
+ l2cap::LeConnectionOrientedChannel,
transport::{Sink, Source},
- ClosureCallback,
+ ClosureCallback, PyDictExt, PyObjectExt,
},
};
-use pyo3::types::PyDict;
-use pyo3::{intern, types::PyModule, PyObject, PyResult, Python, ToPyObject};
+use pyo3::{
+ intern,
+ types::{PyDict, PyModule},
+ IntoPy, PyObject, PyResult, Python, ToPyObject,
+};
+use pyo3_asyncio::tokio::into_future;
use std::path;
/// A device that can send/receive HCI frames.
@@ -65,7 +71,7 @@ impl Device {
Python::with_gil(|py| {
self.0
.call_method0(py, intern!(py, "power_on"))
- .and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
+ .and_then(|coroutine| into_future(coroutine.as_ref(py)))
})?
.await
.map(|_| ())
@@ -76,12 +82,28 @@ impl Device {
Python::with_gil(|py| {
self.0
.call_method1(py, intern!(py, "connect"), (peer_addr,))
- .and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
+ .and_then(|coroutine| into_future(coroutine.as_ref(py)))
})?
.await
.map(Connection)
}
+ /// Register a callback to be called for each incoming connection.
+ pub fn on_connection(
+ &mut self,
+ callback: impl Fn(Python, Connection) -> PyResult<()> + Send + 'static,
+ ) -> PyResult<()> {
+ let boxed = ClosureCallback::new(move |py, args, _kwargs| {
+ callback(py, Connection(args.get_item(0)?.into()))
+ });
+
+ Python::with_gil(|py| {
+ self.0
+ .call_method1(py, intern!(py, "add_listener"), ("connection", boxed))
+ })
+ .map(|_| ())
+ }
+
/// Start scanning
pub async fn start_scanning(&self, filter_duplicates: bool) -> PyResult<()> {
Python::with_gil(|py| {
@@ -89,7 +111,7 @@ impl Device {
kwargs.set_item("filter_duplicates", filter_duplicates)?;
self.0
.call_method(py, intern!(py, "start_scanning"), (), Some(kwargs))
- .and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
+ .and_then(|coroutine| into_future(coroutine.as_ref(py)))
})?
.await
.map(|_| ())
@@ -123,6 +145,15 @@ impl Device {
.map(|_| ())
}
+ /// Returns the host used by the device, if any
+ pub fn host(&mut self) -> PyResult<Option<Host>> {
+ Python::with_gil(|py| {
+ self.0
+ .getattr(py, intern!(py, "host"))
+ .map(|obj| obj.into_option(Host::from))
+ })
+ }
+
/// Start advertising the data set with [Device.set_advertisement].
pub async fn start_advertising(&mut self, auto_restart: bool) -> PyResult<()> {
Python::with_gil(|py| {
@@ -131,7 +162,7 @@ impl Device {
self.0
.call_method(py, intern!(py, "start_advertising"), (), Some(kwargs))
- .and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
+ .and_then(|coroutine| into_future(coroutine.as_ref(py)))
})?
.await
.map(|_| ())
@@ -142,16 +173,114 @@ impl Device {
Python::with_gil(|py| {
self.0
.call_method0(py, intern!(py, "stop_advertising"))
- .and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
+ .and_then(|coroutine| into_future(coroutine.as_ref(py)))
})?
.await
.map(|_| ())
}
+
+ /// Registers an L2CAP connection oriented channel server. When a client connects to the server,
+ /// the `server` callback is passed a handle to the established channel. When optional arguments
+ /// are not specified, the Python module specifies the defaults.
+ pub fn register_l2cap_channel_server(
+ &mut self,
+ psm: u16,
+ server: impl Fn(Python, LeConnectionOrientedChannel) -> PyResult<()> + Send + 'static,
+ max_credits: Option<u16>,
+ mtu: Option<u16>,
+ mps: Option<u16>,
+ ) -> PyResult<()> {
+ Python::with_gil(|py| {
+ let boxed = ClosureCallback::new(move |py, args, _kwargs| {
+ server(
+ py,
+ LeConnectionOrientedChannel::from(args.get_item(0)?.into()),
+ )
+ });
+
+ let kwargs = PyDict::new(py);
+ kwargs.set_item("psm", psm)?;
+ kwargs.set_item("server", boxed.into_py(py))?;
+ kwargs.set_opt_item("max_credits", max_credits)?;
+ kwargs.set_opt_item("mtu", mtu)?;
+ kwargs.set_opt_item("mps", mps)?;
+ self.0.call_method(
+ py,
+ intern!(py, "register_l2cap_channel_server"),
+ (),
+ Some(kwargs),
+ )
+ })?;
+ Ok(())
+ }
}
/// A connection to a remote device.
pub struct Connection(PyObject);
+impl Connection {
+ /// Open an L2CAP channel using this connection. When optional arguments are not specified, the
+ /// Python module specifies the defaults.
+ pub async fn open_l2cap_channel(
+ &mut self,
+ psm: u16,
+ max_credits: Option<u16>,
+ mtu: Option<u16>,
+ mps: Option<u16>,
+ ) -> PyResult<LeConnectionOrientedChannel> {
+ Python::with_gil(|py| {
+ let kwargs = PyDict::new(py);
+ kwargs.set_item("psm", psm)?;
+ kwargs.set_opt_item("max_credits", max_credits)?;
+ kwargs.set_opt_item("mtu", mtu)?;
+ kwargs.set_opt_item("mps", mps)?;
+ self.0
+ .call_method(py, intern!(py, "open_l2cap_channel"), (), Some(kwargs))
+ .and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
+ })?
+ .await
+ .map(LeConnectionOrientedChannel::from)
+ }
+
+ /// Disconnect from device with provided reason. When optional arguments are not specified, the
+ /// Python module specifies the defaults.
+ pub async fn disconnect(&mut self, reason: Option<HciErrorCode>) -> PyResult<()> {
+ Python::with_gil(|py| {
+ let kwargs = PyDict::new(py);
+ kwargs.set_opt_item("reason", reason)?;
+ self.0
+ .call_method(py, intern!(py, "disconnect"), (), Some(kwargs))
+ .and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
+ })?
+ .await
+ .map(|_| ())
+ }
+
+ /// Register a callback to be called on disconnection.
+ pub fn on_disconnection(
+ &mut self,
+ callback: impl Fn(Python, HciErrorCode) -> PyResult<()> + Send + 'static,
+ ) -> PyResult<()> {
+ let boxed = ClosureCallback::new(move |py, args, _kwargs| {
+ callback(py, args.get_item(0)?.extract()?)
+ });
+
+ Python::with_gil(|py| {
+ self.0
+ .call_method1(py, intern!(py, "add_listener"), ("disconnection", boxed))
+ })
+ .map(|_| ())
+ }
+
+ /// Returns some information about the connection as a [String].
+ pub fn debug_string(&self) -> PyResult<String> {
+ Python::with_gil(|py| {
+ let str_obj = self.0.call_method0(py, intern!(py, "__str__"))?;
+ str_obj.gil_ref(py).extract()
+ })
+ }
+}
+
/// The other end of a connection
pub struct Peer(PyObject);
@@ -173,7 +302,7 @@ impl Peer {
Python::with_gil(|py| {
self.0
.call_method0(py, intern!(py, "discover_services"))
- .and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
+ .and_then(|coroutine| into_future(coroutine.as_ref(py)))
})?
.await
.and_then(|list| {
@@ -207,13 +336,7 @@ impl Peer {
let class = module.getattr(P::PROXY_CLASS_NAME)?;
self.0
.call_method1(py, intern!(py, "create_service_proxy"), (class,))
- .map(|obj| {
- if obj.is_none(py) {
- None
- } else {
- Some(P::wrap(obj))
- }
- })
+ .map(|obj| obj.into_option(P::wrap))
})
}
}
diff --git a/rust/src/wrapper/drivers/mod.rs b/rust/src/wrapper/drivers/mod.rs
new file mode 100644
index 0000000..ff38ac1
--- /dev/null
+++ b/rust/src/wrapper/drivers/mod.rs
@@ -0,0 +1,17 @@
+// 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
+//
+// http://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.
+
+//! Device drivers
+
+pub mod rtk;
diff --git a/rust/src/wrapper/drivers/rtk.rs b/rust/src/wrapper/drivers/rtk.rs
new file mode 100644
index 0000000..1f629d1
--- /dev/null
+++ b/rust/src/wrapper/drivers/rtk.rs
@@ -0,0 +1,141 @@
+// 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
+//
+// http://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 for Realtek controllers
+
+use crate::wrapper::{host::Host, PyObjectExt};
+use pyo3::{intern, types::PyModule, PyObject, PyResult, Python, ToPyObject};
+use pyo3_asyncio::tokio::into_future;
+
+pub use crate::internal::drivers::rtk::{Firmware, Patch};
+
+/// Driver for a Realtek controller
+pub struct Driver(PyObject);
+
+impl Driver {
+ /// Locate the driver for the provided host.
+ pub async fn for_host(host: &Host, force: bool) -> PyResult<Option<Self>> {
+ Python::with_gil(|py| {
+ PyModule::import(py, intern!(py, "bumble.drivers.rtk"))?
+ .getattr(intern!(py, "Driver"))?
+ .call_method1(intern!(py, "for_host"), (&host.obj, force))
+ .and_then(into_future)
+ })?
+ .await
+ .map(|obj| obj.into_option(Self))
+ }
+
+ /// Check if the host has a known driver.
+ pub async fn check(host: &Host) -> PyResult<bool> {
+ Python::with_gil(|py| {
+ PyModule::import(py, intern!(py, "bumble.drivers.rtk"))?
+ .getattr(intern!(py, "Driver"))?
+ .call_method1(intern!(py, "check"), (&host.obj,))
+ .and_then(|obj| obj.extract::<bool>())
+ })
+ }
+
+ /// Find the [DriverInfo] for the host, if one matches
+ pub async fn driver_info_for_host(host: &Host) -> PyResult<Option<DriverInfo>> {
+ Python::with_gil(|py| {
+ PyModule::import(py, intern!(py, "bumble.drivers.rtk"))?
+ .getattr(intern!(py, "Driver"))?
+ .call_method1(intern!(py, "driver_info_for_host"), (&host.obj,))
+ .and_then(into_future)
+ })?
+ .await
+ .map(|obj| obj.into_option(DriverInfo))
+ }
+
+ /// Send a command to the device to drop firmware
+ pub async fn drop_firmware(host: &mut Host) -> PyResult<()> {
+ Python::with_gil(|py| {
+ PyModule::import(py, intern!(py, "bumble.drivers.rtk"))?
+ .getattr(intern!(py, "Driver"))?
+ .call_method1(intern!(py, "drop_firmware"), (&host.obj,))
+ .and_then(into_future)
+ })?
+ .await
+ .map(|_| ())
+ }
+
+ /// Load firmware onto the device.
+ pub async fn download_firmware(&mut self) -> PyResult<()> {
+ Python::with_gil(|py| {
+ self.0
+ .call_method0(py, intern!(py, "download_firmware"))
+ .and_then(|coroutine| into_future(coroutine.as_ref(py)))
+ })?
+ .await
+ .map(|_| ())
+ }
+}
+
+/// Metadata about a known driver & applicable device
+pub struct DriverInfo(PyObject);
+
+impl DriverInfo {
+ /// Returns a list of all drivers that Bumble knows how to handle.
+ pub fn all_drivers() -> PyResult<Vec<DriverInfo>> {
+ Python::with_gil(|py| {
+ PyModule::import(py, intern!(py, "bumble.drivers.rtk"))?
+ .getattr(intern!(py, "Driver"))?
+ .getattr(intern!(py, "DRIVER_INFOS"))?
+ .iter()?
+ .map(|r| r.map(|h| DriverInfo(h.to_object(py))))
+ .collect::<PyResult<Vec<_>>>()
+ })
+ }
+
+ /// The firmware file name to load from the filesystem, e.g. `foo_fw.bin`.
+ pub fn firmware_name(&self) -> PyResult<String> {
+ Python::with_gil(|py| {
+ self.0
+ .getattr(py, intern!(py, "fw_name"))?
+ .as_ref(py)
+ .extract::<String>()
+ })
+ }
+
+ /// The config file name, if any, to load from the filesystem, e.g. `foo_config.bin`.
+ pub fn config_name(&self) -> PyResult<Option<String>> {
+ Python::with_gil(|py| {
+ let obj = self.0.getattr(py, intern!(py, "config_name"))?;
+ let handle = obj.as_ref(py);
+
+ if handle.is_none() {
+ Ok(None)
+ } else {
+ handle
+ .extract::<String>()
+ .map(|s| if s.is_empty() { None } else { Some(s) })
+ }
+ })
+ }
+
+ /// Whether or not config is required.
+ pub fn config_needed(&self) -> PyResult<bool> {
+ Python::with_gil(|py| {
+ self.0
+ .getattr(py, intern!(py, "config_needed"))?
+ .as_ref(py)
+ .extract::<bool>()
+ })
+ }
+
+ /// ROM id
+ pub fn rom(&self) -> PyResult<u32> {
+ Python::with_gil(|py| self.0.getattr(py, intern!(py, "rom"))?.as_ref(py).extract())
+ }
+}
diff --git a/rust/src/wrapper/hci.rs b/rust/src/wrapper/hci.rs
index 48f7dc1..41dcbf3 100644
--- a/rust/src/wrapper/hci.rs
+++ b/rust/src/wrapper/hci.rs
@@ -15,7 +15,40 @@
//! HCI
use itertools::Itertools as _;
-use pyo3::{exceptions::PyException, intern, types::PyModule, PyErr, PyObject, PyResult, Python};
+use pyo3::{
+ exceptions::PyException, intern, types::PyModule, FromPyObject, PyAny, PyErr, PyObject,
+ PyResult, Python, ToPyObject,
+};
+
+/// HCI error code.
+pub struct HciErrorCode(u8);
+
+impl<'source> FromPyObject<'source> for HciErrorCode {
+ fn extract(ob: &'source PyAny) -> PyResult<Self> {
+ Ok(HciErrorCode(ob.extract()?))
+ }
+}
+
+impl ToPyObject for HciErrorCode {
+ fn to_object(&self, py: Python<'_>) -> PyObject {
+ self.0.to_object(py)
+ }
+}
+
+/// Provides helpers for interacting with HCI
+pub struct HciConstant;
+
+impl HciConstant {
+ /// Human-readable error name
+ pub fn error_name(status: HciErrorCode) -> PyResult<String> {
+ Python::with_gil(|py| {
+ PyModule::import(py, intern!(py, "bumble.hci"))?
+ .getattr(intern!(py, "HCI_Constant"))?
+ .call_method1(intern!(py, "error_name"), (status.0,))?
+ .extract()
+ })
+ }
+}
/// A Bluetooth address
pub struct Address(pub(crate) PyObject);
diff --git a/rust/src/wrapper/host.rs b/rust/src/wrapper/host.rs
new file mode 100644
index 0000000..ab81450
--- /dev/null
+++ b/rust/src/wrapper/host.rs
@@ -0,0 +1,71 @@
+// 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
+//
+// http://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.
+
+//! Host-side types
+
+use crate::wrapper::transport::{Sink, Source};
+use pyo3::{intern, prelude::PyModule, types::PyDict, PyObject, PyResult, Python};
+
+/// Host HCI commands
+pub struct Host {
+ pub(crate) obj: PyObject,
+}
+
+impl Host {
+ /// Create a Host that wraps the provided obj
+ pub(crate) fn from(obj: PyObject) -> Self {
+ Self { obj }
+ }
+
+ /// Create a new Host
+ pub fn new(source: Source, sink: Sink) -> PyResult<Self> {
+ Python::with_gil(|py| {
+ PyModule::import(py, intern!(py, "bumble.host"))?
+ .getattr(intern!(py, "Host"))?
+ .call((source.0, sink.0), None)
+ .map(|any| Self { obj: any.into() })
+ })
+ }
+
+ /// Send a reset command and perform other reset tasks.
+ pub async fn reset(&mut self, driver_factory: DriverFactory) -> PyResult<()> {
+ Python::with_gil(|py| {
+ let kwargs = match driver_factory {
+ DriverFactory::None => {
+ let kw = PyDict::new(py);
+ kw.set_item("driver_factory", py.None())?;
+ Some(kw)
+ }
+ DriverFactory::Auto => {
+ // leave the default in place
+ None
+ }
+ };
+ self.obj
+ .call_method(py, intern!(py, "reset"), (), kwargs)
+ .and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
+ })?
+ .await
+ .map(|_| ())
+ }
+}
+
+/// Driver factory to use when initializing a host
+#[derive(Debug, Clone)]
+pub enum DriverFactory {
+ /// Do not load drivers
+ None,
+ /// Load appropriate driver, if any is found
+ Auto,
+}
diff --git a/rust/src/wrapper/l2cap.rs b/rust/src/wrapper/l2cap.rs
new file mode 100644
index 0000000..5e0752e
--- /dev/null
+++ b/rust/src/wrapper/l2cap.rs
@@ -0,0 +1,92 @@
+// 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
+//
+// http://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.
+
+//! L2CAP
+
+use crate::wrapper::{ClosureCallback, PyObjectExt};
+use pyo3::{intern, PyObject, PyResult, Python};
+
+/// L2CAP connection-oriented channel
+pub struct LeConnectionOrientedChannel(PyObject);
+
+impl LeConnectionOrientedChannel {
+ /// Create a LeConnectionOrientedChannel that wraps the provided obj.
+ pub(crate) fn from(obj: PyObject) -> Self {
+ Self(obj)
+ }
+
+ /// Queues data to be automatically sent across this channel.
+ pub fn write(&mut self, data: &[u8]) -> PyResult<()> {
+ Python::with_gil(|py| self.0.call_method1(py, intern!(py, "write"), (data,))).map(|_| ())
+ }
+
+ /// Wait for queued data to be sent on this channel.
+ pub async fn drain(&mut self) -> PyResult<()> {
+ Python::with_gil(|py| {
+ self.0
+ .call_method0(py, intern!(py, "drain"))
+ .and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
+ })?
+ .await
+ .map(|_| ())
+ }
+
+ /// Register a callback to be called when the channel is closed.
+ pub fn on_close(
+ &mut self,
+ callback: impl Fn(Python) -> PyResult<()> + Send + 'static,
+ ) -> PyResult<()> {
+ let boxed = ClosureCallback::new(move |py, _args, _kwargs| callback(py));
+
+ Python::with_gil(|py| {
+ self.0
+ .call_method1(py, intern!(py, "add_listener"), ("close", boxed))
+ })
+ .map(|_| ())
+ }
+
+ /// Register a callback to be called when the channel receives data.
+ pub fn set_sink(
+ &mut self,
+ callback: impl Fn(Python, &[u8]) -> PyResult<()> + Send + 'static,
+ ) -> PyResult<()> {
+ let boxed = ClosureCallback::new(move |py, args, _kwargs| {
+ callback(py, args.get_item(0)?.extract()?)
+ });
+ Python::with_gil(|py| self.0.setattr(py, intern!(py, "sink"), boxed)).map(|_| ())
+ }
+
+ /// Disconnect the l2cap channel.
+ /// Must be called from a thread with a Python event loop, which should be true on
+ /// `tokio::main` and `async_std::main`.
+ ///
+ /// For more info, see https://awestlake87.github.io/pyo3-asyncio/master/doc/pyo3_asyncio/#event-loop-references-and-contextvars.
+ pub async fn disconnect(&mut self) -> PyResult<()> {
+ Python::with_gil(|py| {
+ self.0
+ .call_method0(py, intern!(py, "disconnect"))
+ .and_then(|coroutine| pyo3_asyncio::tokio::into_future(coroutine.as_ref(py)))
+ })?
+ .await
+ .map(|_| ())
+ }
+
+ /// Returns some information about the channel as a [String].
+ pub fn debug_string(&self) -> PyResult<String> {
+ Python::with_gil(|py| {
+ let str_obj = self.0.call_method0(py, intern!(py, "__str__"))?;
+ str_obj.gil_ref(py).extract()
+ })
+ }
+}
diff --git a/rust/src/wrapper/logging.rs b/rust/src/wrapper/logging.rs
index 141cc04..bd932cb 100644
--- a/rust/src/wrapper/logging.rs
+++ b/rust/src/wrapper/logging.rs
@@ -1,3 +1,17 @@
+// 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
+//
+// http://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 & Python logging
use pyo3::types::PyDict;
diff --git a/rust/src/wrapper/mod.rs b/rust/src/wrapper/mod.rs
index 2ab71c3..94ac15a 100644
--- a/rust/src/wrapper/mod.rs
+++ b/rust/src/wrapper/mod.rs
@@ -31,14 +31,17 @@ pub use pyo3_asyncio;
pub mod assigned_numbers;
pub mod core;
pub mod device;
+pub mod drivers;
pub mod gatt_client;
pub mod hci;
+pub mod host;
+pub mod l2cap;
pub mod logging;
pub mod profile;
pub mod transport;
/// Convenience extensions to [PyObject]
-pub trait PyObjectExt {
+pub trait PyObjectExt: Sized {
/// Get a GIL-bound reference
fn gil_ref<'py>(&'py self, py: Python<'py>) -> &'py PyAny;
@@ -49,6 +52,17 @@ pub trait PyObjectExt {
{
Python::with_gil(|py| self.gil_ref(py).extract::<T>())
}
+
+ /// If the Python object is a Python `None`, return a Rust `None`, otherwise `Some` with the mapped type
+ fn into_option<T>(self, map_obj: impl Fn(Self) -> T) -> Option<T> {
+ Python::with_gil(|py| {
+ if self.gil_ref(py).is_none() {
+ None
+ } else {
+ Some(map_obj(self))
+ }
+ })
+ }
}
impl PyObjectExt for PyObject {
@@ -57,6 +71,21 @@ impl PyObjectExt for PyObject {
}
}
+/// Convenience extensions to [PyDict]
+pub trait PyDictExt {
+ /// Set item in dict only if value is Some, otherwise do nothing.
+ fn set_opt_item<K: ToPyObject, V: ToPyObject>(&self, key: K, value: Option<V>) -> PyResult<()>;
+}
+
+impl PyDictExt for PyDict {
+ fn set_opt_item<K: ToPyObject, V: ToPyObject>(&self, key: K, value: Option<V>) -> PyResult<()> {
+ if let Some(value) = value {
+ self.set_item(key, value)?
+ }
+ Ok(())
+ }
+}
+
/// Wrapper to make Rust closures ([Fn] implementations) callable from Python.
///
/// The Python callable form returns a Python `None`.
diff --git a/rust/src/wrapper/profile.rs b/rust/src/wrapper/profile.rs
index 854ba80..fc473ff 100644
--- a/rust/src/wrapper/profile.rs
+++ b/rust/src/wrapper/profile.rs
@@ -14,7 +14,10 @@
//! GATT profiles
-use crate::wrapper::gatt_client::{CharacteristicProxy, ProfileServiceProxy};
+use crate::wrapper::{
+ gatt_client::{CharacteristicProxy, ProfileServiceProxy},
+ PyObjectExt,
+};
use pyo3::{intern, PyObject, PyResult, Python};
/// Exposes the battery GATT service
@@ -26,13 +29,7 @@ impl BatteryServiceProxy {
Python::with_gil(|py| {
self.0
.getattr(py, intern!(py, "battery_level"))
- .map(|level| {
- if level.is_none(py) {
- None
- } else {
- Some(CharacteristicProxy(level))
- }
- })
+ .map(|level| level.into_option(CharacteristicProxy))
})
}
}
diff --git a/rust/tools/file_header.rs b/rust/tools/file_header.rs
new file mode 100644
index 0000000..fb3286d
--- /dev/null
+++ b/rust/tools/file_header.rs
@@ -0,0 +1,78 @@
+// 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
+//
+// http://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.
+
+use anyhow::anyhow;
+use clap::Parser as _;
+use file_header::{
+ add_headers_recursively, check_headers_recursively,
+ license::spdx::{YearCopyrightOwnerValue, APACHE_2_0},
+};
+use globset::{Glob, GlobSet, GlobSetBuilder};
+use std::{env, path::PathBuf};
+
+fn main() -> anyhow::Result<()> {
+ let rust_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR")?);
+ let ignore_globset = ignore_globset()?;
+ // Note: when adding headers, there is a bug where the line spacing is off for Apache 2.0 (see https://github.com/spdx/license-list-XML/issues/2127)
+ let header = APACHE_2_0.build_header(YearCopyrightOwnerValue::new(2023, "Google LLC".into()));
+
+ let cli = Cli::parse();
+
+ match cli.subcommand {
+ Subcommand::CheckAll => {
+ let result =
+ check_headers_recursively(&rust_dir, |p| !ignore_globset.is_match(p), header, 4)?;
+ if result.has_failure() {
+ return Err(anyhow!(
+ "The following files do not have headers: {result:?}"
+ ));
+ }
+ }
+ Subcommand::AddAll => {
+ let files_with_new_header =
+ add_headers_recursively(&rust_dir, |p| !ignore_globset.is_match(p), header)?;
+ files_with_new_header
+ .iter()
+ .for_each(|path| println!("Added header to: {path:?}"));
+ }
+ }
+ Ok(())
+}
+
+fn ignore_globset() -> anyhow::Result<GlobSet> {
+ Ok(GlobSetBuilder::new()
+ .add(Glob::new("**/.idea/**")?)
+ .add(Glob::new("**/target/**")?)
+ .add(Glob::new("**/.gitignore")?)
+ .add(Glob::new("**/CHANGELOG.md")?)
+ .add(Glob::new("**/Cargo.lock")?)
+ .add(Glob::new("**/Cargo.toml")?)
+ .add(Glob::new("**/README.md")?)
+ .add(Glob::new("*.bin")?)
+ .build()?)
+}
+
+#[derive(clap::Parser)]
+struct Cli {
+ #[clap(subcommand)]
+ subcommand: Subcommand,
+}
+
+#[derive(clap::Subcommand, Debug, Clone)]
+enum Subcommand {
+ /// Checks if a license is present in files that are not in the ignore list.
+ CheckAll,
+ /// Adds a license as needed to files that are not in the ignore list.
+ AddAll,
+}
diff --git a/rust/tools/gen_assigned_numbers.rs b/rust/tools/gen_assigned_numbers.rs
new file mode 100644
index 0000000..b2c525e
--- /dev/null
+++ b/rust/tools/gen_assigned_numbers.rs
@@ -0,0 +1,97 @@
+// 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
+//
+// http://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.
+
+//! This tool generates Rust code with assigned number tables from the equivalent Python.
+
+use pyo3::{
+ intern,
+ types::{PyDict, PyModule},
+ PyResult, Python,
+};
+use std::{collections, env, fs, path};
+
+fn main() -> anyhow::Result<()> {
+ pyo3::prepare_freethreaded_python();
+ let mut dir = path::Path::new(&env::var("CARGO_MANIFEST_DIR")?).to_path_buf();
+ dir.push("src/wrapper/assigned_numbers");
+
+ company_ids(&dir)?;
+
+ Ok(())
+}
+
+fn company_ids(base_dir: &path::Path) -> anyhow::Result<()> {
+ let mut sorted_ids = load_company_ids()?.into_iter().collect::<Vec<_>>();
+ sorted_ids.sort_by_key(|(id, _name)| *id);
+
+ let mut contents = String::new();
+ contents.push_str(LICENSE_HEADER);
+ contents.push_str("\n\n");
+ contents.push_str(
+ "// auto-generated by gen_assigned_numbers, do not edit
+
+use crate::wrapper::core::Uuid16;
+use lazy_static::lazy_static;
+use std::collections;
+
+lazy_static! {
+ /// Assigned company IDs
+ pub static ref COMPANY_IDS: collections::HashMap<Uuid16, &'static str> = [
+",
+ );
+
+ for (id, name) in sorted_ids {
+ contents.push_str(&format!(" ({id}_u16, r#\"{name}\"#),\n"))
+ }
+
+ contents.push_str(
+ " ]
+ .into_iter()
+ .map(|(id, name)| (Uuid16::from_be_bytes(id.to_be_bytes()), name))
+ .collect();
+}
+",
+ );
+
+ let mut company_ids = base_dir.to_path_buf();
+ company_ids.push("company_ids.rs");
+ fs::write(&company_ids, contents)?;
+
+ Ok(())
+}
+
+fn load_company_ids() -> PyResult<collections::HashMap<u16, String>> {
+ Python::with_gil(|py| {
+ PyModule::import(py, intern!(py, "bumble.company_ids"))?
+ .getattr(intern!(py, "COMPANY_IDENTIFIERS"))?
+ .downcast::<PyDict>()?
+ .into_iter()
+ .map(|(k, v)| Ok((k.extract::<u16>()?, v.str()?.to_str()?.to_string())))
+ .collect::<PyResult<collections::HashMap<_, _>>>()
+ })
+}
+
+const LICENSE_HEADER: &str = r#"// 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
+//
+// http://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."#;
diff --git a/setup.cfg b/setup.cfg
index a7a09d6..1ca73c7 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -32,17 +32,22 @@ package_dir =
include_package_data = True
install_requires =
aiohttp ~= 3.8; platform_system!='Emscripten'
- appdirs >= 1.4
- bt-test-interfaces >= 0.0.2
+ appdirs >= 1.4; platform_system!='Emscripten'
+ bt-test-interfaces >= 0.0.2; platform_system!='Emscripten'
click == 8.1.3; platform_system!='Emscripten'
- cryptography == 35; platform_system!='Emscripten'
- grpcio == 1.51.1; platform_system!='Emscripten'
- humanize >= 4.6.0
+ cryptography == 39; platform_system!='Emscripten'
+ # Pyodide bundles a version of cryptography that is built for wasm, which may not match the
+ # versions available on PyPI. Relax the version requirement since it's better than being
+ # completely unable to import the package in case of version mismatch.
+ cryptography >= 39.0; platform_system=='Emscripten'
+ grpcio == 1.57.0; platform_system!='Emscripten'
+ humanize >= 4.6.0; platform_system!='Emscripten'
libusb1 >= 2.0.1; platform_system!='Emscripten'
libusb-package == 1.0.26.1; platform_system!='Emscripten'
+ platformdirs == 3.10.0; platform_system!='Emscripten'
prompt_toolkit >= 3.0.16; platform_system!='Emscripten'
- prettytable >= 3.6.0
- protobuf >= 3.12.4
+ prettytable >= 3.6.0; platform_system!='Emscripten'
+ protobuf >= 3.12.4; platform_system!='Emscripten'
pyee >= 8.2.2
pyserial-asyncio >= 0.5; platform_system!='Emscripten'
pyserial >= 3.5; platform_system!='Emscripten'
@@ -81,9 +86,9 @@ test =
coverage >= 6.4
development =
black == 22.10
- grpcio-tools >= 1.51.1
+ grpcio-tools >= 1.57.0
invoke >= 1.7.3
- mypy == 1.2.0
+ mypy == 1.5.0
nox >= 2022
pylint == 2.15.8
types-appdirs >= 1.4.3
diff --git a/speaker.html b/speaker.html
deleted file mode 100644
index 05cc31f..0000000
--- a/speaker.html
+++ /dev/null
@@ -1,28 +0,0 @@
-<!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>
diff --git a/tasks.py b/tasks.py
index 3a3a01a..6df5a8b 100644
--- a/tasks.py
+++ b/tasks.py
@@ -177,3 +177,33 @@ project_tasks.add_task(lint)
project_tasks.add_task(format_code, name="format")
project_tasks.add_task(check_types, name="check-types")
project_tasks.add_task(pre_commit)
+
+
+# -----------------------------------------------------------------------------
+# Web
+# -----------------------------------------------------------------------------
+web_tasks = Collection()
+ns.add_collection(web_tasks, name="web")
+
+
+# -----------------------------------------------------------------------------
+@task
+def serve(ctx, port=8000):
+ """
+ Run a simple HTTP server for the examples under the `web` directory.
+ """
+ import http.server
+
+ address = ("", port)
+
+ class Handler(http.server.SimpleHTTPRequestHandler):
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, directory="web", **kwargs)
+
+ server = http.server.HTTPServer(address, Handler)
+ print(f"Now serving on port {port} 🕸️")
+ server.serve_forever()
+
+
+# -----------------------------------------------------------------------------
+web_tasks.add_task(serve)
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..1e45f74
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1,13 @@
+# 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.
diff --git a/tests/at_test.py b/tests/at_test.py
new file mode 100644
index 0000000..a0f00dd
--- /dev/null
+++ b/tests/at_test.py
@@ -0,0 +1,35 @@
+# 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.
+
+from bumble import at
+
+
+def test_tokenize_parameters():
+ assert at.tokenize_parameters(b'1, 2, 3') == [b'1', b',', b'2', b',', b'3']
+ assert at.tokenize_parameters(b'"1, 2, 3"') == [b'1, 2, 3']
+ assert at.tokenize_parameters(b'(1, "2, 3")') == [b'(', b'1', b',', b'2, 3', b')']
+
+
+def test_parse_parameters():
+ assert at.parse_parameters(b'1, 2, 3') == [b'1', b'2', b'3']
+ assert at.parse_parameters(b'1,, 3') == [b'1', b'', b'3']
+ assert at.parse_parameters(b'"1, 2, 3"') == [b'1, 2, 3']
+ assert at.parse_parameters(b'1, (2, (3))') == [b'1', [b'2', [b'3']]]
+ assert at.parse_parameters(b'1, (2, "3, 4"), 5') == [b'1', [b'2', b'3, 4'], b'5']
+
+
+# -----------------------------------------------------------------------------
+if __name__ == '__main__':
+ test_tokenize_parameters()
+ test_parse_parameters()
diff --git a/tests/gatt_test.py b/tests/gatt_test.py
index dd0277e..d9f6d60 100644
--- a/tests/gatt_test.py
+++ b/tests/gatt_test.py
@@ -891,10 +891,10 @@ async def async_main():
# -----------------------------------------------------------------------------
-def test_attribute_string_to_permissions():
- assert Attribute.string_to_permissions('READABLE') == 1
- assert Attribute.string_to_permissions('WRITEABLE') == 2
- assert Attribute.string_to_permissions('READABLE,WRITEABLE') == 3
+def test_permissions_from_string():
+ assert Attribute.Permissions.from_string('READABLE') == 1
+ assert Attribute.Permissions.from_string('WRITEABLE') == 2
+ assert Attribute.Permissions.from_string('READABLE,WRITEABLE') == 3
# -----------------------------------------------------------------------------
diff --git a/tests/hfp_test.py b/tests/hfp_test.py
new file mode 100644
index 0000000..481d0b7
--- /dev/null
+++ b/tests/hfp_test.py
@@ -0,0 +1,100 @@
+# 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 logging
+import os
+import pytest
+
+from typing import Tuple
+
+from .test_utils import TwoDevices
+from bumble import hfp
+from bumble import rfcomm
+
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+async def make_hfp_connections(
+ hf_config: hfp.Configuration,
+) -> Tuple[hfp.HfProtocol, hfp.HfpProtocol]:
+ # Setup devices
+ devices = TwoDevices()
+ await devices.setup_connection()
+
+ # Setup RFCOMM channel
+ wait_dlc = asyncio.get_running_loop().create_future()
+ rfcomm_channel = rfcomm.Server(devices.devices[0]).listen(
+ lambda dlc: wait_dlc.set_result(dlc)
+ )
+ assert devices.connections[0]
+ assert devices.connections[1]
+ client_mux = await rfcomm.Client(devices.devices[1], devices.connections[1]).start()
+
+ client_dlc = await client_mux.open_dlc(rfcomm_channel)
+ server_dlc = await wait_dlc
+
+ # Setup HFP connection
+ hf = hfp.HfProtocol(client_dlc, hf_config)
+ ag = hfp.HfpProtocol(server_dlc)
+ return hf, ag
+
+
+# -----------------------------------------------------------------------------
+
+
+@pytest.mark.asyncio
+async def test_slc():
+ hf_config = hfp.Configuration(
+ supported_hf_features=[], supported_hf_indicators=[], supported_audio_codecs=[]
+ )
+ hf, ag = await make_hfp_connections(hf_config)
+
+ async def ag_loop():
+ while line := await ag.next_line():
+ if line.startswith('AT+BRSF'):
+ ag.send_response_line('+BRSF: 0')
+ elif line.startswith('AT+CIND=?'):
+ ag.send_response_line(
+ '+CIND: ("call",(0,1)),("callsetup",(0-3)),("service",(0-1)),'
+ '("signal",(0-5)),("roam",(0,1)),("battchg",(0-5)),'
+ '("callheld",(0-2))'
+ )
+ elif line.startswith('AT+CIND?'):
+ ag.send_response_line('+CIND: 0,0,1,4,1,5,0')
+ ag.send_response_line('OK')
+
+ ag_task = asyncio.create_task(ag_loop())
+
+ await hf.initiate_slc()
+ ag_task.cancel()
+
+
+# -----------------------------------------------------------------------------
+async def run():
+ await test_slc()
+
+
+# -----------------------------------------------------------------------------
+if __name__ == '__main__':
+ logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
+ asyncio.run(run())
diff --git a/tests/keystore_test.py b/tests/keystore_test.py
index 2e73039..2a3d48d 100644
--- a/tests/keystore_test.py
+++ b/tests/keystore_test.py
@@ -18,6 +18,8 @@
import asyncio
import json
import logging
+import pathlib
+import pytest
import tempfile
import os
@@ -83,87 +85,95 @@ JSON3 = """
# -----------------------------------------------------------------------------
-async def test_basic():
- with tempfile.NamedTemporaryFile(mode="r+", encoding='utf-8') as file:
- keystore = JsonKeyStore('my_namespace', file.name)
+@pytest.fixture
+def temporary_file():
+ file = tempfile.NamedTemporaryFile(delete=False)
+ file.close()
+ yield file.name
+ pathlib.Path(file.name).unlink()
+
+
+# -----------------------------------------------------------------------------
+async def test_basic(temporary_file):
+ with open(temporary_file, mode='w', encoding='utf-8') as file:
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
+ keystore = JsonKeyStore('my_namespace', temporary_file)
- 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']
+ 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
+
+ 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)
+async def test_parsing(temporary_file):
+ with open(temporary_file, mode='w', encoding='utf-8') as file:
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')
+ keystore = JsonKeyStore('my_namespace', file.name)
+ 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)
+async def test_default_namespace(temporary_file):
+ with open(temporary_file, mode='w', encoding='utf-8') as file:
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')
+ keystore = JsonKeyStore(None, file.name)
+ 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)
+ with open(temporary_file, mode='w', encoding='utf-8') as file:
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)
+ keystore = JsonKeyStore(None, file.name)
+ 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)
+ 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 open(temporary_file, mode='w', encoding='utf-8') as file:
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')
+ keystore = JsonKeyStore(None, file.name)
+ 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')
# -----------------------------------------------------------------------------
diff --git a/tests/l2cap_test.py b/tests/l2cap_test.py
index 6f8e181..c6b2340 100644
--- a/tests/l2cap_test.py
+++ b/tests/l2cap_test.py
@@ -21,13 +21,9 @@ import os
import random
import pytest
-from bumble.controller import Controller
-from bumble.link import LocalLink
-from bumble.device import Device
-from bumble.host import Host
-from bumble.transport import AsyncPipeSink
from bumble.core import ProtocolError
from bumble.l2cap import L2CAP_Connection_Request
+from .test_utils import TwoDevices
# -----------------------------------------------------------------------------
@@ -37,60 +33,6 @@ logger = logging.getLogger(__name__)
# -----------------------------------------------------------------------------
-class TwoDevices:
- def __init__(self):
- self.connections = [None, None]
-
- self.link = LocalLink()
- self.controllers = [
- Controller('C1', link=self.link),
- Controller('C2', link=self.link),
- ]
- self.devices = [
- Device(
- address='F0:F1:F2:F3:F4:F5',
- host=Host(self.controllers[0], AsyncPipeSink(self.controllers[0])),
- ),
- Device(
- address='F5:F4:F3:F2:F1:F0',
- host=Host(self.controllers[1], AsyncPipeSink(self.controllers[1])),
- ),
- ]
-
- self.paired = [None, None]
-
- def on_connection(self, which, connection):
- self.connections[which] = connection
-
- def on_paired(self, which, keys):
- self.paired[which] = keys
-
-
-# -----------------------------------------------------------------------------
-async def setup_connection():
- # 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)
- )
-
- # Start
- await two_devices.devices[0].power_on()
- await two_devices.devices[1].power_on()
-
- # Connect the two devices
- await two_devices.devices[0].connect(two_devices.devices[1].random_address)
-
- # Check the post conditions
- assert two_devices.connections[0] is not None
- assert two_devices.connections[1] is not None
-
- return two_devices
# -----------------------------------------------------------------------------
@@ -132,7 +74,8 @@ def test_helpers():
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_basic_connection():
- devices = await setup_connection()
+ devices = TwoDevices()
+ await devices.setup_connection()
psm = 1234
# Check that if there's no one listening, we can't connect
@@ -184,7 +127,8 @@ async def test_basic_connection():
# -----------------------------------------------------------------------------
async def transfer_payload(max_credits, mtu, mps):
- devices = await setup_connection()
+ devices = TwoDevices()
+ await devices.setup_connection()
received = []
@@ -226,7 +170,8 @@ async def test_transfer():
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_bidirectional_transfer():
- devices = await setup_connection()
+ devices = TwoDevices()
+ await devices.setup_connection()
client_received = []
server_received = []
diff --git a/tests/sdp_test.py b/tests/sdp_test.py
index f07b579..090e7b2 100644
--- a/tests/sdp_test.py
+++ b/tests/sdp_test.py
@@ -15,15 +15,30 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
-from bumble.core import UUID
-from bumble.sdp import DataElement
+import asyncio
+import logging
+import os
+
+from bumble.core import UUID, BT_L2CAP_PROTOCOL_ID, BT_RFCOMM_PROTOCOL_ID
+from bumble.sdp import (
+ DataElement,
+ ServiceAttribute,
+ Client,
+ Server,
+ SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
+ SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
+ SDP_PUBLIC_BROWSE_ROOT,
+ SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
+ SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+)
+from .test_utils import TwoDevices
# -----------------------------------------------------------------------------
# pylint: disable=invalid-name
# -----------------------------------------------------------------------------
# -----------------------------------------------------------------------------
-def basic_check(x):
+def basic_check(x: DataElement) -> None:
serialized = bytes(x)
if len(serialized) < 500:
print('Original:', x)
@@ -41,7 +56,7 @@ def basic_check(x):
# -----------------------------------------------------------------------------
-def test_data_elements():
+def test_data_elements() -> None:
e = DataElement(DataElement.NIL, None)
basic_check(e)
@@ -157,5 +172,108 @@ def test_data_elements():
# -----------------------------------------------------------------------------
-if __name__ == '__main__':
+def sdp_records():
+ return {
+ 0x00010001: [
+ ServiceAttribute(
+ SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
+ DataElement.unsigned_integer_32(0x00010001),
+ ),
+ ServiceAttribute(
+ SDP_BROWSE_GROUP_LIST_ATTRIBUTE_ID,
+ DataElement.sequence([DataElement.uuid(SDP_PUBLIC_BROWSE_ROOT)]),
+ ),
+ ServiceAttribute(
+ SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
+ DataElement.sequence(
+ [DataElement.uuid(UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE'))]
+ ),
+ ),
+ ServiceAttribute(
+ SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+ DataElement.sequence(
+ [
+ DataElement.sequence([DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]),
+ ]
+ ),
+ ),
+ ]
+ }
+
+
+# -----------------------------------------------------------------------------
+async def test_service_search():
+ # Setup connections
+ devices = TwoDevices()
+ await devices.setup_connection()
+ assert devices.connections[0]
+ assert devices.connections[1]
+
+ # Register SDP service
+ devices.devices[0].sdp_server.service_records.update(sdp_records())
+
+ # Search for service
+ client = Client(devices.devices[1])
+ await client.connect(devices.connections[1])
+ services = await client.search_services(
+ [UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE')]
+ )
+
+ # Then
+ assert services[0] == 0x00010001
+
+
+# -----------------------------------------------------------------------------
+async def test_service_attribute():
+ # Setup connections
+ devices = TwoDevices()
+ await devices.setup_connection()
+
+ # Register SDP service
+ devices.devices[0].sdp_server.service_records.update(sdp_records())
+
+ # Search for service
+ client = Client(devices.devices[1])
+ await client.connect(devices.connections[1])
+ attributes = await client.get_attributes(
+ 0x00010001, [SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID]
+ )
+
+ # Then
+ assert attributes[0].value.value == sdp_records()[0x00010001][0].value.value
+
+
+# -----------------------------------------------------------------------------
+async def test_service_search_attribute():
+ # Setup connections
+ devices = TwoDevices()
+ await devices.setup_connection()
+
+ # Register SDP service
+ devices.devices[0].sdp_server.service_records.update(sdp_records())
+
+ # Search for service
+ client = Client(devices.devices[1])
+ await client.connect(devices.connections[1])
+ attributes = await client.search_attributes(
+ [UUID('E6D55659-C8B4-4B85-96BB-B1143AF6D3AE')], [(0x0000FFFF, 8)]
+ )
+
+ # Then
+ for expect, actual in zip(attributes, sdp_records().values()):
+ assert expect.id == actual.id
+ assert expect.value == actual.value
+
+
+# -----------------------------------------------------------------------------
+async def run():
test_data_elements()
+ await test_service_attribute()
+ await test_service_search()
+ await test_service_search_attribute()
+
+
+# -----------------------------------------------------------------------------
+if __name__ == '__main__':
+ logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
+ asyncio.run(run())
diff --git a/tests/self_test.py b/tests/self_test.py
index 4c35045..98ce5e8 100644
--- a/tests/self_test.py
+++ b/tests/self_test.py
@@ -68,13 +68,16 @@ class TwoDevices:
),
]
- self.paired = [None, None]
+ self.paired = [
+ asyncio.get_event_loop().create_future(),
+ asyncio.get_event_loop().create_future(),
+ ]
def on_connection(self, which, connection):
self.connections[which] = connection
- def on_paired(self, which, keys):
- self.paired[which] = keys
+ def on_paired(self, which: int, keys: PairingKeys):
+ self.paired[which].set_result(keys)
# -----------------------------------------------------------------------------
@@ -323,8 +326,8 @@ async def _test_self_smp_with_configs(pairing_config1, pairing_config2):
# Pair
await two_devices.devices[0].pair(connection)
assert connection.is_encrypted
- assert two_devices.paired[0] is not None
- assert two_devices.paired[1] is not None
+ assert await two_devices.paired[0] is not None
+ assert await two_devices.paired[1] is not None
# -----------------------------------------------------------------------------
@@ -527,16 +530,12 @@ async def test_self_smp_over_classic():
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))
+ two_devices.connections[0].on(
+ 'pairing', lambda keys: two_devices.on_paired(0, keys)
+ )
+ two_devices.connections[1].on(
+ 'pairing', lambda keys: two_devices.on_paired(1, keys)
+ )
# Mock SMP
with patch('bumble.smp.Session', spec=True) as MockSmpSession:
@@ -547,7 +546,7 @@ async def test_self_smp_over_classic():
# Start CTKD
await two_devices.connections[0].pair()
- await asyncio.gather(*paired)
+ await asyncio.gather(*two_devices.paired)
# Phase 2 commands should not be invoked
MockSmpSession.send_pairing_confirm_command.assert_not_called()
diff --git a/tests/test_utils.py b/tests/test_utils.py
new file mode 100644
index 0000000..f19f18c
--- /dev/null
+++ b/tests/test_utils.py
@@ -0,0 +1,73 @@
+# 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.
+
+from typing import List, Optional
+
+from bumble.controller import Controller
+from bumble.link import LocalLink
+from bumble.device import Device, Connection
+from bumble.host import Host
+from bumble.transport import AsyncPipeSink
+from bumble.hci import Address
+
+
+class TwoDevices:
+ connections: List[Optional[Connection]]
+
+ def __init__(self) -> None:
+ self.connections = [None, None]
+
+ self.link = LocalLink()
+ self.controllers = [
+ Controller('C1', link=self.link),
+ Controller('C2', link=self.link),
+ ]
+ self.devices = [
+ Device(
+ address=Address('F0:F1:F2:F3:F4:F5'),
+ host=Host(self.controllers[0], AsyncPipeSink(self.controllers[0])),
+ ),
+ Device(
+ address=Address('F5:F4:F3:F2:F1:F0'),
+ host=Host(self.controllers[1], AsyncPipeSink(self.controllers[1])),
+ ),
+ ]
+
+ self.paired = [None, None]
+
+ def on_connection(self, which, connection):
+ self.connections[which] = connection
+
+ def on_paired(self, which, keys):
+ self.paired[which] = keys
+
+ async def setup_connection(self) -> None:
+ # Attach listeners
+ self.devices[0].on(
+ 'connection', lambda connection: self.on_connection(0, connection)
+ )
+ self.devices[1].on(
+ 'connection', lambda connection: self.on_connection(1, connection)
+ )
+
+ # Start
+ await self.devices[0].power_on()
+ await self.devices[1].power_on()
+
+ # Connect the two devices
+ await self.devices[0].connect(self.devices[1].random_address)
+
+ # Check the post conditions
+ assert self.connections[0] is not None
+ assert self.connections[1] is not None
diff --git a/tests/utils_test.py b/tests/utils_test.py
new file mode 100644
index 0000000..d6f5780
--- /dev/null
+++ b/tests/utils_test.py
@@ -0,0 +1,77 @@
+# 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.
+
+import contextlib
+import logging
+import os
+
+from bumble import utils
+from pyee import EventEmitter
+from unittest.mock import MagicMock
+
+
+def test_on() -> None:
+ emitter = EventEmitter()
+ with contextlib.closing(utils.EventWatcher()) as context:
+ mock = MagicMock()
+ context.on(emitter, 'event', mock)
+
+ emitter.emit('event')
+
+ assert not emitter.listeners('event')
+ assert mock.call_count == 1
+
+
+def test_on_decorator() -> None:
+ emitter = EventEmitter()
+ with contextlib.closing(utils.EventWatcher()) as context:
+ mock = MagicMock()
+
+ @context.on(emitter, 'event')
+ def on_event(*_) -> None:
+ mock()
+
+ emitter.emit('event')
+
+ assert not emitter.listeners('event')
+ assert mock.call_count == 1
+
+
+def test_multiple_handlers() -> None:
+ emitter = EventEmitter()
+ with contextlib.closing(utils.EventWatcher()) as context:
+ mock = MagicMock()
+
+ context.once(emitter, 'a', mock)
+ context.once(emitter, 'b', mock)
+
+ emitter.emit('b', 'b')
+
+ assert not emitter.listeners('a')
+ assert not emitter.listeners('b')
+
+ mock.assert_called_once_with('b')
+
+
+# -----------------------------------------------------------------------------
+def run_tests():
+ test_on()
+ test_on_decorator()
+ test_multiple_handlers()
+
+
+# -----------------------------------------------------------------------------
+if __name__ == '__main__':
+ logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
+ run_tests()
diff --git a/tools/rtk_fw_download.py b/tools/rtk_fw_download.py
index b027141..89c49b2 100644
--- a/tools/rtk_fw_download.py
+++ b/tools/rtk_fw_download.py
@@ -67,8 +67,9 @@ def download_file(base_url, name, remove_suffix):
@click.command
@click.option(
"--output-dir",
- default=".",
- help="Output directory where the files will be saved",
+ default="",
+ help="Output directory where the files will be saved. Defaults to the OS-specific"
+ "app data dir, which the driver will check when trying to find firmware",
show_default=True,
)
@click.option(
@@ -84,7 +85,10 @@ 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 output_dir == '':
+ output_dir = rtk.rtk_firmware_dir()
+ else:
+ output_dir = pathlib.Path(output_dir)
if not output_dir.is_dir():
print("Output dir does not exist or is not a directory")
return
diff --git a/tools/rtk_util.py b/tools/rtk_util.py
index 7452915..35afd92 100644
--- a/tools/rtk_util.py
+++ b/tools/rtk_util.py
@@ -61,9 +61,8 @@ async def do_load(usb_transport, force):
# 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
+ print("Firmware already loaded or no supported driver for this device.")
+ return
await driver.download_firmware()
diff --git a/web/README.md b/web/README.md
new file mode 100644
index 0000000..a8cc89c
--- /dev/null
+++ b/web/README.md
@@ -0,0 +1,48 @@
+Bumble For Web Browsers
+=======================
+
+Early prototype the consists of running the Bumble stack in a web browser
+environment, using [pyodide](https://pyodide.org/)
+
+Two examples are included here:
+
+ * scanner - a simple scanner
+ * speaker - a pure-web-based version of the Speaker app
+
+Both examples rely on the shared code in `bumble.js`.
+
+Running The Examples
+--------------------
+
+To run the examples, you will need an HTTP server to serve the HTML and JS files, and
+and a WebSocket server serving an HCI transport.
+
+For HCI over WebSocket, recent versions of the `netsim` virtual controller support it,
+or you may use the Bumble HCI Bridge app to bridge a WebSocket server to a virtual
+controller using some other transport (ex: `python apps/hci_bridge.py ws-server:_:9999 usb:0`).
+
+For HTTP, start an HTTP server with the `web` directory as its
+root. You can use the invoke task `inv web.serve` for convenience.
+
+In a browser, open either `scanner/scanner.html` or `speaker/speaker.html`.
+You can pass optional query parameters:
+
+ * `package` may be set to point to a local build of Bumble (`.whl` files).
+ The filename must be URL-encoded of course, and must be located under
+ the `web` directory (the HTTP server won't serve files not under its
+ root directory).
+ * `hci` may be set to specify a non-default WebSocket URL to use as the HCI
+ transport (the default is: `"ws://localhost:9922/hci`). This also needs
+ to be URL-encoded.
+
+Example:
+ With a local HTTP server running on port 8000, to run the `scanner` example
+ with a locally-built Bumble package `../bumble-0.0.163.dev5+g6f832b6.d20230812-py3-none-any.whl`
+ (assuming that `bumble-0.0.163.dev5+g6f832b6.d20230812-py3-none-any.whl` exists under the `web`
+ directory and the HCI WebSocket transport at `ws://localhost:9999/hci`, the URL with the
+ URL-encoded query parameters would be:
+ `http://localhost:8000/scanner/scanner.html?hci=ws%3A%2F%2Flocalhost%3A9999%2Fhci&package=..%2Fbumble-0.0.163.dev5%2Bg6f832b6.d20230812-py3-none-any.whl`
+
+
+NOTE: to get a local build of the Bumble package, use `inv build`, the built `.whl` file can be found in the `dist` directory.
+Make a copy of the built `.whl` file in the `web` directory. \ No newline at end of file
diff --git a/web/bumble.js b/web/bumble.js
new file mode 100644
index 0000000..b1243a5
--- /dev/null
+++ b/web/bumble.js
@@ -0,0 +1,91 @@
+function bufferToHex(buffer) {
+ return [...new Uint8Array(buffer)].map(x => x.toString(16).padStart(2, '0')).join('');
+}
+
+class PacketSource {
+ constructor(pyodide) {
+ this.parser = pyodide.runPython(`
+ from bumble.transport.common import PacketParser
+ class ProxiedPacketParser(PacketParser):
+ def feed_data(self, js_data):
+ super().feed_data(bytes(js_data.to_py()))
+ ProxiedPacketParser()
+ `);
+ }
+
+ set_packet_sink(sink) {
+ this.parser.set_packet_sink(sink);
+ }
+
+ data_received(data) {
+ console.log(`HCI[controller->host]: ${bufferToHex(data)}`);
+ this.parser.feed_data(data);
+ }
+}
+
+class PacketSink {
+ constructor(writer) {
+ this.writer = writer;
+ }
+
+ on_packet(packet) {
+ const buffer = packet.toJs({create_proxies : false});
+ packet.destroy();
+ console.log(`HCI[host->controller]: ${bufferToHex(buffer)}`);
+ // TODO: create an async queue here instead of blindly calling write without awaiting
+ this.writer(buffer);
+ }
+}
+
+export async function connectWebSocketTransport(pyodide, hciWsUrl) {
+ return new Promise((resolve, reject) => {
+ let resolved = false;
+
+ let ws = new WebSocket(hciWsUrl);
+ ws.binaryType = "arraybuffer";
+
+ ws.onopen = () => {
+ console.log("WebSocket open");
+ resolve({
+ packet_source,
+ packet_sink
+ });
+ resolved = true;
+ }
+
+ ws.onclose = () => {
+ console.log("WebSocket close");
+ if (!resolved) {
+ reject(`Failed to connect to ${hciWsUrl}`)
+ }
+ }
+
+ ws.onmessage = (event) => {
+ packet_source.data_received(event.data);
+ }
+
+ const packet_source = new PacketSource(pyodide);
+ const packet_sink = new PacketSink((packet) => ws.send(packet));
+ })
+}
+
+export async function loadBumble(pyodide, bumblePackage) {
+ // Load the Bumble module
+ await pyodide.loadPackage("micropip");
+ await pyodide.runPythonAsync(`
+ import micropip
+ await micropip.install("${bumblePackage}")
+ package_list = micropip.list()
+ print(package_list)
+ `)
+
+ // Mount a filesystem so that we can persist data like the Key Store
+ let mountDir = "/bumble";
+ pyodide.FS.mkdir(mountDir);
+ pyodide.FS.mount(pyodide.FS.filesystems.IDBFS, { root: "." }, mountDir);
+
+ // Sync previously persisted filesystem data into memory
+ pyodide.FS.syncfs(true, () => {
+ console.log("FS synced in")
+ });
+} \ No newline at end of file
diff --git a/web/index.html b/web/index.html
deleted file mode 100644
index 4374db0..0000000
--- a/web/index.html
+++ /dev/null
@@ -1,131 +0,0 @@
-<html>
- <head>
- <script src="https://cdn.jsdelivr.net/pyodide/v0.19.1/full/pyodide.js"></script>
- </head>
-
- <body>
- <button onclick="runUSB()">USB</button>
- <button onclick="runSerial()">Serial</button>
- <br />
- <br />
- <div>Output:</div>
- <textarea id="output" style="width: 100%;" rows="30" disabled></textarea>
-
- <script>
- function bufferToHex(buffer) {
- return [...new Uint8Array(buffer)].map(x => x.toString(16).padStart(2, '0')).join('');
- }
-
- const output = document.getElementById("output");
- const code = document.getElementById("code");
-
- function addToOutput(s) {
- output.value += s + "\n";
- }
-
- output.value = "Initializing...\n";
-
- async function main() {
- let pyodide = await loadPyodide({
- indexURL: "https://cdn.jsdelivr.net/pyodide/v0.19.1/full/",
- })
- output.value += "Ready!\n"
-
- return pyodide;
- }
-
- let pyodideReadyPromise = main();
-
- async function readLoop(port, packet_source) {
- const reader = port.readable.getReader()
- try {
- while (true) {
- console.log('@@@ Reading...')
- const { done, value } = await reader.read()
- if (done) {
- console.log("--- DONE!")
- break
- }
-
- console.log('@@@ Serial data:', bufferToHex(value))
- if (packet_source.delegate !== undefined) {
- packet_source.delegate.data_received(value)
- } else {
- console.warn('@@@ delegate not set yet, dropping data')
- }
- }
- } catch (error) {
- console.error(error)
- } finally {
- reader.releaseLock()
- }
- }
-
- async function runUSB() {
- const device = await navigator.usb.requestDevice({
- filters: [
- {
- classCode: 0xE0,
- subclassCode: 0x01
- }
- ]
- });
-
- if (device.configuration === null) {
- await device.selectConfiguration(1);
- }
- await device.claimInterface(0)
- }
-
- async function runSerial() {
- const ports = await navigator.serial.getPorts()
- console.log('Paired ports:', ports)
-
- const port = await navigator.serial.requestPort()
- await port.open({ baudRate: 1000000 })
- const writer = port.writable.getWriter()
- }
-
- async function run() {
-
- let pyodide = await pyodideReadyPromise;
- try {
- const script = await(await fetch('scanner.py')).text()
- await pyodide.loadPackage('micropip')
- await pyodide.runPythonAsync(`
- import micropip
- await micropip.install('../dist/bumble-0.0.36.dev0+g3adbfe7.d20210807-py3-none-any.whl')
- `)
- let output = await pyodide.runPythonAsync(script)
- addToOutput(output)
-
- const pythonMain = pyodide.globals.get('main')
- const packet_source = {}
- const packet_sink = {
- on_packet: (packet) => {
- // Variant A, with the conversion done in Javascript
- const buffer = packet.toJs()
- console.log(`$$$ on_packet: ${bufferToHex(buffer)}`)
- // TODO: create an sync queue here instead of blindly calling write without awaiting
- /*await*/ writer.write(buffer)
- packet.destroy()
-
- // Variant B, with the conversion `to_js` done at the Python layer
- // console.log(`$$$ on_packet: ${bufferToHex(packet)}`)
- // /*await*/ writer.write(packet)
- }
- }
- serialLooper = readLoop(port, packet_source)
- pythonResult = await pythonMain(packet_source, packet_sink)
- console.log(pythonResult)
- serialResult = await serialLooper
- writer.releaseLock()
- await port.close()
- console.log('### done')
- } catch (err) {
- addToOutput(err);
- }
- }
- </script>
- </body>
-</html>
diff --git a/web/scanner/scanner.html b/web/scanner/scanner.html
new file mode 100644
index 0000000..12c65dd
--- /dev/null
+++ b/web/scanner/scanner.html
@@ -0,0 +1,129 @@
+<html>
+
+<head>
+ <script src="https://cdn.jsdelivr.net/pyodide/v0.23.2/full/pyodide.js"></script>
+ <style>
+ body {
+ font-family: monospace;
+ }
+
+ table, th, td {
+ padding: 2px;
+ white-space: pre;
+ border: 1px solid black;
+ border-collapse: collapse;
+ }
+ </style>
+</head>
+
+<body>
+ <button id="connectButton" disabled>Connect</button>
+ <br />
+ <br />
+ <div>Log Output</div><br>
+ <textarea id="output" style="width: 100%;" rows="10" disabled></textarea>
+ <div id="scanTableContainer"><table></table></div>
+
+ <script type="module">
+ import { loadBumble, connectWebSocketTransport } from "../bumble.js"
+ let pyodide;
+ let output;
+
+ function logToOutput(s) {
+ output.value += s + "\n";
+ console.log(s);
+ }
+
+ async function run() {
+ const params = (new URL(document.location)).searchParams;
+ const hciWsUrl = params.get("hci") || "ws://localhost:9922/hci";
+
+ try {
+ // Create a WebSocket HCI transport
+ let transport
+ try {
+ transport = await connectWebSocketTransport(pyodide, hciWsUrl);
+ } catch (error) {
+ logToOutput(error);
+ return;
+ }
+
+ // Run the scanner example
+ const script = await (await fetch("scanner.py")).text();
+ await pyodide.runPythonAsync(script);
+ const pythonMain = pyodide.globals.get("main");
+ logToOutput("Starting scanner...");
+ await pythonMain(transport.packet_source, transport.packet_sink, onScanUpdate);
+ logToOutput("Scanner running");
+ } catch (err) {
+ logToOutput(err);
+ }
+ }
+
+ function onScanUpdate(scanEntries) {
+ scanEntries = scanEntries.toJs();
+
+ const scanTable = document.createElement("table");
+
+ const tableHeader = document.createElement("tr");
+ for (const name of ["Address", "Address Type", "RSSI", "Data"]) {
+ const header = document.createElement("th");
+ header.appendChild(document.createTextNode(name));
+ tableHeader.appendChild(header);
+ }
+ scanTable.appendChild(tableHeader);
+
+ scanEntries.forEach(entry => {
+ const row = document.createElement("tr");
+
+ const addressCell = document.createElement("td");
+ addressCell.appendChild(document.createTextNode(entry.address));
+ row.appendChild(addressCell);
+
+ const addressTypeCell = document.createElement("td");
+ addressTypeCell.appendChild(document.createTextNode(entry.address_type));
+ row.appendChild(addressTypeCell);
+
+ const rssiCell = document.createElement("td");
+ rssiCell.appendChild(document.createTextNode(entry.rssi));
+ row.appendChild(rssiCell);
+
+ const dataCell = document.createElement("td");
+ dataCell.appendChild(document.createTextNode(entry.data));
+ row.appendChild(dataCell);
+
+ scanTable.appendChild(row);
+ });
+
+ const scanTableContainer = document.getElementById("scanTableContainer");
+ scanTableContainer.replaceChild(scanTable, scanTableContainer.firstChild);
+
+ return true;
+ }
+
+ async function main() {
+ output = document.getElementById("output");
+
+ // Load pyodide
+ logToOutput("Loading Pyodide");
+ pyodide = await loadPyodide();
+
+ // Load Bumble
+ logToOutput("Loading Bumble");
+ const params = (new URL(document.location)).searchParams;
+ const bumblePackage = params.get("package") || "bumble";
+ await loadBumble(pyodide, bumblePackage);
+
+ logToOutput("Ready!")
+
+ // Enable the Connect button
+ const connectButton = document.getElementById("connectButton");
+ connectButton.disabled = false
+ connectButton.addEventListener("click", run)
+ }
+
+ main();
+ </script>
+</body>
+
+</html> \ No newline at end of file
diff --git a/web/scanner.py b/web/scanner/scanner.py
index 59eda67..c0fc456 100644
--- a/web/scanner.py
+++ b/web/scanner/scanner.py
@@ -15,50 +15,38 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
+import time
+
from bumble.device import Device
-from bumble.transport.common import PacketParser
# -----------------------------------------------------------------------------
-class ScannerListener(Device.Listener):
- def on_advertisement(self, advertisement):
- address_type_string = ('P', 'R', 'PI', 'RI')[advertisement.address.address_type]
- print(
- f'>>> {advertisement.address} [{address_type_string}]: RSSI={advertisement.rssi}, {advertisement.ad_data}'
- )
-
-
-class HciSource:
- def __init__(self, host_source):
- self.parser = PacketParser()
- host_source.delegate = self
+class ScanEntry:
+ def __init__(self, advertisement):
+ self.address = advertisement.address.to_string(False)
+ self.address_type = ('Public', 'Random', 'Public Identity', 'Random Identity')[
+ advertisement.address.address_type
+ ]
+ self.rssi = advertisement.rssi
+ self.data = advertisement.data.to_string("\n")
- def set_packet_sink(self, sink):
- self.parser.set_packet_sink(sink)
- # host source delegation
- def data_received(self, data):
- print('*** DATA from JS:', data)
- buffer = bytes(data.to_py())
- self.parser.feed_data(buffer)
-
-
-# class HciSink:
-# def __init__(self, host_sink):
-# self.host_sink = host_sink
+# -----------------------------------------------------------------------------
+class ScannerListener(Device.Listener):
+ def __init__(self, callback):
+ self.callback = callback
+ self.entries = {}
-# def on_packet(self, packet):
-# print(f'>>> PACKET from Python: {packet}')
-# self.host_sink.on_packet(packet)
+ def on_advertisement(self, advertisement):
+ self.entries[advertisement.address] = ScanEntry(advertisement)
+ self.callback(list(self.entries.values()))
# -----------------------------------------------------------------------------
-async def main(host_source, host_sink):
+async def main(hci_source, hci_sink, callback):
print('### Starting Scanner')
- hci_source = HciSource(host_source)
- hci_sink = host_sink
device = Device.with_hci('Bumble', 'F0:F1:F2:F3:F4:F5', hci_source, hci_sink)
- device.listener = ScannerListener()
+ device.listener = ScannerListener(callback)
await device.power_on()
await device.start_scanning()
diff --git a/web/speaker/logo.svg b/web/speaker/logo.svg
new file mode 100644
index 0000000..70ef7a9
--- /dev/null
+++ b/web/speaker/logo.svg
@@ -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>
diff --git a/web/speaker/speaker.css b/web/speaker/speaker.css
new file mode 100644
index 0000000..988392a
--- /dev/null
+++ b/web/speaker/speaker.css
@@ -0,0 +1,76 @@
+body, h1, h2, h3, h4, h5, h6 {
+ font-family: sans-serif;
+}
+
+#controlsDiv {
+ margin: 6px;
+}
+
+#errorText {
+ 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; } \ No newline at end of file
diff --git a/web/speaker/speaker.html b/web/speaker/speaker.html
new file mode 100644
index 0000000..a20f084
--- /dev/null
+++ b/web/speaker/speaker.html
@@ -0,0 +1,34 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Bumble Speaker</title>
+ <script src="https://cdn.jsdelivr.net/pyodide/v0.23.2/full/pyodide.js"></script>
+ <script type="module" 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="errorText"></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>
+ </div>
+ <canvas id="fftCanvas" width="1024", height="300">Audio Frequencies Animation</canvas>
+ <audio id="audio"></audio>
+ </div>
+</body>
+</html> \ No newline at end of file
diff --git a/web/speaker/speaker.js b/web/speaker/speaker.js
new file mode 100644
index 0000000..b94180f
--- /dev/null
+++ b/web/speaker/speaker.js
@@ -0,0 +1,289 @@
+import { loadBumble, connectWebSocketTransport } from "../bumble.js";
+
+(function () {
+ 'use strict';
+
+ let codecText;
+ let packetsReceivedText;
+ let bytesReceivedText;
+ let streamStateText;
+ let connectionStateText;
+ let errorText;
+ 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 fftCanvas;
+ let fftCanvasContext;
+ let bandwidthCanvas;
+ let bandwidthCanvasContext;
+ let bandwidthBinCount;
+ let bandwidthBins = [];
+ let pyodide;
+
+ const FFT_WIDTH = 800;
+ const FFT_HEIGHT = 256;
+ const BANDWIDTH_WIDTH = 500;
+ const BANDWIDTH_HEIGHT = 100;
+
+
+ function init() {
+ initUI();
+ initMediaSource();
+ initAudioElement();
+ initAnalyzer();
+ initBumble();
+ }
+
+ function initUI() {
+ audioOnButton = document.getElementById("audioOnButton");
+ codecText = document.getElementById("codecText");
+ packetsReceivedText = document.getElementById("packetsReceivedText");
+ bytesReceivedText = document.getElementById("bytesReceivedText");
+ streamStateText = document.getElementById("streamStateText");
+ errorText = document.getElementById("errorText");
+ connectionStateText = document.getElementById("connectionStateText");
+
+ audioOnButton.onclick = () => startAudio();
+
+ codecText.innerText = "AAC";
+ setErrorText("");
+
+ 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);
+ }
+
+ async function initBumble() {
+ // Load pyodide
+ console.log("Loading Pyodide");
+ pyodide = await loadPyodide();
+
+ // Load Bumble
+ console.log("Loading Bumble");
+ const params = (new URL(document.location)).searchParams;
+ const bumblePackage = params.get("package") || "bumble";
+ await loadBumble(pyodide, bumblePackage);
+
+ console.log("Ready!")
+
+ const hciWsUrl = params.get("hci") || "ws://localhost:9922/hci";
+ try {
+ // Create a WebSocket HCI transport
+ let transport
+ try {
+ transport = await connectWebSocketTransport(pyodide, hciWsUrl);
+ } catch (error) {
+ console.error(error);
+ setErrorText(error);
+ return;
+ }
+
+ // Run the scanner example
+ const script = await (await fetch("speaker.py")).text();
+ await pyodide.runPythonAsync(script);
+ const pythonMain = pyodide.globals.get("main");
+ console.log("Starting speaker...");
+ await pythonMain(transport.packet_source, transport.packet_sink, onEvent);
+ console.log("Speaker running");
+ } catch (err) {
+ console.log(err);
+ }
+ }
+
+ 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 setErrorText(message) {
+ errorText.innerText = message;
+ if (message.length == 0) {
+ errorText.style.display = "none";
+ } else {
+ errorText.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;
+ }
+ }
+
+ async function onEvent(name, params) {
+ // Dispatch the message.
+ const handlerName = `on${name.charAt(0).toUpperCase()}${name.slice(1)}`
+ const handler = eventHandlers[handlerName];
+ if (handler !== undefined) {
+ handler(params);
+ } else {
+ console.warn(`unhandled event: ${name}`)
+ }
+ }
+
+ function onStart() {
+ setStreamState("STARTED");
+ }
+
+ function onStop() {
+ setStreamState("STOPPED");
+ }
+
+ function onSuspend() {
+ setStreamState("SUSPENDED");
+ }
+
+ function onConnection(params) {
+ connectionStateText.innerText = `CONNECTED: ${params.get('peer_name')} (${params.get('peer_address')})`;
+ }
+
+ function onDisconnection(params) {
+ connectionStateText.innerText = "DISCONNECTED";
+ }
+
+ function onAudio(python_packet) {
+ const packet = python_packet.toJs({create_proxies : false});
+ python_packet.destroy();
+ 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 onKeystoreupdate() {
+ // Sync the FS
+ pyodide.FS.syncfs(() => {
+ console.log("FS synced out")
+ });
+ }
+
+ const eventHandlers = {
+ onStart,
+ onStop,
+ onSuspend,
+ onConnection,
+ onDisconnection,
+ onAudio,
+ onKeystoreupdate
+ }
+
+ window.onload = (event) => {
+ init();
+ }
+
+}()); \ No newline at end of file
diff --git a/web/speaker/speaker.py b/web/speaker/speaker.py
new file mode 100644
index 0000000..d9293a4
--- /dev/null
+++ b/web/speaker/speaker.py
@@ -0,0 +1,321 @@
+# 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 enum
+import logging
+from typing import Dict, List
+
+from bumble.core import BT_BR_EDR_TRANSPORT, CommandTimeoutError
+from bumble.device import Device, DeviceConfiguration
+from bumble.pairing import PairingConfig
+from bumble.sdp import ServiceAttribute
+from bumble.avdtp import (
+ AVDTP_AUDIO_MEDIA_TYPE,
+ Listener,
+ MediaCodecCapabilities,
+ MediaPacket,
+ Protocol,
+)
+from bumble.a2dp import (
+ make_audio_sink_service_sdp_records,
+ MPEG_2_AAC_LC_OBJECT_TYPE,
+ 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__)
+
+
+# -----------------------------------------------------------------------------
+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 Speaker:
+ class StreamState(enum.Enum):
+ IDLE = 0
+ STOPPED = 1
+ STARTED = 2
+ SUSPENDED = 3
+
+ def __init__(self, hci_source, hci_sink, emit_event, codec, discover):
+ self.hci_source = hci_source
+ self.hci_sink = hci_sink
+ self.emit_event = emit_event
+ self.codec = codec
+ self.discover = discover
+ self.device = None
+ self.connection = None
+ self.listener = None
+ self.packets_received = 0
+ self.bytes_received = 0
+ self.stream_state = Speaker.StreamState.IDLE
+ self.audio_extractor = AudioExtractor.create(codec)
+
+ 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,
+ ),
+ )
+
+ def on_key_store_update(self):
+ print("Key Store updated")
+ self.emit_event('keystoreupdate', None)
+
+ def on_bluetooth_connection(self, connection):
+ print(f'Connection: {connection}')
+ self.connection = connection
+ connection.on('disconnection', self.on_bluetooth_disconnection)
+ peer_name = '' if connection.peer_name is None else connection.peer_name
+ peer_address = connection.peer_address.to_string(False)
+ self.emit_event(
+ 'connection', {'peer_name': peer_name, 'peer_address': peer_address}
+ )
+
+ def on_bluetooth_disconnection(self, reason):
+ print(f'Disconnection ({reason})')
+ self.connection = None
+ AsyncRunner.spawn(self.advertise())
+ self.emit_event('disconnection', None)
+
+ 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")
+ self.stream_state = self.StreamState.STARTED
+ self.emit_event('start', None)
+
+ def on_sink_stop(self):
+ print("Sink Stopped")
+ self.stream_state = self.StreamState.STOPPED
+ self.emit_event('stop', None)
+
+ def on_sink_suspend(self):
+ print("Sink Suspended")
+ self.stream_state = self.StreamState.SUSPENDED
+ self.emit_event('suspend', None)
+
+ 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)
+ self.emit_event("audio", self.audio_extractor.extract_audio(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):
+ # Create a device
+ device_config = DeviceConfiguration()
+ device_config.name = "Bumble Speaker"
+ device_config.class_of_device = 0x240414
+ device_config.keystore = "JsonKeyStore:/bumble/keystore.json"
+ device_config.classic_enabled = True
+ device_config.le_enabled = False
+ self.device = Device.from_config_with_hci(
+ device_config, self.hci_source, self.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()
+
+ # Listen for Bluetooth connections
+ self.device.on('connection', self.on_bluetooth_connection)
+
+ # Listen for changes to the key store
+ self.device.on('key_store_update', self.on_key_store_update)
+
+ # 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={self.codec}')
+
+ if connect_address:
+ # Connect to the source
+ try:
+ await self.connect(connect_address)
+ except CommandTimeoutError:
+ print("Connection timed out")
+ return
+ else:
+ # Start being discoverable and connectable
+ print("Waiting for connection...")
+ await self.advertise()
+
+
+# -----------------------------------------------------------------------------
+async def main(hci_source, hci_sink, emit_event):
+ # logging.basicConfig(level='DEBUG')
+ speaker = Speaker(hci_source, hci_sink, emit_event, "aac", False)
+ await speaker.run(None)