Snap for 10453563 from e73357f94de4f352ac9dc282206c1bdacbc68481 to mainline-tzdata5-release

Change-Id: Ie05b3986eff04eb20e85387d7578e46644a4a35e
diff --git a/.github/workflows/python-build.yml b/.github/workflows/python-build.yml
new file mode 100644
index 0000000..18193b1
--- /dev/null
+++ b/.github/workflows/python-build.yml
@@ -0,0 +1,27 @@
+name: Python Build
+
+on:
+  push:
+    branches: [ main ]
+  pull_request:
+    branches: [ main ]
+
+jobs:
+  build:
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        python-version: ["3.10", "3.11"]
+    steps:
+      - uses: actions/checkout@v3
+      - name: Set Up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v4
+        with:
+          python-version: ${{ matrix.python-version }}
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+          python -m pip install build
+          python -m pip install .
+      - name: Build
+        run: python -m build
diff --git a/.github/workflows/python-lint-and-format.yml b/.github/workflows/python-lint-and-format.yml
new file mode 100644
index 0000000..8ddff35
--- /dev/null
+++ b/.github/workflows/python-lint-and-format.yml
@@ -0,0 +1,34 @@
+name: Python Lint & Format
+
+on:
+  push:
+    branches: [ main ]
+  pull_request:
+    branches: [ main ]
+
+jobs:
+  lint_and_format:
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        python-version: ["3.10", "3.11"]
+    steps:
+      - uses: actions/checkout@v3
+      - name: Set Up Python ${{ matrix.python-version }}
+        uses: actions/setup-python@v4
+        with:
+          python-version: ${{ matrix.python-version }}
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+          pip install .[dev]
+      - name: Lint with mypy
+        run: |
+          mypy
+      - name: Lint with pyright
+        run: |
+          pyright
+      - name: Check the Format with black and isort
+        run: |
+          black --check avatar/ cases/
+          isort --check avatar/ cases/
diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml
new file mode 100644
index 0000000..7d9bc19
--- /dev/null
+++ b/.github/workflows/python-publish.yml
@@ -0,0 +1,37 @@
+name: Upload Python Package
+
+on:
+  release:
+    types: [published]
+    
+permissions:
+  contents: read
+
+jobs:
+  deploy:
+    name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI
+    runs-on: ubuntu-latest
+
+    steps:
+    - name: Check out from Git
+      uses: actions/checkout@v3
+    - name: Get history and tags for SCM versioning to work
+      run: |
+        git fetch --prune --unshallow
+        git fetch --depth=1 origin +refs/tags/*:refs/tags/*
+    - name: Set up Python
+      uses: actions/setup-python@v3
+      with:
+        python-version: '3.10'
+    - name: Install dependencies
+      run: |
+        python -m pip install --upgrade pip
+        python -m pip install build
+    - name: Build package
+      run: python -m build
+    - name: Publish package to PyPI
+      if: github.event_name == 'release' && startsWith(github.ref, 'refs/tags')
+      uses: pypa/gh-action-pypi-publish@release/v1
+      with:
+        user: __token__
+        password: ${{ secrets.PYPI_API_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 4ea05a1..18c1013 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
+dist/
 venv/
-__pycache__/
\ No newline at end of file
+__pycache__/
diff --git a/Android.bp b/Android.bp
index d919409..2d859db 100644
--- a/Android.bp
+++ b/Android.bp
@@ -19,14 +19,13 @@
     name: "libavatar",
     srcs: [
         "avatar/*.py",
-        "avatar/bumble_server/*.py",
         "avatar/controllers/*.py",
-        "avatar/servers/*.py"
     ],
     libs: [
         "pandora-python",
         "libprotobuf-python",
         "bumble",
+        "bumble-pandora",
         "mobly",
     ],
     data: [
@@ -34,19 +33,7 @@
     ]
 }
 
-python_test_host {
-    name: "bumble_pandora_server",
-    main: "avatar/bumble_server/__init__.py",
-    srcs: [
-        "avatar/bumble_server/*.py",
-        "avatar/bumble_device.py",
-    ],
-    libs: [
-        "bumble",
-        "pandora-python",
-    ],
-
-    test_options: {
-        unit_test: false,
-    },
+filegroup {
+    name: "avatar-cases",
+    srcs: ["cases/*.py"],
 }
diff --git a/README.md b/README.md
index 211a20d..3dcfc2d 100644
--- a/README.md
+++ b/README.md
@@ -7,25 +7,20 @@
 ## Install
 
 ```bash
-git submodule update --init
 python -m venv venv
 source venv/bin/activate.fish # or any other shell
-pip install [-e] bt-test-interfaces/python
-pip install [-e] third-party/bumble
 pip install [-e] .
 ```
 
-## Rebuild gRPC Bluetooth test interfaces
-
-```bash
-pip install grpcio-tools==1.46.3
-./bt-test-interfaces/python/_build/grpc.py
-```
-
 ## Usage
 
 ```bash
-python examples/example.py -c examples/simulated_bumble_android.yml --verbose
+python cases/host_test.py -c cases/config.yml --verbose
+```
+
+## Specify a test bed
+```bash
+python cases/host_test.py -c cases/config.yml --test_bed bumble.bumbles --verbose
 ```
 
 ## Development
@@ -37,7 +32,7 @@
 
 1. Run the example using Bumble vs Bumble config file. The default `6402` HCI port of `root-canal` may be changed in this config file.
 ```
-python examples/example.py -c examples/simulated_bumble_bumble.yml --verbose
+python cases/host_test.py -c cases/config.yml --verbose
 ```
 
 3. Lint with `pyright` and `mypy`
@@ -48,6 +43,6 @@
 
 3. Format & imports style
 ```
-black avatar/ examples/
-isort avatar/ examples/
+black avatar/ cases/
+isort avatar/ cases/
 ```
diff --git a/avatar/__init__.py b/avatar/__init__.py
index 8050261..b679c27 100644
--- a/avatar/__init__.py
+++ b/avatar/__init__.py
@@ -17,8 +17,9 @@
 any Bluetooth test cases virtually and physically.
 """
 
-__version__ = "0.0.1"
+__version__ = "0.0.2"
 
+import enum
 import functools
 import grpc
 import grpc.aio
@@ -30,7 +31,7 @@
 from avatar.pandora_client import BumblePandoraClient as BumblePandoraDevice, PandoraClient as PandoraDevice
 from avatar.pandora_server import PandoraServer
 from mobly import base_test
-from typing import Any, Callable, Dict, Iterable, Iterator, List, Sized, Tuple, Type, TypeVar
+from typing import Any, Callable, Dict, Iterable, Iterator, List, Optional, Sized, Tuple, Type, TypeVar
 
 # public symbols
 __all__ = [
@@ -174,9 +175,18 @@
 
                     return wrapper
 
+                def normalize(a: Any) -> Any:
+                    if isinstance(a, enum.Enum):
+                        return a.value
+                    return a
+
                 # we need to pass `input` here, otherwise it will be set to the value
                 # from the last iteration of `inputs`
-                setattr(owner, f"{name}{input}".replace(' ', ''), decorate(input))
+                setattr(
+                    owner,
+                    f"{name}{tuple([normalize(a) for a in input])}".replace(" ", ""),
+                    decorate(input),
+                )
             delattr(owner, name)
 
     return wrapper
diff --git a/avatar/bumble_device.py b/avatar/bumble_device.py
deleted file mode 100644
index 872e013..0000000
--- a/avatar/bumble_device.py
+++ /dev/null
@@ -1,142 +0,0 @@
-# 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 BumbleDevice:
-    """
-    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)
-
-    # FIXME: add `classic_enabled` to `DeviceConfiguration` ?
-    device.classic_enabled = config.get('classic_enabled', False)
-    # 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/avatar/bumble_server/__init__.py b/avatar/bumble_server/__init__.py
deleted file mode 100644
index 184968f..0000000
--- a/avatar/bumble_server/__init__.py
+++ /dev/null
@@ -1,89 +0,0 @@
-# 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."""
-
-__version__ = "0.0.1"
-
-import asyncio
-import grpc
-import grpc.aio
-import logging
-
-from avatar.bumble_device import BumbleDevice
-from avatar.bumble_server.host import HostService
-from avatar.bumble_server.security import SecurityService, SecurityStorageService
-from bumble.smp import PairingDelegate
-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
-
-# Add servicers hooks.
-_SERVICERS_HOOKS: List[Callable[[BumbleDevice, grpc.aio.Server], None]] = []
-
-
-def register_servicer_hook(hook: Callable[[BumbleDevice, grpc.aio.Server], None]) -> None:
-    _SERVICERS_HOOKS.append(hook)
-
-
-async def serve_bumble(bumble: BumbleDevice, 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}')
-
-    # load IO capability from config.
-    io_capability_name: str = bumble.config.get('io_capability', 'no_output_no_input').upper()
-    io_capability: int = getattr(PairingDelegate, io_capability_name)
-
-    try:
-        while True:
-            # add Pandora services to the gRPC server.
-            add_HostServicer_to_server(HostService(server, bumble.device), server)
-            add_SecurityServicer_to_server(SecurityService(bumble.device, io_capability), server)
-            add_SecurityStorageServicer_to_server(SecurityStorageService(bumble.device), server)
-
-            # call hooks if any.
-            for hook in _SERVICERS_HOOKS:
-                hook(bumble, server)
-
-            # open device.
-            await bumble.open()
-            try:
-                # Pandora require classic devices to to be discoverable & connectable.
-                if bumble.device.classic_enabled:
-                    await bumble.device.set_discoverable(False)
-                    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)
-
-
-BUMBLE_SERVER_GRPC_PORT = 7999
-ROOTCANAL_PORT_CUTTLEFISH = 7300
-
-if __name__ == '__main__':
-    bumble = BumbleDevice({'transport': f'tcp-client:127.0.0.1:{ROOTCANAL_PORT_CUTTLEFISH}', 'classic_enabled': True})
-    logging.basicConfig(level=logging.DEBUG)
-    asyncio.run(serve_bumble(bumble, port=BUMBLE_SERVER_GRPC_PORT))
diff --git a/avatar/bumble_server/host.py b/avatar/bumble_server/host.py
deleted file mode 100644
index d9d6e4a..0000000
--- a/avatar/bumble_server/host.py
+++ /dev/null
@@ -1,682 +0,0 @@
-# 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 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):
-    grpc_server: grpc.aio.Server
-    device: Device
-    waited_connections: Set[int]
-
-    def __init__(self, grpc_server: grpc.aio.Server, device: Device) -> None:
-        super().__init__()
-        self.log = utils.BumbleServerLoggerAdapter(logging.getLogger(), {'service_name': 'Host', 'device': device})
-        self.grpc_server = grpc_server
-        self.device = device
-        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/avatar/bumble_server/security.py b/avatar/bumble_server/security.py
deleted file mode 100644
index 000d49f..0000000
--- a/avatar/bumble_server/security.py
+++ /dev/null
@@ -1,454 +0,0 @@
-# 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 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.smp 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, cast
-
-
-class PairingDelegate(BasePairingDelegate):
-    def __init__(
-        self,
-        connection: BumbleConnection,
-        service: "SecurityService",
-        io_capability: int = BasePairingDelegate.NO_OUTPUT_NO_INPUT,
-        local_initiator_key_distribution: int = BasePairingDelegate.DEFAULT_KEY_DISTRIBUTION,
-        local_responder_key_distribution: int = 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) -> 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
-        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
-        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:
-        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, io_capability: int) -> 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
-
-        def pairing_config_factory(connection: BumbleConnection) -> PairingConfig:
-            return PairingConfig(
-                sc=True,
-                mitm=True,
-                bonding=True,
-                delegate=PairingDelegate(
-                    connection, self, io_capability=cast(int, getattr(self.device, 'io_capability'))
-                ),
-            )
-
-        setattr(device, 'io_capability', io_capability)
-        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(f"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(f"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(f"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(f'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(f'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) -> None:
-        self.log = utils.BumbleServerLoggerAdapter(
-            logging.getLogger(), {'service_name': 'SecurityStorage', 'device': device}
-        )
-        self.device = device
-
-    @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/avatar/bumble_server/utils.py b/avatar/bumble_server/utils.py
deleted file mode 100644
index fc47d09..0000000
--- a/avatar/bumble_server/utils.py
+++ /dev/null
@@ -1,102 +0,0 @@
-# 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, 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/avatar/controllers/__init__.py b/avatar/controllers/__init__.py
index 543e742..46e19ca 100644
--- a/avatar/controllers/__init__.py
+++ b/avatar/controllers/__init__.py
@@ -13,5 +13,3 @@
 # limitations under the License.
 
 """Avatar Mobly controllers."""
-
-__version__ = "0.0.1"
diff --git a/avatar/controllers/bumble_device.py b/avatar/controllers/bumble_device.py
index 157139c..7fc36fd 100644
--- a/avatar/controllers/bumble_device.py
+++ b/avatar/controllers/bumble_device.py
@@ -17,18 +17,18 @@
 import asyncio
 import avatar.aio
 
-from avatar.bumble_device import BumbleDevice
+from bumble.pandora.device import PandoraDevice as BumblePandoraDevice
 from typing import Any, Dict, List, Optional
 
 MOBLY_CONTROLLER_CONFIG_NAME = 'BumbleDevice'
 
 
-def create(configs: List[Dict[str, Any]]) -> List[BumbleDevice]:
+def create(configs: List[Dict[str, Any]]) -> List[BumblePandoraDevice]:
     """Create a list of `BumbleDevice` from configs."""
-    return [BumbleDevice(config) for config in configs]
+    return [BumblePandoraDevice(config) for config in configs]
 
 
-def destroy(devices: List[BumbleDevice]) -> None:
+def destroy(devices: List[BumblePandoraDevice]) -> None:
     """Destroy each `BumbleDevice`"""
 
     async def close_devices() -> None:
@@ -37,6 +37,6 @@
     avatar.aio.run_until_complete(close_devices())
 
 
-def get_info(devices: List[BumbleDevice]) -> List[Optional[Dict[str, str]]]:
-    """Return the device info for each `BumbleDevice`."""
+def get_info(devices: List[BumblePandoraDevice]) -> List[Optional[Dict[str, str]]]:
+    """Return the device info for each `BumblePandoraDevice`."""
     return [device.info() for device in devices]
diff --git a/avatar/controllers/pandora_device.py b/avatar/controllers/pandora_device.py
index 298950a..10b136b 100644
--- a/avatar/controllers/pandora_device.py
+++ b/avatar/controllers/pandora_device.py
@@ -1,3 +1,7 @@
+# Copyright 2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
 # You may obtain a copy of the License at
 #
 #     https://www.apache.org/licenses/LICENSE-2.0
diff --git a/avatar/pandora_client.py b/avatar/pandora_client.py
index 548e6d1..dab098c 100644
--- a/avatar/pandora_client.py
+++ b/avatar/pandora_client.py
@@ -23,8 +23,9 @@
 import grpc.aio
 import logging
 
-from avatar.bumble_device import BumbleDevice
+from bumble import pandora as bumble_server
 from bumble.hci import Address as BumbleAddress
+from bumble.pandora.device import PandoraDevice as BumblePandoraDevice
 from dataclasses import dataclass
 from pandora import host_grpc, host_grpc_aio, security_grpc, security_grpc_aio
 from typing import Any, Dict, MutableMapping, Optional, Tuple, Union
@@ -54,6 +55,7 @@
     """Provides Pandora interface access to a device via gRPC."""
 
     # public fields
+    name: str
     grpc_target: str  # Server address for the gRPC channel.
     log: 'PandoraClientLoggerAdapter'  # Logger adapter.
 
@@ -70,8 +72,9 @@
         Args:
           grpc_target: Server address for the gRPC channel.
         """
+        self.name = name
         self.grpc_target = grpc_target
-        self.log = PandoraClientLoggerAdapter(logging.getLogger(), {'client': self, 'client_name': name})
+        self.log = PandoraClientLoggerAdapter(logging.getLogger(), {'client': self})
         self._channel = grpc.insecure_channel(grpc_target)  # type: ignore
         self._address = Address(b'\x00\x00\x00\x00\x00\x00')
         self._aio = None
@@ -177,19 +180,24 @@
         assert self.extra
         client = self.extra['client']
         assert isinstance(client, PandoraClient)
-        client_name = self.extra.get('client_name', client.__class__.__name__)
         addr = ':'.join([f'{x:02X}' for x in client.address[4:]])
-        return (f'[{client_name}:{addr}] {msg}', kwargs)
+        return (f'[{client.name}:{addr}] {msg}', kwargs)
 
 
 class BumblePandoraClient(PandoraClient):
     """Special Pandora client which also give access to a Bumble device instance."""
 
-    _bumble: BumbleDevice  # Bumble device wrapper.
+    _bumble: BumblePandoraDevice  # Bumble device wrapper.
+    _server_config: bumble_server.Config  # Bumble server config.
 
-    def __init__(self, grpc_target: str, bumble: BumbleDevice) -> None:
+    def __init__(self, grpc_target: str, bumble: BumblePandoraDevice, server_config: bumble_server.Config) -> None:
         super().__init__(grpc_target, 'bumble')
         self._bumble = bumble
+        self._server_config = server_config
+
+    @property
+    def server_config(self) -> bumble_server.Config:
+        return self._server_config
 
     @property
     def config(self) -> Dict[str, Any]:
diff --git a/avatar/pandora_server.py b/avatar/pandora_server.py
index ceb57ed..4fd56fb 100644
--- a/avatar/pandora_server.py
+++ b/avatar/pandora_server.py
@@ -19,20 +19,21 @@
 import avatar.aio
 import grpc
 import grpc.aio
+import portpicker
 import threading
 import types
 
-from avatar.bumble_device import BumbleDevice
-from avatar.bumble_server import serve_bumble
 from avatar.controllers import bumble_device, pandora_device
 from avatar.pandora_client import BumblePandoraClient, PandoraClient
+from bumble import pandora as bumble_server
+from bumble.pandora.device import PandoraDevice as BumblePandoraDevice
 from contextlib import suppress
 from mobly.controllers import android_device
 from mobly.controllers.android_device import AndroidDevice
 from typing import Generic, Optional, TypeVar
 
 ANDROID_SERVER_PACKAGE = 'com.android.pandora'
-ANDROID_SERVER_GRPC_PORT = 8999  # TODO: Use a dynamic port
+ANDROID_SERVER_GRPC_PORT = 8999
 
 
 # Generic type for `PandoraServer`.
@@ -63,7 +64,7 @@
         """Stops and cleans up the Pandora server on the device."""
 
 
-class BumblePandoraServer(PandoraServer[BumbleDevice]):
+class BumblePandoraServer(PandoraServer[BumblePandoraDevice]):
     """Manages the Pandora gRPC server on a BumbleDevice."""
 
     MOBLY_CONTROLLER_MODULE = bumble_device
@@ -81,9 +82,12 @@
         server = grpc.aio.server()
         port = server.add_insecure_port(f'localhost:{0}')
 
-        self._task = avatar.aio.loop.create_task(serve_bumble(self.device, grpc_server=server, port=port))
+        config = bumble_server.Config()
+        self._task = avatar.aio.loop.create_task(
+            bumble_server.serve(self.device, config=config, grpc_server=server, port=port)
+        )
 
-        return BumblePandoraClient(f'localhost:{port}', self.device)
+        return BumblePandoraClient(f'localhost:{port}', self.device, config)
 
     def stop(self) -> None:
         """Stops and cleans up the Pandora server on the Bumble device."""
@@ -105,13 +109,14 @@
     MOBLY_CONTROLLER_MODULE = android_device
 
     _instrumentation: Optional[threading.Thread] = None
-    _port: int = ANDROID_SERVER_GRPC_PORT
+    _port: int
 
     def start(self) -> PandoraClient:
         """Sets up and starts the Pandora server on the Android device."""
         assert self._instrumentation is None
 
         # start Pandora Android gRPC server.
+        self._port = portpicker.pick_unused_port()  # type: ignore
         self._instrumentation = threading.Thread(
             target=lambda: self.device.adb._exec_adb_cmd(  # type: ignore
                 'shell',
@@ -136,6 +141,6 @@
             'shell', f'am force-stop {ANDROID_SERVER_PACKAGE}', shell=False, timeout=None, stderr=None
         )
 
-        self.device.adb.forward(['--remove', f'tcp:{ANDROID_SERVER_GRPC_PORT}'])  # type: ignore
+        self.device.adb.forward(['--remove', f'tcp:{self._port}'])  # type: ignore
         self._instrumentation.join()
         self._instrumentation = None
diff --git a/bt-test-interfaces b/bt-test-interfaces
deleted file mode 120000
index 4cee449..0000000
--- a/bt-test-interfaces
+++ /dev/null
@@ -1 +0,0 @@
-../bt-test-interfaces/
\ No newline at end of file
diff --git a/cases/__init__.py b/cases/__init__.py
new file mode 100644
index 0000000..bb545f4
--- /dev/null
+++ b/cases/__init__.py
@@ -0,0 +1,13 @@
+# 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.
diff --git a/cases/config.yml b/cases/config.yml
new file mode 100644
index 0000000..3682981
--- /dev/null
+++ b/cases/config.yml
@@ -0,0 +1,15 @@
+---
+
+TestBeds:
+- Name: android.bumbles
+  Controllers:
+    AndroidDevice: '*'
+    BumbleDevice:
+    - transport: 'tcp-client:127.0.0.1:6211'
+    - transport: 'tcp-client:127.0.0.1:6211'
+- Name: bumble.bumbles
+  Controllers:
+    BumbleDevice:
+    - transport: 'tcp-client:127.0.0.1:7300'
+    - transport: 'tcp-client:127.0.0.1:7300'
+    - transport: 'tcp-client:127.0.0.1:7300'
diff --git a/cases/host_test.py b/cases/host_test.py
new file mode 100644
index 0000000..59e5100
--- /dev/null
+++ b/cases/host_test.py
@@ -0,0 +1,140 @@
+# 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 avatar
+import grpc
+import logging
+
+from avatar import BumblePandoraDevice, PandoraDevice, PandoraDevices
+from mobly import base_test, signals, test_runner
+from mobly.asserts import assert_equal  # type: ignore
+from mobly.asserts import assert_false  # type: ignore
+from mobly.asserts import assert_is_none  # type: ignore
+from mobly.asserts import assert_is_not_none  # type: ignore
+from mobly.asserts import assert_true  # type: ignore
+from mobly.asserts import explicit_pass  # type: ignore
+from pandora.host_pb2 import (
+    DISCOVERABLE_GENERAL,
+    DISCOVERABLE_LIMITED,
+    NOT_DISCOVERABLE,
+    Connection,
+    DiscoverabilityMode,
+)
+from typing import Optional
+
+
+class HostTest(base_test.BaseTestClass):  # type: ignore[misc]
+    devices: Optional[PandoraDevices] = None
+
+    # pandora devices.
+    dut: PandoraDevice
+    ref: PandoraDevice
+
+    def setup_class(self) -> None:
+        self.devices = PandoraDevices(self)
+        self.dut, self.ref, *_ = self.devices
+
+        # Enable BR/EDR mode for Bumble devices.
+        for device in self.devices:
+            if isinstance(device, BumblePandoraDevice):
+                device.config.setdefault('classic_enabled', True)
+
+    def teardown_class(self) -> None:
+        if self.devices:
+            self.devices.stop_all()
+
+    @avatar.asynchronous
+    async def setup_test(self) -> None:  # pytype: disable=wrong-arg-types
+        await asyncio.gather(self.dut.reset(), self.ref.reset())
+
+    @avatar.parameterized(
+        (DISCOVERABLE_LIMITED,),
+        (DISCOVERABLE_GENERAL,),
+    )  # type: ignore[misc]
+    def test_discoverable(self, mode: DiscoverabilityMode) -> None:
+        self.dut.host.SetDiscoverabilityMode(mode=mode)
+        inquiry = self.ref.host.Inquiry(timeout=15.0)
+        try:
+            assert_is_not_none(next((x for x in inquiry if x.address == self.dut.address), None))
+        finally:
+            inquiry.cancel()
+
+    # This test should reach the `Inquiry` timeout.
+    @avatar.rpc_except(
+        {
+            grpc.StatusCode.DEADLINE_EXCEEDED: lambda e: explicit_pass(e.details()),
+        }
+    )
+    def test_not_discoverable(self) -> None:
+        self.dut.host.SetDiscoverabilityMode(mode=NOT_DISCOVERABLE)
+        inquiry = self.ref.host.Inquiry(timeout=3.0)
+        try:
+            assert_is_none(next((x for x in inquiry if x.address == self.dut.address), None))
+        finally:
+            inquiry.cancel()
+
+    @avatar.asynchronous
+    async def test_connect(self) -> None:
+        if self.dut.name == 'android':
+            raise signals.TestSkip('TODO: Android connection is too flaky (b/285634621)')
+        ref_dut_res, dut_ref_res = await asyncio.gather(
+            self.ref.aio.host.WaitConnection(address=self.dut.address),
+            self.dut.aio.host.Connect(address=self.ref.address),
+        )
+        assert_is_not_none(ref_dut_res.connection)
+        assert_is_not_none(dut_ref_res.connection)
+        ref_dut, dut_ref = ref_dut_res.connection, dut_ref_res.connection
+        assert ref_dut and dut_ref
+        assert_true(await self.is_connected(self.ref, ref_dut), "")
+
+    @avatar.asynchronous
+    async def test_accept(self) -> None:
+        dut_ref_res, ref_dut_res = await asyncio.gather(
+            self.dut.aio.host.WaitConnection(address=self.ref.address),
+            self.ref.aio.host.Connect(address=self.dut.address),
+        )
+        assert_is_not_none(ref_dut_res.connection)
+        assert_is_not_none(dut_ref_res.connection)
+        ref_dut, dut_ref = ref_dut_res.connection, dut_ref_res.connection
+        assert ref_dut and dut_ref
+        assert_true(await self.is_connected(self.ref, ref_dut), "")
+
+    @avatar.asynchronous
+    async def test_disconnect(self) -> None:
+        if self.dut.name == 'android':
+            raise signals.TestSkip('TODO: Android disconnection is too flaky (b/286081956)')
+        dut_ref_res, ref_dut_res = await asyncio.gather(
+            self.dut.aio.host.WaitConnection(address=self.ref.address),
+            self.ref.aio.host.Connect(address=self.dut.address),
+        )
+        assert_is_not_none(ref_dut_res.connection)
+        assert_is_not_none(dut_ref_res.connection)
+        ref_dut, dut_ref = ref_dut_res.connection, dut_ref_res.connection
+        assert ref_dut and dut_ref
+        await self.dut.aio.host.Disconnect(connection=dut_ref)
+        assert_false(await self.is_connected(self.ref, ref_dut), "")
+
+    async def is_connected(self, device: PandoraDevice, connection: Connection) -> bool:
+        try:
+            await device.aio.host.WaitDisconnection(connection=connection, timeout=5)
+            return False
+        except grpc.RpcError as e:
+            assert_equal(e.code(), grpc.StatusCode.DEADLINE_EXCEEDED)  # type: ignore
+            return True
+
+
+if __name__ == '__main__':
+    logging.basicConfig(level=logging.DEBUG)
+    test_runner.main()  # type: ignore
diff --git a/cases/le_host_test.py b/cases/le_host_test.py
new file mode 100644
index 0000000..eb2f017
--- /dev/null
+++ b/cases/le_host_test.py
@@ -0,0 +1,250 @@
+# 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 avatar
+import enum
+import grpc
+import itertools
+import logging
+import random
+
+from avatar import BumblePandoraDevice, PandoraDevice, PandoraDevices
+from mobly import base_test, test_runner
+from mobly.asserts import assert_equal  # type: ignore
+from mobly.asserts import assert_false  # type: ignore
+from mobly.asserts import assert_is_not_none  # type: ignore
+from mobly.asserts import assert_true  # type: ignore
+from mobly.asserts import explicit_pass  # type: ignore
+from pandora.host_pb2 import PUBLIC, RANDOM, Connection, DataTypes, OwnAddressType
+from typing import Any, Dict, Literal, Optional, Union
+
+
+class AdvertisingEventProperties(enum.IntEnum):
+    ADV_IND = 0x13
+    ADV_DIRECT_IND = 0x15
+    ADV_SCAN_IND = 0x12
+    ADV_NONCONN_IND = 0x10
+
+    CONNECTABLE = 0x01
+    SCANNABLE = 0x02
+    DIRECTED = 0x04
+    LEGACY = 0x10
+    ANONYMOUS = 0x20
+
+
+class LeHostTest(base_test.BaseTestClass):  # type: ignore[misc]
+    devices: Optional[PandoraDevices] = None
+
+    # pandora devices.
+    dut: PandoraDevice
+    ref: PandoraDevice
+
+    def setup_class(self) -> None:
+        self.devices = PandoraDevices(self)
+        self.dut, self.ref, *_ = self.devices
+
+        # Enable BR/EDR mode for Bumble devices.
+        for device in self.devices:
+            if isinstance(device, BumblePandoraDevice):
+                device.config.setdefault('classic_enabled', True)
+
+    def teardown_class(self) -> None:
+        if self.devices:
+            self.devices.stop_all()
+
+    @avatar.asynchronous
+    async def setup_test(self) -> None:  # pytype: disable=wrong-arg-types
+        await asyncio.gather(self.dut.reset(), self.ref.reset())
+
+    @avatar.parameterized(
+        *itertools.product(
+            ('connectable', 'non_connectable'),
+            ('scannable', 'non_scannable'),
+            ('directed', 'undirected'),
+            (0, 31),
+        )
+    )  # type: ignore[misc]
+    def test_scan(
+        self,
+        connectable: Union[Literal['connectable'], Literal['non_connectable']],
+        scannable: Union[Literal['scannable'], Literal['non_scannable']],
+        directed: Union[Literal['directed'], Literal['undirected']],
+        data_len: int,
+    ) -> None:
+        '''
+        Advertise from the REF device with the specified legacy advertising
+        event properties. Use the manufacturer specific data to pad the advertising data to the
+        desired length. The scan response data must always be provided when
+        scannable but it is defaulted.
+        '''
+        man_specific_data_length = max(0, data_len - 5)  # Flags (3) + LV (2)
+        man_specific_data = bytes([random.randint(1, 255) for _ in range(man_specific_data_length)])
+        data = DataTypes(manufacturer_specific_data=man_specific_data) if data_len > 0 else None
+
+        is_connectable = True if connectable == 'connectable' else False
+        scan_response_data = DataTypes() if scannable == 'scannable' else None
+        target = self.dut.address if directed == 'directed' else None
+
+        advertise = self.ref.host.Advertise(
+            legacy=True,
+            connectable=is_connectable,
+            data=data,  # type: ignore[arg-type]
+            scan_response_data=scan_response_data,  # type: ignore[arg-type]
+            public=target,
+            own_address_type=PUBLIC,
+        )
+
+        scan = self.dut.host.Scan(legacy=False, passive=False, timeout=5.0)
+        report = next((x for x in scan if x.public == self.ref.address))
+        try:
+            report = next((x for x in scan if x.public == self.ref.address))
+
+            # TODO: scannable is not set by the android server
+            # TODO: direct_address is not set by the android server
+            assert_true(report.legacy, msg='expected legacy advertising report')
+            assert_equal(report.connectable, is_connectable or directed == 'directed')
+            assert_equal(
+                report.data.manufacturer_specific_data, man_specific_data if directed == 'undirected' else b''
+            )
+            assert_false(report.truncated, msg='expected non-truncated advertising report')
+        except grpc.aio.AioRpcError as e:
+            if (
+                e.code() == grpc.StatusCode.DEADLINE_EXCEEDED
+                and scannable == 'non_scannable'
+                and directed == 'undirected'
+            ):
+                explicit_pass('')
+            raise e
+        finally:
+            scan.cancel()
+            advertise.cancel()
+
+    @avatar.parameterized(
+        (dict(incomplete_service_class_uuids16=["183A", "181F"]),),
+        (dict(incomplete_service_class_uuids32=["FFFF183A", "FFFF181F"]),),
+        (dict(incomplete_service_class_uuids128=["FFFF181F-FFFF-1000-8000-00805F9B34FB"]),),
+        (dict(shortened_local_name="avatar"),),
+        (dict(complete_local_name="avatar_the_last_test_blender"),),
+        (dict(tx_power_level=20),),
+        (dict(class_of_device=0x40680),),
+        (dict(peripheral_connection_interval_min=0x0006, peripheral_connection_interval_max=0x0C80),),
+        (dict(service_solicitation_uuids16=["183A", "181F"]),),
+        (dict(service_solicitation_uuids32=["FFFF183A", "FFFF181F"]),),
+        (dict(service_solicitation_uuids128=["FFFF183A-FFFF-1000-8000-00805F9B34FB"]),),
+        (dict(service_data_uuid16={"183A": bytes([1, 2, 3, 4])}),),
+        (dict(service_data_uuid32={"FFFF183A": bytes([1, 2, 3, 4])}),),
+        (dict(service_data_uuid128={"FFFF181F-FFFF-1000-8000-00805F9B34FB": bytes([1, 2, 3, 4])}),),
+        (dict(appearance=0x0591),),
+        (dict(advertising_interval=0x1000),),
+        (dict(uri="https://www.google.com"),),
+        (dict(le_supported_features=bytes([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x10, 0x9F])),),
+        (dict(manufacturer_specific_data=bytes([0, 1, 2, 3, 4])),),
+    )  # type: ignore[misc]
+    def test_scan_response_data(self, data: Dict[str, Any]) -> None:
+        '''
+        Advertise from the REF device with the specified advertising data.
+        Validate that the REF generates the correct advertising data,
+        and that the dut presents the correct advertising data in the scan
+        result.
+        '''
+        advertise = self.ref.host.Advertise(
+            legacy=True,
+            connectable=True,
+            data=DataTypes(**data),
+            own_address_type=PUBLIC,
+        )
+
+        scan = self.dut.host.Scan(legacy=False, passive=False)
+        report = next((x for x in scan if x.public == self.ref.address))
+
+        scan.cancel()
+        advertise.cancel()
+
+        assert_true(report.legacy, msg='expected legacy advertising report')
+        assert_equal(report.connectable, True)
+        for (key, value) in data.items():
+            assert_equal(getattr(report.data, key), value)  # type: ignore[misc]
+        assert_false(report.truncated, msg='expected non-truncated advertising report')
+
+    @avatar.parameterized(
+        (RANDOM,),
+        (PUBLIC,),
+    )  # type: ignore[misc]
+    @avatar.asynchronous
+    async def test_connect(self, ref_address_type: OwnAddressType) -> None:
+        advertise = self.ref.aio.host.Advertise(
+            legacy=True,
+            connectable=True,
+            own_address_type=ref_address_type,
+            data=DataTypes(manufacturer_specific_data=b'pause cafe'),
+        )
+
+        scan = self.dut.aio.host.Scan(own_address_type=RANDOM)
+        ref = await anext((x async for x in scan if x.data.manufacturer_specific_data == b'pause cafe'))
+        scan.cancel()
+
+        ref_dut_res, dut_ref_res = await asyncio.gather(
+            anext(aiter(advertise)),
+            self.dut.aio.host.ConnectLE(**ref.address_asdict(), own_address_type=RANDOM),
+        )
+        assert_equal(dut_ref_res.result_variant(), 'connection')
+        dut_ref, ref_dut = dut_ref_res.connection, ref_dut_res.connection
+        assert_is_not_none(dut_ref)
+        assert dut_ref
+        advertise.cancel()
+        assert_true(await self.is_connected(self.ref, ref_dut), "")
+
+    @avatar.parameterized(
+        (RANDOM,),
+        (PUBLIC,),
+    )  # type: ignore[misc]
+    @avatar.asynchronous
+    async def test_disconnect(self, ref_address_type: OwnAddressType) -> None:
+        advertise = self.ref.aio.host.Advertise(
+            legacy=True,
+            connectable=True,
+            own_address_type=ref_address_type,
+            data=DataTypes(manufacturer_specific_data=b'pause cafe'),
+        )
+
+        scan = self.dut.aio.host.Scan(own_address_type=RANDOM)
+        ref = await anext((x async for x in scan if x.data.manufacturer_specific_data == b'pause cafe'))
+        scan.cancel()
+
+        ref_dut_res, dut_ref_res = await asyncio.gather(
+            anext(aiter(advertise)),
+            self.dut.aio.host.ConnectLE(**ref.address_asdict(), own_address_type=RANDOM),
+        )
+        assert_equal(dut_ref_res.result_variant(), 'connection')
+        dut_ref, ref_dut = dut_ref_res.connection, ref_dut_res.connection
+        assert_is_not_none(dut_ref)
+        assert dut_ref
+        advertise.cancel()
+        assert_true(await self.is_connected(self.ref, ref_dut), "")
+        await self.dut.aio.host.Disconnect(connection=dut_ref)
+        assert_false(await self.is_connected(self.ref, ref_dut), "")
+
+    async def is_connected(self, device: PandoraDevice, connection: Connection) -> bool:
+        try:
+            await device.aio.host.WaitDisconnection(connection=connection, timeout=5)
+            return False
+        except grpc.RpcError as e:
+            assert_equal(e.code(), grpc.StatusCode.DEADLINE_EXCEEDED)  # type: ignore
+            return True
+
+
+if __name__ == '__main__':
+    logging.basicConfig(level=logging.DEBUG)
+    test_runner.main()  # type: ignore
diff --git a/cases/le_security_test.py b/cases/le_security_test.py
new file mode 100644
index 0000000..9305c9f
--- /dev/null
+++ b/cases/le_security_test.py
@@ -0,0 +1,349 @@
+# Copyright 2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import asyncio
+import avatar
+import itertools
+import logging
+
+from avatar import BumblePandoraDevice, PandoraDevice, PandoraDevices
+from bumble.pairing import PairingDelegate
+from mobly import base_test, signals, test_runner
+from mobly.asserts import assert_equal  # type: ignore
+from mobly.asserts import assert_in  # type: ignore
+from mobly.asserts import assert_is_not_none  # type: ignore
+from mobly.asserts import fail  # type: ignore
+from pandora.host_pb2 import PUBLIC, RANDOM, DataTypes, OwnAddressType, Connection
+from pandora.security_pb2 import LE_LEVEL3, PairingEventAnswer, SecureResponse, WaitSecurityResponse
+from typing import Any, Literal, Optional, Tuple, Union
+
+
+class LeSecurityTest(base_test.BaseTestClass):  # type: ignore[misc]
+    '''
+    This class aim to test LE Pairing on LE
+    Bluetooth devices.
+    '''
+
+    devices: Optional[PandoraDevices] = None
+
+    # pandora devices.
+    dut: PandoraDevice
+    ref: PandoraDevice
+
+    def setup_class(self) -> None:
+        self.devices = PandoraDevices(self)
+        self.dut, self.ref, *_ = self.devices
+
+        # Enable BR/EDR for Bumble devices.
+        for device in self.devices:
+            if isinstance(device, BumblePandoraDevice):
+                device.config.setdefault('classic_enabled', True)
+
+    def teardown_class(self) -> None:
+        if self.devices:
+            self.devices.stop_all()
+
+    @avatar.parameterized(
+        *itertools.product(
+            ('outgoing_connection', 'incoming_connection'),
+            ('outgoing_pairing', 'incoming_pairing'),
+            ('against_random', 'against_public'),
+            (
+                'accept',
+                'reject',
+                'rejected',
+                'disconnect',
+                'disconnected',
+            ),
+            (
+                'against_default_io_cap',
+                'against_no_output_no_input',
+                'against_keyboard_only',
+                'against_display_only',
+                'against_display_yes_no',
+                'against_both_display_and_keyboard',
+            ),
+        )
+    )  # type: ignore[misc]
+
+    @avatar.asynchronous
+    async def test_le_pairing(
+        self,
+        connect: Union[Literal['outgoing_connection'], Literal['incoming_connection']],
+        pair: Union[Literal['outgoing_pairing'], Literal['incoming_pairing']],
+        ref_address_type: Union[Literal['against_random'], Literal['against_public']],
+        variant: Union[
+            Literal['accept'],
+            Literal['reject'],
+            Literal['rejected'],
+            Literal['disconnect'],
+            Literal['disconnected'],
+        ],
+        ref_io_capability: Union[
+            Literal['against_default_io_cap'],
+            Literal['against_no_output_no_input'],
+            Literal['against_keyboard_only'],
+            Literal['against_display_only'],
+            Literal['against_display_yes_no'],
+            Literal['against_both_display_and_keyboard'],
+        ],
+    ) -> None:
+
+        if self.dut.name == 'android' and connect == 'outgoing_connection' and pair == 'incoming_pairing':
+            # TODO: do not skip when doing physical tests.
+            raise signals.TestSkip(
+                'TODO: Yet to implement the test cases:\n'
+            )
+
+        if self.dut.name == 'android' and connect == 'incoming_connection' and pair == 'outgoing_pairing':
+            # TODO: do not skip when doing physical tests.
+            raise signals.TestSkip(
+                'TODO: Yet to implement the test cases:\n'
+            )
+
+        if self.dut.name == 'android' and 'disconnect' in variant:
+            raise signals.TestSkip(
+                'TODO: Fix AOSP pandora server for this variant:\n'
+                + '- Looks like `Disconnect`  never complete.\n'
+                + '- When disconnected the `Secure/WaitSecurity` never returns.'
+            )
+
+        if 'reject' in variant or 'rejected' in variant:
+            raise signals.TestSkip(
+                'TODO: Currently these scnearios are not working. Working on them.'
+            )
+
+        if isinstance(self.ref, BumblePandoraDevice) and ref_io_capability == 'against_default_io_cap':
+            raise signals.TestSkip('Skip default IO cap for Bumble REF.')
+
+        if not isinstance(self.ref, BumblePandoraDevice) and ref_io_capability != 'against_default_io_cap':
+            raise signals.TestSkip('Unable to override IO capability on non Bumble device.')
+
+        # Factory reset both DUT and REF devices.
+        await asyncio.gather(self.dut.reset(), self.ref.reset())
+
+        # Override REF IO capability if supported.
+        if isinstance(self.ref, BumblePandoraDevice):
+            io_capability = {
+                'against_no_output_no_input': PairingDelegate.IoCapability.NO_OUTPUT_NO_INPUT,
+                'against_keyboard_only': PairingDelegate.IoCapability.KEYBOARD_INPUT_ONLY,
+                'against_display_only': PairingDelegate.IoCapability.DISPLAY_OUTPUT_ONLY,
+                'against_display_yes_no': PairingDelegate.IoCapability.DISPLAY_OUTPUT_AND_YES_NO_INPUT,
+                'against_both_display_and_keyboard': PairingDelegate.IoCapability.DISPLAY_OUTPUT_AND_KEYBOARD_INPUT,
+            }[ref_io_capability]
+            self.ref.server_config.io_capability = io_capability
+
+        dut_address_type = RANDOM
+        ref_address_type = {
+                'against_random' : RANDOM,
+                'against_public' : PUBLIC,
+            }[ref_address_type]
+
+        # Pandora connection tokens
+        ref_dut, dut_ref = None, None
+
+        # Connection/pairing task.
+        async def connect_and_pair() -> Tuple[SecureResponse, WaitSecurityResponse]:
+            nonlocal ref_dut
+            nonlocal dut_ref
+
+            # Make LE connection task.
+            async def connect_le(
+                initiator: PandoraDevice, acceptor: PandoraDevice,
+                initiator_addr_type: OwnAddressType, acceptor_addr_type: OwnAddressType
+            ) -> Tuple[Connection, Connection]:
+
+                #Acceptor - Advertise
+                advertisement = acceptor.aio.host.Advertise(
+                                legacy=True,
+                                connectable=True,
+                                own_address_type=acceptor_addr_type,
+                                data=DataTypes(manufacturer_specific_data=b'pause cafe'),
+                )
+
+                #Initiator - Scan and fetch the address
+                scan = initiator.aio.host.Scan(own_address_type=initiator_addr_type)
+                acceptor_addr = await anext(
+                    (x async for x in scan if b'pause cafe' in x.data.manufacturer_specific_data)
+                )  # pytype: disable=name-error
+                scan.cancel()
+
+                #Initiator - LE connect
+                init_res, wait_res = await asyncio.gather(
+                    initiator.aio.host.ConnectLE(own_address_type=initiator_addr_type, **acceptor_addr.address_asdict()),
+                    anext(aiter(advertisement)),  # pytype: disable=name-error
+                )
+
+                advertisement.cancel()
+                assert_equal(init_res.result_variant(), 'connection')
+
+                assert init_res.connection is not None and wait_res.connection is not None
+                return init_res.connection, wait_res.connection
+
+            # Make LE connection.
+            if connect == 'incoming_connection':
+                #DUT is acceptor
+                ref_dut, dut_ref = await connect_le(self.ref, self.dut, ref_address_type, dut_address_type)
+            else:
+                #DUT is initiator
+                dut_ref, ref_dut = await connect_le(self.dut, self.ref, dut_address_type, ref_address_type)
+
+            # Pairing.
+
+            if pair == 'incoming_pairing':
+                return await asyncio.gather(
+                    self.ref.aio.security.Secure(connection=ref_dut, le=LE_LEVEL3),
+                    self.dut.aio.security.WaitSecurity(connection=dut_ref, le=LE_LEVEL3),
+                )
+            #Outgoing pairing
+            return await asyncio.gather(
+                self.dut.aio.security.Secure(connection=dut_ref, le=LE_LEVEL3),
+                self.ref.aio.security.WaitSecurity(connection=ref_dut, le=LE_LEVEL3),
+            )
+
+        # Listen for pairing event on bot DUT and REF.
+        dut_pairing, ref_pairing = self.dut.aio.security.OnPairing(), self.ref.aio.security.OnPairing()
+
+        # Start connection/pairing.
+        connect_and_pair_task = asyncio.create_task(connect_and_pair())
+
+        shall_pass = variant == 'accept'
+
+        try:
+            dut_pairing_fut = asyncio.create_task(anext(dut_pairing))
+            ref_pairing_fut = asyncio.create_task(anext(ref_pairing))
+
+            def on_done(_: Any) -> None:
+                if not dut_pairing_fut.done():
+                    dut_pairing_fut.cancel()
+                if not ref_pairing_fut.done():
+                    ref_pairing_fut.cancel()
+
+            connect_and_pair_task.add_done_callback(on_done)
+
+            ref_ev = await asyncio.wait_for(ref_pairing_fut, timeout=5.0)
+            self.ref.log.info(f'REF pairing event: {ref_ev.method_variant()}')
+
+            dut_ev_answer, ref_ev_answer = None, None
+            if not connect_and_pair_task.done():
+                dut_ev = await asyncio.wait_for(dut_pairing_fut, timeout=15.0)
+                self.dut.log.info(f'DUT pairing event: {dut_ev.method_variant()}')
+
+                if dut_ev.method_variant() in ('numeric_comparison', 'just_works'):
+                    assert_in(ref_ev.method_variant(), ('numeric_comparison', 'just_works'))
+
+                    confirm = True
+                    if (
+                        dut_ev.method_variant() == 'numeric_comparison'
+                        and ref_ev.method_variant() == 'numeric_comparison'
+                    ):
+                        confirm = ref_ev.numeric_comparison == dut_ev.numeric_comparison
+
+                    dut_ev_answer = PairingEventAnswer(event=dut_ev, confirm=False if variant == 'reject' else confirm)
+                    ref_ev_answer = PairingEventAnswer(
+                        event=ref_ev, confirm=False if variant == 'rejected' else confirm
+                    )
+
+                elif dut_ev.method_variant() == 'passkey_entry_notification':
+                    assert_equal(ref_ev.method_variant(), 'passkey_entry_request')
+                    assert_is_not_none(dut_ev.passkey_entry_notification)
+                    assert dut_ev.passkey_entry_notification is not None
+
+                    if variant == 'reject':
+                        # DUT cannot reject, pairing shall pass.
+                        shall_pass = True
+
+                    ref_ev_answer = PairingEventAnswer(
+                        event=ref_ev,
+                        passkey=None if variant == 'rejected' else dut_ev.passkey_entry_notification,
+                    )
+
+                elif dut_ev.method_variant() == 'passkey_entry_request':
+                    assert_equal(ref_ev.method_variant(), 'passkey_entry_notification')
+                    assert_is_not_none(ref_ev.passkey_entry_notification)
+
+                    if variant == 'rejected':
+                        # REF cannot reject, pairing shall pass.
+                        shall_pass = True
+
+                    assert ref_ev.passkey_entry_notification is not None
+                    dut_ev_answer = PairingEventAnswer(
+                        event=dut_ev,
+                        passkey=None if variant == 'reject' else ref_ev.passkey_entry_notification,
+                    )
+
+                else:
+                    fail("")
+
+                if variant == 'disconnect':
+                    # Disconnect:
+                    # - REF respond to pairing event if any.
+                    # - DUT trigger disconnect.
+                    if ref_ev_answer is not None:
+                        ref_pairing.send_nowait(ref_ev_answer)
+                    assert dut_ref is not None
+                    await self.dut.aio.host.Disconnect(connection=dut_ref)
+
+                elif variant == 'disconnected':
+                    # Disconnected:
+                    # - DUT respond to pairing event if any.
+                    # - REF trigger disconnect.
+                    if dut_ev_answer is not None:
+                        dut_pairing.send_nowait(dut_ev_answer)
+                    assert ref_dut is not None
+                    await self.ref.aio.host.Disconnect(connection=ref_dut)
+
+                else:
+                    # Otherwise:
+                    # - REF respond to pairing event if any.
+                    # - DUT respond to pairing event if any.
+                    if ref_ev_answer is not None:
+                        ref_pairing.send_nowait(ref_ev_answer)
+                    if dut_ev_answer is not None:
+                        dut_pairing.send_nowait(dut_ev_answer)
+
+        except (asyncio.CancelledError, asyncio.TimeoutError):
+            logging.error('Pairing timed-out or has been canceled.')
+
+        except AssertionError:
+            logging.exception('Pairing failed.')
+            if not connect_and_pair_task.done():
+                connect_and_pair_task.cancel()
+
+        finally:
+            try:
+                (secure, wait_security) = await asyncio.wait_for(connect_and_pair_task, 15.0)
+                logging.info(f'Pairing result: {secure.result_variant()}/{wait_security.result_variant()}')
+
+                if shall_pass:
+                    assert_equal(secure.result_variant(), 'success')
+                    assert_equal(wait_security.result_variant(), 'success')
+                else:
+                    assert_in(
+                        secure.result_variant(),
+                        ('connection_died', 'pairing_failure', 'authentication_failure', 'not_reached'),
+                    )
+                    assert_in(
+                        wait_security.result_variant(),
+                        ('connection_died', 'pairing_failure', 'authentication_failure', 'not_reached'),
+                    )
+
+            finally:
+                dut_pairing.cancel()
+                ref_pairing.cancel()
+
+
+if __name__ == '__main__':
+    logging.basicConfig(level=logging.DEBUG)
+    test_runner.main()  # type: ignore
diff --git a/cases/security_test.py b/cases/security_test.py
new file mode 100644
index 0000000..9f977f2
--- /dev/null
+++ b/cases/security_test.py
@@ -0,0 +1,350 @@
+# Copyright 2023 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import asyncio
+import avatar
+import itertools
+import logging
+
+from avatar import BumblePandoraDevice, PandoraDevice, PandoraDevices
+from bumble.hci import HCI_CENTRAL_ROLE, HCI_PERIPHERAL_ROLE
+from bumble.pairing import PairingDelegate
+from mobly import base_test, signals, test_runner
+from mobly.asserts import assert_equal  # type: ignore
+from mobly.asserts import assert_in  # type: ignore
+from mobly.asserts import assert_is_not_none  # type: ignore
+from mobly.asserts import fail  # type: ignore
+from pandora.host_pb2 import Connection
+from pandora.security_pb2 import LEVEL2, PairingEventAnswer, SecureResponse, WaitSecurityResponse
+from typing import Any, Literal, Optional, Tuple, Union
+
+
+class SecurityTest(base_test.BaseTestClass):  # type: ignore[misc]
+    '''
+    This class aim to test SSP (Secure Simple Pairing) on Classic
+    Bluetooth devices.
+    '''
+
+    devices: Optional[PandoraDevices] = None
+
+    # pandora devices.
+    dut: PandoraDevice
+    ref: PandoraDevice
+
+    @avatar.asynchronous
+    async def setup_class(self) -> None:
+        self.devices = PandoraDevices(self)
+        self.dut, self.ref, *_ = self.devices
+
+        # Enable BR/EDR mode and SSP for Bumble devices.
+        for device in self.devices:
+            if isinstance(device, BumblePandoraDevice):
+                device.config.setdefault('classic_enabled', True)
+                device.config.setdefault('classic_ssp_enabled', True)
+                device.config.setdefault(
+                    'server',
+                    {
+                        'io_capability': 'display_output_and_yes_no_input',
+                    },
+                )
+
+        await asyncio.gather(self.dut.reset(), self.ref.reset())
+
+    def teardown_class(self) -> None:
+        if self.devices:
+            self.devices.stop_all()
+
+    @avatar.parameterized(
+        *itertools.product(
+            ('outgoing_connection', 'incoming_connection'),
+            ('outgoing_pairing', 'incoming_pairing'),
+            (
+                'accept',
+                'reject',
+                'rejected',
+                'disconnect',
+                'disconnected',
+            ),
+            (
+                'against_default_io_cap',
+                'against_no_output_no_input',
+                'against_keyboard_only',
+                'against_display_only',
+                'against_display_yes_no',
+            ),
+            ('against_central', 'against_peripheral'),
+        )
+    )  # type: ignore[misc]
+    @avatar.asynchronous
+    async def test_ssp(
+        self,
+        connect: Union[Literal['outgoing_connection'], Literal['incoming_connection']],
+        pair: Union[Literal['outgoing_pairing'], Literal['incoming_pairing']],
+        variant: Union[
+            Literal['accept'],
+            Literal['reject'],
+            Literal['rejected'],
+            Literal['disconnect'],
+            Literal['disconnected'],
+        ],
+        ref_io_capability: Union[
+            Literal['against_default_io_cap'],
+            Literal['against_no_output_no_input'],
+            Literal['against_keyboard_only'],
+            Literal['against_display_only'],
+            Literal['against_display_yes_no'],
+        ],
+        ref_role: Union[
+            Literal['against_central'],
+            Literal['against_peripheral'],
+        ],
+    ) -> None:
+        if self.dut.name == 'android' and connect == 'outgoing_connection' and pair == 'incoming_pairing':
+            # TODO: do not skip when doing physical tests.
+            raise signals.TestSkip(
+                'TODO: Fix rootcanal when both side trigger authentication:\n'
+                + 'Android always trigger auth for outgoing connections.'
+            )
+
+        if self.dut.name == 'android' and 'disconnect' in variant:
+            raise signals.TestSkip(
+                'TODO: Fix AOSP pandora server for this variant:\n'
+                + '- Looks like `Disconnect`  never complete.\n'
+                + '- When disconnected the `Secure/WaitSecurity` never returns.'
+            )
+
+        if (
+            self.dut.name == 'android'
+            and ref_io_capability == 'against_keyboard_only'
+            and variant == 'rejected'
+            and (connect == 'incoming_connection' or pair == 'outgoing_pairing')
+        ):
+            raise signals.TestSkip(
+                'TODO: Fix AOSP stack for this variant:\n'
+                + 'Android does not seems to react correctly against pairing reject from KEYBOARD_ONLY devices.'
+            )
+
+        if self.dut.name == 'android' and pair == 'outgoing_pairing' and ref_role == 'against_central':
+            raise signals.TestSkip(
+                'TODO: Fix PandoraSecurity server for android:\n'
+                + 'report the encryption state the with the bonding state'
+            )
+
+        if isinstance(self.ref, BumblePandoraDevice) and ref_io_capability == 'against_default_io_cap':
+            raise signals.TestSkip('Skip default IO cap for Bumble REF.')
+
+        if not isinstance(self.ref, BumblePandoraDevice) and ref_io_capability != 'against_default_io_cap':
+            raise signals.TestSkip('Unable to override IO capability on non Bumble device.')
+
+        # Factory reset both DUT and REF devices.
+        await asyncio.gather(self.dut.reset(), self.ref.reset())
+
+        # Override REF IO capability if supported.
+        if isinstance(self.ref, BumblePandoraDevice):
+            io_capability = {
+                'against_no_output_no_input': PairingDelegate.IoCapability.NO_OUTPUT_NO_INPUT,
+                'against_keyboard_only': PairingDelegate.IoCapability.KEYBOARD_INPUT_ONLY,
+                'against_display_only': PairingDelegate.IoCapability.DISPLAY_OUTPUT_ONLY,
+                'against_display_yes_no': PairingDelegate.IoCapability.DISPLAY_OUTPUT_AND_YES_NO_INPUT,
+            }[ref_io_capability]
+            self.ref.server_config.io_capability = io_capability
+
+        # Pandora connection tokens
+        ref_dut, dut_ref = None, None
+
+        # Connection/pairing task.
+        async def connect_and_pair() -> Tuple[SecureResponse, WaitSecurityResponse]:
+            nonlocal ref_dut
+            nonlocal dut_ref
+
+            # Make classic connection task.
+            async def bredr_connect(
+                initiator: PandoraDevice, acceptor: PandoraDevice
+            ) -> Tuple[Connection, Connection]:
+                init_res, wait_res = await asyncio.gather(
+                    initiator.aio.host.Connect(address=acceptor.address),
+                    acceptor.aio.host.WaitConnection(address=initiator.address),
+                )
+                assert_equal(init_res.result_variant(), 'connection')
+                assert_equal(wait_res.result_variant(), 'connection')
+                assert init_res.connection is not None and wait_res.connection is not None
+                return init_res.connection, wait_res.connection
+
+            # Make classic connection.
+            if connect == 'incoming_connection':
+                ref_dut, dut_ref = await bredr_connect(self.ref, self.dut)
+            else:
+                dut_ref, ref_dut = await bredr_connect(self.dut, self.ref)
+
+            # Role switch.
+            if isinstance(self.ref, BumblePandoraDevice):
+                ref_dut_raw = self.ref.device.lookup_connection(int.from_bytes(ref_dut.cookie.value, 'big'))
+                if ref_dut_raw is not None:
+                    role = {
+                        'against_central': HCI_CENTRAL_ROLE,
+                        'against_peripheral': HCI_PERIPHERAL_ROLE,
+                    }[ref_role]
+
+                    if ref_dut_raw.role != role:
+                        self.ref.log.info(
+                            f"Role switch to: {'`CENTRAL`' if role == HCI_CENTRAL_ROLE else '`PERIPHERAL`'}"
+                        )
+                        await ref_dut_raw.switch_role(role)
+
+            # Pairing.
+            if pair == 'incoming_pairing':
+                return await asyncio.gather(
+                    self.ref.aio.security.Secure(connection=ref_dut, classic=LEVEL2),
+                    self.dut.aio.security.WaitSecurity(connection=dut_ref, classic=LEVEL2),
+                )
+
+            return await asyncio.gather(
+                self.dut.aio.security.Secure(connection=dut_ref, classic=LEVEL2),
+                self.ref.aio.security.WaitSecurity(connection=ref_dut, classic=LEVEL2),
+            )
+
+        # Listen for pairing event on bot DUT and REF.
+        dut_pairing, ref_pairing = self.dut.aio.security.OnPairing(), self.ref.aio.security.OnPairing()
+
+        # Start connection/pairing.
+        connect_and_pair_task = asyncio.create_task(connect_and_pair())
+
+        shall_pass = variant == 'accept'
+        try:
+            dut_pairing_fut = asyncio.create_task(anext(dut_pairing))
+            ref_pairing_fut = asyncio.create_task(anext(ref_pairing))
+
+            def on_done(_: Any) -> None:
+                if not dut_pairing_fut.done():
+                    dut_pairing_fut.cancel()
+                if not ref_pairing_fut.done():
+                    ref_pairing_fut.cancel()
+
+            connect_and_pair_task.add_done_callback(on_done)
+
+            ref_ev = await asyncio.wait_for(ref_pairing_fut, timeout=5.0)
+            self.ref.log.info(f'REF pairing event: {ref_ev.method_variant()}')
+
+            dut_ev_answer, ref_ev_answer = None, None
+            if not connect_and_pair_task.done():
+                dut_ev = await asyncio.wait_for(dut_pairing_fut, timeout=15.0)
+                self.dut.log.info(f'DUT pairing event: {dut_ev.method_variant()}')
+
+                if dut_ev.method_variant() in ('numeric_comparison', 'just_works'):
+                    assert_in(ref_ev.method_variant(), ('numeric_comparison', 'just_works'))
+
+                    confirm = True
+                    if (
+                        dut_ev.method_variant() == 'numeric_comparison'
+                        and ref_ev.method_variant() == 'numeric_comparison'
+                    ):
+                        confirm = ref_ev.numeric_comparison == dut_ev.numeric_comparison
+
+                    dut_ev_answer = PairingEventAnswer(event=dut_ev, confirm=False if variant == 'reject' else confirm)
+                    ref_ev_answer = PairingEventAnswer(
+                        event=ref_ev, confirm=False if variant == 'rejected' else confirm
+                    )
+
+                elif dut_ev.method_variant() == 'passkey_entry_notification':
+                    assert_equal(ref_ev.method_variant(), 'passkey_entry_request')
+                    assert_is_not_none(dut_ev.passkey_entry_notification)
+                    assert dut_ev.passkey_entry_notification is not None
+
+                    if variant == 'reject':
+                        # DUT cannot reject, pairing shall pass.
+                        shall_pass = True
+
+                    ref_ev_answer = PairingEventAnswer(
+                        event=ref_ev,
+                        passkey=None if variant == 'rejected' else dut_ev.passkey_entry_notification,
+                    )
+
+                elif dut_ev.method_variant() == 'passkey_entry_request':
+                    assert_equal(ref_ev.method_variant(), 'passkey_entry_notification')
+                    assert_is_not_none(ref_ev.passkey_entry_notification)
+
+                    if variant == 'rejected':
+                        # REF cannot reject, pairing shall pass.
+                        shall_pass = True
+
+                    assert ref_ev.passkey_entry_notification is not None
+                    dut_ev_answer = PairingEventAnswer(
+                        event=dut_ev,
+                        passkey=None if variant == 'reject' else ref_ev.passkey_entry_notification,
+                    )
+
+                else:
+                    fail("")
+
+                if variant == 'disconnect':
+                    # Disconnect:
+                    # - REF respond to pairing event if any.
+                    # - DUT trigger disconnect.
+                    if ref_ev_answer is not None:
+                        ref_pairing.send_nowait(ref_ev_answer)
+                    assert dut_ref is not None
+                    await self.dut.aio.host.Disconnect(connection=dut_ref)
+
+                elif variant == 'disconnected':
+                    # Disconnected:
+                    # - DUT respond to pairing event if any.
+                    # - REF trigger disconnect.
+                    if dut_ev_answer is not None:
+                        dut_pairing.send_nowait(dut_ev_answer)
+                    assert ref_dut is not None
+                    await self.ref.aio.host.Disconnect(connection=ref_dut)
+
+                else:
+                    # Otherwise:
+                    # - REF respond to pairing event if any.
+                    # - DUT respond to pairing event if any.
+                    if ref_ev_answer is not None:
+                        ref_pairing.send_nowait(ref_ev_answer)
+                    if dut_ev_answer is not None:
+                        dut_pairing.send_nowait(dut_ev_answer)
+
+        except (asyncio.CancelledError, asyncio.TimeoutError):
+            logging.error('Pairing timed-out or has been canceled.')
+
+        except AssertionError:
+            logging.exception('Pairing failed.')
+            if not connect_and_pair_task.done():
+                connect_and_pair_task.cancel()
+
+        finally:
+            try:
+                (secure, wait_security) = await asyncio.wait_for(connect_and_pair_task, 15.0)
+                logging.info(f'Pairing result: {secure.result_variant()}/{wait_security.result_variant()}')
+
+                if shall_pass:
+                    assert_equal(secure.result_variant(), 'success')
+                    assert_equal(wait_security.result_variant(), 'success')
+                else:
+                    assert_in(
+                        secure.result_variant(),
+                        ('connection_died', 'pairing_failure', 'authentication_failure', 'not_reached'),
+                    )
+                    assert_in(
+                        wait_security.result_variant(),
+                        ('connection_died', 'pairing_failure', 'authentication_failure', 'not_reached'),
+                    )
+
+            finally:
+                dut_pairing.cancel()
+                ref_pairing.cancel()
+
+
+if __name__ == '__main__':
+    logging.basicConfig(level=logging.DEBUG)
+    test_runner.main()  # type: ignore
diff --git a/examples/example.py b/examples/example.py
deleted file mode 100644
index b3557fc..0000000
--- a/examples/example.py
+++ /dev/null
@@ -1,324 +0,0 @@
-# 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 avatar
-import grpc
-import logging
-
-from avatar import BumblePandoraDevice, PandoraDevice, PandoraDevices
-from bumble.smp import PairingDelegate
-from concurrent import futures
-from contextlib import suppress
-from mobly import base_test, signals, test_runner
-from mobly.asserts import assert_equal  # type: ignore
-from mobly.asserts import assert_in  # type: ignore
-from mobly.asserts import assert_is_none  # type: ignore
-from mobly.asserts import assert_is_not_none  # type: ignore
-from mobly.asserts import explicit_pass, fail  # type: ignore
-from pandora.host_pb2 import (
-    DISCOVERABLE_GENERAL,
-    DISCOVERABLE_LIMITED,
-    NOT_DISCOVERABLE,
-    PUBLIC,
-    RANDOM,
-    DataTypes,
-    DiscoverabilityMode,
-    OwnAddressType,
-)
-from pandora.security_pb2 import LE_LEVEL3, LEVEL2, PairingEventAnswer
-from typing import NoReturn, Optional
-
-
-class ExampleTest(base_test.BaseTestClass):  # type: ignore[misc]
-    devices: Optional[PandoraDevices] = None
-
-    # pandora devices.
-    dut: PandoraDevice
-    ref: PandoraDevice
-
-    def setup_class(self) -> None:
-        self.devices = PandoraDevices(self)
-        self.dut, self.ref, *_ = self.devices
-
-        # Enable BR/EDR mode for Bumble devices.
-        for device in self.devices:
-            if isinstance(device, BumblePandoraDevice):
-                device.config.setdefault('classic_enabled', True)
-
-    def teardown_class(self) -> None:
-        if self.devices:
-            self.devices.stop_all()
-
-    @avatar.asynchronous
-    async def setup_test(self) -> None:  # pytype: disable=wrong-arg-types
-        await asyncio.gather(self.dut.reset(), self.ref.reset())
-
-    def test_print_addresses(self) -> None:
-        dut_address = self.dut.address
-        self.dut.log.info(f'Address: {dut_address}')
-        ref_address = self.ref.address
-        self.ref.log.info(f'Address: {ref_address}')
-
-    def test_classic_connect(self) -> None:
-        dut_address = self.dut.address
-        self.dut.log.info(f'Address: {dut_address}')
-        connection = self.ref.host.Connect(address=dut_address).connection
-        assert connection
-        self.ref.log.info(f'Connected with: {dut_address}')
-        self.ref.host.Disconnect(connection=connection)
-
-    # Using this decorator allow us to write one `test_le_connect`, and
-    # run it multiple time with different parameters.
-    # Here we check that no matter the address type we use for both sides
-    # the connection still complete.
-    @avatar.parameterized(
-        (RANDOM, RANDOM),
-        (RANDOM, PUBLIC),
-    )  # type: ignore[misc]
-    def test_le_connect(self, dut_address_type: OwnAddressType, ref_address_type: OwnAddressType) -> None:
-        if not isinstance(self.ref, BumblePandoraDevice):
-            raise signals.TestSkip('Test require Bumble as reference device')
-
-        advertisement = self.ref.host.Advertise(legacy=True, connectable=True, own_address_type=ref_address_type)
-        scan = self.dut.host.Scan(own_address_type=dut_address_type)
-        if ref_address_type == PUBLIC:
-            scan_response = next((x for x in scan if x.public == self.ref.address))
-            dut_ref = self.dut.host.ConnectLE(
-                public=scan_response.public,
-                own_address_type=dut_address_type,
-            ).connection
-        else:
-            scan_response = next((x for x in scan if x.random == self.ref.random_address))
-            dut_ref = self.dut.host.ConnectLE(
-                random=scan_response.random,
-                own_address_type=dut_address_type,
-            ).connection
-        scan.cancel()
-        ref_dut = next(advertisement).connection
-        advertisement.cancel()
-        assert dut_ref and ref_dut
-        self.dut.host.Disconnect(connection=dut_ref)
-
-    @avatar.rpc_except(
-        {
-            # This test should reach the `Inquiry` timeout.
-            grpc.StatusCode.DEADLINE_EXCEEDED: lambda e: explicit_pass(e.details()),
-        }
-    )
-    def test_not_discoverable(self) -> None:
-        self.dut.host.SetDiscoverabilityMode(mode=NOT_DISCOVERABLE)
-        inquiry = self.ref.host.Inquiry(timeout=3.0)
-        try:
-            assert_is_none(next((x for x in inquiry if x.address == self.dut.address), None))
-        finally:
-            inquiry.cancel()
-
-    @avatar.parameterized(
-        (DISCOVERABLE_LIMITED,),
-        (DISCOVERABLE_GENERAL,),
-    )  # type: ignore[misc]
-    def test_discoverable(self, mode: DiscoverabilityMode) -> None:
-        self.dut.host.SetDiscoverabilityMode(mode=mode)
-        inquiry = self.ref.host.Inquiry(timeout=15.0)
-        try:
-            assert_is_not_none(next((x for x in inquiry if x.address == self.dut.address), None))
-        finally:
-            inquiry.cancel()
-
-    @avatar.asynchronous
-    async def test_wait_connection(self) -> None:  # pytype: disable=wrong-arg-types
-        dut_ref_co = self.dut.aio.host.WaitConnection(address=self.ref.address)
-        ref_dut = await self.ref.aio.host.Connect(address=self.dut.address)
-        dut_ref = await dut_ref_co
-        assert_is_not_none(ref_dut.connection)
-        assert_is_not_none(dut_ref.connection)
-        assert ref_dut.connection
-        await self.ref.aio.host.Disconnect(connection=ref_dut.connection)
-
-    def test_scan_response_data(self) -> None:
-        advertisement = self.dut.host.Advertise(
-            legacy=True,
-            data=DataTypes(
-                complete_service_class_uuids16=['FDF0'],
-            ),
-            scan_response_data=DataTypes(
-                include_class_of_device=True,
-            ),
-        )
-
-        scan = self.ref.host.Scan()
-        scan_response = next((x for x in scan if x.public == self.dut.address))
-
-        scan.cancel()
-        advertisement.cancel()
-
-        assert_equal(type(scan_response.data.class_of_device), int)
-        assert_equal(type(scan_response.data.complete_service_class_uuids16[0]), str)
-
-    async def handle_pairing_events(self) -> NoReturn:
-        ref_pairing_stream = self.ref.aio.security.OnPairing()
-        dut_pairing_stream = self.dut.aio.security.OnPairing()
-
-        try:
-            while True:
-                ref_pairing_event, dut_pairing_event = await asyncio.gather(
-                    anext(ref_pairing_stream),  # pytype: disable=name-error
-                    anext(dut_pairing_stream),  # pytype: disable=name-error
-                )
-
-                if dut_pairing_event.method_variant() in ('numeric_comparison', 'just_works'):
-                    assert_in(ref_pairing_event.method_variant(), ('numeric_comparison', 'just_works'))
-                    dut_pairing_stream.send_nowait(
-                        PairingEventAnswer(
-                            event=dut_pairing_event,
-                            confirm=True,
-                        )
-                    )
-                    ref_pairing_stream.send_nowait(
-                        PairingEventAnswer(
-                            event=ref_pairing_event,
-                            confirm=True,
-                        )
-                    )
-                elif dut_pairing_event.method_variant() == 'passkey_entry_notification':
-                    assert_equal(ref_pairing_event.method_variant(), 'passkey_entry_request')
-                    ref_pairing_stream.send_nowait(
-                        PairingEventAnswer(
-                            event=ref_pairing_event,
-                            passkey=dut_pairing_event.passkey_entry_notification,
-                        )
-                    )
-                elif dut_pairing_event.method_variant() == 'passkey_entry_request':
-                    assert_equal(ref_pairing_event.method_variant(), 'passkey_entry_notification')
-                    dut_pairing_stream.send_nowait(
-                        PairingEventAnswer(
-                            event=dut_pairing_event,
-                            passkey=ref_pairing_event.passkey_entry_notification,
-                        )
-                    )
-                else:
-                    fail("unreachable")
-
-        finally:
-            ref_pairing_stream.cancel()
-            dut_pairing_stream.cancel()
-
-    @avatar.parameterized(
-        (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,),
-    )  # type: ignore[misc]
-    @avatar.asynchronous
-    async def test_classic_pairing(self, ref_io_capability: int) -> None:  # pytype: disable=wrong-arg-types
-        if not isinstance(self.ref, BumblePandoraDevice):
-            raise signals.TestSkip('Test require Bumble as reference device(s)')
-
-        # override reference device IO capability
-        setattr(self.ref.device, 'io_capability', ref_io_capability)
-
-        pairing = asyncio.create_task(self.handle_pairing_events())
-        (dut_ref_res, ref_dut_res) = await asyncio.gather(
-            self.dut.aio.host.WaitConnection(address=self.ref.address),
-            self.ref.aio.host.Connect(address=self.dut.address),
-        )
-
-        assert_equal(ref_dut_res.result_variant(), 'connection')
-        assert_equal(dut_ref_res.result_variant(), 'connection')
-        ref_dut = ref_dut_res.connection
-        dut_ref = dut_ref_res.connection
-        assert ref_dut and dut_ref
-
-        (secure, wait_security) = await asyncio.gather(
-            self.ref.aio.security.Secure(connection=ref_dut, classic=LEVEL2),
-            self.dut.aio.security.WaitSecurity(connection=dut_ref, classic=LEVEL2),
-        )
-
-        pairing.cancel()
-        with suppress(asyncio.CancelledError, futures.CancelledError):
-            await pairing
-
-        assert_equal(secure.result_variant(), 'success')
-        assert_equal(wait_security.result_variant(), 'success')
-
-        await asyncio.gather(
-            self.dut.aio.host.Disconnect(connection=dut_ref),
-            self.ref.aio.host.WaitDisconnection(connection=ref_dut),
-        )
-
-    @avatar.parameterized(
-        (RANDOM, RANDOM, PairingDelegate.NO_OUTPUT_NO_INPUT),
-        (RANDOM, RANDOM, PairingDelegate.KEYBOARD_INPUT_ONLY),
-        (RANDOM, RANDOM, PairingDelegate.DISPLAY_OUTPUT_ONLY),
-        (RANDOM, RANDOM, PairingDelegate.DISPLAY_OUTPUT_AND_YES_NO_INPUT),
-        (RANDOM, RANDOM, PairingDelegate.DISPLAY_OUTPUT_AND_KEYBOARD_INPUT),
-        (RANDOM, PUBLIC, PairingDelegate.DISPLAY_OUTPUT_AND_KEYBOARD_INPUT),
-    )  # type: ignore[misc]
-    @avatar.asynchronous
-    async def test_le_pairing(  # pytype: disable=wrong-arg-types
-        self, dut_address_type: OwnAddressType, ref_address_type: OwnAddressType, ref_io_capability: int
-    ) -> None:
-        if not isinstance(self.ref, BumblePandoraDevice):
-            raise signals.TestSkip('Test require Bumble as reference device(s)')
-
-        # override reference device IO capability
-        setattr(self.ref.device, 'io_capability', ref_io_capability)
-
-        advertisement = self.dut.aio.host.Advertise(
-            legacy=True,
-            connectable=True,
-            own_address_type=dut_address_type,
-            data=DataTypes(manufacturer_specific_data=b'pause cafe'),
-        )
-
-        scan = self.ref.aio.host.Scan(own_address_type=ref_address_type)
-        dut = await anext(
-            (x async for x in scan if b'pause cafe' in x.data.manufacturer_specific_data)
-        )  # pytype: disable=name-error
-        scan.cancel()
-        assert dut
-
-        pairing = asyncio.create_task(self.handle_pairing_events())
-        (ref_dut_res, dut_ref_res) = await asyncio.gather(
-            self.ref.aio.host.ConnectLE(own_address_type=ref_address_type, **dut.address_asdict()),
-            anext(aiter(advertisement)),  # pytype: disable=name-error
-        )
-
-        advertisement.cancel()
-        ref_dut, dut_ref = ref_dut_res.connection, dut_ref_res.connection
-        assert ref_dut and dut_ref
-
-        (secure, wait_security) = await asyncio.gather(
-            self.ref.aio.security.Secure(connection=ref_dut, le=LE_LEVEL3),
-            self.dut.aio.security.WaitSecurity(connection=dut_ref, le=LE_LEVEL3),
-        )
-
-        pairing.cancel()
-        with suppress(asyncio.CancelledError, futures.CancelledError):
-            await pairing
-
-        assert_equal(secure.result_variant(), 'success')
-        assert_equal(wait_security.result_variant(), 'success')
-
-        await asyncio.gather(
-            self.dut.aio.host.Disconnect(connection=dut_ref),
-            self.ref.aio.host.WaitDisconnection(connection=ref_dut),
-        )
-
-
-if __name__ == '__main__':
-    logging.basicConfig(level=logging.DEBUG)
-    test_runner.main()  # type: ignore
diff --git a/examples/simulated_bumble_android.yml b/examples/simulated_bumble_android.yml
deleted file mode 100644
index dca9b2c..0000000
--- a/examples/simulated_bumble_android.yml
+++ /dev/null
@@ -1,8 +0,0 @@
----
-
-TestBeds:
-- Name: ExampleTest
-  Controllers:
-    AndroidDevice: '*'
-    BumbleDevice:
-    - transport: 'tcp-client:127.0.0.1:7300'
diff --git a/examples/simulated_bumble_bumble.yml b/examples/simulated_bumble_bumble.yml
deleted file mode 100644
index a353e0a..0000000
--- a/examples/simulated_bumble_bumble.yml
+++ /dev/null
@@ -1,24 +0,0 @@
----
-
-# BumbleDevice configuration:
-#   classic_enabled: [true, false] # (false by default)
-#   class_of_device: 1234 # See assigned numbers
-#   keystore: JsonKeyStore # or empty
-#   io_capability:
-#     no_output_no_input # (default)
-#     keyboard_input_only
-#     display_output_only
-#     display_output_and_yes_no_input
-#     display_output_and_keyboard_input
-
-TestBeds:
-- Name: ExampleTest
-  Controllers:
-    BumbleDevice:
-    # DUT device
-    - transport: 'tcp-client:127.0.0.1:6402'
-      class_of_device: 2360324
-      io_capability: display_output_only
-    # Reference device
-    - transport: 'tcp-client:127.0.0.1:6402'
-      class_of_device: 2360324
diff --git a/pyproject.toml b/pyproject.toml
index 78acd47..789f629 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,19 +1,27 @@
 [project]
-name = "avatar"
+name = "pandora-avatar"
 authors = [{name = "Pandora", email = "pandora-core@google.com"}]
 readme = "README.md"
 dynamic = ["version", "description"]
+requires-python = ">=3.8"
+classifiers = [
+    "Programming Language :: Python :: 3.10",
+    "License :: OSI Approved :: Apache Software License"
+]
 dependencies = [
     "bt-test-interfaces",
-    "bumble",
-    "grpcio==1.51.1",
+    "bumble==0.0.154",
+    "grpcio>=1.51.1",
     "mobly>=1.12",
-    "bitstruct>=8.12",
+    "portpicker>=1.5.2",
 ]
 
+[project.urls]
+Source = "https://github.com/google/avatar"
+
 [project.optional-dependencies]
 dev = [
-    "grpcio-tools==1.51.1",
+    "grpcio-tools>=1.51.1",
     "black==22.10.0",
     "pyright==1.1.298",
     "mypy==1.0",
@@ -28,6 +36,9 @@
 target-version = ["py38", "py39", "py310", "py311"]
 skip-string-normalization = true
 
+[tool.flit.module]
+name = "avatar"
+
 [tool.isort]
 profile = "black"
 line_length = 119
@@ -38,9 +49,7 @@
 [tool.mypy]
 strict = true
 warn_unused_ignores = false
-files = ["avatar", "examples"]
-mypy_path = '$MYPY_CONFIG_FILE_DIR/bt-test-interfaces/python:$MYPY_CONFIG_FILE_DIR/third-party/bumble'
-exclude = 'third-party/bumble'
+files = ["avatar", "cases"]
 
 [[tool.mypy.overrides]]
 module = "grpc.*"
@@ -50,22 +59,22 @@
 module = "mobly.*"
 ignore_missing_imports = true
 
+[[tool.mypy.overrides]]
+module = "portpicker.*"
+ignore_missing_imports = true
+
 [tool.pyright]
-include = ["avatar", "examples"]
+include = ["avatar", "cases"]
 exclude = ["**/__pycache__"]
 typeCheckingMode = "strict"
 useLibraryCodeForTypes = true
 verboseOutput = false
-extraPaths = [
-    'bt-test-interfaces/python',
-    'third-party/bumble'
-]
 reportMissingTypeStubs = false
 reportUnknownLambdaType = false
 reportImportCycles = false
 
 [tool.pytype]
-inputs = ['avatar', 'examples']
+inputs = ['avatar', 'cases']
 
 [build-system]
 requires = ["flit_core==3.7.1"]
diff --git a/third-party/bumble b/third-party/bumble
deleted file mode 120000
index e5285f6..0000000
--- a/third-party/bumble
+++ /dev/null
@@ -1 +0,0 @@
-../../../python/bumble/
\ No newline at end of file