Snap for 11049560 from 2560849bf4526b22306247738d7e409baa23882d to mainline-art-release

Change-Id: Iac85cba9808578b31ba7ac46fa5bb3e09e88964e
diff --git a/.github/workflows/avatar.yml b/.github/workflows/avatar.yml
new file mode 100644
index 0000000..21945ef
--- /dev/null
+++ b/.github/workflows/avatar.yml
@@ -0,0 +1,91 @@
+name: Avatar
+
+on:
+  push:
+    branches: [ main ]
+  pull_request:
+    branches: [ main ]
+
+jobs:
+  build:
+    name: Build with Python ${{ matrix.python-version }}
+    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
+        run: |
+          pip install --upgrade pip
+          pip install build
+          pip install .
+      - name: Build
+        run: python -m build
+  lint:
+    name: Lint for Python ${{ matrix.python-version }}
+    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
+        run: pip install .[dev]
+      - run: mypy
+      - run: pyright
+  format:
+    name: Check Python formatting
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v3
+      - name: Set Up Python 3.11
+        uses: actions/setup-python@v4
+        with:
+          python-version: 3.11
+      - name: Install
+        run: |
+          pip install --upgrade pip
+          pip install .[dev]
+      - run: black --check avatar/
+      - run: isort --check avatar
+  test:
+    name: Test Bumble vs Bumble(s) [${{ matrix.shard }}]
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        shard: [
+           1/24,  2/24,  3/24,  4/24,
+           5/24,  6/24,  7/24,  8/24,
+           9/24, 10/24, 11/24, 12/24,
+          13/24, 14/24, 15/24, 16/24,
+          17/24, 18/24, 19/24, 20/24,
+          21/24, 22/24, 23/24, 24/24,
+        ]
+    steps:
+      - uses: actions/checkout@v3
+      - name: Set Up Python 3.11
+        uses: actions/setup-python@v4
+        with:
+          python-version: 3.11
+      - name: Install
+        run: |
+          pip install --upgrade pip
+          pip install rootcanal==1.3.0
+          pip install .
+      - name: Rootcanal
+        run: nohup python -m rootcanal > rootcanal.log &
+      - name: Test
+        run: |
+          avatar --list | grep -Ev '^=' > test-names.txt
+          timeout 5m avatar --test-beds bumble.bumbles --tests $(split test-names.txt -n l/${{ matrix.shard }})
+      - name: Rootcanal Logs
+        run: cat rootcanal.log
diff --git a/.github/workflows/python-publish.yml b/.github/workflows/pypi-publish.yml
similarity index 97%
rename from .github/workflows/python-publish.yml
rename to .github/workflows/pypi-publish.yml
index 7d9bc19..36192a9 100644
--- a/.github/workflows/python-publish.yml
+++ b/.github/workflows/pypi-publish.yml
@@ -1,4 +1,4 @@
-name: Upload Python Package
+name: PyPI Publish
 
 on:
   release:
diff --git a/.github/workflows/python-build.yml b/.github/workflows/python-build.yml
deleted file mode 100644
index 18193b1..0000000
--- a/.github/workflows/python-build.yml
+++ /dev/null
@@ -1,27 +0,0 @@
-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
deleted file mode 100644
index 8ddff35..0000000
--- a/.github/workflows/python-lint-and-format.yml
+++ /dev/null
@@ -1,34 +0,0 @@
-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/Android.bp b/Android.bp
index a984e60..28f7c2e 100644
--- a/Android.bp
+++ b/Android.bp
@@ -19,6 +19,7 @@
     name: "libavatar",
     srcs: [
         "avatar/*.py",
+        "avatar/cases/*.py",
         "avatar/controllers/*.py",
         "avatar/metrics/*.py",
     ],
@@ -33,8 +34,3 @@
         "avatar/py.typed"
     ]
 }
