aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAndroid Build Coastguard Worker <android-build-coastguard-worker@google.com>2023-12-04 13:35:25 +0000
committerAndroid Build Coastguard Worker <android-build-coastguard-worker@google.com>2023-12-04 13:35:25 +0000
commita8b5f59c0616e8427a52ee1cae15c14caa0e2017 (patch)
treeb689ca1779ca555deedc306a24a6fa01eb46b4ed
parent3cbc1049e3ef2cbd42577b8bcc7b3ca30749d99c (diff)
parent2560849bf4526b22306247738d7e409baa23882d (diff)
downloadavatar-android14-mainline-sdkext-release.tar.gz
Snap for 11173240 from 2560849bf4526b22306247738d7e409baa23882d to mainline-sdkext-releaseaml_sdk_341510000aml_sdk_341410000android14-mainline-sdkext-release
Change-Id: Ic9090db845376a35b84ee02b5bd21f9f769f62dc
-rw-r--r--.github/workflows/avatar.yml91
-rw-r--r--.github/workflows/pypi-publish.yml (renamed from .github/workflows/python-publish.yml)2
-rw-r--r--.github/workflows/python-build.yml27
-rw-r--r--.github/workflows/python-lint-and-format.yml34
-rw-r--r--Android.bp6
-rw-r--r--avatar/__init__.py103
-rw-r--r--avatar/cases/__init__.py (renamed from cases/__init__.py)0
-rw-r--r--avatar/cases/config.yml (renamed from cases/config.yml)0
-rw-r--r--avatar/cases/host_test.py (renamed from cases/host_test.py)20
-rw-r--r--avatar/cases/le_host_test.py (renamed from cases/le_host_test.py)15
-rw-r--r--avatar/cases/le_security_test.py (renamed from cases/le_security_test.py)95
-rw-r--r--avatar/cases/security_test.py (renamed from cases/security_test.py)179
-rw-r--r--avatar/metrics/interceptors.py2
-rw-r--r--avatar/metrics/trace.py21
-rw-r--r--avatar/metrics/trace_pb2.py12
-rw-r--r--avatar/metrics/trace_pb2.pyi11
-rw-r--r--avatar/pandora.py67
-rw-r--r--avatar/pandora_client.py8
-rw-r--r--avatar/pandora_server.py6
-rw-r--r--avatar/runner.py140
-rw-r--r--pyproject.toml61
21 files changed, 716 insertions, 184 deletions
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
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 @@ python_library_host {
name: "libavatar",
srcs: [
"avatar/*.py",
+ "avatar/cases/*.py",
"avatar/controllers/*.py",
"avatar/metrics/*.py",
],
@@ -33,8 +34,3 @@ python_library_host {
"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 @@ Avatar is a scalable multi-platform Bluetooth testing tool capable of running
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 @@ class PandoraDevices(Sized, Iterable[PandoraDevice]):
# 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 @@ def rpc_except(
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
index bb545f4..bb545f4 100644
--- a/cases/__init__.py
+++ b/avatar/cases/__init__.py
diff --git a/cases/config.yml b/avatar/cases/config.yml
index a5451cd..a5451cd 100644
--- a/cases/config.yml
+++ b/avatar/cases/config.yml
diff --git a/cases/host_test.py b/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 avatar
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
index eb2f017..b6ab4aa 100644
--- a/cases/le_host_test.py
+++ b/avatar/cases/le_host_test.py
@@ -20,14 +20,21 @@ import itertools
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 @@ class LeHostTest(base_test.BaseTestClass): # type: ignore[misc]
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
index 9c836b2..b91d8c7 100644
--- a/cases/le_security_test.py
+++ b/avatar/cases/le_security_test.py
@@ -17,15 +17,29 @@ import avatar
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 @@ class LeSecurityTest(base_test.BaseTestClass): # type: ignore[misc]
'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 @@ class LeSecurityTest(base_test.BaseTestClass): # type: ignore[misc]
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 @@ class LeSecurityTest(base_test.BaseTestClass): # type: ignore[misc]
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 @@ class LeSecurityTest(base_test.BaseTestClass): # type: ignore[misc]
+ '- 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 @@ class LeSecurityTest(base_test.BaseTestClass): # type: ignore[misc]
'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 @@ class LeSecurityTest(base_test.BaseTestClass): # type: ignore[misc]
initiator_addr_type: OwnAddressType,
acceptor_addr_type: OwnAddressType,
) -> Tuple[Connection, Connection]:
-
# Acceptor - Advertise
advertisement = acceptor.aio.host.Advertise(
legacy=True,
@@ -172,24 +220,13 @@ class LeSecurityTest(base_test.BaseTestClass): # type: ignore[misc]
# 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 @@ class LeSecurityTest(base_test.BaseTestClass): # type: ignore[misc]
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
index 4d23b0c..5213119 100644
--- a/cases/security_test.py
+++ b/avatar/cases/security_test.py
@@ -17,17 +17,67 @@ import avatar
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 @@ class SecurityTest(base_test.BaseTestClass): # type: ignore[misc]
# 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 @@ class SecurityTest(base_test.BaseTestClass): # type: ignore[misc]
'rejected',
'disconnect',
'disconnected',
+ 'accept_ctkd',
),
(
'against_default_io_cap',
@@ -97,6 +149,7 @@ class SecurityTest(base_test.BaseTestClass): # type: ignore[misc]
Literal['rejected'],
Literal['disconnect'],
Literal['disconnected'],
+ Literal['accept_ctkd'],
],
ref_io_capability: Union[
Literal['against_default_io_cap'],
@@ -142,6 +195,10 @@ class SecurityTest(base_test.BaseTestClass): # type: ignore[misc]
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 @@ class SecurityTest(base_test.BaseTestClass): # type: ignore[misc]
'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 @@ class SecurityTest(base_test.BaseTestClass): # type: ignore[misc]
# 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 @@ class SecurityTest(base_test.BaseTestClass): # type: ignore[misc]
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 @@ class AioUnaryStreamInterceptor(grpc.aio.UnaryStreamClientInterceptor): # type:
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 @@ class AioStreamStreamInterceptor(grpc.aio.StreamStreamClientInterceptor): # typ
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 atexit
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_value(v: Any) -> Tuple[Any, Dict[str, Any]]:
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 @@ _globals = globals()
_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
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 portpicker
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 @@ classifiers = [
]
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 @@ ignore_missing_imports = true
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"]