├── .github └── workflows │ ├── avatar.yml │ └── pypi-publish.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── avatar ├── __init__.py ├── aio.py ├── cases │ ├── __init__.py │ ├── config.yml │ ├── host_test.py │ ├── le_host_test.py │ ├── le_security_test.py │ └── security_test.py ├── controllers │ ├── __init__.py │ ├── bumble_device.py │ ├── pandora_device.py │ └── usb_bumble_device.py ├── metrics │ ├── README.md │ ├── __init__.py │ ├── interceptors.py │ ├── trace.proto │ ├── trace.py │ ├── trace_pb2.py │ └── trace_pb2.pyi ├── pandora_client.py ├── pandora_server.py ├── pandora_snippet.py ├── py.typed └── runner.py ├── doc ├── android-bumble-extensions.md ├── android-guide.md ├── images │ ├── avatar-android-bumble-virtual-architecture-simplified.svg │ └── avatar-extended-architecture-simplified.svg └── overview.md └── pyproject.toml /.github/workflows/avatar.yml: -------------------------------------------------------------------------------- 1 | name: Avatar 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | name: Build with Python ${{ matrix.python-version }} 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | python-version: ["3.10", "3.11"] 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Set Up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - name: Install 23 | run: | 24 | pip install --upgrade pip 25 | pip install build 26 | pip install . 27 | - name: Build 28 | run: python -m build 29 | lint: 30 | name: Lint for Python ${{ matrix.python-version }} 31 | runs-on: ubuntu-latest 32 | strategy: 33 | matrix: 34 | python-version: ["3.10", "3.11"] 35 | steps: 36 | - uses: actions/checkout@v3 37 | - name: Set Up Python ${{ matrix.python-version }} 38 | uses: actions/setup-python@v4 39 | with: 40 | python-version: ${{ matrix.python-version }} 41 | - name: Install 42 | run: pip install .[dev] 43 | - run: mypy 44 | - run: pyright 45 | format: 46 | name: Check Python formatting 47 | runs-on: ubuntu-latest 48 | steps: 49 | - uses: actions/checkout@v3 50 | - name: Set Up Python 3.11 51 | uses: actions/setup-python@v4 52 | with: 53 | python-version: 3.11 54 | - name: Install 55 | run: | 56 | pip install --upgrade pip 57 | pip install .[dev] 58 | - run: black --check avatar/ 59 | - run: isort --check avatar 60 | test: 61 | name: Test Bumble vs Bumble(s) [${{ matrix.shard }}] 62 | runs-on: ubuntu-latest 63 | strategy: 64 | matrix: 65 | shard: [ 66 | 1/24, 2/24, 3/24, 4/24, 67 | 5/24, 6/24, 7/24, 8/24, 68 | 9/24, 10/24, 11/24, 12/24, 69 | 13/24, 14/24, 15/24, 16/24, 70 | 17/24, 18/24, 19/24, 20/24, 71 | 21/24, 22/24, 23/24, 24/24, 72 | ] 73 | steps: 74 | - uses: actions/checkout@v3 75 | - name: Set Up Python 3.11 76 | uses: actions/setup-python@v4 77 | with: 78 | python-version: 3.11 79 | - name: Install 80 | run: | 81 | pip install --upgrade pip 82 | pip install rootcanal==1.10.0 83 | pip install . 84 | - name: Rootcanal 85 | run: nohup python -m rootcanal > rootcanal.log & 86 | - name: Test 87 | run: | 88 | avatar --list | grep -Ev '^=' > test-names.txt 89 | timeout 5m avatar --test-beds bumble.bumbles --tests $(split test-names.txt -n l/${{ matrix.shard }}) 90 | - name: Rootcanal Logs 91 | run: cat rootcanal.log 92 | -------------------------------------------------------------------------------- /.github/workflows/pypi-publish.yml: -------------------------------------------------------------------------------- 1 | name: PyPI Publish 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | deploy: 12 | name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Check out from Git 17 | uses: actions/checkout@v3 18 | - name: Get history and tags for SCM versioning to work 19 | run: | 20 | git fetch --prune --unshallow 21 | git fetch --depth=1 origin +refs/tags/*:refs/tags/* 22 | - name: Set up Python 23 | uses: actions/setup-python@v3 24 | with: 25 | python-version: '3.10' 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | python -m pip install build 30 | - name: Build package 31 | run: python -m build 32 | - name: Publish package to PyPI 33 | if: github.event_name == 'release' && startsWith(github.ref, 'refs/tags') 34 | uses: pypa/gh-action-pypi-publish@release/v1 35 | with: 36 | user: __token__ 37 | password: ${{ secrets.PYPI_API_TOKEN }} 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | venv/ 3 | __pycache__/ 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | We'd love to accept your patches and contributions to this project. There are 4 | just a few small guidelines you need to follow. 5 | 6 | ## Contributor License Agreement 7 | 8 | Contributions to this project must be accompanied by a Contributor License 9 | Agreement. You (or your employer) retain the copyright to your contribution; 10 | this simply gives us permission to use and redistribute your contributions as 11 | part of the project. Head over to to see 12 | your current agreements on file or to sign a new one. 13 | 14 | You generally only need to submit a CLA once, so if you've already submitted one 15 | (even if it was for a different project), you probably don't need to do it 16 | again. 17 | 18 | ## Style Guide 19 | 20 | Every contributions must follow [Google Python style guide]( 21 | https://google.github.io/styleguide/pyguide.html). 22 | 23 | ## Code Reviews 24 | 25 | All submissions, including submissions by project members, require review. 26 | 27 | ## Community Guidelines 28 | 29 | This project follows [Google's Open Source Community 30 | Guidelines](https://opensource.google/conduct/). 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Avatar 2 | 3 | Avatar is a python Bluetooth testing tool orchestrating multiple devices which 4 | implement the [Pandora interfaces]( 5 | https://github.com/google/bt-test-interfaces). 6 | 7 | ## Install 8 | 9 | ```bash 10 | python -m venv venv 11 | source venv/bin/activate.fish # or any other shell 12 | pip install [-e] . 13 | ``` 14 | 15 | ## Usage 16 | 17 | ```bash 18 | python cases/host_test.py -c cases/config.yml --verbose 19 | ``` 20 | 21 | ## Specify a test bed 22 | ```bash 23 | python cases/host_test.py -c cases/config.yml --test_bed bumble.bumbles --verbose 24 | ``` 25 | 26 | ## Development 27 | 28 | 1. Make sure to have a `root-canal` instance running somewhere. 29 | ```bash 30 | root-canal 31 | ``` 32 | 33 | 1. Run the example using Bumble vs Bumble config file. The default `6402` HCI 34 | port of `root-canal` may be changed in this config file. 35 | ``` 36 | python cases/host_test.py -c cases/config.yml --verbose 37 | ``` 38 | 39 | 1. Lint with `pyright` and `mypy` 40 | ``` 41 | pyright 42 | mypy 43 | ``` 44 | 45 | 1. Format & imports style 46 | ``` 47 | black avatar/ cases/ 48 | isort avatar/ cases/ 49 | ``` 50 | -------------------------------------------------------------------------------- /avatar/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """ 16 | Avatar is a scalable multi-platform Bluetooth testing tool capable of running 17 | any Bluetooth test cases virtually and physically. 18 | """ 19 | 20 | __version__ = "0.0.11" 21 | 22 | import argparse 23 | import enum 24 | import functools 25 | import grpc 26 | import grpc.aio 27 | import importlib 28 | import logging 29 | import pathlib 30 | 31 | from avatar import pandora_server 32 | from avatar.aio import asynchronous 33 | from avatar.metrics import trace 34 | from avatar.pandora_client import BumblePandoraClient as BumblePandoraDevice 35 | from avatar.pandora_client import PandoraClient as PandoraDevice 36 | from avatar.pandora_server import PandoraServer 37 | from avatar.runner import SuiteRunner 38 | from mobly import base_test 39 | from mobly import signals 40 | from typing import Any, Callable, Dict, Iterable, Iterator, List, Optional, Sized, Tuple, Type, TypeVar 41 | 42 | # public symbols 43 | __all__ = [ 44 | 'asynchronous', 45 | 'enableFlag', 46 | 'parameterized', 47 | 'rpc_except', 48 | 'PandoraDevices', 49 | 'PandoraDevice', 50 | 'BumblePandoraDevice', 51 | ] 52 | 53 | 54 | PANDORA_COMMON_SERVER_CLASSES: Dict[str, Type[pandora_server.PandoraServer[Any]]] = { 55 | 'PandoraDevice': pandora_server.PandoraServer, 56 | 'AndroidDevice': pandora_server.AndroidPandoraServer, 57 | 'BumbleDevice': pandora_server.BumblePandoraServer, 58 | 'UsbDevice': pandora_server.UsbBumblePandoraServer, 59 | } 60 | 61 | KEY_PANDORA_SERVER_CLASS = 'pandora_server_class' 62 | 63 | 64 | class PandoraDevices(Sized, Iterable[PandoraDevice]): 65 | """Utility for abstracting controller registration and Pandora setup.""" 66 | 67 | _test: base_test.BaseTestClass 68 | _clients: List[PandoraDevice] 69 | _servers: List[PandoraServer[Any]] 70 | 71 | def __init__(self, test: base_test.BaseTestClass) -> None: 72 | """Creates a PandoraDevices list. 73 | 74 | It performs three steps: 75 | - Register the underlying controllers to the test. 76 | - Start the corresponding PandoraServer for each controller. 77 | - Store a PandoraClient for each server. 78 | 79 | The order in which the clients are returned can be determined by the 80 | (optional) `order_` params in user_params. Controllers 81 | without such a param will be set up last (order=100). 82 | 83 | Args: 84 | test: Instance of the Mobly test class. 85 | """ 86 | self._test = test 87 | self._clients = [] 88 | self._servers = [] 89 | 90 | trace.hook_test(test, self) 91 | user_params: Dict[str, Any] = test.user_params # type: ignore 92 | controller_configs: Dict[str, Any] = test.controller_configs.copy() # type: ignore 93 | sorted_controllers = sorted( 94 | controller_configs.keys(), key=lambda controller: user_params.get(f'order_{controller}', 100) 95 | ) 96 | for controller in sorted_controllers: 97 | # Find the corresponding PandoraServer class for the controller. 98 | if f'{KEY_PANDORA_SERVER_CLASS}_{controller}' in user_params: 99 | # Try to load the server dynamically if module specified in user_params. 100 | class_path = user_params[f'{KEY_PANDORA_SERVER_CLASS}_{controller}'] 101 | logging.info('Loading Pandora server class %s from config for %s.', class_path, controller) 102 | server_cls = _load_pandora_server_class(class_path) 103 | else: 104 | # Search in the list of commonly-used controllers. 105 | try: 106 | server_cls = PANDORA_COMMON_SERVER_CLASSES[controller] 107 | except KeyError as e: 108 | raise RuntimeError( 109 | f'PandoraServer module for {controller} not found in either the ' 110 | 'config or PANDORA_COMMON_SERVER_CLASSES.' 111 | ) from e 112 | 113 | # Register the controller and load its Pandora servers. 114 | logging.info('Starting %s(s) for %s', server_cls.__name__, controller) 115 | try: 116 | devices: Optional[List[Any]] = test.register_controller( # type: ignore 117 | server_cls.MOBLY_CONTROLLER_MODULE 118 | ) 119 | except Exception: 120 | logging.exception('abort: failed to register controller') 121 | raise signals.TestAbortAll("") 122 | assert devices 123 | for device in devices: # type: ignore 124 | self._servers.append(server_cls(device)) 125 | 126 | self.start_all() 127 | 128 | def __len__(self) -> int: 129 | return len(self._clients) 130 | 131 | def __iter__(self) -> Iterator[PandoraDevice]: 132 | return iter(self._clients) 133 | 134 | def start_all(self) -> None: 135 | """Start all Pandora servers and returns their clients.""" 136 | if len(self._clients): 137 | return 138 | for server in self._servers: 139 | self._clients.append(server.start()) 140 | 141 | def stop_all(self) -> None: 142 | """Closes all opened Pandora clients and servers.""" 143 | if not len(self._clients): 144 | return 145 | for client in self: 146 | client.close() 147 | for server in self._servers: 148 | server.stop() 149 | self._clients.clear() 150 | 151 | 152 | def _load_pandora_server_class(class_path: str) -> Type[pandora_server.PandoraServer[Any]]: 153 | """Dynamically load a PandoraServer from a user-specified module+class. 154 | 155 | Args: 156 | class_path: String in format '.', where the module is fully 157 | importable using importlib.import_module. e.g.: 158 | my.pandora.server.module.MyPandoraServer 159 | 160 | Returns: 161 | The loaded PandoraServer instance. 162 | """ 163 | # Dynamically import the module, and get the class 164 | module_name, class_name = class_path.rsplit('.', 1) 165 | module = importlib.import_module(module_name) 166 | server_class = getattr(module, class_name) 167 | # Check that the class is a subclass of PandoraServer 168 | if not issubclass(server_class, pandora_server.PandoraServer): 169 | raise TypeError(f'The specified class {class_path} is not a subclass of PandoraServer.') 170 | return server_class # type: ignore 171 | 172 | 173 | class Wrapper(object): 174 | func: Callable[..., Any] 175 | 176 | def __init__(self, func: Callable[..., Any]) -> None: 177 | self.func = func 178 | 179 | 180 | # Multiply the same function from `inputs` parameters 181 | def parameterized(*inputs: Tuple[Any, ...]) -> Type[Wrapper]: 182 | class wrapper(Wrapper): 183 | def __set_name__(self, owner: str, name: str) -> None: 184 | for input in inputs: 185 | 186 | def decorate(input: Tuple[Any, ...]) -> Callable[..., Any]: 187 | @functools.wraps(self.func) 188 | def wrapper(*args: Any, **kwargs: Any) -> Any: 189 | return self.func(*args, *input, **kwargs) 190 | 191 | return wrapper 192 | 193 | def normalize(a: Any) -> Any: 194 | if isinstance(a, enum.Enum): 195 | return a.value 196 | return a 197 | 198 | # we need to pass `input` here, otherwise it will be set to the value 199 | # from the last iteration of `inputs` 200 | setattr( 201 | owner, 202 | f"{name}{tuple([normalize(a) for a in input])}".replace(" ", ""), 203 | decorate(input), 204 | ) 205 | delattr(owner, name) 206 | 207 | return wrapper 208 | 209 | 210 | def enableFlag(flag: str) -> Callable[..., Any]: 211 | """Enable aconfig flag. 212 | 213 | Requires that the test class declares a devices: Optional[PandoraDevices] attribute. 214 | 215 | Args: 216 | flag: aconfig flag name including package, e.g.: 'com.android.bluetooth.flags.' 217 | 218 | Raises: 219 | AttributeError: when the 'devices' attribute is not found or not set 220 | TypeError: when the provided flag argument is not a string 221 | """ 222 | 223 | def decorator(func: Callable[..., Any]) -> Callable[..., Any]: 224 | @functools.wraps(func) 225 | def wrapper(self: base_test.BaseTestClass, *args: Any, **kwargs: Any) -> Any: 226 | devices = getattr(self, 'devices', None) 227 | 228 | if not devices: 229 | raise AttributeError("Attribute 'devices' not found in test class or is None") 230 | 231 | if not isinstance(devices, PandoraDevices): 232 | raise TypeError("devices attribute must be of a PandoraDevices type") 233 | 234 | for server in devices._servers: 235 | if isinstance(server, pandora_server.AndroidPandoraServer): 236 | server.device.adb.shell(['device_config override bluetooth', flag, 'true']) # type: ignore 237 | break 238 | return func(self, *args, **kwargs) 239 | 240 | return wrapper 241 | 242 | return decorator 243 | 244 | 245 | _T = TypeVar('_T') 246 | 247 | 248 | # Decorate a test function with a wrapper that catch gRPC errors 249 | # and call a callback if the status `code` match. 250 | def rpc_except( 251 | excepts: Dict[grpc.StatusCode, Callable[[grpc.aio.AioRpcError], Any]], 252 | ) -> Callable[[Callable[..., _T]], Callable[..., _T]]: 253 | def wrap(func: Callable[..., _T]) -> Callable[..., _T]: 254 | @functools.wraps(func) 255 | def wrapper(*args: Any, **kwargs: Any) -> _T: 256 | try: 257 | return func(*args, **kwargs) 258 | except (grpc.RpcError, grpc.aio.AioRpcError) as e: 259 | if f := excepts.get(e.code(), None): # type: ignore 260 | return f(e) # type: ignore 261 | raise e 262 | 263 | return wrapper 264 | 265 | return wrap 266 | 267 | 268 | def args_parser() -> argparse.ArgumentParser: 269 | parser = argparse.ArgumentParser(description='Avatar test runner.') 270 | parser.add_argument( 271 | 'input', 272 | type=str, 273 | nargs='*', 274 | metavar='', 275 | help='Lits of folder or test file to run', 276 | default=[], 277 | ) 278 | parser.add_argument('-c', '--config', type=str, metavar='', help='Path to the test configuration file.') 279 | parser.add_argument( 280 | '-l', 281 | '--list', 282 | '--list_tests', # For backward compatibility with tradefed `MoblyBinaryHostTest` 283 | action='store_true', 284 | help='Print the names of the tests defined in a script without ' 'executing them.', 285 | ) 286 | parser.add_argument( 287 | '-o', 288 | '--log-path', 289 | '--log_path', # For backward compatibility with tradefed `MoblyBinaryHostTest` 290 | type=str, 291 | metavar='', 292 | help='Path to the test configuration file.', 293 | ) 294 | parser.add_argument( 295 | '-t', 296 | '--tests', 297 | nargs='+', 298 | type=str, 299 | metavar='[ClassA[.test_a] ClassB[.test_b] ...]', 300 | help='A list of test classes and optional tests to execute.', 301 | ) 302 | parser.add_argument( 303 | '-b', 304 | '--test-beds', 305 | '--test_bed', # For backward compatibility with tradefed `MoblyBinaryHostTest` 306 | nargs='+', 307 | type=str, 308 | metavar='[ ...]', 309 | help='Specify which test beds to run tests on.', 310 | ) 311 | parser.add_argument('-v', '--verbose', action='store_true', help='Set console logger level to DEBUG') 312 | parser.add_argument('-x', '--no-default-cases', action='store_true', help='Dot no include default test cases') 313 | return parser 314 | 315 | 316 | # Avatar default entry point 317 | def main(args: Optional[argparse.Namespace] = None) -> None: 318 | import sys 319 | 320 | # Create an Avatar suite runner. 321 | runner = SuiteRunner() 322 | 323 | # Parse arguments. 324 | argv = args or args_parser().parse_args() 325 | if argv.input: 326 | for path in argv.input: 327 | runner.add_path(pathlib.Path(path)) 328 | if argv.config: 329 | runner.add_config_file(pathlib.Path(argv.config)) 330 | if argv.log_path: 331 | runner.set_logs_dir(pathlib.Path(argv.log_path)) 332 | if argv.tests: 333 | runner.add_test_filters(argv.tests) 334 | if argv.test_beds: 335 | runner.add_test_beds(argv.test_beds) 336 | if argv.verbose: 337 | runner.set_logs_verbose() 338 | if not argv.no_default_cases: 339 | runner.add_path(pathlib.Path(__file__).resolve().parent / 'cases') 340 | 341 | # List tests to standard output. 342 | if argv.list: 343 | for _, (tag, test_names) in runner.included_tests.items(): 344 | for name in test_names: 345 | print(f"{tag}.{name}") 346 | sys.exit(0) 347 | 348 | # Run the test suite. 349 | logging.basicConfig(level=logging.INFO) 350 | if not runner.run(): 351 | sys.exit(1) 352 | -------------------------------------------------------------------------------- /avatar/aio.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import asyncio 16 | import functools 17 | import threading 18 | 19 | from typing import Any, Awaitable, Callable, TypeVar 20 | 21 | _T = TypeVar('_T') 22 | 23 | 24 | # Keep running an event loop is a separate thread, 25 | # which is then used to: 26 | # * Schedule Bumble(s) IO & gRPC server. 27 | # * Schedule asynchronous tests. 28 | loop = asyncio.new_event_loop() 29 | 30 | 31 | def thread_loop() -> None: 32 | loop.run_forever() 33 | loop.run_until_complete(loop.shutdown_asyncgens()) 34 | 35 | 36 | thread = threading.Thread(target=thread_loop, daemon=True) 37 | thread.start() 38 | 39 | 40 | # run coroutine into our loop until complete 41 | def run_until_complete(coro: Awaitable[_T]) -> _T: 42 | return asyncio.run_coroutine_threadsafe(coro, loop).result() 43 | 44 | 45 | # Convert an asynchronous function to a synchronous one by 46 | # executing it's code within our loop 47 | def asynchronous(func: Callable[..., Awaitable[_T]]) -> Callable[..., _T]: 48 | @functools.wraps(func) 49 | def wrapper(*args: Any, **kwargs: Any) -> _T: 50 | return run_until_complete(func(*args, **kwargs)) 51 | 52 | return wrapper 53 | -------------------------------------------------------------------------------- /avatar/cases/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | -------------------------------------------------------------------------------- /avatar/cases/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | TestBeds: 4 | - Name: android.bumbles 5 | Controllers: 6 | AndroidDevice: '*' 7 | BumbleDevice: 8 | - transport: 'tcp-client:127.0.0.1:6211' 9 | - transport: 'tcp-client:127.0.0.1:6211' 10 | - Name: bumble.bumbles 11 | Controllers: 12 | BumbleDevice: 13 | - transport: 'tcp-client:127.0.0.1:6402' 14 | - transport: 'tcp-client:127.0.0.1:6402' 15 | - transport: 'tcp-client:127.0.0.1:6402' 16 | -------------------------------------------------------------------------------- /avatar/cases/host_test.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import asyncio 16 | import avatar 17 | import grpc 18 | import logging 19 | 20 | from avatar import BumblePandoraDevice 21 | from avatar import PandoraDevice 22 | from avatar import PandoraDevices 23 | from mobly import base_test 24 | from mobly import signals 25 | from mobly import test_runner 26 | from mobly.asserts import assert_equal # type: ignore 27 | from mobly.asserts import assert_false # type: ignore 28 | from mobly.asserts import assert_is_none # type: ignore 29 | from mobly.asserts import assert_is_not_none # type: ignore 30 | from mobly.asserts import assert_true # type: ignore 31 | from mobly.asserts import explicit_pass # type: ignore 32 | from pandora.host_pb2 import DISCOVERABLE_GENERAL 33 | from pandora.host_pb2 import DISCOVERABLE_LIMITED 34 | from pandora.host_pb2 import NOT_DISCOVERABLE 35 | from pandora.host_pb2 import Connection 36 | from pandora.host_pb2 import DiscoverabilityMode 37 | from typing import Optional 38 | 39 | 40 | class HostTest(base_test.BaseTestClass): # type: ignore[misc] 41 | devices: Optional[PandoraDevices] = None 42 | 43 | # pandora devices. 44 | dut: PandoraDevice 45 | ref: PandoraDevice 46 | 47 | def setup_class(self) -> None: 48 | self.devices = PandoraDevices(self) 49 | self.dut, self.ref, *_ = self.devices 50 | 51 | # Enable BR/EDR mode for Bumble devices. 52 | for device in self.devices: 53 | if isinstance(device, BumblePandoraDevice): 54 | device.config.setdefault('classic_enabled', True) 55 | 56 | def teardown_class(self) -> None: 57 | if self.devices: 58 | self.devices.stop_all() 59 | 60 | @avatar.asynchronous 61 | async def setup_test(self) -> None: # pytype: disable=wrong-arg-types 62 | await asyncio.gather(self.dut.reset(), self.ref.reset()) 63 | 64 | @avatar.parameterized( 65 | (DISCOVERABLE_LIMITED,), 66 | (DISCOVERABLE_GENERAL,), 67 | ) # type: ignore[misc] 68 | def test_discoverable(self, mode: DiscoverabilityMode) -> None: 69 | self.dut.host.SetDiscoverabilityMode(mode=mode) 70 | inquiry = self.ref.host.Inquiry(timeout=15.0) 71 | try: 72 | assert_is_not_none(next((x for x in inquiry if x.address == self.dut.address), None)) 73 | finally: 74 | inquiry.cancel() 75 | 76 | # This test should reach the `Inquiry` timeout. 77 | @avatar.rpc_except( 78 | { 79 | grpc.StatusCode.DEADLINE_EXCEEDED: lambda e: explicit_pass(e.details()), 80 | } 81 | ) 82 | def test_not_discoverable(self) -> None: 83 | self.dut.host.SetDiscoverabilityMode(mode=NOT_DISCOVERABLE) 84 | inquiry = self.ref.host.Inquiry(timeout=3.0) 85 | try: 86 | assert_is_none(next((x for x in inquiry if x.address == self.dut.address), None)) 87 | finally: 88 | inquiry.cancel() 89 | 90 | @avatar.asynchronous 91 | async def test_connect(self) -> None: 92 | if self.dut.name == 'android': 93 | raise signals.TestSkip('TODO: Android connection is too flaky (b/285634621)') 94 | ref_dut_res, dut_ref_res = await asyncio.gather( 95 | self.ref.aio.host.WaitConnection(address=self.dut.address), 96 | self.dut.aio.host.Connect(address=self.ref.address), 97 | ) 98 | assert_is_not_none(ref_dut_res.connection) 99 | assert_is_not_none(dut_ref_res.connection) 100 | ref_dut, dut_ref = ref_dut_res.connection, dut_ref_res.connection 101 | assert ref_dut and dut_ref 102 | assert_true(await self.is_connected(self.ref, ref_dut), "") 103 | 104 | @avatar.asynchronous 105 | async def test_accept(self) -> None: 106 | dut_ref_res, ref_dut_res = await asyncio.gather( 107 | self.dut.aio.host.WaitConnection(address=self.ref.address), 108 | self.ref.aio.host.Connect(address=self.dut.address), 109 | ) 110 | assert_is_not_none(ref_dut_res.connection) 111 | assert_is_not_none(dut_ref_res.connection) 112 | ref_dut, dut_ref = ref_dut_res.connection, dut_ref_res.connection 113 | assert ref_dut and dut_ref 114 | assert_true(await self.is_connected(self.ref, ref_dut), "") 115 | 116 | @avatar.asynchronous 117 | async def test_disconnect(self) -> None: 118 | if self.dut.name == 'android': 119 | raise signals.TestSkip('TODO: Android disconnection is too flaky (b/286081956)') 120 | dut_ref_res, ref_dut_res = await asyncio.gather( 121 | self.dut.aio.host.WaitConnection(address=self.ref.address), 122 | self.ref.aio.host.Connect(address=self.dut.address), 123 | ) 124 | assert_is_not_none(ref_dut_res.connection) 125 | assert_is_not_none(dut_ref_res.connection) 126 | ref_dut, dut_ref = ref_dut_res.connection, dut_ref_res.connection 127 | assert ref_dut and dut_ref 128 | await self.dut.aio.host.Disconnect(connection=dut_ref) 129 | assert_false(await self.is_connected(self.ref, ref_dut), "") 130 | 131 | async def is_connected(self, device: PandoraDevice, connection: Connection) -> bool: 132 | try: 133 | await device.aio.host.WaitDisconnection(connection=connection, timeout=5) 134 | return False 135 | except grpc.RpcError as e: 136 | assert_equal(e.code(), grpc.StatusCode.DEADLINE_EXCEEDED) # type: ignore 137 | return True 138 | 139 | 140 | if __name__ == '__main__': 141 | logging.basicConfig(level=logging.DEBUG) 142 | test_runner.main() # type: ignore 143 | -------------------------------------------------------------------------------- /avatar/cases/le_host_test.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import asyncio 16 | import avatar 17 | import enum 18 | import grpc 19 | import itertools 20 | import logging 21 | import random 22 | 23 | from avatar import BumblePandoraDevice 24 | from avatar import PandoraDevice 25 | from avatar import PandoraDevices 26 | from mobly import base_test 27 | from mobly import test_runner 28 | from mobly.asserts import assert_equal # type: ignore 29 | from mobly.asserts import assert_false # type: ignore 30 | from mobly.asserts import assert_is_not_none # type: ignore 31 | from mobly.asserts import assert_true # type: ignore 32 | from mobly.asserts import explicit_pass # type: ignore 33 | from pandora.host_pb2 import PRIMARY_1M 34 | from pandora.host_pb2 import PRIMARY_CODED 35 | from pandora.host_pb2 import PUBLIC 36 | from pandora.host_pb2 import RANDOM 37 | from pandora.host_pb2 import SECONDARY_1M 38 | from pandora.host_pb2 import SECONDARY_CODED 39 | from pandora.host_pb2 import Connection 40 | from pandora.host_pb2 import DataTypes 41 | from pandora.host_pb2 import OwnAddressType 42 | from pandora.host_pb2 import PrimaryPhy 43 | from typing import Any, Dict, Literal, Optional, Union 44 | 45 | 46 | class AdvertisingEventProperties(enum.IntEnum): 47 | ADV_IND = 0x13 48 | ADV_DIRECT_IND = 0x15 49 | ADV_SCAN_IND = 0x12 50 | ADV_NONCONN_IND = 0x10 51 | 52 | CONNECTABLE = 0x01 53 | SCANNABLE = 0x02 54 | DIRECTED = 0x04 55 | LEGACY = 0x10 56 | ANONYMOUS = 0x20 57 | 58 | 59 | class LeHostTest(base_test.BaseTestClass): # type: ignore[misc] 60 | devices: Optional[PandoraDevices] = None 61 | 62 | # pandora devices. 63 | dut: PandoraDevice 64 | ref: PandoraDevice 65 | 66 | scan_timeout: float 67 | 68 | def setup_class(self) -> None: 69 | self.devices = PandoraDevices(self) 70 | self.dut, self.ref, *_ = self.devices 71 | self.scan_timeout = float(self.user_params.get('scan_timeout') or 15.0) # type: ignore 72 | 73 | # Enable BR/EDR mode for Bumble devices. 74 | for device in self.devices: 75 | if isinstance(device, BumblePandoraDevice): 76 | device.config.setdefault('classic_enabled', True) 77 | 78 | def teardown_class(self) -> None: 79 | if self.devices: 80 | self.devices.stop_all() 81 | 82 | @avatar.asynchronous 83 | async def setup_test(self) -> None: # pytype: disable=wrong-arg-types 84 | await asyncio.gather(self.dut.reset(), self.ref.reset()) 85 | 86 | @avatar.parameterized( 87 | *itertools.product( 88 | ('connectable', 'non_connectable'), 89 | ('scannable', 'non_scannable'), 90 | ('directed', 'undirected'), 91 | (0, 31), 92 | ) 93 | ) # type: ignore[misc] 94 | def test_scan( 95 | self, 96 | connectable: Union[Literal['connectable'], Literal['non_connectable']], 97 | scannable: Union[Literal['scannable'], Literal['non_scannable']], 98 | directed: Union[Literal['directed'], Literal['undirected']], 99 | data_len: int, 100 | ) -> None: 101 | ''' 102 | Advertise from the REF device with the specified legacy advertising 103 | event properties. Use the manufacturer specific data to pad the advertising data to the 104 | desired length. The scan response data must always be provided when 105 | scannable but it is defaulted. 106 | ''' 107 | man_specific_data_length = max(0, data_len - 5) # Flags (3) + LV (2) 108 | man_specific_data = bytes([random.randint(1, 255) for _ in range(man_specific_data_length)]) 109 | data = DataTypes(manufacturer_specific_data=man_specific_data) if data_len > 0 else None 110 | 111 | is_connectable = True if connectable == 'connectable' else False 112 | scan_response_data = DataTypes() if scannable == 'scannable' else None 113 | target = self.dut.address if directed == 'directed' else None 114 | 115 | advertise = self.ref.host.Advertise( 116 | legacy=True, 117 | connectable=is_connectable, 118 | data=data, # type: ignore[arg-type] 119 | scan_response_data=scan_response_data, # type: ignore[arg-type] 120 | public=target, 121 | own_address_type=PUBLIC, 122 | ) 123 | 124 | scan = self.dut.host.Scan(legacy=False, passive=False, timeout=self.scan_timeout) 125 | try: 126 | report = next((x for x in scan if x.public == self.ref.address)) 127 | 128 | # TODO: scannable is not set by the android server 129 | # TODO: direct_address is not set by the android server 130 | assert_true(report.legacy, msg='expected legacy advertising report') 131 | assert_equal(report.connectable, is_connectable or directed == 'directed') 132 | assert_equal( 133 | report.data.manufacturer_specific_data, man_specific_data if directed == 'undirected' else b'' 134 | ) 135 | assert_false(report.truncated, msg='expected non-truncated advertising report') 136 | except grpc.aio.AioRpcError as e: 137 | if ( 138 | e.code() == grpc.StatusCode.DEADLINE_EXCEEDED 139 | and scannable == 'non_scannable' 140 | and directed == 'undirected' 141 | ): 142 | explicit_pass('') 143 | raise e 144 | finally: 145 | scan.cancel() 146 | advertise.cancel() 147 | 148 | @avatar.parameterized( 149 | *itertools.product( 150 | # The advertisement cannot be both connectable and scannable. 151 | ('connectable', 'non_connectable', 'non_connectable_scannable'), 152 | ('directed', 'undirected'), 153 | # Bumble does not send multiple HCI commands, so it must also fit in 154 | # 1 HCI command (max length 251 minus overhead). 155 | (0, 150), 156 | (PRIMARY_1M, PRIMARY_CODED), 157 | ), 158 | ) # type: ignore[misc] 159 | def test_extended_scan( 160 | self, 161 | connectable_scannable: Union[ 162 | Literal['connectable'], Literal['non_connectable'], Literal['non_connectable_scannable'] 163 | ], 164 | directed: Union[Literal['directed'], Literal['undirected']], 165 | data_len: int, 166 | primary_phy: PrimaryPhy, 167 | ) -> None: 168 | ''' 169 | Advertise from the REF device with the specified extended advertising 170 | event properties. Use the manufacturer specific data to pad the advertising data to the 171 | desired length. The scan response data must always be provided when 172 | scannable. 173 | ''' 174 | man_specific_data_length = max(0, data_len - 5) # Flags (3) + LV (2) 175 | man_specific_data = bytes([random.randint(1, 255) for _ in range(man_specific_data_length)]) 176 | data = DataTypes(manufacturer_specific_data=man_specific_data) if data_len > 0 else None 177 | scan_response_data = None 178 | # Extended advertisements with advertising data cannot also have 179 | # scan response data. 180 | if connectable_scannable == 'non_connectable_scannable': 181 | scan_response_data = data 182 | data = None 183 | 184 | is_connectable = True if connectable_scannable == 'connectable' else False 185 | target = self.dut.address if directed == 'directed' else None 186 | 187 | # For a better test, make the secondary phy the same as the primary to 188 | # avoid the scan just scanning the 1M advertisement when the primary 189 | # phy is CODED. 190 | secondary_phy = SECONDARY_1M 191 | if primary_phy == PRIMARY_CODED: 192 | secondary_phy = SECONDARY_CODED 193 | 194 | advertise = self.ref.host.Advertise( 195 | legacy=False, 196 | connectable=is_connectable, 197 | data=data, # type: ignore[arg-type] 198 | scan_response_data=scan_response_data, # type: ignore[arg-type] 199 | public=target, 200 | own_address_type=PUBLIC, 201 | primary_phy=primary_phy, 202 | secondary_phy=secondary_phy, 203 | ) 204 | 205 | scan = self.dut.host.Scan( 206 | legacy=False, 207 | passive=False, 208 | timeout=self.scan_timeout, 209 | phys=[primary_phy], 210 | ) 211 | try: 212 | report = next((x for x in scan if x.public == self.ref.address)) 213 | 214 | # TODO: scannable is not set by the android server 215 | # TODO: direct_address is not set by the android server 216 | assert_false(report.legacy, msg='expected extended advertising report') 217 | assert_equal(report.connectable, is_connectable) 218 | assert_equal(report.data.manufacturer_specific_data, man_specific_data) 219 | assert_false(report.truncated, msg='expected non-truncated advertising report') 220 | assert_equal(report.primary_phy, primary_phy) 221 | except grpc.aio.AioRpcError as e: 222 | raise e 223 | finally: 224 | scan.cancel() 225 | advertise.cancel() 226 | 227 | @avatar.parameterized( 228 | (dict(incomplete_service_class_uuids16=["183A", "181F"]),), 229 | (dict(incomplete_service_class_uuids32=["FFFF183A", "FFFF181F"]),), 230 | (dict(incomplete_service_class_uuids128=["FFFF181F-FFFF-1000-8000-00805F9B34FB"]),), 231 | (dict(shortened_local_name="avatar"),), 232 | (dict(complete_local_name="avatar_the_last_test_blender"),), 233 | (dict(tx_power_level=20),), 234 | (dict(class_of_device=0x40680),), 235 | (dict(peripheral_connection_interval_min=0x0006, peripheral_connection_interval_max=0x0C80),), 236 | (dict(service_solicitation_uuids16=["183A", "181F"]),), 237 | (dict(service_solicitation_uuids32=["FFFF183A", "FFFF181F"]),), 238 | (dict(service_solicitation_uuids128=["FFFF183A-FFFF-1000-8000-00805F9B34FB"]),), 239 | (dict(service_data_uuid16={"183A": bytes([1, 2, 3, 4])}),), 240 | (dict(service_data_uuid32={"FFFF183A": bytes([1, 2, 3, 4])}),), 241 | (dict(service_data_uuid128={"FFFF181F-FFFF-1000-8000-00805F9B34FB": bytes([1, 2, 3, 4])}),), 242 | (dict(appearance=0x0591),), 243 | (dict(advertising_interval=0x1000),), 244 | (dict(uri="https://www.google.com"),), 245 | (dict(le_supported_features=bytes([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x10, 0x9F])),), 246 | (dict(manufacturer_specific_data=bytes([0, 1, 2, 3, 4])),), 247 | ) # type: ignore[misc] 248 | def test_scan_response_data(self, data: Dict[str, Any]) -> None: 249 | ''' 250 | Advertise from the REF device with the specified advertising data. 251 | Validate that the REF generates the correct advertising data, 252 | and that the dut presents the correct advertising data in the scan 253 | result. 254 | ''' 255 | advertise = self.ref.host.Advertise( 256 | legacy=True, 257 | connectable=True, 258 | data=DataTypes(**data), 259 | own_address_type=PUBLIC, 260 | ) 261 | 262 | scan = self.dut.host.Scan(legacy=False, passive=False, timeout=self.scan_timeout) 263 | report = next((x for x in scan if x.public == self.ref.address)) 264 | 265 | scan.cancel() 266 | advertise.cancel() 267 | 268 | assert_true(report.legacy, msg='expected legacy advertising report') 269 | assert_equal(report.connectable, True) 270 | for key, value in data.items(): 271 | assert_equal(getattr(report.data, key), value) # type: ignore[misc] 272 | assert_false(report.truncated, msg='expected non-truncated advertising report') 273 | 274 | @avatar.parameterized( 275 | (RANDOM,), 276 | (PUBLIC,), 277 | ) # type: ignore[misc] 278 | @avatar.asynchronous 279 | async def test_connect(self, ref_address_type: OwnAddressType) -> None: 280 | advertise = self.ref.aio.host.Advertise( 281 | legacy=True, 282 | connectable=True, 283 | own_address_type=ref_address_type, 284 | data=DataTypes(manufacturer_specific_data=b'pause cafe'), 285 | ) 286 | 287 | scan = self.dut.aio.host.Scan(own_address_type=RANDOM, timeout=self.scan_timeout) 288 | ref = await anext((x async for x in scan if x.data.manufacturer_specific_data == b'pause cafe')) 289 | scan.cancel() 290 | 291 | ref_dut_res, dut_ref_res = await asyncio.gather( 292 | anext(aiter(advertise)), 293 | self.dut.aio.host.ConnectLE(**ref.address_asdict(), own_address_type=RANDOM, timeout=self.scan_timeout), 294 | ) 295 | assert_equal(dut_ref_res.result_variant(), 'connection') 296 | dut_ref, ref_dut = dut_ref_res.connection, ref_dut_res.connection 297 | assert_is_not_none(dut_ref) 298 | assert dut_ref 299 | advertise.cancel() 300 | assert_true(await self.is_connected(self.ref, ref_dut), "") 301 | 302 | @avatar.parameterized( 303 | (RANDOM,), 304 | (PUBLIC,), 305 | ) # type: ignore[misc] 306 | @avatar.asynchronous 307 | async def test_disconnect(self, ref_address_type: OwnAddressType) -> None: 308 | advertise = self.ref.aio.host.Advertise( 309 | legacy=True, 310 | connectable=True, 311 | own_address_type=ref_address_type, 312 | data=DataTypes(manufacturer_specific_data=b'pause cafe'), 313 | ) 314 | 315 | scan = self.dut.aio.host.Scan(own_address_type=RANDOM, timeout=self.scan_timeout) 316 | ref = await anext((x async for x in scan if x.data.manufacturer_specific_data == b'pause cafe')) 317 | scan.cancel() 318 | 319 | ref_dut_res, dut_ref_res = await asyncio.gather( 320 | anext(aiter(advertise)), 321 | self.dut.aio.host.ConnectLE(**ref.address_asdict(), own_address_type=RANDOM, timeout=self.scan_timeout), 322 | ) 323 | assert_equal(dut_ref_res.result_variant(), 'connection') 324 | dut_ref, ref_dut = dut_ref_res.connection, ref_dut_res.connection 325 | assert_is_not_none(dut_ref) 326 | assert dut_ref 327 | advertise.cancel() 328 | assert_true(await self.is_connected(self.ref, ref_dut), "") 329 | await self.dut.aio.host.Disconnect(connection=dut_ref) 330 | assert_false(await self.is_connected(self.ref, ref_dut), "") 331 | 332 | async def is_connected(self, device: PandoraDevice, connection: Connection) -> bool: 333 | try: 334 | await device.aio.host.WaitDisconnection(connection=connection, timeout=5) 335 | return False 336 | except grpc.RpcError as e: 337 | assert_equal(e.code(), grpc.StatusCode.DEADLINE_EXCEEDED) # type: ignore 338 | return True 339 | 340 | 341 | if __name__ == '__main__': 342 | logging.basicConfig(level=logging.DEBUG) 343 | test_runner.main() # type: ignore 344 | -------------------------------------------------------------------------------- /avatar/cases/le_security_test.py: -------------------------------------------------------------------------------- 1 | # Copyright 2023 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import asyncio 16 | import avatar 17 | import itertools 18 | import logging 19 | 20 | from avatar import BumblePandoraDevice 21 | from avatar import PandoraDevice 22 | from avatar import PandoraDevices 23 | from avatar import pandora_snippet 24 | from bumble.pairing import PairingConfig 25 | from bumble.pairing import PairingDelegate 26 | from mobly import base_test 27 | from mobly import signals 28 | from mobly import test_runner 29 | from mobly.asserts import assert_equal # type: ignore 30 | from mobly.asserts import assert_in # type: ignore 31 | from mobly.asserts import assert_is_not_none # type: ignore 32 | from mobly.asserts import fail # type: ignore 33 | from pandora.host_pb2 import PUBLIC 34 | from pandora.host_pb2 import RANDOM 35 | from pandora.host_pb2 import Connection 36 | from pandora.host_pb2 import DataTypes 37 | from pandora.host_pb2 import OwnAddressType 38 | from pandora.security_pb2 import LE_LEVEL3 39 | from pandora.security_pb2 import LEVEL2 40 | from pandora.security_pb2 import PairingEventAnswer 41 | from pandora.security_pb2 import SecureResponse 42 | from pandora.security_pb2 import WaitSecurityResponse 43 | from typing import Any, Literal, Optional, Tuple, Union 44 | 45 | 46 | class LeSecurityTest(base_test.BaseTestClass): # type: ignore[misc] 47 | ''' 48 | This class aim to test LE Pairing on LE 49 | Bluetooth devices. 50 | ''' 51 | 52 | devices: Optional[PandoraDevices] = None 53 | 54 | # pandora devices. 55 | dut: PandoraDevice 56 | ref: PandoraDevice 57 | 58 | def setup_class(self) -> None: 59 | self.devices = PandoraDevices(self) 60 | self.dut, self.ref, *_ = self.devices 61 | 62 | # Enable BR/EDR for Bumble devices. 63 | for device in self.devices: 64 | if isinstance(device, BumblePandoraDevice): 65 | device.config.setdefault('classic_enabled', True) 66 | 67 | def teardown_class(self) -> None: 68 | if self.devices: 69 | self.devices.stop_all() 70 | 71 | @avatar.parameterized( 72 | *itertools.product( 73 | ('outgoing_connection', 'incoming_connection'), 74 | ('outgoing_pairing', 'incoming_pairing'), 75 | ('against_random', 'against_public'), 76 | ( 77 | 'accept', 78 | 'reject', 79 | 'rejected', 80 | 'disconnect', 81 | 'disconnected', 82 | ), 83 | ( 84 | 'against_default_io_cap', 85 | 'against_no_output_no_input', 86 | 'against_keyboard_only', 87 | 'against_display_only', 88 | 'against_display_yes_no', 89 | 'against_both_display_and_keyboard', 90 | ), 91 | ( 92 | 'ltk_irk_csrk', 93 | 'ltk_irk_csrk_lk', 94 | ), 95 | ) 96 | ) # type: ignore[misc] 97 | @avatar.asynchronous 98 | async def test_le_pairing( 99 | self, 100 | connect: Union[Literal['outgoing_connection'], Literal['incoming_connection']], 101 | pair: Union[Literal['outgoing_pairing'], Literal['incoming_pairing']], 102 | ref_address_type_name: Union[Literal['against_random'], Literal['against_public']], 103 | variant: Union[ 104 | Literal['accept'], 105 | Literal['accept_ctkd'], 106 | Literal['reject'], 107 | Literal['rejected'], 108 | Literal['disconnect'], 109 | Literal['disconnected'], 110 | ], 111 | ref_io_capability: Union[ 112 | Literal['against_default_io_cap'], 113 | Literal['against_no_output_no_input'], 114 | Literal['against_keyboard_only'], 115 | Literal['against_display_only'], 116 | Literal['against_display_yes_no'], 117 | Literal['against_both_display_and_keyboard'], 118 | ], 119 | key_distribution: Union[ 120 | Literal['ltk_irk_csrk'], 121 | Literal['ltk_irk_csrk_lk'], 122 | ], 123 | ) -> None: 124 | await self.perform_test_le_pairing( 125 | connect, 126 | pair, 127 | ref_address_type_name, 128 | variant, 129 | ref_io_capability, 130 | key_distribution, 131 | ) 132 | 133 | async def perform_test_le_pairing( 134 | self, 135 | connect: Union[Literal['outgoing_connection'], Literal['incoming_connection']], 136 | pair: Union[Literal['outgoing_pairing'], Literal['incoming_pairing']], 137 | ref_address_type_name: Union[Literal['against_random'], Literal['against_public']], 138 | variant: Union[ 139 | Literal['accept'], 140 | Literal['accept_ctkd'], 141 | Literal['reject'], 142 | Literal['rejected'], 143 | Literal['disconnect'], 144 | Literal['disconnected'], 145 | ], 146 | ref_io_capability: Union[ 147 | Literal['against_default_io_cap'], 148 | Literal['against_no_output_no_input'], 149 | Literal['against_keyboard_only'], 150 | Literal['against_display_only'], 151 | Literal['against_display_yes_no'], 152 | Literal['against_both_display_and_keyboard'], 153 | ], 154 | key_distribution: Union[ 155 | Literal['ltk_irk_csrk'], 156 | Literal['ltk_irk_csrk_lk'], 157 | ], 158 | ) -> None: 159 | '''This function is a default implementation which could be overriden if needed''' 160 | 161 | if self.dut.name == 'android' and connect == 'outgoing_connection' and pair == 'incoming_pairing': 162 | # TODO: do not skip when doing physical tests. 163 | raise signals.TestSkip('TODO: Yet to implement the test cases:\n') 164 | 165 | if self.dut.name == 'android' and connect == 'incoming_connection' and pair == 'outgoing_pairing': 166 | # TODO: do not skip when doing physical tests. 167 | raise signals.TestSkip('TODO: Yet to implement the test cases:\n') 168 | 169 | if self.dut.name == 'android' and 'disconnect' in variant: 170 | raise signals.TestSkip( 171 | 'TODO: Fix AOSP pandora server for this variant:\n' 172 | + '- Looks like `Disconnect` never complete.\n' 173 | + '- When disconnected the `Secure/WaitSecurity` never returns.' 174 | ) 175 | 176 | if self.dut.name == 'android' and 'reject' in variant: 177 | raise signals.TestSkip('TODO: Currently these scnearios are not working. Working on them.') 178 | 179 | if self.ref.name == 'android' and ref_address_type_name == 'against_public': 180 | raise signals.TestSkip('Android does not support PUBLIC address type.') 181 | 182 | if isinstance(self.ref, BumblePandoraDevice) and ref_io_capability == 'against_default_io_cap': 183 | raise signals.TestSkip('Skip default IO cap for Bumble REF.') 184 | 185 | if not isinstance(self.ref, BumblePandoraDevice) and ref_io_capability != 'against_default_io_cap': 186 | raise signals.TestSkip('Unable to override IO capability on non Bumble device.') 187 | 188 | if 'lk' in key_distribution and ref_io_capability == 'against_no_output_no_input': 189 | raise signals.TestSkip('CTKD requires Security Level 4') 190 | 191 | # Factory reset both DUT and REF devices. 192 | await asyncio.gather(self.dut.reset(), self.ref.reset()) 193 | 194 | # Override REF IO capability if supported. 195 | if isinstance(self.ref, BumblePandoraDevice): 196 | io_capability = { 197 | 'against_no_output_no_input': PairingDelegate.IoCapability.NO_OUTPUT_NO_INPUT, 198 | 'against_keyboard_only': PairingDelegate.IoCapability.KEYBOARD_INPUT_ONLY, 199 | 'against_display_only': PairingDelegate.IoCapability.DISPLAY_OUTPUT_ONLY, 200 | 'against_display_yes_no': PairingDelegate.IoCapability.DISPLAY_OUTPUT_AND_YES_NO_INPUT, 201 | 'against_both_display_and_keyboard': PairingDelegate.IoCapability.DISPLAY_OUTPUT_AND_KEYBOARD_INPUT, 202 | }[ref_io_capability] 203 | self.ref.server_config.io_capability = io_capability 204 | bumble_key_distribution = sum( 205 | { 206 | 'ltk': PairingDelegate.KeyDistribution.DISTRIBUTE_ENCRYPTION_KEY, 207 | 'irk': PairingDelegate.KeyDistribution.DISTRIBUTE_IDENTITY_KEY, 208 | 'csrk': PairingDelegate.KeyDistribution.DISTRIBUTE_SIGNING_KEY, 209 | 'lk': PairingDelegate.KeyDistribution.DISTRIBUTE_LINK_KEY, 210 | }[x] 211 | for x in key_distribution.split('_') 212 | ) 213 | assert bumble_key_distribution 214 | self.ref.server_config.smp_local_initiator_key_distribution = bumble_key_distribution 215 | self.ref.server_config.smp_local_responder_key_distribution = bumble_key_distribution 216 | self.ref.server_config.identity_address_type = PairingConfig.AddressType.PUBLIC 217 | 218 | if isinstance(self.dut, BumblePandoraDevice): 219 | ALL_KEYS = ( 220 | PairingDelegate.KeyDistribution.DISTRIBUTE_ENCRYPTION_KEY 221 | | PairingDelegate.KeyDistribution.DISTRIBUTE_IDENTITY_KEY 222 | | PairingDelegate.KeyDistribution.DISTRIBUTE_SIGNING_KEY 223 | | PairingDelegate.KeyDistribution.DISTRIBUTE_LINK_KEY 224 | ) 225 | self.dut.server_config.smp_local_initiator_key_distribution = ALL_KEYS 226 | self.dut.server_config.smp_local_responder_key_distribution = ALL_KEYS 227 | self.dut.server_config.identity_address_type = PairingConfig.AddressType.PUBLIC 228 | 229 | dut_address_type = RANDOM 230 | ref_address_type = { 231 | 'against_random': RANDOM, 232 | 'against_public': PUBLIC, 233 | }[ref_address_type_name] 234 | 235 | # Pandora connection tokens 236 | ref_dut, dut_ref = None, None 237 | 238 | # Connection/pairing task. 239 | async def connect_and_pair() -> Tuple[SecureResponse, WaitSecurityResponse]: 240 | nonlocal ref_dut 241 | nonlocal dut_ref 242 | 243 | # Make LE connection task. 244 | async def connect_le( 245 | initiator: PandoraDevice, 246 | acceptor: PandoraDevice, 247 | initiator_addr_type: OwnAddressType, 248 | acceptor_addr_type: OwnAddressType, 249 | ) -> Tuple[Connection, Connection]: 250 | # Acceptor - Advertise 251 | advertisement = acceptor.aio.host.Advertise( 252 | legacy=True, 253 | connectable=True, 254 | own_address_type=acceptor_addr_type, 255 | data=DataTypes(manufacturer_specific_data=b'pause cafe'), 256 | ) 257 | 258 | # Initiator - Scan and fetch the address 259 | scan = initiator.aio.host.Scan(own_address_type=initiator_addr_type) 260 | acceptor_scan = await anext( 261 | (x async for x in scan if b'pause cafe' in x.data.manufacturer_specific_data) 262 | ) # pytype: disable=name-error 263 | scan.cancel() 264 | 265 | # Initiator - LE connect 266 | return await pandora_snippet.connect_le(initiator, advertisement, acceptor_scan, initiator_addr_type) 267 | 268 | # Make LE connection. 269 | if connect == 'incoming_connection': 270 | # DUT is acceptor 271 | ref_dut, dut_ref = await connect_le(self.ref, self.dut, ref_address_type, dut_address_type) 272 | else: 273 | # DUT is initiator 274 | dut_ref, ref_dut = await connect_le(self.dut, self.ref, dut_address_type, ref_address_type) 275 | 276 | # Pairing. 277 | 278 | if pair == 'incoming_pairing': 279 | return await asyncio.gather( 280 | self.ref.aio.security.Secure(connection=ref_dut, le=LE_LEVEL3), 281 | self.dut.aio.security.WaitSecurity(connection=dut_ref, le=LE_LEVEL3), 282 | ) 283 | # Outgoing pairing 284 | return await asyncio.gather( 285 | self.dut.aio.security.Secure(connection=dut_ref, le=LE_LEVEL3), 286 | self.ref.aio.security.WaitSecurity(connection=ref_dut, le=LE_LEVEL3), 287 | ) 288 | 289 | # Listen for pairing event on bot DUT and REF. 290 | dut_pairing, ref_pairing = self.dut.aio.security.OnPairing(), self.ref.aio.security.OnPairing() 291 | 292 | # Start connection/pairing. 293 | connect_and_pair_task = asyncio.create_task(connect_and_pair()) 294 | 295 | shall_pass = variant == 'accept' 296 | 297 | try: 298 | dut_pairing_fut = asyncio.create_task(anext(dut_pairing)) 299 | ref_pairing_fut = asyncio.create_task(anext(ref_pairing)) 300 | 301 | def on_done(_: Any) -> None: 302 | if not dut_pairing_fut.done(): 303 | dut_pairing_fut.cancel() 304 | if not ref_pairing_fut.done(): 305 | ref_pairing_fut.cancel() 306 | 307 | connect_and_pair_task.add_done_callback(on_done) 308 | 309 | ref_ev = await asyncio.wait_for(ref_pairing_fut, timeout=15.0) 310 | self.ref.log.info(f'REF pairing event: {ref_ev.method_variant()}') 311 | 312 | dut_ev_answer, ref_ev_answer = None, None 313 | if not connect_and_pair_task.done(): 314 | dut_ev = await asyncio.wait_for(dut_pairing_fut, timeout=15.0) 315 | self.dut.log.info(f'DUT pairing event: {dut_ev.method_variant()}') 316 | 317 | if dut_ev.method_variant() in ('numeric_comparison', 'just_works'): 318 | assert_in(ref_ev.method_variant(), ('numeric_comparison', 'just_works')) 319 | 320 | confirm = True 321 | if ( 322 | dut_ev.method_variant() == 'numeric_comparison' 323 | and ref_ev.method_variant() == 'numeric_comparison' 324 | ): 325 | confirm = ref_ev.numeric_comparison == dut_ev.numeric_comparison 326 | 327 | dut_ev_answer = PairingEventAnswer(event=dut_ev, confirm=False if variant == 'reject' else confirm) 328 | ref_ev_answer = PairingEventAnswer( 329 | event=ref_ev, confirm=False if variant == 'rejected' else confirm 330 | ) 331 | 332 | elif dut_ev.method_variant() == 'passkey_entry_notification': 333 | assert_equal(ref_ev.method_variant(), 'passkey_entry_request') 334 | assert_is_not_none(dut_ev.passkey_entry_notification) 335 | assert dut_ev.passkey_entry_notification is not None 336 | 337 | if variant == 'reject': 338 | # DUT cannot reject, pairing shall pass. 339 | shall_pass = True 340 | 341 | ref_ev_answer = PairingEventAnswer( 342 | event=ref_ev, 343 | passkey=None if variant == 'rejected' else dut_ev.passkey_entry_notification, 344 | ) 345 | 346 | elif dut_ev.method_variant() == 'passkey_entry_request': 347 | assert_equal(ref_ev.method_variant(), 'passkey_entry_notification') 348 | assert_is_not_none(ref_ev.passkey_entry_notification) 349 | 350 | if variant == 'rejected': 351 | # REF cannot reject, pairing shall pass. 352 | shall_pass = True 353 | 354 | assert ref_ev.passkey_entry_notification is not None 355 | dut_ev_answer = PairingEventAnswer( 356 | event=dut_ev, 357 | passkey=None if variant == 'reject' else ref_ev.passkey_entry_notification, 358 | ) 359 | 360 | else: 361 | fail("") 362 | 363 | if variant == 'disconnect': 364 | # Disconnect: 365 | # - REF respond to pairing event if any. 366 | # - DUT trigger disconnect. 367 | if ref_ev_answer is not None: 368 | ref_pairing.send_nowait(ref_ev_answer) 369 | assert dut_ref is not None 370 | await self.dut.aio.host.Disconnect(connection=dut_ref) 371 | 372 | elif variant == 'disconnected': 373 | # Disconnected: 374 | # - DUT respond to pairing event if any. 375 | # - REF trigger disconnect. 376 | if dut_ev_answer is not None: 377 | dut_pairing.send_nowait(dut_ev_answer) 378 | assert ref_dut is not None 379 | await self.ref.aio.host.Disconnect(connection=ref_dut) 380 | 381 | else: 382 | # Otherwise: 383 | # - REF respond to pairing event if any. 384 | # - DUT respond to pairing event if any. 385 | if ref_ev_answer is not None: 386 | ref_pairing.send_nowait(ref_ev_answer) 387 | if dut_ev_answer is not None: 388 | dut_pairing.send_nowait(dut_ev_answer) 389 | 390 | except (asyncio.CancelledError, asyncio.TimeoutError): 391 | logging.error('Pairing timed-out or has been canceled.') 392 | 393 | except AssertionError: 394 | logging.exception('Pairing failed.') 395 | if not connect_and_pair_task.done(): 396 | connect_and_pair_task.cancel() 397 | 398 | finally: 399 | try: 400 | (secure, wait_security) = await asyncio.wait_for(connect_and_pair_task, 15.0) 401 | logging.info(f'Pairing result: {secure.result_variant()}/{wait_security.result_variant()}') 402 | 403 | if shall_pass: 404 | assert_equal(secure.result_variant(), 'success') 405 | assert_equal(wait_security.result_variant(), 'success') 406 | if 'lk' in key_distribution: 407 | # Make a Classic connection 408 | if self.dut.name == 'android': 409 | # Android IOP: Android automatically trigger a BR/EDR connection request 410 | # in this case. 411 | ref_dut_classic_res = await self.ref.aio.host.WaitConnection(self.dut.address) 412 | assert_is_not_none(ref_dut_classic_res.connection) 413 | assert ref_dut_classic_res.connection 414 | ref_dut_classic = ref_dut_classic_res.connection 415 | else: 416 | ref_dut_classic, _ = await pandora_snippet.connect(self.ref, self.dut) 417 | # Try to encrypt Classic connection 418 | ref_dut_secure = await self.ref.aio.security.Secure(ref_dut_classic, classic=LEVEL2) 419 | assert_equal(ref_dut_secure.result_variant(), 'success') 420 | else: 421 | assert_in( 422 | secure.result_variant(), 423 | ('connection_died', 'pairing_failure', 'authentication_failure', 'not_reached'), 424 | ) 425 | assert_in( 426 | wait_security.result_variant(), 427 | ('connection_died', 'pairing_failure', 'authentication_failure', 'not_reached'), 428 | ) 429 | 430 | finally: 431 | dut_pairing.cancel() 432 | ref_pairing.cancel() 433 | 434 | 435 | if __name__ == '__main__': 436 | logging.basicConfig(level=logging.DEBUG) 437 | test_runner.main() # type: ignore 438 | -------------------------------------------------------------------------------- /avatar/cases/security_test.py: -------------------------------------------------------------------------------- 1 | # Copyright 2023 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import asyncio 16 | import avatar 17 | import itertools 18 | import logging 19 | import secrets 20 | 21 | from avatar import BumblePandoraDevice 22 | from avatar import PandoraDevice 23 | from avatar import PandoraDevices 24 | from avatar import pandora_snippet 25 | from bumble.hci import HCI_CENTRAL_ROLE 26 | from bumble.hci import HCI_PERIPHERAL_ROLE 27 | from bumble.hci import HCI_Write_Default_Link_Policy_Settings_Command 28 | from bumble.keys import PairingKeys 29 | from bumble.pairing import PairingConfig 30 | from bumble.pairing import PairingDelegate 31 | from mobly import base_test 32 | from mobly import signals 33 | from mobly import test_runner 34 | from mobly.asserts import assert_equal # type: ignore 35 | from mobly.asserts import assert_in # type: ignore 36 | from mobly.asserts import assert_is_not_none # type: ignore 37 | from mobly.asserts import fail # type: ignore 38 | from pandora.host_pb2 import RANDOM 39 | from pandora.host_pb2 import RESOLVABLE_OR_PUBLIC 40 | from pandora.host_pb2 import Connection as PandoraConnection 41 | from pandora.host_pb2 import DataTypes 42 | from pandora.security_pb2 import LE_LEVEL2 43 | from pandora.security_pb2 import LEVEL2 44 | from pandora.security_pb2 import PairingEventAnswer 45 | from pandora.security_pb2 import SecureResponse 46 | from pandora.security_pb2 import WaitSecurityResponse 47 | from typing import Any, List, Literal, Optional, Tuple, Union 48 | 49 | DEFAULT_SMP_KEY_DISTRIBUTION = ( 50 | PairingDelegate.KeyDistribution.DISTRIBUTE_ENCRYPTION_KEY 51 | | PairingDelegate.KeyDistribution.DISTRIBUTE_IDENTITY_KEY 52 | | PairingDelegate.KeyDistribution.DISTRIBUTE_SIGNING_KEY 53 | ) 54 | 55 | 56 | async def le_connect_with_rpa_and_encrypt(central: PandoraDevice, peripheral: PandoraDevice) -> None: 57 | # Note: Android doesn't support own_address_type=RESOLVABLE_OR_PUBLIC(offloaded resolution) 58 | # But own_address_type=RANDOM still set a public RPA generated in host 59 | advertisement = peripheral.aio.host.Advertise( 60 | legacy=True, 61 | connectable=True, 62 | own_address_type=RANDOM if peripheral.name == 'android' else RESOLVABLE_OR_PUBLIC, 63 | data=DataTypes(manufacturer_specific_data=b'pause cafe'), 64 | ) 65 | 66 | (cen_res, per_res) = await asyncio.gather( 67 | central.aio.host.ConnectLE( 68 | own_address_type=RANDOM if central.name == 'android' else RESOLVABLE_OR_PUBLIC, 69 | public=peripheral.address, 70 | ), 71 | anext(aiter(advertisement)), # pytype: disable=name-error 72 | ) 73 | 74 | advertisement.cancel() 75 | assert_equal(cen_res.result_variant(), 'connection') 76 | cen_per = cen_res.connection 77 | per_cen = per_res.connection 78 | assert cen_per is not None and per_cen is not None 79 | 80 | encryption = await peripheral.aio.security.Secure(connection=per_cen, le=LE_LEVEL2) 81 | assert_equal(encryption.result_variant(), 'success') 82 | 83 | 84 | class SecurityTest(base_test.BaseTestClass): # type: ignore[misc] 85 | ''' 86 | This class aim to test SSP (Secure Simple Pairing) on Classic 87 | Bluetooth devices. 88 | ''' 89 | 90 | devices: Optional[PandoraDevices] = None 91 | 92 | # pandora devices. 93 | dut: PandoraDevice 94 | ref: PandoraDevice 95 | 96 | @avatar.asynchronous 97 | async def setup_class(self) -> None: 98 | self.devices = PandoraDevices(self) 99 | self.dut, self.ref, *_ = self.devices 100 | 101 | # Enable BR/EDR mode and SSP for Bumble devices. 102 | for device in self.devices: 103 | if isinstance(device, BumblePandoraDevice): 104 | device.config.setdefault('address_resolution_offload', True) 105 | device.config.setdefault('classic_enabled', True) 106 | device.config.setdefault('classic_ssp_enabled', True) 107 | device.config.setdefault('irk', secrets.token_hex(16)) 108 | device.config.setdefault( 109 | 'server', 110 | { 111 | 'io_capability': 'display_output_and_yes_no_input', 112 | }, 113 | ) 114 | 115 | await asyncio.gather(self.dut.reset(), self.ref.reset()) 116 | 117 | def teardown_class(self) -> None: 118 | if self.devices: 119 | self.devices.stop_all() 120 | 121 | @avatar.parameterized( 122 | *itertools.product( 123 | ('outgoing_connection', 'incoming_connection'), 124 | ('outgoing_pairing', 'incoming_pairing'), 125 | ( 126 | 'accept', 127 | 'reject', 128 | 'rejected', 129 | 'disconnect', 130 | 'disconnected', 131 | 'accept_ctkd', 132 | ), 133 | ( 134 | 'against_default_io_cap', 135 | 'against_no_output_no_input', 136 | 'against_keyboard_only', 137 | 'against_display_only', 138 | 'against_display_yes_no', 139 | ), 140 | ('against_central', 'against_peripheral'), 141 | ) 142 | ) # type: ignore[misc] 143 | @avatar.asynchronous 144 | async def test_ssp( 145 | self, 146 | connect: Union[Literal['outgoing_connection'], Literal['incoming_connection']], 147 | pair: Union[Literal['outgoing_pairing'], Literal['incoming_pairing']], 148 | variant: Union[ 149 | Literal['accept'], 150 | Literal['reject'], 151 | Literal['rejected'], 152 | Literal['disconnect'], 153 | Literal['disconnected'], 154 | Literal['accept_ctkd'], 155 | ], 156 | ref_io_capability: Union[ 157 | Literal['against_default_io_cap'], 158 | Literal['against_no_output_no_input'], 159 | Literal['against_keyboard_only'], 160 | Literal['against_display_only'], 161 | Literal['against_display_yes_no'], 162 | ], 163 | ref_role: Union[ 164 | Literal['against_central'], 165 | Literal['against_peripheral'], 166 | ], 167 | ) -> None: 168 | if self.dut.name == 'android' and connect == 'outgoing_connection' and pair == 'incoming_pairing': 169 | # TODO: do not skip when doing physical tests. 170 | raise signals.TestSkip( 171 | 'TODO: Fix rootcanal when both side trigger authentication:\n' 172 | + 'Android always trigger auth for outgoing connections.' 173 | ) 174 | 175 | if self.dut.name == 'android' and 'disconnect' in variant: 176 | raise signals.TestSkip( 177 | 'TODO: Fix AOSP pandora server for this variant:\n' 178 | + '- Looks like `Disconnect` never complete.\n' 179 | + '- When disconnected the `Secure/WaitSecurity` never returns.' 180 | ) 181 | 182 | if self.dut.name == 'android' and pair == 'outgoing_pairing' and ref_role == 'against_central': 183 | raise signals.TestSkip( 184 | 'TODO: Fix PandoraSecurity server for android:\n' 185 | + 'report the encryption state the with the bonding state' 186 | ) 187 | 188 | if self.ref.name == 'android': 189 | raise signals.TestSkip( 190 | 'TODO: (add bug number) Fix core stack:\n' 191 | + 'BOND_BONDED event is triggered before the encryption changed' 192 | ) 193 | 194 | if isinstance(self.ref, BumblePandoraDevice) and ref_io_capability == 'against_default_io_cap': 195 | raise signals.TestSkip('Skip default IO cap for Bumble REF.') 196 | 197 | if not isinstance(self.ref, BumblePandoraDevice) and ref_io_capability != 'against_default_io_cap': 198 | raise signals.TestSkip('Unable to override IO capability on non Bumble device.') 199 | 200 | # CTKD 201 | if 'ctkd' in variant and ref_io_capability not in ('against_display_yes_no'): 202 | raise signals.TestSkip('CTKD cases must be conducted under Security Level 4') 203 | 204 | # Factory reset both DUT and REF devices. 205 | await asyncio.gather(self.dut.reset(), self.ref.reset()) 206 | 207 | # Override REF IO capability if supported. 208 | if isinstance(self.ref, BumblePandoraDevice): 209 | io_capability = { 210 | 'against_no_output_no_input': PairingDelegate.IoCapability.NO_OUTPUT_NO_INPUT, 211 | 'against_keyboard_only': PairingDelegate.IoCapability.KEYBOARD_INPUT_ONLY, 212 | 'against_display_only': PairingDelegate.IoCapability.DISPLAY_OUTPUT_ONLY, 213 | 'against_display_yes_no': PairingDelegate.IoCapability.DISPLAY_OUTPUT_AND_YES_NO_INPUT, 214 | }[ref_io_capability] 215 | self.ref.server_config.io_capability = io_capability 216 | self.ref.server_config.smp_local_initiator_key_distribution = DEFAULT_SMP_KEY_DISTRIBUTION 217 | self.ref.server_config.smp_local_responder_key_distribution = DEFAULT_SMP_KEY_DISTRIBUTION 218 | # Distribute Public identity address 219 | self.ref.server_config.identity_address_type = PairingConfig.AddressType.PUBLIC 220 | # Allow role switch 221 | # TODO: Remove direct Bumble usage 222 | await self.ref.device.send_command(HCI_Write_Default_Link_Policy_Settings_Command(default_link_policy_settings=0x01), check_result=True) # type: ignore 223 | 224 | # Override DUT Bumble device capabilities. 225 | if isinstance(self.dut, BumblePandoraDevice): 226 | self.dut.server_config.smp_local_initiator_key_distribution = DEFAULT_SMP_KEY_DISTRIBUTION 227 | self.dut.server_config.smp_local_responder_key_distribution = DEFAULT_SMP_KEY_DISTRIBUTION 228 | # Distribute Public identity address 229 | self.dut.server_config.identity_address_type = PairingConfig.AddressType.PUBLIC 230 | # Allow role switch 231 | # TODO: Remove direct Bumble usage 232 | await self.dut.device.send_command(HCI_Write_Default_Link_Policy_Settings_Command(default_link_policy_settings=0x01), check_result=True) # type: ignore 233 | 234 | # Pandora connection tokens 235 | ref_dut: Optional[PandoraConnection] = None 236 | dut_ref: Optional[PandoraConnection] = None 237 | # Bumble connection 238 | ref_dut_bumble = None 239 | dut_ref_bumble = None 240 | # CTKD async task 241 | ctkd_task = None 242 | need_ctkd = 'ctkd' in variant 243 | 244 | # Connection/pairing task. 245 | async def connect_and_pair() -> Tuple[SecureResponse, WaitSecurityResponse]: 246 | nonlocal ref_dut 247 | nonlocal dut_ref 248 | nonlocal ref_dut_bumble 249 | nonlocal dut_ref_bumble 250 | nonlocal ctkd_task 251 | 252 | # Make classic connection. 253 | if connect == 'incoming_connection': 254 | ref_dut, dut_ref = await pandora_snippet.connect(initiator=self.ref, acceptor=self.dut) 255 | else: 256 | dut_ref, ref_dut = await pandora_snippet.connect(initiator=self.dut, acceptor=self.ref) 257 | 258 | # Retrieve Bumble connection 259 | if isinstance(self.dut, BumblePandoraDevice): 260 | dut_ref_bumble = pandora_snippet.get_raw_connection(self.dut, dut_ref) 261 | # Role switch. 262 | if isinstance(self.ref, BumblePandoraDevice): 263 | ref_dut_bumble = pandora_snippet.get_raw_connection(self.ref, ref_dut) 264 | if ref_dut_bumble is not None: 265 | role = { 266 | 'against_central': HCI_CENTRAL_ROLE, 267 | 'against_peripheral': HCI_PERIPHERAL_ROLE, 268 | }[ref_role] 269 | 270 | if ref_dut_bumble.role != role: 271 | self.ref.log.info( 272 | f"Role switch to: {'`CENTRAL`' if role == HCI_CENTRAL_ROLE else '`PERIPHERAL`'}" 273 | ) 274 | await ref_dut_bumble.switch_role(role) 275 | 276 | # TODO: Remove direct Bumble usage 277 | async def wait_ctkd_keys() -> List[PairingKeys]: 278 | futures: List[asyncio.Future[PairingKeys]] = [] 279 | if ref_dut_bumble is not None: 280 | ref_dut_fut = asyncio.get_event_loop().create_future() 281 | futures.append(ref_dut_fut) 282 | 283 | def on_pairing(keys: PairingKeys) -> None: 284 | ref_dut_fut.set_result(keys) 285 | 286 | ref_dut_bumble.on('pairing', on_pairing) 287 | if dut_ref_bumble is not None: 288 | dut_ref_fut = asyncio.get_event_loop().create_future() 289 | futures.append(dut_ref_fut) 290 | 291 | def on_pairing(keys: PairingKeys) -> None: 292 | dut_ref_fut.set_result(keys) 293 | 294 | dut_ref_bumble.on('pairing', on_pairing) 295 | 296 | return await asyncio.gather(*futures) 297 | 298 | if need_ctkd: 299 | # CTKD might be triggered by devices automatically, so CTKD listener must be started here 300 | ctkd_task = asyncio.create_task(wait_ctkd_keys()) 301 | 302 | # Pairing. 303 | if pair == 'incoming_pairing': 304 | return await asyncio.gather( 305 | self.ref.aio.security.Secure(connection=ref_dut, classic=LEVEL2), 306 | self.dut.aio.security.WaitSecurity(connection=dut_ref, classic=LEVEL2), 307 | ) 308 | 309 | return await asyncio.gather( 310 | self.dut.aio.security.Secure(connection=dut_ref, classic=LEVEL2), 311 | self.ref.aio.security.WaitSecurity(connection=ref_dut, classic=LEVEL2), 312 | ) 313 | 314 | # Listen for pairing event on bot DUT and REF. 315 | dut_pairing, ref_pairing = self.dut.aio.security.OnPairing(), self.ref.aio.security.OnPairing() 316 | 317 | # Start connection/pairing. 318 | connect_and_pair_task = asyncio.create_task(connect_and_pair()) 319 | 320 | shall_pass = variant == 'accept' or 'ctkd' in variant 321 | try: 322 | dut_pairing_fut = asyncio.create_task(anext(dut_pairing)) 323 | ref_pairing_fut = asyncio.create_task(anext(ref_pairing)) 324 | 325 | def on_done(_: Any) -> None: 326 | if not dut_pairing_fut.done(): 327 | dut_pairing_fut.cancel() 328 | if not ref_pairing_fut.done(): 329 | ref_pairing_fut.cancel() 330 | 331 | connect_and_pair_task.add_done_callback(on_done) 332 | 333 | ref_ev = await asyncio.wait_for(ref_pairing_fut, timeout=15.0) 334 | self.ref.log.info(f'REF pairing event: {ref_ev.method_variant()}') 335 | 336 | dut_ev_answer, ref_ev_answer = None, None 337 | if not connect_and_pair_task.done(): 338 | dut_ev = await asyncio.wait_for(dut_pairing_fut, timeout=15.0) 339 | self.dut.log.info(f'DUT pairing event: {dut_ev.method_variant()}') 340 | 341 | if dut_ev.method_variant() in ('numeric_comparison', 'just_works'): 342 | assert_in(ref_ev.method_variant(), ('numeric_comparison', 'just_works')) 343 | 344 | confirm = True 345 | if ( 346 | dut_ev.method_variant() == 'numeric_comparison' 347 | and ref_ev.method_variant() == 'numeric_comparison' 348 | ): 349 | confirm = ref_ev.numeric_comparison == dut_ev.numeric_comparison 350 | 351 | dut_ev_answer = PairingEventAnswer(event=dut_ev, confirm=False if variant == 'reject' else confirm) 352 | ref_ev_answer = PairingEventAnswer( 353 | event=ref_ev, confirm=False if variant == 'rejected' else confirm 354 | ) 355 | 356 | elif dut_ev.method_variant() == 'passkey_entry_notification': 357 | assert_equal(ref_ev.method_variant(), 'passkey_entry_request') 358 | assert_is_not_none(dut_ev.passkey_entry_notification) 359 | assert dut_ev.passkey_entry_notification is not None 360 | 361 | if variant == 'reject': 362 | # DUT cannot reject, pairing shall pass. 363 | shall_pass = True 364 | 365 | ref_ev_answer = PairingEventAnswer( 366 | event=ref_ev, 367 | passkey=None if variant == 'rejected' else dut_ev.passkey_entry_notification, 368 | ) 369 | 370 | elif dut_ev.method_variant() == 'passkey_entry_request': 371 | assert_equal(ref_ev.method_variant(), 'passkey_entry_notification') 372 | assert_is_not_none(ref_ev.passkey_entry_notification) 373 | 374 | if variant == 'rejected': 375 | # REF cannot reject, pairing shall pass. 376 | shall_pass = True 377 | 378 | assert ref_ev.passkey_entry_notification is not None 379 | dut_ev_answer = PairingEventAnswer( 380 | event=dut_ev, 381 | passkey=None if variant == 'reject' else ref_ev.passkey_entry_notification, 382 | ) 383 | 384 | else: 385 | fail("") 386 | 387 | if variant == 'disconnect': 388 | # Disconnect: 389 | # - REF respond to pairing event if any. 390 | # - DUT trigger disconnect. 391 | if ref_ev_answer is not None: 392 | ref_pairing.send_nowait(ref_ev_answer) 393 | assert dut_ref is not None 394 | await self.dut.aio.host.Disconnect(connection=dut_ref) 395 | 396 | elif variant == 'disconnected': 397 | # Disconnected: 398 | # - DUT respond to pairing event if any. 399 | # - REF trigger disconnect. 400 | if dut_ev_answer is not None: 401 | dut_pairing.send_nowait(dut_ev_answer) 402 | assert ref_dut is not None 403 | await self.ref.aio.host.Disconnect(connection=ref_dut) 404 | 405 | else: 406 | # Otherwise: 407 | # - REF respond to pairing event if any. 408 | # - DUT respond to pairing event if any. 409 | if ref_ev_answer is not None: 410 | ref_pairing.send_nowait(ref_ev_answer) 411 | if dut_ev_answer is not None: 412 | dut_pairing.send_nowait(dut_ev_answer) 413 | 414 | except (asyncio.CancelledError, asyncio.TimeoutError): 415 | logging.error('Pairing timed-out or has been canceled.') 416 | 417 | except AssertionError: 418 | logging.exception('Pairing failed.') 419 | if not connect_and_pair_task.done(): 420 | connect_and_pair_task.cancel() 421 | 422 | finally: 423 | try: 424 | (secure, wait_security) = await asyncio.wait_for(connect_and_pair_task, 15.0) 425 | logging.info(f'Pairing result: {secure.result_variant()}/{wait_security.result_variant()}') 426 | 427 | if shall_pass: 428 | assert_equal(secure.result_variant(), 'success') 429 | assert_equal(wait_security.result_variant(), 'success') 430 | else: 431 | assert_in( 432 | secure.result_variant(), 433 | ('connection_died', 'pairing_failure', 'authentication_failure', 'not_reached'), 434 | ) 435 | assert_in( 436 | wait_security.result_variant(), 437 | ('connection_died', 'pairing_failure', 'authentication_failure', 'not_reached'), 438 | ) 439 | 440 | finally: 441 | dut_pairing.cancel() 442 | ref_pairing.cancel() 443 | 444 | if not need_ctkd: 445 | return 446 | 447 | ctkd_shall_pass = variant == 'accept_ctkd' 448 | 449 | if variant == 'accept_ctkd': 450 | # TODO: Remove direct Bumble usage 451 | async def ctkd_over_bredr() -> None: 452 | if ref_role == 'against_central': 453 | if ref_dut_bumble is not None: 454 | await ref_dut_bumble.pair() 455 | else: 456 | if dut_ref_bumble is not None: 457 | await dut_ref_bumble.pair() 458 | assert ctkd_task is not None 459 | await ctkd_task 460 | 461 | await ctkd_over_bredr() 462 | else: 463 | fail("Unsupported variant " + variant) 464 | 465 | if ctkd_shall_pass: 466 | # Try to connect with RPA(to verify IRK), and encrypt(to verify LTK) 467 | await le_connect_with_rpa_and_encrypt(self.dut, self.ref) 468 | 469 | 470 | if __name__ == '__main__': 471 | logging.basicConfig(level=logging.DEBUG) 472 | test_runner.main() # type: ignore 473 | -------------------------------------------------------------------------------- /avatar/controllers/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Avatar Mobly controllers.""" 16 | -------------------------------------------------------------------------------- /avatar/controllers/bumble_device.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Bumble device Mobly controller.""" 16 | 17 | import asyncio 18 | import avatar.aio 19 | 20 | from bumble.pandora.device import PandoraDevice as BumblePandoraDevice 21 | from typing import Any, Dict, List, Optional 22 | 23 | MOBLY_CONTROLLER_CONFIG_NAME = 'BumbleDevice' 24 | 25 | 26 | def create(configs: List[Dict[str, Any]]) -> List[BumblePandoraDevice]: 27 | """Create a list of `BumbleDevice` from configs.""" 28 | return [BumblePandoraDevice(config) for config in configs] 29 | 30 | 31 | def destroy(devices: List[BumblePandoraDevice]) -> None: 32 | """Destroy each `BumbleDevice`""" 33 | 34 | async def close_devices() -> None: 35 | await asyncio.gather(*(device.close() for device in devices)) 36 | 37 | avatar.aio.run_until_complete(close_devices()) 38 | 39 | 40 | def get_info(devices: List[BumblePandoraDevice]) -> List[Optional[Dict[str, str]]]: 41 | """Return the device info for each `BumblePandoraDevice`.""" 42 | return [device.info() for device in devices] 43 | -------------------------------------------------------------------------------- /avatar/controllers/pandora_device.py: -------------------------------------------------------------------------------- 1 | # Copyright 2023 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Pandora device Mobly controller.""" 16 | 17 | import importlib 18 | 19 | from avatar.pandora_client import PandoraClient 20 | from typing import Any, Dict, List, Optional, cast 21 | 22 | MOBLY_CONTROLLER_CONFIG_NAME = 'PandoraDevice' 23 | 24 | 25 | def create(configs: List[Dict[str, Any]]) -> List[PandoraClient]: 26 | """Create a list of `PandoraClient` from configs.""" 27 | 28 | def create_device(config: Dict[str, Any]) -> PandoraClient: 29 | module_name = config.pop('module', PandoraClient.__module__) 30 | class_name = config.pop('class', PandoraClient.__name__) 31 | 32 | module = importlib.import_module(module_name) 33 | return cast(PandoraClient, getattr(module, class_name)(**config)) 34 | 35 | return list(map(create_device, configs)) 36 | 37 | 38 | def destroy(devices: List['PandoraClient']) -> None: 39 | """Destroy each `PandoraClient`""" 40 | for device in devices: 41 | device.close() 42 | 43 | 44 | def get_info(devices: List['PandoraClient']) -> List[Optional[Dict[str, Any]]]: 45 | """Return the device info for each `PandoraClient`.""" 46 | return [{'grpc_target': device.grpc_target, 'bd_addr': str(device.address)} for device in devices] 47 | -------------------------------------------------------------------------------- /avatar/controllers/usb_bumble_device.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """UsbDevice Bumble Mobly controller.""" 16 | 17 | 18 | from bumble.pandora.device import PandoraDevice as BumblePandoraDevice 19 | from typing import Any, Dict, List 20 | 21 | MOBLY_CONTROLLER_CONFIG_NAME = 'UsbDevice' 22 | 23 | 24 | def create(configs: List[Dict[str, Any]]) -> List[BumblePandoraDevice]: 25 | """Create a list of `BumbleDevice` from configs.""" 26 | 27 | def transport_from_id(id: str) -> str: 28 | return f'pyusb:!{id.removeprefix("usb:")}' 29 | 30 | return [BumblePandoraDevice(config={'transport': transport_from_id(config['id'])}) for config in configs] 31 | 32 | 33 | from .bumble_device import destroy 34 | from .bumble_device import get_info 35 | 36 | __all__ = ["MOBLY_CONTROLLER_CONFIG_NAME", "create", "destroy", "get_info"] 37 | -------------------------------------------------------------------------------- /avatar/metrics/README.md: -------------------------------------------------------------------------------- 1 | # Metrics 2 | 3 | Avatar metrics use `perfetto` traces. 4 | 5 | ## Perfetto traces 6 | 7 | For convenience, `trace_pb2.py` and `trace_pb2.pyi` are pre-generated. 8 | 9 | To regenerate them run the following: 10 | 11 | ``` 12 | pip install protoc-exe 13 | protoc trace.proto --pyi_out=./ --python_out=./ 14 | ``` 15 | 16 | To ensure compliance with the linter, you must modify the generated 17 | `.pyi` file by replacing `Union[T, _Mapping]` to `T`. 18 | -------------------------------------------------------------------------------- /avatar/metrics/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Avatar metrics.""" 16 | -------------------------------------------------------------------------------- /avatar/metrics/interceptors.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Avatar metrics interceptors.""" 16 | 17 | import grpc 18 | import time 19 | 20 | from avatar.metrics.trace import Callsite 21 | from grpc.aio import ClientCallDetails 22 | from pandora import _utils as utils 23 | from typing import ( 24 | TYPE_CHECKING, 25 | Any, 26 | AsyncIterator, 27 | Awaitable, 28 | Callable, 29 | Generic, 30 | Iterator, 31 | Protocol, 32 | Sequence, 33 | TypeVar, 34 | Union, 35 | ) 36 | 37 | if TYPE_CHECKING: 38 | from avatar.pandora_client import PandoraClient 39 | else: 40 | PandoraClient = object 41 | 42 | 43 | _T = TypeVar('_T') 44 | _U = TypeVar('_U') 45 | _T_co = TypeVar('_T_co', covariant=True) 46 | 47 | 48 | ClientInterceptor = Union[ 49 | grpc.UnaryUnaryClientInterceptor, 50 | grpc.UnaryStreamClientInterceptor, 51 | grpc.StreamStreamClientInterceptor, 52 | ] 53 | 54 | 55 | def interceptors(device: PandoraClient) -> Sequence[ClientInterceptor]: 56 | return [UnaryUnaryInterceptor(device), UnaryStreamInterceptor(device), StreamStreamInterceptor(device)] 57 | 58 | 59 | def aio_interceptors(device: PandoraClient) -> Sequence[grpc.aio.ClientInterceptor]: 60 | return [AioUnaryUnaryInterceptor(device), AioUnaryStreamInterceptor(device), AioStreamStreamInterceptor(device)] 61 | 62 | 63 | class UnaryOutcome(Protocol, Generic[_T_co]): 64 | def result(self) -> _T_co: ... 65 | 66 | 67 | class UnaryUnaryInterceptor(grpc.UnaryUnaryClientInterceptor): # type: ignore[misc] 68 | def __init__(self, device: PandoraClient) -> None: 69 | self.device = device 70 | 71 | def intercept_unary_unary( 72 | self, 73 | continuation: Callable[[ClientCallDetails, _T], UnaryOutcome[_U]], 74 | client_call_details: ClientCallDetails, 75 | request: _T, 76 | ) -> UnaryOutcome[_U]: 77 | callsite = Callsite(self.device, client_call_details.method, request) 78 | response = continuation(client_call_details, request) 79 | callsite.end(response.result()) 80 | return response 81 | 82 | 83 | class UnaryStreamInterceptor(grpc.UnaryStreamClientInterceptor): # type: ignore[misc] 84 | def __init__(self, device: PandoraClient) -> None: 85 | self.device = device 86 | 87 | def intercept_unary_stream( # type: ignore 88 | self, 89 | continuation: Callable[[ClientCallDetails, _T], utils.Stream[_U]], 90 | client_call_details: ClientCallDetails, 91 | request: _T, 92 | ) -> utils.Stream[_U]: 93 | callsite = Callsite(self.device, client_call_details.method, request) 94 | call = continuation(client_call_details, request) 95 | call.add_callback(lambda: callsite.end(None)) # type: ignore 96 | 97 | class Proxy: 98 | def __iter__(self) -> Iterator[_U]: 99 | return self 100 | 101 | def __next__(self) -> _U: 102 | res = next(call) 103 | callsite.input(res) 104 | return res 105 | 106 | def is_active(self) -> bool: 107 | return call.is_active() # type: ignore 108 | 109 | def time_remaining(self) -> float: 110 | return call.time_remaining() # type: ignore 111 | 112 | def cancel(self) -> None: 113 | return call.cancel() # type: ignore 114 | 115 | def add_callback(self, callback: Any) -> None: 116 | return call.add_callback(callback) # type: ignore 117 | 118 | return Proxy() # type: ignore 119 | 120 | 121 | class StreamStreamInterceptor(grpc.StreamStreamClientInterceptor): # type: ignore[misc] 122 | def __init__(self, device: PandoraClient) -> None: 123 | self.device = device 124 | 125 | def intercept_stream_stream( # type: ignore 126 | self, 127 | continuation: Callable[[ClientCallDetails, utils.Sender[_T]], utils.StreamStream[_T, _U]], 128 | client_call_details: ClientCallDetails, 129 | request: utils.Sender[_T], 130 | ) -> utils.StreamStream[_T, _U]: 131 | callsite = Callsite(self.device, client_call_details.method, None) 132 | 133 | class RequestProxy: 134 | def __iter__(self) -> Iterator[_T]: 135 | return self 136 | 137 | def __next__(self) -> _T: 138 | req = next(request) 139 | callsite.output(req) 140 | return req 141 | 142 | call = continuation(client_call_details, RequestProxy()) # type: ignore 143 | call.add_callback(lambda: callsite.end(None)) # type: ignore 144 | 145 | class Proxy: 146 | def __iter__(self) -> Iterator[_U]: 147 | return self 148 | 149 | def __next__(self) -> _U: 150 | res = next(call) 151 | callsite.input(res) 152 | return res 153 | 154 | def is_active(self) -> bool: 155 | return call.is_active() # type: ignore 156 | 157 | def time_remaining(self) -> float: 158 | return call.time_remaining() # type: ignore 159 | 160 | def cancel(self) -> None: 161 | return call.cancel() # type: ignore 162 | 163 | def add_callback(self, callback: Any) -> None: 164 | return call.add_callback(callback) # type: ignore 165 | 166 | return Proxy() # type: ignore 167 | 168 | 169 | class AioUnaryUnaryInterceptor(grpc.aio.UnaryUnaryClientInterceptor): # type: ignore[misc] 170 | def __init__(self, device: PandoraClient) -> None: 171 | self.device = device 172 | 173 | async def intercept_unary_unary( # type: ignore 174 | self, 175 | continuation: Callable[[ClientCallDetails, _T], Awaitable[Awaitable[_U]]], 176 | client_call_details: ClientCallDetails, 177 | request: _T, 178 | ) -> _U: 179 | callsite = Callsite(self.device, client_call_details.method, request) 180 | response = await (await continuation(client_call_details, request)) 181 | callsite.end(response) 182 | return response 183 | 184 | 185 | class AioUnaryStreamInterceptor(grpc.aio.UnaryStreamClientInterceptor): # type: ignore[misc] 186 | def __init__(self, device: PandoraClient) -> None: 187 | self.device = device 188 | 189 | async def intercept_unary_stream( # type: ignore 190 | self, 191 | continuation: Callable[[ClientCallDetails, _T], Awaitable[utils.AioStream[_U]]], 192 | client_call_details: ClientCallDetails, 193 | request: _T, 194 | ) -> utils.AioStream[_U]: 195 | # TODO: this is a workaround for https://github.com/grpc/grpc/pull/33951 196 | # need to be deleted as soon as `grpcio` contains the fix. 197 | now = time.time() 198 | if client_call_details.timeout and client_call_details.timeout > now: 199 | client_call_details = client_call_details._replace( 200 | timeout=client_call_details.timeout - now, 201 | ) 202 | 203 | callsite = Callsite(self.device, client_call_details.method, request) 204 | call = await continuation(client_call_details, request) 205 | call.add_done_callback(lambda _: callsite.end(None)) # type: ignore 206 | iter = aiter(call) 207 | 208 | class Proxy: 209 | def __aiter__(self) -> AsyncIterator[_U]: 210 | return self 211 | 212 | async def __anext__(self) -> _U: 213 | res = await anext(iter) 214 | callsite.input(res) 215 | return res 216 | 217 | def is_active(self) -> bool: 218 | return call.is_active() # type: ignore 219 | 220 | def time_remaining(self) -> float: 221 | return call.time_remaining() # type: ignore 222 | 223 | def cancel(self) -> None: 224 | return call.cancel() # type: ignore 225 | 226 | def add_done_callback(self, callback: Any) -> None: 227 | return call.add_done_callback(callback) # type: ignore 228 | 229 | return Proxy() # type: ignore 230 | 231 | 232 | class AioStreamStreamInterceptor(grpc.aio.StreamStreamClientInterceptor): # type: ignore[misc] 233 | def __init__(self, device: PandoraClient) -> None: 234 | self.device = device 235 | 236 | async def intercept_stream_stream( # type: ignore 237 | self, 238 | continuation: Callable[[ClientCallDetails, utils.AioSender[_T]], Awaitable[utils.AioStreamStream[_T, _U]]], 239 | client_call_details: ClientCallDetails, 240 | request: utils.AioSender[_T], 241 | ) -> utils.AioStreamStream[_T, _U]: 242 | # TODO: this is a workaround for https://github.com/grpc/grpc/pull/33951 243 | # need to be deleted as soon as `grpcio` contains the fix. 244 | now = time.time() 245 | if client_call_details.timeout and client_call_details.timeout > now: 246 | client_call_details = client_call_details._replace( 247 | timeout=client_call_details.timeout - now, 248 | ) 249 | 250 | callsite = Callsite(self.device, client_call_details.method, None) 251 | 252 | class RequestProxy: 253 | def __aiter__(self) -> AsyncIterator[_T]: 254 | return self 255 | 256 | async def __anext__(self) -> _T: 257 | req = await anext(request) 258 | callsite.output(req) 259 | return req 260 | 261 | call = await continuation(client_call_details, RequestProxy()) # type: ignore 262 | call.add_done_callback(lambda _: callsite.end(None)) # type: ignore 263 | iter = aiter(call) 264 | 265 | class ResponseProxy: 266 | def __aiter__(self) -> AsyncIterator[_U]: 267 | return self 268 | 269 | async def __anext__(self) -> _U: 270 | res = await anext(iter) 271 | callsite.input(res) 272 | return res 273 | 274 | def is_active(self) -> bool: 275 | return call.is_active() # type: ignore 276 | 277 | def time_remaining(self) -> float: 278 | return call.time_remaining() # type: ignore 279 | 280 | def cancel(self) -> None: 281 | return call.cancel() # type: ignore 282 | 283 | def add_done_callback(self, callback: Any) -> None: 284 | return call.add_done_callback(callback) # type: ignore 285 | 286 | return ResponseProxy() # type: ignore 287 | -------------------------------------------------------------------------------- /avatar/metrics/trace.proto: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2019 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | syntax = "proto2"; 18 | 19 | package perfetto.protos; 20 | 21 | message Trace { 22 | repeated TracePacket packet = 1; 23 | } 24 | 25 | message TracePacket { 26 | optional uint64 timestamp = 8; 27 | oneof data { 28 | TrackEvent track_event = 11; 29 | TrackDescriptor track_descriptor = 60; 30 | } 31 | oneof optional_trusted_packet_sequence_id { 32 | uint32 trusted_packet_sequence_id = 10; 33 | } 34 | } 35 | 36 | message TrackDescriptor { 37 | optional uint64 uuid = 1; 38 | optional uint64 parent_uuid = 5; 39 | optional string name = 2; 40 | optional ProcessDescriptor process = 3; 41 | optional ThreadDescriptor thread = 4; 42 | } 43 | 44 | message TrackEvent { 45 | enum Type { 46 | TYPE_UNSPECIFIED = 0; 47 | TYPE_SLICE_BEGIN = 1; 48 | TYPE_SLICE_END = 2; 49 | TYPE_INSTANT = 3; 50 | TYPE_COUNTER = 4; 51 | } 52 | required string name = 23; 53 | optional Type type = 9; 54 | optional uint64 track_uuid = 11; 55 | repeated DebugAnnotation debug_annotations = 4; 56 | } 57 | 58 | message ProcessDescriptor { 59 | optional int32 pid = 1; 60 | optional string process_name = 6; 61 | repeated string process_labels = 8; 62 | } 63 | 64 | message ThreadDescriptor { 65 | optional int32 pid = 1; 66 | optional int32 tid = 2; 67 | optional string thread_name = 5; 68 | } 69 | 70 | message DebugAnnotation { 71 | oneof name_field { 72 | string name = 10; 73 | } 74 | oneof value { 75 | bool bool_value = 2; 76 | uint64 uint_value = 3; 77 | int64 int_value = 4; 78 | double double_value = 5; 79 | string string_value = 6; 80 | } 81 | repeated DebugAnnotation dict_entries = 11; 82 | repeated DebugAnnotation array_values = 12; 83 | } 84 | -------------------------------------------------------------------------------- /avatar/metrics/trace.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Avatar metrics trace.""" 16 | 17 | import atexit 18 | import time 19 | import types 20 | 21 | from avatar.metrics.trace_pb2 import DebugAnnotation 22 | from avatar.metrics.trace_pb2 import ProcessDescriptor 23 | from avatar.metrics.trace_pb2 import ThreadDescriptor 24 | from avatar.metrics.trace_pb2 import Trace 25 | from avatar.metrics.trace_pb2 import TracePacket 26 | from avatar.metrics.trace_pb2 import TrackDescriptor 27 | from avatar.metrics.trace_pb2 import TrackEvent 28 | from google.protobuf import any_pb2 29 | from google.protobuf import message 30 | from mobly.base_test import BaseTestClass 31 | from pathlib import Path 32 | from typing import TYPE_CHECKING, Any, Dict, List, Optional, Protocol, Tuple, Union 33 | 34 | if TYPE_CHECKING: 35 | from avatar import PandoraDevices 36 | from avatar.pandora_client import PandoraClient 37 | else: 38 | PandoraClient = object 39 | PandoraDevices = object 40 | 41 | devices_id: Dict[PandoraClient, int] = {} 42 | devices_process_id: Dict[PandoraClient, int] = {} 43 | packets: List[TracePacket] = [] 44 | genesis: int = time.monotonic_ns() 45 | output_path: Optional[Path] = None 46 | id: int = 0 47 | 48 | 49 | def next_id() -> int: 50 | global id 51 | id += 1 52 | return id 53 | 54 | 55 | @atexit.register 56 | def dump_trace() -> None: 57 | global packets, output_path 58 | if output_path is None: 59 | return 60 | trace = Trace(packet=packets) 61 | with open(output_path / "avatar.trace", "wb") as f: 62 | f.write(trace.SerializeToString()) 63 | 64 | 65 | def hook_test(test: BaseTestClass, devices: PandoraDevices) -> None: 66 | global packets, output_path 67 | 68 | if output_path is None: 69 | mobly_output_path: str = test.current_test_info.output_path # type: ignore 70 | output_path = (Path(mobly_output_path) / '..' / '..').resolve() # skip test class and method name 71 | 72 | original_setup_test = test.setup_test 73 | 74 | def setup_test(self: BaseTestClass) -> None: 75 | global genesis 76 | genesis = time.monotonic_ns() 77 | process_id = next_id() 78 | packets.append( 79 | TracePacket( 80 | track_descriptor=TrackDescriptor( 81 | uuid=process_id, 82 | process=ProcessDescriptor( 83 | pid=process_id, process_name=f"{self.__class__.__name__}.{self.current_test_info.name}" 84 | ), 85 | ) 86 | ) 87 | ) 88 | 89 | for device in devices: 90 | devices_process_id[device] = process_id 91 | devices_id[device] = next_id() 92 | descriptor = TrackDescriptor( 93 | uuid=devices_id[device], 94 | parent_uuid=process_id, 95 | thread=ThreadDescriptor(thread_name=device.name, pid=process_id, tid=devices_id[device]), 96 | ) 97 | packets.append(TracePacket(track_descriptor=descriptor)) 98 | 99 | original_setup_test() 100 | 101 | test.setup_test = types.MethodType(setup_test, test) 102 | 103 | 104 | class AsTrace(Protocol): 105 | def as_trace(self) -> TracePacket: ... 106 | 107 | 108 | class Callsite(AsTrace): 109 | id_counter = 0 110 | 111 | @classmethod 112 | def next_id(cls) -> int: 113 | cls.id_counter += 1 114 | return cls.id_counter 115 | 116 | def __init__(self, device: PandoraClient, name: Union[bytes, str], message: Any) -> None: 117 | self.at = time.monotonic_ns() - genesis 118 | self.name = name if isinstance(name, str) else name.decode('utf-8') 119 | self.device = device 120 | self.message = message 121 | self.events: List[CallEvent] = [] 122 | self.id = Callsite.next_id() 123 | 124 | device.log.info(f"{self}") 125 | 126 | def pretty(self) -> str: 127 | name_pretty = self.name[1:].split('.')[-1].replace('/', '.') 128 | if self.message is None: 129 | return f"%{self.id} {name_pretty}" 130 | message_pretty, _ = debug_message(self.message) 131 | return f"{name_pretty}({message_pretty})" 132 | 133 | def __str__(self) -> str: 134 | return f"{str2color('╭──', self.id)} {self.pretty()}" 135 | 136 | def output(self, message: Any) -> None: 137 | self.events.append(CallOutput(self, message)) 138 | 139 | def input(self, message: Any) -> None: 140 | self.events.append(CallInput(self, message)) 141 | 142 | def end(self, message: Any) -> None: 143 | global packets 144 | if self.device not in devices_id: 145 | return 146 | self.events.append(CallEnd(self, message)) 147 | packets.append(self.as_trace()) 148 | for event in self.events: 149 | packets.append(event.as_trace()) 150 | 151 | def as_trace(self) -> TracePacket: 152 | return TracePacket( 153 | timestamp=self.at, 154 | track_event=TrackEvent( 155 | name=self.name, 156 | type=TrackEvent.Type.TYPE_SLICE_BEGIN, 157 | track_uuid=devices_id[self.device], 158 | debug_annotations=( 159 | None 160 | if self.message is None 161 | else [ 162 | DebugAnnotation( 163 | name=self.message.__class__.__name__, dict_entries=debug_message(self.message)[1] 164 | ) 165 | ] 166 | ), 167 | ), 168 | trusted_packet_sequence_id=devices_process_id[self.device], 169 | ) 170 | 171 | 172 | class CallEvent(AsTrace): 173 | def __init__(self, callsite: Callsite, message: Any) -> None: 174 | self.at = time.monotonic_ns() - genesis 175 | self.callsite = callsite 176 | self.message = message 177 | 178 | callsite.device.log.info(f"{self}") 179 | 180 | def __str__(self) -> str: 181 | return f"{str2color('╰──', self.callsite.id)} {self.stringify('⟶ ')}" 182 | 183 | def as_trace(self) -> TracePacket: 184 | return TracePacket( 185 | timestamp=self.at, 186 | track_event=TrackEvent( 187 | name=self.callsite.name, 188 | type=TrackEvent.Type.TYPE_INSTANT, 189 | track_uuid=devices_id[self.callsite.device], 190 | debug_annotations=( 191 | None 192 | if self.message is None 193 | else [ 194 | DebugAnnotation( 195 | name=self.message.__class__.__name__, dict_entries=debug_message(self.message)[1] 196 | ) 197 | ] 198 | ), 199 | ), 200 | trusted_packet_sequence_id=devices_process_id[self.callsite.device], 201 | ) 202 | 203 | def stringify(self, direction: str) -> str: 204 | message_pretty = "" if self.message is None else debug_message(self.message)[0] 205 | return ( 206 | str2color(f"[{(self.at - self.callsite.at) / 1000000000:.3f}s]", self.callsite.id) 207 | + f" {self.callsite.pretty()} {str2color(direction, self.callsite.id)} ({message_pretty})" 208 | ) 209 | 210 | 211 | class CallOutput(CallEvent): 212 | def __str__(self) -> str: 213 | return f"{str2color('├──', self.callsite.id)} {self.stringify('⟶ ')}" 214 | 215 | def as_trace(self) -> TracePacket: 216 | return super().as_trace() 217 | 218 | 219 | class CallInput(CallEvent): 220 | def __str__(self) -> str: 221 | return f"{str2color('├──', self.callsite.id)} {self.stringify('⟵ ')}" 222 | 223 | def as_trace(self) -> TracePacket: 224 | return super().as_trace() 225 | 226 | 227 | class CallEnd(CallEvent): 228 | def __str__(self) -> str: 229 | return f"{str2color('╰──', self.callsite.id)} {self.stringify('⟶ ')}" 230 | 231 | def as_trace(self) -> TracePacket: 232 | return TracePacket( 233 | timestamp=self.at, 234 | track_event=TrackEvent( 235 | name=self.callsite.name, 236 | type=TrackEvent.Type.TYPE_SLICE_END, 237 | track_uuid=devices_id[self.callsite.device], 238 | debug_annotations=( 239 | None 240 | if self.message is None 241 | else [ 242 | DebugAnnotation( 243 | name=self.message.__class__.__name__, dict_entries=debug_message(self.message)[1] 244 | ) 245 | ] 246 | ), 247 | ), 248 | trusted_packet_sequence_id=devices_process_id[self.callsite.device], 249 | ) 250 | 251 | 252 | def debug_value(v: Any) -> Tuple[Any, Dict[str, Any]]: 253 | if isinstance(v, any_pb2.Any): 254 | return '...', {'string_value': f'{v}'} 255 | elif isinstance(v, message.Message): 256 | json, entries = debug_message(v) 257 | return json, {'dict_entries': entries} 258 | elif isinstance(v, bytes): 259 | return (v if len(v) < 16 else '...'), {'string_value': f'{v!r}'} 260 | elif isinstance(v, bool): 261 | return v, {'bool_value': v} 262 | elif isinstance(v, int): 263 | return v, {'int_value': v} 264 | elif isinstance(v, float): 265 | return v, {'double_value': v} 266 | elif isinstance(v, str): 267 | return v, {'string_value': v} 268 | try: 269 | return v, {'array_values': [DebugAnnotation(**debug_value(x)[1]) for x in v]} # type: ignore 270 | except: 271 | return v, {'string_value': f'{v}'} 272 | 273 | 274 | def debug_message(msg: message.Message) -> Tuple[Dict[str, Any], List[DebugAnnotation]]: 275 | json: Dict[str, Any] = {} 276 | dbga: List[DebugAnnotation] = [] 277 | for f, v in msg.ListFields(): 278 | if ( 279 | isinstance(v, bytes) 280 | and len(v) == 6 281 | and ('address' in f.name or (f.containing_oneof and 'address' in f.containing_oneof.name)) 282 | ): 283 | addr = ':'.join([f'{x:02X}' for x in v]) 284 | json[f.name] = addr 285 | dbga.append(DebugAnnotation(name=f.name, string_value=addr)) 286 | else: 287 | json_entry, dbga_entry = debug_value(v) 288 | json[f.name] = json_entry 289 | dbga.append(DebugAnnotation(name=f.name, **dbga_entry)) 290 | return json, dbga 291 | 292 | 293 | def str2color(s: str, id: int) -> str: 294 | CSI = "\x1b[" 295 | CSI_RESET = CSI + "0m" 296 | CSI_BOLD = CSI + "1m" 297 | color = ((id * 10) % (230 - 17)) + 17 298 | return CSI + ("1;38;5;%dm" % color) + CSI_BOLD + s + CSI_RESET 299 | -------------------------------------------------------------------------------- /avatar/metrics/trace_pb2.py: -------------------------------------------------------------------------------- 1 | # pyright: reportGeneralTypeIssues=false 2 | # pyright: reportUnknownVariableType=false 3 | # pyright: reportUnknownMemberType=false 4 | # -*- coding: utf-8 -*- 5 | # Generated by the protocol buffer compiler. DO NOT EDIT! 6 | # source: trace.proto 7 | """Generated protocol buffer code.""" 8 | from google.protobuf import descriptor as _descriptor 9 | from google.protobuf import descriptor_pool as _descriptor_pool 10 | from google.protobuf import symbol_database as _symbol_database 11 | from google.protobuf.internal import builder as _builder 12 | 13 | # @@protoc_insertion_point(imports) 14 | 15 | _sym_db = _symbol_database.Default() 16 | 17 | 18 | DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( 19 | b'\n\x0btrace.proto\x12\x0fperfetto.protos\"5\n\x05Trace\x12,\n\x06packet\x18\x01 \x03(\x0b\x32\x1c.perfetto.protos.TracePacket\"\xe7\x01\n\x0bTracePacket\x12\x11\n\ttimestamp\x18\x08 \x01(\x04\x12\x32\n\x0btrack_event\x18\x0b \x01(\x0b\x32\x1b.perfetto.protos.TrackEventH\x00\x12<\n\x10track_descriptor\x18< \x01(\x0b\x32 .perfetto.protos.TrackDescriptorH\x00\x12$\n\x1atrusted_packet_sequence_id\x18\n \x01(\rH\x01\x42\x06\n\x04\x64\x61taB%\n#optional_trusted_packet_sequence_id\"\xaa\x01\n\x0fTrackDescriptor\x12\x0c\n\x04uuid\x18\x01 \x01(\x04\x12\x13\n\x0bparent_uuid\x18\x05 \x01(\x04\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x33\n\x07process\x18\x03 \x01(\x0b\x32\".perfetto.protos.ProcessDescriptor\x12\x31\n\x06thread\x18\x04 \x01(\x0b\x32!.perfetto.protos.ThreadDescriptor\"\x87\x02\n\nTrackEvent\x12\x0c\n\x04name\x18\x17 \x02(\t\x12.\n\x04type\x18\t \x01(\x0e\x32 .perfetto.protos.TrackEvent.Type\x12\x12\n\ntrack_uuid\x18\x0b \x01(\x04\x12;\n\x11\x64\x65\x62ug_annotations\x18\x04 \x03(\x0b\x32 .perfetto.protos.DebugAnnotation\"j\n\x04Type\x12\x14\n\x10TYPE_UNSPECIFIED\x10\x00\x12\x14\n\x10TYPE_SLICE_BEGIN\x10\x01\x12\x12\n\x0eTYPE_SLICE_END\x10\x02\x12\x10\n\x0cTYPE_INSTANT\x10\x03\x12\x10\n\x0cTYPE_COUNTER\x10\x04\"N\n\x11ProcessDescriptor\x12\x0b\n\x03pid\x18\x01 \x01(\x05\x12\x14\n\x0cprocess_name\x18\x06 \x01(\t\x12\x16\n\x0eprocess_labels\x18\x08 \x03(\t\"A\n\x10ThreadDescriptor\x12\x0b\n\x03pid\x18\x01 \x01(\x05\x12\x0b\n\x03tid\x18\x02 \x01(\x05\x12\x13\n\x0bthread_name\x18\x05 \x01(\t\"\x99\x02\n\x0f\x44\x65\x62ugAnnotation\x12\x0e\n\x04name\x18\n \x01(\tH\x00\x12\x14\n\nbool_value\x18\x02 \x01(\x08H\x01\x12\x14\n\nuint_value\x18\x03 \x01(\x04H\x01\x12\x13\n\tint_value\x18\x04 \x01(\x03H\x01\x12\x16\n\x0c\x64ouble_value\x18\x05 \x01(\x01H\x01\x12\x16\n\x0cstring_value\x18\x06 \x01(\tH\x01\x12\x36\n\x0c\x64ict_entries\x18\x0b \x03(\x0b\x32 .perfetto.protos.DebugAnnotation\x12\x36\n\x0c\x61rray_values\x18\x0c \x03(\x0b\x32 .perfetto.protos.DebugAnnotationB\x0c\n\nname_fieldB\x07\n\x05value' 20 | ) 21 | 22 | _globals = globals() 23 | _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) 24 | _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'trace_pb2', _globals) 25 | if _descriptor._USE_C_DESCRIPTORS == False: 26 | DESCRIPTOR._options = None 27 | _globals['_TRACE']._serialized_start = 32 28 | _globals['_TRACE']._serialized_end = 85 29 | _globals['_TRACEPACKET']._serialized_start = 88 30 | _globals['_TRACEPACKET']._serialized_end = 319 31 | _globals['_TRACKDESCRIPTOR']._serialized_start = 322 32 | _globals['_TRACKDESCRIPTOR']._serialized_end = 492 33 | _globals['_TRACKEVENT']._serialized_start = 495 34 | _globals['_TRACKEVENT']._serialized_end = 758 35 | _globals['_TRACKEVENT_TYPE']._serialized_start = 652 36 | _globals['_TRACKEVENT_TYPE']._serialized_end = 758 37 | _globals['_PROCESSDESCRIPTOR']._serialized_start = 760 38 | _globals['_PROCESSDESCRIPTOR']._serialized_end = 838 39 | _globals['_THREADDESCRIPTOR']._serialized_start = 840 40 | _globals['_THREADDESCRIPTOR']._serialized_end = 905 41 | _globals['_DEBUGANNOTATION']._serialized_start = 908 42 | _globals['_DEBUGANNOTATION']._serialized_end = 1189 43 | # @@protoc_insertion_point(module_scope) 44 | -------------------------------------------------------------------------------- /avatar/metrics/trace_pb2.pyi: -------------------------------------------------------------------------------- 1 | from google.protobuf import descriptor as _descriptor 2 | from google.protobuf import message as _message 3 | from google.protobuf.internal import containers as _containers 4 | from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper 5 | from typing import ClassVar as _ClassVar 6 | from typing import Iterable as _Iterable 7 | from typing import Optional as _Optional 8 | from typing import Union as _Union 9 | 10 | DESCRIPTOR: _descriptor.FileDescriptor 11 | 12 | class Trace(_message.Message): 13 | __slots__ = ["packet"] 14 | PACKET_FIELD_NUMBER: _ClassVar[int] 15 | packet: _containers.RepeatedCompositeFieldContainer[TracePacket] 16 | def __init__(self, packet: _Optional[_Iterable[TracePacket]] = ...) -> None: ... 17 | 18 | class TracePacket(_message.Message): 19 | __slots__ = ["timestamp", "track_event", "track_descriptor", "trusted_packet_sequence_id"] 20 | TIMESTAMP_FIELD_NUMBER: _ClassVar[int] 21 | TRACK_EVENT_FIELD_NUMBER: _ClassVar[int] 22 | TRACK_DESCRIPTOR_FIELD_NUMBER: _ClassVar[int] 23 | TRUSTED_PACKET_SEQUENCE_ID_FIELD_NUMBER: _ClassVar[int] 24 | timestamp: int 25 | track_event: TrackEvent 26 | track_descriptor: TrackDescriptor 27 | trusted_packet_sequence_id: int 28 | def __init__( 29 | self, 30 | timestamp: _Optional[int] = ..., 31 | track_event: _Optional[TrackEvent] = ..., 32 | track_descriptor: _Optional[TrackDescriptor] = ..., 33 | trusted_packet_sequence_id: _Optional[int] = ..., 34 | ) -> None: ... 35 | 36 | class TrackDescriptor(_message.Message): 37 | __slots__ = ["uuid", "parent_uuid", "name", "process", "thread"] 38 | UUID_FIELD_NUMBER: _ClassVar[int] 39 | PARENT_UUID_FIELD_NUMBER: _ClassVar[int] 40 | NAME_FIELD_NUMBER: _ClassVar[int] 41 | PROCESS_FIELD_NUMBER: _ClassVar[int] 42 | THREAD_FIELD_NUMBER: _ClassVar[int] 43 | uuid: int 44 | parent_uuid: int 45 | name: str 46 | process: ProcessDescriptor 47 | thread: ThreadDescriptor 48 | def __init__( 49 | self, 50 | uuid: _Optional[int] = ..., 51 | parent_uuid: _Optional[int] = ..., 52 | name: _Optional[str] = ..., 53 | process: _Optional[ProcessDescriptor] = ..., 54 | thread: _Optional[ThreadDescriptor] = ..., 55 | ) -> None: ... 56 | 57 | class TrackEvent(_message.Message): 58 | __slots__ = ["name", "type", "track_uuid", "debug_annotations"] 59 | 60 | class Type(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): # type: ignore 61 | __slots__ = [] # type: ignore 62 | TYPE_UNSPECIFIED: _ClassVar[TrackEvent.Type] 63 | TYPE_SLICE_BEGIN: _ClassVar[TrackEvent.Type] 64 | TYPE_SLICE_END: _ClassVar[TrackEvent.Type] 65 | TYPE_INSTANT: _ClassVar[TrackEvent.Type] 66 | TYPE_COUNTER: _ClassVar[TrackEvent.Type] 67 | 68 | TYPE_UNSPECIFIED: TrackEvent.Type 69 | TYPE_SLICE_BEGIN: TrackEvent.Type 70 | TYPE_SLICE_END: TrackEvent.Type 71 | TYPE_INSTANT: TrackEvent.Type 72 | TYPE_COUNTER: TrackEvent.Type 73 | NAME_FIELD_NUMBER: _ClassVar[int] 74 | TYPE_FIELD_NUMBER: _ClassVar[int] 75 | TRACK_UUID_FIELD_NUMBER: _ClassVar[int] 76 | DEBUG_ANNOTATIONS_FIELD_NUMBER: _ClassVar[int] 77 | name: str 78 | type: TrackEvent.Type 79 | track_uuid: int 80 | debug_annotations: _containers.RepeatedCompositeFieldContainer[DebugAnnotation] 81 | def __init__( 82 | self, 83 | name: _Optional[str] = ..., 84 | type: _Optional[_Union[TrackEvent.Type, str]] = ..., 85 | track_uuid: _Optional[int] = ..., 86 | debug_annotations: _Optional[_Iterable[DebugAnnotation]] = ..., 87 | ) -> None: ... 88 | 89 | class ProcessDescriptor(_message.Message): 90 | __slots__ = ["pid", "process_name", "process_labels"] 91 | PID_FIELD_NUMBER: _ClassVar[int] 92 | PROCESS_NAME_FIELD_NUMBER: _ClassVar[int] 93 | PROCESS_LABELS_FIELD_NUMBER: _ClassVar[int] 94 | pid: int 95 | process_name: str 96 | process_labels: _containers.RepeatedScalarFieldContainer[str] 97 | def __init__( 98 | self, 99 | pid: _Optional[int] = ..., 100 | process_name: _Optional[str] = ..., 101 | process_labels: _Optional[_Iterable[str]] = ..., 102 | ) -> None: ... 103 | 104 | class ThreadDescriptor(_message.Message): 105 | __slots__ = ["pid", "tid", "thread_name"] 106 | PID_FIELD_NUMBER: _ClassVar[int] 107 | TID_FIELD_NUMBER: _ClassVar[int] 108 | THREAD_NAME_FIELD_NUMBER: _ClassVar[int] 109 | pid: int 110 | tid: int 111 | thread_name: str 112 | def __init__( 113 | self, pid: _Optional[int] = ..., tid: _Optional[int] = ..., thread_name: _Optional[str] = ... 114 | ) -> None: ... 115 | 116 | class DebugAnnotation(_message.Message): 117 | __slots__ = [ 118 | "name", 119 | "bool_value", 120 | "uint_value", 121 | "int_value", 122 | "double_value", 123 | "string_value", 124 | "dict_entries", 125 | "array_values", 126 | ] 127 | NAME_FIELD_NUMBER: _ClassVar[int] 128 | BOOL_VALUE_FIELD_NUMBER: _ClassVar[int] 129 | UINT_VALUE_FIELD_NUMBER: _ClassVar[int] 130 | INT_VALUE_FIELD_NUMBER: _ClassVar[int] 131 | DOUBLE_VALUE_FIELD_NUMBER: _ClassVar[int] 132 | STRING_VALUE_FIELD_NUMBER: _ClassVar[int] 133 | DICT_ENTRIES_FIELD_NUMBER: _ClassVar[int] 134 | ARRAY_VALUES_FIELD_NUMBER: _ClassVar[int] 135 | name: str 136 | bool_value: bool 137 | uint_value: int 138 | int_value: int 139 | double_value: float 140 | string_value: str 141 | dict_entries: _containers.RepeatedCompositeFieldContainer[DebugAnnotation] 142 | array_values: _containers.RepeatedCompositeFieldContainer[DebugAnnotation] 143 | def __init__( 144 | self, 145 | name: _Optional[str] = ..., 146 | bool_value: bool = ..., 147 | uint_value: _Optional[int] = ..., 148 | int_value: _Optional[int] = ..., 149 | double_value: _Optional[float] = ..., 150 | string_value: _Optional[str] = ..., 151 | dict_entries: _Optional[_Iterable[DebugAnnotation]] = ..., 152 | array_values: _Optional[_Iterable[DebugAnnotation]] = ..., 153 | ) -> None: ... 154 | -------------------------------------------------------------------------------- /avatar/pandora_client.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | 16 | """Pandora client interface for Avatar tests.""" 17 | 18 | import asyncio 19 | import avatar.aio 20 | import bumble 21 | import bumble.device 22 | import grpc 23 | import grpc.aio 24 | import logging 25 | 26 | from avatar.metrics.interceptors import aio_interceptors 27 | from avatar.metrics.interceptors import interceptors 28 | from bumble import pandora as bumble_server 29 | from bumble.hci import Address as BumbleAddress 30 | from bumble.pandora.device import PandoraDevice as BumblePandoraDevice 31 | from dataclasses import dataclass 32 | from pandora import host_grpc 33 | from pandora import host_grpc_aio 34 | from pandora import security_grpc 35 | from pandora import security_grpc_aio 36 | from typing import Any, Dict, MutableMapping, Optional, Tuple, Union 37 | 38 | 39 | class Address(bytes): 40 | def __new__(cls, address: Union[bytes, str, BumbleAddress]) -> 'Address': 41 | if type(address) is bytes: 42 | address_bytes = address 43 | elif type(address) is str: 44 | address_bytes = bytes.fromhex(address.replace(':', '')) 45 | elif isinstance(address, BumbleAddress): 46 | address_bytes = bytes(reversed(bytes(address))) 47 | else: 48 | raise ValueError('Invalid address format') 49 | 50 | if len(address_bytes) != 6: 51 | raise ValueError('Invalid address length') 52 | 53 | return bytes.__new__(cls, address_bytes) 54 | 55 | def __str__(self) -> str: 56 | return ':'.join([f'{x:02X}' for x in self]) 57 | 58 | 59 | class PandoraClient: 60 | """Provides Pandora interface access to a device via gRPC.""" 61 | 62 | # public fields 63 | name: str 64 | grpc_target: str # Server address for the gRPC channel. 65 | log: 'PandoraClientLoggerAdapter' # Logger adapter. 66 | 67 | # private fields 68 | _channel: grpc.Channel # Synchronous gRPC channel. 69 | _address: Address # Bluetooth device address 70 | _aio: Optional['PandoraClient.Aio'] # Asynchronous gRPC channel. 71 | 72 | def __init__(self, grpc_target: str, name: str = '..') -> None: 73 | """Creates a PandoraClient. 74 | 75 | Establishes a channel with the Pandora gRPC server. 76 | 77 | Args: 78 | grpc_target: Server address for the gRPC channel. 79 | """ 80 | self.name = name 81 | self.grpc_target = grpc_target 82 | self.log = PandoraClientLoggerAdapter(logging.getLogger(), {'client': self}) 83 | self._channel = grpc.intercept_channel(grpc.insecure_channel(grpc_target), *interceptors(self)) # type: ignore 84 | self._address = Address(b'\x00\x00\x00\x00\x00\x00') 85 | self._aio = None 86 | 87 | def close(self) -> None: 88 | """Closes the gRPC channels.""" 89 | self._channel.close() 90 | if self._aio: 91 | avatar.aio.run_until_complete(self._aio.channel.close()) 92 | 93 | @property 94 | def address(self) -> Address: 95 | """Returns the BD address.""" 96 | return self._address 97 | 98 | @address.setter 99 | def address(self, address: Union[bytes, str, BumbleAddress]) -> None: 100 | """Sets the BD address.""" 101 | self._address = Address(address) 102 | 103 | async def reset(self) -> None: 104 | """Factory reset the device & read it's BD address.""" 105 | attempts, max_attempts = 1, 3 106 | while True: 107 | try: 108 | await self.aio.host.FactoryReset(wait_for_ready=True, timeout=15.0) 109 | 110 | # Factory reset stopped the server, close the client too. 111 | assert self._aio 112 | await self._aio.channel.close() 113 | self._aio = None 114 | 115 | # This call might fail if the server is unavailable. 116 | self._address = Address( 117 | (await self.aio.host.ReadLocalAddress(wait_for_ready=True, timeout=15.0)).address 118 | ) 119 | return 120 | except grpc.aio.AioRpcError as e: 121 | if e.code() in ( 122 | grpc.StatusCode.UNAVAILABLE, 123 | grpc.StatusCode.DEADLINE_EXCEEDED, 124 | grpc.StatusCode.CANCELLED, 125 | ): 126 | if attempts <= max_attempts: 127 | self.log.debug(f'Server unavailable, retry [{attempts}/{max_attempts}].') 128 | attempts += 1 129 | continue 130 | self.log.exception(f'Server still unavailable after {attempts} attempts, abort.') 131 | raise e 132 | 133 | @property 134 | def channel(self) -> grpc.Channel: 135 | """Returns the synchronous gRPC channel.""" 136 | try: 137 | _ = asyncio.get_running_loop() 138 | except: 139 | return self._channel 140 | raise RuntimeError('Trying to use the synchronous gRPC channel from asynchronous code.') 141 | 142 | # Pandora interfaces 143 | 144 | @property 145 | def host(self) -> host_grpc.Host: 146 | """Returns the Pandora Host gRPC interface.""" 147 | return host_grpc.Host(self.channel) 148 | 149 | @property 150 | def security(self) -> security_grpc.Security: 151 | """Returns the Pandora Security gRPC interface.""" 152 | return security_grpc.Security(self.channel) 153 | 154 | @property 155 | def security_storage(self) -> security_grpc.SecurityStorage: 156 | """Returns the Pandora SecurityStorage gRPC interface.""" 157 | return security_grpc.SecurityStorage(self.channel) 158 | 159 | @dataclass 160 | class Aio: 161 | channel: grpc.aio.Channel 162 | 163 | @property 164 | def host(self) -> host_grpc_aio.Host: 165 | """Returns the Pandora Host gRPC interface.""" 166 | return host_grpc_aio.Host(self.channel) 167 | 168 | @property 169 | def security(self) -> security_grpc_aio.Security: 170 | """Returns the Pandora Security gRPC interface.""" 171 | return security_grpc_aio.Security(self.channel) 172 | 173 | @property 174 | def security_storage(self) -> security_grpc_aio.SecurityStorage: 175 | """Returns the Pandora SecurityStorage gRPC interface.""" 176 | return security_grpc_aio.SecurityStorage(self.channel) 177 | 178 | @property 179 | def aio(self) -> 'PandoraClient.Aio': 180 | if not self._aio: 181 | self._aio = PandoraClient.Aio( 182 | grpc.aio.insecure_channel(self.grpc_target, interceptors=aio_interceptors(self)) 183 | ) 184 | return self._aio 185 | 186 | 187 | class PandoraClientLoggerAdapter(logging.LoggerAdapter): # type: ignore 188 | """Formats logs from the PandoraClient.""" 189 | 190 | def process(self, msg: str, kwargs: MutableMapping[str, Any]) -> Tuple[str, MutableMapping[str, Any]]: 191 | assert self.extra 192 | client = self.extra['client'] 193 | assert isinstance(client, PandoraClient) 194 | addr = ':'.join([f'{x:02X}' for x in client.address[4:]]) 195 | return (f'[{client.name:<8}:{addr}] {msg}', kwargs) 196 | 197 | 198 | class BumblePandoraClient(PandoraClient): 199 | """Special Pandora client which also give access to a Bumble device instance.""" 200 | 201 | _bumble: BumblePandoraDevice # Bumble device wrapper. 202 | _server_config: bumble_server.Config # Bumble server config. 203 | 204 | def __init__(self, grpc_target: str, bumble: BumblePandoraDevice, server_config: bumble_server.Config) -> None: 205 | super().__init__(grpc_target, 'bumble') 206 | self._bumble = bumble 207 | self._server_config = server_config 208 | 209 | @property 210 | def server_config(self) -> bumble_server.Config: 211 | return self._server_config 212 | 213 | @property 214 | def config(self) -> Dict[str, Any]: 215 | return self._bumble.config 216 | 217 | @property 218 | def device(self) -> bumble.device.Device: 219 | return self._bumble.device 220 | 221 | @property 222 | def random_address(self) -> Address: 223 | return Address(self.device.random_address) 224 | -------------------------------------------------------------------------------- /avatar/pandora_server.py: -------------------------------------------------------------------------------- 1 | # Copyright 2022 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | 16 | """Interface for controller-specific Pandora server management.""" 17 | 18 | import asyncio 19 | import avatar.aio 20 | import grpc 21 | import grpc.aio 22 | import portpicker 23 | import threading 24 | import types 25 | 26 | from avatar.controllers import bumble_device 27 | from avatar.controllers import pandora_device 28 | from avatar.controllers import usb_bumble_device 29 | from avatar.pandora_client import BumblePandoraClient 30 | from avatar.pandora_client import PandoraClient 31 | from bumble import pandora as bumble_server 32 | from bumble.pandora.device import PandoraDevice as BumblePandoraDevice 33 | from contextlib import suppress 34 | from mobly.controllers import android_device 35 | from mobly.controllers.android_device import AndroidDevice 36 | from typing import Generic, Optional, TypeVar 37 | 38 | ANDROID_SERVER_PACKAGE = 'com.android.pandora' 39 | ANDROID_SERVER_GRPC_PORT = 8999 40 | 41 | 42 | # Generic type for `PandoraServer`. 43 | TDevice = TypeVar('TDevice') 44 | 45 | 46 | class PandoraServer(Generic[TDevice]): 47 | """Abstract interface to manage the Pandora gRPC server on the device.""" 48 | 49 | MOBLY_CONTROLLER_MODULE: types.ModuleType = pandora_device 50 | 51 | device: TDevice 52 | 53 | def __init__(self, device: TDevice) -> None: 54 | """Creates a PandoraServer. 55 | 56 | Args: 57 | device: A Mobly controller instance. 58 | """ 59 | self.device = device 60 | 61 | def start(self) -> PandoraClient: 62 | """Sets up and starts the Pandora server on the device.""" 63 | assert isinstance(self.device, PandoraClient) 64 | return self.device 65 | 66 | def stop(self) -> None: 67 | """Stops and cleans up the Pandora server on the device.""" 68 | 69 | 70 | class BumblePandoraServer(PandoraServer[BumblePandoraDevice]): 71 | """Manages the Pandora gRPC server on a BumbleDevice.""" 72 | 73 | MOBLY_CONTROLLER_MODULE = bumble_device 74 | 75 | _task: Optional[asyncio.Task[None]] = None 76 | 77 | def start(self) -> BumblePandoraClient: 78 | """Sets up and starts the Pandora server on the Bumble device.""" 79 | assert self._task is None 80 | 81 | # set the event loop to make sure the gRPC server use the avatar one. 82 | asyncio.set_event_loop(avatar.aio.loop) 83 | 84 | # create gRPC server & port. 85 | server = grpc.aio.server() 86 | port = server.add_insecure_port(f'localhost:{0}') 87 | 88 | config = bumble_server.Config() 89 | self._task = avatar.aio.loop.create_task( 90 | bumble_server.serve(self.device, config=config, grpc_server=server, port=port) 91 | ) 92 | 93 | return BumblePandoraClient(f'localhost:{port}', self.device, config) 94 | 95 | def stop(self) -> None: 96 | """Stops and cleans up the Pandora server on the Bumble device.""" 97 | 98 | async def server_stop() -> None: 99 | assert self._task is not None 100 | if not self._task.done(): 101 | self._task.cancel() 102 | with suppress(asyncio.CancelledError): 103 | await self._task 104 | self._task = None 105 | 106 | avatar.aio.run_until_complete(server_stop()) 107 | 108 | 109 | class UsbBumblePandoraServer(BumblePandoraServer): 110 | MOBLY_CONTROLLER_MODULE = usb_bumble_device 111 | 112 | 113 | class AndroidPandoraServer(PandoraServer[AndroidDevice]): 114 | """Manages the Pandora gRPC server on an AndroidDevice.""" 115 | 116 | MOBLY_CONTROLLER_MODULE = android_device 117 | 118 | _instrumentation: Optional[threading.Thread] = None 119 | _port: int 120 | 121 | def start(self) -> PandoraClient: 122 | """Sets up and starts the Pandora server on the Android device.""" 123 | assert self._instrumentation is None 124 | 125 | # start Pandora Android gRPC server. 126 | self._port = portpicker.pick_unused_port() # type: ignore 127 | self._instrumentation = threading.Thread( 128 | target=lambda: self.device.adb._exec_adb_cmd( # type: ignore 129 | 'shell', 130 | f'am instrument --no-hidden-api-checks -w {ANDROID_SERVER_PACKAGE}/.Main', 131 | shell=False, 132 | timeout=None, 133 | stderr=None, 134 | ) 135 | ) 136 | 137 | self._instrumentation.start() 138 | self.device.adb.forward([f'tcp:{self._port}', f'tcp:{ANDROID_SERVER_GRPC_PORT}']) # type: ignore 139 | 140 | return PandoraClient(f'localhost:{self._port}', 'android') 141 | 142 | def stop(self) -> None: 143 | """Stops and cleans up the Pandora server on the Android device.""" 144 | assert self._instrumentation is not None 145 | 146 | # Stop Pandora Android gRPC server. 147 | self.device.adb._exec_adb_cmd( # type: ignore 148 | 'shell', f'am force-stop {ANDROID_SERVER_PACKAGE}', shell=False, timeout=None, stderr=None 149 | ) 150 | 151 | self.device.adb.forward(['--remove', f'tcp:{self._port}']) # type: ignore 152 | self._instrumentation.join() 153 | self._instrumentation = None 154 | -------------------------------------------------------------------------------- /avatar/pandora_snippet.py: -------------------------------------------------------------------------------- 1 | # Copyright 2023 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import asyncio 16 | 17 | from avatar import BumblePandoraDevice 18 | from avatar import PandoraDevice 19 | from bumble.device import Connection as BumbleConnection 20 | from mobly.asserts import assert_equal # type: ignore 21 | from mobly.asserts import assert_is_not_none # type: ignore 22 | from pandora._utils import AioStream 23 | from pandora.host_pb2 import AdvertiseResponse 24 | from pandora.host_pb2 import Connection 25 | from pandora.host_pb2 import OwnAddressType 26 | from pandora.host_pb2 import ScanningResponse 27 | from typing import Optional, Tuple 28 | 29 | 30 | def get_raw_connection_handle(device: PandoraDevice, connection: Connection) -> int: 31 | assert isinstance(device, BumblePandoraDevice) 32 | return int.from_bytes(connection.cookie.value, 'big') 33 | 34 | 35 | def get_raw_connection(device: PandoraDevice, connection: Connection) -> Optional[BumbleConnection]: 36 | assert isinstance(device, BumblePandoraDevice) 37 | return device.device.lookup_connection(get_raw_connection_handle(device, connection)) 38 | 39 | 40 | async def connect(initiator: PandoraDevice, acceptor: PandoraDevice) -> Tuple[Connection, Connection]: 41 | init_res, wait_res = await asyncio.gather( 42 | initiator.aio.host.Connect(address=acceptor.address), 43 | acceptor.aio.host.WaitConnection(address=initiator.address), 44 | ) 45 | assert_equal(init_res.result_variant(), 'connection') 46 | assert_equal(wait_res.result_variant(), 'connection') 47 | assert init_res.connection is not None and wait_res.connection is not None 48 | return init_res.connection, wait_res.connection 49 | 50 | 51 | async def connect_le( 52 | initiator: PandoraDevice, 53 | acceptor: AioStream[AdvertiseResponse], 54 | scan: ScanningResponse, 55 | own_address_type: OwnAddressType, 56 | cancel_advertisement: bool = True, 57 | ) -> Tuple[Connection, Connection]: 58 | (init_res, wait_res) = await asyncio.gather( 59 | initiator.aio.host.ConnectLE(own_address_type=own_address_type, **scan.address_asdict()), 60 | anext(aiter(acceptor)), # pytype: disable=name-error 61 | ) 62 | if cancel_advertisement: 63 | acceptor.cancel() 64 | assert_equal(init_res.result_variant(), 'connection') 65 | assert_is_not_none(init_res.connection) 66 | assert init_res.connection 67 | return init_res.connection, wait_res.connection 68 | -------------------------------------------------------------------------------- /avatar/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/google/avatar/8d919e78fb4c026aad728fd037986d94324f188d/avatar/py.typed -------------------------------------------------------------------------------- /avatar/runner.py: -------------------------------------------------------------------------------- 1 | # Copyright 2023 Google LLC 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # https://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | 16 | """Avatar runner.""" 17 | 18 | import inspect 19 | import logging 20 | import os 21 | import pathlib 22 | 23 | from importlib.machinery import SourceFileLoader 24 | from mobly import base_test 25 | from mobly import config_parser 26 | from mobly import signals 27 | from mobly import test_runner 28 | from typing import Dict, List, Tuple, Type 29 | 30 | _BUMBLE_BTSNOOP_FMT = 'bumble_btsnoop_{pid}_{instance}.log' 31 | 32 | 33 | class SuiteRunner: 34 | test_beds: List[str] = [] 35 | test_run_configs: List[config_parser.TestRunConfig] = [] 36 | test_classes: List[Type[base_test.BaseTestClass]] = [] 37 | test_filters: List[str] = [] 38 | logs_dir: pathlib.Path = pathlib.Path('out') 39 | logs_verbose: bool = False 40 | 41 | def set_logs_dir(self, path: pathlib.Path) -> None: 42 | self.logs_dir = path 43 | 44 | def set_logs_verbose(self, verbose: bool = True) -> None: 45 | self.logs_verbose = verbose 46 | 47 | def add_test_beds(self, test_beds: List[str]) -> None: 48 | self.test_beds += test_beds 49 | 50 | def add_test_filters(self, test_filters: List[str]) -> None: 51 | self.test_filters += test_filters 52 | 53 | def add_config_file(self, path: pathlib.Path) -> None: 54 | self.test_run_configs += config_parser.load_test_config_file(str(path)) # type: ignore 55 | 56 | def add_test_class(self, cls: Type[base_test.BaseTestClass]) -> None: 57 | self.test_classes.append(cls) 58 | 59 | def add_test_module(self, path: pathlib.Path) -> None: 60 | try: 61 | module = SourceFileLoader(path.stem, str(path)).load_module() 62 | classes = inspect.getmembers(module, inspect.isclass) 63 | for _, cls in classes: 64 | if issubclass(cls, base_test.BaseTestClass): 65 | self.test_classes.append(cls) 66 | except ImportError: 67 | pass 68 | 69 | def add_path(self, path: pathlib.Path, root: bool = True) -> None: 70 | if path.is_file(): 71 | if path.name.endswith('_test.py'): 72 | self.add_test_module(path) 73 | elif not self.test_run_configs and not root and path.name in ('config.yml', 'config.yaml'): 74 | self.add_config_file(path) 75 | elif root: 76 | raise ValueError(f'{path} is not a test file') 77 | else: 78 | for child in path.iterdir(): 79 | self.add_path(child, root=False) 80 | 81 | def is_included(self, cls: base_test.BaseTestClass, test: str) -> bool: 82 | return not self.test_filters or any(filter_match(cls, test, filter) for filter in self.test_filters) 83 | 84 | @property 85 | def included_tests(self) -> Dict[Type[base_test.BaseTestClass], Tuple[str, List[str]]]: 86 | result: Dict[Type[base_test.BaseTestClass], Tuple[str, List[str]]] = {} 87 | for test_class in self.test_classes: 88 | cls = test_class(config_parser.TestRunConfig()) 89 | test_names: List[str] = [] 90 | try: 91 | # Executes pre-setup procedures, this is required since it might 92 | # generate test methods that we want to return as well. 93 | cls._pre_run() 94 | test_names = cls.tests or cls.get_existing_test_names() # type: ignore 95 | test_names = list(test for test in test_names if self.is_included(cls, test)) 96 | if test_names: 97 | assert cls.TAG 98 | result[test_class] = (cls.TAG, test_names) 99 | except Exception: 100 | logging.exception('Failed to retrieve generated tests.') 101 | finally: 102 | cls._clean_up() 103 | return result 104 | 105 | def run(self) -> bool: 106 | # Create logs directory. 107 | if not self.logs_dir.exists(): 108 | self.logs_dir.mkdir() 109 | 110 | # Enable Bumble snoop logs. 111 | os.environ.setdefault('BUMBLE_SNOOPER', f'btsnoop:file:{self.logs_dir}/{_BUMBLE_BTSNOOP_FMT}') 112 | 113 | # Execute the suite 114 | ok = True 115 | for config in self.test_run_configs: 116 | test_bed: str = config.test_bed_name # type: ignore 117 | if self.test_beds and test_bed not in self.test_beds: 118 | continue 119 | runner = test_runner.TestRunner(config.log_path, config.testbed_name) 120 | with runner.mobly_logger(console_level=logging.DEBUG if self.logs_verbose else logging.INFO): 121 | for test_class, (_, tests) in self.included_tests.items(): 122 | runner.add_test_class(config, test_class, tests) # type: ignore 123 | try: 124 | runner.run() 125 | ok = ok and runner.results.is_all_pass 126 | except signals.TestAbortAll: 127 | ok = ok and not self.test_beds 128 | except Exception: 129 | logging.exception('Exception when executing %s.', config.testbed_name) 130 | ok = False 131 | return ok 132 | 133 | 134 | def filter_match(cls: base_test.BaseTestClass, test: str, filter: str) -> bool: 135 | tag: str = cls.TAG # type: ignore 136 | if '.test_' in filter: 137 | return f"{tag}.{test}".startswith(filter) 138 | if filter.startswith('test_'): 139 | return test.startswith(filter) 140 | return tag.startswith(filter) 141 | -------------------------------------------------------------------------------- /doc/android-bumble-extensions.md: -------------------------------------------------------------------------------- 1 | # Android Bumble extensions 2 | 3 | While [experimental Pandora API][pandora-experimental-api-code] are implemented 4 | for Android, they may not be for Bumble. When writing Android Avatar tests, if 5 | you need one of these APIs on Bumble, you can implement it by adding a custom 6 | service. 7 | 8 | Note: Before going further, make sure you read the 9 | [Implementing your own tests](android-guide#implementing-your-own-tests) 10 | section of the Avatar Android guide. 11 | 12 | In the following example, we will add stub files required to write HID tests for 13 | the [`hid.proto`][hid-proto] interface. 14 | 15 | Note: The code for this example is available in a [WIP CL][hid-example]. 16 | 17 | ## Create a new test class 18 | 19 | Follow [Create a test class](android-guide#create-a-test-class) to create a 20 | `HidTest` test class in `hid_test.py`. 21 | 22 | ## Create a Bumble HID extension 23 | 24 | Create an HID extension file: 25 | 26 | ```shell 27 | cd packages/modules/Bluetooth/ 28 | touch pandora/server/bumble_experimental/hid.py 29 | ``` 30 | 31 | Add the following code to it: 32 | 33 | ```python 34 | import grpc 35 | import logging 36 | 37 | from bumble.device import Device 38 | from pandora_experimental.hid_grpc import ( 39 | SendHostReportRequest, 40 | SendHostReportResponse, 41 | ) 42 | from pandora_experimental.hid_grpc_aio import HIDServicer 43 | 44 | # This class implements the HID experimental Pandora interface. 45 | class HIDService(HIDServicer): 46 | device: Device 47 | 48 | def __init__(self, device: Device) -> None: 49 | self.device = device 50 | 51 | async def SendHostReport( 52 | self, 53 | request: SendHostReportRequest, 54 | context: grpc.ServicerContext 55 | ) -> SendHostReportResponse: 56 | logging.info( 57 | f'SendHostReport(address={request.address}, ' 58 | f'type={request.report_type}, report="{request.report}")' 59 | ) 60 | # You should implement SendHostReport here by doing direct call to the 61 | # Bumble instance (i.e. `self.device`). 62 | return SendHostReportResponse() 63 | ``` 64 | 65 | ## Add an HID test to your test class 66 | 67 | In `hid_test.py`: 68 | 69 | ```python 70 | def test_report(self) -> None: 71 | from pandora_experimental.hid_grpc import HID, HidReportType 72 | HID(self.ref.channel).SendHostReport( 73 | address=self.dut.address, 74 | report_type=HidReportType.HID_REPORT_TYPE_INPUT, 75 | report="pause cafe" 76 | ) 77 | ``` 78 | 79 | ### Add your HID test class to Avatar test suite runner 80 | 81 | ```diff 82 | diff --git a/android/pandora/test/main.py b/android/pandora/test/main.py 83 | --- a/android/pandora/test/main.py 84 | +++ b/android/pandora/test/main.py 85 | @@ -3,18 +3,22 @@ from avatar import bumble_server 86 | 87 | import example 88 | import gatt_test 89 | +import hid_test 90 | 91 | import logging 92 | import sys 93 | 94 | from bumble_experimental.gatt import GATTService 95 | from pandora_experimental.gatt_grpc_aio import add_GATTServicer_to_server 96 | +from bumble_experimental.hid import HIDService 97 | +from pandora_experimental.hid_grpc_aio import add_HIDServicer_to_server 98 | 99 | -_TEST_CLASSES_LIST = [example.ExampleTest, gatt_test.GattTest] 100 | +_TEST_CLASSES_LIST = [example.ExampleTest, gatt_test.GattTest, hid_test.HidTest] 101 | 102 | 103 | def _bumble_servicer_hook(server: bumble_server.Server) -> None: 104 | add_GATTServicer_to_server(GATTService(server.bumble.device), server.server) 105 | + add_HIDServicer_to_server(HIDService(server.bumble.device), server.server) 106 | 107 | 108 | if __name__ == "__main__": 109 | ``` 110 | 111 | You can now run your new HID test: 112 | 113 | ```shell 114 | avatar run --mobly-std-log --include-filter 'HidTest' 115 | ``` 116 | 117 | [pandora-experimental-api-code]: https://cs.android.com/android/platform/superproject/+/main:packages/modules/Bluetooth/pandora/interfaces/pandora_experimental/ 118 | 119 | [hid-proto]: https://cs.android.com/android/platform/superproject/main/+/main:packages/modules/Bluetooth/pandora/interfaces/pandora_experimental/hid.proto 120 | 121 | [hid-example]: https://android-review.git.corp.google.com/c/platform/packages/modules/Bluetooth/+/2454811 122 | -------------------------------------------------------------------------------- /doc/android-guide.md: -------------------------------------------------------------------------------- 1 | # Avatar with Android 2 | 3 | Since Android provides an implementation of the [Pandora APIs]( 4 | https://github.com/google/bt-test-interfaces), Avatar can run with Android 5 | devices. 6 | 7 | ## Setup 8 | 9 | The standard Avatar setup on Android is to test a [Cuttlefish]( 10 | https://source.android.com/docs/setup/create/cuttlefish) virtual Android DUT 11 | against a [Bumble](https://github.com/google/bumble) virtual Reference device 12 | (REF). 13 | 14 | Pandora APIs are implemented both on Android in a 15 | [PandoraServer][pandora-server-code] app and on [Bumble]( 16 | https://github.com/google/bumble/tree/main/bumble/pandora). The communication 17 | between the virtual Android DUT and the virtual Bumble Reference device is made 18 | through [Rootcanal][rootcanal-code], a virtual Bluetooth Controller. 19 | 20 | ![Avatar Android architecture]( 21 | images/avatar-android-bumble-virtual-architecture-simplified.svg) 22 | 23 | ## Usage 24 | 25 | There are two different command line interfaces to use Avatar on Android. 26 | 27 | ### Prerequisites 28 | 29 | You must have a running CF instance. If not, you can run the following commands 30 | from the root of your Android repository: 31 | 32 | ```shell 33 | source build/envsetup.sh 34 | lunch aosp_cf_x86_64_phone-trunk_staging-userdebug 35 | acloud create --local-image --local-instance 36 | ``` 37 | 38 | Note: For Googlers, from an internal Android repository, use the 39 | `cf_x86_64_phone-userdebug` target instead. You can also use a CF remote 40 | instance by removing `--local-instance`. 41 | 42 | ### `avatar` CLI (preferred) 43 | 44 | You can run all the existing Avatar tests on Android by running the following 45 | commands from the root of your Android repository: 46 | 47 | ```shell 48 | cd packages/modules/Bluetooth 49 | source android/pandora/test/envsetup.sh 50 | avatar --help 51 | avatar format # Format test files 52 | avatar lint # Lint test files 53 | avatar run --mobly-std-log # '--mobly-std-log' to print mobly logs, silent otherwise 54 | ``` 55 | 56 | Note: If you have errors such as `ModuleNotFoundError: no module named pip`, 57 | reset your Avatar cache by doing `rm -rf ~/.cache/avatar/venv`. 58 | 59 | ### `atest` CLI 60 | 61 | You can also run all Avatar tests using [`atest`]( 62 | https://source.android.com/docs/core/tests/development/atest): 63 | 64 | ```shell 65 | atest avatar -v # All tests in verbose 66 | ``` 67 | 68 | ## Build a new Avatar test 69 | 70 | Follow the instructions below to create your first Avatar test. 71 | 72 | ### Create a test class 73 | 74 | Create a new Avatar test class file `codelab_test.py` in the Android Avatar 75 | tests folder, `packages/modules/Bluetooth/android/pandora/test/`: 76 | 77 | ```python 78 | import asyncio # Provides utilities for calling asynchronous functions. 79 | 80 | from typing import Optional # Avatar is strictly typed. 81 | 82 | # Importing Mobly modules required for the test. 83 | from mobly import base_test # Mobly base test class . 84 | 85 | # Importing Avatar classes and functions required for the test. 86 | from avatar import PandoraDevices 87 | from avatar.aio import asynchronous # A decorator to run asynchronous functions. 88 | from avatar.pandora_client import BumblePandoraClient, PandoraClient 89 | 90 | # Importing Pandora gRPC message & enum types. 91 | from pandora.host_pb2 import RANDOM, DataTypes 92 | 93 | 94 | # The test class to test the LE (Bluetooth Low Energy) Connectivity. 95 | class CodelabTest(base_test.BaseTestClass): # type: ignore[misc] 96 | devices: Optional[PandoraDevices] = None 97 | dut: PandoraClient 98 | ref: BumblePandoraClient # `BumblePandoraClient` is a sub-class of `PandoraClient` 99 | 100 | # Method to set up the DUT and REF devices for the test (called once). 101 | def setup_class(self) -> None: 102 | self.devices = PandoraDevices(self) # Create Pandora devices from the config. 103 | self.dut, ref, *_ = self.devices 104 | assert isinstance(ref, BumblePandoraClient) # REF device is a Bumble device. 105 | self.ref = ref 106 | 107 | # Method to tear down the DUT and REF devices after the test (called once). 108 | def teardown_class(self) -> None: 109 | # Stopping all the devices if any. 110 | if self.devices: self.devices.stop_all() 111 | 112 | # Method to set up the test environment (called before each test). 113 | @asynchronous 114 | async def setup_test(self) -> None: 115 | # Reset DUT and REF devices asynchronously. 116 | await asyncio.gather(self.dut.reset(), self.ref.reset()) 117 | 118 | # Method to write the actual test. 119 | def test_void(self) -> None: 120 | assert True # This is a placeholder for the test body. 121 | ``` 122 | 123 | For now, your test class contains only a single `test_void`. 124 | 125 | ### Add a test class to Avatar test suite runner 126 | 127 | Add the tests from your test class into 128 | [Avatar Android test suite runner][avatar-android-suite-runner-code]: 129 | 130 | ```diff 131 | diff --git a/android/pandora/test/main.py b/android/pandora/test/main.py 132 | index a124306e8f..742e087521 100644 133 | --- a/android/pandora/test/main.py 134 | +++ b/android/pandora/test/main.py 135 | @@ -1,11 +1,12 @@ 136 | from mobly import suite_runner 137 | 138 | +import codelab_test 139 | import example 140 | 141 | import logging 142 | import sys 143 | 144 | -_TEST_CLASSES_LIST = [example.ExampleTest] 145 | +_TEST_CLASSES_LIST = [example.ExampleTest, codelab_test.CodelabTest] 146 | ``` 147 | 148 | You can now try to run your test class using `avatar`: 149 | 150 | ```shell 151 | avatar run --mobly-std-log --include-filter 'CodelabTest' # All the CodelabTest tests 152 | avatar run --mobly-std-log --include-filter 'CodelabTest#test_void' # Run only test_void 153 | ``` 154 | 155 | Or using `atest`: 156 | 157 | ```shell 158 | atest avatar -v # all tests 159 | atest avatar:'CodelabTest#test_void' -v # Run only test_void 160 | ``` 161 | 162 | ### Add a real test 163 | 164 | You can add a new test to your test class by creating a new method `test_<>`, 165 | which is implemented by a series of calls to the Pandora APIs of either the 166 | Android DUT or the Bumble REF device and assert statement. 167 | 168 | A call to a Pandora API is made using `..()`. 169 | Pandora APIs and their descriptions are in 170 | [`external/pandora/bt-test-interfaces`][pandora-api-code] or 171 | [`package/module/Bluetooth/pandora/interfaces/pandora_experimental`][pandora-experimental-api-code]. 172 | 173 | For example, add the following test to your `codelab_test.py` test class: 174 | 175 | ```python 176 | # Test the LE connection between the central device (DUT) and peripheral device (REF). 177 | def test_le_connect_central(self) -> None: 178 | # Start advertising on the REF device, this makes it discoverable by the DUT. 179 | # The REF advertises as `connectable` and the own address type is set to `random`. 180 | advertisement = self.ref.host.Advertise( 181 | # Legacy since extended advertising is not yet supported in Bumble. 182 | legacy=True, 183 | connectable=True, 184 | own_address_type=RANDOM, 185 | # DUT device matches the REF device using the specific manufacturer data. 186 | data=DataTypes(manufacturer_specific_data=b'pause cafe'), 187 | ) 188 | 189 | # Start scanning on the DUT device. 190 | scan = self.dut.host.Scan(own_address_type=RANDOM) 191 | # Find the REF device using the specific manufacturer data. 192 | peer = next((peer for peer in scan 193 | if b'pause cafe' in peer.data.manufacturer_specific_data)) 194 | scan.cancel() # Stop the scan process on the DUT device. 195 | 196 | # Connect the DUT device to the REF device as central device. 197 | connect_res = self.dut.host.ConnectLE( 198 | own_address_type=RANDOM, 199 | random=peer.random, # Random REF address found during scanning. 200 | ) 201 | advertisement.cancel() 202 | 203 | # Assert that the connection was successful. 204 | assert connect_res.connection 205 | dut_ref = connect_res.connection 206 | 207 | # Disconnect the DUT device from the REF device. 208 | self.dut.host.Disconnect(connection=dut_ref) 209 | ``` 210 | 211 | Then, run your new `test_le_connect_central` test: 212 | 213 | ```shell 214 | avatar run --mobly-std-log --include-filter 'CodelabTest' 215 | ``` 216 | 217 | ### Implement your own tests 218 | 219 | Before starting, you should make sure you have clearly identified the tests you 220 | want to build: see [Where should I start to implement Avatar tests?]( 221 | overview#designing-avatar-tests) 222 | 223 | When your test is defined, you can implement it using the available 224 | [stable Pandora APIs][pandora-api-code]. 225 | 226 | Note: You can find many test examples in 227 | [`packages/modules/Bluetooth/android/pandora/test/`][avatar-android-tests-code]. 228 | 229 | **If you need an API which is not part of the finalized Pandora APIs to build 230 | your test**: 231 | 232 | 1. If the API you need is **on the Android side**: you can also directly use the 233 | [experimental Pandora API][pandora-experimental-api-code], in the same 234 | fashion as the stable ones. 235 | 236 | Warning: those APIs are subject to changes. 237 | 238 | 1. If the API you need is on the Bumble side: you can also use the experimental 239 | Pandora APIs by creating custom [Bumble extensions]( 240 | android-bumble-extensions). 241 | 242 | 1. If the API you need is not part of the experimental Pandora APIs: 243 | 244 | * Create an issue. The Avatar team will decide whether to create a new API or 245 | not. We notably don't want to create APIs for device specific behaviors. 246 | 247 | * If it is decided not to add a new API, you can instead access the Bumble 248 | Bluetooth stack directly within your test. For example: 249 | 250 | ```python 251 | @asynchronous 252 | async def test_pause_cafe(self) -> None: 253 | from bumble.core import BT_LE_TRANSPORT 254 | 255 | # `self.ref.device` an instance of `bumble.device.Device` 256 | connection = await self.ref.device.find_peer_by_name( # type: ignore 257 | "Pause cafe", 258 | transport=BT_LE_TRANSPORT, 259 | ) 260 | 261 | assert connection 262 | await self.ref.device.encrypt(connection, enable=True) # type: ignore 263 | ``` 264 | 265 | ## Contribution guide 266 | 267 | ### Modify the Avatar repository 268 | 269 | All contributions to Avatar (not tests) must be submitted first to GitHub since 270 | it is the source of truth for Avatar. To simplify the development process, 271 | Android developers can make their changes on Android and get reviews on Gerrit 272 | as usual, but then push it first to GitHub: 273 | 274 | 1. Create your CL in [`external/pandora/Avatar`][avatar-code]. 275 | 1. Ask for review on Gerrit as usual. 276 | 1. Upon review and approval, the Avatar team creates a Pull Request on GitHub 277 | with you as the author. The PR is directly approved. 278 | 1. After it passes GitHub Avatar CI, the PR is merged. 279 | 1. Then, the Avatar team merges the change from GitHub to Android. 280 | 281 | ### Upstream experimental Pandora APIs 282 | 283 | The Pandora team continuously works to stabilize new Pandora APIs. When an 284 | experimental Pandora API is considered stable, it is moved from 285 | [`package/module/Bluetooth/pandora/interfaces/pandora_experimental`][pandora-experimental-api-code] 286 | to the official stable Pandora API repository in 287 | [`external/pandora/bt-test-interfaces`][pandora-api-code]. 288 | 289 | ### Upstream Android tests to the Avatar repository 290 | 291 | On a regular basis, the Avatar team evaluates Avatar tests which have been 292 | submitted to Android and upstream them to the Avatar repository in the 293 | [`cases`](/cases/) folder if they are generic, meaning not related to Android 294 | specifically. 295 | 296 | Such added generic tests are removed from 297 | [`packages/modules/Bluetooth/android/pandora/test/`][avatar-android-tests-code]. 298 | 299 | ## Presubmit tests 300 | 301 | All Avatar tests submitted in the 302 | [Android Avatar tests folder][avatar-android-tests-code] and added to 303 | [Avatar suite runner][avatar-android-suite-runner-code] as well as the tests in 304 | the [generic Avatar tests folder][avatar-tests-code], are run in Android 305 | Bluetooth presubmit tests (for every CL). 306 | 307 | Note: Avatar tests will soon also be run regularly on physical devices in 308 | Android postsubmit tests. 309 | 310 | [pandora-server-code]: https://cs.android.com/android/platform/superproject/main/+/main:packages/modules/Bluetooth/android/pandora/server/ 311 | 312 | [rootcanal-code]: https://cs.android.com/android/platform/superproject/main/+/main:packages/modules/Bluetooth/tools/rootcanal 313 | 314 | [pandora-api-code]: https://cs.android.com/android/platform/superproject/+/main:external/pandora/bt-test-interfaces/pandora 315 | 316 | [pandora-experimental-api-code]: https://cs.android.com/android/platform/superproject/main/+/main:packages/modules/Bluetooth/pandora/interfaces/pandora_experimental/ 317 | 318 | [avatar-tests-code]: https://cs.android.com/android/platform/superproject/+/main:external/pandora/avatar/cases 319 | 320 | [avatar-android-tests-code]: https://cs.android.com/android/platform/superproject/main/+/main:packages/modules/Bluetooth/android/pandora/test/ 321 | 322 | [avatar-code]: https://cs.android.com/android/platform/superproject/+/main:external/pandora/avatar 323 | 324 | [avatar-android-suite-runner-code]: https://cs.android.com/android/platform/superproject/main/+/main:packages/modules/Bluetooth/android/pandora/test/main.py 325 | -------------------------------------------------------------------------------- /doc/overview.md: -------------------------------------------------------------------------------- 1 | # Avatar 2 | 3 | Avatar is a python Bluetooth testing tool orchestrating multiple devices which 4 | implement [Pandora APIs](https://github.com/google/bt-test-interfaces) to 5 | automate Bluetooth interoperability and functional tests. 6 | 7 | ## Main architecture 8 | 9 | Avatar is built around 4 key components: 10 | 11 | * [Pandora APIs](https://github.com/google/bt-test-interfaces): They provide a 12 | common abstraction for Avatar to interact with all Bluetooth implementations, 13 | exposing all standard Bluetooth capabilities over [gRPC](https://grpc.io/). 14 | * [Bumble](https://github.com/google/bumble): a python Bluetooth stack which 15 | can be used as a reference against any DUT. 16 | * [Rootcanal][rootcanal-code]: A virtual Bluetooth Controller which emulates the 17 | Bluetooth communication between the devices being tested. It is notably 18 | integrated in Cuttlefish (CF), an Android virtual device. 19 | * [Mobly](https://github.com/google/mobly): Avatar core python test runner. 20 | 21 | For example, here is Avatar Android architecture: 22 | 23 | ![Avatar Android architecture]( 24 | images/avatar-android-bumble-virtual-architecture-simplified.svg) 25 | 26 | A basic Avatar test is built by calling a Pandora API exposed by the DUT to 27 | trigger a Bluetooth action and calling another Pandora API on a Reference 28 | device (REF) to verify that this action has been correctly executed. 29 | 30 | For example: 31 | 32 | ```python 33 | # Test the LE connection between the central device (DUT) and peripheral device (REF). 34 | def test_le_connect_central(self) -> None: 35 | # Start advertising on the REF device, this makes it discoverable by the DUT. 36 | # The REF advertises as `connectable` and the own address type is set to `random`. 37 | advertisement = self.ref.host.Advertise( 38 | # Legacy since extended advertising is not yet supported in Bumble. 39 | legacy=True, 40 | connectable=True, 41 | own_address_type=RANDOM, 42 | # DUT device matches the REF device using the specific manufacturer data. 43 | data=DataTypes(manufacturer_specific_data=b'pause cafe'), 44 | ) 45 | 46 | # Start scanning on the DUT device. 47 | scan = self.dut.host.Scan(own_address_type=RANDOM) 48 | # Find the REF device using the specific manufacturer data. 49 | peer = next((peer for peer in scan 50 | if b'pause cafe' in peer.data.manufacturer_specific_data)) 51 | scan.cancel() # Stop the scan process on the DUT device. 52 | 53 | # Connect the DUT device to the REF device as central device. 54 | connect_res = self.dut.host.ConnectLE( 55 | own_address_type=RANDOM, 56 | random=peer.random, # Random REF address found during scanning. 57 | ) 58 | advertisement.cancel() 59 | 60 | # Assert that the connection was successful. 61 | assert connect_res.connection 62 | dut_ref = connect_res.connection 63 | 64 | # Disconnect the DUT device from the REF device. 65 | self.dut.host.Disconnect(connection=dut_ref) 66 | ``` 67 | 68 | Avatar tests requires DUT and REF to provide a Pandora gRPC server which 69 | implements the Pandora APIs and exposes them. 70 | 71 | ## Bumble as a reference device 72 | 73 | By default, Avatar uses Bumble as a reference device. **Bumble is very practical 74 | at emulating non-Android interoperability behaviors**: because it is written in 75 | python and thus interpreted, its behavior can be overridden directly within the 76 | tests by using direct python calls to Bumble internal functions when no Pandora 77 | API is available (see [Implementing your own tests]( 78 | android-guide#implementing-your-own-tests)). 79 | 80 | For example, using another Android device to emulate an interoperability 81 | behavior of a specific headset would require building dedicated hooks in the 82 | Android Bluetooth stack and the corresponding APIs which wouldn't be practical. 83 | 84 | However, other setups are also supported (see [Extended architecture](#extended-architecture)). 85 | 86 | ## Types of Avatar tests 87 | 88 | Avatar principally addresses 2 types of tests: 89 | 90 | ### Functional tests 91 | 92 | * Verify that the DUT is meeting a required specification. 93 | * This can be either the official Bluetooth specification or a vendor 94 | specification (for example, Google's ASHA specification). 95 | * Aim to address all functional tests not supported by the official Bluetooth 96 | Profile Tuning Suite (PTS). 97 | 98 | ### Interoperability regression tests 99 | 100 | * Built by isolating and simulating only the problematic behavior of a peer 101 | device in Bumble after it has been discovered. 102 | * Effectively scalable because it would be impossible to set up a dedicated 103 | physical test bench for each peer device presenting an interoperability issue. 104 | 105 | ### Examples 106 | 107 | * [Android ASHA central tests][asha-central-tests-code] 108 | * [Android GATT read characteristic while pairing test][gatt-test-example-code] 109 | 110 | ## Design Avatar tests 111 | 112 | There are different approaches to identify new Avatar tests to implement: 113 | 114 | ### From the Bluetooth specification (or a Google specification) 115 | 116 | The first approach is for creating functional tests: mandatory behaviors are 117 | identified in the Bluetooth specification and corresponding test cases are 118 | defined, and then implemented, except for test cases which are already covered 119 | by PTS and which must be implemented with PTS-bot. This helps cover most of the 120 | usual flows in the Bluetooth stack, and prevent any new changes to break them. 121 | 122 | For example: [ASHA central tests][asha-central-tests-spec] (identified from the 123 | ASHA specification). 124 | 125 | This approach applies to all layers of the stack and should in general be 126 | prioritized over the other approaches because breaking any of those usual flows 127 | likely translates into a top priority issue (since a large number of devices 128 | can be impacted). 129 | 130 | ### From a bug fix 131 | 132 | The second approach is for creating interoperability regression tests: in most 133 | cases, interoperability issues are discovered in Dog Food or production, due to 134 | the extremely large number of Bluetooth devices on the market, which cannot be 135 | tested preventively, even manually. 136 | 137 | When such a bug is fixed, Avatar can be leveraged to build a corresponding 138 | regression test. 139 | 140 | ### From a coverage report 141 | 142 | The third approach is to start from a code coverage report: uncovered code 143 | paths are identified and corresponding Avatar tests are implemented to target 144 | them. 145 | 146 | ## Extended architecture 147 | 148 | Avatar is capable to handle any setup with multiple devices which implement 149 | the Pandora APIs. Avatar tests can be run physically or virtually (with 150 | Rootcanal). 151 | 152 | ![Avatar Android architecture]( 153 | images/avatar-extended-architecture-simplified.svg) 154 | 155 | Avatar notably supports the following setups: 156 | 157 | * Bumble DUT vs Bumble REF 158 | * Android DUT vs Bumble REF 159 | * Android DUT vs Android REF 160 | 161 | [rootcanal-code]: https://cs.android.com/android/platform/superproject/+/main:packages/modules/Bluetooth/tools/rootcanal/ 162 | 163 | [asha-central-tests-code]: https://cs.android.com/android/platform/superproject/+/main:packages/modules/Bluetooth/android/pandora/test/asha_test.py 164 | 165 | [gatt-test-example-code]: https://r.android.com/2470981 166 | 167 | [asha-central-tests-spec]: https://docs.google.com/document/d/1HmihYrjBGDys4FAEgh05e5BHPMxNUiz8QIOYez9GT1M/edit?usp=sharing 168 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "pandora-avatar" 3 | authors = [{name = "Pandora", email = "pandora-core@google.com"}] 4 | readme = "README.md" 5 | dynamic = ["version", "description"] 6 | requires-python = ">=3.8" 7 | classifiers = [ 8 | "Programming Language :: Python :: 3.10", 9 | "License :: OSI Approved :: Apache Software License" 10 | ] 11 | dependencies = [ 12 | "bt-test-interfaces>=0.0.6", 13 | "bumble>=0.0.199", 14 | "protobuf==4.24.2", 15 | "grpcio>=1.62.1", 16 | "mobly==1.12.2", 17 | "portpicker>=1.5.2", 18 | ] 19 | 20 | [project.urls] 21 | Source = "https://github.com/google/avatar" 22 | 23 | [project.scripts] 24 | avatar = "avatar:main" 25 | 26 | [project.optional-dependencies] 27 | dev = [ 28 | "rootcanal>=1.10.0", 29 | "grpcio-tools>=1.62.1", 30 | "pyright==1.1.298", 31 | "mypy==1.5.1", 32 | "black==24.10.0", 33 | "isort==5.12.0", 34 | "types-psutil==5.9.5.16", 35 | "types-setuptools==68.1.0.1", 36 | "types-protobuf==4.24.0.1" 37 | ] 38 | 39 | [tool.flit.module] 40 | name = "avatar" 41 | 42 | [tool.flit.sdist] 43 | include = ["doc/"] 44 | 45 | [tool.black] 46 | line-length = 119 47 | target-version = ["py38", "py39", "py310", "py311"] 48 | skip-string-normalization = true 49 | 50 | [tool.isort] 51 | profile = "black" 52 | line_length = 119 53 | no_sections = true 54 | lines_between_types = 1 55 | force_single_line = true 56 | single_line_exclusions = ["typing", "typing_extensions", "collections.abc"] 57 | 58 | [tool.pyright] 59 | include = ["avatar"] 60 | exclude = ["**/__pycache__", "**/*_pb2.py"] 61 | typeCheckingMode = "strict" 62 | useLibraryCodeForTypes = true 63 | verboseOutput = false 64 | reportMissingTypeStubs = false 65 | reportUnknownLambdaType = false 66 | reportImportCycles = false 67 | reportPrivateUsage = false 68 | 69 | [tool.mypy] 70 | strict = true 71 | warn_unused_ignores = false 72 | files = ["avatar"] 73 | 74 | [[tool.mypy.overrides]] 75 | module = "grpc.*" 76 | ignore_missing_imports = true 77 | 78 | [[tool.mypy.overrides]] 79 | module = "google.protobuf.*" 80 | ignore_missing_imports = true 81 | 82 | [[tool.mypy.overrides]] 83 | module = "mobly.*" 84 | ignore_missing_imports = true 85 | 86 | [[tool.mypy.overrides]] 87 | module = "portpicker.*" 88 | ignore_missing_imports = true 89 | 90 | [tool.pytype] 91 | inputs = ['avatar'] 92 | 93 | [build-system] 94 | requires = ["flit_core==3.7.1"] 95 | build-backend = "flit_core.buildapi" 96 | --------------------------------------------------------------------------------