├── .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 | 
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 | 
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 | 
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 |
--------------------------------------------------------------------------------