aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAndroid Build Coastguard Worker <android-build-coastguard-worker@google.com>2023-07-07 05:19:07 +0000
committerAndroid Build Coastguard Worker <android-build-coastguard-worker@google.com>2023-07-07 05:19:07 +0000
commit3d4b7d286f24ca2ffc83028066628e0c7162836c (patch)
tree9de2e37332c9056aacfebc24339302109cf2c64a
parentd0bcb79dafd1e15afb2a75556cd9849194a677bf (diff)
parent763d76ecdc285e49c0535f256b0df4a932e1f145 (diff)
downloadbumble-3d4b7d286f24ca2ffc83028066628e0c7162836c.tar.gz
Snap for 10453563 from 763d76ecdc285e49c0535f256b0df4a932e1f145 to mainline-tzdata5-release
Change-Id: I5d596eec8997171387cf2fff6d7dea2115ee951a
-rw-r--r--Android.bp35
-rw-r--r--LICENSE19
-rw-r--r--apps/bench.py6
-rw-r--r--apps/console.py230
-rw-r--r--apps/gg_bridge.py7
-rw-r--r--apps/pair.py25
-rw-r--r--apps/pandora_server.py30
-rw-r--r--bumble/att.py6
-rw-r--r--bumble/core.py59
-rw-r--r--bumble/device.py345
-rw-r--r--bumble/gap.py4
-rw-r--r--bumble/gatt.py151
-rw-r--r--bumble/gatt_client.py138
-rw-r--r--bumble/gatt_server.py34
-rw-r--r--bumble/host.py29
-rw-r--r--bumble/keys.py75
-rw-r--r--bumble/pairing.py188
-rw-r--r--bumble/pandora/__init__.py105
-rw-r--r--bumble/pandora/config.py48
-rw-r--r--bumble/pandora/device.py157
-rw-r--r--bumble/pandora/host.py856
-rw-r--r--bumble/pandora/py.typed0
-rw-r--r--bumble/pandora/security.py529
-rw-r--r--bumble/pandora/utils.py112
-rw-r--r--bumble/profiles/asha_service.py11
-rw-r--r--bumble/profiles/battery_service.py2
-rw-r--r--bumble/profiles/device_information_service.py8
-rw-r--r--bumble/profiles/heart_rate_service.py6
-rw-r--r--bumble/rfcomm.py2
-rw-r--r--bumble/smp.py469
-rw-r--r--bumble/transport/__init__.py5
-rw-r--r--bumble/transport/android_emulator.py10
-rw-r--r--bumble/transport/android_netsim.py410
-rw-r--r--bumble/transport/emulated_bluetooth_packets_pb2.py45
-rw-r--r--bumble/transport/emulated_bluetooth_packets_pb2_grpc.py17
-rw-r--r--bumble/transport/emulated_bluetooth_pb2.py46
-rw-r--r--bumble/transport/emulated_bluetooth_pb2.pyi26
-rw-r--r--bumble/transport/emulated_bluetooth_pb2_grpc.py244
-rw-r--r--bumble/transport/emulated_bluetooth_vhci_pb2.py46
-rw-r--r--bumble/transport/emulated_bluetooth_vhci_pb2.pyi19
-rw-r--r--bumble/transport/grpc_protobuf/__init__.py0
-rw-r--r--bumble/transport/grpc_protobuf/common_pb2.py25
-rw-r--r--bumble/transport/grpc_protobuf/common_pb2.pyi12
-rw-r--r--bumble/transport/grpc_protobuf/common_pb2_grpc.py4
-rw-r--r--bumble/transport/grpc_protobuf/emulated_bluetooth_device_pb2.py64
-rw-r--r--bumble/transport/grpc_protobuf/emulated_bluetooth_device_pb2.pyi158
-rw-r--r--bumble/transport/grpc_protobuf/emulated_bluetooth_device_pb2_grpc.py193
-rw-r--r--bumble/transport/grpc_protobuf/emulated_bluetooth_packets_pb2.py28
-rw-r--r--bumble/transport/grpc_protobuf/emulated_bluetooth_packets_pb2.pyi (renamed from bumble/transport/emulated_bluetooth_packets_pb2.pyi)21
-rw-r--r--bumble/transport/grpc_protobuf/emulated_bluetooth_packets_pb2_grpc.py4
-rw-r--r--bumble/transport/grpc_protobuf/emulated_bluetooth_pb2.py32
-rw-r--r--bumble/transport/grpc_protobuf/emulated_bluetooth_pb2.pyi19
-rw-r--r--bumble/transport/grpc_protobuf/emulated_bluetooth_pb2_grpc.py237
-rw-r--r--bumble/transport/grpc_protobuf/emulated_bluetooth_vhci_pb2.py27
-rw-r--r--bumble/transport/grpc_protobuf/emulated_bluetooth_vhci_pb2.pyi5
-rw-r--r--bumble/transport/grpc_protobuf/emulated_bluetooth_vhci_pb2_grpc.py (renamed from bumble/transport/emulated_bluetooth_vhci_pb2_grpc.py)73
-rw-r--r--bumble/transport/grpc_protobuf/grpc_endpoint_description_pb2.py30
-rw-r--r--bumble/transport/grpc_protobuf/grpc_endpoint_description_pb2.pyi34
-rw-r--r--bumble/transport/grpc_protobuf/grpc_endpoint_description_pb2_grpc.py4
-rw-r--r--bumble/transport/grpc_protobuf/hci_packet_pb2.py28
-rw-r--r--bumble/transport/grpc_protobuf/hci_packet_pb2.pyi22
-rw-r--r--bumble/transport/grpc_protobuf/hci_packet_pb2_grpc.py4
-rw-r--r--bumble/transport/grpc_protobuf/packet_streamer_pb2.py31
-rw-r--r--bumble/transport/grpc_protobuf/packet_streamer_pb2.pyi27
-rw-r--r--bumble/transport/grpc_protobuf/packet_streamer_pb2_grpc.py109
-rw-r--r--bumble/transport/grpc_protobuf/startup_pb2.py32
-rw-r--r--bumble/transport/grpc_protobuf/startup_pb2.pyi46
-rw-r--r--bumble/transport/grpc_protobuf/startup_pb2_grpc.py4
-rw-r--r--docs/mkdocs/requirements.txt2
-rw-r--r--docs/mkdocs/src/platforms/android.md87
-rw-r--r--docs/mkdocs/src/transports/android_emulator.md41
-rw-r--r--docs/mkdocs/src/transports/index.md3
-rw-r--r--examples/a2dp_sink1.json1
-rw-r--r--examples/hfp_handsfree.json4
-rw-r--r--examples/keyboard.py20
-rw-r--r--examples/run_asha_sink.py8
-rw-r--r--examples/run_controller.py2
-rw-r--r--examples/run_gatt_client_and_server.py2
-rw-r--r--examples/run_gatt_server.py8
-rw-r--r--examples/run_notifier.py8
-rw-r--r--pyproject.toml32
-rw-r--r--scripts/process_android_emulator_protos.sh25
-rw-r--r--scripts/process_android_netsim_protos.sh14
-rw-r--r--setup.cfg11
-rw-r--r--tests/core_test.py20
-rw-r--r--tests/gatt_test.py197
-rw-r--r--tests/self_test.py54
87 files changed, 5158 insertions, 1208 deletions
diff --git a/Android.bp b/Android.bp
index 3083993..640bded 100644
--- a/Android.bp
+++ b/Android.bp
@@ -26,3 +26,38 @@ python_library_host {
"pyee",
]
}
+
+python_library_host {
+ name: "bumble-pandora",
+ srcs: [
+ "bumble/pandora/*.py",
+ ],
+ libs: [
+ "bumble",
+ "pandora-python",
+ "libprotobuf-python",
+ ],
+ data: [
+ "bumble/pandora/py.typed"
+ ]
+}
+
+python_test_host {
+ name: "bumble_pandora_server",
+ main: "apps/pandora_server.py",
+ srcs: [
+ "apps/pandora_server.py",
+ ],
+ libs: [
+ "bumble-pandora",
+ "pandora-python",
+ ],
+
+ test_options: {
+ unit_test: false,
+ },
+
+ test_suites: [
+ "general-tests"
+ ]
+}
diff --git a/LICENSE b/LICENSE
index d645695..0cb8949 100644
--- a/LICENSE
+++ b/LICENSE
@@ -200,3 +200,22 @@
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.
+
+
+---
+
+
+Files: bumble/colors.py
+Copyright (c) 2012 Giorgos Verigakis <verigak@gmail.com>
+
+Permission to use, copy, modify, and distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
diff --git a/apps/bench.py b/apps/bench.py
index 19cdcfa..4708403 100644
--- a/apps/bench.py
+++ b/apps/bench.py
@@ -558,11 +558,13 @@ class GattServer:
# Setup the GATT service
self.speed_tx = Characteristic(
SPEED_TX_UUID,
- Characteristic.WRITE,
+ Characteristic.Properties.WRITE,
Characteristic.WRITEABLE,
CharacteristicValue(write=self.on_tx_write),
)
- self.speed_rx = Characteristic(SPEED_RX_UUID, Characteristic.NOTIFY, 0)
+ self.speed_rx = Characteristic(
+ SPEED_RX_UUID, Characteristic.Properties.NOTIFY, 0
+ )
speed_service = Service(
SPEED_SERVICE_UUID,
diff --git a/apps/console.py b/apps/console.py
index 26223d7..0ea9e5b 100644
--- a/apps/console.py
+++ b/apps/console.py
@@ -24,10 +24,12 @@ import logging
import os
import random
import re
-from typing import Optional
+import humanize
+from typing import Optional, Union
from collections import OrderedDict
import click
+from prettytable import PrettyTable
from prompt_toolkit import Application
from prompt_toolkit.history import FileHistory
@@ -125,7 +127,8 @@ class ConsoleApp:
def __init__(self):
self.known_addresses = set()
- self.known_attributes = []
+ self.known_remote_attributes = []
+ self.known_local_attributes = []
self.device = None
self.connected_peer = None
self.top_tab = 'device'
@@ -162,6 +165,8 @@ class ConsoleApp:
'device': None,
'local-services': None,
'remote-services': None,
+ 'local-values': None,
+ 'remote-values': None,
},
'filter': {
'address': None,
@@ -172,10 +177,11 @@ class ConsoleApp:
'disconnect': None,
'discover': {'services': None, 'attributes': None},
'request-mtu': None,
- 'read': LiveCompleter(self.known_attributes),
- 'write': LiveCompleter(self.known_attributes),
- 'subscribe': LiveCompleter(self.known_attributes),
- 'unsubscribe': LiveCompleter(self.known_attributes),
+ 'read': LiveCompleter(self.known_remote_attributes),
+ 'write': LiveCompleter(self.known_remote_attributes),
+ 'local-write': LiveCompleter(self.known_local_attributes),
+ 'subscribe': LiveCompleter(self.known_remote_attributes),
+ 'unsubscribe': LiveCompleter(self.known_remote_attributes),
'set-phy': {'1m': None, '2m': None, 'coded': None},
'set-default-phy': None,
'quit': None,
@@ -207,6 +213,8 @@ class ConsoleApp:
self.log_text = FormattedTextControl(
get_cursor_position=lambda: Point(0, max(0, len(self.log_lines) - 1))
)
+ self.local_values_text = FormattedTextControl()
+ self.remote_values_text = FormattedTextControl()
self.log_height = Dimension(min=7, weight=4)
self.log_max_lines = 100
self.log_lines = []
@@ -222,10 +230,18 @@ class ConsoleApp:
filter=Condition(lambda: self.top_tab == 'local-services'),
),
ConditionalContainer(
+ Frame(Window(self.local_values_text), title='Local Values'),
+ filter=Condition(lambda: self.top_tab == 'local-values'),
+ ),
+ ConditionalContainer(
Frame(Window(self.remote_services_text), title='Remote Services'),
filter=Condition(lambda: self.top_tab == 'remote-services'),
),
ConditionalContainer(
+ Frame(Window(self.remote_values_text), title='Remote Values'),
+ filter=Condition(lambda: self.top_tab == 'remote-values'),
+ ),
+ ConditionalContainer(
Frame(Window(self.log_text, height=self.log_height), title='Log'),
filter=Condition(lambda: self.top_tab == 'log'),
),
@@ -366,17 +382,19 @@ class ConsoleApp:
def show_remote_services(self, services):
lines = []
- del self.known_attributes[:]
+ del self.known_remote_attributes[:]
for service in services:
lines.append(("ansicyan", f"{service}\n"))
for characteristic in service.characteristics:
lines.append(('ansimagenta', f' {characteristic} + \n'))
- self.known_attributes.append(
+ self.known_remote_attributes.append(
f'{service.uuid.to_hex_str()}.{characteristic.uuid.to_hex_str()}'
)
- self.known_attributes.append(f'*.{characteristic.uuid.to_hex_str()}')
- self.known_attributes.append(f'#{characteristic.handle:X}')
+ self.known_remote_attributes.append(
+ f'*.{characteristic.uuid.to_hex_str()}'
+ )
+ self.known_remote_attributes.append(f'#{characteristic.handle:X}')
for descriptor in characteristic.descriptors:
lines.append(("ansigreen", f" {descriptor}\n"))
@@ -385,12 +403,31 @@ class ConsoleApp:
def show_local_services(self, attributes):
lines = []
+ del self.known_local_attributes[:]
for attribute in attributes:
if isinstance(attribute, Service):
+ # Save the most recent service for use later
+ service = attribute
lines.append(("ansicyan", f"{attribute}\n"))
- elif isinstance(attribute, (Characteristic, CharacteristicDeclaration)):
+ elif isinstance(attribute, Characteristic):
+ # CharacteristicDeclaration includes all info from Characteristic
+ # no need to print it twice
+ continue
+ elif isinstance(attribute, CharacteristicDeclaration):
+ # Save the most recent characteristic declaration for use later
+ characteristic_declaration = attribute
+ self.known_local_attributes.append(
+ f'{service.uuid.to_hex_str()}.{attribute.characteristic.uuid.to_hex_str()}'
+ )
+ self.known_local_attributes.append(
+ f'#{attribute.characteristic.handle:X}'
+ )
lines.append(("ansimagenta", f" {attribute}\n"))
elif isinstance(attribute, Descriptor):
+ self.known_local_attributes.append(
+ f'{service.uuid.to_hex_str()}.{characteristic_declaration.characteristic.uuid.to_hex_str()}.{attribute.type.to_hex_str()}'
+ )
+ self.known_local_attributes.append(f'#{attribute.handle:X}')
lines.append(("ansigreen", f" {attribute}\n"))
else:
lines.append(("ansiyellow", f"{attribute}\n"))
@@ -494,7 +531,7 @@ class ConsoleApp:
self.show_attributes(attributes)
- def find_characteristic(self, param) -> Optional[CharacteristicProxy]:
+ def find_remote_characteristic(self, param) -> Optional[CharacteristicProxy]:
if not self.connected_peer:
return None
parts = param.split('.')
@@ -516,6 +553,38 @@ class ConsoleApp:
return None
+ def find_local_attribute(
+ self, param
+ ) -> Optional[Union[Characteristic, Descriptor]]:
+ parts = param.split('.')
+ if len(parts) == 3:
+ service_uuid = UUID(parts[0])
+ characteristic_uuid = UUID(parts[1])
+ descriptor_uuid = UUID(parts[2])
+ return self.device.gatt_server.get_descriptor_attribute(
+ service_uuid, characteristic_uuid, descriptor_uuid
+ )
+ if len(parts) == 2:
+ service_uuid = UUID(parts[0])
+ characteristic_uuid = UUID(parts[1])
+ characteristic_attributes = (
+ self.device.gatt_server.get_characteristic_attributes(
+ service_uuid, characteristic_uuid
+ )
+ )
+ if characteristic_attributes:
+ return characteristic_attributes[1]
+ return None
+ elif len(parts) == 1:
+ if parts[0].startswith('#'):
+ attribute_handle = int(f'{parts[0][1:]}', 16)
+ attribute = self.device.gatt_server.get_attribute(attribute_handle)
+ if isinstance(attribute, (Characteristic, Descriptor)):
+ return attribute
+ return None
+
+ return None
+
async def rssi_monitor_loop(self):
while True:
if self.monitor_rssi and self.connected_peer:
@@ -674,10 +743,109 @@ class ConsoleApp:
'device',
'local-services',
'remote-services',
+ 'local-values',
+ 'remote-values',
}:
self.top_tab = params[0]
self.ui.invalidate()
+ while self.top_tab == 'local-values':
+ await self.do_show_local_values()
+ await asyncio.sleep(1)
+
+ while self.top_tab == 'remote-values':
+ await self.do_show_remote_values()
+ await asyncio.sleep(1)
+
+ async def do_show_local_values(self):
+ prettytable = PrettyTable()
+ field_names = ["Service", "Characteristic", "Descriptor"]
+
+ # if there's no connections, add a column just for value
+ if not self.device.connections:
+ field_names.append("Value")
+
+ # if there are connections, add a column for each connection's value
+ for connection in self.device.connections.values():
+ field_names.append(f"Connection {connection.handle}")
+
+ for attribute in self.device.gatt_server.attributes:
+ if isinstance(attribute, Characteristic):
+ service = self.device.gatt_server.get_attribute_group(
+ attribute.handle, Service
+ )
+ if not service:
+ continue
+ values = [
+ attribute.read_value(connection)
+ for connection in self.device.connections.values()
+ ]
+ if not values:
+ values = [attribute.read_value(None)]
+ prettytable.add_row([f"{service.uuid}", attribute.uuid, ""] + values)
+
+ elif isinstance(attribute, Descriptor):
+ service = self.device.gatt_server.get_attribute_group(
+ attribute.handle, Service
+ )
+ if not service:
+ continue
+ characteristic = self.device.gatt_server.get_attribute_group(
+ attribute.handle, Characteristic
+ )
+ if not characteristic:
+ continue
+ values = [
+ attribute.read_value(connection)
+ for connection in self.device.connections.values()
+ ]
+ if not values:
+ values = [attribute.read_value(None)]
+
+ # TODO: future optimization: convert CCCD value to human readable string
+
+ prettytable.add_row(
+ [service.uuid, characteristic.uuid, attribute.type] + values
+ )
+
+ prettytable.field_names = field_names
+ self.local_values_text.text = prettytable.get_string()
+ self.ui.invalidate()
+
+ async def do_show_remote_values(self):
+ prettytable = PrettyTable(
+ field_names=[
+ "Connection",
+ "Service",
+ "Characteristic",
+ "Descriptor",
+ "Time",
+ "Value",
+ ]
+ )
+ for connection in self.device.connections.values():
+ for handle, (time, value) in connection.gatt_client.cached_values.items():
+ row = [connection.handle]
+ attribute = connection.gatt_client.get_attributes(handle)
+ if not attribute:
+ continue
+ if len(attribute) == 3:
+ row.extend(
+ [attribute[0].uuid, attribute[1].uuid, attribute[2].type]
+ )
+ elif len(attribute) == 2:
+ row.extend([attribute[0].uuid, attribute[1].uuid, ""])
+ elif len(attribute) == 1:
+ row.extend([attribute[0].uuid, "", ""])
+ else:
+ continue
+
+ row.extend([humanize.naturaltime(time), value])
+ prettytable.add_row(row)
+
+ self.remote_values_text.text = prettytable.get_string()
+ self.ui.invalidate()
+
async def do_get_phy(self, _):
if not self.connected_peer:
self.show_error('not connected')
@@ -720,7 +888,7 @@ class ConsoleApp:
self.show_error('not connected')
return
- characteristic = self.find_characteristic(params[0])
+ characteristic = self.find_remote_characteristic(params[0])
if characteristic is None:
self.show_error('no such characteristic')
return
@@ -745,15 +913,43 @@ class ConsoleApp:
except ValueError:
value = str.encode(params[1]) # must be a string
- characteristic = self.find_characteristic(params[0])
+ characteristic = self.find_remote_characteristic(params[0])
if characteristic is None:
self.show_error('no such characteristic')
return
# use write with response if supported
- with_response = characteristic.properties & Characteristic.WRITE
+ with_response = characteristic.properties & Characteristic.Properties.WRITE
await characteristic.write_value(value, with_response=with_response)
+ async def do_local_write(self, params):
+ if len(params) != 2:
+ self.show_error(
+ 'invalid syntax', 'expected local-write <attribute> <value>'
+ )
+ return
+
+ if params[1].upper().startswith("0X"):
+ value = bytes.fromhex(params[1][2:]) # parse as hex string
+ else:
+ try:
+ value = int(params[1]).to_bytes(2, "little") # try as 2 byte integer
+ except ValueError:
+ value = str.encode(params[1]) # must be a string
+
+ attribute = self.find_local_attribute(params[0])
+ if not attribute:
+ self.show_error('invalid syntax', 'unable to find attribute')
+ return
+
+ # send data to any subscribers
+ if isinstance(attribute, Characteristic):
+ attribute.write_value(None, value)
+ if attribute.has_properties(Characteristic.NOTIFY):
+ await self.device.gatt_server.notify_subscribers(attribute)
+ if attribute.has_properties(Characteristic.INDICATE):
+ await self.device.gatt_server.indicate_subscribers(attribute)
+
async def do_subscribe(self, params):
if not self.connected_peer:
self.show_error('not connected')
@@ -763,7 +959,7 @@ class ConsoleApp:
self.show_error('invalid syntax', 'expected subscribe <attribute>')
return
- characteristic = self.find_characteristic(params[0])
+ characteristic = self.find_remote_characteristic(params[0])
if characteristic is None:
self.show_error('no such characteristic')
return
@@ -783,7 +979,7 @@ class ConsoleApp:
self.show_error('invalid syntax', 'expected subscribe <attribute>')
return
- characteristic = self.find_characteristic(params[0])
+ characteristic = self.find_remote_characteristic(params[0])
if characteristic is None:
self.show_error('no such characteristic')
return
diff --git a/apps/gg_bridge.py b/apps/gg_bridge.py
index 17c1662..88ebdc5 100644
--- a/apps/gg_bridge.py
+++ b/apps/gg_bridge.py
@@ -230,13 +230,13 @@ class GattlinkNodeBridge(GattlinkL2capEndpoint, Device.Listener):
)
self.tx_characteristic = Characteristic(
GG_GATTLINK_TX_CHARACTERISTIC_UUID,
- Characteristic.NOTIFY,
+ Characteristic.Properties.NOTIFY,
Characteristic.READABLE,
)
self.tx_characteristic.on('subscription', self.on_tx_subscription)
self.psm_characteristic = Characteristic(
GG_GATTLINK_L2CAP_CHANNEL_PSM_CHARACTERISTIC_UUID,
- Characteristic.READ | Characteristic.NOTIFY,
+ Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
Characteristic.READABLE,
bytes([psm, 0]),
)
@@ -339,8 +339,7 @@ async def run(
# Create a UDP to TX bridge (receive from TX, send to UDP)
bridge.tx_socket, _ = await loop.create_datagram_endpoint(
- # pylint: disable-next=unnecessary-lambda
- lambda: asyncio.DatagramProtocol(),
+ asyncio.DatagramProtocol,
remote_addr=(send_host, send_port),
)
diff --git a/apps/pair.py b/apps/pair.py
index 3729143..a7844fe 100644
--- a/apps/pair.py
+++ b/apps/pair.py
@@ -24,7 +24,7 @@ from prompt_toolkit.shortcuts import PromptSession
from bumble.colors import color
from bumble.device import Device, Peer
from bumble.transport import open_transport_or_link
-from bumble.smp import PairingDelegate, PairingConfig
+from bumble.pairing import PairingDelegate, PairingConfig
from bumble.smp import error_name as smp_error_name
from bumble.keys import JsonKeyStore
from bumble.core import ProtocolError
@@ -264,6 +264,7 @@ async def pair(
sc,
mitm,
bond,
+ ctkd,
io,
prompt,
request,
@@ -302,7 +303,8 @@ async def pair(
[
Characteristic(
'552957FB-CF1F-4A31-9535-E78847E1A714',
- Characteristic.READ | Characteristic.WRITE,
+ Characteristic.Properties.READ
+ | Characteristic.Properties.WRITE,
Characteristic.READABLE | Characteristic.WRITEABLE,
CharacteristicValue(
read=read_with_error, write=write_with_error
@@ -316,6 +318,7 @@ async def pair(
if mode == 'classic':
device.classic_enabled = True
device.le_enabled = False
+ device.classic_smp_enabled = ctkd
# Get things going
await device.power_on()
@@ -342,8 +345,13 @@ async def pair(
print(color(f'Pairing failed: {error}', 'red'))
return
else:
- # Advertise so that peers can find us and connect
- await device.start_advertising(auto_restart=True)
+ if mode == 'le':
+ # Advertise so that peers can find us and connect
+ await device.start_advertising(auto_restart=True)
+ else:
+ # Become discoverable and connectable
+ await device.set_discoverable(True)
+ await device.set_connectable(True)
# Run until the user asks to exit
await Waiter.instance.wait_until_terminated()
@@ -379,6 +387,13 @@ class LogHandler(logging.Handler):
'--bond', type=bool, default=True, help='Enable bonding', show_default=True
)
@click.option(
+ '--ctkd',
+ type=bool,
+ default=True,
+ help='Enable CTKD',
+ show_default=True,
+)
+@click.option(
'--io',
type=click.Choice(
['keyboard', 'display', 'display+keyboard', 'display+yes/no', 'none']
@@ -404,6 +419,7 @@ def main(
sc,
mitm,
bond,
+ ctkd,
io,
prompt,
request,
@@ -426,6 +442,7 @@ def main(
sc,
mitm,
bond,
+ ctkd,
io,
prompt,
request,
diff --git a/apps/pandora_server.py b/apps/pandora_server.py
new file mode 100644
index 0000000..5f92309
--- /dev/null
+++ b/apps/pandora_server.py
@@ -0,0 +1,30 @@
+import asyncio
+import click
+import logging
+
+from bumble.pandora import PandoraDevice, serve
+
+BUMBLE_SERVER_GRPC_PORT = 7999
+ROOTCANAL_PORT_CUTTLEFISH = 7300
+
+
+@click.command()
+@click.option('--grpc-port', help='gRPC port to serve', default=BUMBLE_SERVER_GRPC_PORT)
+@click.option(
+ '--rootcanal-port', help='Rootcanal TCP port', default=ROOTCANAL_PORT_CUTTLEFISH
+)
+@click.option(
+ '--transport',
+ help='HCI transport',
+ default=f'tcp-client:127.0.0.1:<rootcanal-port>',
+)
+def main(grpc_port: int, rootcanal_port: int, transport: str) -> None:
+ if '<rootcanal-port>' in transport:
+ transport = transport.replace('<rootcanal-port>', str(rootcanal_port))
+ device = PandoraDevice({'transport': transport})
+ logging.basicConfig(level=logging.DEBUG)
+ asyncio.run(serve(device, port=grpc_port))
+
+
+if __name__ == '__main__':
+ main() # pylint: disable=no-value-for-parameter
diff --git a/bumble/att.py b/bumble/att.py
index 8311d18..55ae8a5 100644
--- a/bumble/att.py
+++ b/bumble/att.py
@@ -750,10 +750,10 @@ class Attribute(EventEmitter):
permissions_str.split(","),
0,
)
- except TypeError:
+ except TypeError as exc:
raise TypeError(
- f"Attribute::permissions error:\nExpected a string containing any of the keys, seperated by commas: {','.join(Attribute.PERMISSION_NAMES.values())}\nGot: {permissions_str}"
- )
+ 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 __init__(self, attribute_type, permissions, value=b''):
EventEmitter.__init__(self)
diff --git a/bumble/core.py b/bumble/core.py
index 34412c7..1cc10ec 100644
--- a/bumble/core.py
+++ b/bumble/core.py
@@ -152,7 +152,12 @@ class UUID:
BASE_UUID = bytes.fromhex('00001000800000805F9B34FB')[::-1] # little-endian
UUIDS: List[UUID] = [] # Registry of all instances created
- def __init__(self, uuid_str_or_int, name=None):
+ uuid_bytes: bytes
+ name: Optional[str]
+
+ def __init__(
+ self, uuid_str_or_int: Union[str, int], name: Optional[str] = None
+ ) -> None:
if isinstance(uuid_str_or_int, int):
self.uuid_bytes = struct.pack('<H', uuid_str_or_int)
else:
@@ -172,7 +177,7 @@ class UUID:
self.uuid_bytes = bytes(reversed(bytes.fromhex(uuid_str)))
self.name = name
- def register(self):
+ def register(self) -> UUID:
# Register this object in the class registry, and update the entry's name if
# it wasn't set already
for uuid in self.UUIDS:
@@ -196,22 +201,22 @@ class UUID:
raise ValueError('only 2, 4 and 16 bytes are allowed')
@classmethod
- def from_16_bits(cls, uuid_16, name=None):
+ def from_16_bits(cls, uuid_16: int, name: Optional[str] = None) -> UUID:
return cls.from_bytes(struct.pack('<H', uuid_16), name)
@classmethod
- def from_32_bits(cls, uuid_32, name=None):
+ def from_32_bits(cls, uuid_32: int, name: Optional[str] = None) -> UUID:
return cls.from_bytes(struct.pack('<I', uuid_32), name)
@classmethod
- def parse_uuid(cls, uuid_as_bytes, offset):
+ def parse_uuid(cls, uuid_as_bytes: bytes, offset: int) -> Tuple[int, UUID]:
return len(uuid_as_bytes), cls.from_bytes(uuid_as_bytes[offset:])
@classmethod
- def parse_uuid_2(cls, uuid_as_bytes, offset):
+ def parse_uuid_2(cls, uuid_as_bytes: bytes, offset: int) -> Tuple[int, UUID]:
return offset + 2, cls.from_bytes(uuid_as_bytes[offset : offset + 2])
- def to_bytes(self, force_128=False):
+ def to_bytes(self, force_128: bool = False) -> bytes:
'''
Serialize UUID in little-endian byte-order
'''
@@ -227,7 +232,7 @@ class UUID:
else:
assert False, "unreachable"
- def to_pdu_bytes(self):
+ def to_pdu_bytes(self) -> bytes:
'''
Convert to bytes for use in an ATT PDU.
According to Vol 3, Part F - 3.2.1 Attribute Type:
@@ -236,11 +241,11 @@ class UUID:
'''
return self.to_bytes(force_128=(len(self.uuid_bytes) == 4))
- def to_hex_str(self) -> str:
+ def to_hex_str(self, separator: str = '') -> str:
if len(self.uuid_bytes) == 2 or len(self.uuid_bytes) == 4:
return bytes(reversed(self.uuid_bytes)).hex().upper()
- return ''.join(
+ return separator.join(
[
bytes(reversed(self.uuid_bytes[12:16])).hex(),
bytes(reversed(self.uuid_bytes[10:12])).hex(),
@@ -250,10 +255,10 @@ class UUID:
]
).upper()
- def __bytes__(self):
+ def __bytes__(self) -> bytes:
return self.to_bytes()
- def __eq__(self, other):
+ def __eq__(self, other: object) -> bool:
if isinstance(other, UUID):
return self.to_bytes(force_128=True) == other.to_bytes(force_128=True)
@@ -262,35 +267,19 @@ class UUID:
return False
- def __hash__(self):
+ def __hash__(self) -> int:
return hash(self.uuid_bytes)
- def __str__(self):
+ def __str__(self) -> str:
+ result = self.to_hex_str(separator='-')
if len(self.uuid_bytes) == 2:
- uuid = struct.unpack('<H', self.uuid_bytes)[0]
- result = f'UUID-16:{uuid:04X}'
+ result = 'UUID-16:' + result
elif len(self.uuid_bytes) == 4:
- uuid = struct.unpack('<I', self.uuid_bytes)[0]
- result = f'UUID-32:{uuid:08X}'
- else:
- result = '-'.join(
- [
- bytes(reversed(self.uuid_bytes[12:16])).hex(),
- bytes(reversed(self.uuid_bytes[10:12])).hex(),
- bytes(reversed(self.uuid_bytes[8:10])).hex(),
- bytes(reversed(self.uuid_bytes[6:8])).hex(),
- bytes(reversed(self.uuid_bytes[0:6])).hex(),
- ]
- ).upper()
-
+ result = 'UUID-32:' + result
if self.name is not None:
- return result + f' ({self.name})'
-
+ result += f' ({self.name})'
return result
- def __repr__(self):
- return str(self)
-
# -----------------------------------------------------------------------------
# Common UUID constants
@@ -773,7 +762,7 @@ class AdvertisingData:
def uuid_list_to_objects(ad_data: bytes, uuid_size: int) -> List[UUID]:
uuids = []
offset = 0
- while (uuid_size * (offset + 1)) <= len(ad_data):
+ while (offset + uuid_size) <= len(ad_data):
uuids.append(UUID.from_bytes(ad_data[offset : offset + uuid_size]))
offset += uuid_size
return uuids
diff --git a/bumble/device.py b/bumble/device.py
index 25fa099..258a43d 100644
--- a/bumble/device.py
+++ b/bumble/device.py
@@ -23,17 +23,19 @@ import asyncio
import logging
from contextlib import asynccontextmanager, AsyncExitStack
from dataclasses import dataclass
-from typing import Any, ClassVar, Dict, List, Optional, Tuple, Union
+from typing import Any, Callable, ClassVar, Dict, List, Optional, Tuple, Type, Union
from .colors import color
from .att import ATT_CID, ATT_DEFAULT_MTU, ATT_PDU
from .gatt import Characteristic, Descriptor, Service
from .hci import (
+ HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_192_TYPE,
+ HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE,
HCI_CENTRAL_ROLE,
HCI_COMMAND_STATUS_PENDING,
HCI_CONNECTION_REJECTED_DUE_TO_LIMITED_RESOURCES_ERROR,
- HCI_DISPLAY_ONLY_IO_CAPABILITY,
HCI_DISPLAY_YES_NO_IO_CAPABILITY,
+ HCI_DISPLAY_ONLY_IO_CAPABILITY,
HCI_EXTENDED_INQUIRY_MODE,
HCI_GENERAL_INQUIRY_LAP,
HCI_INVALID_HCI_COMMAND_PARAMETERS_ERROR,
@@ -141,6 +143,7 @@ from .keys import (
KeyStore,
PairingKeys,
)
+from .pairing import PairingConfig
from . import gatt_client
from . import gatt_server
from . import smp
@@ -198,6 +201,7 @@ DEVICE_DEFAULT_L2CAP_COC_MAX_CREDITS = l2cap.L2CAP_LE_CREDIT_BASED_CONN
# Classes
# -----------------------------------------------------------------------------
+
# -----------------------------------------------------------------------------
class Advertisement:
address: Address
@@ -524,11 +528,15 @@ class Connection(CompositeEventEmitter):
transport: int
self_address: Address
peer_address: Address
+ peer_resolvable_address: Optional[Address]
role: int
encryption: int
authenticated: bool
sc: bool
link_key_type: int
+ gatt_client: gatt_client.Client
+ pairing_peer_io_capability: Optional[int]
+ pairing_peer_authentication_requirements: Optional[int]
@composite_listener
class Listener:
@@ -592,10 +600,12 @@ class Connection(CompositeEventEmitter):
self.gatt_server = (
device.gatt_server
) # By default, use the device's shared server
+ self.pairing_peer_io_capability = None
+ self.pairing_peer_authentication_requirements = None
# [Classic only]
@classmethod
- def incomplete(cls, device, peer_address):
+ def incomplete(cls, device, peer_address, role):
"""
Instantiate an incomplete connection (ie. one waiting for a HCI Connection
Complete event).
@@ -608,28 +618,30 @@ class Connection(CompositeEventEmitter):
device.public_address,
peer_address,
None,
- None,
+ role,
None,
None,
)
# [Classic only]
- def complete(self, handle, peer_resolvable_address, role, parameters):
+ def complete(self, handle, parameters):
"""
Finish an incomplete connection upon completion.
"""
assert self.handle is None
assert self.transport == BT_BR_EDR_TRANSPORT
self.handle = handle
- self.peer_resolvable_address = peer_resolvable_address
- # Quirk: role might be known before complete
- if self.role is None:
- self.role = role
self.parameters = parameters
@property
def role_name(self):
- return 'CENTRAL' if self.role == BT_CENTRAL_ROLE else 'PERIPHERAL'
+ if self.role is None:
+ return 'NOT-SET'
+ if self.role == BT_CENTRAL_ROLE:
+ return 'CENTRAL'
+ if self.role == BT_PERIPHERAL_ROLE:
+ return 'PERIPHERAL'
+ return f'UNKNOWN[{self.role}]'
@property
def is_encrypted(self):
@@ -637,7 +649,7 @@ class Connection(CompositeEventEmitter):
@property
def is_incomplete(self) -> bool:
- return self.handle == None
+ return self.handle is None
def send_l2cap_pdu(self, cid, pdu):
self.device.send_l2cap_pdu(self.handle, cid, pdu)
@@ -750,10 +762,11 @@ class DeviceConfiguration:
self.advertising_interval_max = DEVICE_DEFAULT_ADVERTISING_INTERVAL
self.le_enabled = True
# LE host enable 2nd parameter
- self.le_simultaneous_enabled = True
+ self.le_simultaneous_enabled = False
self.classic_enabled = False
self.classic_sc_enabled = True
self.classic_ssp_enabled = True
+ self.classic_smp_enabled = True
self.classic_accept_any = True
self.connectable = True
self.discoverable = True
@@ -788,6 +801,9 @@ class DeviceConfiguration:
self.classic_ssp_enabled = config.get(
'classic_ssp_enabled', self.classic_ssp_enabled
)
+ self.classic_smp_enabled = config.get(
+ 'classic_smp_enabled', self.classic_smp_enabled
+ )
self.classic_accept_any = config.get(
'classic_accept_any', self.classic_accept_any
)
@@ -873,12 +889,12 @@ def host_event_handler(function):
# List of host event handlers for the Device class.
# (we define this list outside the class, because referencing a class in method
# decorators is not straightforward)
-device_host_event_handlers: list[str] = []
+device_host_event_handlers: List[str] = []
# -----------------------------------------------------------------------------
class Device(CompositeEventEmitter):
- # incomplete list of fields.
+ # Incomplete list of fields.
random_address: Address
public_address: Address
classic_enabled: bool
@@ -893,6 +909,7 @@ class Device(CompositeEventEmitter):
Address, List[asyncio.Future[Union[Connection, Tuple[Address, int, int]]]]
]
advertisement_accumulators: Dict[Address, AdvertisementDataAccumulator]
+ config: DeviceConfiguration
@composite_listener
class Listener:
@@ -980,9 +997,10 @@ class Device(CompositeEventEmitter):
self.connect_own_address_type = None
# Use the initial config or a default
+ config = config or DeviceConfiguration()
+ self.config = config
+
self.public_address = Address('00:00:00:00:00:00')
- if config is None:
- config = DeviceConfiguration()
self.name = config.name
self.random_address = config.address
self.class_of_device = config.class_of_device
@@ -990,13 +1008,14 @@ class Device(CompositeEventEmitter):
self.advertising_data = config.advertising_data
self.advertising_interval_min = config.advertising_interval_min
self.advertising_interval_max = config.advertising_interval_max
- self.keystore = KeyStore.create_for_device(config)
+ self.keystore = None
self.irk = config.irk
self.le_enabled = config.le_enabled
self.classic_enabled = config.classic_enabled
self.le_simultaneous_enabled = config.le_simultaneous_enabled
- self.classic_ssp_enabled = config.classic_ssp_enabled
self.classic_sc_enabled = config.classic_sc_enabled
+ self.classic_ssp_enabled = config.classic_ssp_enabled
+ self.classic_smp_enabled = config.classic_smp_enabled
self.discoverable = config.discoverable
self.connectable = config.connectable
self.classic_accept_any = config.classic_accept_any
@@ -1018,7 +1037,9 @@ class Device(CompositeEventEmitter):
descriptors.append(new_descriptor)
new_characteristic = Characteristic(
uuid=characteristic["uuid"],
- properties=characteristic["properties"],
+ properties=Characteristic.Properties.from_string(
+ characteristic["properties"]
+ ),
permissions=characteristic["permissions"],
descriptors=descriptors,
)
@@ -1037,12 +1058,12 @@ class Device(CompositeEventEmitter):
self.random_address = address
# Setup SMP
- self.smp_manager = smp.Manager(self)
- self.l2cap_channel_manager.register_fixed_channel(smp.SMP_CID, self.on_smp_pdu)
- self.l2cap_channel_manager.register_fixed_channel(
- smp.SMP_BR_CID, self.on_smp_pdu
+ self.smp_manager = smp.Manager(
+ self, pairing_config_factory=lambda connection: PairingConfig()
)
+ self.l2cap_channel_manager.register_fixed_channel(smp.SMP_CID, self.on_smp_pdu)
+
# Register the SDP server with the L2CAP Channel Manager
self.sdp_server.register(self.l2cap_channel_manager)
@@ -1165,6 +1186,7 @@ class Device(CompositeEventEmitter):
# Reset the controller
await self.host.reset()
+ # Try to get the public address from the controller
response = await self.send_command(HCI_Read_BD_ADDR_Command()) # type: ignore[call-arg]
if response.return_parameters.status == HCI_SUCCESS:
logger.debug(
@@ -1172,6 +1194,17 @@ class Device(CompositeEventEmitter):
)
self.public_address = response.return_parameters.bd_addr
+ # Instantiate the Key Store (we do this here rather than at __init__ time
+ # because some Key Store implementations use the public address as a namespace)
+ if self.keystore is None:
+ self.keystore = KeyStore.create_for_device(self)
+
+ # Finish setting up SMP based on post-init configurable options
+ if self.classic_smp_enabled:
+ self.l2cap_channel_manager.register_fixed_channel(
+ smp.SMP_BR_CID, self.on_smp_pdu
+ )
+
if self.host.supports_command(HCI_WRITE_LE_HOST_SUPPORT_COMMAND):
await self.send_command(
HCI_Write_LE_Host_Support_Command(
@@ -1219,7 +1252,7 @@ class Device(CompositeEventEmitter):
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:
+ 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,
@@ -1600,7 +1633,7 @@ class Device(CompositeEventEmitter):
pending connection.
connection_parameters_preferences: (BLE only, ignored for BR/EDR)
- * None: use all PHYs with default parameters
+ * None: use the 1M PHY with default parameters
* map: each entry has a PHY as key and a ConnectionParametersPreferences
object as value
@@ -1669,9 +1702,7 @@ class Device(CompositeEventEmitter):
if connection_parameters_preferences is None:
if connection_parameters_preferences is None:
connection_parameters_preferences = {
- HCI_LE_1M_PHY: ConnectionParametersPreferences.default,
- HCI_LE_2M_PHY: ConnectionParametersPreferences.default,
- HCI_LE_CODED_PHY: ConnectionParametersPreferences.default,
+ HCI_LE_1M_PHY: ConnectionParametersPreferences.default
}
self.connect_own_address_type = own_address_type
@@ -1793,7 +1824,7 @@ class Device(CompositeEventEmitter):
else:
# Save pending connection
self.pending_connections[peer_address] = Connection.incomplete(
- self, peer_address
+ self, peer_address, BT_CENTRAL_ROLE
)
# TODO: allow passing other settings
@@ -1930,9 +1961,12 @@ class Device(CompositeEventEmitter):
self.on('connection', on_connection)
self.on('connection_failure', on_connection_failure)
- # Save pending connection
+ # Save pending connection, with the Peripheral role.
+ # Even if we requested a role switch in the HCI_Accept_Connection_Request
+ # command, this connection is still considered Peripheral until an eventual
+ # role change event.
self.pending_connections[peer_address] = Connection.incomplete(
- self, peer_address
+ self, peer_address, BT_PERIPHERAL_ROLE
)
try:
@@ -2163,13 +2197,23 @@ class Device(CompositeEventEmitter):
await self.stop_discovery()
@property
- def pairing_config_factory(self):
+ def pairing_config_factory(self) -> Callable[[Connection], PairingConfig]:
return self.smp_manager.pairing_config_factory
@pairing_config_factory.setter
- def pairing_config_factory(self, pairing_config_factory):
+ def pairing_config_factory(
+ self, pairing_config_factory: Callable[[Connection], PairingConfig]
+ ) -> None:
self.smp_manager.pairing_config_factory = pairing_config_factory
+ @property
+ def smp_session_proxy(self) -> Type[smp.Session]:
+ return self.smp_manager.session_proxy
+
+ @smp_session_proxy.setter
+ def smp_session_proxy(self, session_proxy: Type[smp.Session]) -> None:
+ self.smp_manager.session_proxy = session_proxy
+
async def pair(self, connection):
return await self.smp_manager.pair(connection)
@@ -2199,13 +2243,18 @@ class Device(CompositeEventEmitter):
if connection.role == BT_PERIPHERAL_ROLE and keys.ltk_peripheral:
return keys.ltk_peripheral.value
- async def get_link_key(self, address):
+ async def get_link_key(self, address: Address) -> Optional[bytes]:
# Look for the key in the keystore
if self.keystore is not None:
keys = await self.keystore.get(str(address))
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
+
return keys.link_key.value
+ return None
# [Classic only]
async def authenticate(self, connection):
@@ -2409,8 +2458,14 @@ class Device(CompositeEventEmitter):
def on_link_key(self, bd_addr, link_key, key_type):
# Store the keys in the key store
if self.keystore:
+ authenticated = key_type in (
+ HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_192_TYPE,
+ HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE,
+ )
pairing_keys = PairingKeys()
- pairing_keys.link_key = PairingKeys.Key(value=link_key)
+ pairing_keys.link_key = PairingKeys.Key(
+ value=link_key, authenticated=authenticated
+ )
async def store_keys():
try:
@@ -2454,25 +2509,24 @@ class Device(CompositeEventEmitter):
connection_handle,
transport,
peer_address,
- peer_resolvable_address,
role,
connection_parameters,
):
logger.debug(
f'*** Connection: [0x{connection_handle:04X}] '
- f'{peer_address} as {HCI_Constant.role_name(role)}'
+ f'{peer_address} {"" if role is None else HCI_Constant.role_name(role)}'
)
if connection_handle in self.connections:
logger.warning(
'new connection reuses the same handle as a previous connection'
)
+ peer_resolvable_address = None
+
if transport == BT_BR_EDR_TRANSPORT:
# Create a new connection
connection = self.pending_connections.pop(peer_address)
- connection.complete(
- connection_handle, peer_resolvable_address, role, connection_parameters
- )
+ connection.complete(connection_handle, connection_parameters)
self.connections[connection_handle] = connection
# Emit an event to notify listeners of the new connection
@@ -2584,7 +2638,9 @@ class Device(CompositeEventEmitter):
# device configuration is set to accept any incoming connection
elif self.classic_accept_any:
# Save pending connection
- self.pending_connections[bd_addr] = Connection.incomplete(self, bd_addr)
+ self.pending_connections[bd_addr] = Connection.incomplete(
+ self, bd_addr, BT_PERIPHERAL_ROLE
+ )
self.host.send_command_sync(
HCI_Accept_Connection_Request_Command(
@@ -2675,7 +2731,7 @@ class Device(CompositeEventEmitter):
# 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 known if we are truly authenticated
+ # 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}] '
@@ -2690,22 +2746,6 @@ class Device(CompositeEventEmitter):
# Ask what the pairing config should be for this connection
pairing_config = self.pairing_config_factory(connection)
- # Map the SMP IO capability to a Classic IO capability
- # pylint: disable=line-too-long
- io_capability = {
- smp.SMP_DISPLAY_ONLY_IO_CAPABILITY: HCI_DISPLAY_ONLY_IO_CAPABILITY,
- smp.SMP_DISPLAY_YES_NO_IO_CAPABILITY: HCI_DISPLAY_YES_NO_IO_CAPABILITY,
- smp.SMP_KEYBOARD_ONLY_IO_CAPABILITY: HCI_KEYBOARD_ONLY_IO_CAPABILITY,
- smp.SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY,
- smp.SMP_KEYBOARD_DISPLAY_IO_CAPABILITY: HCI_DISPLAY_YES_NO_IO_CAPABILITY,
- }.get(pairing_config.delegate.io_capability)
-
- if io_capability is None:
- logger.warning(
- f'cannot map IO capability ({pairing_config.delegate.io_capability}'
- )
- io_capability = HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY
-
# Compute the authentication requirements
authentication_requirements = (
# No Bonding
@@ -2724,7 +2764,7 @@ class Device(CompositeEventEmitter):
self.host.send_command_sync(
HCI_IO_Capability_Request_Reply_Command(
bd_addr=connection.peer_address,
- io_capability=io_capability,
+ io_capability=pairing_config.delegate.classic_io_capability,
oob_data_present=0x00, # Not present
authentication_requirements=authentication_requirements,
)
@@ -2733,114 +2773,127 @@ class Device(CompositeEventEmitter):
# [Classic only]
@host_event_handler
@with_connection_from_address
- def on_authentication_user_confirmation_request(self, connection, code):
+ def on_authentication_io_capability_response(
+ self, connection, io_capability, authentication_requirements
+ ):
+ connection.peer_pairing_io_capability = io_capability
+ connection.peer_pairing_authentication_requirements = (
+ authentication_requirements
+ )
+
+ # [Classic only]
+ @host_event_handler
+ @with_connection_from_address
+ def on_authentication_user_confirmation_request(self, connection, code) -> None:
# Ask what the pairing config should be for this connection
pairing_config = self.pairing_config_factory(connection)
+ io_capability = pairing_config.delegate.classic_io_capability
+ peer_io_capability = connection.peer_pairing_io_capability
+
+ async def confirm() -> bool:
+ # Ask the user to confirm the pairing, without display
+ return await pairing_config.delegate.confirm()
+
+ async def auto_confirm() -> bool:
+ # Ask the user to auto-confirm the pairing, without display
+ return await pairing_config.delegate.confirm(auto=True)
+
+ async def display_confirm() -> bool:
+ # Display the code and ask the user to compare
+ return await pairing_config.delegate.compare_numbers(code, digits=6)
+
+ async def display_auto_confirm() -> bool:
+ # Display the code to the user and ask the delegate to auto-confirm
+ await pairing_config.delegate.display_number(code, digits=6)
+ return await pairing_config.delegate.confirm(auto=True)
+
+ async def na() -> bool:
+ assert False, "N/A: unreachable"
+
+ # See Bluetooth spec @ Vol 3, Part C 5.2.2.6
+ methods = {
+ HCI_DISPLAY_ONLY_IO_CAPABILITY: {
+ HCI_DISPLAY_ONLY_IO_CAPABILITY: display_auto_confirm,
+ HCI_DISPLAY_YES_NO_IO_CAPABILITY: display_confirm,
+ HCI_KEYBOARD_ONLY_IO_CAPABILITY: na,
+ HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: auto_confirm,
+ },
+ HCI_DISPLAY_YES_NO_IO_CAPABILITY: {
+ HCI_DISPLAY_ONLY_IO_CAPABILITY: display_auto_confirm,
+ HCI_DISPLAY_YES_NO_IO_CAPABILITY: display_confirm,
+ HCI_KEYBOARD_ONLY_IO_CAPABILITY: na,
+ HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: auto_confirm,
+ },
+ HCI_KEYBOARD_ONLY_IO_CAPABILITY: {
+ HCI_DISPLAY_ONLY_IO_CAPABILITY: na,
+ HCI_DISPLAY_YES_NO_IO_CAPABILITY: na,
+ HCI_KEYBOARD_ONLY_IO_CAPABILITY: na,
+ HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: auto_confirm,
+ },
+ HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: {
+ HCI_DISPLAY_ONLY_IO_CAPABILITY: confirm,
+ HCI_DISPLAY_YES_NO_IO_CAPABILITY: confirm,
+ HCI_KEYBOARD_ONLY_IO_CAPABILITY: auto_confirm,
+ HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY: auto_confirm,
+ },
+ }
- can_compare = pairing_config.delegate.io_capability not in (
- smp.SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY,
- smp.SMP_DISPLAY_ONLY_IO_CAPABILITY,
- )
-
- # Respond
- if can_compare:
+ method = methods[peer_io_capability][io_capability]
- async def compare_numbers():
- numbers_match = await connection.abort_on(
- 'disconnection',
- pairing_config.delegate.compare_numbers(code, digits=6),
- )
- if numbers_match:
- await self.host.send_command(
- HCI_User_Confirmation_Request_Reply_Command(
- bd_addr=connection.peer_address
- )
- )
- else:
- await self.host.send_command(
- HCI_User_Confirmation_Request_Negative_Reply_Command(
- bd_addr=connection.peer_address
- )
+ async def reply() -> None:
+ if await connection.abort_on('disconnection', method()):
+ await self.host.send_command(
+ HCI_User_Confirmation_Request_Reply_Command( # type: ignore[call-arg]
+ bd_addr=connection.peer_address
)
-
- asyncio.create_task(compare_numbers())
- else:
-
- async def confirm():
- confirm = await connection.abort_on(
- 'disconnection', pairing_config.delegate.confirm()
)
- if confirm:
- await self.host.send_command(
- HCI_User_Confirmation_Request_Reply_Command(
- bd_addr=connection.peer_address
- )
- )
- else:
- await self.host.send_command(
- HCI_User_Confirmation_Request_Negative_Reply_Command(
- bd_addr=connection.peer_address
- )
+ else:
+ await self.host.send_command(
+ HCI_User_Confirmation_Request_Negative_Reply_Command( # type: ignore[call-arg]
+ bd_addr=connection.peer_address
)
+ )
- asyncio.create_task(confirm())
+ AsyncRunner.spawn(reply())
# [Classic only]
@host_event_handler
@with_connection_from_address
- def on_authentication_user_passkey_request(self, connection):
+ def on_authentication_user_passkey_request(self, connection) -> None:
# Ask what the pairing config should be for this connection
pairing_config = self.pairing_config_factory(connection)
- can_input = pairing_config.delegate.io_capability in (
- smp.SMP_KEYBOARD_ONLY_IO_CAPABILITY,
- smp.SMP_KEYBOARD_DISPLAY_IO_CAPABILITY,
- )
-
- # Respond
- if can_input:
-
- async def get_number():
- number = await connection.abort_on(
- 'disconnection', pairing_config.delegate.get_number()
- )
- if number is not None:
- await self.host.send_command(
- HCI_User_Passkey_Request_Reply_Command(
- bd_addr=connection.peer_address, numeric_value=number
- )
+ async def reply() -> None:
+ number = await connection.abort_on(
+ 'disconnection', pairing_config.delegate.get_number()
+ )
+ if number is not None:
+ await self.host.send_command(
+ HCI_User_Passkey_Request_Reply_Command( # type: ignore[call-arg]
+ bd_addr=connection.peer_address, numeric_value=number
)
- else:
- await self.host.send_command(
- HCI_User_Passkey_Request_Negative_Reply_Command(
- bd_addr=connection.peer_address
- )
+ )
+ else:
+ await self.host.send_command(
+ HCI_User_Passkey_Request_Negative_Reply_Command( # type: ignore[call-arg]
+ bd_addr=connection.peer_address
)
-
- asyncio.create_task(get_number())
- else:
- self.host.send_command_sync(
- HCI_User_Passkey_Request_Negative_Reply_Command(
- bd_addr=connection.peer_address
)
- )
+
+ AsyncRunner.spawn(reply())
# [Classic only]
@host_event_handler
@with_connection_from_address
def on_pin_code_request(self, connection):
- # classic legacy pairing
+ # Classic legacy pairing
# Ask what the pairing config should be for this connection
pairing_config = self.pairing_config_factory(connection)
+ io_capability = pairing_config.delegate.classic_io_capability
- can_input = pairing_config.delegate.io_capability in (
- smp.SMP_KEYBOARD_ONLY_IO_CAPABILITY,
- smp.SMP_KEYBOARD_DISPLAY_IO_CAPABILITY,
- )
-
- # respond the pin code
- if can_input:
-
+ # Respond
+ if io_capability == HCI_KEYBOARD_ONLY_IO_CAPABILITY:
+ # Ask the user to enter a string
async def get_pin_code():
pin_code = await connection.abort_on(
'disconnection', pairing_config.delegate.get_string(16)
@@ -2880,6 +2933,7 @@ class Device(CompositeEventEmitter):
# Ask what the pairing config should be for this connection
pairing_config = self.pairing_config_factory(connection)
+ # Show the passkey to the user
connection.abort_on(
'disconnection', pairing_config.delegate.display_number(passkey)
)
@@ -3031,18 +3085,15 @@ class Device(CompositeEventEmitter):
connection.emit('role_change_failure', error)
self.emit('role_change_failure', address, error)
- @with_connection_from_handle
- def on_pairing_start(self, connection):
+ def on_pairing_start(self, connection: Connection) -> None:
connection.emit('pairing_start')
- @with_connection_from_handle
- def on_pairing(self, connection, keys, sc):
+ def on_pairing(self, connection: Connection, keys: PairingKeys, sc: bool) -> None:
connection.sc = sc
connection.authenticated = True
connection.emit('pairing', keys)
- @with_connection_from_handle
- def on_pairing_failure(self, connection, reason):
+ def on_pairing_failure(self, connection: Connection, reason: int) -> None:
connection.emit('pairing_failure', reason)
@with_connection_from_handle
diff --git a/bumble/gap.py b/bumble/gap.py
index a4d5077..29df89f 100644
--- a/bumble/gap.py
+++ b/bumble/gap.py
@@ -41,14 +41,14 @@ class GenericAccessService(Service):
def __init__(self, device_name, appearance=(0, 0)):
device_name_characteristic = Characteristic(
GATT_DEVICE_NAME_CHARACTERISTIC,
- Characteristic.READ,
+ Characteristic.Properties.READ,
Characteristic.READABLE,
device_name.encode('utf-8')[:248],
)
appearance_characteristic = Characteristic(
GATT_APPEARANCE_CHARACTERISTIC,
- Characteristic.READ,
+ Characteristic.Properties.READ,
Characteristic.READABLE,
struct.pack('<H', (appearance[0] << 6) | appearance[1]),
)
diff --git a/bumble/gatt.py b/bumble/gatt.py
index 7aa065c..ea2b690 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
+from typing import Optional, Sequence, List
from .colors import color
from .core import UUID, get_dict_key_by_value
@@ -205,8 +205,16 @@ class Service(Attribute):
'''
uuid: UUID
+ characteristics: List[Characteristic]
+ included_services: List[Service]
- def __init__(self, uuid, characteristics: list[Characteristic], primary=True):
+ def __init__(
+ self,
+ uuid,
+ characteristics: List[Characteristic],
+ primary=True,
+ included_services: List[Service] = [],
+ ):
# Convert the uuid to a UUID object if it isn't already
if isinstance(uuid, str):
uuid = UUID(uuid)
@@ -219,7 +227,7 @@ class Service(Attribute):
uuid.to_pdu_bytes(),
)
self.uuid = uuid
- # self.included_services = []
+ self.included_services = included_services[:]
self.characteristics = characteristics[:]
self.primary = primary
@@ -247,75 +255,107 @@ class TemplateService(Service):
to expose their UUID as a class property
'''
- UUID = None
+ UUID: Optional[UUID] = None
def __init__(self, characteristics, primary=True):
super().__init__(self.UUID, characteristics, primary)
# -----------------------------------------------------------------------------
-class Characteristic(Attribute):
+class IncludedServiceDeclaration(Attribute):
'''
- See Vol 3, Part G - 3.3 CHARACTERISTIC DEFINITION
+ See Vol 3, Part G - 3.2 INCLUDE DEFINITION
'''
- # Property flags
- BROADCAST = 0x01
- READ = 0x02
- WRITE_WITHOUT_RESPONSE = 0x04
- WRITE = 0x08
- NOTIFY = 0x10
- INDICATE = 0x20
- AUTHENTICATED_SIGNED_WRITES = 0x40
- EXTENDED_PROPERTIES = 0x80
-
- PROPERTY_NAMES = {
- BROADCAST: 'BROADCAST',
- READ: 'READ',
- WRITE_WITHOUT_RESPONSE: 'WRITE_WITHOUT_RESPONSE',
- WRITE: 'WRITE',
- NOTIFY: 'NOTIFY',
- INDICATE: 'INDICATE',
- AUTHENTICATED_SIGNED_WRITES: 'AUTHENTICATED_SIGNED_WRITES',
- EXTENDED_PROPERTIES: 'EXTENDED_PROPERTIES',
- }
-
- @staticmethod
- def property_name(property_int):
- return Characteristic.PROPERTY_NAMES.get(property_int, '')
-
- @staticmethod
- def properties_as_string(properties):
- return ','.join(
- [
- Characteristic.property_name(p)
- for p in Characteristic.PROPERTY_NAMES
- if properties & p
- ]
+ service: Service
+
+ def __init__(self, service):
+ declaration_bytes = struct.pack(
+ '<HH2s', service.handle, service.end_group_handle, service.uuid.to_bytes()
+ )
+ super().__init__(
+ GATT_INCLUDE_ATTRIBUTE_TYPE, Attribute.READABLE, declaration_bytes
)
+ self.service = service
- @staticmethod
- def string_to_properties(properties_str: str):
- return functools.reduce(
- lambda x, y: x | get_dict_key_by_value(Characteristic.PROPERTY_NAMES, y),
- properties_str.split(","),
- 0,
+ def __str__(self):
+ return (
+ f'IncludedServiceDefinition(handle=0x{self.handle:04X}, '
+ f'group_starting_handle=0x{self.service.handle:04X}, '
+ f'group_ending_handle=0x{self.service.end_group_handle:04X}, '
+ f'uuid={self.service.uuid}, '
+ f'{self.service.properties!s})'
)
+
+# -----------------------------------------------------------------------------
+class Characteristic(Attribute):
+ '''
+ See Vol 3, Part G - 3.3 CHARACTERISTIC DEFINITION
+ '''
+
+ uuid: UUID
+ properties: Characteristic.Properties
+
+ class Properties(enum.IntFlag):
+ """Property flags"""
+
+ BROADCAST = 0x01
+ READ = 0x02
+ WRITE_WITHOUT_RESPONSE = 0x04
+ WRITE = 0x08
+ NOTIFY = 0x10
+ INDICATE = 0x20
+ AUTHENTICATED_SIGNED_WRITES = 0x40
+ EXTENDED_PROPERTIES = 0x80
+
+ @staticmethod
+ def from_string(properties_str: str) -> Characteristic.Properties:
+ property_names: List[str] = []
+ for property in Characteristic.Properties:
+ if property.name is None:
+ raise TypeError()
+ property_names.append(property.name)
+
+ def string_to_property(property_string) -> Characteristic.Properties:
+ for property in zip(Characteristic.Properties, property_names):
+ if property_string == property[1]:
+ return property[0]
+ raise TypeError(f"Unable to convert {property_string} to Property")
+
+ try:
+ return functools.reduce(
+ lambda x, y: x | string_to_property(y),
+ properties_str.split(","),
+ Characteristic.Properties(0),
+ )
+ except TypeError:
+ raise TypeError(
+ f"Characteristic.Properties::from_string() error:\nExpected a string containing any of the keys, separated by commas: {','.join(property_names)}\nGot: {properties_str}"
+ )
+
+ # For backwards compatibility these are defined here
+ # For new code, please use Characteristic.Properties.X
+ BROADCAST = Properties.BROADCAST
+ READ = Properties.READ
+ WRITE_WITHOUT_RESPONSE = Properties.WRITE_WITHOUT_RESPONSE
+ WRITE = Properties.WRITE
+ NOTIFY = Properties.NOTIFY
+ INDICATE = Properties.INDICATE
+ AUTHENTICATED_SIGNED_WRITES = Properties.AUTHENTICATED_SIGNED_WRITES
+ EXTENDED_PROPERTIES = Properties.EXTENDED_PROPERTIES
+
def __init__(
self,
uuid,
- properties,
+ properties: Characteristic.Properties,
permissions,
value=b'',
descriptors: Sequence[Descriptor] = (),
):
super().__init__(uuid, permissions, value)
self.uuid = self.type
- if isinstance(properties, str):
- self.properties = Characteristic.string_to_properties(properties)
- else:
- self.properties = properties
+ self.properties = properties
self.descriptors = descriptors
def get_descriptor(self, descriptor_type):
@@ -325,12 +365,15 @@ class Characteristic(Attribute):
return None
+ def has_properties(self, properties: Characteristic.Properties) -> bool:
+ return self.properties & properties == properties
+
def __str__(self):
return (
f'Characteristic(handle=0x{self.handle:04X}, '
f'end=0x{self.end_group_handle:04X}, '
f'uuid={self.uuid}, '
- f'properties={Characteristic.properties_as_string(self.properties)})'
+ f'{self.properties!s})'
)
@@ -340,6 +383,8 @@ class CharacteristicDeclaration(Attribute):
See Vol 3, Part G - 3.3.1 CHARACTERISTIC DECLARATION
'''
+ characteristic: Characteristic
+
def __init__(self, characteristic, value_handle):
declaration_bytes = (
struct.pack('<BH', characteristic.properties, value_handle)
@@ -355,8 +400,8 @@ class CharacteristicDeclaration(Attribute):
return (
f'CharacteristicDeclaration(handle=0x{self.handle:04X}, '
f'value_handle=0x{self.value_handle:04X}, '
- f'uuid={self.characteristic.uuid}, properties='
- f'{Characteristic.properties_as_string(self.characteristic.properties)})'
+ f'uuid={self.characteristic.uuid}, '
+ f'{self.characteristic.properties!s})'
)
diff --git a/bumble/gatt_client.py b/bumble/gatt_client.py
index 25add18..a33039e 100644
--- a/bumble/gatt_client.py
+++ b/bumble/gatt_client.py
@@ -27,7 +27,8 @@ from __future__ import annotations
import asyncio
import logging
import struct
-from typing import List, Optional
+from datetime import datetime
+from typing import List, Optional, Dict, Tuple, Callable, Union, Any
from pyee import EventEmitter
@@ -62,7 +63,7 @@ from .gatt import (
GATT_PRIMARY_SERVICE_ATTRIBUTE_TYPE,
GATT_REQUEST_TIMEOUT,
GATT_SECONDARY_SERVICE_ATTRIBUTE_TYPE,
- Service,
+ GATT_INCLUDE_ATTRIBUTE_TYPE,
Characteristic,
ClientCharacteristicConfigurationBits,
)
@@ -109,6 +110,7 @@ class AttributeProxy(EventEmitter):
class ServiceProxy(AttributeProxy):
uuid: UUID
characteristics: List[CharacteristicProxy]
+ included_services: List[ServiceProxy]
@staticmethod
def from_client(service_class, client, service_uuid):
@@ -139,12 +141,21 @@ class ServiceProxy(AttributeProxy):
class CharacteristicProxy(AttributeProxy):
+ properties: Characteristic.Properties
descriptors: List[DescriptorProxy]
+ subscribers: Dict[Any, Callable]
- def __init__(self, client, handle, end_group_handle, uuid, properties):
+ def __init__(
+ self,
+ client,
+ handle,
+ end_group_handle,
+ uuid,
+ properties: int,
+ ):
super().__init__(client, handle, end_group_handle, uuid)
self.uuid = uuid
- self.properties = properties
+ self.properties = Characteristic.Properties(properties)
self.descriptors = []
self.descriptors_discovered = False
self.subscribers = {} # Map from subscriber to proxy subscriber
@@ -159,7 +170,9 @@ class CharacteristicProxy(AttributeProxy):
async def discover_descriptors(self):
return await self.client.discover_descriptors(self)
- async def subscribe(self, subscriber=None, prefer_notify=True):
+ async def subscribe(
+ self, subscriber: Optional[Callable] = None, prefer_notify=True
+ ):
if subscriber is not None:
if subscriber in self.subscribers:
# We already have a proxy subscriber
@@ -186,7 +199,7 @@ class CharacteristicProxy(AttributeProxy):
return (
f'Characteristic(handle=0x{self.handle:04X}, '
f'uuid={self.uuid}, '
- f'properties={Characteristic.properties_as_string(self.properties)})'
+ f'{self.properties!s})'
)
@@ -213,6 +226,7 @@ class ProfileServiceProxy:
# -----------------------------------------------------------------------------
class Client:
services: List[ServiceProxy]
+ cached_values: Dict[int, Tuple[datetime, bytes]]
def __init__(self, connection):
self.connection = connection
@@ -225,6 +239,7 @@ class Client:
) # Notification subscribers, by attribute handle
self.indication_subscribers = {} # Indication subscribers, by attribute handle
self.services = []
+ self.cached_values = {}
def send_gatt_pdu(self, pdu):
self.connection.send_l2cap_pdu(ATT_CID, pdu)
@@ -309,6 +324,35 @@ class Client:
if c.uuid == uuid
]
+ def get_attribute_grouping(
+ self, attribute_handle: int
+ ) -> Optional[
+ Union[
+ ServiceProxy,
+ Tuple[ServiceProxy, CharacteristicProxy],
+ Tuple[ServiceProxy, CharacteristicProxy, DescriptorProxy],
+ ]
+ ]:
+ """
+ Get the attribute(s) associated with an attribute handle
+ """
+ for service in self.services:
+ if service.handle == attribute_handle:
+ return service
+ if service.handle <= attribute_handle <= service.end_group_handle:
+ for characteristic in service.characteristics:
+ if characteristic.handle == attribute_handle:
+ return (service, characteristic)
+ if (
+ characteristic.handle
+ <= attribute_handle
+ <= characteristic.end_group_handle
+ ):
+ for descriptor in characteristic.descriptors:
+ if descriptor.handle == attribute_handle:
+ return (service, characteristic, descriptor)
+ return None
+
def on_service_discovered(self, service):
'''Add a service to the service list if it wasn't already there'''
already_known = False
@@ -460,12 +504,69 @@ class Client:
return services
- async def discover_included_services(self, _service):
+ async def discover_included_services(
+ self, service: ServiceProxy
+ ) -> List[ServiceProxy]:
'''
See Vol 3, Part G - 4.5.1 Find Included Services
'''
- # TODO
- return []
+
+ starting_handle = service.handle
+ ending_handle = service.end_group_handle
+
+ included_services: List[ServiceProxy] = []
+ while starting_handle <= ending_handle:
+ response = await self.send_request(
+ ATT_Read_By_Type_Request(
+ starting_handle=starting_handle,
+ ending_handle=ending_handle,
+ attribute_type=GATT_INCLUDE_ATTRIBUTE_TYPE,
+ )
+ )
+ if response is None:
+ # TODO raise appropriate exception
+ return []
+
+ # Check if we reached the end of the iteration
+ if response.op_code == ATT_ERROR_RESPONSE:
+ if response.error_code != ATT_ATTRIBUTE_NOT_FOUND_ERROR:
+ # Unexpected end
+ logger.warning(
+ '!!! unexpected error while discovering included services: '
+ f'{HCI_Constant.error_name(response.error_code)}'
+ )
+ raise ATT_Error(
+ error_code=response.error_code,
+ message='Unexpected error while discovering included services',
+ )
+ break
+
+ # Stop if for some reason the list was empty
+ if not response.attributes:
+ break
+
+ # Process all included services returned in this iteration
+ for attribute_handle, attribute_value in response.attributes:
+ if attribute_handle < starting_handle:
+ # Something's not right
+ logger.warning(f'bogus handle value: {attribute_handle}')
+ return []
+
+ group_starting_handle, group_ending_handle = struct.unpack_from(
+ '<HH', attribute_value
+ )
+ service_uuid = UUID.from_bytes(attribute_value[4:])
+ included_service = ServiceProxy(
+ self, group_starting_handle, group_ending_handle, service_uuid, True
+ )
+
+ included_services.append(included_service)
+
+ # Move on to the next included services
+ starting_handle = response.attributes[-1][0] + 1
+
+ service.included_services = included_services
+ return included_services
async def discover_characteristics(
self, uuids, service: Optional[ServiceProxy]
@@ -678,8 +779,8 @@ class Client:
return
if (
- characteristic.properties & Characteristic.NOTIFY
- and characteristic.properties & Characteristic.INDICATE
+ characteristic.properties & Characteristic.Properties.NOTIFY
+ and characteristic.properties & Characteristic.Properties.INDICATE
):
if prefer_notify:
bits = ClientCharacteristicConfigurationBits.NOTIFICATION
@@ -687,10 +788,10 @@ class Client:
else:
bits = ClientCharacteristicConfigurationBits.INDICATION
subscribers = self.indication_subscribers
- elif characteristic.properties & Characteristic.NOTIFY:
+ elif characteristic.properties & Characteristic.Properties.NOTIFY:
bits = ClientCharacteristicConfigurationBits.NOTIFICATION
subscribers = self.notification_subscribers
- elif characteristic.properties & Characteristic.INDICATE:
+ elif characteristic.properties & Characteristic.Properties.INDICATE:
bits = ClientCharacteristicConfigurationBits.INDICATION
subscribers = self.indication_subscribers
else:
@@ -800,6 +901,7 @@ class Client:
offset += len(part)
+ self.cache_value(attribute_handle, attribute_value)
# Return the value as bytes
return attribute_value
@@ -934,6 +1036,8 @@ class Client:
)
if not subscribers:
logger.warning('!!! received notification with no subscriber')
+
+ self.cache_value(notification.attribute_handle, notification.attribute_value)
for subscriber in subscribers:
if callable(subscriber):
subscriber(notification.attribute_value)
@@ -945,6 +1049,8 @@ class Client:
subscribers = self.indication_subscribers.get(indication.attribute_handle, [])
if not subscribers:
logger.warning('!!! received indication with no subscriber')
+
+ self.cache_value(indication.attribute_handle, indication.attribute_value)
for subscriber in subscribers:
if callable(subscriber):
subscriber(indication.attribute_value)
@@ -953,3 +1059,9 @@ class Client:
# Confirm that we received the indication
self.send_confirmation(ATT_Handle_Value_Confirmation())
+
+ def cache_value(self, attribute_handle: int, value: bytes):
+ self.cached_values[attribute_handle] = (
+ datetime.now(),
+ value,
+ )
diff --git a/bumble/gatt_server.py b/bumble/gatt_server.py
index 3a5953a..3624905 100644
--- a/bumble/gatt_server.py
+++ b/bumble/gatt_server.py
@@ -27,7 +27,7 @@ import asyncio
import logging
from collections import defaultdict
import struct
-from typing import List, Tuple, Optional
+from typing import List, Tuple, Optional, TypeVar, Type
from pyee import EventEmitter
from .colors import color
@@ -68,6 +68,7 @@ from .gatt import (
Characteristic,
CharacteristicDeclaration,
CharacteristicValue,
+ IncludedServiceDeclaration,
Descriptor,
Service,
)
@@ -94,6 +95,7 @@ class Server(EventEmitter):
def __init__(self, device):
super().__init__()
self.device = device
+ self.services = []
self.attributes = [] # Attributes, ordered by increasing handle values
self.attributes_by_handle = {} # Map for fast attribute access by handle
self.max_mtu = (
@@ -135,6 +137,21 @@ class Server(EventEmitter):
return attribute
return None
+ AttributeGroupType = TypeVar('AttributeGroupType', Service, Characteristic)
+
+ def get_attribute_group(
+ self, handle: int, group_type: Type[AttributeGroupType]
+ ) -> Optional[AttributeGroupType]:
+ return next(
+ (
+ attribute
+ for attribute in self.attributes
+ if isinstance(attribute, group_type)
+ and attribute.handle <= handle <= attribute.end_group_handle
+ ),
+ None,
+ )
+
def get_service_attribute(self, service_uuid: UUID) -> Optional[Service]:
return next(
(
@@ -207,7 +224,14 @@ class Server(EventEmitter):
# Add the service attribute to the DB
self.add_attribute(service)
- # TODO: add included services
+ # Add all included service
+ for included_service in service.included_services:
+ # Not registered yet, register the included service first.
+ if included_service not in self.services:
+ self.add_service(included_service)
+ # TODO: Handle circular service reference
+ include_declaration = IncludedServiceDeclaration(included_service)
+ self.add_attribute(include_declaration)
# Add all characteristics
for characteristic in service.characteristics:
@@ -228,7 +252,10 @@ class Server(EventEmitter):
# unless there is one already
if (
characteristic.properties
- & (Characteristic.NOTIFY | Characteristic.INDICATE)
+ & (
+ Characteristic.Properties.NOTIFY
+ | Characteristic.Properties.INDICATE
+ )
and characteristic.get_descriptor(
GATT_CLIENT_CHARACTERISTIC_CONFIGURATION_DESCRIPTOR
)
@@ -256,6 +283,7 @@ class Server(EventEmitter):
# Update the service group end
service.end_group_handle = self.attributes[-1].handle
+ self.services.append(service)
def add_services(self, services):
for service in services:
diff --git a/bumble/host.py b/bumble/host.py
index 9e05c8c..afde2ee 100644
--- a/bumble/host.py
+++ b/bumble/host.py
@@ -94,10 +94,9 @@ HOST_HC_TOTAL_NUM_ACL_DATA_PACKETS = 1
# -----------------------------------------------------------------------------
class Connection:
- def __init__(self, host, handle, role, peer_address, transport):
+ def __init__(self, host, handle, peer_address, transport):
self.host = host
self.handle = handle
- self.role = role
self.peer_address = peer_address
self.assembler = HCI_AclDataPacketAssembler(self.on_acl_pdu)
self.transport = transport
@@ -396,8 +395,8 @@ class Host(AbortableEventEmitter):
def supports_command(self, command):
# Find the support flag position for this command
- for (octet, flags) in enumerate(HCI_SUPPORTED_COMMANDS_FLAGS):
- for (flag_position, value) in enumerate(flags):
+ for octet, flags in enumerate(HCI_SUPPORTED_COMMANDS_FLAGS):
+ for flag_position, value in enumerate(flags):
if value == command:
# Check if the flag is set
if octet < len(self.local_supported_commands) and flag_position < 8:
@@ -410,7 +409,7 @@ class Host(AbortableEventEmitter):
@property
def supported_commands(self):
commands = []
- for (octet, flags) in enumerate(self.local_supported_commands):
+ for octet, flags in enumerate(self.local_supported_commands):
if octet < len(HCI_SUPPORTED_COMMANDS_FLAGS):
for flag in range(8):
if flags & (1 << flag) != 0:
@@ -534,7 +533,7 @@ class Host(AbortableEventEmitter):
if event.status == HCI_SUCCESS:
# Create/update the connection
logger.debug(
- f'### CONNECTION: [0x{event.connection_handle:04X}] '
+ f'### LE CONNECTION: [0x{event.connection_handle:04X}] '
f'{event.peer_address} as {HCI_Constant.role_name(event.role)}'
)
@@ -543,7 +542,6 @@ class Host(AbortableEventEmitter):
connection = Connection(
self,
event.connection_handle,
- event.role,
event.peer_address,
BT_LE_TRANSPORT,
)
@@ -560,7 +558,6 @@ class Host(AbortableEventEmitter):
event.connection_handle,
BT_LE_TRANSPORT,
event.peer_address,
- None,
event.role,
connection_parameters,
)
@@ -589,7 +586,6 @@ class Host(AbortableEventEmitter):
connection = Connection(
self,
event.connection_handle,
- BT_CENTRAL_ROLE,
event.bd_addr,
BT_BR_EDR_TRANSPORT,
)
@@ -602,7 +598,6 @@ class Host(AbortableEventEmitter):
BT_BR_EDR_TRANSPORT,
event.bd_addr,
None,
- BT_CENTRAL_ROLE,
None,
)
else:
@@ -622,8 +617,7 @@ class Host(AbortableEventEmitter):
if event.status == HCI_SUCCESS:
logger.debug(
f'### DISCONNECTION: [0x{event.connection_handle:04X}] '
- f'{connection.peer_address} as '
- f'{HCI_Constant.role_name(connection.role)}, '
+ f'{connection.peer_address} '
f'reason={event.reason}'
)
del self.connections[event.connection_handle]
@@ -739,10 +733,6 @@ class Host(AbortableEventEmitter):
f'role change for {event.bd_addr}: '
f'{HCI_Constant.role_name(event.new_role)}'
)
- if connection := self.find_connection_by_bd_addr(
- event.bd_addr, BT_BR_EDR_TRANSPORT
- ):
- connection.role = event.new_role
self.emit('role_change', event.bd_addr, event.new_role)
else:
logger.debug(
@@ -849,7 +839,12 @@ class Host(AbortableEventEmitter):
self.emit('authentication_io_capability_request', event.bd_addr)
def on_hci_io_capability_response_event(self, event):
- pass
+ self.emit(
+ 'authentication_io_capability_response',
+ event.bd_addr,
+ event.io_capability,
+ event.authentication_requirements,
+ )
def on_hci_user_confirmation_request_event(self, event):
self.emit(
diff --git a/bumble/keys.py b/bumble/keys.py
index 7fed660..a30e753 100644
--- a/bumble/keys.py
+++ b/bumble/keys.py
@@ -20,15 +20,19 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
+from __future__ import annotations
import asyncio
import logging
import os
import json
-from typing import Optional
+from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
from .colors import color
from .hci import Address
+if TYPE_CHECKING:
+ from .device import Device
+
# -----------------------------------------------------------------------------
# Logging
@@ -135,19 +139,19 @@ class PairingKeys:
# -----------------------------------------------------------------------------
class KeyStore:
- async def delete(self, name):
+ async def delete(self, name: str):
pass
- async def update(self, name, keys):
+ async def update(self, name: str, keys: PairingKeys) -> None:
pass
- async def get(self, _name):
- return PairingKeys()
+ async def get(self, _name: str) -> Optional[PairingKeys]:
+ return None
- async def get_all(self):
+ async def get_all(self) -> List[Tuple[str, PairingKeys]]:
return []
- async def delete_all(self):
+ async def delete_all(self) -> None:
all_keys = await self.get_all()
await asyncio.gather(*(self.delete(name) for (name, _) in all_keys))
@@ -173,15 +177,15 @@ class KeyStore:
separator = '\n'
@staticmethod
- def create_for_device(device_config):
- if device_config.keystore is None:
- return None
+ def create_for_device(device: Device) -> KeyStore:
+ if device.config.keystore is None:
+ return MemoryKeyStore()
- keystore_type = device_config.keystore.split(':', 1)[0]
+ keystore_type = device.config.keystore.split(':', 1)[0]
if keystore_type == 'JsonKeyStore':
- return JsonKeyStore.from_device_config(device_config)
+ return JsonKeyStore.from_device(device)
- return None
+ return MemoryKeyStore()
# -----------------------------------------------------------------------------
@@ -204,7 +208,9 @@ class JsonKeyStore(KeyStore):
self.directory_name = os.path.join(
appdirs.user_data_dir(self.APP_NAME, self.APP_AUTHOR), self.KEYS_DIR
)
- json_filename = f'{self.namespace}.json'.lower().replace(':', '-')
+ json_filename = (
+ f'{self.namespace}.json'.lower().replace(':', '-').replace('/p', '-p')
+ )
self.filename = os.path.join(self.directory_name, json_filename)
else:
self.filename = filename
@@ -213,9 +219,19 @@ class JsonKeyStore(KeyStore):
logger.debug(f'JSON keystore: {self.filename}')
@staticmethod
- def from_device_config(device_config):
- params = device_config.keystore.split(':', 1)[1:]
- namespace = str(device_config.address)
+ def from_device(device: Device) -> Optional[JsonKeyStore]:
+ if not device.config.keystore:
+ return None
+
+ params = device.config.keystore.split(':', 1)[1:]
+
+ # Use a namespace based on the device address
+ if device.public_address not in (Address.ANY, Address.ANY_RANDOM):
+ namespace = str(device.public_address)
+ elif device.random_address != Address.ANY_RANDOM:
+ namespace = str(device.random_address)
+ else:
+ namespace = JsonKeyStore.DEFAULT_NAMESPACE
if params:
filename = params[0]
else:
@@ -241,7 +257,7 @@ class JsonKeyStore(KeyStore):
json.dump(db, output, sort_keys=True, indent=4)
# Atomically replace the previous file
- os.rename(temp_filename, self.filename)
+ os.replace(temp_filename, self.filename)
async def delete(self, name: str) -> None:
db = await self.load()
@@ -257,7 +273,7 @@ class JsonKeyStore(KeyStore):
db = await self.load()
namespace = db.setdefault(self.namespace, {})
- namespace[name] = keys.to_dict()
+ namespace.setdefault(name, {}).update(keys.to_dict())
await self.save(db)
@@ -291,3 +307,24 @@ class JsonKeyStore(KeyStore):
return None
return PairingKeys.from_dict(keys)
+
+
+# -----------------------------------------------------------------------------
+class MemoryKeyStore(KeyStore):
+ all_keys: Dict[str, PairingKeys]
+
+ def __init__(self) -> None:
+ self.all_keys = {}
+
+ async def delete(self, name: str) -> None:
+ if name in self.all_keys:
+ del self.all_keys[name]
+
+ async def update(self, name: str, keys: PairingKeys) -> None:
+ self.all_keys[name] = keys
+
+ async def get(self, name: str) -> Optional[PairingKeys]:
+ return self.all_keys.get(name)
+
+ async def get_all(self) -> List[Tuple[str, PairingKeys]]:
+ return list(self.all_keys.items())
diff --git a/bumble/pairing.py b/bumble/pairing.py
new file mode 100644
index 0000000..ab356ee
--- /dev/null
+++ b/bumble/pairing.py
@@ -0,0 +1,188 @@
+# 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 enum
+from typing import Optional, Tuple
+
+from .hci import (
+ HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY,
+ HCI_DISPLAY_ONLY_IO_CAPABILITY,
+ HCI_DISPLAY_YES_NO_IO_CAPABILITY,
+ HCI_KEYBOARD_ONLY_IO_CAPABILITY,
+)
+from .smp import (
+ SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY,
+ SMP_KEYBOARD_ONLY_IO_CAPABILITY,
+ SMP_DISPLAY_ONLY_IO_CAPABILITY,
+ SMP_DISPLAY_YES_NO_IO_CAPABILITY,
+ SMP_KEYBOARD_DISPLAY_IO_CAPABILITY,
+ SMP_ENC_KEY_DISTRIBUTION_FLAG,
+ SMP_ID_KEY_DISTRIBUTION_FLAG,
+ SMP_SIGN_KEY_DISTRIBUTION_FLAG,
+ SMP_LINK_KEY_DISTRIBUTION_FLAG,
+)
+
+
+# -----------------------------------------------------------------------------
+class PairingDelegate:
+ """Abstract base class for Pairing Delegates."""
+
+ # I/O Capabilities.
+ # These are defined abstractly, and can be mapped to specific Classic pairing
+ # and/or SMP constants.
+ class IoCapability(enum.IntEnum):
+ NO_OUTPUT_NO_INPUT = SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY
+ KEYBOARD_INPUT_ONLY = SMP_KEYBOARD_ONLY_IO_CAPABILITY
+ DISPLAY_OUTPUT_ONLY = SMP_DISPLAY_ONLY_IO_CAPABILITY
+ DISPLAY_OUTPUT_AND_YES_NO_INPUT = SMP_DISPLAY_YES_NO_IO_CAPABILITY
+ DISPLAY_OUTPUT_AND_KEYBOARD_INPUT = SMP_KEYBOARD_DISPLAY_IO_CAPABILITY
+
+ # Direct names for backward compatibility.
+ NO_OUTPUT_NO_INPUT = IoCapability.NO_OUTPUT_NO_INPUT
+ KEYBOARD_INPUT_ONLY = IoCapability.KEYBOARD_INPUT_ONLY
+ DISPLAY_OUTPUT_ONLY = IoCapability.DISPLAY_OUTPUT_ONLY
+ DISPLAY_OUTPUT_AND_YES_NO_INPUT = IoCapability.DISPLAY_OUTPUT_AND_YES_NO_INPUT
+ DISPLAY_OUTPUT_AND_KEYBOARD_INPUT = IoCapability.DISPLAY_OUTPUT_AND_KEYBOARD_INPUT
+
+ # Key Distribution [LE only]
+ class KeyDistribution(enum.IntFlag):
+ DISTRIBUTE_ENCRYPTION_KEY = SMP_ENC_KEY_DISTRIBUTION_FLAG
+ DISTRIBUTE_IDENTITY_KEY = SMP_ID_KEY_DISTRIBUTION_FLAG
+ DISTRIBUTE_SIGNING_KEY = SMP_SIGN_KEY_DISTRIBUTION_FLAG
+ DISTRIBUTE_LINK_KEY = SMP_LINK_KEY_DISTRIBUTION_FLAG
+
+ DEFAULT_KEY_DISTRIBUTION: KeyDistribution = (
+ KeyDistribution.DISTRIBUTE_ENCRYPTION_KEY
+ | KeyDistribution.DISTRIBUTE_IDENTITY_KEY
+ )
+
+ # Default mapping from abstract to Classic I/O capabilities.
+ # Subclasses may override this if they prefer a different mapping.
+ CLASSIC_IO_CAPABILITIES_MAP = {
+ NO_OUTPUT_NO_INPUT: HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY,
+ KEYBOARD_INPUT_ONLY: HCI_KEYBOARD_ONLY_IO_CAPABILITY,
+ DISPLAY_OUTPUT_ONLY: HCI_DISPLAY_ONLY_IO_CAPABILITY,
+ DISPLAY_OUTPUT_AND_YES_NO_INPUT: HCI_DISPLAY_YES_NO_IO_CAPABILITY,
+ DISPLAY_OUTPUT_AND_KEYBOARD_INPUT: HCI_DISPLAY_YES_NO_IO_CAPABILITY,
+ }
+
+ io_capability: IoCapability
+ local_initiator_key_distribution: KeyDistribution
+ local_responder_key_distribution: KeyDistribution
+
+ def __init__(
+ self,
+ io_capability: IoCapability = NO_OUTPUT_NO_INPUT,
+ local_initiator_key_distribution: KeyDistribution = DEFAULT_KEY_DISTRIBUTION,
+ local_responder_key_distribution: KeyDistribution = DEFAULT_KEY_DISTRIBUTION,
+ ) -> None:
+ self.io_capability = io_capability
+ self.local_initiator_key_distribution = local_initiator_key_distribution
+ self.local_responder_key_distribution = local_responder_key_distribution
+
+ @property
+ def classic_io_capability(self) -> int:
+ """Map the abstract I/O capability to a Classic constant."""
+
+ # pylint: disable=line-too-long
+ return self.CLASSIC_IO_CAPABILITIES_MAP.get(
+ self.io_capability, HCI_NO_INPUT_NO_OUTPUT_IO_CAPABILITY
+ )
+
+ @property
+ def smp_io_capability(self) -> int:
+ """Map the abstract I/O capability to an SMP constant."""
+
+ # This is just a 1-1 direct mapping
+ return self.io_capability
+
+ async def accept(self) -> bool:
+ """Accept or reject a Pairing request."""
+ return True
+
+ async def confirm(self, auto: bool = False) -> bool:
+ """
+ Respond yes or no to a Pairing confirmation question.
+ The `auto` parameter stands for automatic confirmation.
+ """
+ return True
+
+ # pylint: disable-next=unused-argument
+ async def compare_numbers(self, number: int, digits: int) -> bool:
+ """Compare two numbers."""
+ return True
+
+ async def get_number(self) -> Optional[int]:
+ """
+ Return an optional number as an answer to a passkey request.
+ Returning `None` will result in a negative reply.
+ """
+ return 0
+
+ async def get_string(self, max_length: int) -> Optional[str]:
+ """
+ Return a string whose utf-8 encoding is up to max_length bytes.
+ """
+ return None
+
+ # pylint: disable-next=unused-argument
+ async def display_number(self, number: int, digits: int) -> None:
+ """Display a number."""
+
+ # [LE only]
+ async def key_distribution_response(
+ self, peer_initiator_key_distribution: int, peer_responder_key_distribution: int
+ ) -> Tuple[int, int]:
+ """
+ Return the key distribution response in an SMP protocol context.
+
+ NOTE: since it is only used by the SMP protocol, this method's input and output
+ are directly as integers, using the SMP constants, rather than the abstract
+ KeyDistribution enums.
+ """
+ return (
+ int(
+ peer_initiator_key_distribution & self.local_initiator_key_distribution
+ ),
+ int(
+ peer_responder_key_distribution & self.local_responder_key_distribution
+ ),
+ )
+
+
+# -----------------------------------------------------------------------------
+class PairingConfig:
+ """Configuration for the Pairing protocol."""
+
+ def __init__(
+ self,
+ sc: bool = True,
+ mitm: bool = True,
+ bonding: bool = True,
+ delegate: Optional[PairingDelegate] = None,
+ ) -> None:
+ self.sc = sc
+ self.mitm = mitm
+ self.bonding = bonding
+ self.delegate = delegate or PairingDelegate()
+
+ def __str__(self) -> str:
+ return (
+ f'PairingConfig(sc={self.sc}, '
+ f'mitm={self.mitm}, bonding={self.bonding}, '
+ f'delegate[{self.delegate.io_capability}])'
+ )
diff --git a/bumble/pandora/__init__.py b/bumble/pandora/__init__.py
new file mode 100644
index 0000000..e02f54a
--- /dev/null
+++ b/bumble/pandora/__init__.py
@@ -0,0 +1,105 @@
+# Copyright 2022 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+Bumble Pandora server.
+This module implement the Pandora Bluetooth test APIs for the Bumble stack.
+"""
+
+__version__ = "0.0.1"
+
+import grpc
+import grpc.aio
+
+from .config import Config
+from .device import PandoraDevice
+from .host import HostService
+from .security import SecurityService, SecurityStorageService
+from pandora.host_grpc_aio import add_HostServicer_to_server
+from pandora.security_grpc_aio import (
+ add_SecurityServicer_to_server,
+ add_SecurityStorageServicer_to_server,
+)
+from typing import Callable, List, Optional
+
+# public symbols
+__all__ = [
+ 'register_servicer_hook',
+ 'serve',
+ 'Config',
+ 'PandoraDevice',
+]
+
+
+# Add servicers hooks.
+_SERVICERS_HOOKS: List[Callable[[PandoraDevice, Config, grpc.aio.Server], None]] = []
+
+
+def register_servicer_hook(
+ hook: Callable[[PandoraDevice, Config, grpc.aio.Server], None]
+) -> None:
+ _SERVICERS_HOOKS.append(hook)
+
+
+async def serve(
+ bumble: PandoraDevice,
+ config: Config = Config(),
+ grpc_server: Optional[grpc.aio.Server] = None,
+ port: int = 0,
+) -> None:
+ # initialize a gRPC server if not provided.
+ server = grpc_server if grpc_server is not None else grpc.aio.server()
+ port = server.add_insecure_port(f'localhost:{port}')
+
+ try:
+ while True:
+ # load server config from dict.
+ config.load_from_dict(bumble.config.get('server', {}))
+
+ # add Pandora services to the gRPC server.
+ add_HostServicer_to_server(
+ HostService(server, bumble.device, config), server
+ )
+ add_SecurityServicer_to_server(
+ SecurityService(bumble.device, config), server
+ )
+ add_SecurityStorageServicer_to_server(
+ SecurityStorageService(bumble.device, config), server
+ )
+
+ # call hooks if any.
+ for hook in _SERVICERS_HOOKS:
+ hook(bumble, config, server)
+
+ # open device.
+ await bumble.open()
+ try:
+ # Pandora require classic devices to be discoverable & connectable.
+ if bumble.device.classic_enabled:
+ await bumble.device.set_discoverable(True)
+ await bumble.device.set_connectable(True)
+
+ # start & serve gRPC server.
+ await server.start()
+ await server.wait_for_termination()
+ finally:
+ # close device.
+ await bumble.close()
+
+ # re-initialize the gRPC server.
+ server = grpc.aio.server()
+ server.add_insecure_port(f'localhost:{port}')
+ finally:
+ # stop server.
+ await server.stop(None)
diff --git a/bumble/pandora/config.py b/bumble/pandora/config.py
new file mode 100644
index 0000000..5edba55
--- /dev/null
+++ b/bumble/pandora/config.py
@@ -0,0 +1,48 @@
+# Copyright 2022 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from bumble.pairing import PairingDelegate
+from dataclasses import dataclass
+from typing import Any, Dict
+
+
+@dataclass
+class Config:
+ io_capability: PairingDelegate.IoCapability = PairingDelegate.NO_OUTPUT_NO_INPUT
+ pairing_sc_enable: bool = True
+ pairing_mitm_enable: bool = True
+ pairing_bonding_enable: bool = True
+ smp_local_initiator_key_distribution: PairingDelegate.KeyDistribution = (
+ PairingDelegate.DEFAULT_KEY_DISTRIBUTION
+ )
+ smp_local_responder_key_distribution: PairingDelegate.KeyDistribution = (
+ PairingDelegate.DEFAULT_KEY_DISTRIBUTION
+ )
+
+ def load_from_dict(self, config: Dict[str, Any]) -> None:
+ io_capability_name: str = config.get(
+ 'io_capability', 'no_output_no_input'
+ ).upper()
+ self.io_capability = getattr(PairingDelegate, io_capability_name)
+ self.pairing_sc_enable = config.get('pairing_sc_enable', True)
+ self.pairing_mitm_enable = config.get('pairing_mitm_enable', True)
+ self.pairing_bonding_enable = config.get('pairing_bonding_enable', True)
+ self.smp_local_initiator_key_distribution = config.get(
+ 'smp_local_initiator_key_distribution',
+ PairingDelegate.DEFAULT_KEY_DISTRIBUTION,
+ )
+ self.smp_local_responder_key_distribution = config.get(
+ 'smp_local_responder_key_distribution',
+ PairingDelegate.DEFAULT_KEY_DISTRIBUTION,
+ )
diff --git a/bumble/pandora/device.py b/bumble/pandora/device.py
new file mode 100644
index 0000000..a4403b6
--- /dev/null
+++ b/bumble/pandora/device.py
@@ -0,0 +1,157 @@
+# Copyright 2022 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Generic & dependency free Bumble (reference) device."""
+
+from bumble import transport
+from bumble.core import (
+ BT_GENERIC_AUDIO_SERVICE,
+ BT_HANDSFREE_SERVICE,
+ BT_L2CAP_PROTOCOL_ID,
+ BT_RFCOMM_PROTOCOL_ID,
+)
+from bumble.device import Device, DeviceConfiguration
+from bumble.host import Host
+from bumble.sdp import (
+ SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+ SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+ SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
+ SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
+ DataElement,
+ ServiceAttribute,
+)
+from typing import Any, Dict, List, Optional
+
+
+class PandoraDevice:
+ """
+ Small wrapper around a Bumble device and it's HCI transport.
+ Notes:
+ - The Bumble device is idle by default.
+ - Repetitive calls to `open`/`close` will result on new Bumble device instances.
+ """
+
+ # Bumble device instance & configuration.
+ device: Device
+ config: Dict[str, Any]
+
+ # HCI transport name & instance.
+ _hci_name: str
+ _hci: Optional[transport.Transport] # type: ignore[name-defined]
+
+ def __init__(self, config: Dict[str, Any]) -> None:
+ self.config = config
+ self.device = _make_device(config)
+ self._hci_name = config.get('transport', '')
+ self._hci = None
+
+ @property
+ def idle(self) -> bool:
+ return self._hci is None
+
+ async def open(self) -> None:
+ if self._hci is not None:
+ return
+
+ # open HCI transport & set device host.
+ self._hci = await transport.open_transport(self._hci_name)
+ self.device.host = Host(controller_source=self._hci.source, controller_sink=self._hci.sink) # type: ignore[no-untyped-call]
+
+ # power-on.
+ await self.device.power_on()
+
+ async def close(self) -> None:
+ if self._hci is None:
+ return
+
+ # flush & re-initialize device.
+ await self.device.host.flush()
+ self.device.host = None # type: ignore[assignment]
+ self.device = _make_device(self.config)
+
+ # close HCI transport.
+ await self._hci.close()
+ self._hci = None
+
+ async def reset(self) -> None:
+ await self.close()
+ await self.open()
+
+ def info(self) -> Optional[Dict[str, str]]:
+ return {
+ 'public_bd_address': str(self.device.public_address),
+ 'random_address': str(self.device.random_address),
+ }
+
+
+def _make_device(config: Dict[str, Any]) -> Device:
+ """Initialize an idle Bumble device instance."""
+
+ # initialize bumble device.
+ device_config = DeviceConfiguration()
+ device_config.load_from_dict(config)
+ device = Device(config=device_config, host=None)
+
+ # Add fake a2dp service to avoid Android disconnect
+ device.sdp_service_records = _make_sdp_records(1)
+
+ return device
+
+
+# TODO(b/267540823): remove when Pandora A2dp is supported
+def _make_sdp_records(rfcomm_channel: int) -> Dict[int, List[ServiceAttribute]]:
+ return {
+ 0x00010001: [
+ ServiceAttribute(
+ SDP_SERVICE_RECORD_HANDLE_ATTRIBUTE_ID,
+ DataElement.unsigned_integer_32(0x00010001),
+ ),
+ ServiceAttribute(
+ SDP_SERVICE_CLASS_ID_LIST_ATTRIBUTE_ID,
+ DataElement.sequence(
+ [
+ DataElement.uuid(BT_HANDSFREE_SERVICE),
+ DataElement.uuid(BT_GENERIC_AUDIO_SERVICE),
+ ]
+ ),
+ ),
+ ServiceAttribute(
+ SDP_PROTOCOL_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+ DataElement.sequence(
+ [
+ DataElement.sequence([DataElement.uuid(BT_L2CAP_PROTOCOL_ID)]),
+ DataElement.sequence(
+ [
+ DataElement.uuid(BT_RFCOMM_PROTOCOL_ID),
+ DataElement.unsigned_integer_8(rfcomm_channel),
+ ]
+ ),
+ ]
+ ),
+ ),
+ ServiceAttribute(
+ SDP_BLUETOOTH_PROFILE_DESCRIPTOR_LIST_ATTRIBUTE_ID,
+ DataElement.sequence(
+ [
+ DataElement.sequence(
+ [
+ DataElement.uuid(BT_HANDSFREE_SERVICE),
+ DataElement.unsigned_integer_16(0x0105),
+ ]
+ )
+ ]
+ ),
+ ),
+ ]
+ }
diff --git a/bumble/pandora/host.py b/bumble/pandora/host.py
new file mode 100644
index 0000000..63b295d
--- /dev/null
+++ b/bumble/pandora/host.py
@@ -0,0 +1,856 @@
+# Copyright 2022 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import asyncio
+import bumble.device
+import grpc
+import grpc.aio
+import logging
+import struct
+
+from . import utils
+from .config import Config
+from bumble.core import (
+ BT_BR_EDR_TRANSPORT,
+ BT_LE_TRANSPORT,
+ BT_PERIPHERAL_ROLE,
+ UUID,
+ AdvertisingData,
+ ConnectionError,
+)
+from bumble.device import (
+ DEVICE_DEFAULT_SCAN_INTERVAL,
+ DEVICE_DEFAULT_SCAN_WINDOW,
+ Advertisement,
+ AdvertisingType,
+ Device,
+)
+from bumble.gatt import Service
+from bumble.hci import (
+ HCI_CONNECTION_ALREADY_EXISTS_ERROR,
+ HCI_PAGE_TIMEOUT_ERROR,
+ HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR,
+ Address,
+)
+from google.protobuf import any_pb2, empty_pb2 # pytype: disable=pyi-error
+from pandora.host_grpc_aio import HostServicer
+from pandora.host_pb2 import (
+ NOT_CONNECTABLE,
+ NOT_DISCOVERABLE,
+ PRIMARY_1M,
+ PRIMARY_CODED,
+ SECONDARY_1M,
+ SECONDARY_2M,
+ SECONDARY_CODED,
+ SECONDARY_NONE,
+ AdvertiseRequest,
+ AdvertiseResponse,
+ Connection,
+ ConnectLERequest,
+ ConnectLEResponse,
+ ConnectRequest,
+ ConnectResponse,
+ DataTypes,
+ DisconnectRequest,
+ InquiryResponse,
+ PrimaryPhy,
+ ReadLocalAddressResponse,
+ ScanningResponse,
+ ScanRequest,
+ SecondaryPhy,
+ SetConnectabilityModeRequest,
+ SetDiscoverabilityModeRequest,
+ WaitConnectionRequest,
+ WaitConnectionResponse,
+ WaitDisconnectionRequest,
+)
+from typing import AsyncGenerator, Dict, List, Optional, Set, Tuple, cast
+
+PRIMARY_PHY_MAP: Dict[int, PrimaryPhy] = {
+ # Default value reported by Bumble for legacy Advertising reports.
+ # FIXME(uael): `None` might be a better value, but Bumble need to change accordingly.
+ 0: PRIMARY_1M,
+ 1: PRIMARY_1M,
+ 3: PRIMARY_CODED,
+}
+
+SECONDARY_PHY_MAP: Dict[int, SecondaryPhy] = {
+ 0: SECONDARY_NONE,
+ 1: SECONDARY_1M,
+ 2: SECONDARY_2M,
+ 3: SECONDARY_CODED,
+}
+
+
+class HostService(HostServicer):
+ waited_connections: Set[int]
+
+ def __init__(
+ self, grpc_server: grpc.aio.Server, device: Device, config: Config
+ ) -> None:
+ self.log = utils.BumbleServerLoggerAdapter(
+ logging.getLogger(), {'service_name': 'Host', 'device': device}
+ )
+ self.grpc_server = grpc_server
+ self.device = device
+ self.config = config
+ self.waited_connections = set()
+
+ @utils.rpc
+ async def FactoryReset(
+ self, request: empty_pb2.Empty, context: grpc.ServicerContext
+ ) -> empty_pb2.Empty:
+ self.log.info('FactoryReset')
+
+ # delete all bonds
+ if self.device.keystore is not None:
+ await self.device.keystore.delete_all()
+
+ # trigger gRCP server stop then return
+ asyncio.create_task(self.grpc_server.stop(None))
+ return empty_pb2.Empty()
+
+ @utils.rpc
+ async def Reset(
+ self, request: empty_pb2.Empty, context: grpc.ServicerContext
+ ) -> empty_pb2.Empty:
+ self.log.info('Reset')
+
+ # clear service.
+ self.waited_connections.clear()
+
+ # (re) power device on
+ await self.device.power_on()
+ return empty_pb2.Empty()
+
+ @utils.rpc
+ async def ReadLocalAddress(
+ self, request: empty_pb2.Empty, context: grpc.ServicerContext
+ ) -> ReadLocalAddressResponse:
+ self.log.info('ReadLocalAddress')
+ return ReadLocalAddressResponse(
+ address=bytes(reversed(bytes(self.device.public_address)))
+ )
+
+ @utils.rpc
+ async def Connect(
+ self, request: ConnectRequest, context: grpc.ServicerContext
+ ) -> ConnectResponse:
+ # Need to reverse bytes order since Bumble Address is using MSB.
+ address = Address(
+ bytes(reversed(request.address)), address_type=Address.PUBLIC_DEVICE_ADDRESS
+ )
+ self.log.info(f"Connect to {address}")
+
+ try:
+ connection = await self.device.connect(
+ address, transport=BT_BR_EDR_TRANSPORT
+ )
+ except ConnectionError as e:
+ if e.error_code == HCI_PAGE_TIMEOUT_ERROR:
+ self.log.warning(f"Peer not found: {e}")
+ return ConnectResponse(peer_not_found=empty_pb2.Empty())
+ if e.error_code == HCI_CONNECTION_ALREADY_EXISTS_ERROR:
+ self.log.warning(f"Connection already exists: {e}")
+ return ConnectResponse(connection_already_exists=empty_pb2.Empty())
+ raise e
+
+ self.log.info(f"Connect to {address} done (handle={connection.handle})")
+
+ cookie = any_pb2.Any(value=connection.handle.to_bytes(4, 'big'))
+ return ConnectResponse(connection=Connection(cookie=cookie))
+
+ @utils.rpc
+ async def WaitConnection(
+ self, request: WaitConnectionRequest, context: grpc.ServicerContext
+ ) -> WaitConnectionResponse:
+ if not request.address:
+ raise ValueError('Request address field must be set')
+
+ # Need to reverse bytes order since Bumble Address is using MSB.
+ address = Address(
+ bytes(reversed(request.address)), address_type=Address.PUBLIC_DEVICE_ADDRESS
+ )
+ if address in (Address.NIL, Address.ANY):
+ raise ValueError('Invalid address')
+
+ self.log.info(f"WaitConnection from {address}...")
+
+ connection = self.device.find_connection_by_bd_addr(
+ address, transport=BT_BR_EDR_TRANSPORT
+ )
+ if connection and id(connection) in self.waited_connections:
+ # this connection was already returned: wait for a new one.
+ connection = None
+
+ if not connection:
+ connection = await self.device.accept(address)
+
+ # save connection has waited and respond.
+ self.waited_connections.add(id(connection))
+
+ self.log.info(
+ f"WaitConnection from {address} done (handle={connection.handle})"
+ )
+
+ cookie = any_pb2.Any(value=connection.handle.to_bytes(4, 'big'))
+ return WaitConnectionResponse(connection=Connection(cookie=cookie))
+
+ @utils.rpc
+ async def ConnectLE(
+ self, request: ConnectLERequest, context: grpc.ServicerContext
+ ) -> ConnectLEResponse:
+ address = utils.address_from_request(request, request.WhichOneof("address"))
+ if address in (Address.NIL, Address.ANY):
+ raise ValueError('Invalid address')
+
+ self.log.info(f"ConnectLE to {address}...")
+
+ try:
+ connection = await self.device.connect(
+ address,
+ transport=BT_LE_TRANSPORT,
+ own_address_type=request.own_address_type,
+ )
+ except ConnectionError as e:
+ if e.error_code == HCI_PAGE_TIMEOUT_ERROR:
+ self.log.warning(f"Peer not found: {e}")
+ return ConnectLEResponse(peer_not_found=empty_pb2.Empty())
+ if e.error_code == HCI_CONNECTION_ALREADY_EXISTS_ERROR:
+ self.log.warning(f"Connection already exists: {e}")
+ return ConnectLEResponse(connection_already_exists=empty_pb2.Empty())
+ raise e
+
+ self.log.info(f"ConnectLE to {address} done (handle={connection.handle})")
+
+ cookie = any_pb2.Any(value=connection.handle.to_bytes(4, 'big'))
+ return ConnectLEResponse(connection=Connection(cookie=cookie))
+
+ @utils.rpc
+ async def Disconnect(
+ self, request: DisconnectRequest, context: grpc.ServicerContext
+ ) -> empty_pb2.Empty:
+ connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
+ self.log.info(f"Disconnect: {connection_handle}")
+
+ self.log.info("Disconnecting...")
+ if connection := self.device.lookup_connection(connection_handle):
+ await connection.disconnect(HCI_REMOTE_USER_TERMINATED_CONNECTION_ERROR)
+ self.log.info("Disconnected")
+
+ return empty_pb2.Empty()
+
+ @utils.rpc
+ async def WaitDisconnection(
+ self, request: WaitDisconnectionRequest, context: grpc.ServicerContext
+ ) -> empty_pb2.Empty:
+ connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
+ self.log.info(f"WaitDisconnection: {connection_handle}")
+
+ if connection := self.device.lookup_connection(connection_handle):
+ disconnection_future: asyncio.Future[
+ None
+ ] = asyncio.get_running_loop().create_future()
+
+ def on_disconnection(_: None) -> None:
+ disconnection_future.set_result(None)
+
+ connection.on('disconnection', on_disconnection)
+ try:
+ await disconnection_future
+ self.log.info("Disconnected")
+ finally:
+ connection.remove_listener('disconnection', on_disconnection) # type: ignore
+
+ return empty_pb2.Empty()
+
+ @utils.rpc
+ async def Advertise(
+ self, request: AdvertiseRequest, context: grpc.ServicerContext
+ ) -> AsyncGenerator[AdvertiseResponse, None]:
+ if not request.legacy:
+ raise NotImplementedError(
+ "TODO: add support for extended advertising in Bumble"
+ )
+ if request.interval:
+ raise NotImplementedError("TODO: add support for `request.interval`")
+ if request.interval_range:
+ raise NotImplementedError("TODO: add support for `request.interval_range`")
+ if request.primary_phy:
+ raise NotImplementedError("TODO: add support for `request.primary_phy`")
+ if request.secondary_phy:
+ raise NotImplementedError("TODO: add support for `request.secondary_phy`")
+
+ if self.device.is_advertising:
+ raise NotImplementedError('TODO: add support for advertising sets')
+
+ if data := request.data:
+ self.device.advertising_data = bytes(self.unpack_data_types(data))
+
+ if scan_response_data := request.scan_response_data:
+ self.device.scan_response_data = bytes(
+ self.unpack_data_types(scan_response_data)
+ )
+ scannable = True
+ else:
+ scannable = False
+
+ # Retrieve services data
+ for service in self.device.gatt_server.attributes:
+ if isinstance(service, Service) and (
+ service_data := service.get_advertising_data()
+ ):
+ service_uuid = service.uuid.to_hex_str('-')
+ if (
+ service_uuid in request.data.incomplete_service_class_uuids16
+ or service_uuid in request.data.complete_service_class_uuids16
+ or service_uuid in request.data.incomplete_service_class_uuids32
+ or service_uuid in request.data.complete_service_class_uuids32
+ or service_uuid
+ in request.data.incomplete_service_class_uuids128
+ or service_uuid in request.data.complete_service_class_uuids128
+ ):
+ self.device.advertising_data += service_data
+ if (
+ service_uuid
+ in scan_response_data.incomplete_service_class_uuids16
+ or service_uuid
+ in scan_response_data.complete_service_class_uuids16
+ or service_uuid
+ in scan_response_data.incomplete_service_class_uuids32
+ or service_uuid
+ in scan_response_data.complete_service_class_uuids32
+ or service_uuid
+ in scan_response_data.incomplete_service_class_uuids128
+ or service_uuid
+ in scan_response_data.complete_service_class_uuids128
+ ):
+ self.device.scan_response_data += service_data
+
+ target = None
+ if request.connectable and scannable:
+ advertising_type = AdvertisingType.UNDIRECTED_CONNECTABLE_SCANNABLE
+ elif scannable:
+ advertising_type = AdvertisingType.UNDIRECTED_SCANNABLE
+ else:
+ advertising_type = AdvertisingType.UNDIRECTED
+ else:
+ target = None
+ advertising_type = AdvertisingType.UNDIRECTED
+
+ if request.target:
+ # Need to reverse bytes order since Bumble Address is using MSB.
+ target_bytes = bytes(reversed(request.target))
+ if request.target_variant() == "public":
+ target = Address(target_bytes, Address.PUBLIC_DEVICE_ADDRESS)
+ advertising_type = (
+ AdvertisingType.DIRECTED_CONNECTABLE_HIGH_DUTY
+ ) # FIXME: HIGH_DUTY ?
+ else:
+ target = Address(target_bytes, Address.RANDOM_DEVICE_ADDRESS)
+ advertising_type = (
+ AdvertisingType.DIRECTED_CONNECTABLE_HIGH_DUTY
+ ) # FIXME: HIGH_DUTY ?
+
+ if request.connectable:
+
+ def on_connection(connection: bumble.device.Connection) -> None:
+ if (
+ connection.transport == BT_LE_TRANSPORT
+ and connection.role == BT_PERIPHERAL_ROLE
+ ):
+ pending_connection.set_result(connection)
+
+ self.device.on('connection', on_connection)
+
+ try:
+ while True:
+ if not self.device.is_advertising:
+ self.log.info('Advertise')
+ await self.device.start_advertising(
+ target=target,
+ advertising_type=advertising_type,
+ own_address_type=request.own_address_type,
+ )
+
+ if not request.connectable:
+ await asyncio.sleep(1)
+ continue
+
+ pending_connection: asyncio.Future[
+ bumble.device.Connection
+ ] = asyncio.get_running_loop().create_future()
+
+ self.log.info('Wait for LE connection...')
+ connection = await pending_connection
+
+ self.log.info(
+ f"Advertise: Connected to {connection.peer_address} (handle={connection.handle})"
+ )
+
+ cookie = any_pb2.Any(value=connection.handle.to_bytes(4, 'big'))
+ yield AdvertiseResponse(connection=Connection(cookie=cookie))
+
+ # wait a small delay before restarting the advertisement.
+ await asyncio.sleep(1)
+ finally:
+ if request.connectable:
+ self.device.remove_listener('connection', on_connection) # type: ignore
+
+ try:
+ self.log.info('Stop advertising')
+ await self.device.abort_on('flush', self.device.stop_advertising())
+ except:
+ pass
+
+ @utils.rpc
+ async def Scan(
+ self, request: ScanRequest, context: grpc.ServicerContext
+ ) -> AsyncGenerator[ScanningResponse, None]:
+ # TODO: modify `start_scanning` to accept floats instead of int for ms values
+ if request.phys:
+ raise NotImplementedError("TODO: add support for `request.phys`")
+
+ self.log.info('Scan')
+
+ scan_queue: asyncio.Queue[Advertisement] = asyncio.Queue()
+ handler = self.device.on('advertisement', scan_queue.put_nowait)
+ await self.device.start_scanning(
+ legacy=request.legacy,
+ active=not request.passive,
+ own_address_type=request.own_address_type,
+ scan_interval=int(request.interval)
+ if request.interval
+ else DEVICE_DEFAULT_SCAN_INTERVAL,
+ scan_window=int(request.window)
+ if request.window
+ else DEVICE_DEFAULT_SCAN_WINDOW,
+ )
+
+ try:
+ # TODO: add support for `direct_address` in Bumble
+ # TODO: add support for `periodic_advertising_interval` in Bumble
+ while adv := await scan_queue.get():
+ sr = ScanningResponse(
+ legacy=adv.is_legacy,
+ connectable=adv.is_connectable,
+ scannable=adv.is_scannable,
+ truncated=adv.is_truncated,
+ sid=adv.sid,
+ primary_phy=PRIMARY_PHY_MAP[adv.primary_phy],
+ secondary_phy=SECONDARY_PHY_MAP[adv.secondary_phy],
+ tx_power=adv.tx_power,
+ rssi=adv.rssi,
+ data=self.pack_data_types(adv.data),
+ )
+
+ if adv.address.address_type == Address.PUBLIC_DEVICE_ADDRESS:
+ sr.public = bytes(reversed(bytes(adv.address)))
+ elif adv.address.address_type == Address.RANDOM_DEVICE_ADDRESS:
+ sr.random = bytes(reversed(bytes(adv.address)))
+ elif adv.address.address_type == Address.PUBLIC_IDENTITY_ADDRESS:
+ sr.public_identity = bytes(reversed(bytes(adv.address)))
+ else:
+ sr.random_static_identity = bytes(reversed(bytes(adv.address)))
+
+ yield sr
+
+ finally:
+ self.device.remove_listener('advertisement', handler) # type: ignore
+ try:
+ self.log.info('Stop scanning')
+ await self.device.abort_on('flush', self.device.stop_scanning())
+ except:
+ pass
+
+ @utils.rpc
+ async def Inquiry(
+ self, request: empty_pb2.Empty, context: grpc.ServicerContext
+ ) -> AsyncGenerator[InquiryResponse, None]:
+ self.log.info('Inquiry')
+
+ inquiry_queue: asyncio.Queue[
+ Optional[Tuple[Address, int, AdvertisingData, int]]
+ ] = asyncio.Queue()
+ complete_handler = self.device.on(
+ 'inquiry_complete', lambda: inquiry_queue.put_nowait(None)
+ )
+ result_handler = self.device.on( # type: ignore
+ 'inquiry_result',
+ lambda address, class_of_device, eir_data, rssi: inquiry_queue.put_nowait( # type: ignore
+ (address, class_of_device, eir_data, rssi) # type: ignore
+ ),
+ )
+
+ await self.device.start_discovery(auto_restart=False)
+ try:
+ while inquiry_result := await inquiry_queue.get():
+ (address, class_of_device, eir_data, rssi) = inquiry_result
+ # FIXME: if needed, add support for `page_scan_repetition_mode` and `clock_offset` in Bumble
+ yield InquiryResponse(
+ address=bytes(reversed(bytes(address))),
+ class_of_device=class_of_device,
+ rssi=rssi,
+ data=self.pack_data_types(eir_data),
+ )
+
+ finally:
+ self.device.remove_listener('inquiry_complete', complete_handler) # type: ignore
+ self.device.remove_listener('inquiry_result', result_handler) # type: ignore
+ try:
+ self.log.info('Stop inquiry')
+ await self.device.abort_on('flush', self.device.stop_discovery())
+ except:
+ pass
+
+ @utils.rpc
+ async def SetDiscoverabilityMode(
+ self, request: SetDiscoverabilityModeRequest, context: grpc.ServicerContext
+ ) -> empty_pb2.Empty:
+ self.log.info("SetDiscoverabilityMode")
+ await self.device.set_discoverable(request.mode != NOT_DISCOVERABLE)
+ return empty_pb2.Empty()
+
+ @utils.rpc
+ async def SetConnectabilityMode(
+ self, request: SetConnectabilityModeRequest, context: grpc.ServicerContext
+ ) -> empty_pb2.Empty:
+ self.log.info("SetConnectabilityMode")
+ await self.device.set_connectable(request.mode != NOT_CONNECTABLE)
+ return empty_pb2.Empty()
+
+ def unpack_data_types(self, dt: DataTypes) -> AdvertisingData:
+ ad_structures: List[Tuple[int, bytes]] = []
+
+ uuids: List[str]
+ datas: Dict[str, bytes]
+
+ def uuid128_from_str(uuid: str) -> bytes:
+ """Decode a 128-bit uuid encoded as XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
+ to byte format."""
+ return bytes(reversed(bytes.fromhex(uuid.replace('-', ''))))
+
+ def uuid32_from_str(uuid: str) -> bytes:
+ """Decode a 32-bit uuid encoded as XXXXXXXX to byte format."""
+ return bytes(reversed(bytes.fromhex(uuid)))
+
+ def uuid16_from_str(uuid: str) -> bytes:
+ """Decode a 16-bit uuid encoded as XXXX to byte format."""
+ return bytes(reversed(bytes.fromhex(uuid)))
+
+ if uuids := dt.incomplete_service_class_uuids16:
+ ad_structures.append(
+ (
+ AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
+ b''.join([uuid16_from_str(uuid) for uuid in uuids]),
+ )
+ )
+ if uuids := dt.complete_service_class_uuids16:
+ ad_structures.append(
+ (
+ AdvertisingData.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS,
+ b''.join([uuid16_from_str(uuid) for uuid in uuids]),
+ )
+ )
+ if uuids := dt.incomplete_service_class_uuids32:
+ ad_structures.append(
+ (
+ AdvertisingData.INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS,
+ b''.join([uuid32_from_str(uuid) for uuid in uuids]),
+ )
+ )
+ if uuids := dt.complete_service_class_uuids32:
+ ad_structures.append(
+ (
+ AdvertisingData.COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS,
+ b''.join([uuid32_from_str(uuid) for uuid in uuids]),
+ )
+ )
+ if uuids := dt.incomplete_service_class_uuids128:
+ ad_structures.append(
+ (
+ AdvertisingData.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS,
+ b''.join([uuid128_from_str(uuid) for uuid in uuids]),
+ )
+ )
+ if uuids := dt.complete_service_class_uuids128:
+ ad_structures.append(
+ (
+ AdvertisingData.COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS,
+ b''.join([uuid128_from_str(uuid) for uuid in uuids]),
+ )
+ )
+ if dt.HasField('include_shortened_local_name'):
+ ad_structures.append(
+ (
+ AdvertisingData.SHORTENED_LOCAL_NAME,
+ bytes(self.device.name[:8], 'utf-8'),
+ )
+ )
+ elif dt.shortened_local_name:
+ ad_structures.append(
+ (
+ AdvertisingData.SHORTENED_LOCAL_NAME,
+ bytes(dt.shortened_local_name, 'utf-8'),
+ )
+ )
+ if dt.HasField('include_complete_local_name'):
+ ad_structures.append(
+ (AdvertisingData.COMPLETE_LOCAL_NAME, bytes(self.device.name, 'utf-8'))
+ )
+ elif dt.complete_local_name:
+ ad_structures.append(
+ (
+ AdvertisingData.COMPLETE_LOCAL_NAME,
+ bytes(dt.complete_local_name, 'utf-8'),
+ )
+ )
+ if dt.HasField('include_tx_power_level'):
+ raise ValueError('unsupported data type')
+ elif dt.tx_power_level:
+ ad_structures.append(
+ (
+ AdvertisingData.TX_POWER_LEVEL,
+ bytes(struct.pack('<I', dt.tx_power_level)[:1]),
+ )
+ )
+ if dt.HasField('include_class_of_device'):
+ ad_structures.append(
+ (
+ AdvertisingData.CLASS_OF_DEVICE,
+ bytes(struct.pack('<I', self.device.class_of_device)[:-1]),
+ )
+ )
+ elif dt.class_of_device:
+ ad_structures.append(
+ (
+ AdvertisingData.CLASS_OF_DEVICE,
+ bytes(struct.pack('<I', dt.class_of_device)[:-1]),
+ )
+ )
+ if dt.peripheral_connection_interval_min:
+ ad_structures.append(
+ (
+ AdvertisingData.PERIPHERAL_CONNECTION_INTERVAL_RANGE,
+ bytes(
+ [
+ *struct.pack('<H', dt.peripheral_connection_interval_min),
+ *struct.pack(
+ '<H',
+ dt.peripheral_connection_interval_max
+ if dt.peripheral_connection_interval_max
+ else dt.peripheral_connection_interval_min,
+ ),
+ ]
+ ),
+ )
+ )
+ if uuids := dt.service_solicitation_uuids16:
+ ad_structures.append(
+ (
+ AdvertisingData.LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS,
+ b''.join([uuid16_from_str(uuid) for uuid in uuids]),
+ )
+ )
+ if uuids := dt.service_solicitation_uuids32:
+ ad_structures.append(
+ (
+ AdvertisingData.LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS,
+ b''.join([uuid32_from_str(uuid) for uuid in uuids]),
+ )
+ )
+ if uuids := dt.service_solicitation_uuids128:
+ ad_structures.append(
+ (
+ AdvertisingData.LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS,
+ b''.join([uuid128_from_str(uuid) for uuid in uuids]),
+ )
+ )
+ if datas := dt.service_data_uuid16:
+ ad_structures.extend(
+ [
+ (
+ AdvertisingData.SERVICE_DATA_16_BIT_UUID,
+ uuid16_from_str(uuid) + data,
+ )
+ for uuid, data in datas.items()
+ ]
+ )
+ if datas := dt.service_data_uuid32:
+ ad_structures.extend(
+ [
+ (
+ AdvertisingData.SERVICE_DATA_32_BIT_UUID,
+ uuid32_from_str(uuid) + data,
+ )
+ for uuid, data in datas.items()
+ ]
+ )
+ if datas := dt.service_data_uuid128:
+ ad_structures.extend(
+ [
+ (
+ AdvertisingData.SERVICE_DATA_128_BIT_UUID,
+ uuid128_from_str(uuid) + data,
+ )
+ for uuid, data in datas.items()
+ ]
+ )
+ if dt.appearance:
+ ad_structures.append(
+ (AdvertisingData.APPEARANCE, struct.pack('<H', dt.appearance))
+ )
+ if dt.advertising_interval:
+ ad_structures.append(
+ (
+ AdvertisingData.ADVERTISING_INTERVAL,
+ struct.pack('<H', dt.advertising_interval),
+ )
+ )
+ if dt.uri:
+ ad_structures.append((AdvertisingData.URI, bytes(dt.uri, 'utf-8')))
+ if dt.le_supported_features:
+ ad_structures.append(
+ (AdvertisingData.LE_SUPPORTED_FEATURES, dt.le_supported_features)
+ )
+ if dt.manufacturer_specific_data:
+ ad_structures.append(
+ (
+ AdvertisingData.MANUFACTURER_SPECIFIC_DATA,
+ dt.manufacturer_specific_data,
+ )
+ )
+
+ return AdvertisingData(ad_structures)
+
+ def pack_data_types(self, ad: AdvertisingData) -> DataTypes:
+ dt = DataTypes()
+ uuids: List[UUID]
+ s: str
+ i: int
+ ij: Tuple[int, int]
+ uuid_data: Tuple[UUID, bytes]
+ data: bytes
+
+ if uuids := cast(
+ List[UUID],
+ ad.get(AdvertisingData.INCOMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS),
+ ):
+ dt.incomplete_service_class_uuids16.extend(
+ list(map(lambda x: x.to_hex_str('-'), uuids))
+ )
+ if uuids := cast(
+ List[UUID],
+ ad.get(AdvertisingData.COMPLETE_LIST_OF_16_BIT_SERVICE_CLASS_UUIDS),
+ ):
+ dt.complete_service_class_uuids16.extend(
+ list(map(lambda x: x.to_hex_str('-'), uuids))
+ )
+ if uuids := cast(
+ List[UUID],
+ ad.get(AdvertisingData.INCOMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS),
+ ):
+ dt.incomplete_service_class_uuids32.extend(
+ list(map(lambda x: x.to_hex_str('-'), uuids))
+ )
+ if uuids := cast(
+ List[UUID],
+ ad.get(AdvertisingData.COMPLETE_LIST_OF_32_BIT_SERVICE_CLASS_UUIDS),
+ ):
+ dt.complete_service_class_uuids32.extend(
+ list(map(lambda x: x.to_hex_str('-'), uuids))
+ )
+ if uuids := cast(
+ List[UUID],
+ ad.get(AdvertisingData.INCOMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS),
+ ):
+ dt.incomplete_service_class_uuids128.extend(
+ list(map(lambda x: x.to_hex_str('-'), uuids))
+ )
+ if uuids := cast(
+ List[UUID],
+ ad.get(AdvertisingData.COMPLETE_LIST_OF_128_BIT_SERVICE_CLASS_UUIDS),
+ ):
+ dt.complete_service_class_uuids128.extend(
+ list(map(lambda x: x.to_hex_str('-'), uuids))
+ )
+ if s := cast(str, ad.get(AdvertisingData.SHORTENED_LOCAL_NAME)):
+ dt.shortened_local_name = s
+ if s := cast(str, ad.get(AdvertisingData.COMPLETE_LOCAL_NAME)):
+ dt.complete_local_name = s
+ if i := cast(int, ad.get(AdvertisingData.TX_POWER_LEVEL)):
+ dt.tx_power_level = i
+ if i := cast(int, ad.get(AdvertisingData.CLASS_OF_DEVICE)):
+ dt.class_of_device = i
+ if ij := cast(
+ Tuple[int, int],
+ ad.get(AdvertisingData.PERIPHERAL_CONNECTION_INTERVAL_RANGE),
+ ):
+ dt.peripheral_connection_interval_min = ij[0]
+ dt.peripheral_connection_interval_max = ij[1]
+ if uuids := cast(
+ List[UUID],
+ ad.get(AdvertisingData.LIST_OF_16_BIT_SERVICE_SOLICITATION_UUIDS),
+ ):
+ dt.service_solicitation_uuids16.extend(
+ list(map(lambda x: x.to_hex_str('-'), uuids))
+ )
+ if uuids := cast(
+ List[UUID],
+ ad.get(AdvertisingData.LIST_OF_32_BIT_SERVICE_SOLICITATION_UUIDS),
+ ):
+ dt.service_solicitation_uuids32.extend(
+ list(map(lambda x: x.to_hex_str('-'), uuids))
+ )
+ if uuids := cast(
+ List[UUID],
+ ad.get(AdvertisingData.LIST_OF_128_BIT_SERVICE_SOLICITATION_UUIDS),
+ ):
+ dt.service_solicitation_uuids128.extend(
+ list(map(lambda x: x.to_hex_str('-'), uuids))
+ )
+ if uuid_data := cast(
+ Tuple[UUID, bytes], ad.get(AdvertisingData.SERVICE_DATA_16_BIT_UUID)
+ ):
+ dt.service_data_uuid16[uuid_data[0].to_hex_str('-')] = uuid_data[1]
+ if uuid_data := cast(
+ Tuple[UUID, bytes], ad.get(AdvertisingData.SERVICE_DATA_32_BIT_UUID)
+ ):
+ dt.service_data_uuid32[uuid_data[0].to_hex_str('-')] = uuid_data[1]
+ if uuid_data := cast(
+ Tuple[UUID, bytes], ad.get(AdvertisingData.SERVICE_DATA_128_BIT_UUID)
+ ):
+ dt.service_data_uuid128[uuid_data[0].to_hex_str('-')] = uuid_data[1]
+ if data := cast(bytes, ad.get(AdvertisingData.PUBLIC_TARGET_ADDRESS, raw=True)):
+ dt.public_target_addresses.extend(
+ [data[i * 6 :: i * 6 + 6] for i in range(int(len(data) / 6))]
+ )
+ if data := cast(bytes, ad.get(AdvertisingData.RANDOM_TARGET_ADDRESS, raw=True)):
+ dt.random_target_addresses.extend(
+ [data[i * 6 :: i * 6 + 6] for i in range(int(len(data) / 6))]
+ )
+ if i := cast(int, ad.get(AdvertisingData.APPEARANCE)):
+ dt.appearance = i
+ if i := cast(int, ad.get(AdvertisingData.ADVERTISING_INTERVAL)):
+ dt.advertising_interval = i
+ if s := cast(str, ad.get(AdvertisingData.URI)):
+ dt.uri = s
+ if data := cast(bytes, ad.get(AdvertisingData.LE_SUPPORTED_FEATURES, raw=True)):
+ dt.le_supported_features = data
+ if data := cast(
+ bytes, ad.get(AdvertisingData.MANUFACTURER_SPECIFIC_DATA, raw=True)
+ ):
+ dt.manufacturer_specific_data = data
+
+ return dt
diff --git a/bumble/pandora/py.typed b/bumble/pandora/py.typed
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/bumble/pandora/py.typed
diff --git a/bumble/pandora/security.py b/bumble/pandora/security.py
new file mode 100644
index 0000000..fee1b7a
--- /dev/null
+++ b/bumble/pandora/security.py
@@ -0,0 +1,529 @@
+# Copyright 2022 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import asyncio
+import grpc
+import logging
+
+from . import utils
+from .config import Config
+from bumble import hci
+from bumble.core import (
+ BT_BR_EDR_TRANSPORT,
+ BT_LE_TRANSPORT,
+ BT_PERIPHERAL_ROLE,
+ ProtocolError,
+)
+from bumble.device import Connection as BumbleConnection, Device
+from bumble.hci import HCI_Error
+from bumble.pairing import PairingConfig, PairingDelegate as BasePairingDelegate
+from contextlib import suppress
+from google.protobuf import (
+ any_pb2,
+ empty_pb2,
+ wrappers_pb2,
+) # pytype: disable=pyi-error
+from google.protobuf.wrappers_pb2 import BoolValue # pytype: disable=pyi-error
+from pandora.host_pb2 import Connection
+from pandora.security_grpc_aio import SecurityServicer, SecurityStorageServicer
+from pandora.security_pb2 import (
+ LE_LEVEL1,
+ LE_LEVEL2,
+ LE_LEVEL3,
+ LE_LEVEL4,
+ LEVEL0,
+ LEVEL1,
+ LEVEL2,
+ LEVEL3,
+ LEVEL4,
+ DeleteBondRequest,
+ IsBondedRequest,
+ LESecurityLevel,
+ PairingEvent,
+ PairingEventAnswer,
+ SecureRequest,
+ SecureResponse,
+ SecurityLevel,
+ WaitSecurityRequest,
+ WaitSecurityResponse,
+)
+from typing import Any, AsyncGenerator, AsyncIterator, Callable, Dict, Optional, Union
+
+
+class PairingDelegate(BasePairingDelegate):
+ def __init__(
+ self,
+ connection: BumbleConnection,
+ service: "SecurityService",
+ io_capability: BasePairingDelegate.IoCapability = BasePairingDelegate.NO_OUTPUT_NO_INPUT,
+ local_initiator_key_distribution: BasePairingDelegate.KeyDistribution = BasePairingDelegate.DEFAULT_KEY_DISTRIBUTION,
+ local_responder_key_distribution: BasePairingDelegate.KeyDistribution = BasePairingDelegate.DEFAULT_KEY_DISTRIBUTION,
+ ) -> None:
+ self.log = utils.BumbleServerLoggerAdapter(
+ logging.getLogger(),
+ {'service_name': 'Security', 'device': connection.device},
+ )
+ self.connection = connection
+ self.service = service
+ super().__init__(
+ io_capability,
+ local_initiator_key_distribution,
+ local_responder_key_distribution,
+ )
+
+ async def accept(self) -> bool:
+ return True
+
+ def add_origin(self, ev: PairingEvent) -> PairingEvent:
+ if not self.connection.is_incomplete:
+ assert ev.connection
+ ev.connection.CopyFrom(
+ Connection(
+ cookie=any_pb2.Any(value=self.connection.handle.to_bytes(4, 'big'))
+ )
+ )
+ else:
+ # In BR/EDR, connection may not be complete,
+ # use address instead
+ assert self.connection.transport == BT_BR_EDR_TRANSPORT
+ ev.address = bytes(reversed(bytes(self.connection.peer_address)))
+
+ return ev
+
+ async def confirm(self, auto: bool = False) -> bool:
+ self.log.info(
+ f"Pairing event: `just_works` (io_capability: {self.io_capability})"
+ )
+
+ if self.service.event_queue is None or self.service.event_answer is None:
+ return True
+
+ event = self.add_origin(PairingEvent(just_works=empty_pb2.Empty()))
+ self.service.event_queue.put_nowait(event)
+ answer = await anext(self.service.event_answer) # pytype: disable=name-error
+ assert answer.event == event
+ assert answer.answer_variant() == 'confirm' and answer.confirm is not None
+ return answer.confirm
+
+ async def compare_numbers(self, number: int, digits: int = 6) -> bool:
+ self.log.info(
+ f"Pairing event: `numeric_comparison` (io_capability: {self.io_capability})"
+ )
+
+ if self.service.event_queue is None or self.service.event_answer is None:
+ raise RuntimeError('security: unhandled number comparison request')
+
+ event = self.add_origin(PairingEvent(numeric_comparison=number))
+ self.service.event_queue.put_nowait(event)
+ answer = await anext(self.service.event_answer) # pytype: disable=name-error
+ assert answer.event == event
+ assert answer.answer_variant() == 'confirm' and answer.confirm is not None
+ return answer.confirm
+
+ async def get_number(self) -> Optional[int]:
+ self.log.info(
+ f"Pairing event: `passkey_entry_request` (io_capability: {self.io_capability})"
+ )
+
+ if self.service.event_queue is None or self.service.event_answer is None:
+ raise RuntimeError('security: unhandled number request')
+
+ event = self.add_origin(PairingEvent(passkey_entry_request=empty_pb2.Empty()))
+ self.service.event_queue.put_nowait(event)
+ answer = await anext(self.service.event_answer) # pytype: disable=name-error
+ assert answer.event == event
+ if answer.answer_variant() is None:
+ return None
+ assert answer.answer_variant() == 'passkey'
+ return answer.passkey
+
+ async def get_string(self, max_length: int) -> Optional[str]:
+ self.log.info(
+ f"Pairing event: `pin_code_request` (io_capability: {self.io_capability})"
+ )
+
+ if self.service.event_queue is None or self.service.event_answer is None:
+ raise RuntimeError('security: unhandled pin_code request')
+
+ event = self.add_origin(PairingEvent(pin_code_request=empty_pb2.Empty()))
+ self.service.event_queue.put_nowait(event)
+ answer = await anext(self.service.event_answer) # pytype: disable=name-error
+ assert answer.event == event
+ if answer.answer_variant() is None:
+ return None
+ assert answer.answer_variant() == 'pin'
+
+ if answer.pin is None:
+ return None
+
+ pin = answer.pin.decode('utf-8')
+ if not pin or len(pin) > max_length:
+ raise ValueError(f'Pin must be utf-8 encoded up to {max_length} bytes')
+
+ return pin
+
+ async def display_number(self, number: int, digits: int = 6) -> None:
+ if (
+ self.connection.transport == BT_BR_EDR_TRANSPORT
+ and self.io_capability == BasePairingDelegate.DISPLAY_OUTPUT_ONLY
+ ):
+ return
+
+ self.log.info(
+ f"Pairing event: `passkey_entry_notification` (io_capability: {self.io_capability})"
+ )
+
+ if self.service.event_queue is None:
+ raise RuntimeError('security: unhandled number display request')
+
+ event = self.add_origin(PairingEvent(passkey_entry_notification=number))
+ self.service.event_queue.put_nowait(event)
+
+
+BR_LEVEL_REACHED: Dict[SecurityLevel, Callable[[BumbleConnection], bool]] = {
+ LEVEL0: lambda connection: True,
+ LEVEL1: lambda connection: connection.encryption == 0 or connection.authenticated,
+ LEVEL2: lambda connection: connection.encryption != 0 and connection.authenticated,
+ LEVEL3: lambda connection: connection.encryption != 0
+ and connection.authenticated
+ and connection.link_key_type
+ in (
+ hci.HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_192_TYPE,
+ hci.HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE,
+ ),
+ LEVEL4: lambda connection: connection.encryption
+ == hci.HCI_Encryption_Change_Event.AES_CCM
+ and connection.authenticated
+ and connection.link_key_type
+ == hci.HCI_AUTHENTICATED_COMBINATION_KEY_GENERATED_FROM_P_256_TYPE,
+}
+
+LE_LEVEL_REACHED: Dict[LESecurityLevel, Callable[[BumbleConnection], bool]] = {
+ LE_LEVEL1: lambda connection: True,
+ LE_LEVEL2: lambda connection: connection.encryption != 0,
+ LE_LEVEL3: lambda connection: connection.encryption != 0
+ and connection.authenticated,
+ LE_LEVEL4: lambda connection: connection.encryption != 0
+ and connection.authenticated
+ and connection.sc,
+}
+
+
+class SecurityService(SecurityServicer):
+ def __init__(self, device: Device, config: Config) -> None:
+ self.log = utils.BumbleServerLoggerAdapter(
+ logging.getLogger(), {'service_name': 'Security', 'device': device}
+ )
+ self.event_queue: Optional[asyncio.Queue[PairingEvent]] = None
+ self.event_answer: Optional[AsyncIterator[PairingEventAnswer]] = None
+ self.device = device
+ self.config = config
+
+ def pairing_config_factory(connection: BumbleConnection) -> PairingConfig:
+ return PairingConfig(
+ sc=config.pairing_sc_enable,
+ mitm=config.pairing_mitm_enable,
+ bonding=config.pairing_bonding_enable,
+ delegate=PairingDelegate(
+ connection,
+ self,
+ io_capability=config.io_capability,
+ local_initiator_key_distribution=config.smp_local_initiator_key_distribution,
+ local_responder_key_distribution=config.smp_local_responder_key_distribution,
+ ),
+ )
+
+ self.device.pairing_config_factory = pairing_config_factory
+
+ @utils.rpc
+ async def OnPairing(
+ self, request: AsyncIterator[PairingEventAnswer], context: grpc.ServicerContext
+ ) -> AsyncGenerator[PairingEvent, None]:
+ self.log.info('OnPairing')
+
+ if self.event_queue is not None:
+ raise RuntimeError('already streaming pairing events')
+
+ if len(self.device.connections):
+ raise RuntimeError(
+ 'the `OnPairing` method shall be initiated before establishing any connections.'
+ )
+
+ self.event_queue = asyncio.Queue()
+ self.event_answer = request
+
+ try:
+ while event := await self.event_queue.get():
+ yield event
+
+ finally:
+ self.event_queue = None
+ self.event_answer = None
+
+ @utils.rpc
+ async def Secure(
+ self, request: SecureRequest, context: grpc.ServicerContext
+ ) -> SecureResponse:
+ connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
+ self.log.info(f"Secure: {connection_handle}")
+
+ connection = self.device.lookup_connection(connection_handle)
+ assert connection
+
+ oneof = request.WhichOneof('level')
+ level = getattr(request, oneof)
+ assert {BT_BR_EDR_TRANSPORT: 'classic', BT_LE_TRANSPORT: 'le'}[
+ connection.transport
+ ] == oneof
+
+ # security level already reached
+ if self.reached_security_level(connection, level):
+ return SecureResponse(success=empty_pb2.Empty())
+
+ # trigger pairing if needed
+ if self.need_pairing(connection, level):
+ try:
+ self.log.info('Pair...')
+
+ if (
+ connection.transport == BT_LE_TRANSPORT
+ and connection.role == BT_PERIPHERAL_ROLE
+ ):
+ wait_for_security: asyncio.Future[
+ bool
+ ] = asyncio.get_running_loop().create_future()
+ connection.on("pairing", lambda *_: wait_for_security.set_result(True)) # type: ignore
+ connection.on("pairing_failure", wait_for_security.set_exception)
+
+ connection.request_pairing()
+
+ await wait_for_security
+ else:
+ await connection.pair()
+
+ self.log.info('Paired')
+ except asyncio.CancelledError:
+ self.log.warning("Connection died during encryption")
+ return SecureResponse(connection_died=empty_pb2.Empty())
+ except (HCI_Error, ProtocolError) as e:
+ self.log.warning(f"Pairing failure: {e}")
+ return SecureResponse(pairing_failure=empty_pb2.Empty())
+
+ # trigger authentication if needed
+ if self.need_authentication(connection, level):
+ try:
+ self.log.info('Authenticate...')
+ await connection.authenticate()
+ self.log.info('Authenticated')
+ except asyncio.CancelledError:
+ self.log.warning("Connection died during authentication")
+ return SecureResponse(connection_died=empty_pb2.Empty())
+ except (HCI_Error, ProtocolError) as e:
+ self.log.warning(f"Authentication failure: {e}")
+ return SecureResponse(authentication_failure=empty_pb2.Empty())
+
+ # trigger encryption if needed
+ if self.need_encryption(connection, level):
+ try:
+ self.log.info('Encrypt...')
+ await connection.encrypt()
+ self.log.info('Encrypted')
+ except asyncio.CancelledError:
+ self.log.warning("Connection died during encryption")
+ return SecureResponse(connection_died=empty_pb2.Empty())
+ except (HCI_Error, ProtocolError) as e:
+ self.log.warning(f"Encryption failure: {e}")
+ return SecureResponse(encryption_failure=empty_pb2.Empty())
+
+ # security level has been reached ?
+ if self.reached_security_level(connection, level):
+ return SecureResponse(success=empty_pb2.Empty())
+ return SecureResponse(not_reached=empty_pb2.Empty())
+
+ @utils.rpc
+ async def WaitSecurity(
+ self, request: WaitSecurityRequest, context: grpc.ServicerContext
+ ) -> WaitSecurityResponse:
+ connection_handle = int.from_bytes(request.connection.cookie.value, 'big')
+ self.log.info(f"WaitSecurity: {connection_handle}")
+
+ connection = self.device.lookup_connection(connection_handle)
+ assert connection
+
+ assert request.level
+ level = request.level
+ assert {BT_BR_EDR_TRANSPORT: 'classic', BT_LE_TRANSPORT: 'le'}[
+ connection.transport
+ ] == request.level_variant()
+
+ wait_for_security: asyncio.Future[
+ str
+ ] = asyncio.get_running_loop().create_future()
+ authenticate_task: Optional[asyncio.Future[None]] = None
+
+ async def authenticate() -> None:
+ assert connection
+ if (encryption := connection.encryption) != 0:
+ self.log.debug('Disable encryption...')
+ try:
+ await connection.encrypt(enable=False)
+ except:
+ pass
+ self.log.debug('Disable encryption: done')
+
+ self.log.debug('Authenticate...')
+ await connection.authenticate()
+ self.log.debug('Authenticate: done')
+
+ if encryption != 0 and connection.encryption != encryption:
+ self.log.debug('Re-enable encryption...')
+ await connection.encrypt()
+ self.log.debug('Re-enable encryption: done')
+
+ def set_failure(name: str) -> Callable[..., None]:
+ def wrapper(*args: Any) -> None:
+ self.log.info(f'Wait for security: error `{name}`: {args}')
+ wait_for_security.set_result(name)
+
+ return wrapper
+
+ def try_set_success(*_: Any) -> None:
+ assert connection
+ if self.reached_security_level(connection, level):
+ self.log.info('Wait for security: done')
+ wait_for_security.set_result('success')
+
+ def on_encryption_change(*_: Any) -> None:
+ assert connection
+ if self.reached_security_level(connection, level):
+ self.log.info('Wait for security: done')
+ wait_for_security.set_result('success')
+ elif (
+ connection.transport == BT_BR_EDR_TRANSPORT
+ and self.need_authentication(connection, level)
+ ):
+ nonlocal authenticate_task
+ if authenticate_task is None:
+ authenticate_task = asyncio.create_task(authenticate())
+
+ listeners: Dict[str, Callable[..., None]] = {
+ 'disconnection': set_failure('connection_died'),
+ 'pairing_failure': set_failure('pairing_failure'),
+ 'connection_authentication_failure': set_failure('authentication_failure'),
+ 'connection_encryption_failure': set_failure('encryption_failure'),
+ 'pairing': try_set_success,
+ 'connection_authentication': try_set_success,
+ 'connection_encryption_change': on_encryption_change,
+ }
+
+ # register event handlers
+ for event, listener in listeners.items():
+ connection.on(event, listener)
+
+ # security level already reached
+ if self.reached_security_level(connection, level):
+ return WaitSecurityResponse(success=empty_pb2.Empty())
+
+ self.log.info('Wait for security...')
+ kwargs = {}
+ kwargs[await wait_for_security] = empty_pb2.Empty()
+
+ # remove event handlers
+ for event, listener in listeners.items():
+ connection.remove_listener(event, listener) # type: ignore
+
+ # wait for `authenticate` to finish if any
+ if authenticate_task is not None:
+ self.log.info('Wait for authentication...')
+ try:
+ await authenticate_task # type: ignore
+ except:
+ pass
+ self.log.info('Authenticated')
+
+ return WaitSecurityResponse(**kwargs)
+
+ def reached_security_level(
+ self, connection: BumbleConnection, level: Union[SecurityLevel, LESecurityLevel]
+ ) -> bool:
+ self.log.debug(
+ str(
+ {
+ 'level': level,
+ 'encryption': connection.encryption,
+ 'authenticated': connection.authenticated,
+ 'sc': connection.sc,
+ 'link_key_type': connection.link_key_type,
+ }
+ )
+ )
+
+ if isinstance(level, LESecurityLevel):
+ return LE_LEVEL_REACHED[level](connection)
+
+ return BR_LEVEL_REACHED[level](connection)
+
+ def need_pairing(self, connection: BumbleConnection, level: int) -> bool:
+ if connection.transport == BT_LE_TRANSPORT:
+ return level >= LE_LEVEL3 and not connection.authenticated
+ return False
+
+ def need_authentication(self, connection: BumbleConnection, level: int) -> bool:
+ if connection.transport == BT_LE_TRANSPORT:
+ return False
+ if level == LEVEL2 and connection.encryption != 0:
+ return not connection.authenticated
+ return level >= LEVEL2 and not connection.authenticated
+
+ def need_encryption(self, connection: BumbleConnection, level: int) -> bool:
+ # TODO(abel): need to support MITM
+ if connection.transport == BT_LE_TRANSPORT:
+ return level == LE_LEVEL2 and not connection.encryption
+ return level >= LEVEL2 and not connection.encryption
+
+
+class SecurityStorageService(SecurityStorageServicer):
+ def __init__(self, device: Device, config: Config) -> None:
+ self.log = utils.BumbleServerLoggerAdapter(
+ logging.getLogger(), {'service_name': 'SecurityStorage', 'device': device}
+ )
+ self.device = device
+ self.config = config
+
+ @utils.rpc
+ async def IsBonded(
+ self, request: IsBondedRequest, context: grpc.ServicerContext
+ ) -> wrappers_pb2.BoolValue:
+ address = utils.address_from_request(request, request.WhichOneof("address"))
+ self.log.info(f"IsBonded: {address}")
+
+ if self.device.keystore is not None:
+ is_bonded = await self.device.keystore.get(str(address)) is not None
+ else:
+ is_bonded = False
+
+ return BoolValue(value=is_bonded)
+
+ @utils.rpc
+ async def DeleteBond(
+ self, request: DeleteBondRequest, context: grpc.ServicerContext
+ ) -> empty_pb2.Empty:
+ address = utils.address_from_request(request, request.WhichOneof("address"))
+ self.log.info(f"DeleteBond: {address}")
+
+ if self.device.keystore is not None:
+ with suppress(KeyError):
+ await self.device.keystore.delete(str(address))
+
+ return empty_pb2.Empty()
diff --git a/bumble/pandora/utils.py b/bumble/pandora/utils.py
new file mode 100644
index 0000000..c07a5bc
--- /dev/null
+++ b/bumble/pandora/utils.py
@@ -0,0 +1,112 @@
+# Copyright 2022 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import contextlib
+import functools
+import grpc
+import inspect
+import logging
+
+from bumble.device import Device
+from bumble.hci import Address
+from google.protobuf.message import Message # pytype: disable=pyi-error
+from typing import Any, Dict, Generator, MutableMapping, Optional, Tuple
+
+ADDRESS_TYPES: Dict[str, int] = {
+ "public": Address.PUBLIC_DEVICE_ADDRESS,
+ "random": Address.RANDOM_DEVICE_ADDRESS,
+ "public_identity": Address.PUBLIC_IDENTITY_ADDRESS,
+ "random_static_identity": Address.RANDOM_IDENTITY_ADDRESS,
+}
+
+
+def address_from_request(request: Message, field: Optional[str]) -> Address:
+ if field is None:
+ return Address.ANY
+ return Address(bytes(reversed(getattr(request, field))), ADDRESS_TYPES[field])
+
+
+class BumbleServerLoggerAdapter(logging.LoggerAdapter): # type: ignore
+ """Formats logs from the PandoraClient."""
+
+ def process(
+ self, msg: str, kwargs: MutableMapping[str, Any]
+ ) -> Tuple[str, MutableMapping[str, Any]]:
+ assert self.extra
+ service_name = self.extra['service_name']
+ assert isinstance(service_name, str)
+ device = self.extra['device']
+ assert isinstance(device, Device)
+ addr_bytes = bytes(
+ reversed(bytes(device.public_address))
+ ) # pytype: disable=attribute-error
+ addr = ':'.join([f'{x:02X}' for x in addr_bytes[4:]])
+ return (f'[bumble.{service_name}:{addr}] {msg}', kwargs)
+
+
+@contextlib.contextmanager
+def exception_to_rpc_error(
+ context: grpc.ServicerContext,
+) -> Generator[None, None, None]:
+ try:
+ yield None
+ except NotImplementedError as e:
+ context.set_code(grpc.StatusCode.UNIMPLEMENTED) # type: ignore
+ context.set_details(str(e)) # type: ignore
+ except ValueError as e:
+ context.set_code(grpc.StatusCode.INVALID_ARGUMENT) # type: ignore
+ context.set_details(str(e)) # type: ignore
+ except RuntimeError as e:
+ context.set_code(grpc.StatusCode.ABORTED) # type: ignore
+ context.set_details(str(e)) # type: ignore
+
+
+# Decorate an RPC servicer method with a wrapper that transform exceptions to gRPC errors.
+def rpc(func: Any) -> Any:
+ @functools.wraps(func)
+ async def asyncgen_wrapper(
+ self: Any, request: Any, context: grpc.ServicerContext
+ ) -> Any:
+ with exception_to_rpc_error(context):
+ async for v in func(self, request, context):
+ yield v
+
+ @functools.wraps(func)
+ async def async_wrapper(
+ self: Any, request: Any, context: grpc.ServicerContext
+ ) -> Any:
+ with exception_to_rpc_error(context):
+ return await func(self, request, context)
+
+ @functools.wraps(func)
+ def gen_wrapper(self: Any, request: Any, context: grpc.ServicerContext) -> Any:
+ with exception_to_rpc_error(context):
+ for v in func(self, request, context):
+ yield v
+
+ @functools.wraps(func)
+ def wrapper(self: Any, request: Any, context: grpc.ServicerContext) -> Any:
+ with exception_to_rpc_error(context):
+ return func(self, request, context)
+
+ if inspect.isasyncgenfunction(func):
+ return asyncgen_wrapper
+
+ if inspect.iscoroutinefunction(func):
+ return async_wrapper
+
+ if inspect.isgenerator(func):
+ return gen_wrapper
+
+ return wrapper
diff --git a/bumble/profiles/asha_service.py b/bumble/profiles/asha_service.py
index 1b1e93a..6898397 100644
--- a/bumble/profiles/asha_service.py
+++ b/bumble/profiles/asha_service.py
@@ -103,7 +103,7 @@ class AshaService(TemplateService):
self.read_only_properties_characteristic = Characteristic(
GATT_ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC,
- Characteristic.READ,
+ Characteristic.Properties.READ,
Characteristic.READABLE,
bytes(
[
@@ -120,19 +120,20 @@ class AshaService(TemplateService):
self.audio_control_point_characteristic = Characteristic(
GATT_ASHA_AUDIO_CONTROL_POINT_CHARACTERISTIC,
- Characteristic.WRITE | Characteristic.WRITE_WITHOUT_RESPONSE,
+ Characteristic.Properties.WRITE
+ | Characteristic.Properties.WRITE_WITHOUT_RESPONSE,
Characteristic.WRITEABLE,
CharacteristicValue(write=on_audio_control_point_write),
)
self.audio_status_characteristic = Characteristic(
GATT_ASHA_AUDIO_STATUS_CHARACTERISTIC,
- Characteristic.READ | Characteristic.NOTIFY,
+ Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
Characteristic.READABLE,
bytes([0]),
)
self.volume_characteristic = Characteristic(
GATT_ASHA_VOLUME_CHARACTERISTIC,
- Characteristic.WRITE_WITHOUT_RESPONSE,
+ Characteristic.Properties.WRITE_WITHOUT_RESPONSE,
Characteristic.WRITEABLE,
CharacteristicValue(write=on_volume_write),
)
@@ -151,7 +152,7 @@ class AshaService(TemplateService):
self.psm = self.device.register_l2cap_channel_server(self.psm, on_coc, 8)
self.le_psm_out_characteristic = Characteristic(
GATT_ASHA_LE_PSM_OUT_CHARACTERISTIC,
- Characteristic.READ,
+ Characteristic.Properties.READ,
Characteristic.READABLE,
struct.pack('<H', self.psm),
)
diff --git a/bumble/profiles/battery_service.py b/bumble/profiles/battery_service.py
index f6ccc10..211fee0 100644
--- a/bumble/profiles/battery_service.py
+++ b/bumble/profiles/battery_service.py
@@ -36,7 +36,7 @@ class BatteryService(TemplateService):
self.battery_level_characteristic = PackedCharacteristicAdapter(
Characteristic(
GATT_BATTERY_LEVEL_CHARACTERISTIC,
- Characteristic.READ | Characteristic.NOTIFY,
+ Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
Characteristic.READABLE,
CharacteristicValue(read=read_battery_level),
),
diff --git a/bumble/profiles/device_information_service.py b/bumble/profiles/device_information_service.py
index 2ad9cae..09bfd6c 100644
--- a/bumble/profiles/device_information_service.py
+++ b/bumble/profiles/device_information_service.py
@@ -63,7 +63,9 @@ class DeviceInformationService(TemplateService):
# TODO: pnp_id
):
characteristics = [
- Characteristic(uuid, Characteristic.READ, Characteristic.READABLE, field)
+ Characteristic(
+ uuid, Characteristic.Properties.READ, Characteristic.READABLE, field
+ )
for (field, uuid) in (
(manufacturer_name, GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC),
(model_number, GATT_MODEL_NUMBER_STRING_CHARACTERISTIC),
@@ -79,7 +81,7 @@ class DeviceInformationService(TemplateService):
characteristics.append(
Characteristic(
GATT_SYSTEM_ID_CHARACTERISTIC,
- Characteristic.READ,
+ Characteristic.Properties.READ,
Characteristic.READABLE,
self.pack_system_id(*system_id),
)
@@ -89,7 +91,7 @@ class DeviceInformationService(TemplateService):
characteristics.append(
Characteristic(
GATT_REGULATORY_CERTIFICATION_DATA_LIST_CHARACTERISTIC,
- Characteristic.READ,
+ Characteristic.Properties.READ,
Characteristic.READABLE,
ieee_regulatory_certification_data_list,
)
diff --git a/bumble/profiles/heart_rate_service.py b/bumble/profiles/heart_rate_service.py
index 5755535..c7d3018 100644
--- a/bumble/profiles/heart_rate_service.py
+++ b/bumble/profiles/heart_rate_service.py
@@ -152,7 +152,7 @@ class HeartRateService(TemplateService):
self.heart_rate_measurement_characteristic = DelegatedCharacteristicAdapter(
Characteristic(
GATT_HEART_RATE_MEASUREMENT_CHARACTERISTIC,
- Characteristic.NOTIFY,
+ Characteristic.Properties.NOTIFY,
0,
CharacteristicValue(read=read_heart_rate_measurement),
),
@@ -164,7 +164,7 @@ class HeartRateService(TemplateService):
if body_sensor_location is not None:
self.body_sensor_location_characteristic = Characteristic(
GATT_BODY_SENSOR_LOCATION_CHARACTERISTIC,
- Characteristic.READ,
+ Characteristic.Properties.READ,
Characteristic.READABLE,
bytes([int(body_sensor_location)]),
)
@@ -182,7 +182,7 @@ class HeartRateService(TemplateService):
self.heart_rate_control_point_characteristic = PackedCharacteristicAdapter(
Characteristic(
GATT_HEART_RATE_CONTROL_POINT_CHARACTERISTIC,
- Characteristic.WRITE,
+ Characteristic.Properties.WRITE,
Characteristic.WRITEABLE,
CharacteristicValue(write=write_heart_rate_control_point_value),
),
diff --git a/bumble/rfcomm.py b/bumble/rfcomm.py
index dbb2795..71be8dc 100644
--- a/bumble/rfcomm.py
+++ b/bumble/rfcomm.py
@@ -439,7 +439,7 @@ class DLC(EventEmitter):
logger.debug(
f'<<< Credits [{self.dlci}]: '
- f'received {credits}, total={self.tx_credits}'
+ f'received {received_credits}, total={self.tx_credits}'
)
data = data[1:]
diff --git a/bumble/smp.py b/bumble/smp.py
index 1714743..f3fbf27 100644
--- a/bumble/smp.py
+++ b/bumble/smp.py
@@ -26,12 +26,27 @@ from __future__ import annotations
import logging
import asyncio
import secrets
-from typing import Dict, Optional, Type
+from typing import (
+ TYPE_CHECKING,
+ Any,
+ Awaitable,
+ Callable,
+ Dict,
+ List,
+ Optional,
+ Tuple,
+ Type,
+)
from pyee import EventEmitter
from .colors import color
-from .hci import Address, HCI_LE_Enable_Encryption_Command, HCI_Object, key_with_value
+from .hci import (
+ Address,
+ HCI_LE_Enable_Encryption_Command,
+ HCI_Object,
+ key_with_value,
+)
from .core import (
BT_BR_EDR_TRANSPORT,
BT_CENTRAL_ROLE,
@@ -42,6 +57,10 @@ from .core import (
from .keys import PairingKeys
from . import crypto
+if TYPE_CHECKING:
+ from bumble.device import Connection, Device
+ from bumble.pairing import PairingConfig
+
# -----------------------------------------------------------------------------
# Logging
@@ -175,7 +194,7 @@ SMP_CTKD_H7_BRLE_SALT = bytes.fromhex('00000000000000000000000000000000746D7032'
# -----------------------------------------------------------------------------
# Utils
# -----------------------------------------------------------------------------
-def error_name(error_code):
+def error_name(error_code: int) -> str:
return name_or_number(SMP_ERROR_NAMES, error_code)
@@ -188,11 +207,12 @@ class SMP_Command:
'''
smp_classes: Dict[int, Type[SMP_Command]] = {}
+ fields: Any
code = 0
name = ''
@staticmethod
- def from_bytes(pdu):
+ def from_bytes(pdu: bytes) -> "SMP_Command":
code = pdu[0]
cls = SMP_Command.smp_classes.get(code)
@@ -208,11 +228,11 @@ class SMP_Command:
return self
@staticmethod
- def command_name(code):
+ def command_name(code: int) -> str:
return name_or_number(SMP_COMMAND_NAMES, code)
@staticmethod
- def auth_req_str(value):
+ def auth_req_str(value: int) -> str:
bonding_flags = value & 3
mitm = (value >> 2) & 1
sc = (value >> 3) & 1
@@ -225,12 +245,12 @@ class SMP_Command:
)
@staticmethod
- def io_capability_name(io_capability):
+ def io_capability_name(io_capability: int) -> str:
return name_or_number(SMP_IO_CAPABILITY_NAMES, io_capability)
@staticmethod
- def key_distribution_str(value):
- key_types = []
+ def key_distribution_str(value: int) -> str:
+ key_types: List[str] = []
if value & SMP_ENC_KEY_DISTRIBUTION_FLAG:
key_types.append('ENC')
if value & SMP_ID_KEY_DISTRIBUTION_FLAG:
@@ -242,7 +262,7 @@ class SMP_Command:
return ','.join(key_types)
@staticmethod
- def keypress_notification_type_name(notification_type):
+ def keypress_notification_type_name(notification_type: int) -> str:
return name_or_number(SMP_KEYPRESS_NOTIFICATION_TYPE_NAMES, notification_type)
@staticmethod
@@ -263,14 +283,14 @@ class SMP_Command:
return inner
- def __init__(self, pdu=None, **kwargs):
+ def __init__(self, pdu: Optional[bytes] = None, **kwargs: Any) -> None:
if hasattr(self, 'fields') and kwargs:
HCI_Object.init_from_fields(self, self.fields, kwargs)
if pdu is None:
pdu = bytes([self.code]) + HCI_Object.dict_to_bytes(kwargs, self.fields)
self.pdu = pdu
- def init_from_bytes(self, pdu, offset):
+ def init_from_bytes(self, pdu: bytes, offset: int) -> None:
return HCI_Object.init_from_bytes(self, pdu, offset, self.fields)
def to_bytes(self):
@@ -311,6 +331,13 @@ class SMP_Pairing_Request_Command(SMP_Command):
See Bluetooth spec @ Vol 3, Part H - 3.5.1 Pairing Request
'''
+ io_capability: int
+ oob_data_flag: int
+ auth_req: int
+ maximum_encryption_key_size: int
+ initiator_key_distribution: int
+ responder_key_distribution: int
+
# -----------------------------------------------------------------------------
@SMP_Command.subclass(
@@ -334,6 +361,13 @@ class SMP_Pairing_Response_Command(SMP_Command):
See Bluetooth spec @ Vol 3, Part H - 3.5.2 Pairing Response
'''
+ io_capability: int
+ oob_data_flag: int
+ auth_req: int
+ maximum_encryption_key_size: int
+ initiator_key_distribution: int
+ responder_key_distribution: int
+
# -----------------------------------------------------------------------------
@SMP_Command.subclass([('confirm_value', 16)])
@@ -342,6 +376,8 @@ class SMP_Pairing_Confirm_Command(SMP_Command):
See Bluetooth spec @ Vol 3, Part H - 3.5.3 Pairing Confirm
'''
+ confirm_value: bytes
+
# -----------------------------------------------------------------------------
@SMP_Command.subclass([('random_value', 16)])
@@ -350,6 +386,8 @@ class SMP_Pairing_Random_Command(SMP_Command):
See Bluetooth spec @ Vol 3, Part H - 3.5.4 Pairing Random
'''
+ random_value: bytes
+
# -----------------------------------------------------------------------------
@SMP_Command.subclass([('reason', {'size': 1, 'mapper': error_name})])
@@ -358,6 +396,8 @@ class SMP_Pairing_Failed_Command(SMP_Command):
See Bluetooth spec @ Vol 3, Part H - 3.5.5 Pairing Failed
'''
+ reason: int
+
# -----------------------------------------------------------------------------
@SMP_Command.subclass([('public_key_x', 32), ('public_key_y', 32)])
@@ -366,6 +406,9 @@ class SMP_Pairing_Public_Key_Command(SMP_Command):
See Bluetooth spec @ Vol 3, Part H - 3.5.6 Pairing Public Key
'''
+ public_key_x: bytes
+ public_key_y: bytes
+
# -----------------------------------------------------------------------------
@SMP_Command.subclass(
@@ -378,6 +421,8 @@ class SMP_Pairing_DHKey_Check_Command(SMP_Command):
See Bluetooth spec @ Vol 3, Part H - 3.5.7 Pairing DHKey Check
'''
+ dhkey_check: bytes
+
# -----------------------------------------------------------------------------
@SMP_Command.subclass(
@@ -393,6 +438,8 @@ class SMP_Pairing_Keypress_Notification_Command(SMP_Command):
See Bluetooth spec @ Vol 3, Part H - 3.5.8 Keypress Notification
'''
+ notification_type: int
+
# -----------------------------------------------------------------------------
@SMP_Command.subclass([('long_term_key', 16)])
@@ -401,6 +448,8 @@ class SMP_Encryption_Information_Command(SMP_Command):
See Bluetooth spec @ Vol 3, Part H - 3.6.2 Encryption Information
'''
+ long_term_key: bytes
+
# -----------------------------------------------------------------------------
@SMP_Command.subclass([('ediv', 2), ('rand', 8)])
@@ -409,6 +458,9 @@ class SMP_Master_Identification_Command(SMP_Command):
See Bluetooth spec @ Vol 3, Part H - 3.6.3 Master Identification
'''
+ ediv: int
+ rand: bytes
+
# -----------------------------------------------------------------------------
@SMP_Command.subclass([('identity_resolving_key', 16)])
@@ -417,6 +469,8 @@ class SMP_Identity_Information_Command(SMP_Command):
See Bluetooth spec @ Vol 3, Part H - 3.6.4 Identity Information
'''
+ identity_resolving_key: bytes
+
# -----------------------------------------------------------------------------
@SMP_Command.subclass(
@@ -430,6 +484,9 @@ class SMP_Identity_Address_Information_Command(SMP_Command):
See Bluetooth spec @ Vol 3, Part H - 3.6.5 Identity Address Information
'''
+ addr_type: int
+ bd_addr: Address
+
# -----------------------------------------------------------------------------
@SMP_Command.subclass([('signature_key', 16)])
@@ -438,6 +495,8 @@ class SMP_Signing_Information_Command(SMP_Command):
See Bluetooth spec @ Vol 3, Part H - 3.6.6 Signing Information
'''
+ signature_key: bytes
+
# -----------------------------------------------------------------------------
@SMP_Command.subclass(
@@ -450,9 +509,11 @@ class SMP_Security_Request_Command(SMP_Command):
See Bluetooth spec @ Vol 3, Part H - 3.6.7 Security Request
'''
+ auth_req: int
+
# -----------------------------------------------------------------------------
-def smp_auth_req(bonding, mitm, sc, keypress, ct2):
+def smp_auth_req(bonding: bool, mitm: bool, sc: bool, keypress: bool, ct2: bool) -> int:
value = 0
if bonding:
value |= SMP_BONDING_AUTHREQ
@@ -476,7 +537,7 @@ class AddressResolver:
address_bytes = bytes(address)
hash_part = address_bytes[0:3]
prand = address_bytes[3:6]
- for (irk, resolved_address) in self.resolving_keys:
+ for irk, resolved_address in self.resolving_keys:
local_hash = crypto.ah(irk, prand)
if local_hash == hash_part:
# Match!
@@ -492,86 +553,6 @@ class AddressResolver:
# -----------------------------------------------------------------------------
-class PairingDelegate:
- NO_OUTPUT_NO_INPUT = SMP_NO_INPUT_NO_OUTPUT_IO_CAPABILITY
- KEYBOARD_INPUT_ONLY = SMP_KEYBOARD_ONLY_IO_CAPABILITY
- DISPLAY_OUTPUT_ONLY = SMP_DISPLAY_ONLY_IO_CAPABILITY
- DISPLAY_OUTPUT_AND_YES_NO_INPUT = SMP_DISPLAY_YES_NO_IO_CAPABILITY
- DISPLAY_OUTPUT_AND_KEYBOARD_INPUT = SMP_KEYBOARD_DISPLAY_IO_CAPABILITY
- DEFAULT_KEY_DISTRIBUTION: int = (
- SMP_ENC_KEY_DISTRIBUTION_FLAG | SMP_ID_KEY_DISTRIBUTION_FLAG
- )
-
- def __init__(
- self,
- io_capability: int = NO_OUTPUT_NO_INPUT,
- local_initiator_key_distribution: int = DEFAULT_KEY_DISTRIBUTION,
- local_responder_key_distribution: int = DEFAULT_KEY_DISTRIBUTION,
- ) -> None:
- self.io_capability = io_capability
- self.local_initiator_key_distribution = local_initiator_key_distribution
- self.local_responder_key_distribution = local_responder_key_distribution
-
- async def accept(self) -> bool:
- return True
-
- async def confirm(self) -> bool:
- return True
-
- # pylint: disable-next=unused-argument
- async def compare_numbers(self, number: int, digits: int) -> bool:
- return True
-
- async def get_number(self) -> Optional[int]:
- '''
- Returns an optional number as an answer to a passkey request.
- Returning `None` will result in a negative reply.
- '''
- return 0
-
- async def get_string(self, max_length) -> Optional[str]:
- '''
- Returns a string whose utf-8 encoding is up to max_length bytes.
- '''
- return None
-
- # pylint: disable-next=unused-argument
- async def display_number(self, number: int, digits: int) -> None:
- pass
-
- async def key_distribution_response(
- self, peer_initiator_key_distribution, peer_responder_key_distribution
- ):
- return (
- (peer_initiator_key_distribution & self.local_initiator_key_distribution),
- (peer_responder_key_distribution & self.local_responder_key_distribution),
- )
-
-
-# -----------------------------------------------------------------------------
-class PairingConfig:
- def __init__(
- self,
- sc: bool = True,
- mitm: bool = True,
- bonding: bool = True,
- delegate: Optional[PairingDelegate] = None,
- ) -> None:
- self.sc = sc
- self.mitm = mitm
- self.bonding = bonding
- self.delegate = delegate or PairingDelegate()
-
- def __str__(self):
- io_capability_str = SMP_Command.io_capability_name(self.delegate.io_capability)
- return (
- f'PairingConfig(sc={self.sc}, '
- f'mitm={self.mitm}, bonding={self.bonding}, '
- f'delegate[{io_capability_str}])'
- )
-
-
-# -----------------------------------------------------------------------------
class Session:
# Pairing methods
JUST_WORKS = 0
@@ -645,11 +626,17 @@ class Session:
},
}
- def __init__(self, manager, connection, pairing_config):
+ def __init__(
+ self,
+ manager: Manager,
+ connection: Connection,
+ pairing_config: PairingConfig,
+ is_initiator: bool,
+ ) -> None:
self.manager = manager
self.connection = connection
- self.preq = None
- self.pres = None
+ self.preq: Optional[bytes] = None
+ self.pres: Optional[bytes] = None
self.ea = None
self.eb = None
self.tk = bytes(16)
@@ -659,32 +646,32 @@ class Session:
self.ltk_ediv = 0
self.ltk_rand = bytes(8)
self.link_key = None
- self.initiator_key_distribution = 0
- self.responder_key_distribution = 0
- self.peer_random_value = None
- self.peer_public_key_x = bytes(32)
+ self.initiator_key_distribution: int = 0
+ self.responder_key_distribution: int = 0
+ self.peer_random_value: Optional[bytes] = None
+ self.peer_public_key_x: bytes = bytes(32)
self.peer_public_key_y = bytes(32)
self.peer_ltk = None
self.peer_ediv = None
- self.peer_rand = None
+ self.peer_rand: Optional[bytes] = None
self.peer_identity_resolving_key = None
- self.peer_bd_addr = None
+ self.peer_bd_addr: Optional[Address] = None
self.peer_signature_key = None
- self.peer_expected_distributions = []
+ self.peer_expected_distributions: List[Type[SMP_Command]] = []
self.dh_key = None
self.confirm_value = None
- self.passkey = None
+ self.passkey: Optional[int] = None
self.passkey_ready = asyncio.Event()
self.passkey_step = 0
self.passkey_display = False
self.pairing_method = 0
self.pairing_config = pairing_config
- self.wait_before_continuing = None
+ self.wait_before_continuing: Optional[asyncio.Future[None]] = None
self.completed = False
- self.ctkd_task = None
+ self.ctkd_task: Optional[Awaitable[None]] = None
# Decide if we're the initiator or the responder
- self.is_initiator = connection.role == BT_CENTRAL_ROLE
+ self.is_initiator = is_initiator
self.is_responder = not self.is_initiator
# Listen for connection events
@@ -699,7 +686,9 @@ class Session:
# Create a future that can be used to wait for the session to complete
if self.is_initiator:
- self.pairing_result = asyncio.get_running_loop().create_future()
+ self.pairing_result: Optional[
+ asyncio.Future[None]
+ ] = asyncio.get_running_loop().create_future()
else:
self.pairing_result = None
@@ -712,11 +701,11 @@ class Session:
)
# Authentication Requirements Flags - Vol 3, Part H, Figure 3.3
- self.bonding = pairing_config.bonding
- self.sc = pairing_config.sc
- self.mitm = pairing_config.mitm
+ self.bonding: bool = pairing_config.bonding
+ self.sc: bool = pairing_config.sc
+ self.mitm: bool = pairing_config.mitm
self.keypress = False
- self.ct2 = False
+ self.ct2: bool = False
# I/O Capabilities
self.io_capability = pairing_config.delegate.io_capability
@@ -740,34 +729,35 @@ class Session:
self.iat = 1 if peer_address.is_random else 0
@property
- def pkx(self):
+ def pkx(self) -> Tuple[bytes, bytes]:
return (bytes(reversed(self.manager.ecc_key.x)), self.peer_public_key_x)
@property
- def pka(self):
+ def pka(self) -> bytes:
return self.pkx[0 if self.is_initiator else 1]
@property
- def pkb(self):
+ def pkb(self) -> bytes:
return self.pkx[0 if self.is_responder else 1]
@property
- def nx(self):
+ def nx(self) -> Tuple[bytes, bytes]:
+ assert self.peer_random_value
return (self.r, self.peer_random_value)
@property
- def na(self):
+ def na(self) -> bytes:
return self.nx[0 if self.is_initiator else 1]
@property
- def nb(self):
+ def nb(self) -> bytes:
return self.nx[0 if self.is_responder else 1]
@property
- def auth_req(self):
+ def auth_req(self) -> int:
return smp_auth_req(self.bonding, self.mitm, self.sc, self.keypress, self.ct2)
- def get_long_term_key(self, rand, ediv):
+ def get_long_term_key(self, rand: bytes, ediv: int) -> Optional[bytes]:
if not self.sc and not self.completed:
if rand == self.ltk_rand and ediv == self.ltk_ediv:
return self.stk
@@ -777,13 +767,13 @@ class Session:
return None
def decide_pairing_method(
- self, auth_req, initiator_io_capability, responder_io_capability
- ):
+ self, auth_req: int, initiator_io_capability: int, responder_io_capability: int
+ ) -> None:
if (not self.mitm) and (auth_req & SMP_MITM_AUTHREQ == 0):
self.pairing_method = self.JUST_WORKS
return
- details = self.PAIRING_METHODS[initiator_io_capability][responder_io_capability]
+ details = self.PAIRING_METHODS[initiator_io_capability][responder_io_capability] # type: ignore[index]
if isinstance(details, tuple) and len(details) == 2:
# One entry for legacy pairing and one for secure connections
details = details[1 if self.sc else 0]
@@ -795,7 +785,9 @@ class Session:
self.pairing_method = details[0]
self.passkey_display = details[1 if self.is_initiator else 2]
- def check_expected_value(self, expected, received, error):
+ def check_expected_value(
+ self, expected: bytes, received: bytes, error: int
+ ) -> bool:
logger.debug(f'expected={expected.hex()} got={received.hex()}')
if expected != received:
logger.info(color('pairing confirm/check mismatch', 'red'))
@@ -803,8 +795,8 @@ class Session:
return False
return True
- def prompt_user_for_confirmation(self, next_steps):
- async def prompt():
+ def prompt_user_for_confirmation(self, next_steps: Callable[[], None]) -> None:
+ async def prompt() -> None:
logger.debug('ask for confirmation')
try:
response = await self.pairing_config.delegate.confirm()
@@ -818,8 +810,10 @@ class Session:
self.connection.abort_on('disconnection', prompt())
- def prompt_user_for_numeric_comparison(self, code, next_steps):
- async def prompt():
+ def prompt_user_for_numeric_comparison(
+ self, code: int, next_steps: Callable[[], None]
+ ) -> None:
+ async def prompt() -> None:
logger.debug(f'verification code: {code}')
try:
response = await self.pairing_config.delegate.compare_numbers(
@@ -835,11 +829,15 @@ class Session:
self.connection.abort_on('disconnection', prompt())
- def prompt_user_for_number(self, next_steps):
- async def prompt():
+ def prompt_user_for_number(self, next_steps: Callable[[int], None]) -> None:
+ async def prompt() -> None:
logger.debug('prompting user for passkey')
try:
passkey = await self.pairing_config.delegate.get_number()
+ if passkey is None:
+ logger.debug('Passkey request rejected')
+ self.send_pairing_failed(SMP_PASSKEY_ENTRY_FAILED_ERROR)
+ return
logger.debug(f'user input: {passkey}')
next_steps(passkey)
except Exception as error:
@@ -848,9 +846,10 @@ class Session:
self.connection.abort_on('disconnection', prompt())
- def display_passkey(self):
+ def display_passkey(self) -> None:
# Generate random Passkey/PIN code
self.passkey = secrets.randbelow(1000000)
+ assert self.passkey is not None
logger.debug(f'Pairing PIN CODE: {self.passkey:06}')
self.passkey_ready.set()
@@ -864,9 +863,9 @@ class Session:
self.pairing_config.delegate.display_number(self.passkey, digits=6),
)
- def input_passkey(self, next_steps=None):
+ def input_passkey(self, next_steps: Optional[Callable[[], None]] = None) -> None:
# Prompt the user for the passkey displayed on the peer
- def after_input(passkey):
+ def after_input(passkey: int) -> None:
self.passkey = passkey
if not self.sc:
@@ -880,7 +879,9 @@ class Session:
self.prompt_user_for_number(after_input)
- def display_or_input_passkey(self, next_steps=None):
+ def display_or_input_passkey(
+ self, next_steps: Optional[Callable[[], None]] = None
+ ) -> None:
if self.passkey_display:
self.display_passkey()
if next_steps is not None:
@@ -888,14 +889,14 @@ class Session:
else:
self.input_passkey(next_steps)
- def send_command(self, command):
+ def send_command(self, command: SMP_Command) -> None:
self.manager.send_command(self.connection, command)
- def send_pairing_failed(self, error):
+ def send_pairing_failed(self, error: int) -> None:
self.send_command(SMP_Pairing_Failed_Command(reason=error))
self.on_pairing_failure(error)
- def send_pairing_request_command(self):
+ def send_pairing_request_command(self) -> None:
self.manager.on_session_start(self)
command = SMP_Pairing_Request_Command(
@@ -909,7 +910,7 @@ class Session:
self.preq = bytes(command)
self.send_command(command)
- def send_pairing_response_command(self):
+ def send_pairing_response_command(self) -> None:
response = SMP_Pairing_Response_Command(
io_capability=self.io_capability,
oob_data_flag=0,
@@ -921,18 +922,19 @@ class Session:
self.pres = bytes(response)
self.send_command(response)
- def send_pairing_confirm_command(self):
+ def send_pairing_confirm_command(self) -> None:
self.r = crypto.r()
logger.debug(f'generated random: {self.r.hex()}')
if self.sc:
- async def next_steps():
+ async def next_steps() -> None:
if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
z = 0
elif self.pairing_method == self.PASSKEY:
# We need a passkey
await self.passkey_ready.wait()
+ assert self.passkey
z = 0x80 + ((self.passkey >> self.passkey_step) & 1)
else:
@@ -963,10 +965,10 @@ class Session:
self.send_command(SMP_Pairing_Confirm_Command(confirm_value=confirm_value))
- def send_pairing_random_command(self):
+ def send_pairing_random_command(self) -> None:
self.send_command(SMP_Pairing_Random_Command(random_value=self.r))
- def send_public_key_command(self):
+ def send_public_key_command(self) -> None:
self.send_command(
SMP_Pairing_Public_Key_Command(
public_key_x=bytes(reversed(self.manager.ecc_key.x)),
@@ -974,18 +976,18 @@ class Session:
)
)
- def send_pairing_dhkey_check_command(self):
+ def send_pairing_dhkey_check_command(self) -> None:
self.send_command(
SMP_Pairing_DHKey_Check_Command(
dhkey_check=self.ea if self.is_initiator else self.eb
)
)
- def start_encryption(self, key):
+ def start_encryption(self, key: bytes) -> None:
# We can now encrypt the connection with the short term key, so that we can
# distribute the long term and/or other keys over an encrypted connection
self.manager.device.host.send_command_sync(
- HCI_LE_Enable_Encryption_Command(
+ HCI_LE_Enable_Encryption_Command( # type: ignore[call-arg]
connection_handle=self.connection.handle,
random_number=bytes(8),
encrypted_diversifier=0,
@@ -993,7 +995,7 @@ class Session:
)
)
- async def derive_ltk(self):
+ async def derive_ltk(self) -> None:
link_key = await self.manager.device.get_link_key(self.connection.peer_address)
assert link_key is not None
ilk = (
@@ -1003,7 +1005,7 @@ class Session:
)
self.ltk = crypto.h6(ilk, b'brle')
- def distribute_keys(self):
+ def distribute_keys(self) -> None:
# Distribute the keys as required
if self.is_initiator:
# CTKD: Derive LTK from LinkKey
@@ -1103,7 +1105,7 @@ class Session:
)
self.link_key = crypto.h6(ilk, b'lebr')
- def compute_peer_expected_distributions(self, key_distribution_flags):
+ def compute_peer_expected_distributions(self, key_distribution_flags: int) -> None:
# Set our expectations for what to wait for in the key distribution phase
self.peer_expected_distributions = []
if not self.sc and self.connection.transport == BT_LE_TRANSPORT:
@@ -1126,7 +1128,7 @@ class Session:
f'{[c.__name__ for c in self.peer_expected_distributions]}'
)
- def check_key_distribution(self, command_class):
+ def check_key_distribution(self, command_class: Type[SMP_Command]) -> None:
# First, check that the connection is encrypted
if not self.connection.is_encrypted:
logger.warning(
@@ -1154,7 +1156,7 @@ class Session:
)
self.send_pairing_failed(SMP_UNSPECIFIED_REASON_ERROR)
- async def pair(self):
+ async def pair(self) -> None:
# Start pairing as an initiator
# TODO: check that this session isn't already active
@@ -1162,9 +1164,10 @@ class Session:
self.send_pairing_request_command()
# Wait for the pairing process to finish
+ assert self.pairing_result
await self.connection.abort_on('disconnection', self.pairing_result)
- def on_disconnection(self, _):
+ def on_disconnection(self, _: int) -> None:
self.connection.remove_listener('disconnection', self.on_disconnection)
self.connection.remove_listener(
'connection_encryption_change', self.on_connection_encryption_change
@@ -1175,14 +1178,14 @@ class Session:
)
self.manager.on_session_end(self)
- def on_peer_key_distribution_complete(self):
+ def on_peer_key_distribution_complete(self) -> None:
# The initiator can now send its keys
if self.is_initiator:
self.distribute_keys()
self.connection.abort_on('disconnection', self.on_pairing())
- def on_connection_encryption_change(self):
+ def on_connection_encryption_change(self) -> None:
if self.connection.is_encrypted:
if self.is_responder:
# The responder distributes its keys first, the initiator later
@@ -1192,11 +1195,11 @@ class Session:
if not self.peer_expected_distributions:
self.on_peer_key_distribution_complete()
- def on_connection_encryption_key_refresh(self):
+ def on_connection_encryption_key_refresh(self) -> None:
# Do as if the connection had just been encrypted
self.on_connection_encryption_change()
- async def on_pairing(self):
+ async def on_pairing(self) -> None:
logger.debug('pairing complete')
if self.completed:
@@ -1208,7 +1211,7 @@ class Session:
self.pairing_result.set_result(None)
# Use the peer address from the pairing protocol or the connection
- if self.peer_bd_addr:
+ if self.peer_bd_addr is not None:
peer_address = self.peer_bd_addr
else:
peer_address = self.connection.peer_address
@@ -1257,7 +1260,7 @@ class Session:
)
self.manager.on_pairing(self, peer_address, keys)
- def on_pairing_failure(self, reason):
+ def on_pairing_failure(self, reason: int) -> None:
logger.warning(f'pairing failure ({error_name(reason)})')
if self.completed:
@@ -1270,7 +1273,7 @@ class Session:
self.pairing_result.set_exception(error)
self.manager.on_pairing_failure(self, reason)
- def on_smp_command(self, command):
+ def on_smp_command(self, command: SMP_Command) -> None:
# Find the handler method
handler_name = f'on_{command.name.lower()}'
handler = getattr(self, handler_name, None)
@@ -1286,12 +1289,16 @@ class Session:
else:
logger.error(color('SMP command not handled???', 'red'))
- def on_smp_pairing_request_command(self, command):
+ def on_smp_pairing_request_command(
+ self, command: SMP_Pairing_Request_Command
+ ) -> None:
self.connection.abort_on(
'disconnection', self.on_smp_pairing_request_command_async(command)
)
- async def on_smp_pairing_request_command_async(self, command):
+ async def on_smp_pairing_request_command_async(
+ self, command: SMP_Pairing_Request_Command
+ ) -> None:
# Check if the request should proceed
accepted = await self.pairing_config.delegate.accept()
if not accepted:
@@ -1351,7 +1358,9 @@ class Session:
):
self.distribute_keys()
- def on_smp_pairing_response_command(self, command):
+ def on_smp_pairing_response_command(
+ self, command: SMP_Pairing_Response_Command
+ ) -> None:
if self.is_responder:
logger.warning(color('received pairing response as a responder', 'red'))
return
@@ -1402,7 +1411,9 @@ class Session:
else:
self.send_pairing_confirm_command()
- def on_smp_pairing_confirm_command_legacy(self, _):
+ def on_smp_pairing_confirm_command_legacy(
+ self, _: SMP_Pairing_Confirm_Command
+ ) -> None:
if self.is_initiator:
self.send_pairing_random_command()
else:
@@ -1412,7 +1423,9 @@ class Session:
else:
self.send_pairing_confirm_command()
- def on_smp_pairing_confirm_command_secure_connections(self, _):
+ def on_smp_pairing_confirm_command_secure_connections(
+ self, _: SMP_Pairing_Confirm_Command
+ ) -> None:
if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
if self.is_initiator:
self.r = crypto.r()
@@ -1423,14 +1436,18 @@ class Session:
else:
self.send_pairing_confirm_command()
- def on_smp_pairing_confirm_command(self, command):
+ def on_smp_pairing_confirm_command(
+ self, command: SMP_Pairing_Confirm_Command
+ ) -> None:
self.confirm_value = command.confirm_value
if self.sc:
self.on_smp_pairing_confirm_command_secure_connections(command)
else:
self.on_smp_pairing_confirm_command_legacy(command)
- def on_smp_pairing_random_command_legacy(self, command):
+ def on_smp_pairing_random_command_legacy(
+ self, command: SMP_Pairing_Random_Command
+ ) -> None:
# Check that the confirmation values match
confirm_verifier = crypto.c1(
self.tk,
@@ -1442,6 +1459,7 @@ class Session:
self.ia,
self.ra,
)
+ assert self.confirm_value
if not self.check_expected_value(
self.confirm_value, confirm_verifier, SMP_CONFIRM_VALUE_FAILED_ERROR
):
@@ -1465,7 +1483,9 @@ class Session:
else:
self.send_pairing_random_command()
- def on_smp_pairing_random_command_secure_connections(self, command):
+ def on_smp_pairing_random_command_secure_connections(
+ self, command: SMP_Pairing_Random_Command
+ ) -> None:
if self.pairing_method == self.PASSKEY and self.passkey is None:
logger.warning('no passkey entered, ignoring command')
return
@@ -1473,6 +1493,7 @@ class Session:
# pylint: disable=too-many-return-statements
if self.is_initiator:
if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
+ assert self.confirm_value
# Check that the random value matches what was committed to earlier
confirm_verifier = crypto.f4(
self.pkb, self.pka, command.random_value, bytes([0])
@@ -1482,6 +1503,7 @@ class Session:
):
return
elif self.pairing_method == self.PASSKEY:
+ assert self.passkey and self.confirm_value
# Check that the random value matches what was committed to earlier
confirm_verifier = crypto.f4(
self.pkb,
@@ -1506,6 +1528,7 @@ class Session:
if self.pairing_method in (self.JUST_WORKS, self.NUMERIC_COMPARISON):
self.send_pairing_random_command()
elif self.pairing_method == self.PASSKEY:
+ assert self.passkey and self.confirm_value
# Check that the random value matches what was committed to earlier
confirm_verifier = crypto.f4(
self.pka,
@@ -1539,19 +1562,21 @@ class Session:
ra = bytes(16)
rb = ra
elif self.pairing_method == self.PASSKEY:
+ assert self.passkey
ra = self.passkey.to_bytes(16, byteorder='little')
rb = ra
else:
# OOB not implemented yet
return
+ assert self.preq and self.pres
io_cap_a = self.preq[1:4]
io_cap_b = self.pres[1:4]
self.ea = crypto.f6(mac_key, self.na, self.nb, rb, io_cap_a, a, b)
self.eb = crypto.f6(mac_key, self.nb, self.na, ra, io_cap_b, b, a)
# Next steps to be performed after possible user confirmation
- def next_steps():
+ def next_steps() -> None:
# The initiator sends the DH Key check to the responder
if self.is_initiator:
self.send_pairing_dhkey_check_command()
@@ -1573,14 +1598,18 @@ class Session:
else:
next_steps()
- def on_smp_pairing_random_command(self, command):
+ def on_smp_pairing_random_command(
+ self, command: SMP_Pairing_Random_Command
+ ) -> None:
self.peer_random_value = command.random_value
if self.sc:
self.on_smp_pairing_random_command_secure_connections(command)
else:
self.on_smp_pairing_random_command_legacy(command)
- def on_smp_pairing_public_key_command(self, command):
+ def on_smp_pairing_public_key_command(
+ self, command: SMP_Pairing_Public_Key_Command
+ ) -> None:
# Store the public key so that we can compute the confirmation value later
self.peer_public_key_x = command.public_key_x
self.peer_public_key_y = command.public_key_y
@@ -1609,9 +1638,12 @@ class Session:
# We can now send the confirmation value
self.send_pairing_confirm_command()
- def on_smp_pairing_dhkey_check_command(self, command):
+ def on_smp_pairing_dhkey_check_command(
+ self, command: SMP_Pairing_DHKey_Check_Command
+ ) -> None:
# Check that what we received matches what we computed earlier
expected = self.eb if self.is_initiator else self.ea
+ assert expected
if not self.check_expected_value(
expected, command.dhkey_check, SMP_DHKEY_CHECK_FAILED_ERROR
):
@@ -1620,7 +1652,8 @@ class Session:
if self.is_responder:
if self.wait_before_continuing is not None:
- async def next_steps():
+ async def next_steps() -> None:
+ assert self.wait_before_continuing
await self.wait_before_continuing
self.wait_before_continuing = None
self.send_pairing_dhkey_check_command()
@@ -1629,29 +1662,42 @@ class Session:
else:
self.send_pairing_dhkey_check_command()
else:
+ assert self.ltk
self.start_encryption(self.ltk)
- def on_smp_pairing_failed_command(self, command):
+ def on_smp_pairing_failed_command(
+ self, command: SMP_Pairing_Failed_Command
+ ) -> None:
self.on_pairing_failure(command.reason)
- def on_smp_encryption_information_command(self, command):
+ def on_smp_encryption_information_command(
+ self, command: SMP_Encryption_Information_Command
+ ) -> None:
self.peer_ltk = command.long_term_key
self.check_key_distribution(SMP_Encryption_Information_Command)
- def on_smp_master_identification_command(self, command):
+ def on_smp_master_identification_command(
+ self, command: SMP_Master_Identification_Command
+ ) -> None:
self.peer_ediv = command.ediv
self.peer_rand = command.rand
self.check_key_distribution(SMP_Master_Identification_Command)
- def on_smp_identity_information_command(self, command):
+ def on_smp_identity_information_command(
+ self, command: SMP_Identity_Information_Command
+ ) -> None:
self.peer_identity_resolving_key = command.identity_resolving_key
self.check_key_distribution(SMP_Identity_Information_Command)
- def on_smp_identity_address_information_command(self, command):
+ def on_smp_identity_address_information_command(
+ self, command: SMP_Identity_Address_Information_Command
+ ) -> None:
self.peer_bd_addr = command.bd_addr
self.check_key_distribution(SMP_Identity_Address_Information_Command)
- def on_smp_signing_information_command(self, command):
+ def on_smp_signing_information_command(
+ self, command: SMP_Signing_Information_Command
+ ) -> None:
self.peer_signature_key = command.signature_key
self.check_key_distribution(SMP_Signing_Information_Command)
@@ -1662,14 +1708,24 @@ class Manager(EventEmitter):
Implements the Initiator and Responder roles of the Security Manager Protocol
'''
- def __init__(self, device):
+ device: Device
+ sessions: Dict[int, Session]
+ pairing_config_factory: Callable[[Connection], PairingConfig]
+ session_proxy: Type[Session]
+
+ def __init__(
+ self,
+ device: Device,
+ pairing_config_factory: Callable[[Connection], PairingConfig],
+ ) -> None:
super().__init__()
self.device = device
self.sessions = {}
self._ecc_key = None
- self.pairing_config_factory = lambda connection: PairingConfig()
+ self.pairing_config_factory = pairing_config_factory
+ self.session_proxy = Session
- def send_command(self, connection, command):
+ def send_command(self, connection: Connection, command: SMP_Command) -> None:
logger.debug(
f'>>> Sending SMP Command on connection [0x{connection.handle:04X}] '
f'{connection.peer_address}: {command}'
@@ -1677,18 +1733,15 @@ 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_pdu(self, connection, pdu):
+ def on_smp_pdu(self, connection: Connection, pdu: bytes) -> None:
# 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:
+ logger.warning('Remote starts pairing as Peripheral!')
pairing_config = self.pairing_config_factory(connection)
- if pairing_config is None:
- # Pairing disabled
- self.send_command(
- connection,
- SMP_Pairing_Failed_Command(reason=SMP_PAIRING_NOT_SUPPORTED_ERROR),
- )
- return
- session = Session(self, connection, pairing_config)
+ session = self.session_proxy(
+ self, connection, pairing_config, is_initiator=False
+ )
self.sessions[connection.handle] = session
# Parse the L2CAP payload into an SMP Command object
@@ -1702,21 +1755,24 @@ class Manager(EventEmitter):
session.on_smp_command(command)
@property
- def ecc_key(self):
+ def ecc_key(self) -> crypto.EccKey:
if self._ecc_key is None:
self._ecc_key = crypto.EccKey.generate()
+ assert self._ecc_key
return self._ecc_key
- async def pair(self, connection):
+ async def pair(self, connection: Connection) -> None:
# TODO: check if there's already a session for this connection
+ if connection.role != BT_CENTRAL_ROLE:
+ logger.warning('Start pairing as Peripheral!')
pairing_config = self.pairing_config_factory(connection)
- if pairing_config is None:
- raise ValueError('pairing config must not be None when initiating')
- session = Session(self, connection, pairing_config)
+ session = self.session_proxy(
+ self, connection, pairing_config, is_initiator=True
+ )
self.sessions[connection.handle] = session
return await session.pair()
- def request_pairing(self, connection):
+ def request_pairing(self, connection: Connection) -> None:
pairing_config = self.pairing_config_factory(connection)
if pairing_config:
auth_req = smp_auth_req(
@@ -1730,15 +1786,18 @@ class Manager(EventEmitter):
auth_req = 0
self.send_command(connection, SMP_Security_Request_Command(auth_req=auth_req))
- def on_session_start(self, session):
- self.device.on_pairing_start(session.connection.handle)
+ def on_session_start(self, session: Session) -> None:
+ self.device.on_pairing_start(session.connection)
- def on_pairing(self, session, identity_address, keys):
+ 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}')
@@ -1746,17 +1805,19 @@ class Manager(EventEmitter):
self.device.abort_on('flush', store_keys())
# Notify the device
- self.device.on_pairing(session.connection.handle, keys, session.sc)
+ self.device.on_pairing(session.connection, keys, session.sc)
- def on_pairing_failure(self, session, reason):
- self.device.on_pairing_failure(session.connection.handle, reason)
+ def on_pairing_failure(self, session: Session, reason: int) -> None:
+ self.device.on_pairing_failure(session.connection, reason)
- def on_session_end(self, session):
+ def on_session_end(self, session: Session) -> None:
logger.debug(f'session end for connection 0x{session.connection.handle:04X}')
if session.connection.handle in self.sessions:
del self.sessions[session.connection.handle]
- def get_long_term_key(self, connection, rand, ediv):
+ def get_long_term_key(
+ self, connection: Connection, rand: bytes, ediv: int
+ ) -> Optional[bytes]:
if session := self.sessions.get(connection.handle):
return session.get_long_term_key(rand, ediv)
diff --git a/bumble/transport/__init__.py b/bumble/transport/__init__.py
index 2d4600f..840b3e5 100644
--- a/bumble/transport/__init__.py
+++ b/bumble/transport/__init__.py
@@ -145,6 +145,11 @@ async def _open_transport(name: str) -> Transport:
return await open_android_emulator_transport(spec[0] if spec else None)
+ if scheme == 'android-netsim':
+ from .android_netsim import open_android_netsim_transport
+
+ return await open_android_netsim_transport(spec[0] if spec else None)
+
raise ValueError('unknown transport scheme')
diff --git a/bumble/transport/android_emulator.py b/bumble/transport/android_emulator.py
index 6d9e4d1..b78e263 100644
--- a/bumble/transport/android_emulator.py
+++ b/bumble/transport/android_emulator.py
@@ -16,14 +16,14 @@
# Imports
# -----------------------------------------------------------------------------
import logging
-import grpc
+import grpc.aio
from .common import PumpedTransport, PumpedPacketSource, PumpedPacketSink
-from .emulated_bluetooth_pb2_grpc import EmulatedBluetoothServiceStub
-from .emulated_bluetooth_vhci_pb2_grpc import VhciForwardingServiceStub
-# pylint: disable-next=no-name-in-module
-from .emulated_bluetooth_packets_pb2 import HCIPacket
+# pylint: disable=no-name-in-module
+from .grpc_protobuf.emulated_bluetooth_pb2_grpc import EmulatedBluetoothServiceStub
+from .grpc_protobuf.emulated_bluetooth_packets_pb2 import HCIPacket
+from .grpc_protobuf.emulated_bluetooth_vhci_pb2_grpc import VhciForwardingServiceStub
# -----------------------------------------------------------------------------
diff --git a/bumble/transport/android_netsim.py b/bumble/transport/android_netsim.py
new file mode 100644
index 0000000..99ebf87
--- /dev/null
+++ b/bumble/transport/android_netsim.py
@@ -0,0 +1,410 @@
+# 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 asyncio
+import atexit
+import logging
+import grpc.aio
+import os
+import pathlib
+import sys
+from typing import Optional
+
+from .common import (
+ ParserSource,
+ PumpedTransport,
+ PumpedPacketSource,
+ PumpedPacketSink,
+ Transport,
+)
+
+# pylint: disable=no-name-in-module
+from .grpc_protobuf.packet_streamer_pb2_grpc import PacketStreamerStub
+from .grpc_protobuf.packet_streamer_pb2_grpc import (
+ PacketStreamerServicer,
+ add_PacketStreamerServicer_to_server,
+)
+from .grpc_protobuf.packet_streamer_pb2 import PacketRequest, PacketResponse
+from .grpc_protobuf.hci_packet_pb2 import HCIPacket
+from .grpc_protobuf.startup_pb2 import Chip, ChipInfo
+from .grpc_protobuf.common_pb2 import ChipKind
+
+# -----------------------------------------------------------------------------
+# Logging
+# -----------------------------------------------------------------------------
+logger = logging.getLogger(__name__)
+
+
+# -----------------------------------------------------------------------------
+# Constants
+# -----------------------------------------------------------------------------
+DEFAULT_NAME = 'bumble0'
+DEFAULT_MANUFACTURER = 'Bumble'
+
+
+# -----------------------------------------------------------------------------
+def get_ini_dir() -> Optional[pathlib.Path]:
+ if sys.platform == 'darwin':
+ if tmpdir := os.getenv('TMPDIR', None):
+ return pathlib.Path(tmpdir)
+ if home := os.getenv('HOME', None):
+ return pathlib.Path(home) / 'Library/Caches/TemporaryItems'
+ elif sys.platform == 'linux':
+ if xdg_runtime_dir := os.environ.get('XDG_RUNTIME_DIR', None):
+ return pathlib.Path(xdg_runtime_dir)
+ elif sys.platform == 'win32':
+ if local_app_data_dir := os.environ.get('LOCALAPPDATA', None):
+ return pathlib.Path(local_app_data_dir) / 'Temp'
+
+ return None
+
+
+# -----------------------------------------------------------------------------
+def find_grpc_port() -> int:
+ if not (ini_dir := get_ini_dir()):
+ logger.debug('no known directory for .ini file')
+ return 0
+
+ ini_file = ini_dir / 'netsim.ini'
+ 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:
+ key, value = line.split('=')
+ if key == 'grpc.port':
+ logger.debug(f'gRPC port = {value}')
+ return int(value)
+
+ # Not found
+ return 0
+
+
+# -----------------------------------------------------------------------------
+def publish_grpc_port(grpc_port) -> bool:
+ if not (ini_dir := get_ini_dir()):
+ logger.debug('no known directory for .ini file')
+ return False
+
+ if not ini_dir.is_dir():
+ logger.debug('ini directory does not exist')
+ return False
+
+ ini_file = ini_dir / 'netsim.ini'
+ try:
+ ini_file.write_text(f'grpc.port={grpc_port}\n')
+ logger.debug(f"published gRPC port at {ini_file}")
+
+ def cleanup():
+ logger.debug("removing .ini file")
+ ini_file.unlink()
+
+ atexit.register(cleanup)
+ return True
+ except OSError:
+ logger.debug('failed to write to .ini file')
+ return False
+
+
+# -----------------------------------------------------------------------------
+async def open_android_netsim_controller_transport(server_host, server_port):
+ 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):
+ logger.warning("unable to publish gRPC port")
+
+ class HciDevice:
+ def __init__(self, context, on_data_received):
+ self.context = context
+ self.on_data_received = on_data_received
+ self.name = None
+ self.loop = asyncio.get_running_loop()
+ self.done = self.loop.create_future()
+ self.task = self.loop.create_task(self.pump())
+
+ async def pump(self):
+ try:
+ await self.pump_loop()
+ except asyncio.CancelledError:
+ logger.debug('Pump task canceled')
+ self.done.set_result(None)
+
+ async def pump_loop(self):
+ while True:
+ request = await self.context.read()
+ if request == grpc.aio.EOF:
+ logger.debug('End of request stream')
+ self.done.set_result(None)
+ return
+
+ # If we're not initialized yet, wait for a init packet.
+ if self.name is None:
+ if request.WhichOneof('request_type') == 'initial_info':
+ logger.debug(f'Received initial info: {request}')
+
+ # We only accept BLUETOOTH
+ if request.initial_info.chip.kind != ChipKind.BLUETOOTH:
+ logger.warning('Unsupported chip type')
+ error = PacketResponse(error='Unsupported chip type')
+ await self.context.write(error)
+ return
+
+ self.name = request.initial_info.name
+ continue
+
+ # Expect a data packet
+ request_type = request.WhichOneof('request_type')
+ if request_type != 'hci_packet':
+ logger.warning(f'Unexpected request type: {request_type}')
+ error = PacketResponse(error='Unexpected request type')
+ await self.context.write(error)
+ continue
+
+ # Process the packet
+ data = (
+ bytes([request.hci_packet.packet_type]) + request.hci_packet.packet
+ )
+ 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:])
+ )
+ )
+
+ self.loop.create_task(send())
+
+ def terminate(self):
+ self.task.cancel()
+
+ async def wait_for_termination(self):
+ await self.done
+
+ class Server(PacketStreamerServicer, ParserSource):
+ def __init__(self):
+ PacketStreamerServicer.__init__(self)
+ ParserSource.__init__(self)
+ self.device = None
+
+ # Create a gRPC server with `so_reuseport=0` so that if there's already
+ # a server listening on that port, we get an exception.
+ self.grpc_server = grpc.aio.server(options=(('grpc.so_reuseport', 0),))
+ add_PacketStreamerServicer_to_server(self, self.grpc_server)
+ self.grpc_server.add_insecure_port(f'{server_host}:{server_port}')
+ logger.debug(f'gRPC server listening on {server_host}:{server_port}')
+
+ async def start(self):
+ logger.debug('Starting gRPC server')
+ await self.grpc_server.start()
+
+ async def serve(self):
+ # Keep serving until terminated.
+ try:
+ await self.grpc_server.wait_for_termination()
+ logger.debug('gRPC server terminated')
+ except asyncio.CancelledError:
+ logger.debug('gRPC server cancelled')
+ await self.grpc_server.stop(None)
+
+ def on_packet(self, packet):
+ if not self.device:
+ logger.debug('no device, dropping packet')
+ return
+
+ 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
+ if self.device:
+ logger.debug('busy, already serving a device')
+ return PacketResponse(error='Busy')
+
+ # Instantiate a new device
+ self.device = HciDevice(context, self.parser.feed_data)
+
+ # Wait for the device to terminate
+ logger.debug('Waiting for device to terminate')
+ try:
+ await self.device.wait_for_termination()
+ except asyncio.CancelledError:
+ logger.debug('Request canceled')
+ self.device.terminate()
+
+ logger.debug('Device terminated')
+ self.device = None
+
+ server = Server()
+ await server.start()
+ asyncio.get_running_loop().create_task(server.serve())
+
+ class GrpcServerTransport(Transport):
+ async def close(self):
+ await super().close()
+
+ return GrpcServerTransport(server, server)
+
+
+# -----------------------------------------------------------------------------
+async def open_android_netsim_host_transport(server_host, server_port, options):
+ # Wrapper for I/O operations
+ class HciDevice:
+ def __init__(self, name, manufacturer, hci_device):
+ self.name = name
+ self.manufacturer = manufacturer
+ self.hci_device = hci_device
+
+ async def start(self): # Send the startup info
+ chip_info = ChipInfo(
+ name=self.name,
+ chip=Chip(kind=ChipKind.BLUETOOTH, manufacturer=self.manufacturer),
+ )
+ logger.debug(f'Sending chip info to netsim: {chip_info}')
+ await self.hci_device.write(PacketRequest(initial_info=chip_info))
+
+ 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':
+ return (
+ bytes([response.hci_packet.packet_type])
+ + response.hci_packet.packet
+ )
+
+ raise ValueError('unsupported response type')
+
+ async def write(self, packet):
+ await self.hci_device.write(
+ PacketRequest(
+ hci_packet=HCIPacket(packet_type=packet[0], packet=packet[1:])
+ )
+ )
+
+ name = 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(
+ name=name,
+ manufacturer=manufacturer,
+ hci_device=service.StreamPackets(),
+ )
+ await hci_device.start()
+
+ # Create the transport object
+ transport = PumpedTransport(
+ PumpedPacketSource(hci_device.read),
+ PumpedPacketSink(hci_device.write),
+ channel.close,
+ )
+ transport.start()
+
+ return transport
+
+
+# -----------------------------------------------------------------------------
+async def open_android_netsim_transport(spec):
+ '''
+ Open a transport connection as a client or server, implementing Android's `netsim`
+ simulator protocol over gRPC.
+ The parameter string has this syntax:
+ [<host>:<port>][<options>]
+ Where <options> is a ','-separated list of <name>=<value> pairs.
+
+ General options:
+ mode=host|controller (default: host)
+ Specifies whether the transport is used
+ to connect *to* a netsim server (netsim is the controller), or accept
+ connections *as* a netsim-compatible server.
+
+ 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`
+ property.
+ Options for this mode are:
+ name=<name>
+ The "chip" name, used to identify the "chip" instance. This
+ may be useful when several clients are connected, since each needs to use a
+ different name.
+
+ In `controller` mode:
+ The <host>:<port> part is required. <host> may be the address of a local network
+ interface, or '_' to accept connections on all local network interfaces.
+
+ Examples:
+ (empty string) --> connect to netsim on the port specified in the .ini file
+ localhost:8555 --> connect to netsim on localhost:8555
+ name=bumble1 --> connect to netsim, using `bumble1` as the "chip" name.
+ localhost:8555,name=bumble1 --> connect to netsim on localhost:8555, using
+ `bumble1` as the "chip" name.
+ _:8877,mode=controller --> accept connections as a controller on any interface
+ on port 8877.
+ '''
+
+ # Parse the parameters
+ params = spec.split(',') if spec else []
+ if params and ':' in params[0]:
+ # Explicit <host>:<port>
+ host, port = params[0].split(':')
+ params_offset = 1
+ else:
+ host = None
+ port = 0
+ params_offset = 0
+
+ options = {}
+ for param in params[params_offset:]:
+ if '=' not in param:
+ raise ValueError('invalid parameter, expected <name>=<value>')
+ option_name, option_value = param.split('=')
+ options[option_name] = option_value
+
+ mode = options.get('mode', 'host')
+ if mode == 'host':
+ return await open_android_netsim_host_transport(host, port, options)
+ if mode == 'controller':
+ if host is None:
+ raise ValueError('<host>:<port> missing')
+ return await open_android_netsim_controller_transport(host, port)
+
+ raise ValueError('invalid mode option')
diff --git a/bumble/transport/emulated_bluetooth_packets_pb2.py b/bumble/transport/emulated_bluetooth_packets_pb2.py
deleted file mode 100644
index 2802ff1..0000000
--- a/bumble/transport/emulated_bluetooth_packets_pb2.py
+++ /dev/null
@@ -1,45 +0,0 @@
-# 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.
-
-# -*- coding: utf-8 -*-
-# Generated by the protocol buffer compiler. DO NOT EDIT!
-# source: emulated_bluetooth_packets.proto
-"""Generated protocol buffer code."""
-from google.protobuf.internal import builder as _builder
-from google.protobuf import descriptor as _descriptor
-from google.protobuf import descriptor_pool as _descriptor_pool
-from google.protobuf import symbol_database as _symbol_database
-
-# @@protoc_insertion_point(imports)
-
-_sym_db = _symbol_database.Default()
-
-
-DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(
- b'\n emulated_bluetooth_packets.proto\x12\x1b\x61ndroid.emulation.bluetooth\"\xfb\x01\n\tHCIPacket\x12?\n\x04type\x18\x01 \x01(\x0e\x32\x31.android.emulation.bluetooth.HCIPacket.PacketType\x12\x0e\n\x06packet\x18\x02 \x01(\x0c\"\x9c\x01\n\nPacketType\x12\x1b\n\x17PACKET_TYPE_UNSPECIFIED\x10\x00\x12\x1b\n\x17PACKET_TYPE_HCI_COMMAND\x10\x01\x12\x13\n\x0fPACKET_TYPE_ACL\x10\x02\x12\x13\n\x0fPACKET_TYPE_SCO\x10\x03\x12\x15\n\x11PACKET_TYPE_EVENT\x10\x04\x12\x13\n\x0fPACKET_TYPE_ISO\x10\x05\x42J\n\x1f\x63om.android.emulation.bluetoothP\x01\xf8\x01\x01\xa2\x02\x03\x41\x45\x42\xaa\x02\x1b\x41ndroid.Emulation.Bluetoothb\x06proto3'
-)
-
-_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
-_builder.BuildTopDescriptorsAndMessages(
- DESCRIPTOR, 'emulated_bluetooth_packets_pb2', globals()
-)
-if _descriptor._USE_C_DESCRIPTORS == False:
-
- DESCRIPTOR._options = None
- DESCRIPTOR._serialized_options = b'\n\037com.android.emulation.bluetoothP\001\370\001\001\242\002\003AEB\252\002\033Android.Emulation.Bluetooth'
- _HCIPACKET._serialized_start = 66
- _HCIPACKET._serialized_end = 317
- _HCIPACKET_PACKETTYPE._serialized_start = 161
- _HCIPACKET_PACKETTYPE._serialized_end = 317
-# @@protoc_insertion_point(module_scope)
diff --git a/bumble/transport/emulated_bluetooth_packets_pb2_grpc.py b/bumble/transport/emulated_bluetooth_packets_pb2_grpc.py
deleted file mode 100644
index 3450039..0000000
--- a/bumble/transport/emulated_bluetooth_packets_pb2_grpc.py
+++ /dev/null
@@ -1,17 +0,0 @@
-# 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.
-
-# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
-"""Client and server classes corresponding to protobuf-defined services."""
-import grpc
diff --git a/bumble/transport/emulated_bluetooth_pb2.py b/bumble/transport/emulated_bluetooth_pb2.py
deleted file mode 100644
index 42015f6..0000000
--- a/bumble/transport/emulated_bluetooth_pb2.py
+++ /dev/null
@@ -1,46 +0,0 @@
-# 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.
-
-# -*- coding: utf-8 -*-
-# Generated by the protocol buffer compiler. DO NOT EDIT!
-# source: emulated_bluetooth.proto
-"""Generated protocol buffer code."""
-from google.protobuf.internal import builder as _builder
-from google.protobuf import descriptor as _descriptor
-from google.protobuf import descriptor_pool as _descriptor_pool
-from google.protobuf import symbol_database as _symbol_database
-
-# @@protoc_insertion_point(imports)
-
-_sym_db = _symbol_database.Default()
-
-
-from . import emulated_bluetooth_packets_pb2 as emulated__bluetooth__packets__pb2
-
-
-DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(
- b'\n\x18\x65mulated_bluetooth.proto\x12\x1b\x61ndroid.emulation.bluetooth\x1a emulated_bluetooth_packets.proto\"\x19\n\x07RawData\x12\x0e\n\x06packet\x18\x01 \x01(\x0c\x32\xcb\x02\n\x18\x45mulatedBluetoothService\x12\x64\n\x12registerClassicPhy\x12$.android.emulation.bluetooth.RawData\x1a$.android.emulation.bluetooth.RawData(\x01\x30\x01\x12`\n\x0eregisterBlePhy\x12$.android.emulation.bluetooth.RawData\x1a$.android.emulation.bluetooth.RawData(\x01\x30\x01\x12g\n\x11registerHCIDevice\x12&.android.emulation.bluetooth.HCIPacket\x1a&.android.emulation.bluetooth.HCIPacket(\x01\x30\x01\x42\"\n\x1e\x63om.android.emulator.bluetoothP\x01\x62\x06proto3'
-)
-
-_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
-_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'emulated_bluetooth_pb2', globals())
-if _descriptor._USE_C_DESCRIPTORS == False:
-
- DESCRIPTOR._options = None
- DESCRIPTOR._serialized_options = b'\n\036com.android.emulator.bluetoothP\001'
- _RAWDATA._serialized_start = 91
- _RAWDATA._serialized_end = 116
- _EMULATEDBLUETOOTHSERVICE._serialized_start = 119
- _EMULATEDBLUETOOTHSERVICE._serialized_end = 450
-# @@protoc_insertion_point(module_scope)
diff --git a/bumble/transport/emulated_bluetooth_pb2.pyi b/bumble/transport/emulated_bluetooth_pb2.pyi
deleted file mode 100644
index fb87a52..0000000
--- a/bumble/transport/emulated_bluetooth_pb2.pyi
+++ /dev/null
@@ -1,26 +0,0 @@
-# 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 emulated_bluetooth_packets_pb2 as _emulated_bluetooth_packets_pb2
-from google.protobuf import descriptor as _descriptor
-from google.protobuf import message as _message
-from typing import ClassVar as _ClassVar, Optional as _Optional
-
-DESCRIPTOR: _descriptor.FileDescriptor
-
-class RawData(_message.Message):
- __slots__ = ["packet"]
- PACKET_FIELD_NUMBER: _ClassVar[int]
- packet: bytes
- def __init__(self, packet: _Optional[bytes] = ...) -> None: ...
diff --git a/bumble/transport/emulated_bluetooth_pb2_grpc.py b/bumble/transport/emulated_bluetooth_pb2_grpc.py
deleted file mode 100644
index 8559c9e..0000000
--- a/bumble/transport/emulated_bluetooth_pb2_grpc.py
+++ /dev/null
@@ -1,244 +0,0 @@
-# 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.
-
-# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
-"""Client and server classes corresponding to protobuf-defined services."""
-import grpc
-
-from . import emulated_bluetooth_packets_pb2 as emulated__bluetooth__packets__pb2
-from . import emulated_bluetooth_pb2 as emulated__bluetooth__pb2
-
-
-class EmulatedBluetoothServiceStub(object):
- """An Emulated Bluetooth Service exposes the emulated bluetooth chip from the
- android emulator. It allows you to register emulated bluetooth devices and
- control the packets that are exchanged between the device and the world.
-
- This service enables you to establish a "virtual network" of emulated
- bluetooth devices that can interact with each other.
-
- Note: This is not yet finalized, it is likely that these definitions will
- evolve.
- """
-
- def __init__(self, channel):
- """Constructor.
-
- Args:
- channel: A grpc.Channel.
- """
- self.registerClassicPhy = channel.stream_stream(
- '/android.emulation.bluetooth.EmulatedBluetoothService/registerClassicPhy',
- request_serializer=emulated__bluetooth__pb2.RawData.SerializeToString,
- response_deserializer=emulated__bluetooth__pb2.RawData.FromString,
- )
- self.registerBlePhy = channel.stream_stream(
- '/android.emulation.bluetooth.EmulatedBluetoothService/registerBlePhy',
- request_serializer=emulated__bluetooth__pb2.RawData.SerializeToString,
- response_deserializer=emulated__bluetooth__pb2.RawData.FromString,
- )
- self.registerHCIDevice = channel.stream_stream(
- '/android.emulation.bluetooth.EmulatedBluetoothService/registerHCIDevice',
- request_serializer=emulated__bluetooth__packets__pb2.HCIPacket.SerializeToString,
- response_deserializer=emulated__bluetooth__packets__pb2.HCIPacket.FromString,
- )
-
-
-class EmulatedBluetoothServiceServicer(object):
- """An Emulated Bluetooth Service exposes the emulated bluetooth chip from the
- android emulator. It allows you to register emulated bluetooth devices and
- control the packets that are exchanged between the device and the world.
-
- This service enables you to establish a "virtual network" of emulated
- bluetooth devices that can interact with each other.
-
- Note: This is not yet finalized, it is likely that these definitions will
- evolve.
- """
-
- def registerClassicPhy(self, request_iterator, context):
- """Connect device to link layer. This will establish a direct connection
- to the emulated bluetooth chip and configure the following:
-
- - Each connection creates a new device and attaches it to the link layer
- - Link Layer packets are transmitted directly to the phy
-
- This should be used for classic connections.
-
- This is used to directly connect various android emulators together.
- For example a wear device can connect to an android emulator through
- this.
- """
- context.set_code(grpc.StatusCode.UNIMPLEMENTED)
- context.set_details('Method not implemented!')
- raise NotImplementedError('Method not implemented!')
-
- def registerBlePhy(self, request_iterator, context):
- """Connect device to link layer. This will establish a direct connection
- to root canal and execute the following:
-
- - Each connection creates a new device and attaches it to the link layer
- - Link Layer packets are transmitted directly to the phy
-
- This should be used for BLE connections.
-
- This is used to directly connect various android emulators together.
- For example a wear device can connect to an android emulator through
- this.
- """
- context.set_code(grpc.StatusCode.UNIMPLEMENTED)
- context.set_details('Method not implemented!')
- raise NotImplementedError('Method not implemented!')
-
- def registerHCIDevice(self, request_iterator, context):
- """Connect the device to the emulated bluetooth chip. The device will
- participate in the network. You can configure the chip to scan, advertise
- and setup connections with other devices that are connected to the
- network.
-
- This is usually used when you have a need for an emulated bluetooth chip
- and have a bluetooth stack that can interpret and handle the packets
- correctly.
-
- For example the apache nimble stack can use this endpoint as the
- transport layer.
- """
- context.set_code(grpc.StatusCode.UNIMPLEMENTED)
- context.set_details('Method not implemented!')
- raise NotImplementedError('Method not implemented!')
-
-
-def add_EmulatedBluetoothServiceServicer_to_server(servicer, server):
- rpc_method_handlers = {
- 'registerClassicPhy': grpc.stream_stream_rpc_method_handler(
- servicer.registerClassicPhy,
- request_deserializer=emulated__bluetooth__pb2.RawData.FromString,
- response_serializer=emulated__bluetooth__pb2.RawData.SerializeToString,
- ),
- 'registerBlePhy': grpc.stream_stream_rpc_method_handler(
- servicer.registerBlePhy,
- request_deserializer=emulated__bluetooth__pb2.RawData.FromString,
- response_serializer=emulated__bluetooth__pb2.RawData.SerializeToString,
- ),
- 'registerHCIDevice': grpc.stream_stream_rpc_method_handler(
- servicer.registerHCIDevice,
- request_deserializer=emulated__bluetooth__packets__pb2.HCIPacket.FromString,
- response_serializer=emulated__bluetooth__packets__pb2.HCIPacket.SerializeToString,
- ),
- }
- generic_handler = grpc.method_handlers_generic_handler(
- 'android.emulation.bluetooth.EmulatedBluetoothService', rpc_method_handlers
- )
- server.add_generic_rpc_handlers((generic_handler,))
-
-
-# This class is part of an EXPERIMENTAL API.
-class EmulatedBluetoothService(object):
- """An Emulated Bluetooth Service exposes the emulated bluetooth chip from the
- android emulator. It allows you to register emulated bluetooth devices and
- control the packets that are exchanged between the device and the world.
-
- This service enables you to establish a "virtual network" of emulated
- bluetooth devices that can interact with each other.
-
- Note: This is not yet finalized, it is likely that these definitions will
- evolve.
- """
-
- @staticmethod
- def registerClassicPhy(
- request_iterator,
- target,
- options=(),
- channel_credentials=None,
- call_credentials=None,
- insecure=False,
- compression=None,
- wait_for_ready=None,
- timeout=None,
- metadata=None,
- ):
- return grpc.experimental.stream_stream(
- request_iterator,
- target,
- '/android.emulation.bluetooth.EmulatedBluetoothService/registerClassicPhy',
- emulated__bluetooth__pb2.RawData.SerializeToString,
- emulated__bluetooth__pb2.RawData.FromString,
- options,
- channel_credentials,
- insecure,
- call_credentials,
- compression,
- wait_for_ready,
- timeout,
- metadata,
- )
-
- @staticmethod
- def registerBlePhy(
- request_iterator,
- target,
- options=(),
- channel_credentials=None,
- call_credentials=None,
- insecure=False,
- compression=None,
- wait_for_ready=None,
- timeout=None,
- metadata=None,
- ):
- return grpc.experimental.stream_stream(
- request_iterator,
- target,
- '/android.emulation.bluetooth.EmulatedBluetoothService/registerBlePhy',
- emulated__bluetooth__pb2.RawData.SerializeToString,
- emulated__bluetooth__pb2.RawData.FromString,
- options,
- channel_credentials,
- insecure,
- call_credentials,
- compression,
- wait_for_ready,
- timeout,
- metadata,
- )
-
- @staticmethod
- def registerHCIDevice(
- request_iterator,
- target,
- options=(),
- channel_credentials=None,
- call_credentials=None,
- insecure=False,
- compression=None,
- wait_for_ready=None,
- timeout=None,
- metadata=None,
- ):
- return grpc.experimental.stream_stream(
- request_iterator,
- target,
- '/android.emulation.bluetooth.EmulatedBluetoothService/registerHCIDevice',
- emulated__bluetooth__packets__pb2.HCIPacket.SerializeToString,
- emulated__bluetooth__packets__pb2.HCIPacket.FromString,
- options,
- channel_credentials,
- insecure,
- call_credentials,
- compression,
- wait_for_ready,
- timeout,
- metadata,
- )
diff --git a/bumble/transport/emulated_bluetooth_vhci_pb2.py b/bumble/transport/emulated_bluetooth_vhci_pb2.py
deleted file mode 100644
index e8009ad..0000000
--- a/bumble/transport/emulated_bluetooth_vhci_pb2.py
+++ /dev/null
@@ -1,46 +0,0 @@
-# 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.
-
-# -*- coding: utf-8 -*-
-# Generated by the protocol buffer compiler. DO NOT EDIT!
-# source: emulated_bluetooth_vhci.proto
-"""Generated protocol buffer code."""
-from google.protobuf.internal import builder as _builder
-from google.protobuf import descriptor as _descriptor
-from google.protobuf import descriptor_pool as _descriptor_pool
-from google.protobuf import symbol_database as _symbol_database
-
-# @@protoc_insertion_point(imports)
-
-_sym_db = _symbol_database.Default()
-
-
-from . import emulated_bluetooth_packets_pb2 as emulated__bluetooth__packets__pb2
-
-
-DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(
- b'\n\x1d\x65mulated_bluetooth_vhci.proto\x12\x1b\x61ndroid.emulation.bluetooth\x1a emulated_bluetooth_packets.proto2y\n\x15VhciForwardingService\x12`\n\nattachVhci\x12&.android.emulation.bluetooth.HCIPacket\x1a&.android.emulation.bluetooth.HCIPacket(\x01\x30\x01\x42J\n\x1f\x63om.android.emulation.bluetoothP\x01\xf8\x01\x01\xa2\x02\x03\x41\x45\x42\xaa\x02\x1b\x41ndroid.Emulation.Bluetoothb\x06proto3'
-)
-
-_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
-_builder.BuildTopDescriptorsAndMessages(
- DESCRIPTOR, 'emulated_bluetooth_vhci_pb2', globals()
-)
-if _descriptor._USE_C_DESCRIPTORS == False:
-
- DESCRIPTOR._options = None
- DESCRIPTOR._serialized_options = b'\n\037com.android.emulation.bluetoothP\001\370\001\001\242\002\003AEB\252\002\033Android.Emulation.Bluetooth'
- _VHCIFORWARDINGSERVICE._serialized_start = 96
- _VHCIFORWARDINGSERVICE._serialized_end = 217
-# @@protoc_insertion_point(module_scope)
diff --git a/bumble/transport/emulated_bluetooth_vhci_pb2.pyi b/bumble/transport/emulated_bluetooth_vhci_pb2.pyi
deleted file mode 100644
index 0877ad0..0000000
--- a/bumble/transport/emulated_bluetooth_vhci_pb2.pyi
+++ /dev/null
@@ -1,19 +0,0 @@
-# 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 emulated_bluetooth_packets_pb2 as _emulated_bluetooth_packets_pb2
-from google.protobuf import descriptor as _descriptor
-from typing import ClassVar as _ClassVar
-
-DESCRIPTOR: _descriptor.FileDescriptor
diff --git a/bumble/transport/grpc_protobuf/__init__.py b/bumble/transport/grpc_protobuf/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/bumble/transport/grpc_protobuf/__init__.py
diff --git a/bumble/transport/grpc_protobuf/common_pb2.py b/bumble/transport/grpc_protobuf/common_pb2.py
new file mode 100644
index 0000000..c54a2e0
--- /dev/null
+++ b/bumble/transport/grpc_protobuf/common_pb2.py
@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+# Generated by the protocol buffer compiler. DO NOT EDIT!
+# source: common.proto
+"""Generated protocol buffer code."""
+from google.protobuf.internal import builder as _builder
+from google.protobuf import descriptor as _descriptor
+from google.protobuf import descriptor_pool as _descriptor_pool
+from google.protobuf import symbol_database as _symbol_database
+# @@protoc_insertion_point(imports)
+
+_sym_db = _symbol_database.Default()
+
+
+
+
+DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0c\x63ommon.proto\x12\rnetsim.common*=\n\x08\x43hipKind\x12\x0f\n\x0bUNSPECIFIED\x10\x00\x12\r\n\tBLUETOOTH\x10\x01\x12\x08\n\x04WIFI\x10\x02\x12\x07\n\x03UWB\x10\x03\x62\x06proto3')
+
+_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
+_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'common_pb2', globals())
+if _descriptor._USE_C_DESCRIPTORS == False:
+
+ DESCRIPTOR._options = None
+ _CHIPKIND._serialized_start=31
+ _CHIPKIND._serialized_end=92
+# @@protoc_insertion_point(module_scope)
diff --git a/bumble/transport/grpc_protobuf/common_pb2.pyi b/bumble/transport/grpc_protobuf/common_pb2.pyi
new file mode 100644
index 0000000..4cc934d
--- /dev/null
+++ b/bumble/transport/grpc_protobuf/common_pb2.pyi
@@ -0,0 +1,12 @@
+from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper
+from google.protobuf import descriptor as _descriptor
+from typing import ClassVar as _ClassVar
+
+BLUETOOTH: ChipKind
+DESCRIPTOR: _descriptor.FileDescriptor
+UNSPECIFIED: ChipKind
+UWB: ChipKind
+WIFI: ChipKind
+
+class ChipKind(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
+ __slots__ = []
diff --git a/bumble/transport/grpc_protobuf/common_pb2_grpc.py b/bumble/transport/grpc_protobuf/common_pb2_grpc.py
new file mode 100644
index 0000000..2daafff
--- /dev/null
+++ b/bumble/transport/grpc_protobuf/common_pb2_grpc.py
@@ -0,0 +1,4 @@
+# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
+"""Client and server classes corresponding to protobuf-defined services."""
+import grpc
+
diff --git a/bumble/transport/grpc_protobuf/emulated_bluetooth_device_pb2.py b/bumble/transport/grpc_protobuf/emulated_bluetooth_device_pb2.py
new file mode 100644
index 0000000..7380cb4
--- /dev/null
+++ b/bumble/transport/grpc_protobuf/emulated_bluetooth_device_pb2.py
@@ -0,0 +1,64 @@
+# -*- coding: utf-8 -*-
+# Generated by the protocol buffer compiler. DO NOT EDIT!
+# source: emulated_bluetooth_device.proto
+"""Generated protocol buffer code."""
+from google.protobuf.internal import builder as _builder
+from google.protobuf import descriptor as _descriptor
+from google.protobuf import descriptor_pool as _descriptor_pool
+from google.protobuf import symbol_database as _symbol_database
+# @@protoc_insertion_point(imports)
+
+_sym_db = _symbol_database.Default()
+
+
+from . import grpc_endpoint_description_pb2 as grpc__endpoint__description__pb2
+from google.protobuf import empty_pb2 as google_dot_protobuf_dot_empty__pb2
+
+
+DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1f\x65mulated_bluetooth_device.proto\x12\x1b\x61ndroid.emulation.bluetooth\x1a\x1fgrpc_endpoint_description.proto\x1a\x1bgoogle/protobuf/empty.proto\"&\n\x12\x43\x61llbackIdentifier\x12\x10\n\x08identity\x18\x01 \x01(\t\"#\n\x10\x44\x65viceIdentifier\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\t\"\xf5\x02\n\x15\x43onnectionStateChange\x12K\n\x12\x63\x61llback_device_id\x18\x01 \x01(\x0b\x32/.android.emulation.bluetooth.CallbackIdentifier\x12\x42\n\x0b\x66rom_device\x18\x02 \x01(\x0b\x32-.android.emulation.bluetooth.DeviceIdentifier\x12U\n\tnew_state\x18\x03 \x01(\x0e\x32\x42.android.emulation.bluetooth.ConnectionStateChange.ConnectionState\"t\n\x0f\x43onnectionState\x12\x1e\n\x1a\x43ONNECTION_STATE_UNDEFINED\x10\x00\x12!\n\x1d\x43ONNECTION_STATE_DISCONNECTED\x10\x01\x12\x1e\n\x1a\x43ONNECTION_STATE_CONNECTED\x10\x02\"A\n\x04Uuid\x12\x0c\n\x02id\x18\x01 \x01(\rH\x00\x12\r\n\x03lsb\x18\x02 \x01(\x04H\x00\x12\x0b\n\x03msb\x18\x03 \x01(\x03\x42\x0f\n\rshort_or_long\"\xf3\x01\n\x1a\x43haracteristicValueRequest\x12K\n\x12\x63\x61llback_device_id\x18\x01 \x01(\x0b\x32/.android.emulation.bluetooth.CallbackIdentifier\x12\x42\n\x0b\x66rom_device\x18\x02 \x01(\x0b\x32-.android.emulation.bluetooth.DeviceIdentifier\x12\x36\n\x0b\x63\x61llback_id\x18\x03 \x01(\x0b\x32!.android.emulation.bluetooth.Uuid\x12\x0c\n\x04\x64\x61ta\x18\x04 \x01(\x0c\"\xdd\x01\n\x1b\x43haracteristicValueResponse\x12S\n\x06status\x18\x01 \x01(\x0e\x32\x43.android.emulation.bluetooth.CharacteristicValueResponse.GattStatus\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c\"[\n\nGattStatus\x12\x1b\n\x17GATT_STATUS_UNSPECIFIED\x10\x00\x12\x17\n\x13GATT_STATUS_SUCCESS\x10\x01\x12\x17\n\x13GATT_STATUS_FAILURE\x10\x02\"\xb3\x05\n\x12GattCharacteristic\x12/\n\x04uuid\x18\x01 \x01(\x0b\x32!.android.emulation.bluetooth.Uuid\x12\x12\n\nproperties\x18\x02 \x01(\r\x12\x13\n\x0bpermissions\x18\x03 \x01(\r\x12\x36\n\x0b\x63\x61llback_id\x18\x04 \x01(\x0b\x32!.android.emulation.bluetooth.Uuid\"\xea\x01\n\nProperties\x12\x18\n\x14PROPERTY_UNSPECIFIED\x10\x00\x12\x16\n\x12PROPERTY_BROADCAST\x10\x01\x12\x11\n\rPROPERTY_READ\x10\x02\x12\x1e\n\x1aPROPERTY_WRITE_NO_RESPONSE\x10\x04\x12\x12\n\x0ePROPERTY_WRITE\x10\x08\x12\x13\n\x0fPROPERTY_NOTIFY\x10\x10\x12\x15\n\x11PROPERTY_INDICATE\x10 \x12\x19\n\x15PROPERTY_SIGNED_WRITE\x10@\x12\x1c\n\x17PROPERTY_EXTENDED_PROPS\x10\x80\x01\"\x9d\x02\n\x0bPermissions\x12\x1a\n\x16PERMISSION_UNSPECIFIED\x10\x00\x12\x13\n\x0fPERMISSION_READ\x10\x01\x12\x1d\n\x19PERMISSION_READ_ENCRYPTED\x10\x02\x12\"\n\x1ePERMISSION_READ_ENCRYPTED_MITM\x10\x04\x12\x14\n\x10PERMISSION_WRITE\x10\x10\x12\x1e\n\x1aPERMISSION_WRITE_ENCRYPTED\x10 \x12#\n\x1fPERMISSION_WRITE_ENCRYPTED_MITM\x10@\x12\x1c\n\x17PERMISSION_WRITE_SIGNED\x10\x80\x01\x12!\n\x1cPERMISSION_WRITE_SIGNED_MITM\x10\x80\x02\"\xb7\x02\n\x0bGattService\x12/\n\x04uuid\x18\x01 \x01(\x0b\x32!.android.emulation.bluetooth.Uuid\x12J\n\x0cservice_type\x18\x02 \x01(\x0e\x32\x34.android.emulation.bluetooth.GattService.ServiceType\x12H\n\x0f\x63haracteristics\x18\x03 \x03(\x0b\x32/.android.emulation.bluetooth.GattCharacteristic\"a\n\x0bServiceType\x12\x1c\n\x18SERVICE_TYPE_UNSPECIFIED\x10\x00\x12\x18\n\x14SERVICE_TYPE_PRIMARY\x10\x01\x12\x1a\n\x16SERVICE_TYPE_SECONDARY\x10\x02\"I\n\x0bGattProfile\x12:\n\x08services\x18\x01 \x03(\x0b\x32(.android.emulation.bluetooth.GattService\"\xf0\x03\n\rAdvertisement\x12\x13\n\x0b\x64\x65vice_name\x18\x01 \x01(\t\x12R\n\x0f\x63onnection_mode\x18\x02 \x01(\x0e\x32\x39.android.emulation.bluetooth.Advertisement.ConnectionMode\x12P\n\x0e\x64iscovery_mode\x18\x03 \x01(\x0e\x32\x38.android.emulation.bluetooth.Advertisement.DiscoveryMode\"\x94\x01\n\x0e\x43onnectionMode\x12\x1f\n\x1b\x43ONNECTION_MODE_UNSPECIFIED\x10\x00\x12#\n\x1f\x43ONNECTION_MODE_NON_CONNECTABLE\x10\x01\x12\x1c\n\x18\x43ONNECTION_MODE_DIRECTED\x10\x02\x12\x1e\n\x1a\x43ONNECTION_MODE_UNDIRECTED\x10\x03\"\x8c\x01\n\rDiscoveryMode\x12\x1e\n\x1a\x44ISCOVERY_MODE_UNSPECIFIED\x10\x00\x12#\n\x1f\x44ISCOVERY_MODE_NON_DISCOVERABLE\x10\x01\x12\x1a\n\x16\x44ISCOVERY_MODE_LIMITED\x10\x02\x12\x1a\n\x16\x44ISCOVERY_MODE_GENERAL\x10\x03\"\xc0\x01\n\nGattDevice\x12\x34\n\x08\x65ndpoint\x18\x01 \x01(\x0b\x32\".android.emulation.remote.Endpoint\x12\x41\n\radvertisement\x18\x02 \x01(\x0b\x32*.android.emulation.bluetooth.Advertisement\x12\x39\n\x07profile\x18\x03 \x01(\x0b\x32(.android.emulation.bluetooth.GattProfile2\xb9\x04\n\x11GattDeviceService\x12\x90\x01\n\x1bOnCharacteristicReadRequest\x12\x37.android.emulation.bluetooth.CharacteristicValueRequest\x1a\x38.android.emulation.bluetooth.CharacteristicValueResponse\x12\x91\x01\n\x1cOnCharacteristicWriteRequest\x12\x37.android.emulation.bluetooth.CharacteristicValueRequest\x1a\x38.android.emulation.bluetooth.CharacteristicValueResponse\x12\x95\x01\n\x1eOnCharacteristicObserveRequest\x12\x37.android.emulation.bluetooth.CharacteristicValueRequest\x1a\x38.android.emulation.bluetooth.CharacteristicValueResponse0\x01\x12\x65\n\x17OnConnectionStateChange\x12\x32.android.emulation.bluetooth.ConnectionStateChange\x1a\x16.google.protobuf.EmptyB\"\n\x1e\x63om.android.emulator.bluetoothP\x01\x62\x06proto3')
+
+_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
+_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'emulated_bluetooth_device_pb2', globals())
+if _descriptor._USE_C_DESCRIPTORS == False:
+
+ DESCRIPTOR._options = None
+ DESCRIPTOR._serialized_options = b'\n\036com.android.emulator.bluetoothP\001'
+ _CALLBACKIDENTIFIER._serialized_start=126
+ _CALLBACKIDENTIFIER._serialized_end=164
+ _DEVICEIDENTIFIER._serialized_start=166
+ _DEVICEIDENTIFIER._serialized_end=201
+ _CONNECTIONSTATECHANGE._serialized_start=204
+ _CONNECTIONSTATECHANGE._serialized_end=577
+ _CONNECTIONSTATECHANGE_CONNECTIONSTATE._serialized_start=461
+ _CONNECTIONSTATECHANGE_CONNECTIONSTATE._serialized_end=577
+ _UUID._serialized_start=579
+ _UUID._serialized_end=644
+ _CHARACTERISTICVALUEREQUEST._serialized_start=647
+ _CHARACTERISTICVALUEREQUEST._serialized_end=890
+ _CHARACTERISTICVALUERESPONSE._serialized_start=893
+ _CHARACTERISTICVALUERESPONSE._serialized_end=1114
+ _CHARACTERISTICVALUERESPONSE_GATTSTATUS._serialized_start=1023
+ _CHARACTERISTICVALUERESPONSE_GATTSTATUS._serialized_end=1114
+ _GATTCHARACTERISTIC._serialized_start=1117
+ _GATTCHARACTERISTIC._serialized_end=1808
+ _GATTCHARACTERISTIC_PROPERTIES._serialized_start=1286
+ _GATTCHARACTERISTIC_PROPERTIES._serialized_end=1520
+ _GATTCHARACTERISTIC_PERMISSIONS._serialized_start=1523
+ _GATTCHARACTERISTIC_PERMISSIONS._serialized_end=1808
+ _GATTSERVICE._serialized_start=1811
+ _GATTSERVICE._serialized_end=2122
+ _GATTSERVICE_SERVICETYPE._serialized_start=2025
+ _GATTSERVICE_SERVICETYPE._serialized_end=2122
+ _GATTPROFILE._serialized_start=2124
+ _GATTPROFILE._serialized_end=2197
+ _ADVERTISEMENT._serialized_start=2200
+ _ADVERTISEMENT._serialized_end=2696
+ _ADVERTISEMENT_CONNECTIONMODE._serialized_start=2405
+ _ADVERTISEMENT_CONNECTIONMODE._serialized_end=2553
+ _ADVERTISEMENT_DISCOVERYMODE._serialized_start=2556
+ _ADVERTISEMENT_DISCOVERYMODE._serialized_end=2696
+ _GATTDEVICE._serialized_start=2699
+ _GATTDEVICE._serialized_end=2891
+ _GATTDEVICESERVICE._serialized_start=2894
+ _GATTDEVICESERVICE._serialized_end=3463
+# @@protoc_insertion_point(module_scope)
diff --git a/bumble/transport/grpc_protobuf/emulated_bluetooth_device_pb2.pyi b/bumble/transport/grpc_protobuf/emulated_bluetooth_device_pb2.pyi
new file mode 100644
index 0000000..c14501b
--- /dev/null
+++ b/bumble/transport/grpc_protobuf/emulated_bluetooth_device_pb2.pyi
@@ -0,0 +1,158 @@
+from . import grpc_endpoint_description_pb2 as _grpc_endpoint_description_pb2
+from google.protobuf import empty_pb2 as _empty_pb2
+from google.protobuf.internal import containers as _containers
+from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper
+from google.protobuf import descriptor as _descriptor
+from google.protobuf import message as _message
+from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Mapping, Optional as _Optional, Union as _Union
+
+DESCRIPTOR: _descriptor.FileDescriptor
+
+class Advertisement(_message.Message):
+ __slots__ = ["connection_mode", "device_name", "discovery_mode"]
+ class ConnectionMode(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
+ __slots__ = []
+ class DiscoveryMode(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
+ __slots__ = []
+ CONNECTION_MODE_DIRECTED: Advertisement.ConnectionMode
+ CONNECTION_MODE_FIELD_NUMBER: _ClassVar[int]
+ CONNECTION_MODE_NON_CONNECTABLE: Advertisement.ConnectionMode
+ CONNECTION_MODE_UNDIRECTED: Advertisement.ConnectionMode
+ CONNECTION_MODE_UNSPECIFIED: Advertisement.ConnectionMode
+ DEVICE_NAME_FIELD_NUMBER: _ClassVar[int]
+ DISCOVERY_MODE_FIELD_NUMBER: _ClassVar[int]
+ DISCOVERY_MODE_GENERAL: Advertisement.DiscoveryMode
+ DISCOVERY_MODE_LIMITED: Advertisement.DiscoveryMode
+ DISCOVERY_MODE_NON_DISCOVERABLE: Advertisement.DiscoveryMode
+ DISCOVERY_MODE_UNSPECIFIED: Advertisement.DiscoveryMode
+ connection_mode: Advertisement.ConnectionMode
+ device_name: str
+ discovery_mode: Advertisement.DiscoveryMode
+ def __init__(self, device_name: _Optional[str] = ..., connection_mode: _Optional[_Union[Advertisement.ConnectionMode, str]] = ..., discovery_mode: _Optional[_Union[Advertisement.DiscoveryMode, str]] = ...) -> None: ...
+
+class CallbackIdentifier(_message.Message):
+ __slots__ = ["identity"]
+ IDENTITY_FIELD_NUMBER: _ClassVar[int]
+ identity: str
+ def __init__(self, identity: _Optional[str] = ...) -> None: ...
+
+class CharacteristicValueRequest(_message.Message):
+ __slots__ = ["callback_device_id", "callback_id", "data", "from_device"]
+ CALLBACK_DEVICE_ID_FIELD_NUMBER: _ClassVar[int]
+ CALLBACK_ID_FIELD_NUMBER: _ClassVar[int]
+ DATA_FIELD_NUMBER: _ClassVar[int]
+ FROM_DEVICE_FIELD_NUMBER: _ClassVar[int]
+ callback_device_id: CallbackIdentifier
+ callback_id: Uuid
+ data: bytes
+ from_device: DeviceIdentifier
+ def __init__(self, callback_device_id: _Optional[_Union[CallbackIdentifier, _Mapping]] = ..., from_device: _Optional[_Union[DeviceIdentifier, _Mapping]] = ..., callback_id: _Optional[_Union[Uuid, _Mapping]] = ..., data: _Optional[bytes] = ...) -> None: ...
+
+class CharacteristicValueResponse(_message.Message):
+ __slots__ = ["data", "status"]
+ class GattStatus(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
+ __slots__ = []
+ DATA_FIELD_NUMBER: _ClassVar[int]
+ GATT_STATUS_FAILURE: CharacteristicValueResponse.GattStatus
+ GATT_STATUS_SUCCESS: CharacteristicValueResponse.GattStatus
+ GATT_STATUS_UNSPECIFIED: CharacteristicValueResponse.GattStatus
+ STATUS_FIELD_NUMBER: _ClassVar[int]
+ data: bytes
+ status: CharacteristicValueResponse.GattStatus
+ def __init__(self, status: _Optional[_Union[CharacteristicValueResponse.GattStatus, str]] = ..., data: _Optional[bytes] = ...) -> None: ...
+
+class ConnectionStateChange(_message.Message):
+ __slots__ = ["callback_device_id", "from_device", "new_state"]
+ class ConnectionState(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
+ __slots__ = []
+ CALLBACK_DEVICE_ID_FIELD_NUMBER: _ClassVar[int]
+ CONNECTION_STATE_CONNECTED: ConnectionStateChange.ConnectionState
+ CONNECTION_STATE_DISCONNECTED: ConnectionStateChange.ConnectionState
+ CONNECTION_STATE_UNDEFINED: ConnectionStateChange.ConnectionState
+ FROM_DEVICE_FIELD_NUMBER: _ClassVar[int]
+ NEW_STATE_FIELD_NUMBER: _ClassVar[int]
+ callback_device_id: CallbackIdentifier
+ from_device: DeviceIdentifier
+ new_state: ConnectionStateChange.ConnectionState
+ def __init__(self, callback_device_id: _Optional[_Union[CallbackIdentifier, _Mapping]] = ..., from_device: _Optional[_Union[DeviceIdentifier, _Mapping]] = ..., new_state: _Optional[_Union[ConnectionStateChange.ConnectionState, str]] = ...) -> None: ...
+
+class DeviceIdentifier(_message.Message):
+ __slots__ = ["address"]
+ ADDRESS_FIELD_NUMBER: _ClassVar[int]
+ address: str
+ def __init__(self, address: _Optional[str] = ...) -> None: ...
+
+class GattCharacteristic(_message.Message):
+ __slots__ = ["callback_id", "permissions", "properties", "uuid"]
+ class Permissions(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
+ __slots__ = []
+ class Properties(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
+ __slots__ = []
+ CALLBACK_ID_FIELD_NUMBER: _ClassVar[int]
+ PERMISSIONS_FIELD_NUMBER: _ClassVar[int]
+ PERMISSION_READ: GattCharacteristic.Permissions
+ PERMISSION_READ_ENCRYPTED: GattCharacteristic.Permissions
+ PERMISSION_READ_ENCRYPTED_MITM: GattCharacteristic.Permissions
+ PERMISSION_UNSPECIFIED: GattCharacteristic.Permissions
+ PERMISSION_WRITE: GattCharacteristic.Permissions
+ PERMISSION_WRITE_ENCRYPTED: GattCharacteristic.Permissions
+ PERMISSION_WRITE_ENCRYPTED_MITM: GattCharacteristic.Permissions
+ PERMISSION_WRITE_SIGNED: GattCharacteristic.Permissions
+ PERMISSION_WRITE_SIGNED_MITM: GattCharacteristic.Permissions
+ PROPERTIES_FIELD_NUMBER: _ClassVar[int]
+ PROPERTY_BROADCAST: GattCharacteristic.Properties
+ PROPERTY_EXTENDED_PROPS: GattCharacteristic.Properties
+ PROPERTY_INDICATE: GattCharacteristic.Properties
+ PROPERTY_NOTIFY: GattCharacteristic.Properties
+ PROPERTY_READ: GattCharacteristic.Properties
+ PROPERTY_SIGNED_WRITE: GattCharacteristic.Properties
+ PROPERTY_UNSPECIFIED: GattCharacteristic.Properties
+ PROPERTY_WRITE: GattCharacteristic.Properties
+ PROPERTY_WRITE_NO_RESPONSE: GattCharacteristic.Properties
+ UUID_FIELD_NUMBER: _ClassVar[int]
+ callback_id: Uuid
+ permissions: int
+ properties: int
+ uuid: Uuid
+ def __init__(self, uuid: _Optional[_Union[Uuid, _Mapping]] = ..., properties: _Optional[int] = ..., permissions: _Optional[int] = ..., callback_id: _Optional[_Union[Uuid, _Mapping]] = ...) -> None: ...
+
+class GattDevice(_message.Message):
+ __slots__ = ["advertisement", "endpoint", "profile"]
+ ADVERTISEMENT_FIELD_NUMBER: _ClassVar[int]
+ ENDPOINT_FIELD_NUMBER: _ClassVar[int]
+ PROFILE_FIELD_NUMBER: _ClassVar[int]
+ advertisement: Advertisement
+ endpoint: _grpc_endpoint_description_pb2.Endpoint
+ profile: GattProfile
+ def __init__(self, endpoint: _Optional[_Union[_grpc_endpoint_description_pb2.Endpoint, _Mapping]] = ..., advertisement: _Optional[_Union[Advertisement, _Mapping]] = ..., profile: _Optional[_Union[GattProfile, _Mapping]] = ...) -> None: ...
+
+class GattProfile(_message.Message):
+ __slots__ = ["services"]
+ SERVICES_FIELD_NUMBER: _ClassVar[int]
+ services: _containers.RepeatedCompositeFieldContainer[GattService]
+ def __init__(self, services: _Optional[_Iterable[_Union[GattService, _Mapping]]] = ...) -> None: ...
+
+class GattService(_message.Message):
+ __slots__ = ["characteristics", "service_type", "uuid"]
+ class ServiceType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
+ __slots__ = []
+ CHARACTERISTICS_FIELD_NUMBER: _ClassVar[int]
+ SERVICE_TYPE_FIELD_NUMBER: _ClassVar[int]
+ SERVICE_TYPE_PRIMARY: GattService.ServiceType
+ SERVICE_TYPE_SECONDARY: GattService.ServiceType
+ SERVICE_TYPE_UNSPECIFIED: GattService.ServiceType
+ UUID_FIELD_NUMBER: _ClassVar[int]
+ characteristics: _containers.RepeatedCompositeFieldContainer[GattCharacteristic]
+ service_type: GattService.ServiceType
+ uuid: Uuid
+ def __init__(self, uuid: _Optional[_Union[Uuid, _Mapping]] = ..., service_type: _Optional[_Union[GattService.ServiceType, str]] = ..., characteristics: _Optional[_Iterable[_Union[GattCharacteristic, _Mapping]]] = ...) -> None: ...
+
+class Uuid(_message.Message):
+ __slots__ = ["id", "lsb", "msb"]
+ ID_FIELD_NUMBER: _ClassVar[int]
+ LSB_FIELD_NUMBER: _ClassVar[int]
+ MSB_FIELD_NUMBER: _ClassVar[int]
+ id: int
+ lsb: int
+ msb: int
+ def __init__(self, id: _Optional[int] = ..., lsb: _Optional[int] = ..., msb: _Optional[int] = ...) -> None: ...
diff --git a/bumble/transport/grpc_protobuf/emulated_bluetooth_device_pb2_grpc.py b/bumble/transport/grpc_protobuf/emulated_bluetooth_device_pb2_grpc.py
new file mode 100644
index 0000000..754627d
--- /dev/null
+++ b/bumble/transport/grpc_protobuf/emulated_bluetooth_device_pb2_grpc.py
@@ -0,0 +1,193 @@
+# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
+"""Client and server classes corresponding to protobuf-defined services."""
+import grpc
+
+from . import emulated_bluetooth_device_pb2 as emulated__bluetooth__device__pb2
+from google.protobuf import empty_pb2 as google_dot_protobuf_dot_empty__pb2
+
+
+class GattDeviceServiceStub(object):
+ """You can provide your own GattDevice by implementing this service
+ and registering it with the android emulator.
+
+ The device will appear as a real bluetooth device, and you will
+ receive callbacks when the bluetooth system wants to
+ read, write or observe a characteristic.
+ """
+
+ def __init__(self, channel):
+ """Constructor.
+
+ Args:
+ channel: A grpc.Channel.
+ """
+ self.OnCharacteristicReadRequest = channel.unary_unary(
+ '/android.emulation.bluetooth.GattDeviceService/OnCharacteristicReadRequest',
+ request_serializer=emulated__bluetooth__device__pb2.CharacteristicValueRequest.SerializeToString,
+ response_deserializer=emulated__bluetooth__device__pb2.CharacteristicValueResponse.FromString,
+ )
+ self.OnCharacteristicWriteRequest = channel.unary_unary(
+ '/android.emulation.bluetooth.GattDeviceService/OnCharacteristicWriteRequest',
+ request_serializer=emulated__bluetooth__device__pb2.CharacteristicValueRequest.SerializeToString,
+ response_deserializer=emulated__bluetooth__device__pb2.CharacteristicValueResponse.FromString,
+ )
+ self.OnCharacteristicObserveRequest = channel.unary_stream(
+ '/android.emulation.bluetooth.GattDeviceService/OnCharacteristicObserveRequest',
+ request_serializer=emulated__bluetooth__device__pb2.CharacteristicValueRequest.SerializeToString,
+ response_deserializer=emulated__bluetooth__device__pb2.CharacteristicValueResponse.FromString,
+ )
+ self.OnConnectionStateChange = channel.unary_unary(
+ '/android.emulation.bluetooth.GattDeviceService/OnConnectionStateChange',
+ request_serializer=emulated__bluetooth__device__pb2.ConnectionStateChange.SerializeToString,
+ response_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString,
+ )
+
+
+class GattDeviceServiceServicer(object):
+ """You can provide your own GattDevice by implementing this service
+ and registering it with the android emulator.
+
+ The device will appear as a real bluetooth device, and you will
+ receive callbacks when the bluetooth system wants to
+ read, write or observe a characteristic.
+ """
+
+ def OnCharacteristicReadRequest(self, request, context):
+ """A remote client has requested to read a local characteristic.
+
+ Return the current observed value.
+ """
+ context.set_code(grpc.StatusCode.UNIMPLEMENTED)
+ context.set_details('Method not implemented!')
+ raise NotImplementedError('Method not implemented!')
+
+ def OnCharacteristicWriteRequest(self, request, context):
+ """A remote client has requested to write to a local characteristic.
+
+ Return the current observed value.
+ """
+ context.set_code(grpc.StatusCode.UNIMPLEMENTED)
+ context.set_details('Method not implemented!')
+ raise NotImplementedError('Method not implemented!')
+
+ def OnCharacteristicObserveRequest(self, request, context):
+ """Listens for notifications from the emulated device, the device should
+ write to the stream with a response when a change has occurred.
+ """
+ context.set_code(grpc.StatusCode.UNIMPLEMENTED)
+ context.set_details('Method not implemented!')
+ raise NotImplementedError('Method not implemented!')
+
+ def OnConnectionStateChange(self, request, context):
+ """A remote device has been connected or disconnected.
+ """
+ context.set_code(grpc.StatusCode.UNIMPLEMENTED)
+ context.set_details('Method not implemented!')
+ raise NotImplementedError('Method not implemented!')
+
+
+def add_GattDeviceServiceServicer_to_server(servicer, server):
+ rpc_method_handlers = {
+ 'OnCharacteristicReadRequest': grpc.unary_unary_rpc_method_handler(
+ servicer.OnCharacteristicReadRequest,
+ request_deserializer=emulated__bluetooth__device__pb2.CharacteristicValueRequest.FromString,
+ response_serializer=emulated__bluetooth__device__pb2.CharacteristicValueResponse.SerializeToString,
+ ),
+ 'OnCharacteristicWriteRequest': grpc.unary_unary_rpc_method_handler(
+ servicer.OnCharacteristicWriteRequest,
+ request_deserializer=emulated__bluetooth__device__pb2.CharacteristicValueRequest.FromString,
+ response_serializer=emulated__bluetooth__device__pb2.CharacteristicValueResponse.SerializeToString,
+ ),
+ 'OnCharacteristicObserveRequest': grpc.unary_stream_rpc_method_handler(
+ servicer.OnCharacteristicObserveRequest,
+ request_deserializer=emulated__bluetooth__device__pb2.CharacteristicValueRequest.FromString,
+ response_serializer=emulated__bluetooth__device__pb2.CharacteristicValueResponse.SerializeToString,
+ ),
+ 'OnConnectionStateChange': grpc.unary_unary_rpc_method_handler(
+ servicer.OnConnectionStateChange,
+ request_deserializer=emulated__bluetooth__device__pb2.ConnectionStateChange.FromString,
+ response_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString,
+ ),
+ }
+ generic_handler = grpc.method_handlers_generic_handler(
+ 'android.emulation.bluetooth.GattDeviceService', rpc_method_handlers)
+ server.add_generic_rpc_handlers((generic_handler,))
+
+
+ # This class is part of an EXPERIMENTAL API.
+class GattDeviceService(object):
+ """You can provide your own GattDevice by implementing this service
+ and registering it with the android emulator.
+
+ The device will appear as a real bluetooth device, and you will
+ receive callbacks when the bluetooth system wants to
+ read, write or observe a characteristic.
+ """
+
+ @staticmethod
+ def OnCharacteristicReadRequest(request,
+ target,
+ options=(),
+ channel_credentials=None,
+ call_credentials=None,
+ insecure=False,
+ compression=None,
+ wait_for_ready=None,
+ timeout=None,
+ metadata=None):
+ return grpc.experimental.unary_unary(request, target, '/android.emulation.bluetooth.GattDeviceService/OnCharacteristicReadRequest',
+ emulated__bluetooth__device__pb2.CharacteristicValueRequest.SerializeToString,
+ emulated__bluetooth__device__pb2.CharacteristicValueResponse.FromString,
+ options, channel_credentials,
+ insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
+
+ @staticmethod
+ def OnCharacteristicWriteRequest(request,
+ target,
+ options=(),
+ channel_credentials=None,
+ call_credentials=None,
+ insecure=False,
+ compression=None,
+ wait_for_ready=None,
+ timeout=None,
+ metadata=None):
+ return grpc.experimental.unary_unary(request, target, '/android.emulation.bluetooth.GattDeviceService/OnCharacteristicWriteRequest',
+ emulated__bluetooth__device__pb2.CharacteristicValueRequest.SerializeToString,
+ emulated__bluetooth__device__pb2.CharacteristicValueResponse.FromString,
+ options, channel_credentials,
+ insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
+
+ @staticmethod
+ def OnCharacteristicObserveRequest(request,
+ target,
+ options=(),
+ channel_credentials=None,
+ call_credentials=None,
+ insecure=False,
+ compression=None,
+ wait_for_ready=None,
+ timeout=None,
+ metadata=None):
+ return grpc.experimental.unary_stream(request, target, '/android.emulation.bluetooth.GattDeviceService/OnCharacteristicObserveRequest',
+ emulated__bluetooth__device__pb2.CharacteristicValueRequest.SerializeToString,
+ emulated__bluetooth__device__pb2.CharacteristicValueResponse.FromString,
+ options, channel_credentials,
+ insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
+
+ @staticmethod
+ def OnConnectionStateChange(request,
+ target,
+ options=(),
+ channel_credentials=None,
+ call_credentials=None,
+ insecure=False,
+ compression=None,
+ wait_for_ready=None,
+ timeout=None,
+ metadata=None):
+ return grpc.experimental.unary_unary(request, target, '/android.emulation.bluetooth.GattDeviceService/OnConnectionStateChange',
+ emulated__bluetooth__device__pb2.ConnectionStateChange.SerializeToString,
+ google_dot_protobuf_dot_empty__pb2.Empty.FromString,
+ options, channel_credentials,
+ insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
diff --git a/bumble/transport/grpc_protobuf/emulated_bluetooth_packets_pb2.py b/bumble/transport/grpc_protobuf/emulated_bluetooth_packets_pb2.py
new file mode 100644
index 0000000..8544f95
--- /dev/null
+++ b/bumble/transport/grpc_protobuf/emulated_bluetooth_packets_pb2.py
@@ -0,0 +1,28 @@
+# -*- coding: utf-8 -*-
+# Generated by the protocol buffer compiler. DO NOT EDIT!
+# source: emulated_bluetooth_packets.proto
+"""Generated protocol buffer code."""
+from google.protobuf.internal import builder as _builder
+from google.protobuf import descriptor as _descriptor
+from google.protobuf import descriptor_pool as _descriptor_pool
+from google.protobuf import symbol_database as _symbol_database
+# @@protoc_insertion_point(imports)
+
+_sym_db = _symbol_database.Default()
+
+
+
+
+DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n emulated_bluetooth_packets.proto\x12\x1b\x61ndroid.emulation.bluetooth\"\xfb\x01\n\tHCIPacket\x12?\n\x04type\x18\x01 \x01(\x0e\x32\x31.android.emulation.bluetooth.HCIPacket.PacketType\x12\x0e\n\x06packet\x18\x02 \x01(\x0c\"\x9c\x01\n\nPacketType\x12\x1b\n\x17PACKET_TYPE_UNSPECIFIED\x10\x00\x12\x1b\n\x17PACKET_TYPE_HCI_COMMAND\x10\x01\x12\x13\n\x0fPACKET_TYPE_ACL\x10\x02\x12\x13\n\x0fPACKET_TYPE_SCO\x10\x03\x12\x15\n\x11PACKET_TYPE_EVENT\x10\x04\x12\x13\n\x0fPACKET_TYPE_ISO\x10\x05\x42J\n\x1f\x63om.android.emulation.bluetoothP\x01\xf8\x01\x01\xa2\x02\x03\x41\x45\x42\xaa\x02\x1b\x41ndroid.Emulation.Bluetoothb\x06proto3')
+
+_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
+_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'emulated_bluetooth_packets_pb2', globals())
+if _descriptor._USE_C_DESCRIPTORS == False:
+
+ DESCRIPTOR._options = None
+ DESCRIPTOR._serialized_options = b'\n\037com.android.emulation.bluetoothP\001\370\001\001\242\002\003AEB\252\002\033Android.Emulation.Bluetooth'
+ _HCIPACKET._serialized_start=66
+ _HCIPACKET._serialized_end=317
+ _HCIPACKET_PACKETTYPE._serialized_start=161
+ _HCIPACKET_PACKETTYPE._serialized_end=317
+# @@protoc_insertion_point(module_scope)
diff --git a/bumble/transport/emulated_bluetooth_packets_pb2.pyi b/bumble/transport/grpc_protobuf/emulated_bluetooth_packets_pb2.pyi
index a823e78..80cdee1 100644
--- a/bumble/transport/emulated_bluetooth_packets_pb2.pyi
+++ b/bumble/transport/grpc_protobuf/emulated_bluetooth_packets_pb2.pyi
@@ -1,17 +1,3 @@
-# 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.
-
from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper
from google.protobuf import descriptor as _descriptor
from google.protobuf import message as _message
@@ -21,7 +7,6 @@ DESCRIPTOR: _descriptor.FileDescriptor
class HCIPacket(_message.Message):
__slots__ = ["packet", "type"]
-
class PacketType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
__slots__ = []
PACKET_FIELD_NUMBER: _ClassVar[int]
@@ -34,8 +19,4 @@ class HCIPacket(_message.Message):
TYPE_FIELD_NUMBER: _ClassVar[int]
packet: bytes
type: HCIPacket.PacketType
- def __init__(
- self,
- type: _Optional[_Union[HCIPacket.PacketType, str]] = ...,
- packet: _Optional[bytes] = ...,
- ) -> None: ...
+ def __init__(self, type: _Optional[_Union[HCIPacket.PacketType, str]] = ..., packet: _Optional[bytes] = ...) -> None: ...
diff --git a/bumble/transport/grpc_protobuf/emulated_bluetooth_packets_pb2_grpc.py b/bumble/transport/grpc_protobuf/emulated_bluetooth_packets_pb2_grpc.py
new file mode 100644
index 0000000..2daafff
--- /dev/null
+++ b/bumble/transport/grpc_protobuf/emulated_bluetooth_packets_pb2_grpc.py
@@ -0,0 +1,4 @@
+# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
+"""Client and server classes corresponding to protobuf-defined services."""
+import grpc
+
diff --git a/bumble/transport/grpc_protobuf/emulated_bluetooth_pb2.py b/bumble/transport/grpc_protobuf/emulated_bluetooth_pb2.py
new file mode 100644
index 0000000..054b250
--- /dev/null
+++ b/bumble/transport/grpc_protobuf/emulated_bluetooth_pb2.py
@@ -0,0 +1,32 @@
+# -*- coding: utf-8 -*-
+# Generated by the protocol buffer compiler. DO NOT EDIT!
+# source: emulated_bluetooth.proto
+"""Generated protocol buffer code."""
+from google.protobuf.internal import builder as _builder
+from google.protobuf import descriptor as _descriptor
+from google.protobuf import descriptor_pool as _descriptor_pool
+from google.protobuf import symbol_database as _symbol_database
+# @@protoc_insertion_point(imports)
+
+_sym_db = _symbol_database.Default()
+
+
+from . import emulated_bluetooth_packets_pb2 as emulated__bluetooth__packets__pb2
+from . import emulated_bluetooth_device_pb2 as emulated__bluetooth__device__pb2
+
+
+DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x18\x65mulated_bluetooth.proto\x12\x1b\x61ndroid.emulation.bluetooth\x1a emulated_bluetooth_packets.proto\x1a\x1f\x65mulated_bluetooth_device.proto\"\x19\n\x07RawData\x12\x0e\n\x06packet\x18\x01 \x01(\x0c\"a\n\x12RegistrationStatus\x12K\n\x12\x63\x61llback_device_id\x18\x01 \x01(\x0b\x32/.android.emulation.bluetooth.CallbackIdentifier2\xbb\x03\n\x18\x45mulatedBluetoothService\x12\x64\n\x12registerClassicPhy\x12$.android.emulation.bluetooth.RawData\x1a$.android.emulation.bluetooth.RawData(\x01\x30\x01\x12`\n\x0eregisterBlePhy\x12$.android.emulation.bluetooth.RawData\x1a$.android.emulation.bluetooth.RawData(\x01\x30\x01\x12g\n\x11registerHCIDevice\x12&.android.emulation.bluetooth.HCIPacket\x1a&.android.emulation.bluetooth.HCIPacket(\x01\x30\x01\x12n\n\x12registerGattDevice\x12\'.android.emulation.bluetooth.GattDevice\x1a/.android.emulation.bluetooth.RegistrationStatusB\"\n\x1e\x63om.android.emulator.bluetoothP\x01\x62\x06proto3')
+
+_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
+_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'emulated_bluetooth_pb2', globals())
+if _descriptor._USE_C_DESCRIPTORS == False:
+
+ DESCRIPTOR._options = None
+ DESCRIPTOR._serialized_options = b'\n\036com.android.emulator.bluetoothP\001'
+ _RAWDATA._serialized_start=124
+ _RAWDATA._serialized_end=149
+ _REGISTRATIONSTATUS._serialized_start=151
+ _REGISTRATIONSTATUS._serialized_end=248
+ _EMULATEDBLUETOOTHSERVICE._serialized_start=251
+ _EMULATEDBLUETOOTHSERVICE._serialized_end=694
+# @@protoc_insertion_point(module_scope)
diff --git a/bumble/transport/grpc_protobuf/emulated_bluetooth_pb2.pyi b/bumble/transport/grpc_protobuf/emulated_bluetooth_pb2.pyi
new file mode 100644
index 0000000..9f54383
--- /dev/null
+++ b/bumble/transport/grpc_protobuf/emulated_bluetooth_pb2.pyi
@@ -0,0 +1,19 @@
+from . import emulated_bluetooth_packets_pb2 as _emulated_bluetooth_packets_pb2
+from . import emulated_bluetooth_device_pb2 as _emulated_bluetooth_device_pb2
+from google.protobuf import descriptor as _descriptor
+from google.protobuf import message as _message
+from typing import ClassVar as _ClassVar, Mapping as _Mapping, Optional as _Optional, Union as _Union
+
+DESCRIPTOR: _descriptor.FileDescriptor
+
+class RawData(_message.Message):
+ __slots__ = ["packet"]
+ PACKET_FIELD_NUMBER: _ClassVar[int]
+ packet: bytes
+ def __init__(self, packet: _Optional[bytes] = ...) -> None: ...
+
+class RegistrationStatus(_message.Message):
+ __slots__ = ["callback_device_id"]
+ CALLBACK_DEVICE_ID_FIELD_NUMBER: _ClassVar[int]
+ callback_device_id: _emulated_bluetooth_device_pb2.CallbackIdentifier
+ def __init__(self, callback_device_id: _Optional[_Union[_emulated_bluetooth_device_pb2.CallbackIdentifier, _Mapping]] = ...) -> None: ...
diff --git a/bumble/transport/grpc_protobuf/emulated_bluetooth_pb2_grpc.py b/bumble/transport/grpc_protobuf/emulated_bluetooth_pb2_grpc.py
new file mode 100644
index 0000000..9ad3bb9
--- /dev/null
+++ b/bumble/transport/grpc_protobuf/emulated_bluetooth_pb2_grpc.py
@@ -0,0 +1,237 @@
+# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
+"""Client and server classes corresponding to protobuf-defined services."""
+import grpc
+
+from . import emulated_bluetooth_device_pb2 as emulated__bluetooth__device__pb2
+from . import emulated_bluetooth_packets_pb2 as emulated__bluetooth__packets__pb2
+from . import emulated_bluetooth_pb2 as emulated__bluetooth__pb2
+
+
+class EmulatedBluetoothServiceStub(object):
+ """An Emulated Bluetooth Service exposes the emulated bluetooth chip from the
+ android emulator. It allows you to register emulated bluetooth devices and
+ control the packets that are exchanged between the device and the world.
+
+ This service enables you to establish a "virtual network" of emulated
+ bluetooth devices that can interact with each other.
+
+ Note: This is not yet finalized, it is likely that these definitions will
+ evolve.
+ """
+
+ def __init__(self, channel):
+ """Constructor.
+
+ Args:
+ channel: A grpc.Channel.
+ """
+ self.registerClassicPhy = channel.stream_stream(
+ '/android.emulation.bluetooth.EmulatedBluetoothService/registerClassicPhy',
+ request_serializer=emulated__bluetooth__pb2.RawData.SerializeToString,
+ response_deserializer=emulated__bluetooth__pb2.RawData.FromString,
+ )
+ self.registerBlePhy = channel.stream_stream(
+ '/android.emulation.bluetooth.EmulatedBluetoothService/registerBlePhy',
+ request_serializer=emulated__bluetooth__pb2.RawData.SerializeToString,
+ response_deserializer=emulated__bluetooth__pb2.RawData.FromString,
+ )
+ self.registerHCIDevice = channel.stream_stream(
+ '/android.emulation.bluetooth.EmulatedBluetoothService/registerHCIDevice',
+ request_serializer=emulated__bluetooth__packets__pb2.HCIPacket.SerializeToString,
+ response_deserializer=emulated__bluetooth__packets__pb2.HCIPacket.FromString,
+ )
+ self.registerGattDevice = channel.unary_unary(
+ '/android.emulation.bluetooth.EmulatedBluetoothService/registerGattDevice',
+ request_serializer=emulated__bluetooth__device__pb2.GattDevice.SerializeToString,
+ response_deserializer=emulated__bluetooth__pb2.RegistrationStatus.FromString,
+ )
+
+
+class EmulatedBluetoothServiceServicer(object):
+ """An Emulated Bluetooth Service exposes the emulated bluetooth chip from the
+ android emulator. It allows you to register emulated bluetooth devices and
+ control the packets that are exchanged between the device and the world.
+
+ This service enables you to establish a "virtual network" of emulated
+ bluetooth devices that can interact with each other.
+
+ Note: This is not yet finalized, it is likely that these definitions will
+ evolve.
+ """
+
+ def registerClassicPhy(self, request_iterator, context):
+ """Connect device to link layer. This will establish a direct connection
+ to the emulated bluetooth chip and configure the following:
+
+ - Each connection creates a new device and attaches it to the link layer
+ - Link Layer packets are transmitted directly to the phy
+
+ This should be used for classic connections.
+
+ This is used to directly connect various android emulators together.
+ For example a wear device can connect to an android emulator through
+ this.
+ """
+ context.set_code(grpc.StatusCode.UNIMPLEMENTED)
+ context.set_details('Method not implemented!')
+ raise NotImplementedError('Method not implemented!')
+
+ def registerBlePhy(self, request_iterator, context):
+ """Connect device to link layer. This will establish a direct connection
+ to root canal and execute the following:
+
+ - Each connection creates a new device and attaches it to the link layer
+ - Link Layer packets are transmitted directly to the phy
+
+ This should be used for BLE connections.
+
+ This is used to directly connect various android emulators together.
+ For example a wear device can connect to an android emulator through
+ this.
+ """
+ context.set_code(grpc.StatusCode.UNIMPLEMENTED)
+ context.set_details('Method not implemented!')
+ raise NotImplementedError('Method not implemented!')
+
+ def registerHCIDevice(self, request_iterator, context):
+ """Connect the device to the emulated bluetooth chip. The device will
+ participate in the network. You can configure the chip to scan, advertise
+ and setup connections with other devices that are connected to the
+ network.
+
+ This is usually used when you have a need for an emulated bluetooth chip
+ and have a bluetooth stack that can interpret and handle the packets
+ correctly.
+
+ For example the apache nimble stack can use this endpoint as the
+ transport layer.
+ """
+ context.set_code(grpc.StatusCode.UNIMPLEMENTED)
+ context.set_details('Method not implemented!')
+ raise NotImplementedError('Method not implemented!')
+
+ def registerGattDevice(self, request, context):
+ """Registers an emulated bluetooth device. The emulator will reach out to
+ the emulated device to read/write and subscribe to properties.
+
+ The following gRPC error codes can be returned:
+ - FAILED_PRECONDITION (code 9):
+ - root canal is not available on this device
+ - unable to reach the endpoint for the GattDevice
+ - INTERNAL (code 13) if there was an internal emulator failure.
+
+ The device will not be discoverable in case of an error.
+ """
+ context.set_code(grpc.StatusCode.UNIMPLEMENTED)
+ context.set_details('Method not implemented!')
+ raise NotImplementedError('Method not implemented!')
+
+
+def add_EmulatedBluetoothServiceServicer_to_server(servicer, server):
+ rpc_method_handlers = {
+ 'registerClassicPhy': grpc.stream_stream_rpc_method_handler(
+ servicer.registerClassicPhy,
+ request_deserializer=emulated__bluetooth__pb2.RawData.FromString,
+ response_serializer=emulated__bluetooth__pb2.RawData.SerializeToString,
+ ),
+ 'registerBlePhy': grpc.stream_stream_rpc_method_handler(
+ servicer.registerBlePhy,
+ request_deserializer=emulated__bluetooth__pb2.RawData.FromString,
+ response_serializer=emulated__bluetooth__pb2.RawData.SerializeToString,
+ ),
+ 'registerHCIDevice': grpc.stream_stream_rpc_method_handler(
+ servicer.registerHCIDevice,
+ request_deserializer=emulated__bluetooth__packets__pb2.HCIPacket.FromString,
+ response_serializer=emulated__bluetooth__packets__pb2.HCIPacket.SerializeToString,
+ ),
+ 'registerGattDevice': grpc.unary_unary_rpc_method_handler(
+ servicer.registerGattDevice,
+ request_deserializer=emulated__bluetooth__device__pb2.GattDevice.FromString,
+ response_serializer=emulated__bluetooth__pb2.RegistrationStatus.SerializeToString,
+ ),
+ }
+ generic_handler = grpc.method_handlers_generic_handler(
+ 'android.emulation.bluetooth.EmulatedBluetoothService', rpc_method_handlers)
+ server.add_generic_rpc_handlers((generic_handler,))
+
+
+ # This class is part of an EXPERIMENTAL API.
+class EmulatedBluetoothService(object):
+ """An Emulated Bluetooth Service exposes the emulated bluetooth chip from the
+ android emulator. It allows you to register emulated bluetooth devices and
+ control the packets that are exchanged between the device and the world.
+
+ This service enables you to establish a "virtual network" of emulated
+ bluetooth devices that can interact with each other.
+
+ Note: This is not yet finalized, it is likely that these definitions will
+ evolve.
+ """
+
+ @staticmethod
+ def registerClassicPhy(request_iterator,
+ target,
+ options=(),
+ channel_credentials=None,
+ call_credentials=None,
+ insecure=False,
+ compression=None,
+ wait_for_ready=None,
+ timeout=None,
+ metadata=None):
+ return grpc.experimental.stream_stream(request_iterator, target, '/android.emulation.bluetooth.EmulatedBluetoothService/registerClassicPhy',
+ emulated__bluetooth__pb2.RawData.SerializeToString,
+ emulated__bluetooth__pb2.RawData.FromString,
+ options, channel_credentials,
+ insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
+
+ @staticmethod
+ def registerBlePhy(request_iterator,
+ target,
+ options=(),
+ channel_credentials=None,
+ call_credentials=None,
+ insecure=False,
+ compression=None,
+ wait_for_ready=None,
+ timeout=None,
+ metadata=None):
+ return grpc.experimental.stream_stream(request_iterator, target, '/android.emulation.bluetooth.EmulatedBluetoothService/registerBlePhy',
+ emulated__bluetooth__pb2.RawData.SerializeToString,
+ emulated__bluetooth__pb2.RawData.FromString,
+ options, channel_credentials,
+ insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
+
+ @staticmethod
+ def registerHCIDevice(request_iterator,
+ target,
+ options=(),
+ channel_credentials=None,
+ call_credentials=None,
+ insecure=False,
+ compression=None,
+ wait_for_ready=None,
+ timeout=None,
+ metadata=None):
+ return grpc.experimental.stream_stream(request_iterator, target, '/android.emulation.bluetooth.EmulatedBluetoothService/registerHCIDevice',
+ emulated__bluetooth__packets__pb2.HCIPacket.SerializeToString,
+ emulated__bluetooth__packets__pb2.HCIPacket.FromString,
+ options, channel_credentials,
+ insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
+
+ @staticmethod
+ def registerGattDevice(request,
+ target,
+ options=(),
+ channel_credentials=None,
+ call_credentials=None,
+ insecure=False,
+ compression=None,
+ wait_for_ready=None,
+ timeout=None,
+ metadata=None):
+ return grpc.experimental.unary_unary(request, target, '/android.emulation.bluetooth.EmulatedBluetoothService/registerGattDevice',
+ emulated__bluetooth__device__pb2.GattDevice.SerializeToString,
+ emulated__bluetooth__pb2.RegistrationStatus.FromString,
+ options, channel_credentials,
+ insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
diff --git a/bumble/transport/grpc_protobuf/emulated_bluetooth_vhci_pb2.py b/bumble/transport/grpc_protobuf/emulated_bluetooth_vhci_pb2.py
new file mode 100644
index 0000000..30a2cb6
--- /dev/null
+++ b/bumble/transport/grpc_protobuf/emulated_bluetooth_vhci_pb2.py
@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+# Generated by the protocol buffer compiler. DO NOT EDIT!
+# source: emulated_bluetooth_vhci.proto
+"""Generated protocol buffer code."""
+from google.protobuf.internal import builder as _builder
+from google.protobuf import descriptor as _descriptor
+from google.protobuf import descriptor_pool as _descriptor_pool
+from google.protobuf import symbol_database as _symbol_database
+# @@protoc_insertion_point(imports)
+
+_sym_db = _symbol_database.Default()
+
+
+from . import emulated_bluetooth_packets_pb2 as emulated__bluetooth__packets__pb2
+
+
+DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1d\x65mulated_bluetooth_vhci.proto\x12\x1b\x61ndroid.emulation.bluetooth\x1a emulated_bluetooth_packets.proto2y\n\x15VhciForwardingService\x12`\n\nattachVhci\x12&.android.emulation.bluetooth.HCIPacket\x1a&.android.emulation.bluetooth.HCIPacket(\x01\x30\x01\x42J\n\x1f\x63om.android.emulation.bluetoothP\x01\xf8\x01\x01\xa2\x02\x03\x41\x45\x42\xaa\x02\x1b\x41ndroid.Emulation.Bluetoothb\x06proto3')
+
+_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
+_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'emulated_bluetooth_vhci_pb2', globals())
+if _descriptor._USE_C_DESCRIPTORS == False:
+
+ DESCRIPTOR._options = None
+ DESCRIPTOR._serialized_options = b'\n\037com.android.emulation.bluetoothP\001\370\001\001\242\002\003AEB\252\002\033Android.Emulation.Bluetooth'
+ _VHCIFORWARDINGSERVICE._serialized_start=96
+ _VHCIFORWARDINGSERVICE._serialized_end=217
+# @@protoc_insertion_point(module_scope)
diff --git a/bumble/transport/grpc_protobuf/emulated_bluetooth_vhci_pb2.pyi b/bumble/transport/grpc_protobuf/emulated_bluetooth_vhci_pb2.pyi
new file mode 100644
index 0000000..3311c9b
--- /dev/null
+++ b/bumble/transport/grpc_protobuf/emulated_bluetooth_vhci_pb2.pyi
@@ -0,0 +1,5 @@
+import emulated_bluetooth_packets_pb2 as _emulated_bluetooth_packets_pb2
+from google.protobuf import descriptor as _descriptor
+from typing import ClassVar as _ClassVar
+
+DESCRIPTOR: _descriptor.FileDescriptor
diff --git a/bumble/transport/emulated_bluetooth_vhci_pb2_grpc.py b/bumble/transport/grpc_protobuf/emulated_bluetooth_vhci_pb2_grpc.py
index 41f0feb..031f669 100644
--- a/bumble/transport/emulated_bluetooth_vhci_pb2_grpc.py
+++ b/bumble/transport/grpc_protobuf/emulated_bluetooth_vhci_pb2_grpc.py
@@ -1,17 +1,3 @@
-# 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.
-
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
"""Client and server classes corresponding to protobuf-defined services."""
import grpc
@@ -35,10 +21,10 @@ class VhciForwardingServiceStub(object):
channel: A grpc.Channel.
"""
self.attachVhci = channel.stream_stream(
- '/android.emulation.bluetooth.VhciForwardingService/attachVhci',
- request_serializer=emulated__bluetooth__packets__pb2.HCIPacket.SerializeToString,
- response_deserializer=emulated__bluetooth__packets__pb2.HCIPacket.FromString,
- )
+ '/android.emulation.bluetooth.VhciForwardingService/attachVhci',
+ request_serializer=emulated__bluetooth__packets__pb2.HCIPacket.SerializeToString,
+ response_deserializer=emulated__bluetooth__packets__pb2.HCIPacket.FromString,
+ )
class VhciForwardingServiceServicer(object):
@@ -75,19 +61,18 @@ class VhciForwardingServiceServicer(object):
def add_VhciForwardingServiceServicer_to_server(servicer, server):
rpc_method_handlers = {
- 'attachVhci': grpc.stream_stream_rpc_method_handler(
- servicer.attachVhci,
- request_deserializer=emulated__bluetooth__packets__pb2.HCIPacket.FromString,
- response_serializer=emulated__bluetooth__packets__pb2.HCIPacket.SerializeToString,
- ),
+ 'attachVhci': grpc.stream_stream_rpc_method_handler(
+ servicer.attachVhci,
+ request_deserializer=emulated__bluetooth__packets__pb2.HCIPacket.FromString,
+ response_serializer=emulated__bluetooth__packets__pb2.HCIPacket.SerializeToString,
+ ),
}
generic_handler = grpc.method_handlers_generic_handler(
- 'android.emulation.bluetooth.VhciForwardingService', rpc_method_handlers
- )
+ 'android.emulation.bluetooth.VhciForwardingService', rpc_method_handlers)
server.add_generic_rpc_handlers((generic_handler,))
-# This class is part of an EXPERIMENTAL API.
+ # This class is part of an EXPERIMENTAL API.
class VhciForwardingService(object):
"""This is a service which allows you to directly intercept the VHCI packets
that are coming and going to the device before they are delivered to
@@ -98,30 +83,18 @@ class VhciForwardingService(object):
"""
@staticmethod
- def attachVhci(
- request_iterator,
- target,
- options=(),
- channel_credentials=None,
- call_credentials=None,
- insecure=False,
- compression=None,
- wait_for_ready=None,
- timeout=None,
- metadata=None,
- ):
- return grpc.experimental.stream_stream(
- request_iterator,
+ def attachVhci(request_iterator,
target,
- '/android.emulation.bluetooth.VhciForwardingService/attachVhci',
+ options=(),
+ channel_credentials=None,
+ call_credentials=None,
+ insecure=False,
+ compression=None,
+ wait_for_ready=None,
+ timeout=None,
+ metadata=None):
+ return grpc.experimental.stream_stream(request_iterator, target, '/android.emulation.bluetooth.VhciForwardingService/attachVhci',
emulated__bluetooth__packets__pb2.HCIPacket.SerializeToString,
emulated__bluetooth__packets__pb2.HCIPacket.FromString,
- options,
- channel_credentials,
- insecure,
- call_credentials,
- compression,
- wait_for_ready,
- timeout,
- metadata,
- )
+ options, channel_credentials,
+ insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
diff --git a/bumble/transport/grpc_protobuf/grpc_endpoint_description_pb2.py b/bumble/transport/grpc_protobuf/grpc_endpoint_description_pb2.py
new file mode 100644
index 0000000..00d404c
--- /dev/null
+++ b/bumble/transport/grpc_protobuf/grpc_endpoint_description_pb2.py
@@ -0,0 +1,30 @@
+# -*- coding: utf-8 -*-
+# Generated by the protocol buffer compiler. DO NOT EDIT!
+# source: grpc_endpoint_description.proto
+"""Generated protocol buffer code."""
+from google.protobuf.internal import builder as _builder
+from google.protobuf import descriptor as _descriptor
+from google.protobuf import descriptor_pool as _descriptor_pool
+from google.protobuf import symbol_database as _symbol_database
+# @@protoc_insertion_point(imports)
+
+_sym_db = _symbol_database.Default()
+
+
+
+
+DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1fgrpc_endpoint_description.proto\x12\x18\x61ndroid.emulation.remote\"V\n\x0b\x43redentials\x12\x16\n\x0epem_root_certs\x18\x01 \x01(\t\x12\x17\n\x0fpem_private_key\x18\x02 \x01(\t\x12\x16\n\x0epem_cert_chain\x18\x03 \x01(\t\"$\n\x06Header\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\"\x96\x01\n\x08\x45ndpoint\x12\x0e\n\x06target\x18\x01 \x01(\t\x12>\n\x0ftls_credentials\x18\x02 \x01(\x0b\x32%.android.emulation.remote.Credentials\x12:\n\x10required_headers\x18\x03 \x03(\x0b\x32 .android.emulation.remote.HeaderB \n\x1c\x63om.android.emulation.remoteP\x01\x62\x06proto3')
+
+_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
+_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'grpc_endpoint_description_pb2', globals())
+if _descriptor._USE_C_DESCRIPTORS == False:
+
+ DESCRIPTOR._options = None
+ DESCRIPTOR._serialized_options = b'\n\034com.android.emulation.remoteP\001'
+ _CREDENTIALS._serialized_start=61
+ _CREDENTIALS._serialized_end=147
+ _HEADER._serialized_start=149
+ _HEADER._serialized_end=185
+ _ENDPOINT._serialized_start=188
+ _ENDPOINT._serialized_end=338
+# @@protoc_insertion_point(module_scope)
diff --git a/bumble/transport/grpc_protobuf/grpc_endpoint_description_pb2.pyi b/bumble/transport/grpc_protobuf/grpc_endpoint_description_pb2.pyi
new file mode 100644
index 0000000..3b81215
--- /dev/null
+++ b/bumble/transport/grpc_protobuf/grpc_endpoint_description_pb2.pyi
@@ -0,0 +1,34 @@
+from google.protobuf.internal import containers as _containers
+from google.protobuf import descriptor as _descriptor
+from google.protobuf import message as _message
+from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Mapping, Optional as _Optional, Union as _Union
+
+DESCRIPTOR: _descriptor.FileDescriptor
+
+class Credentials(_message.Message):
+ __slots__ = ["pem_cert_chain", "pem_private_key", "pem_root_certs"]
+ PEM_CERT_CHAIN_FIELD_NUMBER: _ClassVar[int]
+ PEM_PRIVATE_KEY_FIELD_NUMBER: _ClassVar[int]
+ PEM_ROOT_CERTS_FIELD_NUMBER: _ClassVar[int]
+ pem_cert_chain: str
+ pem_private_key: str
+ pem_root_certs: str
+ def __init__(self, pem_root_certs: _Optional[str] = ..., pem_private_key: _Optional[str] = ..., pem_cert_chain: _Optional[str] = ...) -> None: ...
+
+class Endpoint(_message.Message):
+ __slots__ = ["required_headers", "target", "tls_credentials"]
+ REQUIRED_HEADERS_FIELD_NUMBER: _ClassVar[int]
+ TARGET_FIELD_NUMBER: _ClassVar[int]
+ TLS_CREDENTIALS_FIELD_NUMBER: _ClassVar[int]
+ required_headers: _containers.RepeatedCompositeFieldContainer[Header]
+ target: str
+ tls_credentials: Credentials
+ def __init__(self, target: _Optional[str] = ..., tls_credentials: _Optional[_Union[Credentials, _Mapping]] = ..., required_headers: _Optional[_Iterable[_Union[Header, _Mapping]]] = ...) -> None: ...
+
+class Header(_message.Message):
+ __slots__ = ["key", "value"]
+ KEY_FIELD_NUMBER: _ClassVar[int]
+ VALUE_FIELD_NUMBER: _ClassVar[int]
+ key: str
+ value: str
+ def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ...
diff --git a/bumble/transport/grpc_protobuf/grpc_endpoint_description_pb2_grpc.py b/bumble/transport/grpc_protobuf/grpc_endpoint_description_pb2_grpc.py
new file mode 100644
index 0000000..2daafff
--- /dev/null
+++ b/bumble/transport/grpc_protobuf/grpc_endpoint_description_pb2_grpc.py
@@ -0,0 +1,4 @@
+# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
+"""Client and server classes corresponding to protobuf-defined services."""
+import grpc
+
diff --git a/bumble/transport/grpc_protobuf/hci_packet_pb2.py b/bumble/transport/grpc_protobuf/hci_packet_pb2.py
new file mode 100644
index 0000000..ef014c4
--- /dev/null
+++ b/bumble/transport/grpc_protobuf/hci_packet_pb2.py
@@ -0,0 +1,28 @@
+# -*- coding: utf-8 -*-
+# Generated by the protocol buffer compiler. DO NOT EDIT!
+# source: hci_packet.proto
+"""Generated protocol buffer code."""
+from google.protobuf.internal import builder as _builder
+from google.protobuf import descriptor as _descriptor
+from google.protobuf import descriptor_pool as _descriptor_pool
+from google.protobuf import symbol_database as _symbol_database
+# @@protoc_insertion_point(imports)
+
+_sym_db = _symbol_database.Default()
+
+
+
+
+DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x10hci_packet.proto\x12\rnetsim.packet\"\xb2\x01\n\tHCIPacket\x12\x38\n\x0bpacket_type\x18\x01 \x01(\x0e\x32#.netsim.packet.HCIPacket.PacketType\x12\x0e\n\x06packet\x18\x02 \x01(\x0c\"[\n\nPacketType\x12\x1a\n\x16HCI_PACKET_UNSPECIFIED\x10\x00\x12\x0b\n\x07\x43OMMAND\x10\x01\x12\x07\n\x03\x41\x43L\x10\x02\x12\x07\n\x03SCO\x10\x03\x12\t\n\x05\x45VENT\x10\x04\x12\x07\n\x03ISO\x10\x05\x42J\n\x1f\x63om.android.emulation.bluetoothP\x01\xf8\x01\x01\xa2\x02\x03\x41\x45\x42\xaa\x02\x1b\x41ndroid.Emulation.Bluetoothb\x06proto3')
+
+_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
+_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'hci_packet_pb2', globals())
+if _descriptor._USE_C_DESCRIPTORS == False:
+
+ DESCRIPTOR._options = None
+ DESCRIPTOR._serialized_options = b'\n\037com.android.emulation.bluetoothP\001\370\001\001\242\002\003AEB\252\002\033Android.Emulation.Bluetooth'
+ _HCIPACKET._serialized_start=36
+ _HCIPACKET._serialized_end=214
+ _HCIPACKET_PACKETTYPE._serialized_start=123
+ _HCIPACKET_PACKETTYPE._serialized_end=214
+# @@protoc_insertion_point(module_scope)
diff --git a/bumble/transport/grpc_protobuf/hci_packet_pb2.pyi b/bumble/transport/grpc_protobuf/hci_packet_pb2.pyi
new file mode 100644
index 0000000..04bb972
--- /dev/null
+++ b/bumble/transport/grpc_protobuf/hci_packet_pb2.pyi
@@ -0,0 +1,22 @@
+from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper
+from google.protobuf import descriptor as _descriptor
+from google.protobuf import message as _message
+from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union
+
+DESCRIPTOR: _descriptor.FileDescriptor
+
+class HCIPacket(_message.Message):
+ __slots__ = ["packet", "packet_type"]
+ class PacketType(int, metaclass=_enum_type_wrapper.EnumTypeWrapper):
+ __slots__ = []
+ ACL: HCIPacket.PacketType
+ COMMAND: HCIPacket.PacketType
+ EVENT: HCIPacket.PacketType
+ HCI_PACKET_UNSPECIFIED: HCIPacket.PacketType
+ ISO: HCIPacket.PacketType
+ PACKET_FIELD_NUMBER: _ClassVar[int]
+ PACKET_TYPE_FIELD_NUMBER: _ClassVar[int]
+ SCO: HCIPacket.PacketType
+ packet: bytes
+ packet_type: HCIPacket.PacketType
+ def __init__(self, packet_type: _Optional[_Union[HCIPacket.PacketType, str]] = ..., packet: _Optional[bytes] = ...) -> None: ...
diff --git a/bumble/transport/grpc_protobuf/hci_packet_pb2_grpc.py b/bumble/transport/grpc_protobuf/hci_packet_pb2_grpc.py
new file mode 100644
index 0000000..2daafff
--- /dev/null
+++ b/bumble/transport/grpc_protobuf/hci_packet_pb2_grpc.py
@@ -0,0 +1,4 @@
+# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
+"""Client and server classes corresponding to protobuf-defined services."""
+import grpc
+
diff --git a/bumble/transport/grpc_protobuf/packet_streamer_pb2.py b/bumble/transport/grpc_protobuf/packet_streamer_pb2.py
new file mode 100644
index 0000000..ea07940
--- /dev/null
+++ b/bumble/transport/grpc_protobuf/packet_streamer_pb2.py
@@ -0,0 +1,31 @@
+# -*- coding: utf-8 -*-
+# Generated by the protocol buffer compiler. DO NOT EDIT!
+# source: packet_streamer.proto
+"""Generated protocol buffer code."""
+from google.protobuf.internal import builder as _builder
+from google.protobuf import descriptor as _descriptor
+from google.protobuf import descriptor_pool as _descriptor_pool
+from google.protobuf import symbol_database as _symbol_database
+# @@protoc_insertion_point(imports)
+
+_sym_db = _symbol_database.Default()
+
+
+from . import hci_packet_pb2 as hci__packet__pb2
+from . import startup_pb2 as startup__pb2
+
+
+DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x15packet_streamer.proto\x12\rnetsim.packet\x1a\x10hci_packet.proto\x1a\rstartup.proto\"\x93\x01\n\rPacketRequest\x12\x30\n\x0cinitial_info\x18\x01 \x01(\x0b\x32\x18.netsim.startup.ChipInfoH\x00\x12.\n\nhci_packet\x18\x02 \x01(\x0b\x32\x18.netsim.packet.HCIPacketH\x00\x12\x10\n\x06packet\x18\x03 \x01(\x0cH\x00\x42\x0e\n\x0crequest_type\"t\n\x0ePacketResponse\x12\x0f\n\x05\x65rror\x18\x01 \x01(\tH\x00\x12.\n\nhci_packet\x18\x02 \x01(\x0b\x32\x18.netsim.packet.HCIPacketH\x00\x12\x10\n\x06packet\x18\x03 \x01(\x0cH\x00\x42\x0f\n\rresponse_type2b\n\x0ePacketStreamer\x12P\n\rStreamPackets\x12\x1c.netsim.packet.PacketRequest\x1a\x1d.netsim.packet.PacketResponse(\x01\x30\x01\x62\x06proto3')
+
+_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
+_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'packet_streamer_pb2', globals())
+if _descriptor._USE_C_DESCRIPTORS == False:
+
+ DESCRIPTOR._options = None
+ _PACKETREQUEST._serialized_start=74
+ _PACKETREQUEST._serialized_end=221
+ _PACKETRESPONSE._serialized_start=223
+ _PACKETRESPONSE._serialized_end=339
+ _PACKETSTREAMER._serialized_start=341
+ _PACKETSTREAMER._serialized_end=439
+# @@protoc_insertion_point(module_scope)
diff --git a/bumble/transport/grpc_protobuf/packet_streamer_pb2.pyi b/bumble/transport/grpc_protobuf/packet_streamer_pb2.pyi
new file mode 100644
index 0000000..d867613
--- /dev/null
+++ b/bumble/transport/grpc_protobuf/packet_streamer_pb2.pyi
@@ -0,0 +1,27 @@
+from . import hci_packet_pb2 as _hci_packet_pb2
+from . import startup_pb2 as _startup_pb2
+from google.protobuf import descriptor as _descriptor
+from google.protobuf import message as _message
+from typing import ClassVar as _ClassVar, Mapping as _Mapping, Optional as _Optional, Union as _Union
+
+DESCRIPTOR: _descriptor.FileDescriptor
+
+class PacketRequest(_message.Message):
+ __slots__ = ["hci_packet", "initial_info", "packet"]
+ HCI_PACKET_FIELD_NUMBER: _ClassVar[int]
+ INITIAL_INFO_FIELD_NUMBER: _ClassVar[int]
+ PACKET_FIELD_NUMBER: _ClassVar[int]
+ hci_packet: _hci_packet_pb2.HCIPacket
+ initial_info: _startup_pb2.ChipInfo
+ packet: bytes
+ def __init__(self, initial_info: _Optional[_Union[_startup_pb2.ChipInfo, _Mapping]] = ..., hci_packet: _Optional[_Union[_hci_packet_pb2.HCIPacket, _Mapping]] = ..., packet: _Optional[bytes] = ...) -> None: ...
+
+class PacketResponse(_message.Message):
+ __slots__ = ["error", "hci_packet", "packet"]
+ ERROR_FIELD_NUMBER: _ClassVar[int]
+ HCI_PACKET_FIELD_NUMBER: _ClassVar[int]
+ PACKET_FIELD_NUMBER: _ClassVar[int]
+ error: str
+ hci_packet: _hci_packet_pb2.HCIPacket
+ packet: bytes
+ def __init__(self, error: _Optional[str] = ..., hci_packet: _Optional[_Union[_hci_packet_pb2.HCIPacket, _Mapping]] = ..., packet: _Optional[bytes] = ...) -> None: ...
diff --git a/bumble/transport/grpc_protobuf/packet_streamer_pb2_grpc.py b/bumble/transport/grpc_protobuf/packet_streamer_pb2_grpc.py
new file mode 100644
index 0000000..45ab653
--- /dev/null
+++ b/bumble/transport/grpc_protobuf/packet_streamer_pb2_grpc.py
@@ -0,0 +1,109 @@
+# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
+"""Client and server classes corresponding to protobuf-defined services."""
+import grpc
+
+from . import packet_streamer_pb2 as packet__streamer__pb2
+
+
+class PacketStreamerStub(object):
+ """*
+ This is the packet service for the network simulator.
+
+ Android Virtual Devices (AVDs) and accessory devices use this service to
+ connect to the network simulator and pass packets back and forth.
+
+ AVDs running in a guest VM are built with virtual controllers for each radio
+ chip. These controllers route chip requests to host emulators (qemu and
+ crosvm) using virtio and from there they are forwarded to this gRpc service.
+
+ This setup provides a transparent radio environment across AVDs and
+ accessories because the network simulator contains libraries to emulate
+ Bluetooth, 80211MAC, UWB, and Rtt chips.
+
+ """
+
+ def __init__(self, channel):
+ """Constructor.
+
+ Args:
+ channel: A grpc.Channel.
+ """
+ self.StreamPackets = channel.stream_stream(
+ '/netsim.packet.PacketStreamer/StreamPackets',
+ request_serializer=packet__streamer__pb2.PacketRequest.SerializeToString,
+ response_deserializer=packet__streamer__pb2.PacketResponse.FromString,
+ )
+
+
+class PacketStreamerServicer(object):
+ """*
+ This is the packet service for the network simulator.
+
+ Android Virtual Devices (AVDs) and accessory devices use this service to
+ connect to the network simulator and pass packets back and forth.
+
+ AVDs running in a guest VM are built with virtual controllers for each radio
+ chip. These controllers route chip requests to host emulators (qemu and
+ crosvm) using virtio and from there they are forwarded to this gRpc service.
+
+ This setup provides a transparent radio environment across AVDs and
+ accessories because the network simulator contains libraries to emulate
+ Bluetooth, 80211MAC, UWB, and Rtt chips.
+
+ """
+
+ def StreamPackets(self, request_iterator, context):
+ """Attach a virtual radio controller to the network simulation.
+ """
+ context.set_code(grpc.StatusCode.UNIMPLEMENTED)
+ context.set_details('Method not implemented!')
+ raise NotImplementedError('Method not implemented!')
+
+
+def add_PacketStreamerServicer_to_server(servicer, server):
+ rpc_method_handlers = {
+ 'StreamPackets': grpc.stream_stream_rpc_method_handler(
+ servicer.StreamPackets,
+ request_deserializer=packet__streamer__pb2.PacketRequest.FromString,
+ response_serializer=packet__streamer__pb2.PacketResponse.SerializeToString,
+ ),
+ }
+ generic_handler = grpc.method_handlers_generic_handler(
+ 'netsim.packet.PacketStreamer', rpc_method_handlers)
+ server.add_generic_rpc_handlers((generic_handler,))
+
+
+ # This class is part of an EXPERIMENTAL API.
+class PacketStreamer(object):
+ """*
+ This is the packet service for the network simulator.
+
+ Android Virtual Devices (AVDs) and accessory devices use this service to
+ connect to the network simulator and pass packets back and forth.
+
+ AVDs running in a guest VM are built with virtual controllers for each radio
+ chip. These controllers route chip requests to host emulators (qemu and
+ crosvm) using virtio and from there they are forwarded to this gRpc service.
+
+ This setup provides a transparent radio environment across AVDs and
+ accessories because the network simulator contains libraries to emulate
+ Bluetooth, 80211MAC, UWB, and Rtt chips.
+
+ """
+
+ @staticmethod
+ def StreamPackets(request_iterator,
+ target,
+ options=(),
+ channel_credentials=None,
+ call_credentials=None,
+ insecure=False,
+ compression=None,
+ wait_for_ready=None,
+ timeout=None,
+ metadata=None):
+ return grpc.experimental.stream_stream(request_iterator, target, '/netsim.packet.PacketStreamer/StreamPackets',
+ packet__streamer__pb2.PacketRequest.SerializeToString,
+ packet__streamer__pb2.PacketResponse.FromString,
+ options, channel_credentials,
+ insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
diff --git a/bumble/transport/grpc_protobuf/startup_pb2.py b/bumble/transport/grpc_protobuf/startup_pb2.py
new file mode 100644
index 0000000..532ac0e
--- /dev/null
+++ b/bumble/transport/grpc_protobuf/startup_pb2.py
@@ -0,0 +1,32 @@
+# -*- coding: utf-8 -*-
+# Generated by the protocol buffer compiler. DO NOT EDIT!
+# source: startup.proto
+"""Generated protocol buffer code."""
+from google.protobuf.internal import builder as _builder
+from google.protobuf import descriptor as _descriptor
+from google.protobuf import descriptor_pool as _descriptor_pool
+from google.protobuf import symbol_database as _symbol_database
+# @@protoc_insertion_point(imports)
+
+_sym_db = _symbol_database.Default()
+
+
+from . import common_pb2 as common__pb2
+
+
+DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\rstartup.proto\x12\x0enetsim.startup\x1a\x0c\x63ommon.proto\"\x7f\n\x0bStartupInfo\x12\x33\n\x07\x64\x65vices\x18\x01 \x03(\x0b\x32\".netsim.startup.StartupInfo.Device\x1a;\n\x06\x44\x65vice\x12\x0c\n\x04name\x18\x01 \x01(\t\x12#\n\x05\x63hips\x18\x02 \x03(\x0b\x32\x14.netsim.startup.Chip\"<\n\x08\x43hipInfo\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\"\n\x04\x63hip\x18\x02 \x01(\x0b\x32\x14.netsim.startup.Chip\"\x96\x01\n\x04\x43hip\x12%\n\x04kind\x18\x01 \x01(\x0e\x32\x17.netsim.common.ChipKind\x12\n\n\x02id\x18\x02 \x01(\t\x12\x14\n\x0cmanufacturer\x18\x03 \x01(\t\x12\x14\n\x0cproduct_name\x18\x04 \x01(\t\x12\r\n\x05\x66\x64_in\x18\x05 \x01(\x05\x12\x0e\n\x06\x66\x64_out\x18\x06 \x01(\x05\x12\x10\n\x08loopback\x18\x07 \x01(\x08\x62\x06proto3')
+
+_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
+_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'startup_pb2', globals())
+if _descriptor._USE_C_DESCRIPTORS == False:
+
+ DESCRIPTOR._options = None
+ _STARTUPINFO._serialized_start=47
+ _STARTUPINFO._serialized_end=174
+ _STARTUPINFO_DEVICE._serialized_start=115
+ _STARTUPINFO_DEVICE._serialized_end=174
+ _CHIPINFO._serialized_start=176
+ _CHIPINFO._serialized_end=236
+ _CHIP._serialized_start=239
+ _CHIP._serialized_end=389
+# @@protoc_insertion_point(module_scope)
diff --git a/bumble/transport/grpc_protobuf/startup_pb2.pyi b/bumble/transport/grpc_protobuf/startup_pb2.pyi
new file mode 100644
index 0000000..604d915
--- /dev/null
+++ b/bumble/transport/grpc_protobuf/startup_pb2.pyi
@@ -0,0 +1,46 @@
+from . import common_pb2 as _common_pb2
+from google.protobuf.internal import containers as _containers
+from google.protobuf import descriptor as _descriptor
+from google.protobuf import message as _message
+from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Mapping, Optional as _Optional, Union as _Union
+
+DESCRIPTOR: _descriptor.FileDescriptor
+
+class Chip(_message.Message):
+ __slots__ = ["fd_in", "fd_out", "id", "kind", "loopback", "manufacturer", "product_name"]
+ FD_IN_FIELD_NUMBER: _ClassVar[int]
+ FD_OUT_FIELD_NUMBER: _ClassVar[int]
+ ID_FIELD_NUMBER: _ClassVar[int]
+ KIND_FIELD_NUMBER: _ClassVar[int]
+ LOOPBACK_FIELD_NUMBER: _ClassVar[int]
+ MANUFACTURER_FIELD_NUMBER: _ClassVar[int]
+ PRODUCT_NAME_FIELD_NUMBER: _ClassVar[int]
+ fd_in: int
+ fd_out: int
+ id: str
+ kind: _common_pb2.ChipKind
+ loopback: bool
+ manufacturer: str
+ product_name: str
+ def __init__(self, kind: _Optional[_Union[_common_pb2.ChipKind, str]] = ..., id: _Optional[str] = ..., manufacturer: _Optional[str] = ..., product_name: _Optional[str] = ..., fd_in: _Optional[int] = ..., fd_out: _Optional[int] = ..., loopback: bool = ...) -> None: ...
+
+class ChipInfo(_message.Message):
+ __slots__ = ["chip", "name"]
+ CHIP_FIELD_NUMBER: _ClassVar[int]
+ NAME_FIELD_NUMBER: _ClassVar[int]
+ chip: Chip
+ name: str
+ def __init__(self, name: _Optional[str] = ..., chip: _Optional[_Union[Chip, _Mapping]] = ...) -> None: ...
+
+class StartupInfo(_message.Message):
+ __slots__ = ["devices"]
+ class Device(_message.Message):
+ __slots__ = ["chips", "name"]
+ CHIPS_FIELD_NUMBER: _ClassVar[int]
+ NAME_FIELD_NUMBER: _ClassVar[int]
+ chips: _containers.RepeatedCompositeFieldContainer[Chip]
+ name: str
+ def __init__(self, name: _Optional[str] = ..., chips: _Optional[_Iterable[_Union[Chip, _Mapping]]] = ...) -> None: ...
+ DEVICES_FIELD_NUMBER: _ClassVar[int]
+ devices: _containers.RepeatedCompositeFieldContainer[StartupInfo.Device]
+ def __init__(self, devices: _Optional[_Iterable[_Union[StartupInfo.Device, _Mapping]]] = ...) -> None: ...
diff --git a/bumble/transport/grpc_protobuf/startup_pb2_grpc.py b/bumble/transport/grpc_protobuf/startup_pb2_grpc.py
new file mode 100644
index 0000000..2daafff
--- /dev/null
+++ b/bumble/transport/grpc_protobuf/startup_pb2_grpc.py
@@ -0,0 +1,4 @@
+# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
+"""Client and server classes corresponding to protobuf-defined services."""
+import grpc
+
diff --git a/docs/mkdocs/requirements.txt b/docs/mkdocs/requirements.txt
index 8fc37dd..a241683 100644
--- a/docs/mkdocs/requirements.txt
+++ b/docs/mkdocs/requirements.txt
@@ -2,5 +2,5 @@
mkdocs == 1.4.0
mkdocs-material == 8.5.6
mkdocs-material-extensions == 1.0.3
-pymdown-extensions == 9.6
+pymdown-extensions == 10.0
mkdocstrings-python == 0.7.1
diff --git a/docs/mkdocs/src/platforms/android.md b/docs/mkdocs/src/platforms/android.md
index cbb62db..4b08d88 100644
--- a/docs/mkdocs/src/platforms/android.md
+++ b/docs/mkdocs/src/platforms/android.md
@@ -9,19 +9,20 @@ The two main use cases are:
* Connecting the Bumble host stack to the Android emulator's virtual controller.
* Using Bumble as an HCI bridge to connect the Android emulator to a physical
- Bluetooth controller, such as a USB dongle
+ Bluetooth controller, such as a USB dongle, or other HCI transport.
!!! warning
Bluetooth support in the Android emulator is a recent feature that may still
be evolving. The information contained here be somewhat out of sync with the
version of the emulator you are using.
- You will need version 31.3.8.0 or later.
+ You will need version 33.1.4.0 or later.
The Android emulator supports Bluetooth in two ways: either by exposing virtual
Bluetooth controllers to which you can connect a virtual Bluetooth host stack, or
-by exposing an way to connect your own virtual controller to the Android Bluetooth
+by exposing a way to connect your own virtual controller to the Android Bluetooth
stack via a virtual HCI interface.
-Both ways are controlled via gRPC requests to the Android emulator.
+Both ways are controlled via gRPC requests to the Android emulator controller and/or
+from the Android emulator.
## Launching the Emulator
@@ -33,48 +34,82 @@ the command line.
For details on how to launch the Android emulator from the command line,
visit [this Android Studio user guide page](https://developer.android.com/studio/run/emulator-commandline)
-The `-grpc <port>` command line option may be used to select a gRPC port other than the default.
+The `-packet-streamer-endpoint <endpoint>` command line option may be used to enable
+Bluetooth emulation and tell the emulator which virtual controller to connect to.
-## Connecting to Root Canal
+## Connecting to Netsim
-The Android emulator's virtual Bluetooth controller is called **Root Canal**.
-Multiple instances of Root Canal virtual controllers can be instantiated, they
-communicate link layer packets between them, thus creating a virtual radio network.
-Configuring a Bumble Device instance to use Root Canal as a virtual controller
+If the emulator doesn't have Bluetooth emulation enabled by default, use the
+`-packet-streamer-endpoint default` option to tell it to connect to Netsim.
+If Netsim is not running, the emulator will start it automatically.
+
+The Android emulator's virtual Bluetooth controller is called **Netsim**.
+Netsim runs as a background process and allows multiple clients to connect to it,
+each connecting to its own virtual controller instance hosted by Netsim. All the
+clients connected to the same Netsim process can then "talk" to each other over a
+virtual radio link layer.
+Netsim supports other wireless protocols than Bluetooth, but the relevant part here
+is Bluetooth. The virtual Bluetooth controller used by Netsim is sometimes referred to
+as **Root Canal**.
+
+Configuring a Bumble Device instance to use netsim as a virtual controller
allows that virtual device to communicate with the Android Bluetooth stack, and
through it with Android applications as well as system-managed profiles.
-To connect a Bumble host stack to a Root Canal virtual controller instance, use
-the bumble `android-emulator` transport in `host` mode (the default).
+To connect a Bumble host stack to a netsim virtual controller instance, use
+the Bumble `android-netsim` transport in `host` mode (the default).
+
+!!! example "Run the example GATT server connected to the emulator via Netsim"
+ ``` shell
+ $ python run_gatt_server.py device1.json android-netsim
+ ```
-!!! example "Run the example GATT server connected to the emulator"
+By default, the Bumble `android-netsim` transport will try to automatically discover
+the port number on which the netsim process is exposing its gRPC server interface. If
+that discovery process fails, or if you want to specify the interface manually, you
+can pass a `hostname` and `port` as parameters to the transport, as: `android-netsim:<host>:<port>`.
+
+!!! example "Run the example GATT server connected to the emulator via Netsim on a localhost, port 8877"
``` shell
- $ python run_gatt_server.py device1.json android-emulator
+ $ python run_gatt_server.py device1.json android-netsim:localhost:8877
```
+### Multiple Instances
+
+If you want to connect multiple Bumble devices to netsim, it may be useful to give each one
+a netsim controller with a specific name. This can be done using the `name=<name>` transport option.
+For example: `android-netsim:localhost:8877,name=bumble1`
+
## Connecting a Custom Virtual Controller
This is an advanced use case, which may not be officially supported, but should work in recent
versions of the emulator.
-You will likely need to start the emulator from the command line, in order to specify the `-forward-vhci` option (unless the emulator offers a way to control that feature from a user/ui menu).
-!!! example "Launch the emulator with VHCI forwarding"
- In this example, we launch an emulator AVD named "Tiramisu"
+The first step is to run the Bumble HCI bridge, specifying netsim as the "host" end of the
+bridge, and another controller (typically a USB Bluetooth dongle, but any other supported
+transport can work as well) as the "controller" end of the bridge.
+
+To connect a virtual controller to the Android Bluetooth stack, use the bumble `android-netsim` transport in `controller` mode. For example, with port number 8877, the transport name would be: `android-netsim:_:8877,mode=controller`.
+
+!!! example "Connect the Android emulator to the first USB Bluetooth dongle, using the `hci_bridge` application"
```shell
- $ emulator -forward-vhci -avd Tiramisu
+ $ bumble-hci-bridge android-netsim:_:8877,mode=controller usb:0
```
-!!! tip
- Attaching a virtual controller use the VHCI forwarder while the Android Bluetooth stack
- is running isn't supported. So you need to disable Bluetooth in your running Android guest
- before attaching the virtual controller, then re-enable it once attached.
+Then, you can start the emulator and tell it to connect to this bridge, instead of netsim.
+You will likely need to start the emulator from the command line, in order to specify the `-packet-streamer-endpoint <hostname>:<port>` option (unless the emulator offers a way to control that feature from a user/ui menu).
-To connect a virtual controller to the Android Bluetooth stack, use the bumble `android-emulator` transport in `controller` mode. For example, using the default gRPC port, the transport name would be: `android-emulator:mode=controller`.
-
-!!! example "Connect the Android emulator to the first USB Bluetooth dongle, using the `hci_bridge` application"
+!!! example "Launch the emulator with a netsim replacement"
+ In this example, we launch an emulator AVD named "Tiramisu", with a Bumble HCI bridge running
+ on port 8877.
```shell
- $ bumble-hci-bridge android-emulator:mode=controller usb:0
+ $ emulator -packet-streamer-endpoint localhost:8877 -avd Tiramisu
```
+!!! tip
+ Attaching a virtual controller while the Android Bluetooth stack is running may not be well supported. So you may need to disable Bluetooth in your running Android guest
+ before attaching the virtual controller, then re-enable it once attached.
+
+
## Other Tools
The `show` application that's included with Bumble can be used to parse and pretty-print the HCI packets
diff --git a/docs/mkdocs/src/transports/android_emulator.md b/docs/mkdocs/src/transports/android_emulator.md
index ead71b8..974ba4f 100644
--- a/docs/mkdocs/src/transports/android_emulator.md
+++ b/docs/mkdocs/src/transports/android_emulator.md
@@ -1,22 +1,41 @@
ANDROID EMULATOR TRANSPORT
==========================
-The Android emulator transport either connects, as a host, to a "Root Canal" virtual controller
-("host" mode), or attaches a virtual controller to the Android Bluetooth host stack ("controller" mode).
+!!! warning
+ Bluetooth support in the Android emulator has recently changed. The older mode, using
+ the `android-emulator` transport name with Bumble, while still implemented, is now
+ obsolete, and may not be supported by recent versions of the emulator.
+ Use the `android-netsim` transport name instead.
+
+
+The Android "netsim" transport either connects, as a host, to a **Netsim** virtual controller
+("host" mode), or acts as a virtual controller itself ("controller" mode) accepting host
+connections.
## Moniker
-The moniker syntax for an Android Emulator transport is: `android-emulator:[mode=<host|controller>][<hostname>:<port>]`, where
-the `mode` parameter can specify running as a host or a controller, and `<hostname>:<port>` can specify a host name (or IP address) and TCP port number on which to reach the gRPC server for the emulator.
-Both the `mode=<host|controller>` and `<hostname>:<port>` parameters are optional (so the moniker `android-emulator` by itself is a valid moniker, which will create a transport in `host` mode, connected to `localhost` on the default gRPC port for the emulator).
+The moniker syntax for an Android Emulator "netsim" transport is: `android-netsim:[<host>:<port>][<options>]`,
+where `<options>` is a ','-separated list of `<name>=<value>` pairs`.
+The `mode` parameter name can specify running as a host or a controller, and `<hostname>:<port>` can specify a host name (or IP address) and TCP port number on which to reach the gRPC server for the emulator (in "host" mode), or to accept gRPC connections (in "controller" mode).
+Both the `mode=<host|controller>` and `<hostname>:<port>` parameters are optional (so the moniker `android-netsim` by itself is a valid moniker, which will create a transport in `host` mode, connected to `localhost` on the default gRPC port for the Netsim background process).
+
+!!! example Example
+ `android-netsim`
+ connect as a host to Netsim on the gRPC port discovered automatically.
!!! example Example
- `android-emulator`
- connect as a host to the emulator on localhost:8554
+ `android-netsim:_:8555,mode=controller`
+ Run as a controller, accepting gRPC connection on port 8555.
!!! example Example
- `android-emulator:mode=controller`
- connect as a controller to the emulator on localhost:8554
+ `android-netsim:localhost:8555`
+ connect as a host to Netsim on localhost:8555
!!! example Example
- `android-emulator:localhost:8555`
- connect as a host to the emulator on localhost:8555
+ `android-netsim:localhost:8555`
+ connect as a host to Netsim on localhost:8555
+
+!!! example Example
+ `android-netsim:name=bumble1234`
+ connect as a host to Netsim on the discovered gRPC port, using `bumble1234` as the
+ controller instance name.
+
diff --git a/docs/mkdocs/src/transports/index.md b/docs/mkdocs/src/transports/index.md
index 70827c1..feb57e5 100644
--- a/docs/mkdocs/src/transports/index.md
+++ b/docs/mkdocs/src/transports/index.md
@@ -16,5 +16,6 @@ Several types of transports are supported:
* [PTY](pty.md): a PTY (pseudo terminal) is used to send/receive HCI packets. This is convenient to expose a virtual controller as if it were an HCI UART
* [VHCI](vhci.md): used to attach a virtual controller to a Bluetooth stack on platforms that support it.
* [HCI Socket](hci_socket.md): an HCI socket, on platforms that support it, to send/receive HCI packets to/from an HCI controller managed by the OS.
- * [Android Emulator](android_emulator.md): a gRPC connection to an Android emulator is used to setup either an HCI interface to the emulator's "Root Canal" virtual controller, or attach a virtual controller to the Android Bluetooth host stack.
+ * [Android Emulator](android_emulator.md): a gRPC connection to the Android emulator's "netsim"
+ virtual controller, or from the Android emulator, is used to setup either an HCI interface to the emulator's "netsim" virtual controller, or serve as a virtual controller for the Android Bluetooth host stack.
* [File](file.md): HCI packets are read/written to a file-like node in the filesystem.
diff --git a/examples/a2dp_sink1.json b/examples/a2dp_sink1.json
index 61ce80d..8603194 100644
--- a/examples/a2dp_sink1.json
+++ b/examples/a2dp_sink1.json
@@ -1,5 +1,6 @@
{
"name": "Bumble Speaker",
+ "address": "F0:F1:F2:F3:F4:F5",
"class_of_device": 2360324,
"keystore": "JsonKeyStore"
}
diff --git a/examples/hfp_handsfree.json b/examples/hfp_handsfree.json
index 5d46a80..50d7841 100644
--- a/examples/hfp_handsfree.json
+++ b/examples/hfp_handsfree.json
@@ -1,4 +1,6 @@
{
"name": "Bumble Hands-Free",
- "class_of_device": 2360324
+ "class_of_device": 2360324,
+ "keystore": "JsonKeyStore",
+ "le_enabled": false
}
diff --git a/examples/keyboard.py b/examples/keyboard.py
index 16dbeb6..314a805 100644
--- a/examples/keyboard.py
+++ b/examples/keyboard.py
@@ -209,7 +209,7 @@ async def keyboard_host(device, peer_address):
return
for i, characteristic in enumerate(report_characteristics):
print(color('REPORT:', 'yellow'), characteristic)
- if characteristic.properties & Characteristic.NOTIFY:
+ if characteristic.properties & Characteristic.Properties.NOTIFY:
await peer.discover_descriptors(characteristic)
report_reference_descriptor = characteristic.get_descriptor(
GATT_REPORT_REFERENCE_DESCRIPTOR
@@ -241,7 +241,9 @@ async def keyboard_device(device, command):
# Create an 'input report' characteristic to send keyboard reports to the host
input_report_characteristic = Characteristic(
GATT_REPORT_CHARACTERISTIC,
- Characteristic.READ | Characteristic.WRITE | Characteristic.NOTIFY,
+ Characteristic.Properties.READ
+ | Characteristic.Properties.WRITE
+ | Characteristic.Properties.NOTIFY,
Characteristic.READABLE | Characteristic.WRITEABLE,
bytes([0, 0, 0, 0, 0, 0, 0, 0]),
[
@@ -256,8 +258,8 @@ async def keyboard_device(device, command):
# Create an 'output report' characteristic to receive keyboard reports from the host
output_report_characteristic = Characteristic(
GATT_REPORT_CHARACTERISTIC,
- Characteristic.READ
- | Characteristic.WRITE
+ Characteristic.Properties.READ
+ | Characteristic.Properties.WRITE
| Characteristic.WRITE_WITHOUT_RESPONSE,
Characteristic.READABLE | Characteristic.WRITEABLE,
bytes([0]),
@@ -278,7 +280,7 @@ async def keyboard_device(device, command):
[
Characteristic(
GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC,
- Characteristic.READ,
+ Characteristic.Properties.READ,
Characteristic.READABLE,
'Bumble',
)
@@ -289,13 +291,13 @@ async def keyboard_device(device, command):
[
Characteristic(
GATT_PROTOCOL_MODE_CHARACTERISTIC,
- Characteristic.READ,
+ Characteristic.Properties.READ,
Characteristic.READABLE,
bytes([HID_REPORT_PROTOCOL]),
),
Characteristic(
GATT_HID_INFORMATION_CHARACTERISTIC,
- Characteristic.READ,
+ Characteristic.Properties.READ,
Characteristic.READABLE,
# bcdHID=1.1, bCountryCode=0x00,
# Flags=RemoteWake|NormallyConnectable
@@ -309,7 +311,7 @@ async def keyboard_device(device, command):
),
Characteristic(
GATT_REPORT_MAP_CHARACTERISTIC,
- Characteristic.READ,
+ Characteristic.Properties.READ,
Characteristic.READABLE,
HID_KEYBOARD_REPORT_MAP,
),
@@ -322,7 +324,7 @@ async def keyboard_device(device, command):
[
Characteristic(
GATT_BATTERY_LEVEL_CHARACTERISTIC,
- Characteristic.READ,
+ Characteristic.Properties.READ,
Characteristic.READABLE,
bytes([100]),
)
diff --git a/examples/run_asha_sink.py b/examples/run_asha_sink.py
index 3b8083c..3e4955d 100644
--- a/examples/run_asha_sink.py
+++ b/examples/run_asha_sink.py
@@ -101,7 +101,7 @@ async def main():
# Add the ASHA service to the GATT server
read_only_properties_characteristic = Characteristic(
ASHA_READ_ONLY_PROPERTIES_CHARACTERISTIC,
- Characteristic.READ,
+ Characteristic.Properties.READ,
Characteristic.READABLE,
bytes(
[
@@ -127,13 +127,13 @@ async def main():
)
audio_control_point_characteristic = Characteristic(
ASHA_AUDIO_CONTROL_POINT_CHARACTERISTIC,
- Characteristic.WRITE | Characteristic.WRITE_WITHOUT_RESPONSE,
+ Characteristic.Properties.WRITE | Characteristic.WRITE_WITHOUT_RESPONSE,
Characteristic.WRITEABLE,
CharacteristicValue(write=on_audio_control_point_write),
)
audio_status_characteristic = Characteristic(
ASHA_AUDIO_STATUS_CHARACTERISTIC,
- Characteristic.READ | Characteristic.NOTIFY,
+ Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
Characteristic.READABLE,
bytes([0]),
)
@@ -145,7 +145,7 @@ async def main():
)
le_psm_out_characteristic = Characteristic(
ASHA_LE_PSM_OUT_CHARACTERISTIC,
- Characteristic.READ,
+ Characteristic.Properties.READ,
Characteristic.READABLE,
struct.pack('<H', psm),
)
diff --git a/examples/run_controller.py b/examples/run_controller.py
index 885b96d..596ac8b 100644
--- a/examples/run_controller.py
+++ b/examples/run_controller.py
@@ -80,7 +80,7 @@ async def main():
)
manufacturer_name_characteristic = Characteristic(
GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC,
- Characteristic.READ,
+ Characteristic.Properties.READ,
Characteristic.READABLE,
"Fitbit",
[descriptor],
diff --git a/examples/run_gatt_client_and_server.py b/examples/run_gatt_client_and_server.py
index f3df733..609fe18 100644
--- a/examples/run_gatt_client_and_server.py
+++ b/examples/run_gatt_client_and_server.py
@@ -70,7 +70,7 @@ async def main():
)
manufacturer_name_characteristic = Characteristic(
GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC,
- Characteristic.READ,
+ Characteristic.Properties.READ,
Characteristic.READABLE,
"Fitbit",
[descriptor],
diff --git a/examples/run_gatt_server.py b/examples/run_gatt_server.py
index 041b440..46d42a2 100644
--- a/examples/run_gatt_server.py
+++ b/examples/run_gatt_server.py
@@ -96,7 +96,7 @@ async def main():
)
manufacturer_name_characteristic = Characteristic(
GATT_MANUFACTURER_NAME_STRING_CHARACTERISTIC,
- Characteristic.READ,
+ Characteristic.Properties.READ,
Characteristic.READABLE,
'Fitbit',
[descriptor],
@@ -109,13 +109,13 @@ async def main():
[
Characteristic(
'D901B45B-4916-412E-ACCA-376ECB603B2C',
- Characteristic.READ | Characteristic.WRITE,
+ Characteristic.Properties.READ | Characteristic.Properties.WRITE,
Characteristic.READABLE | Characteristic.WRITEABLE,
CharacteristicValue(read=my_custom_read, write=my_custom_write),
),
Characteristic(
'552957FB-CF1F-4A31-9535-E78847E1A714',
- Characteristic.READ | Characteristic.WRITE,
+ Characteristic.Properties.READ | Characteristic.Properties.WRITE,
Characteristic.READABLE | Characteristic.WRITEABLE,
CharacteristicValue(
read=my_custom_read_with_error, write=my_custom_write_with_error
@@ -123,7 +123,7 @@ async def main():
),
Characteristic(
'486F64C6-4B5F-4B3B-8AFF-EDE134A8446A',
- Characteristic.READ | Characteristic.NOTIFY,
+ Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
Characteristic.READABLE,
'hello',
),
diff --git a/examples/run_notifier.py b/examples/run_notifier.py
index 673286b..5f6def3 100644
--- a/examples/run_notifier.py
+++ b/examples/run_notifier.py
@@ -74,19 +74,21 @@ async def main():
# Add a few entries to the device's GATT server
characteristic1 = Characteristic(
'486F64C6-4B5F-4B3B-8AFF-EDE134A8446A',
- Characteristic.READ | Characteristic.NOTIFY,
+ Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
Characteristic.READABLE,
bytes([0x40]),
)
characteristic2 = Characteristic(
'8EBDEBAE-0017-418E-8D3B-3A3809492165',
- Characteristic.READ | Characteristic.INDICATE,
+ Characteristic.Properties.READ | Characteristic.Properties.INDICATE,
Characteristic.READABLE,
bytes([0x41]),
)
characteristic3 = Characteristic(
'8EBDEBAE-0017-418E-8D3B-3A3809492165',
- Characteristic.READ | Characteristic.NOTIFY | Characteristic.INDICATE,
+ Characteristic.Properties.READ
+ | Characteristic.Properties.NOTIFY
+ | Characteristic.Properties.INDICATE,
Characteristic.READABLE,
bytes([0x42]),
)
diff --git a/pyproject.toml b/pyproject.toml
index ff9588c..8662723 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -13,6 +13,9 @@ testpaths = [
[tool.pylint.master]
init-hook = 'import sys; sys.path.append(".")'
+ignore-paths = [
+ '.*_pb2(_grpc)?.py'
+]
[tool.pylint.messages_control]
max-line-length = "88"
@@ -37,26 +40,25 @@ disable = [
"too-many-statements",
]
-ignore = [
- "emulated_bluetooth_pb2.py",
- "emulated_bluetooth_pb2_grpc.py",
- "emulated_bluetooth_vhci_pb2_grpc.py",
- "emulated_bluetooth_packets_pb2.py",
- "emulated_bluetooth_vhci_pb2.py"
-]
+[tool.pylint.main]
+ignore="pandora" # FIXME: pylint does not support stubs yet:
[tool.pylint.typecheck]
signature-mutators="AsyncRunner.run_in_task"
[tool.black]
skip-string-normalization = true
+extend-exclude = '''
+(
+ .*_pb2(_grpc)?.py # exclude autogenerated Protocol Buffer files anywhere in the project
+)
+'''
-[[tool.mypy.overrides]]
-module = "bumble.transport.emulated_bluetooth_pb2_grpc"
-ignore_missing_imports = true
+[tool.mypy]
+exclude = ['bumble/transport/grpc_protobuf']
[[tool.mypy.overrides]]
-module = "bumble.transport.emulated_bluetooth_packets_pb2"
+module = "bumble.transport.grpc_protobuf.*"
ignore_errors = true
[[tool.mypy.overrides]]
@@ -64,18 +66,10 @@ module = "aioconsole.*"
ignore_missing_imports = true
[[tool.mypy.overrides]]
-module = "colors.*"
-ignore_missing_imports = true
-
-[[tool.mypy.overrides]]
module = "construct.*"
ignore_missing_imports = true
[[tool.mypy.overrides]]
-module = "emulated_bluetooth_packets_pb2.*"
-ignore_missing_imports = true
-
-[[tool.mypy.overrides]]
module = "grpc.*"
ignore_missing_imports = true
diff --git a/scripts/process_android_emulator_protos.sh b/scripts/process_android_emulator_protos.sh
index e60e4cf..d5070df 100644
--- a/scripts/process_android_emulator_protos.sh
+++ b/scripts/process_android_emulator_protos.sh
@@ -1,27 +1,16 @@
-# Invoke this script with an argument pointing to where the Android emulator .proto files are.
-# The .proto files should be slightly modified from their original version (as distributed with
-# the Android emulator):
-# --> Remove unused types/methods from emulated_bluetooth.proto
+# Invoke this script with an argument pointing to where the Android emulator .proto files are
+# (for example, ~/Library/Android/sdk/emulator/lib on a mac, or
+# $AOSP/external/qemu/android/android-grpc/python/aemu-grpc/src/aemu/proto from the AOSP sources)
+PROTOC_OUT=bumble/transport/grpc_protobuf
-PROTOC_OUT=bumble/transport
-LICENSE_FILE_INPUT=bumble/transport/android_emulator.py
-
-proto_files=(emulated_bluetooth.proto emulated_bluetooth_vhci.proto emulated_bluetooth_packets.proto)
+proto_files=(emulated_bluetooth.proto emulated_bluetooth_vhci.proto emulated_bluetooth_packets.proto emulated_bluetooth_device.proto grpc_endpoint_description.proto)
for proto_file in "${proto_files[@]}"
do
python -m grpc_tools.protoc -I$1 --proto_path=bumble/transport --python_out=$PROTOC_OUT --pyi_out=$PROTOC_OUT --grpc_python_out=$PROTOC_OUT $1/$proto_file
done
-python_files=(emulated_bluetooth_pb2.py emulated_bluetooth_pb2_grpc.py emulated_bluetooth_packets_pb2.py emulated_bluetooth_packets_pb2_grpc.py emulated_bluetooth_vhci_pb2_grpc.py emulated_bluetooth_vhci_pb2.py)
+python_files=(emulated_bluetooth_pb2_grpc.py emulated_bluetooth_pb2.py emulated_bluetooth_packets_pb2.py emulated_bluetooth_vhci_pb2_grpc.py emulated_bluetooth_vhci_pb2.py emulated_bluetooth_device_pb2.py grpc_endpoint_description_pb2.py)
for python_file in "${python_files[@]}"
do
- sed -i '' 's/^import .*_pb2 as/from . &/' $PROTOC_OUT/$python_file
-done
-
-stub_files=(emulated_bluetooth_pb2.pyi emulated_bluetooth_packets_pb2.pyi emulated_bluetooth_vhci_pb2.pyi)
-for source_file in "${python_files[@]}" "${stub_files[@]}"
-do
- head -14 $LICENSE_FILE_INPUT > $PROTOC_OUT/${source_file}.lic
- cat $PROTOC_OUT/$source_file >> $PROTOC_OUT/${source_file}.lic
- mv $PROTOC_OUT/${source_file}.lic $PROTOC_OUT/$source_file
+ sed -i 's/^import .*_pb2 as/from . \0/' $PROTOC_OUT/$python_file
done \ No newline at end of file
diff --git a/scripts/process_android_netsim_protos.sh b/scripts/process_android_netsim_protos.sh
new file mode 100644
index 0000000..5a26602
--- /dev/null
+++ b/scripts/process_android_netsim_protos.sh
@@ -0,0 +1,14 @@
+# Invoke this script with an argument pointing to where the AOSP `tools/netsim/src/proto` is
+PROTOC_OUT=bumble/transport/grpc_protobuf
+
+proto_files=(common.proto packet_streamer.proto hci_packet.proto startup.proto)
+for proto_file in "${proto_files[@]}"
+do
+ python -m grpc_tools.protoc -I$1 --proto_path=bumble/transport --python_out=$PROTOC_OUT --pyi_out=$PROTOC_OUT --grpc_python_out=$PROTOC_OUT $1/$proto_file
+done
+
+python_files=(packet_streamer_pb2_grpc.py packet_streamer_pb2.py hci_packet_pb2.py startup_pb2.py)
+for python_file in "${python_files[@]}"
+do
+ sed -i 's/^import .*_pb2 as/from . \0/' $PROTOC_OUT/$python_file
+done
diff --git a/setup.cfg b/setup.cfg
index 0a4aae3..45c7264 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -24,7 +24,7 @@ url = https://github.com/google/bumble
[options]
python_requires = >=3.8
-packages = bumble, bumble.transport, bumble.profiles, bumble.apps, bumble.apps.link_relay
+packages = bumble, bumble.transport, bumble.profiles, bumble.apps, bumble.apps.link_relay, bumble.pandora
package_dir =
bumble = bumble
bumble.apps = apps
@@ -33,7 +33,7 @@ install_requires =
appdirs >= 1.4
click >= 7.1.2; platform_system!='Emscripten'
cryptography == 35; platform_system!='Emscripten'
- grpcio >= 1.46; platform_system!='Emscripten'
+ grpcio == 1.51.1; platform_system!='Emscripten'
libusb1 >= 2.0.1; platform_system!='Emscripten'
libusb-package == 1.0.26.1; platform_system!='Emscripten'
prompt_toolkit >= 3.0.16; platform_system!='Emscripten'
@@ -43,6 +43,9 @@ install_requires =
pyserial >= 3.5; platform_system!='Emscripten'
pyusb >= 1.2; platform_system!='Emscripten'
websockets >= 8.1; platform_system!='Emscripten'
+ prettytable >= 3.6.0
+ humanize >= 4.6.0
+ bt-test-interfaces >= 0.0.2
[options.entry_points]
console_scripts =
@@ -58,6 +61,7 @@ console_scripts =
bumble-usb-probe = bumble.apps.usb_probe:main
bumble-link-relay = bumble.apps.link_relay.link_relay:main
bumble-bench = bumble.apps.bench:main
+ bumble-pandora-server = bumble.apps.pandora_server:main
[options.package_data]
* = py.typed, *.pyi
@@ -72,8 +76,9 @@ test =
coverage >= 6.4
development =
black == 22.10
+ grpcio-tools >= 1.51.1
invoke >= 1.7.3
- mypy == 1.1.1
+ mypy == 1.2.0
nox >= 2022
pylint == 2.15.8
types-appdirs >= 1.4.3
diff --git a/tests/core_test.py b/tests/core_test.py
index 7ee2dfd..6c9d0c3 100644
--- a/tests/core_test.py
+++ b/tests/core_test.py
@@ -15,7 +15,7 @@
# -----------------------------------------------------------------------------
# Imports
# -----------------------------------------------------------------------------
-from bumble.core import AdvertisingData, get_dict_key_by_value
+from bumble.core import AdvertisingData, UUID, get_dict_key_by_value
# -----------------------------------------------------------------------------
def test_ad_data():
@@ -50,5 +50,23 @@ def test_get_dict_key_by_value():
# -----------------------------------------------------------------------------
+def test_uuid_to_hex_str() -> None:
+ assert UUID("b5ea").to_hex_str() == "B5EA"
+ assert UUID("df5ce654").to_hex_str() == "DF5CE654"
+ assert (
+ UUID("df5ce654-e059-11ed-b5ea-0242ac120002").to_hex_str()
+ == "DF5CE654E05911EDB5EA0242AC120002"
+ )
+ assert UUID("b5ea").to_hex_str('-') == "B5EA"
+ assert UUID("df5ce654").to_hex_str('-') == "DF5CE654"
+ assert (
+ UUID("df5ce654-e059-11ed-b5ea-0242ac120002").to_hex_str('-')
+ == "DF5CE654-E059-11ED-B5EA-0242AC120002"
+ )
+
+
+# -----------------------------------------------------------------------------
if __name__ == '__main__':
test_ad_data()
+ test_get_dict_key_by_value()
+ test_uuid_to_hex_str()
diff --git a/tests/gatt_test.py b/tests/gatt_test.py
index 70bbdb8..0652197 100644
--- a/tests/gatt_test.py
+++ b/tests/gatt_test.py
@@ -23,6 +23,7 @@ import pytest
from bumble.controller import Controller
from bumble.gatt_client import CharacteristicProxy
+from bumble.gatt_server import Server
from bumble.link import LocalLink
from bumble.device import Device, Peer
from bumble.host import Host
@@ -114,7 +115,7 @@ async def test_characteristic_encoding():
c = Foo(
GATT_BATTERY_LEVEL_CHARACTERISTIC,
- Characteristic.READ,
+ Characteristic.Properties.READ,
Characteristic.READABLE,
123,
)
@@ -143,7 +144,9 @@ async def test_characteristic_encoding():
characteristic = Characteristic(
'FDB159DB-036C-49E3-B3DB-6325AC750806',
- Characteristic.READ | Characteristic.WRITE | Characteristic.NOTIFY,
+ Characteristic.Properties.READ
+ | Characteristic.Properties.WRITE
+ | Characteristic.Properties.NOTIFY,
Characteristic.READABLE | Characteristic.WRITEABLE,
bytes([123]),
)
@@ -239,7 +242,9 @@ async def test_attribute_getters():
characteristic_uuid = UUID('FDB159DB-036C-49E3-B3DB-6325AC750806')
characteristic = Characteristic(
characteristic_uuid,
- Characteristic.READ | Characteristic.WRITE | Characteristic.NOTIFY,
+ Characteristic.Properties.READ
+ | Characteristic.Properties.WRITE
+ | Characteristic.Properties.NOTIFY,
Characteristic.READABLE | Characteristic.WRITEABLE,
bytes([123]),
)
@@ -284,7 +289,7 @@ def test_CharacteristicAdapter():
v = bytes([1, 2, 3])
c = Characteristic(
GATT_BATTERY_LEVEL_CHARACTERISTIC,
- Characteristic.READ,
+ Characteristic.Properties.READ,
Characteristic.READABLE,
v,
)
@@ -420,7 +425,7 @@ async def test_read_write():
characteristic1 = Characteristic(
'FDB159DB-036C-49E3-B3DB-6325AC750806',
- Characteristic.READ | Characteristic.WRITE,
+ Characteristic.Properties.READ | Characteristic.Properties.WRITE,
Characteristic.READABLE | Characteristic.WRITEABLE,
)
@@ -437,7 +442,7 @@ async def test_read_write():
characteristic2 = Characteristic(
'66DE9057-C848-4ACA-B993-D675644EBB85',
- Characteristic.READ | Characteristic.WRITE,
+ Characteristic.Properties.READ | Characteristic.Properties.WRITE,
Characteristic.READABLE | Characteristic.WRITEABLE,
CharacteristicValue(
read=on_characteristic2_read, write=on_characteristic2_write
@@ -500,7 +505,7 @@ async def test_read_write2():
v = bytes([0x11, 0x22, 0x33, 0x44])
characteristic1 = Characteristic(
'FDB159DB-036C-49E3-B3DB-6325AC750806',
- Characteristic.READ | Characteristic.WRITE,
+ Characteristic.Properties.READ | Characteristic.Properties.WRITE,
Characteristic.READABLE | Characteristic.WRITEABLE,
value=v,
)
@@ -544,7 +549,7 @@ async def test_subscribe_notify():
characteristic1 = Characteristic(
'FDB159DB-036C-49E3-B3DB-6325AC750806',
- Characteristic.READ | Characteristic.NOTIFY,
+ Characteristic.Properties.READ | Characteristic.Properties.NOTIFY,
Characteristic.READABLE,
bytes([1, 2, 3]),
)
@@ -560,7 +565,7 @@ async def test_subscribe_notify():
characteristic2 = Characteristic(
'66DE9057-C848-4ACA-B993-D675644EBB85',
- Characteristic.READ | Characteristic.INDICATE,
+ Characteristic.Properties.READ | Characteristic.Properties.INDICATE,
Characteristic.READABLE,
bytes([4, 5, 6]),
)
@@ -576,7 +581,9 @@ async def test_subscribe_notify():
characteristic3 = Characteristic(
'AB5E639C-40C1-4238-B9CB-AF41F8B806E4',
- Characteristic.READ | Characteristic.NOTIFY | Characteristic.INDICATE,
+ Characteristic.Properties.READ
+ | Characteristic.Properties.NOTIFY
+ | Characteristic.Properties.INDICATE,
Characteristic.READABLE,
bytes([7, 8, 9]),
)
@@ -796,32 +803,46 @@ async def test_mtu_exchange():
# -----------------------------------------------------------------------------
def test_char_property_to_string():
# single
- assert Characteristic.property_name(0x01) == "BROADCAST"
- assert Characteristic.property_name(Characteristic.BROADCAST) == "BROADCAST"
+ assert str(Characteristic.Properties(0x01)) == "Properties.BROADCAST"
+ assert str(Characteristic.Properties.BROADCAST) == "Properties.BROADCAST"
# double
- assert Characteristic.properties_as_string(0x03) == "BROADCAST,READ"
+ assert str(Characteristic.Properties(0x03)) == "Properties.READ|BROADCAST"
assert (
- Characteristic.properties_as_string(
- Characteristic.BROADCAST | Characteristic.READ
- )
- == "BROADCAST,READ"
+ str(Characteristic.Properties.BROADCAST | Characteristic.Properties.READ)
+ == "Properties.READ|BROADCAST"
)
# -----------------------------------------------------------------------------
-def test_char_property_string_to_type():
+def test_characteristic_property_from_string():
# single
- assert Characteristic.string_to_properties("BROADCAST") == Characteristic.BROADCAST
+ assert (
+ Characteristic.Properties.from_string("BROADCAST")
+ == Characteristic.Properties.BROADCAST
+ )
# double
assert (
- Characteristic.string_to_properties("BROADCAST,READ")
- == Characteristic.BROADCAST | Characteristic.READ
+ Characteristic.Properties.from_string("BROADCAST,READ")
+ == Characteristic.Properties.BROADCAST | Characteristic.Properties.READ
)
assert (
- Characteristic.string_to_properties("READ,BROADCAST")
- == Characteristic.BROADCAST | Characteristic.READ
+ Characteristic.Properties.from_string("READ,BROADCAST")
+ == Characteristic.Properties.BROADCAST | Characteristic.Properties.READ
+ )
+
+
+# -----------------------------------------------------------------------------
+def test_characteristic_property_from_string_assert():
+ with pytest.raises(TypeError) as e_info:
+ Characteristic.Properties.from_string("BROADCAST,HELLO")
+
+ assert (
+ str(e_info.value)
+ == """Characteristic.Properties::from_string() error:
+Expected a string containing any of the keys, separated by commas: BROADCAST,READ,WRITE_WITHOUT_RESPONSE,WRITE,NOTIFY,INDICATE,AUTHENTICATED_SIGNED_WRITES,EXTENDED_PROPERTIES
+Got: BROADCAST,HELLO"""
)
@@ -832,7 +853,9 @@ async def test_server_string():
characteristic = Characteristic(
'FDB159DB-036C-49E3-B3DB-6325AC750806',
- Characteristic.READ | Characteristic.WRITE | Characteristic.NOTIFY,
+ Characteristic.Properties.READ
+ | Characteristic.Properties.WRITE
+ | Characteristic.Properties.NOTIFY,
Characteristic.READABLE | Characteristic.WRITEABLE,
bytes([123]),
)
@@ -843,13 +866,13 @@ async def test_server_string():
assert (
str(server.gatt_server)
== """Service(handle=0x0001, end=0x0005, uuid=UUID-16:1800 (Generic Access))
-CharacteristicDeclaration(handle=0x0002, value_handle=0x0003, uuid=UUID-16:2A00 (Device Name), properties=READ)
-Characteristic(handle=0x0003, end=0x0003, uuid=UUID-16:2A00 (Device Name), properties=READ)
-CharacteristicDeclaration(handle=0x0004, value_handle=0x0005, uuid=UUID-16:2A01 (Appearance), properties=READ)
-Characteristic(handle=0x0005, end=0x0005, uuid=UUID-16:2A01 (Appearance), properties=READ)
+CharacteristicDeclaration(handle=0x0002, value_handle=0x0003, uuid=UUID-16:2A00 (Device Name), Properties.READ)
+Characteristic(handle=0x0003, end=0x0003, uuid=UUID-16:2A00 (Device Name), Properties.READ)
+CharacteristicDeclaration(handle=0x0004, value_handle=0x0005, uuid=UUID-16:2A01 (Appearance), Properties.READ)
+Characteristic(handle=0x0005, end=0x0005, uuid=UUID-16:2A01 (Appearance), Properties.READ)
Service(handle=0x0006, end=0x0009, uuid=3A657F47-D34F-46B3-B1EC-698E29B6B829)
-CharacteristicDeclaration(handle=0x0007, value_handle=0x0008, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, properties=READ,WRITE,NOTIFY)
-Characteristic(handle=0x0008, end=0x0009, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, properties=READ,WRITE,NOTIFY)
+CharacteristicDeclaration(handle=0x0007, value_handle=0x0008, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, Properties.NOTIFY|WRITE|READ)
+Characteristic(handle=0x0008, end=0x0009, uuid=FDB159DB-036C-49E3-B3DB-6325AC750806, Properties.NOTIFY|WRITE|READ)
Descriptor(handle=0x0009, type=UUID-16:2902 (Client Characteristic Configuration), value=0000)"""
)
@@ -871,22 +894,132 @@ def test_attribute_string_to_permissions():
# -----------------------------------------------------------------------------
-def test_charracteristic_permissions():
+def test_characteristic_permissions():
characteristic = Characteristic(
'FDB159DB-036C-49E3-B3DB-6325AC750806',
- Characteristic.READ | Characteristic.WRITE | Characteristic.NOTIFY,
+ Characteristic.Properties.READ
+ | Characteristic.Properties.WRITE
+ | Characteristic.Properties.NOTIFY,
'READABLE,WRITEABLE',
)
assert characteristic.permissions == 3
# -----------------------------------------------------------------------------
+def test_characteristic_has_properties():
+ characteristic = Characteristic(
+ 'FDB159DB-036C-49E3-B3DB-6325AC750806',
+ Characteristic.Properties.READ
+ | Characteristic.Properties.WRITE
+ | Characteristic.Properties.NOTIFY,
+ 'READABLE,WRITEABLE',
+ )
+ assert characteristic.has_properties(Characteristic.Properties.READ)
+ assert characteristic.has_properties(
+ Characteristic.Properties.READ | Characteristic.Properties.WRITE
+ )
+ assert not characteristic.has_properties(
+ Characteristic.Properties.READ
+ | Characteristic.Properties.WRITE
+ | Characteristic.Properties.INDICATE
+ )
+ assert not characteristic.has_properties(Characteristic.Properties.INDICATE)
+
+
+# -----------------------------------------------------------------------------
def test_descriptor_permissions():
descriptor = Descriptor('2902', 'READABLE,WRITEABLE')
assert descriptor.permissions == 3
# -----------------------------------------------------------------------------
+def test_get_attribute_group():
+ device = Device()
+
+ # add some services / characteristics to the gatt server
+ characteristic1 = Characteristic(
+ '1111',
+ Characteristic.READ | Characteristic.WRITE | Characteristic.NOTIFY,
+ Characteristic.READABLE | Characteristic.WRITEABLE,
+ bytes([123]),
+ )
+ characteristic2 = Characteristic(
+ '2222',
+ Characteristic.READ | Characteristic.WRITE | Characteristic.NOTIFY,
+ Characteristic.READABLE | Characteristic.WRITEABLE,
+ bytes([123]),
+ )
+ services = [Service('1212', [characteristic1]), Service('3233', [characteristic2])]
+ device.gatt_server.add_services(services)
+
+ # get the handles from gatt server
+ characteristic_attributes1 = device.gatt_server.get_characteristic_attributes(
+ UUID('1212'), UUID('1111')
+ )
+ assert characteristic_attributes1 is not None
+ characteristic_attributes2 = device.gatt_server.get_characteristic_attributes(
+ UUID('3233'), UUID('2222')
+ )
+ assert characteristic_attributes2 is not None
+ descriptor1 = device.gatt_server.get_descriptor_attribute(
+ UUID('1212'), UUID('1111'), UUID('2902')
+ )
+ assert descriptor1 is not None
+ descriptor2 = device.gatt_server.get_descriptor_attribute(
+ UUID('3233'), UUID('2222'), UUID('2902')
+ )
+ assert descriptor2 is not None
+
+ # confirm the handles map back to the service
+ assert (
+ UUID('1212')
+ == device.gatt_server.get_attribute_group(
+ characteristic_attributes1[0].handle, Service
+ ).uuid
+ )
+ assert (
+ UUID('1212')
+ == device.gatt_server.get_attribute_group(
+ characteristic_attributes1[1].handle, Service
+ ).uuid
+ )
+ assert (
+ UUID('1212')
+ == device.gatt_server.get_attribute_group(descriptor1.handle, Service).uuid
+ )
+ assert (
+ UUID('3233')
+ == device.gatt_server.get_attribute_group(
+ characteristic_attributes2[0].handle, Service
+ ).uuid
+ )
+ assert (
+ UUID('3233')
+ == device.gatt_server.get_attribute_group(
+ characteristic_attributes2[1].handle, Service
+ ).uuid
+ )
+ assert (
+ UUID('3233')
+ == device.gatt_server.get_attribute_group(descriptor2.handle, Service).uuid
+ )
+
+ # confirm the handles map back to the characteristic
+ assert (
+ UUID('1111')
+ == device.gatt_server.get_attribute_group(
+ descriptor1.handle, Characteristic
+ ).uuid
+ )
+ assert (
+ UUID('2222')
+ == device.gatt_server.get_attribute_group(
+ descriptor2.handle, Characteristic
+ ).uuid
+ )
+
+
+# -----------------------------------------------------------------------------
if __name__ == '__main__':
logging.basicConfig(level=os.environ.get('BUMBLE_LOGLEVEL', 'INFO').upper())
test_UUID()
diff --git a/tests/self_test.py b/tests/self_test.py
index d6b16ec..1a1a474 100644
--- a/tests/self_test.py
+++ b/tests/self_test.py
@@ -28,9 +28,8 @@ from bumble.device import Device, Peer
from bumble.host import Host
from bumble.gatt import Service, Characteristic
from bumble.transport import AsyncPipeSink
+from bumble.pairing import PairingConfig, PairingDelegate
from bumble.smp import (
- PairingConfig,
- PairingDelegate,
SMP_PAIRING_NOT_SUPPORTED_ERROR,
SMP_CONFIRM_VALUE_FAILED_ERROR,
)
@@ -163,32 +162,37 @@ async def test_self_gatt():
# Add some GATT characteristics to device 1
c1 = Characteristic(
'3A143AD7-D4A7-436B-97D6-5B62C315E833',
- Characteristic.READ,
+ Characteristic.Properties.READ,
Characteristic.READABLE,
bytes([1, 2, 3]),
)
c2 = Characteristic(
'9557CCE2-DB37-46EB-94C4-50AE5B9CB0F8',
- Characteristic.READ | Characteristic.WRITE,
+ Characteristic.Properties.READ | Characteristic.Properties.WRITE,
Characteristic.READABLE | Characteristic.WRITEABLE,
bytes([4, 5, 6]),
)
c3 = Characteristic(
'84FC1A2E-C52D-4A2D-B8C3-8855BAB86638',
- Characteristic.READ | Characteristic.WRITE_WITHOUT_RESPONSE,
+ Characteristic.Properties.READ
+ | Characteristic.Properties.WRITE_WITHOUT_RESPONSE,
Characteristic.READABLE | Characteristic.WRITEABLE,
bytes([7, 8, 9]),
)
c4 = Characteristic(
'84FC1A2E-C52D-4A2D-B8C3-8855BAB86638',
- Characteristic.READ | Characteristic.NOTIFY | Characteristic.INDICATE,
+ Characteristic.Properties.READ
+ | Characteristic.Properties.NOTIFY
+ | Characteristic.Properties.INDICATE,
Characteristic.READABLE,
bytes([1, 1, 1]),
)
s1 = Service('8140E247-04F0-42C1-BC34-534C344DAFCA', [c1, c2, c3])
s2 = Service('97210A0F-1875-4D05-9E5D-326EB171257A', [c4])
- two_devices.devices[1].add_services([s1, s2])
+ s3 = Service('1853', [])
+ s4 = Service('3A12C182-14E2-4FE0-8C5B-65D7C569F9DB', [], included_services=[s2, s3])
+ two_devices.devices[1].add_services([s1, s2, s4])
# Start
await two_devices.devices[0].power_on()
@@ -223,6 +227,13 @@ async def test_self_gatt():
assert result is not None
assert result == c1.value
+ result = await peer.discover_service(s4.uuid)
+ assert len(result) == 1
+ result = await peer.discover_included_services(result[0])
+ assert len(result) == 2
+ # Service UUID is only present when the UUID is 16-bit Bluetooth UUID
+ assert result[1].uuid.to_bytes() == s3.uuid.to_bytes()
+
# -----------------------------------------------------------------------------
@pytest.mark.asyncio
@@ -234,7 +245,7 @@ async def test_self_gatt_long_read():
characteristics = [
Characteristic(
f'3A143AD7-D4A7-436B-97D6-5B62C315{i:04X}',
- Characteristic.READ,
+ Characteristic.Properties.READ,
Characteristic.READABLE,
bytes([x & 255 for x in range(i)]),
)
@@ -259,7 +270,7 @@ async def test_self_gatt_long_read():
found_service = result[0]
found_characteristics = await found_service.discover_characteristics()
assert len(found_characteristics) == 513
- for (i, characteristic) in enumerate(found_characteristics):
+ for i, characteristic in enumerate(found_characteristics):
value = await characteristic.read_value()
assert value == characteristics[i].value
@@ -314,11 +325,11 @@ async def _test_self_smp_with_configs(pairing_config1, pairing_config2):
# -----------------------------------------------------------------------------
IO_CAP = [
- PairingDelegate.NO_OUTPUT_NO_INPUT,
- PairingDelegate.KEYBOARD_INPUT_ONLY,
- PairingDelegate.DISPLAY_OUTPUT_ONLY,
- PairingDelegate.DISPLAY_OUTPUT_AND_YES_NO_INPUT,
- PairingDelegate.DISPLAY_OUTPUT_AND_KEYBOARD_INPUT,
+ PairingDelegate.IoCapability.NO_OUTPUT_NO_INPUT,
+ PairingDelegate.IoCapability.KEYBOARD_INPUT_ONLY,
+ PairingDelegate.IoCapability.DISPLAY_OUTPUT_ONLY,
+ PairingDelegate.IoCapability.DISPLAY_OUTPUT_AND_YES_NO_INPUT,
+ PairingDelegate.IoCapability.DISPLAY_OUTPUT_AND_KEYBOARD_INPUT,
]
SC = [False, True]
MITM = [False, True]
@@ -332,7 +343,10 @@ KEY_DIST = range(16)
itertools.chain(
itertools.product([IO_CAP], SC, MITM, [15]),
itertools.product(
- [[PairingDelegate.DISPLAY_OUTPUT_AND_KEYBOARD_INPUT]], SC, MITM, KEY_DIST
+ [[PairingDelegate.IoCapability.DISPLAY_OUTPUT_AND_KEYBOARD_INPUT]],
+ SC,
+ MITM,
+ KEY_DIST,
),
),
)
@@ -375,7 +389,7 @@ async def test_self_smp(io_caps, sc, mitm, key_dist):
else:
if (
self.peer_delegate.io_capability
- == PairingDelegate.KEYBOARD_INPUT_ONLY
+ == PairingDelegate.IoCapability.KEYBOARD_INPUT_ONLY
):
peer_number = 6789
else:
@@ -418,7 +432,7 @@ async def test_self_smp(io_caps, sc, mitm, key_dist):
async def test_self_smp_reject():
class RejectingDelegate(PairingDelegate):
def __init__(self):
- super().__init__(PairingDelegate.NO_OUTPUT_NO_INPUT)
+ super().__init__(PairingDelegate.IoCapability.NO_OUTPUT_NO_INPUT)
async def accept(self):
return False
@@ -439,12 +453,14 @@ async def test_self_smp_reject():
async def test_self_smp_wrong_pin():
class WrongPinDelegate(PairingDelegate):
def __init__(self):
- super().__init__(PairingDelegate.DISPLAY_OUTPUT_AND_KEYBOARD_INPUT)
+ super().__init__(
+ PairingDelegate.IoCapability.DISPLAY_OUTPUT_AND_KEYBOARD_INPUT
+ )
async def compare_numbers(self, number, digits):
return False
- wrong_pin_pairing_config = PairingConfig(delegate=WrongPinDelegate())
+ wrong_pin_pairing_config = PairingConfig(mitm=True, delegate=WrongPinDelegate())
paired = False
try:
await _test_self_smp_with_configs(