-
-filegroup {
-    name: "avatar-cases",
-    srcs: ["cases/*.py"],
-}
diff --git a/avatar/__init__.py b/avatar/__init__.py
index e5a1cf2..abc4653 100644
--- a/avatar/__init__.py
+++ b/avatar/__init__.py
@@ -17,21 +17,26 @@
 any Bluetooth test cases virtually and physically.
 """
 
-__version__ = "0.0.2"
+__version__ = "0.0.4"
 
+import argparse
 import enum
 import functools
 import grpc
 import grpc.aio
 import importlib
 import logging
+import pathlib
 
 from avatar import pandora_server
 from avatar.aio import asynchronous
 from avatar.metrics import trace
-from avatar.pandora_client import BumblePandoraClient as BumblePandoraDevice, PandoraClient as PandoraDevice
+from avatar.pandora_client import BumblePandoraClient as BumblePandoraDevice
+from avatar.pandora_client import PandoraClient as PandoraDevice
 from avatar.pandora_server import PandoraServer
+from avatar.runner import SuiteRunner
 from mobly import base_test
+from mobly import signals
 from typing import Any, Callable, Dict, Iterable, Iterator, List, Optional, Sized, Tuple, Type, TypeVar
 
 # public symbols
@@ -105,7 +110,13 @@
 
             # Register the controller and load its Pandora servers.
             logging.info('Starting %s(s) for %s', server_cls.__name__, controller)
-            devices: Optional[List[Any]] = test.register_controller(server_cls.MOBLY_CONTROLLER_MODULE)  # type: ignore
+            try:
+                devices: Optional[List[Any]] = test.register_controller(  # type: ignore
+                    server_cls.MOBLY_CONTROLLER_MODULE
+                )
+            except Exception:
+                logging.exception('abort: failed to register controller')
+                raise signals.TestAbortAll("")
             assert devices
             for device in devices:  # type: ignore
                 self._servers.append(server_cls(device))
@@ -215,3 +226,89 @@
         return wrapper
 
     return wrap
+
+
+def args_parser() -> argparse.ArgumentParser:
+    parser = argparse.ArgumentParser(description='Avatar test runner.')
+    parser.add_argument(
+        'input',
+        type=str,
+        nargs='*',
+        metavar='<PATH>',
+        help='Lits of folder or test file to run',
+        default=[],
+    )
+    parser.add_argument('-c', '--config', type=str, metavar='<PATH>', help='Path to the test configuration file.')
+    parser.add_argument(
+        '-l',
+        '--list',
+        '--list_tests',  # For backward compatibility with tradefed `MoblyBinaryHostTest`
+        action='store_true',
+        help='Print the names of the tests defined in a script without ' 'executing them.',
+    )
+    parser.add_argument(
+        '-o',
+        '--log-path',
+        '--log_path',  # For backward compatibility with tradefed `MoblyBinaryHostTest`
+        type=str,
+        metavar='<PATH>',
+        help='Path to the test configuration file.',
+    )
+    parser.add_argument(
+        '-t',
+        '--tests',
+        nargs='+',
+        type=str,
+        metavar='[ClassA[.test_a] ClassB[.test_b] ...]',
+        help='A list of test classes and optional tests to execute.',
+    )
+    parser.add_argument(
+        '-b',
+        '--test-beds',
+        '--test_bed',  # For backward compatibility with tradefed `MoblyBinaryHostTest`
+        nargs='+',
+        type=str,
+        metavar='[<TEST BED NAME1> <TEST BED NAME2> ...]',
+        help='Specify which test beds to run tests on.',
+    )
+    parser.add_argument('-v', '--verbose', action='store_true', help='Set console logger level to DEBUG')
+    parser.add_argument('-x', '--no-default-cases', action='store_true', help='Dot no include default test cases')
+    return parser
+
+
+# Avatar default entry point
+def main(args: Optional[argparse.Namespace] = None) -> None:
+    import sys
+
+    # Create an Avatar suite runner.
+    runner = SuiteRunner()
+
+    # Parse arguments.
+    argv = args or args_parser().parse_args()
+    if argv.input:
+        for path in argv.input:
+            runner.add_path(pathlib.Path(path))
+    if argv.config:
+        runner.add_config_file(pathlib.Path(argv.config))
+    if argv.log_path:
+        runner.set_logs_dir(pathlib.Path(argv.log_path))
+    if argv.tests:
+        runner.add_test_filters(argv.tests)
+    if argv.test_beds:
+        runner.add_test_beds(argv.test_beds)
+    if argv.verbose:
+        runner.set_logs_verbose()
+    if not argv.no_default_cases:
+        runner.add_path(pathlib.Path(__file__).resolve().parent / 'cases')
+
+    # List tests to standard output.
+    if argv.list:
+        for _, (tag, test_names) in runner.included_tests.items():
+            for name in test_names:
+                print(f"{tag}.{name}")
+        sys.exit(0)
+
+    # Run the test suite.
+    logging.basicConfig(level=logging.INFO)
+    if not runner.run():
+        sys.exit(1)
diff --git a/cases/__init__.py b/avatar/cases/__init__.py
similarity index 100%
rename from cases/__init__.py
rename to avatar/cases/__init__.py
diff --git a/cases/config.yml b/avatar/cases/config.yml
similarity index 100%
rename from cases/config.yml
rename to avatar/cases/config.yml
diff --git a/cases/host_test.py b/avatar/cases/host_test.py
similarity index 92%
rename from cases/host_test.py
rename to avatar/cases/host_test.py
index 59e5100..feaeaa5 100644
--- a/cases/host_test.py
+++ b/avatar/cases/host_test.py
@@ -17,21 +17,23 @@
 import grpc
 import logging
 
-from avatar import BumblePandoraDevice, PandoraDevice, PandoraDevices
-from mobly import base_test, signals, test_runner
+from avatar import BumblePandoraDevice
+from avatar import PandoraDevice
+from avatar import PandoraDevices
+from mobly import base_test
+from mobly import signals
+from mobly import 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 pandora.host_pb2 import DISCOVERABLE_GENERAL
+from pandora.host_pb2 import DISCOVERABLE_LIMITED
+from pandora.host_pb2 import NOT_DISCOVERABLE
+from pandora.host_pb2 import Connection
+from pandora.host_pb2 import DiscoverabilityMode
 from typing import Optional
 
 
diff --git a/cases/le_host_test.py b/avatar/cases/le_host_test.py
similarity index 95%
rename from cases/le_host_test.py
rename to avatar/cases/le_host_test.py
index eb2f017..b6ab4aa 100644
--- a/cases/le_host_test.py
+++ b/avatar/cases/le_host_test.py
@@ -20,14 +20,21 @@
 import logging
 import random
 
-from avatar import BumblePandoraDevice, PandoraDevice, PandoraDevices
-from mobly import base_test, test_runner
+from avatar import BumblePandoraDevice
+from avatar import PandoraDevice
+from avatar import PandoraDevices
+from mobly import base_test
+from mobly import 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 pandora.host_pb2 import PUBLIC
+from pandora.host_pb2 import RANDOM
+from pandora.host_pb2 import Connection
+from pandora.host_pb2 import DataTypes
+from pandora.host_pb2 import OwnAddressType
 from typing import Any, Dict, Literal, Optional, Union
 
 
@@ -174,7 +181,7 @@
 
         assert_true(report.legacy, msg='expected legacy advertising report')
         assert_equal(report.connectable, True)
-        for (key, value) in data.items():
+        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')
 
diff --git a/cases/le_security_test.py b/avatar/cases/le_security_test.py
similarity index 78%
rename from cases/le_security_test.py
rename to avatar/cases/le_security_test.py
index 9c836b2..b91d8c7 100644
--- a/cases/le_security_test.py
+++ b/avatar/cases/le_security_test.py
@@ -17,15 +17,29 @@
 import itertools
 import logging
 
-from avatar import BumblePandoraDevice, PandoraDevice, PandoraDevices
+from avatar import BumblePandoraDevice
+from avatar import PandoraDevice
+from avatar import PandoraDevices
+from avatar import pandora
+from bumble.pairing import PairingConfig
 from bumble.pairing import PairingDelegate
-from mobly import base_test, signals, test_runner
+from mobly import base_test
+from mobly import signals
+from mobly import 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, Connection, DataTypes, OwnAddressType
-from pandora.security_pb2 import LE_LEVEL3, PairingEventAnswer, SecureResponse, WaitSecurityResponse
+from pandora.host_pb2 import PUBLIC
+from pandora.host_pb2 import RANDOM
+from pandora.host_pb2 import Connection
+from pandora.host_pb2 import DataTypes
+from pandora.host_pb2 import OwnAddressType
+from pandora.security_pb2 import LE_LEVEL3
+from pandora.security_pb2 import LEVEL2
+from pandora.security_pb2 import PairingEventAnswer
+from pandora.security_pb2 import SecureResponse
+from pandora.security_pb2 import WaitSecurityResponse
 from typing import Any, Literal, Optional, Tuple, Union
 
 
@@ -74,6 +88,10 @@
                 'against_display_yes_no',
                 'against_both_display_and_keyboard',
             ),
+            (
+                'ltk_irk_csrk',
+                'ltk_irk_csrk_lk',
+            ),
         )
     )  # type: ignore[misc]
     @avatar.asynchronous
@@ -84,6 +102,7 @@
         ref_address_type_name: Union[Literal['against_random'], Literal['against_public']],
         variant: Union[
             Literal['accept'],
+            Literal['accept_ctkd'],
             Literal['reject'],
             Literal['rejected'],
             Literal['disconnect'],
@@ -97,8 +116,11 @@
             Literal['against_display_yes_no'],
             Literal['against_both_display_and_keyboard'],
         ],
+        key_distribution: Union[
+            Literal['ltk_irk_csrk'],
+            Literal['ltk_irk_csrk_lk'],
+        ],
     ) -> 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')
@@ -114,18 +136,21 @@
                 + '- When disconnected the `Secure/WaitSecurity` never returns.'
             )
 
+        if self.dut.name == 'android' and 'reject' in variant:
+            raise signals.TestSkip('TODO: Currently these scnearios are not working. Working on them.')
+
         if self.ref.name == 'android' and ref_address_type_name == 'against_public':
             raise signals.TestSkip('Android does not support PUBLIC address type.')
 
-        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.')
 
+        if 'lk' in key_distribution and ref_io_capability == 'against_no_output_no_input':
+            raise signals.TestSkip('CTKD requires Security Level 4')
+
         # Factory reset both DUT and REF devices.
         await asyncio.gather(self.dut.reset(), self.ref.reset())
 
@@ -139,6 +164,30 @@
                 'against_both_display_and_keyboard': PairingDelegate.IoCapability.DISPLAY_OUTPUT_AND_KEYBOARD_INPUT,
             }[ref_io_capability]
             self.ref.server_config.io_capability = io_capability
+            bumble_key_distribution = sum(
+                {
+                    'ltk': PairingDelegate.KeyDistribution.DISTRIBUTE_ENCRYPTION_KEY,
+                    'irk': PairingDelegate.KeyDistribution.DISTRIBUTE_IDENTITY_KEY,
+                    'csrk': PairingDelegate.KeyDistribution.DISTRIBUTE_SIGNING_KEY,
+                    'lk': PairingDelegate.KeyDistribution.DISTRIBUTE_LINK_KEY,
+                }[x]
+                for x in key_distribution.split('_')
+            )
+            assert bumble_key_distribution
+            self.ref.server_config.smp_local_initiator_key_distribution = bumble_key_distribution
+            self.ref.server_config.smp_local_responder_key_distribution = bumble_key_distribution
+            self.ref.server_config.identity_address_type = PairingConfig.AddressType.PUBLIC
+
+        if isinstance(self.dut, BumblePandoraDevice):
+            ALL_KEYS = (
+                PairingDelegate.KeyDistribution.DISTRIBUTE_ENCRYPTION_KEY
+                | PairingDelegate.KeyDistribution.DISTRIBUTE_IDENTITY_KEY
+                | PairingDelegate.KeyDistribution.DISTRIBUTE_SIGNING_KEY
+                | PairingDelegate.KeyDistribution.DISTRIBUTE_LINK_KEY
+            )
+            self.dut.server_config.smp_local_initiator_key_distribution = ALL_KEYS
+            self.dut.server_config.smp_local_responder_key_distribution = ALL_KEYS
+            self.dut.server_config.identity_address_type = PairingConfig.AddressType.PUBLIC
 
         dut_address_type = RANDOM
         ref_address_type = {
@@ -161,7 +210,6 @@
                 initiator_addr_type: OwnAddressType,
                 acceptor_addr_type: OwnAddressType,
             ) -> Tuple[Connection, Connection]:
-
                 # Acceptor - Advertise
                 advertisement = acceptor.aio.host.Advertise(
                     legacy=True,
@@ -172,24 +220,13 @@
 
                 # Initiator - Scan and fetch the address
                 scan = initiator.aio.host.Scan(own_address_type=initiator_addr_type)
-                acceptor_addr = await anext(
+                acceptor_scan = 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
+                return await pandora.connect_le(initiator, advertisement, acceptor_scan, initiator_addr_type)
 
             # Make LE connection.
             if connect == 'incoming_connection':
@@ -329,6 +366,20 @@
                 if shall_pass:
                     assert_equal(secure.result_variant(), 'success')
                     assert_equal(wait_security.result_variant(), 'success')
+                    if 'lk' in key_distribution:
+                        # Make a Classic connection
+                        if self.dut.name == 'android':
+                            # Android IOP: Android automatically trigger a BR/EDR connection request
+                            # in this case.
+                            ref_dut_classic_res = await self.ref.aio.host.WaitConnection(self.dut.address)
+                            assert_is_not_none(ref_dut_classic_res.connection)
+                            assert ref_dut_classic_res.connection
+                            ref_dut_classic = ref_dut_classic_res.connection
+                        else:
+                            ref_dut_classic, _ = await pandora.connect(self.ref, self.dut)
+                        # Try to encrypt Classic connection
+                        ref_dut_secure = await self.ref.aio.security.Secure(ref_dut_classic, classic=LEVEL2)
+                        assert_equal(ref_dut_secure.result_variant(), 'success')
                 else:
                     assert_in(
                         secure.result_variant(),
diff --git a/cases/security_test.py b/avatar/cases/security_test.py
similarity index 65%
rename from cases/security_test.py
rename to avatar/cases/security_test.py
index 4d23b0c..5213119 100644
--- a/cases/security_test.py
+++ b/avatar/cases/security_test.py
@@ -17,17 +17,67 @@
 import itertools
 import logging
 
-from avatar import BumblePandoraDevice, PandoraDevice, PandoraDevices
-from bumble.hci import HCI_CENTRAL_ROLE, HCI_PERIPHERAL_ROLE
+from avatar import BumblePandoraDevice
+from avatar import PandoraDevice
+from avatar import PandoraDevices
+from avatar import pandora
+from bumble.hci import HCI_CENTRAL_ROLE
+from bumble.hci import HCI_PERIPHERAL_ROLE
+from bumble.hci import HCI_Write_Default_Link_Policy_Settings_Command
+from bumble.keys import PairingKeys
+from bumble.pairing import PairingConfig
 from bumble.pairing import PairingDelegate
-from mobly import base_test, signals, test_runner
+from mobly import base_test
+from mobly import signals
+from mobly import 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
+from pandora.host_pb2 import RANDOM
+from pandora.host_pb2 import RESOLVABLE_OR_PUBLIC
+from pandora.host_pb2 import Connection as PandoraConnection
+from pandora.host_pb2 import DataTypes
+from pandora.security_pb2 import LE_LEVEL2
+from pandora.security_pb2 import LEVEL2
+from pandora.security_pb2 import PairingEventAnswer
+from pandora.security_pb2 import SecureResponse
+from pandora.security_pb2 import WaitSecurityResponse
+from typing import Any, List, Literal, Optional, Tuple, Union
+
+DEFAULT_SMP_KEY_DISTRIBUTION = (
+    PairingDelegate.KeyDistribution.DISTRIBUTE_ENCRYPTION_KEY
+    | PairingDelegate.KeyDistribution.DISTRIBUTE_IDENTITY_KEY
+    | PairingDelegate.KeyDistribution.DISTRIBUTE_SIGNING_KEY
+)
+
+
+async def le_connect_with_rpa_and_encrypt(central: PandoraDevice, peripheral: PandoraDevice) -> None:
+    # Note: Android doesn't support own_address_type=RESOLVABLE_OR_PUBLIC(offloaded resolution)
+    # But own_address_type=RANDOM still set a public RPA generated in host
+    advertisement = peripheral.aio.host.Advertise(
+        legacy=True,
+        connectable=True,
+        own_address_type=RANDOM if peripheral.name == 'android' else RESOLVABLE_OR_PUBLIC,
+        data=DataTypes(manufacturer_specific_data=b'pause cafe'),
+    )
+
+    (cen_res, per_res) = await asyncio.gather(
+        central.aio.host.ConnectLE(
+            own_address_type=RANDOM if central.name == 'android' else RESOLVABLE_OR_PUBLIC,
+            public=peripheral.address,
+        ),
+        anext(aiter(advertisement)),  # pytype: disable=name-error
+    )
+
+    advertisement.cancel()
+    assert_equal(cen_res.result_variant(), 'connection')
+    cen_per = cen_res.connection
+    per_cen = per_res.connection
+    assert cen_per is not None and per_cen is not None
+
+    encryption = await peripheral.aio.security.Secure(connection=per_cen, le=LE_LEVEL2)
+    assert_equal(encryption.result_variant(), 'success')
 
 
 class SecurityTest(base_test.BaseTestClass):  # type: ignore[misc]
@@ -50,6 +100,7 @@
         # Enable BR/EDR mode and SSP for Bumble devices.
         for device in self.devices:
             if isinstance(device, BumblePandoraDevice):
+                device.config.setdefault('address_resolution_offload', True)
                 device.config.setdefault('classic_enabled', True)
                 device.config.setdefault('classic_ssp_enabled', True)
                 device.config.setdefault(
@@ -75,6 +126,7 @@
                 'rejected',
                 'disconnect',
                 'disconnected',
+                'accept_ctkd',
             ),
             (
                 'against_default_io_cap',
@@ -97,6 +149,7 @@
             Literal['rejected'],
             Literal['disconnect'],
             Literal['disconnected'],
+            Literal['accept_ctkd'],
         ],
         ref_io_capability: Union[
             Literal['against_default_io_cap'],
@@ -142,6 +195,10 @@
         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.')
 
+        # CTKD
+        if 'ctkd' in variant and ref_io_capability not in ('against_display_yes_no'):
+            raise signals.TestSkip('CTKD cases must be conducted under Security Level 4')
+
         # Factory reset both DUT and REF devices.
         await asyncio.gather(self.dut.reset(), self.ref.reset())
 
@@ -154,48 +211,91 @@
                 'against_display_yes_no': PairingDelegate.IoCapability.DISPLAY_OUTPUT_AND_YES_NO_INPUT,
             }[ref_io_capability]
             self.ref.server_config.io_capability = io_capability
+            self.ref.server_config.smp_local_initiator_key_distribution = DEFAULT_SMP_KEY_DISTRIBUTION
+            self.ref.server_config.smp_local_responder_key_distribution = DEFAULT_SMP_KEY_DISTRIBUTION
+            # Distribute Public identity address
+            self.ref.server_config.identity_address_type = PairingConfig.AddressType.PUBLIC
+            # Allow role switch
+            # TODO: Remove direct Bumble usage
+            await self.ref.device.send_command(HCI_Write_Default_Link_Policy_Settings_Command(default_link_policy_settings=0x01), check_result=True)  # type: ignore
+
+        # Override DUT Bumble device capabilities.
+        if isinstance(self.dut, BumblePandoraDevice):
+            self.dut.server_config.smp_local_initiator_key_distribution = DEFAULT_SMP_KEY_DISTRIBUTION
+            self.dut.server_config.smp_local_responder_key_distribution = DEFAULT_SMP_KEY_DISTRIBUTION
+            # Distribute Public identity address
+            self.dut.server_config.identity_address_type = PairingConfig.AddressType.PUBLIC
+            # Allow role switch
+            # TODO: Remove direct Bumble usage
+            await self.dut.device.send_command(HCI_Write_Default_Link_Policy_Settings_Command(default_link_policy_settings=0x01), check_result=True)  # type: ignore
 
         # Pandora connection tokens
-        ref_dut, dut_ref = None, None
+        ref_dut: Optional[PandoraConnection] = None
+        dut_ref: Optional[PandoraConnection] = None
+        # Bumble connection
+        ref_dut_bumble = None
+        dut_ref_bumble = None
+        # CTKD async task
+        ctkd_task = None
+        need_ctkd = 'ctkd' in variant
 
         # 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
+            nonlocal ref_dut_bumble
+            nonlocal dut_ref_bumble
+            nonlocal ctkd_task
 
             # Make classic connection.
             if connect == 'incoming_connection':
-                ref_dut, dut_ref = await bredr_connect(self.ref, self.dut)
+                ref_dut, dut_ref = await pandora.connect(initiator=self.ref, acceptor=self.dut)
             else:
-                dut_ref, ref_dut = await bredr_connect(self.dut, self.ref)
+                dut_ref, ref_dut = await pandora.connect(initiator=self.dut, acceptor=self.ref)
 
+            # Retrieve Bumble connection
+            if isinstance(self.dut, BumblePandoraDevice):
+                dut_ref_bumble = pandora.get_raw_connection(self.dut, dut_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:
+                ref_dut_bumble = pandora.get_raw_connection(self.ref, ref_dut)
+                if ref_dut_bumble is not None:
                     role = {
                         'against_central': HCI_CENTRAL_ROLE,
                         'against_peripheral': HCI_PERIPHERAL_ROLE,
                     }[ref_role]
 
-                    if ref_dut_raw.role != role:
+                    if ref_dut_bumble.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)
+                        await ref_dut_bumble.switch_role(role)
+
+            # TODO: Remove direct Bumble usage
+            async def wait_ctkd_keys() -> List[PairingKeys]:
+                futures: List[asyncio.Future[PairingKeys]] = []
+                if ref_dut_bumble is not None:
+                    ref_dut_fut = asyncio.get_event_loop().create_future()
+                    futures.append(ref_dut_fut)
+
+                    def on_pairing(keys: PairingKeys) -> None:
+                        ref_dut_fut.set_result(keys)
+
+                    ref_dut_bumble.on('pairing', on_pairing)
+                if dut_ref_bumble is not None:
+                    dut_ref_fut = asyncio.get_event_loop().create_future()
+                    futures.append(dut_ref_fut)
+
+                    def on_pairing(keys: PairingKeys) -> None:
+                        dut_ref_fut.set_result(keys)
+
+                    dut_ref_bumble.on('pairing', on_pairing)
+
+                return await asyncio.gather(*futures)
+
+            if need_ctkd:
+                # CTKD might be triggered by devices automatically, so CTKD listener must be started here
+                ctkd_task = asyncio.create_task(wait_ctkd_keys())
 
             # Pairing.
             if pair == 'incoming_pairing':
@@ -215,7 +315,7 @@
         # Start connection/pairing.
         connect_and_pair_task = asyncio.create_task(connect_and_pair())
 
-        shall_pass = variant == 'accept'
+        shall_pass = variant == 'accept' or 'ctkd' in variant
         try:
             dut_pairing_fut = asyncio.create_task(anext(dut_pairing))
             ref_pairing_fut = asyncio.create_task(anext(ref_pairing))
@@ -339,6 +439,31 @@
                 dut_pairing.cancel()
                 ref_pairing.cancel()
 
+        if not need_ctkd:
+            return
+
+        ctkd_shall_pass = variant == 'accept_ctkd'
+
+        if variant == 'accept_ctkd':
+            # TODO: Remove direct Bumble usage
+            async def ctkd_over_bredr() -> None:
+                if ref_role == 'against_central':
+                    if ref_dut_bumble is not None:
+                        await ref_dut_bumble.pair()
+                else:
+                    if dut_ref_bumble is not None:
+                        await dut_ref_bumble.pair()
+                assert ctkd_task is not None
+                await ctkd_task
+
+            await ctkd_over_bredr()
+        else:
+            fail("Unsupported variant " + variant)
+
+        if ctkd_shall_pass:
+            # Try to connect with RPA(to verify IRK), and encrypt(to verify LTK)
+            await le_connect_with_rpa_and_encrypt(self.dut, self.ref)
+
 
 if __name__ == '__main__':
     logging.basicConfig(level=logging.DEBUG)
diff --git a/avatar/metrics/interceptors.py b/avatar/metrics/interceptors.py
index e853395..3ac7da1 100644
--- a/avatar/metrics/interceptors.py
+++ b/avatar/metrics/interceptors.py
@@ -193,7 +193,6 @@
         client_call_details: ClientCallDetails,
         request: _T,
     ) -> utils.AioStream[_U]:
-
         # TODO: this is a workaround for https://github.com/grpc/grpc/pull/33951
         #  need to be deleted as soon as `grpcio` contains the fix.
         now = time.time()
@@ -241,7 +240,6 @@
         client_call_details: ClientCallDetails,
         request: utils.AioSender[_T],
     ) -> utils.AioStreamStream[_T, _U]:
-
         # TODO: this is a workaround for https://github.com/grpc/grpc/pull/33951
         #  need to be deleted as soon as `grpcio` contains the fix.
         now = time.time()
diff --git a/avatar/metrics/trace.py b/avatar/metrics/trace.py
index 52f2bc6..86bc21a 100644
--- a/avatar/metrics/trace.py
+++ b/avatar/metrics/trace.py
@@ -18,16 +18,15 @@
 import time
 import types
 
-from avatar.metrics.trace_pb2 import (
-    DebugAnnotation,
-    ProcessDescriptor,
-    ThreadDescriptor,
-    Trace,
-    TracePacket,
-    TrackDescriptor,
-    TrackEvent,
-)
-from google.protobuf import any_pb2, message
+from avatar.metrics.trace_pb2 import DebugAnnotation
+from avatar.metrics.trace_pb2 import ProcessDescriptor
+from avatar.metrics.trace_pb2 import ThreadDescriptor
+from avatar.metrics.trace_pb2 import Trace
+from avatar.metrics.trace_pb2 import TracePacket
+from avatar.metrics.trace_pb2 import TrackDescriptor
+from avatar.metrics.trace_pb2 import TrackEvent
+from google.protobuf import any_pb2
+from google.protobuf import message
 from mobly.base_test import BaseTestClass
 from pathlib import Path
 from typing import TYPE_CHECKING, Any, Dict, List, Optional, Protocol, Tuple, Union
@@ -264,7 +263,7 @@
 def debug_message(msg: message.Message) -> Tuple[Dict[str, Any], List[DebugAnnotation]]:
     json: Dict[str, Any] = {}
     dbga: List[DebugAnnotation] = []
-    for (f, v) in msg.ListFields():
+    for f, v in msg.ListFields():
         if (
             isinstance(v, bytes)
             and len(v) == 6
diff --git a/avatar/metrics/trace_pb2.py b/avatar/metrics/trace_pb2.py
index 095c6e4..7743384 100644
--- a/avatar/metrics/trace_pb2.py
+++ b/avatar/metrics/trace_pb2.py
@@ -1,12 +1,13 @@
+# pyright: reportGeneralTypeIssues=false
+# pyright: reportUnknownVariableType=false
+# pyright: reportUnknownMemberType=false
 # -*- coding: utf-8 -*-
 # Generated by the protocol buffer compiler.  DO NOT EDIT!
 # source: trace.proto
 """Generated protocol buffer code."""
-from google.protobuf import (
-    descriptor as _descriptor,
-    descriptor_pool as _descriptor_pool,
-    symbol_database as _symbol_database,
-)
+from google.protobuf import descriptor as _descriptor
+from google.protobuf import descriptor_pool as _descriptor_pool
+from google.protobuf import symbol_database as _symbol_database
 from google.protobuf.internal import builder as _builder
 
 # @@protoc_insertion_point(imports)
@@ -22,7 +23,6 @@
 _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
 _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'trace_pb2', _globals)
 if _descriptor._USE_C_DESCRIPTORS == False:
-
     DESCRIPTOR._options = None
     _globals['_TRACE']._serialized_start = 32
     _globals['_TRACE']._serialized_end = 85
diff --git a/avatar/metrics/trace_pb2.pyi b/avatar/metrics/trace_pb2.pyi
index e8aff00..fcfac67 100644
--- a/avatar/metrics/trace_pb2.pyi
+++ b/avatar/metrics/trace_pb2.pyi
@@ -1,6 +1,11 @@
-from google.protobuf import descriptor as _descriptor, message as _message
-from google.protobuf.internal import containers as _containers, enum_type_wrapper as _enum_type_wrapper
-from typing import ClassVar as _ClassVar, Iterable as _Iterable, Optional as _Optional, Union as _Union
+from google.protobuf import descriptor as _descriptor
+from google.protobuf import message as _message
+from google.protobuf.internal import containers as _containers
+from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper
+from typing import ClassVar as _ClassVar
+from typing import Iterable as _Iterable
+from typing import Optional as _Optional
+from typing import Union as _Union
 
 DESCRIPTOR: _descriptor.FileDescriptor
 
diff --git a/avatar/pandora.py b/avatar/pandora.py
new file mode 100644
index 0000000..31695ee
--- /dev/null
+++ b/avatar/pandora.py
@@ -0,0 +1,67 @@
+# 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
+
+from avatar import BumblePandoraDevice
+from avatar import PandoraDevice
+from bumble.device import Connection as BumbleConnection
+from mobly.asserts import assert_equal  # type: ignore
+from mobly.asserts import assert_is_not_none  # type: ignore
+from pandora._utils import AioStream
+from pandora.host_pb2 import AdvertiseResponse
+from pandora.host_pb2 import Connection
+from pandora.host_pb2 import OwnAddressType
+from pandora.host_pb2 import ScanningResponse
+from typing import Optional, Tuple
+
+
+def get_raw_connection_handle(device: PandoraDevice, connection: Connection) -> int:
+    assert isinstance(device, BumblePandoraDevice)
+    return int.from_bytes(connection.cookie.value, 'big')
+
+
+def get_raw_connection(device: PandoraDevice, connection: Connection) -> Optional[BumbleConnection]:
+    assert isinstance(device, BumblePandoraDevice)
+    return device.device.lookup_connection(get_raw_connection_handle(device, connection))
+
+
+async def 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
+
+
+async def connect_le(
+    initiator: PandoraDevice,
+    acceptor: AioStream[AdvertiseResponse],
+    scan: ScanningResponse,
+    own_address_type: OwnAddressType,
+    cancel_advertisement: bool = True,
+) -> Tuple[Connection, Connection]:
+    (init_res, wait_res) = await asyncio.gather(
+        initiator.aio.host.ConnectLE(own_address_type=own_address_type, **scan.address_asdict()),
+        anext(aiter(acceptor)),  # pytype: disable=name-error
+    )
+    if cancel_advertisement:
+        acceptor.cancel()
+    assert_equal(init_res.result_variant(), 'connection')
+    assert_is_not_none(init_res.connection)
+    assert init_res.connection
+    return init_res.connection, wait_res.connection
diff --git a/avatar/pandora_client.py b/avatar/pandora_client.py
index 60689aa..98211c6 100644
--- a/avatar/pandora_client.py
+++ b/avatar/pandora_client.py
@@ -23,12 +23,16 @@
 import grpc.aio
 import logging
 
-from avatar.metrics.interceptors import aio_interceptors, interceptors
+from avatar.metrics.interceptors import aio_interceptors
+from avatar.metrics.interceptors import interceptors
 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 pandora import host_grpc
+from pandora import host_grpc_aio
+from pandora import security_grpc
+from pandora import security_grpc_aio
 from typing import Any, Dict, MutableMapping, Optional, Tuple, Union
 
 
diff --git a/avatar/pandora_server.py b/avatar/pandora_server.py
index 4fd56fb..aafc3fc 100644
--- a/avatar/pandora_server.py
+++ b/avatar/pandora_server.py
@@ -23,8 +23,10 @@
 import threading
 import types
 
-from avatar.controllers import bumble_device, pandora_device
-from avatar.pandora_client import BumblePandoraClient, PandoraClient
+from avatar.controllers import bumble_device
+from avatar.controllers import pandora_device
+from avatar.pandora_client import BumblePandoraClient
+from avatar.pandora_client import PandoraClient
 from bumble import pandora as bumble_server
 from bumble.pandora.device import PandoraDevice as BumblePandoraDevice
 from contextlib import suppress
diff --git a/avatar/runner.py b/avatar/runner.py
new file mode 100644
index 0000000..8cc4a53
--- /dev/null
+++ b/avatar/runner.py
@@ -0,0 +1,140 @@
+# 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.
+
+
+"""Avatar runner."""
+
+import inspect
+import logging
+import os
+import pathlib
+
+from importlib.machinery import SourceFileLoader
+from mobly import base_test
+from mobly import config_parser
+from mobly import signals
+from mobly import test_runner
+from typing import Dict, List, Tuple, Type
+
+_BUMBLE_BTSNOOP_FMT = 'bumble_btsnoop_{pid}_{instance}.log'
+
+
+class SuiteRunner:
+    test_beds: List[str] = []
+    test_run_configs: List[config_parser.TestRunConfig] = []
+    test_classes: List[Type[base_test.BaseTestClass]] = []
+    test_filters: List[str] = []
+    logs_dir: pathlib.Path = pathlib.Path('out')
+    logs_verbose: bool = False
+
+    def set_logs_dir(self, path: pathlib.Path) -> None:
+        self.logs_dir = path
+
+    def set_logs_verbose(self, verbose: bool = True) -> None:
+        self.logs_verbose = verbose
+
+    def add_test_beds(self, test_beds: List[str]) -> None:
+        self.test_beds += test_beds
+
+    def add_test_filters(self, test_filters: List[str]) -> None:
+        self.test_filters += test_filters
+
+    def add_config_file(self, path: pathlib.Path) -> None:
+        self.test_run_configs += config_parser.load_test_config_file(str(path))  # type: ignore
+
+    def add_test_class(self, cls: Type[base_test.BaseTestClass]) -> None:
+        self.test_classes.append(cls)
+
+    def add_test_module(self, path: pathlib.Path) -> None:
+        try:
+            module = SourceFileLoader(path.stem, str(path)).load_module()
+            classes = inspect.getmembers(module, inspect.isclass)
+            for _, cls in classes:
+                if issubclass(cls, base_test.BaseTestClass):
+                    self.test_classes.append(cls)
+        except ImportError:
+            pass
+
+    def add_path(self, path: pathlib.Path, root: bool = True) -> None:
+        if path.is_file():
+            if path.name.endswith('_test.py'):
+                self.add_test_module(path)
+            elif not self.test_run_configs and not root and path.name in ('config.yml', 'config.yaml'):
+                self.add_config_file(path)
+            elif root:
+                raise ValueError(f'{path} is not a test file')
+        else:
+            for child in path.iterdir():
+                self.add_path(child, root=False)
+
+    def is_included(self, cls: base_test.BaseTestClass, test: str) -> bool:
+        return not self.test_filters or any(filter_match(cls, test, filter) for filter in self.test_filters)
+
+    @property
+    def included_tests(self) -> Dict[Type[base_test.BaseTestClass], Tuple[str, List[str]]]:
+        result: Dict[Type[base_test.BaseTestClass], Tuple[str, List[str]]] = {}
+        for test_class in self.test_classes:
+            cls = test_class(config_parser.TestRunConfig())
+            test_names: List[str] = []
+            try:
+                # Executes pre-setup procedures, this is required since it might
+                # generate test methods that we want to return as well.
+                cls._pre_run()
+                test_names = cls.tests or cls.get_existing_test_names()  # type: ignore
+                test_names = list(test for test in test_names if self.is_included(cls, test))
+                if test_names:
+                    assert cls.TAG
+                    result[test_class] = (cls.TAG, test_names)
+            except Exception:
+                logging.exception('Failed to retrieve generated tests.')
+            finally:
+                cls._clean_up()
+        return result
+
+    def run(self) -> bool:
+        # Create logs directory.
+        if not self.logs_dir.exists():
+            self.logs_dir.mkdir()
+
+        # Enable Bumble snoop logs.
+        os.environ.setdefault('BUMBLE_SNOOPER', f'btsnoop:file:{self.logs_dir}/{_BUMBLE_BTSNOOP_FMT}')
+
+        # Execute the suite
+        ok = True
+        for config in self.test_run_configs:
+            test_bed: str = config.test_bed_name  # type: ignore
+            if self.test_beds and test_bed not in self.test_beds:
+                continue
+            runner = test_runner.TestRunner(config.log_path, config.testbed_name)
+            with runner.mobly_logger(console_level=logging.DEBUG if self.logs_verbose else logging.INFO):
+                for test_class, (_, tests) in self.included_tests.items():
+                    runner.add_test_class(config, test_class, tests)  # type: ignore
+                try:
+                    runner.run()
+                    ok = ok and runner.results.is_all_pass
+                except signals.TestAbortAll:
+                    ok = ok and not self.test_beds
+                except Exception:
+                    logging.exception('Exception when executing %s.', config.testbed_name)
+                    ok = False
+        return ok
+
+
+def filter_match(cls: base_test.BaseTestClass, test: str, filter: str) -> bool:
+    tag: str = cls.TAG  # type: ignore
+    if '.test_' in filter:
+        return f"{tag}.{test}".startswith(filter)
+    if filter.startswith('test_'):
+        return test.startswith(filter)
+    return tag.startswith(filter)
diff --git a/pyproject.toml b/pyproject.toml
index cba7d27..496da6b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -10,47 +10,66 @@
 ]
 dependencies = [
     "bt-test-interfaces",
-    "bumble==0.0.154",
-    "protobuf>=4.22.0",
-    "grpcio>=1.51.1",
-    "mobly>=1.12",
+    "bumble==0.0.170",
+    "protobuf==4.24.2",
+    "grpcio==1.57",
+    "mobly==1.12.2",
     "portpicker>=1.5.2",
 ]
 
 [project.urls]
 Source = "https://github.com/google/avatar"
 
+[project.scripts]
+avatar = "avatar:main"
+
 [project.optional-dependencies]
 dev = [
-    "grpcio-tools>=1.51.1",
-    "black==22.10.0",
+    "rootcanal==1.3.0",
+    "grpcio-tools>=1.57",
     "pyright==1.1.298",
-    "mypy==1.0",
+    "mypy==1.5.1",
+    "black==23.7.0",
     "isort==5.12.0",
-    "types-psutil>=5.9.5.6",
-    "types-setuptools>=65.7.0.3",
-    "types-protobuf>=4.21.0.3"
+    "types-psutil==5.9.5.16",
+    "types-setuptools==68.1.0.1",
+    "types-protobuf==4.24.0.1"
 ]
 
+[tool.flit.module]
+name = "avatar"
+
+[tool.flit.sdist]
+include = ["doc/"]
+
 [tool.black]
 line-length = 119
 target-version = ["py38", "py39", "py310", "py311"]
 skip-string-normalization = true
 
-[tool.flit.module]
-name = "avatar"
-
 [tool.isort]
 profile = "black"
 line_length = 119
 no_sections = true
 lines_between_types = 1
-combine_as_imports = true
+force_single_line = true
+single_line_exclusions = ["typing", "typing_extensions", "collections.abc"]
+
+[tool.pyright]
+include = ["avatar"]
+exclude = ["**/__pycache__", "**/*_pb2.py"]
+typeCheckingMode = "strict"
+useLibraryCodeForTypes = true
+verboseOutput = false
+reportMissingTypeStubs = false
+reportUnknownLambdaType = false
+reportImportCycles = false
+reportPrivateUsage = false
 
 [tool.mypy]
 strict = true
 warn_unused_ignores = false
-files = ["avatar", "cases"]
+files = ["avatar"]
 
 [[tool.mypy.overrides]]
 module = "grpc.*"
@@ -68,18 +87,8 @@
 module = "portpicker.*"
 ignore_missing_imports = true
 
-[tool.pyright]
-include = ["avatar", "cases"]
-exclude = ["**/__pycache__", "**/*_pb2.py"]
-typeCheckingMode = "strict"
-useLibraryCodeForTypes = true
-verboseOutput = false
-reportMissingTypeStubs = false
-reportUnknownLambdaType = false
-reportImportCycles = false
-
 [tool.pytype]
-inputs = ['avatar', 'cases']
+inputs = ['avatar']
 
 [build-system]
 requires = ["flit_core==3.7.1"]