├── .github └── workflows │ ├── python-package.yml │ └── python-publish.yml ├── .gitignore ├── .markdownlint.yaml ├── .pre-commit-config.yaml ├── .vscode └── settings.json ├── .yamllint ├── LICENSE ├── README.md ├── TvKeys.txt ├── mypy.ini ├── pyproject.toml ├── setup.cfg ├── src ├── androidtvremote2 │ ├── __init__.py │ ├── androidtv_remote.py │ ├── base.py │ ├── certificate_generator.py │ ├── const.py │ ├── exceptions.py │ ├── pairing.py │ ├── polo.proto │ ├── polo_pb2.py │ ├── polo_pb2.pyi │ ├── remote.py │ ├── remotemessage.proto │ ├── remotemessage_pb2.py │ └── remotemessage_pb2.pyi └── demo.py └── tests ├── __init__.py └── test_androidtv_remote.py /.github/workflows/python-package.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # https://github.com/actions/starter-workflows/blob/main/ci/python-package.yml 3 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 4 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 5 | 6 | name: Python package 7 | 8 | on: 9 | push: 10 | branches: [main] 11 | pull_request: 12 | branches: [main] 13 | workflow_dispatch: 14 | 15 | jobs: 16 | build: 17 | 18 | runs-on: ubuntu-latest 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | python-version: ["3.12"] 23 | 24 | steps: 25 | - uses: actions/checkout@v3 26 | - name: Set up Python ${{ matrix.python-version }} 27 | uses: actions/setup-python@v4 28 | with: 29 | python-version: ${{ matrix.python-version }} 30 | - name: Install dependencies 31 | run: | 32 | python -m pip install --upgrade pip 33 | python -m pip install . 34 | python -m pip install flake8 pytest ruff mypy mypy-protobuf 35 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 36 | - name: Lint with flake8 37 | run: | 38 | # stop the build if there are Python syntax errors or undefined names 39 | # exit-zero treats all errors as warnings. 40 | flake8 . --count --exit-zero --show-source --statistics 41 | - name: Lint with ruff 42 | run: | 43 | ruff check . 44 | - name: Static typing with mypy 45 | run: | 46 | mkdir -p .mypy_cache 47 | mypy --install-types --non-interactive . 48 | - name: Test with pytest 49 | run: | 50 | pytest 51 | -------------------------------------------------------------------------------- /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # https://github.com/actions/starter-workflows/blob/main/ci/python-publish.yml 3 | # This workflow will upload a Python Package to PyPI when a release is created 4 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries 5 | 6 | # This workflow uses actions that are not certified by GitHub. 7 | # They are provided by a third-party and are governed by 8 | # separate terms of service, privacy policy, and support 9 | # documentation. 10 | 11 | name: Upload Python Package 12 | 13 | on: 14 | release: 15 | types: [published] 16 | 17 | permissions: 18 | contents: read 19 | 20 | jobs: 21 | release-build: 22 | runs-on: ubuntu-latest 23 | 24 | steps: 25 | - uses: actions/checkout@v4 26 | 27 | - uses: actions/setup-python@v5 28 | with: 29 | python-version: "3.x" 30 | 31 | - name: Build release distributions 32 | run: | 33 | python -m pip install build 34 | python -m build 35 | 36 | - name: Upload distributions 37 | uses: actions/upload-artifact@v4 38 | with: 39 | name: release-dists 40 | path: dist/ 41 | 42 | pypi-publish: 43 | runs-on: ubuntu-latest 44 | needs: 45 | - release-build 46 | permissions: 47 | # IMPORTANT: this permission is mandatory for trusted publishing 48 | id-token: write 49 | 50 | # Dedicated environments with protections for publishing are strongly recommended. 51 | # For more information, see: https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#deployment-protection-rules 52 | environment: 53 | name: pypi 54 | # OPTIONAL: uncomment and update to include your PyPI project URL in the deployment status: 55 | # url: https://pypi.org/p/androidtvremote2 56 | # 57 | # ALTERNATIVE: if your GitHub Release name is the PyPI project version string 58 | # ALTERNATIVE: exactly, uncomment the following line instead: 59 | url: https://pypi.org/project/androidtvremote2/${{ github.event.release.name }} 60 | 61 | steps: 62 | - name: Retrieve release distributions 63 | uses: actions/download-artifact@v4 64 | with: 65 | name: release-dists 66 | path: dist/ 67 | 68 | - name: Publish release distributions to PyPI 69 | uses: pypa/gh-action-pypi-publish@release/v1 70 | with: 71 | packages-dir: dist/ 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # Certificates generated by demo.py 132 | *.pem 133 | -------------------------------------------------------------------------------- /.markdownlint.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # Default state for all rules 3 | default: true 4 | 5 | MD013: false # Disable line-length checking 6 | MD033: 7 | allowed_elements: [img] # Allow embedded image tags as there is no way to resize images in native markdown 8 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | exclude: '_pb2.py|_pb2_grpc.py' 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v5.0.0 6 | hooks: 7 | - id: check-added-large-files 8 | - id: check-ast 9 | - id: check-builtin-literals 10 | - id: check-case-conflict 11 | - id: check-docstring-first 12 | - id: check-executables-have-shebangs 13 | - id: check-json 14 | - id: check-merge-conflict 15 | - id: check-shebang-scripts-are-executable 16 | - id: check-symlinks 17 | - id: check-toml 18 | - id: check-vcs-permalinks 19 | - id: check-xml 20 | - id: check-yaml 21 | - id: debug-statements 22 | - id: destroyed-symlinks 23 | - id: detect-private-key 24 | - id: end-of-file-fixer 25 | - id: fix-byte-order-marker 26 | - id: forbid-submodules 27 | - id: mixed-line-ending 28 | - id: trailing-whitespace 29 | - repo: https://github.com/PyCQA/isort 30 | rev: 6.0.1 31 | hooks: 32 | - id: isort 33 | - repo: https://github.com/psf/black 34 | rev: 25.1.0 35 | hooks: 36 | - id: black 37 | - repo: https://github.com/PyCQA/flake8 38 | rev: 7.1.2 39 | hooks: 40 | - id: flake8 41 | - repo: https://github.com/astral-sh/ruff-pre-commit 42 | rev: v0.11.2 43 | hooks: 44 | - id: ruff 45 | args: [--fix] 46 | - repo: https://github.com/pre-commit/mirrors-mypy 47 | rev: v1.15.0 48 | hooks: 49 | - id: mypy 50 | additional_dependencies: [aiofiles, cryptography, protobuf, types-aiofiles, types-protobuf] 51 | - repo: https://github.com/asottile/pyupgrade 52 | rev: v3.19.1 53 | hooks: 54 | - id: pyupgrade 55 | args: [--py39-plus] 56 | - repo: https://github.com/adrienverge/yamllint.git 57 | rev: v1.37.0 58 | hooks: 59 | - id: yamllint 60 | - repo: https://github.com/igorshubovych/markdownlint-cli 61 | rev: v0.44.0 62 | hooks: 63 | - id: markdownlint 64 | - repo: https://github.com/codespell-project/codespell 65 | rev: v2.4.1 66 | hooks: 67 | - id: codespell 68 | 69 | ci: 70 | autoupdate_schedule: monthly 71 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "aiofiles", 4 | "androidtv", 5 | "androidtvremote", 6 | "atvremote", 7 | "DPAD", 8 | "fugu", 9 | "grpcio", 10 | "isort", 11 | "Isrc", 12 | "mypy", 13 | "peername", 14 | "proto", 15 | "protobuf", 16 | "protoc", 17 | "pynput", 18 | "pytest", 19 | "remotemessage", 20 | "selfsigned", 21 | "Varint", 22 | "venv", 23 | "zeroconf" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /.yamllint: -------------------------------------------------------------------------------- 1 | --- 2 | extends: default 3 | 4 | rules: 5 | line-length: 6 | level: warning 7 | allow-non-breakable-inline-mappings: true 8 | truthy: 9 | check-keys: false 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # androidtvremote2 2 | 3 | A Python library for interacting with Android TV using the Android TV Remote protocol v2. This is the same protocol the Google TV mobile app is using. It doesn't require ADB or enabling developer tools on the Android TV device. It only requires the [Android TV Remote Service](https://play.google.com/store/apps/details?id=com.google.android.tv.remote.service) that comes pre-installed on most Android TV devices. 4 | 5 | For a list of the most common commands you can send to the Android TV see: [TvKeys](https://github.com/tronikos/androidtvremote2/blob/main/TvKeys.txt). 6 | For a full list see [here](https://github.com/tronikos/androidtvremote2/blob/b4c49ac03043b1b9c40c2f2960e466d5a3b8bd67/src/androidtvremote2/remotemessage.proto#L90). 7 | In addition to commands you can send URLs to open apps registered to handle them. See [this guide](https://community.home-assistant.io/t/android-tv-remote-app-links-deep-linking-guide/567921) for how to find deep links for apps. 8 | 9 | ## Credits 10 | 11 | - Official [implementation](https://android.googlesource.com/platform/external/google-tv-pairing-protocol/+/refs/heads/master) of the pairing protocol in Java 12 | - [Implementation](https://github.com/farshid616/Android-TV-Remote-Controller-Python) in Python but for the old v1 protocol 13 | - [Implementation](https://github.com/louis49/androidtv-remote) in Node JS for the v2 protocol 14 | - [Description](https://github.com/Aymkdn/assistant-freebox-cloud/wiki/Google-TV-(aka-Android-TV)-Remote-Control-(v2)) of the v2 protocol 15 | 16 | ## Example 17 | 18 | See [demo.py](https://github.com/tronikos/androidtvremote2/blob/main/src/demo.py) 19 | 20 | ## Development environment 21 | 22 | ```sh 23 | python3 -m venv .venv 24 | source .venv/bin/activate 25 | # for Windows CMD: 26 | # .venv\Scripts\activate.bat 27 | # for Windows PowerShell: 28 | # .venv\Scripts\Activate.ps1 29 | 30 | # Install dependencies 31 | python -m pip install --upgrade pip 32 | python -m pip install . 33 | 34 | # Generate *_pb2.py from *.proto 35 | python -m pip install grpcio-tools mypy-protobuf 36 | python -m grpc_tools.protoc src/androidtvremote2/*.proto --python_out=src/androidtvremote2 --mypy_out=src/androidtvremote2 -Isrc/androidtvremote2 37 | 38 | # Run pre-commit 39 | python -m pip install pre-commit 40 | pre-commit autoupdate 41 | pre-commit install 42 | pre-commit run --all-files 43 | 44 | # Alternative: run formatter, lint, and type checking 45 | python -m pip install isort black flake8 ruff mypy 46 | isort . ; black . ; flake8 . ; ruff check . --fix ; mypy --install-types . 47 | 48 | # Run tests 49 | python -m pip install pytest 50 | pytest 51 | 52 | # Run demo 53 | python -m pip install pynput zeroconf 54 | python src/demo.py 55 | 56 | # Build package 57 | python -m pip install build 58 | python -m build 59 | ``` 60 | -------------------------------------------------------------------------------- /TvKeys.txt: -------------------------------------------------------------------------------- 1 | // Subset of keys in RemoteKeyCode enum in remotemessage.proto. See there for comments. 2 | // Based on https://android.googlesource.com/platform/frameworks/base/+/master/services/core/jni/com_android_server_tv_TvKeys.h 3 | 4 | // Gamepad buttons 5 | DPAD_UP 6 | DPAD_DOWN 7 | DPAD_LEFT 8 | DPAD_RIGHT 9 | DPAD_CENTER 10 | BUTTON_A 11 | BUTTON_B 12 | BUTTON_X 13 | BUTTON_Y 14 | 15 | // Volume Control 16 | VOLUME_DOWN 17 | VOLUME_UP 18 | VOLUME_MUTE 19 | MUTE 20 | 21 | POWER 22 | HOME 23 | BACK 24 | 25 | // Media Control 26 | MEDIA_PLAY_PAUSE 27 | MEDIA_PLAY 28 | MEDIA_PAUSE 29 | MEDIA_NEXT 30 | MEDIA_PREVIOUS 31 | MEDIA_STOP 32 | MEDIA_RECORD 33 | MEDIA_REWIND 34 | MEDIA_FAST_FORWARD 35 | 36 | // TV Control 37 | 0 38 | 1 39 | 2 40 | 3 41 | 4 42 | 5 43 | 6 44 | 7 45 | 8 46 | 9 47 | DEL 48 | ENTER 49 | CHANNEL_UP 50 | CHANNEL_DOWN 51 | 52 | // Old School TV Controls 53 | F1 54 | F2 55 | F3 56 | F4 57 | F5 58 | F6 59 | F7 60 | F8 61 | F9 62 | F10 63 | F11 64 | F12 65 | TV 66 | PROG_RED 67 | PROG_GREEN 68 | PROG_YELLOW 69 | PROG_BLUE 70 | 71 | BUTTON_MODE 72 | EXPLORER 73 | MENU 74 | INFO 75 | GUIDE 76 | TV_TELETEXT 77 | CAPTIONS 78 | DVR 79 | MEDIA_AUDIO_TRACK 80 | SETTINGS 81 | 82 | SEARCH 83 | ASSIST 84 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | exclude = (venv|build) 3 | python_version = 3.10 4 | show_error_codes = true 5 | follow_imports = silent 6 | ignore_missing_imports = true 7 | local_partial_types = true 8 | strict_equality = true 9 | no_implicit_optional = true 10 | warn_incomplete_stub = true 11 | warn_redundant_casts = true 12 | warn_unused_configs = true 13 | warn_unused_ignores = true 14 | enable_error_code = ignore-without-code, redundant-self, truthy-iterable 15 | disable_error_code = annotation-unchecked 16 | extra_checks = false 17 | check_untyped_defs = true 18 | disallow_incomplete_defs = true 19 | disallow_subclassing_any = true 20 | disallow_untyped_calls = true 21 | disallow_untyped_decorators = true 22 | disallow_untyped_defs = true 23 | warn_return_any = true 24 | warn_unreachable = true 25 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "androidtvremote2" 3 | version = "0.2.2" 4 | license = {text = "Apache-2.0"} 5 | authors = [ 6 | { name="tronikos", email="tronikos@gmail.com" }, 7 | ] 8 | description = "A Python library for interacting with Android TV using the Android TV Remote protocol v2" 9 | readme = "README.md" 10 | requires-python = ">=3.10" 11 | dependencies = [ 12 | "aiofiles>=0.8", 13 | "cryptography>=3", 14 | "protobuf>=4.21", 15 | ] 16 | 17 | [project.urls] 18 | "Homepage" = "https://github.com/tronikos/androidtvremote2" 19 | "Bug Tracker" = "https://github.com/tronikos/androidtvremote2/issues" 20 | 21 | [build-system] 22 | requires = ["setuptools"] 23 | build-backend = "setuptools.build_meta" 24 | 25 | [tool.black] 26 | extend-exclude = "_pb2.py|_pb2_grpc.py" 27 | 28 | [tool.isort] 29 | profile = "black" 30 | force_sort_within_sections = true 31 | combine_as_imports = true 32 | extend_skip_glob = ["*_pb2.py", "*_pb2_grpc.py"] 33 | 34 | [tool.ruff] 35 | target-version = "py311" 36 | exclude = ["*_pb2.py", "*_pb2_grpc.py", "*.pyi"] 37 | line-length = 127 38 | 39 | [tool.ruff.lint] 40 | select = [ 41 | "B007", # Loop control variable {name} not used within loop body 42 | "B014", # Exception handler with duplicate exception 43 | "C", # complexity 44 | "D", # docstrings 45 | "E", # pycodestyle 46 | "F", # pyflakes/autoflake 47 | "ICN001", # import concentions; {name} should be imported as {asname} 48 | "PGH004", # Use specific rule codes when using noqa 49 | "PLC0414", # Useless import alias. Import alias does not rename original package. 50 | "SIM105", # Use contextlib.suppress({exception}) instead of try-except-pass 51 | "SIM117", # Merge with-statements that use the same scope 52 | "SIM118", # Use {key} in {dict} instead of {key} in {dict}.keys() 53 | "SIM201", # Use {left} != {right} instead of not {left} == {right} 54 | "SIM212", # Use {a} if {a} else {b} instead of {b} if not {a} else {a} 55 | "SIM300", # Yoda conditions. Use 'age == 42' instead of '42 == age'. 56 | "SIM401", # Use get from dict with default instead of an if block 57 | "T20", # flake8-print 58 | "TRY004", # Prefer TypeError exception for invalid type 59 | "RUF006", # Store a reference to the return value of asyncio.create_task 60 | "UP", # pyupgrade 61 | "W", # pycodestyle 62 | ] 63 | 64 | ignore = [ 65 | "D203", # 1 blank line required before class docstring 66 | "D213", # Multi-line docstring summary should start at the second line 67 | # keep-runtime-annotations 68 | 'UP006', # Non PEP585 annotations 69 | 'UP007', # Non PEP604 annotations 70 | ] 71 | 72 | [tool.ruff.lint.flake8-pytest-style] 73 | fixture-parentheses = false 74 | 75 | [tool.ruff.lint.per-file-ignores] 76 | # Allow for demo script to write to stdout 77 | "demo.py" = ["T201"] 78 | 79 | [tool.ruff.lint.mccabe] 80 | max-complexity = 25 81 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | exclude = .venv,.git,docs,venv,bin,lib,deps,build,*_pb2.py,*_pb2_grpc.py 3 | max-complexity = 25 4 | doctests = True 5 | # To work with Black 6 | # E501: line too long 7 | # W503: Line break occurred before a binary operator 8 | # E203: Whitespace before ':' 9 | # D202 No blank lines allowed after function docstring 10 | # W504 line break after binary operator 11 | ignore = 12 | E501, 13 | W503, 14 | E203, 15 | D202, 16 | W504 17 | noqa-require-code = True 18 | -------------------------------------------------------------------------------- /src/androidtvremote2/__init__.py: -------------------------------------------------------------------------------- 1 | """Library implementing the Android TV Remote protocol.""" 2 | 3 | from .androidtv_remote import AndroidTVRemote 4 | from .exceptions import CannotConnect, ConnectionClosed, InvalidAuth 5 | 6 | __all__ = [ 7 | "AndroidTVRemote", 8 | "CannotConnect", 9 | "ConnectionClosed", 10 | "InvalidAuth", 11 | ] 12 | -------------------------------------------------------------------------------- /src/androidtvremote2/androidtv_remote.py: -------------------------------------------------------------------------------- 1 | """Pairing and connecting to an Android TV for remotely sending commands to it.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | from collections.abc import Callable 7 | import os 8 | import ssl 9 | from urllib.parse import urlparse 10 | 11 | import aiofiles 12 | from cryptography import x509 13 | 14 | from .certificate_generator import generate_selfsigned_cert 15 | from .const import LOGGER 16 | from .exceptions import CannotConnect, ConnectionClosed, InvalidAuth 17 | from .pairing import PairingProtocol 18 | from .remote import RemoteProtocol 19 | from .remotemessage_pb2 import RemoteDirection 20 | 21 | 22 | def _load_cert_chain(certfile: str, keyfile: str) -> ssl.SSLContext: 23 | ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) 24 | ssl_context.check_hostname = False 25 | ssl_context.verify_mode = ssl.VerifyMode.CERT_NONE 26 | ssl_context.load_cert_chain(certfile, keyfile) 27 | return ssl_context 28 | 29 | 30 | class AndroidTVRemote: 31 | """Pairing and connecting to an Android TV for remotely sending commands to it.""" 32 | 33 | def __init__( 34 | self, 35 | client_name: str, 36 | certfile: str, 37 | keyfile: str, 38 | host: str, 39 | api_port: int = 6466, 40 | pair_port: int = 6467, 41 | loop: asyncio.AbstractEventLoop | None = None, 42 | enable_ime: bool = True, 43 | ) -> None: 44 | """Initialize. 45 | 46 | :param client_name: client name. Will be shown on the Android TV during pairing. 47 | :param certfile: filename that contains the client certificate in PEM format. 48 | :param keyfile: filename that contains the public key in PEM format. 49 | :param host: IP address of the Android TV. 50 | :param api_port: port for connecting and sending commands. 51 | :param pair_port: port for pairing. 52 | :param loop: event loop. Used for connections and futures. 53 | :param enable_ime: Needed for getting current_app. 54 | Disable for devices that show 'Use keyboard on mobile device screen'. 55 | """ 56 | self._client_name = client_name 57 | self._certfile = certfile 58 | self._keyfile = keyfile 59 | self.host = host 60 | self._api_port = api_port 61 | self._pair_port = pair_port 62 | self._loop = loop or asyncio.get_running_loop() 63 | self._enable_ime = enable_ime 64 | self._transport: asyncio.Transport | None = None 65 | self._remote_message_protocol: RemoteProtocol | None = None 66 | self._pairing_message_protocol: PairingProtocol | None = None 67 | self._reconnect_task: asyncio.Task | None = None 68 | self._ssl_context: ssl.SSLContext | None = None 69 | self._is_on_updated_callbacks: list[Callable] = [] 70 | self._current_app_updated_callbacks: list[Callable] = [] 71 | self._volume_info_updated_callbacks: list[Callable] = [] 72 | self._is_available_updated_callbacks: list[Callable] = [] 73 | 74 | def is_on_updated(is_on: bool) -> None: 75 | for callback in self._is_on_updated_callbacks: 76 | callback(is_on) 77 | 78 | def current_app_updated(current_app: str) -> None: 79 | for callback in self._current_app_updated_callbacks: 80 | callback(current_app) 81 | 82 | def volume_info_updated(volume_info: dict[str, str | bool]) -> None: 83 | for callback in self._volume_info_updated_callbacks: 84 | callback(volume_info) 85 | 86 | def is_available_updated(is_available: bool) -> None: 87 | for callback in self._is_available_updated_callbacks: 88 | callback(is_available) 89 | 90 | self._on_is_on_updated = is_on_updated 91 | self._on_current_app_updated = current_app_updated 92 | self._on_volume_info_updated = volume_info_updated 93 | self._on_is_available_updated = is_available_updated 94 | 95 | @property 96 | def is_on(self) -> bool | None: 97 | """Whether the Android TV is on or off.""" 98 | if not self._remote_message_protocol: 99 | return None 100 | return self._remote_message_protocol.is_on 101 | 102 | @property 103 | def current_app(self) -> str | None: 104 | """Current app in the foreground on the Android TV. E.g. 'com.google.android.youtube.tv'.""" 105 | if not self._remote_message_protocol: 106 | return None 107 | return self._remote_message_protocol.current_app 108 | 109 | @property 110 | def device_info(self) -> dict[str, str] | None: 111 | """Device info (manufacturer, model, sw_version).""" 112 | if not self._remote_message_protocol: 113 | return None 114 | return self._remote_message_protocol.device_info 115 | 116 | @property 117 | def volume_info(self) -> dict[str, str | bool | int] | None: 118 | """Volume info (level, max, muted).""" 119 | if not self._remote_message_protocol: 120 | return None 121 | return self._remote_message_protocol.volume_info 122 | 123 | def add_is_on_updated_callback(self, callback: Callable) -> None: 124 | """Add a callback for when is_on is updated.""" 125 | self._is_on_updated_callbacks.append(callback) 126 | 127 | def remove_is_on_updated_callback(self, callback: Callable) -> None: 128 | """Remove a callback previously added via add_is_on_updated_callback. 129 | 130 | :raises ValueError: if callback not previously added. 131 | """ 132 | self._is_on_updated_callbacks.remove(callback) 133 | 134 | def add_current_app_updated_callback(self, callback: Callable) -> None: 135 | """Add a callback for when current_app is updated.""" 136 | self._current_app_updated_callbacks.append(callback) 137 | 138 | def remove_current_app_updated_callback(self, callback: Callable) -> None: 139 | """Remove a callback previously added via add_current_app_updated_callback. 140 | 141 | :raises ValueError: if callback not previously added. 142 | """ 143 | self._current_app_updated_callbacks.remove(callback) 144 | 145 | def add_volume_info_updated_callback(self, callback: Callable) -> None: 146 | """Add a callback for when volume_info is updated.""" 147 | self._volume_info_updated_callbacks.append(callback) 148 | 149 | def remove_volume_info_updated_callback(self, callback: Callable) -> None: 150 | """Remove a callback previously added via add_volume_info_updated_callback. 151 | 152 | :raises ValueError: if callback not previously added. 153 | """ 154 | self._volume_info_updated_callbacks.remove(callback) 155 | 156 | def add_is_available_updated_callback(self, callback: Callable) -> None: 157 | """Add a callback for when the Android TV is ready to receive commands or is unavailable.""" 158 | self._is_available_updated_callbacks.append(callback) 159 | 160 | def remove_is_available_updated_callback(self, callback: Callable) -> None: 161 | """Remove a callback previously added via add_is_available_updated_callback. 162 | 163 | :raises ValueError: if callback not previously added. 164 | """ 165 | self._is_available_updated_callbacks.remove(callback) 166 | 167 | async def async_generate_cert_if_missing(self) -> bool: 168 | """Generate client certificate and public key if missing. 169 | 170 | :returns: True if a new certificate was generated. 171 | """ 172 | if os.path.isfile(self._certfile) and os.path.isfile(self._keyfile): 173 | return False 174 | cert_pem, key_pem = generate_selfsigned_cert(self._client_name) 175 | async with aiofiles.open(self._certfile, "w", encoding="utf-8") as out: 176 | await out.write(cert_pem.decode("utf-8")) 177 | async with aiofiles.open(self._keyfile, "w", encoding="utf-8") as out: 178 | await out.write(key_pem.decode("utf-8")) 179 | return True 180 | 181 | async def _create_ssl_context(self) -> ssl.SSLContext: 182 | if self._ssl_context: 183 | return self._ssl_context 184 | try: 185 | ssl_context = await self._loop.run_in_executor( 186 | None, _load_cert_chain, self._certfile, self._keyfile 187 | ) 188 | except FileNotFoundError as exc: 189 | LOGGER.debug("Missing certificate. Error: %s", exc) 190 | raise InvalidAuth from exc 191 | self._ssl_context = ssl_context 192 | return self._ssl_context 193 | 194 | async def async_connect(self) -> None: 195 | """Connect to an Android TV. 196 | 197 | :raises CannotConnect: if couldn't connect, e.g. invalid IP address. 198 | :raises ConnectionClosed: if connection was lost while waiting for the remote to start. 199 | :raises InvalidAuth: if pairing is needed first. 200 | """ 201 | ssl_context = await self._create_ssl_context() 202 | on_con_lost = self._loop.create_future() 203 | on_remote_started = self._loop.create_future() 204 | try: 205 | ( 206 | self._transport, 207 | self._remote_message_protocol, 208 | ) = await self._loop.create_connection( 209 | lambda: RemoteProtocol( 210 | on_con_lost, 211 | on_remote_started, 212 | self._on_is_on_updated, 213 | self._on_current_app_updated, 214 | self._on_volume_info_updated, 215 | self._loop, 216 | self._enable_ime, 217 | ), 218 | self.host, 219 | self._api_port, 220 | ssl=ssl_context, 221 | ) 222 | except OSError as exc: 223 | LOGGER.debug( 224 | "Couldn't connect to %s:%s. Error: %s", self.host, self._api_port, exc 225 | ) 226 | if isinstance(exc, ssl.SSLError): 227 | raise InvalidAuth("Need to pair") from exc 228 | raise CannotConnect( 229 | f"Couldn't connect to {self.host}:{self._api_port}" 230 | ) from exc 231 | 232 | await asyncio.wait( 233 | (on_con_lost, on_remote_started), return_when=asyncio.FIRST_COMPLETED 234 | ) 235 | if on_con_lost.done(): 236 | con_lost_exc = on_con_lost.result() 237 | LOGGER.debug( 238 | "Couldn't connect to %s:%s. Error: %s", 239 | self.host, 240 | self._api_port, 241 | con_lost_exc, 242 | ) 243 | if isinstance(con_lost_exc, ssl.SSLError): 244 | raise InvalidAuth("Need to pair again") from con_lost_exc 245 | raise ConnectionClosed("Connection closed") from con_lost_exc 246 | 247 | async def _async_reconnect( 248 | self, invalid_auth_callback: Callable | None = None 249 | ) -> None: 250 | while self._remote_message_protocol: 251 | exc = await self._remote_message_protocol.on_con_lost 252 | self._on_is_available_updated(False) 253 | LOGGER.debug("Disconnected from %s. Error: %s", self.host, exc) 254 | delay_seconds = 0.1 255 | LOGGER.debug( 256 | "Trying to reconnect to %s in %s seconds", self.host, delay_seconds 257 | ) 258 | while self._remote_message_protocol: 259 | await asyncio.sleep(delay_seconds) 260 | try: 261 | await self.async_connect() 262 | self._on_is_available_updated(True) 263 | break 264 | except (CannotConnect, ConnectionClosed) as exc: 265 | delay_seconds = min(2 * delay_seconds, 30) 266 | LOGGER.debug( 267 | "Couldn't reconnect to %s. Will retry in %s seconds. Error: %s", 268 | self.host, 269 | delay_seconds, 270 | exc, 271 | ) 272 | except InvalidAuth as exc: 273 | LOGGER.debug( 274 | "Couldn't reconnect to %s. Won't retry. Error: %s", 275 | self.host, 276 | exc, 277 | ) 278 | if invalid_auth_callback: 279 | invalid_auth_callback() 280 | return 281 | 282 | def keep_reconnecting(self, invalid_auth_callback: Callable | None = None) -> None: 283 | """Create a task to keep reconnecting whenever connection is lost.""" 284 | self._reconnect_task = self._loop.create_task( 285 | self._async_reconnect(invalid_auth_callback) 286 | ) 287 | 288 | def disconnect(self) -> None: 289 | """Disconnect any open connections.""" 290 | if self._reconnect_task: 291 | self._reconnect_task.cancel() 292 | if self._remote_message_protocol: 293 | if self._remote_message_protocol.transport: 294 | self._remote_message_protocol.transport.close() 295 | self._remote_message_protocol = None 296 | if self._pairing_message_protocol: 297 | if self._pairing_message_protocol.transport: 298 | self._pairing_message_protocol.transport.close() 299 | self._pairing_message_protocol = None 300 | 301 | async def async_get_name_and_mac(self) -> tuple[str, str]: 302 | """Connect to the Android TV and get its name and MAC address from its certificate. 303 | 304 | :raises CannotConnect: if couldn't connect, e.g. invalid IP address. 305 | """ 306 | ssl_context = await self._create_ssl_context() 307 | try: 308 | _, writer = await asyncio.open_connection( 309 | self.host, self._pair_port, ssl=ssl_context 310 | ) 311 | except OSError as exc: 312 | LOGGER.debug( 313 | "Couldn't connect to %s:%s. %s", self.host, self._pair_port, exc 314 | ) 315 | raise CannotConnect from exc 316 | server_cert_bytes = writer.transport.get_extra_info("ssl_object").getpeercert( 317 | True 318 | ) 319 | writer.close() 320 | server_cert = x509.load_der_x509_certificate(server_cert_bytes) 321 | # NVIDIA SHIELD example: 322 | # CN=atvremote/darcy/darcy/SHIELD Android TV/XX:XX:XX:XX:XX:XX 323 | # Nexus Player example: 324 | # dnQualifier=fugu/fugu/Nexus Player/CN=atvremote/XX:XX:XX:XX:XX:XX 325 | common_name = server_cert.subject.get_attributes_for_oid(x509.OID_COMMON_NAME) 326 | common_name_str = str(common_name[0].value) if common_name else "" 327 | dn_qualifier = server_cert.subject.get_attributes_for_oid(x509.OID_DN_QUALIFIER) 328 | dn_qualifier_str = str(dn_qualifier[0].value) if dn_qualifier else "" 329 | common_name_parts = common_name_str.split("/") 330 | dn_qualifier_parts = dn_qualifier_str.split("/") 331 | name = dn_qualifier_parts[-1] if dn_qualifier_str else common_name_parts[-2] 332 | mac = common_name_parts[-1] 333 | return name, mac 334 | 335 | async def async_start_pairing(self) -> None: 336 | """Start the pairing process. 337 | 338 | :raises CannotConnect: if couldn't connect, e.g. invalid IP address. 339 | :raises ConnectionClosed: if connection was lost. 340 | """ 341 | self.disconnect() 342 | ssl_context = await self._create_ssl_context() 343 | on_con_lost = self._loop.create_future() 344 | try: 345 | ( 346 | _, 347 | self._pairing_message_protocol, 348 | ) = await self._loop.create_connection( 349 | lambda: PairingProtocol( 350 | on_con_lost, 351 | self._client_name, 352 | self._certfile, 353 | self._loop, 354 | ), 355 | self.host, 356 | self._pair_port, 357 | ssl=ssl_context, 358 | ) 359 | except OSError as exc: 360 | LOGGER.debug( 361 | "Couldn't connect to %s:%s. %s", self.host, self._pair_port, exc 362 | ) 363 | raise CannotConnect from exc 364 | await self._pairing_message_protocol.async_start_pairing() 365 | 366 | async def async_finish_pairing(self, pairing_code: str) -> None: 367 | """Finish the pairing process. 368 | 369 | :param pairing_code: pairing code shown on the Android TV. 370 | :raises ConnectionClosed: if connection was lost, e.g. user pressed cancel on the Android TV. 371 | :raises InvalidAuth: if pairing was unsuccessful. 372 | """ 373 | if not self._pairing_message_protocol: 374 | LOGGER.debug("Called async_finish_pairing after disconnect") 375 | raise ConnectionClosed("Called async_finish_pairing after disconnect") 376 | await self._pairing_message_protocol.async_finish_pairing(pairing_code) 377 | self.disconnect() 378 | 379 | def send_key_command( 380 | self, key_code: int | str, direction: int | str = RemoteDirection.SHORT 381 | ) -> None: 382 | """Send a key press to Android TV. 383 | 384 | This does not block; it buffers the data and arranges for it to be sent out asynchronously. 385 | 386 | :param key_code: int (e.g. 26) or str (e.g. "KEYCODE_POWER" or just "POWER") from the enum 387 | RemoteKeyCode in remotemessage.proto or str prefixed with "text:" to pass 388 | to send_text. 389 | :param direction: "SHORT" (default) or "START_LONG" or "END_LONG". 390 | :raises ValueError: if key_code in str or direction isn't known. 391 | :raises ConnectionClosed: if client is disconnected. 392 | """ 393 | if not self._remote_message_protocol: 394 | LOGGER.debug("Called send_key_command after disconnect") 395 | raise ConnectionClosed("Called send_key_command after disconnect") 396 | self._remote_message_protocol.send_key_command(key_code, direction) 397 | 398 | def send_text(self, text: str) -> None: 399 | """Send text to Android TV. 400 | 401 | :param text: text to be sent. 402 | :raises ConnectionClosed: if client is disconnected. 403 | :may not work as expected if virtual keyboard is present on the Android TV screen 404 | """ 405 | if not self._remote_message_protocol: 406 | LOGGER.debug("Called send_text after disconnect") 407 | raise ConnectionClosed("Called send_text after disconnect") 408 | self._remote_message_protocol.send_text(text) 409 | 410 | def send_launch_app_command(self, app_link_or_app_id: str) -> None: 411 | """Launch an app on Android TV. 412 | 413 | This does not block; it buffers the data and arranges for it to be sent out asynchronously. 414 | 415 | :raises ConnectionClosed: if client is disconnected. 416 | """ 417 | if not self._remote_message_protocol: 418 | LOGGER.debug("Called send_launch_app_command after disconnect") 419 | raise ConnectionClosed("Called send_launch_app_command after disconnect") 420 | prefix = "" if urlparse(app_link_or_app_id).scheme else "market://launch?id=" 421 | self._remote_message_protocol.send_launch_app_command( 422 | f"{prefix}{app_link_or_app_id}" 423 | ) 424 | -------------------------------------------------------------------------------- /src/androidtvremote2/base.py: -------------------------------------------------------------------------------- 1 | """Protocol for receiving and sending protobuf messages.""" 2 | 3 | from __future__ import annotations 4 | 5 | import asyncio 6 | from typing import cast 7 | 8 | from google.protobuf import text_format 9 | from google.protobuf.internal.decoder import _DecodeVarint # type: ignore[attr-defined] 10 | from google.protobuf.internal.encoder import _EncodeVarint # type: ignore[attr-defined] 11 | from google.protobuf.message import Message 12 | 13 | from .const import LOGGER 14 | 15 | 16 | class ProtobufProtocol(asyncio.Protocol): 17 | """Protocol for receiving and sending protobuf messages.""" 18 | 19 | def __init__(self, on_con_lost: asyncio.Future) -> None: 20 | """Initialize. 21 | 22 | :param on_con_lost: callback for when the connection is lost or closed. 23 | """ 24 | self.on_con_lost = on_con_lost 25 | self.transport: asyncio.Transport | None = None 26 | self._raw_msg_len = -1 27 | self._raw_msg = b"" 28 | 29 | def connection_made(self, transport: asyncio.BaseTransport) -> None: 30 | """Store transport when a connection is made.""" 31 | LOGGER.debug("Connected to %s", transport.get_extra_info("peername")) 32 | self.transport = cast(asyncio.Transport, transport) 33 | 34 | def connection_lost(self, exc: Exception | None) -> None: 35 | """Notify on_con_lost when the connection is lost or closed.""" 36 | LOGGER.debug("Connection lost. Error: %s", exc) 37 | if not self.on_con_lost.done(): 38 | self.on_con_lost.set_result(exc) 39 | 40 | def data_received(self, data: bytes) -> None: 41 | """Receive data until a full protobuf is received and pass it to _handle_message.""" 42 | if not data: 43 | LOGGER.debug("No data received") 44 | return 45 | if self._raw_msg_len < 0: 46 | self._raw_msg_len, pos = _DecodeVarint(data, 0) 47 | pos_end = pos + self._raw_msg_len 48 | self._raw_msg += data[pos:pos_end] 49 | remaining_data = data[pos_end:] 50 | else: 51 | pos_end = self._raw_msg_len - len(self._raw_msg) 52 | self._raw_msg += data[:pos_end] 53 | remaining_data = data[pos_end:] 54 | if self._raw_msg_len == len(self._raw_msg): 55 | raw_msg = self._raw_msg 56 | self._raw_msg_len = -1 57 | self._raw_msg = b"" 58 | # LOGGER.debug("Received: %s", raw_msg) 59 | self._handle_message(raw_msg) 60 | if remaining_data: 61 | self.data_received(remaining_data) 62 | 63 | def _handle_message(self, raw_msg: bytes) -> None: 64 | """Handle a message from the server. Message needs to be parsed to the appropriate protobuf.""" 65 | 66 | def _send_message(self, msg: Message, should_debug_log: bool = True) -> None: 67 | """Send a protobuf message to the server. 68 | 69 | This does not block; it buffers the data and arranges for it to be sent out asynchronously. 70 | """ 71 | if should_debug_log: 72 | LOGGER.debug( 73 | "Sending: %s", text_format.MessageToString(msg, as_one_line=True) 74 | ) 75 | if not self.transport or self.transport.is_closing(): 76 | LOGGER.debug("Connection is closed!") 77 | return 78 | _EncodeVarint(self.transport.write, msg.ByteSize()) 79 | self.transport.write(msg.SerializeToString()) 80 | -------------------------------------------------------------------------------- /src/androidtvremote2/certificate_generator.py: -------------------------------------------------------------------------------- 1 | """Generate self signed certificate.""" 2 | 3 | # Copied from: 4 | # https://github.com/farshid616/Android-TV-Remote-Controller-Python/blob/main/certificate_generator.py 5 | # with small modifications. 6 | 7 | from __future__ import annotations 8 | 9 | from datetime import datetime, timedelta 10 | 11 | from cryptography import x509 12 | from cryptography.hazmat.backends import default_backend 13 | from cryptography.hazmat.primitives import hashes, serialization 14 | from cryptography.hazmat.primitives.asymmetric import rsa 15 | from cryptography.x509.oid import NameOID 16 | 17 | 18 | def generate_selfsigned_cert(hostname: str) -> tuple[bytes, bytes]: 19 | """Generate self signed certificate for a hostname. 20 | 21 | :return: self signed certificate and public key in PEM format 22 | """ 23 | key = rsa.generate_private_key( 24 | public_exponent=65537, 25 | key_size=2048, 26 | backend=default_backend(), 27 | ) 28 | name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, hostname)]) 29 | alt_names = [x509.DNSName(hostname)] 30 | san = x509.SubjectAlternativeName(alt_names) 31 | basic_constraints = x509.BasicConstraints(ca=True, path_length=0) 32 | now = datetime.utcnow() 33 | cert = ( 34 | x509.CertificateBuilder() 35 | .subject_name(name) 36 | .issuer_name(name) 37 | .public_key(key.public_key()) 38 | .serial_number(1000) 39 | .not_valid_before(now) 40 | .not_valid_after(now + timedelta(days=10 * 365)) 41 | .add_extension(basic_constraints, False) 42 | .add_extension(san, False) 43 | .sign(key, hashes.SHA256(), default_backend()) 44 | ) 45 | cert_pem = cert.public_bytes(encoding=serialization.Encoding.PEM) 46 | key_pem = key.private_bytes( 47 | encoding=serialization.Encoding.PEM, 48 | format=serialization.PrivateFormat.TraditionalOpenSSL, 49 | encryption_algorithm=serialization.NoEncryption(), 50 | ) 51 | return cert_pem, key_pem 52 | -------------------------------------------------------------------------------- /src/androidtvremote2/const.py: -------------------------------------------------------------------------------- 1 | """Define package-wide constants.""" 2 | 3 | import logging 4 | 5 | LOGGER = logging.getLogger(__package__) 6 | -------------------------------------------------------------------------------- /src/androidtvremote2/exceptions.py: -------------------------------------------------------------------------------- 1 | """Exceptions.""" 2 | 3 | 4 | class CannotConnect(Exception): 5 | """Error to indicate we cannot connect.""" 6 | 7 | 8 | class ConnectionClosed(Exception): 9 | """Error to indicate a regular EOF was received or the connection was aborted or closed.""" 10 | 11 | 12 | class InvalidAuth(Exception): 13 | """Error to indicate there is invalid auth.""" 14 | -------------------------------------------------------------------------------- /src/androidtvremote2/pairing.py: -------------------------------------------------------------------------------- 1 | """Pairing protocol with an Android TV.""" 2 | 3 | # Based on: 4 | # https://android.googlesource.com/platform/external/google-tv-pairing-protocol/+/refs/heads/master/java/src/com/google/polo/pairing/ 5 | # https://github.com/louis49/androidtv-remote/tree/main/src/pairing 6 | # https://github.com/farshid616/Android-TV-Remote-Controller-Python/blob/main/pairing.py 7 | 8 | from __future__ import annotations 9 | 10 | import asyncio 11 | import hashlib 12 | 13 | import aiofiles 14 | from cryptography import x509 15 | from google.protobuf import text_format 16 | from google.protobuf.message import DecodeError 17 | 18 | from .base import ProtobufProtocol 19 | from .const import LOGGER 20 | from .exceptions import ConnectionClosed, InvalidAuth 21 | from .polo_pb2 import Options, OuterMessage 22 | 23 | 24 | def _create_message() -> OuterMessage: 25 | """Create an OuterMessage with default values.""" 26 | msg = OuterMessage() 27 | msg.protocol_version = 2 28 | msg.status = OuterMessage.Status.STATUS_OK 29 | return msg 30 | 31 | 32 | def _get_modulus_and_exponent(cert: x509.Certificate) -> tuple[int, int]: 33 | """Extract modulus and exponent from a certificate.""" 34 | public_numbers = cert.public_key().public_numbers() # type: ignore[union-attr] 35 | return public_numbers.n, public_numbers.e # type: ignore[union-attr] 36 | 37 | 38 | class PairingProtocol(ProtobufProtocol): 39 | """Implement pairing protocol with an Android TV. 40 | 41 | Messages transmitted between client and server are of type OuterMessage, see polo.proto. 42 | Protocol is described in 43 | https://github.com/Aymkdn/assistant-freebox-cloud/wiki/Google-TV-(aka-Android-TV)-Remote-Control-(v2) 44 | """ 45 | 46 | def __init__( 47 | self, 48 | on_con_lost: asyncio.Future, 49 | client_name: str, 50 | certfile: str, 51 | loop: asyncio.AbstractEventLoop, 52 | ) -> None: 53 | """Initialize. 54 | 55 | :param on_con_lost: callback for when the connection is lost or closed. 56 | :param client_name: client name. Will be shown on the Android TV during pairing. 57 | :param certfile: filename that contains the client certificate in PEM format. 58 | Needed for computing the secret code during pairing. 59 | :param loop: event loop. Used for creating futures. 60 | """ 61 | super().__init__(on_con_lost) 62 | self._client_name = client_name 63 | self._certfile = certfile 64 | self._loop = loop 65 | self._on_pairing_started: asyncio.Future | None = None 66 | self._on_pairing_finished: asyncio.Future | None = None 67 | 68 | async def async_start_pairing(self) -> None: 69 | """Start the pairing process. 70 | 71 | :raises ConnectionClosed: if connection was lost. 72 | """ 73 | self._raise_if_not_connected() 74 | msg = _create_message() 75 | msg.pairing_request.client_name = self._client_name 76 | msg.pairing_request.service_name = "atvremote" 77 | self._on_pairing_started = self._loop.create_future() 78 | self._send_message(msg) 79 | try: 80 | await self._async_wait_for_future_or_con_lost(self._on_pairing_started) 81 | finally: 82 | self._on_pairing_started = None 83 | 84 | async def async_finish_pairing(self, pairing_code: str) -> None: 85 | """Finish the pairing process. 86 | 87 | :param pairing_code: pairing code shown on the Android TV. 88 | :raises ConnectionClosed: if connection was lost. 89 | :raises InvalidAuth: if pairing was unsuccessful. 90 | """ 91 | self._raise_if_not_connected() 92 | if not pairing_code or len(pairing_code) != 6: 93 | LOGGER.debug("Length of PIN (%s) should be exactly 6", pairing_code) 94 | raise InvalidAuth("Length of PIN should be exactly 6") 95 | try: 96 | bytes.fromhex(pairing_code) 97 | except ValueError as exc: 98 | LOGGER.debug("PIN (%s) should be in hex", pairing_code) 99 | raise InvalidAuth("PIN should be in hex") from exc 100 | 101 | async with aiofiles.open(self._certfile, "rb") as fp: 102 | client_cert = x509.load_pem_x509_certificate(await fp.read()) 103 | client_modulus, client_exponent = _get_modulus_and_exponent(client_cert) 104 | 105 | assert self.transport 106 | server_cert_bytes = self.transport.get_extra_info("ssl_object").getpeercert( 107 | True 108 | ) 109 | server_cert = x509.load_der_x509_certificate(server_cert_bytes) 110 | server_modulus, server_exponent = _get_modulus_and_exponent(server_cert) 111 | 112 | h = hashlib.sha256() 113 | h.update(bytes.fromhex(f"{client_modulus:X}")) 114 | h.update(bytes.fromhex(f"0{client_exponent:X}")) 115 | h.update(bytes.fromhex(f"{server_modulus:X}")) 116 | h.update(bytes.fromhex(f"0{server_exponent:X}")) 117 | h.update(bytes.fromhex(pairing_code[2:])) 118 | hash_result = h.digest() 119 | 120 | if hash_result[0] != int(pairing_code[0:2], 16): 121 | LOGGER.debug("Unexpected hash for pairing code: %s", pairing_code) 122 | raise InvalidAuth(f"Unexpected hash for pairing code: {pairing_code}") 123 | 124 | msg = _create_message() 125 | msg.secret.secret = hash_result 126 | self._on_pairing_finished = self._loop.create_future() 127 | self._send_message(msg) 128 | try: 129 | await self._async_wait_for_future_or_con_lost(self._on_pairing_finished) 130 | finally: 131 | self._on_pairing_finished = None 132 | 133 | async def _async_wait_for_future_or_con_lost(self, future: asyncio.Future) -> None: 134 | """Wait for future to finish or connection to be lost.""" 135 | await asyncio.wait( 136 | (self.on_con_lost, future), return_when=asyncio.FIRST_COMPLETED 137 | ) 138 | if future.done(): 139 | if future.exception(): 140 | raise ConnectionClosed(future.exception()) 141 | if future.result(): 142 | return 143 | self._raise_if_not_connected() 144 | 145 | def _raise_if_not_connected(self) -> None: 146 | """Raise ConnectionClosed if not connected.""" 147 | if self.transport is None or self.transport.is_closing(): 148 | LOGGER.debug("Connection has been lost, cannot pair") 149 | raise ConnectionClosed("Connection has been lost") 150 | 151 | def _handle_message(self, raw_msg: bytes) -> None: 152 | """Handle a message from the server.""" 153 | msg = OuterMessage() 154 | try: 155 | msg.ParseFromString(raw_msg) 156 | except DecodeError as exc: 157 | LOGGER.debug("Couldn't parse as OuterMessage. %s", exc) 158 | self._handle_error(exc) 159 | return 160 | LOGGER.debug("Received: %s", text_format.MessageToString(msg, as_one_line=True)) 161 | 162 | if msg.status != OuterMessage.Status.STATUS_OK: 163 | LOGGER.debug( 164 | "Received status: %s in msg: %s", 165 | msg.status, 166 | text_format.MessageToString(msg, as_one_line=True), 167 | ) 168 | self._handle_error(Exception(f"Received status: {msg.status}")) 169 | return 170 | 171 | new_msg = _create_message() 172 | 173 | if msg.HasField("pairing_request_ack"): 174 | new_msg.options.preferred_role = Options.RoleType.ROLE_TYPE_INPUT 175 | enc = new_msg.options.input_encodings.add() 176 | enc.type = Options.Encoding.ENCODING_TYPE_HEXADECIMAL 177 | enc.symbol_length = 6 178 | elif msg.HasField("options"): 179 | new_msg.configuration.client_role = Options.RoleType.ROLE_TYPE_INPUT 180 | new_msg.configuration.encoding.type = ( 181 | Options.Encoding.ENCODING_TYPE_HEXADECIMAL 182 | ) 183 | new_msg.configuration.encoding.symbol_length = 6 184 | elif msg.HasField("configuration_ack"): 185 | if self._on_pairing_started: 186 | self._on_pairing_started.set_result(True) 187 | return 188 | elif msg.HasField("secret_ack"): 189 | if self._on_pairing_finished: 190 | self._on_pairing_finished.set_result(True) 191 | return 192 | else: 193 | LOGGER.debug( 194 | "Unhandled msg: %s", text_format.MessageToString(msg, as_one_line=True) 195 | ) 196 | self._handle_error( 197 | Exception( 198 | f"Unhandled msg: {text_format.MessageToString(msg, as_one_line=True)}" 199 | ) 200 | ) 201 | return 202 | 203 | self._send_message(new_msg) 204 | 205 | def _handle_error(self, exception: Exception) -> None: 206 | """Handle errors during _handle_message.""" 207 | if self._on_pairing_started and not self._on_pairing_started.done(): 208 | self._on_pairing_started.set_exception(exception) 209 | if self._on_pairing_finished and not self._on_pairing_finished.done(): 210 | self._on_pairing_finished.set_exception(exception) 211 | if self.transport: 212 | self.transport.close() 213 | -------------------------------------------------------------------------------- /src/androidtvremote2/polo.proto: -------------------------------------------------------------------------------- 1 | // Copied from https://android.googlesource.com/platform/external/google-tv-pairing-protocol/+/refs/heads/master/proto/polo.proto 2 | // with a change in OuterMessage based on https://github.com/louis49/androidtv-remote/blob/main/src/pairing/pairingmessage.proto 3 | 4 | // Copyright 2009 Google Inc. All Rights Reserved. 5 | 6 | syntax = "proto2"; 7 | 8 | package polo.wire.protobuf; 9 | 10 | option java_outer_classname = "PoloProto"; 11 | option java_package = "com.google.polo.wire.protobuf"; 12 | option optimize_for = LITE_RUNTIME; 13 | 14 | // OuterMessage - base outer message type used in the protocol. 15 | 16 | message OuterMessage { 17 | // Protocol status states. 18 | enum Status { 19 | STATUS_OK = 200; 20 | STATUS_ERROR = 400; 21 | STATUS_BAD_CONFIGURATION = 401; 22 | STATUS_BAD_SECRET = 402; 23 | } 24 | 25 | required uint32 protocol_version = 1 [default = 1]; 26 | 27 | // Protocol status. Any status other than STATUS_OK implies a fault. 28 | required Status status = 2; 29 | 30 | // Initialization phase 31 | optional PairingRequest pairing_request = 10; 32 | optional PairingRequestAck pairing_request_ack = 11; 33 | 34 | // Configuration phase 35 | optional Options options = 20; 36 | optional Configuration configuration = 30; 37 | optional ConfigurationAck configuration_ack = 31; 38 | 39 | // Pairing phase 40 | optional Secret secret = 40; 41 | optional SecretAck secret_ack = 41; 42 | } 43 | 44 | 45 | // 46 | // Initialization messages 47 | // 48 | 49 | message PairingRequest { 50 | // String name of the service to pair with. The name used should be an 51 | // established convention of the application protocol. 52 | required string service_name = 1; 53 | 54 | // Descriptive name of the client. 55 | optional string client_name = 2; 56 | } 57 | 58 | message PairingRequestAck { 59 | // Descriptive name of the server. 60 | optional string server_name = 1; 61 | } 62 | 63 | 64 | // 65 | // Configuration messages 66 | // 67 | 68 | message Options { 69 | message Encoding { 70 | enum EncodingType { 71 | ENCODING_TYPE_UNKNOWN = 0; 72 | ENCODING_TYPE_ALPHANUMERIC = 1; 73 | ENCODING_TYPE_NUMERIC = 2; 74 | ENCODING_TYPE_HEXADECIMAL = 3; 75 | ENCODING_TYPE_QRCODE = 4; 76 | } 77 | 78 | required EncodingType type = 1; 79 | required uint32 symbol_length = 2; 80 | } 81 | 82 | enum RoleType { 83 | ROLE_TYPE_UNKNOWN = 0; 84 | ROLE_TYPE_INPUT = 1; 85 | ROLE_TYPE_OUTPUT = 2; 86 | } 87 | 88 | // List of encodings this endpoint accepts when serving as an input device. 89 | repeated Encoding input_encodings = 1; 90 | 91 | // List of encodings this endpoint can generate as an output device. 92 | repeated Encoding output_encodings = 2; 93 | 94 | // Preferred role, if any. 95 | optional RoleType preferred_role = 3; 96 | } 97 | 98 | message Configuration { 99 | // The encoding to be used in this session. 100 | required Options.Encoding encoding = 1; 101 | 102 | // The role of the client (ie, the one initiating pairing). This implies the 103 | // peer (server) acts as the complementary role. 104 | required Options.RoleType client_role = 2; 105 | } 106 | 107 | message ConfigurationAck { 108 | } 109 | 110 | 111 | // 112 | // Pairing messages 113 | // 114 | 115 | message Secret { 116 | required bytes secret = 1; 117 | } 118 | 119 | message SecretAck { 120 | required bytes secret = 1; 121 | } 122 | -------------------------------------------------------------------------------- /src/androidtvremote2/polo_pb2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by the protocol buffer compiler. DO NOT EDIT! 3 | # source: polo.proto 4 | """Generated protocol buffer code.""" 5 | from google.protobuf import descriptor as _descriptor 6 | from google.protobuf import descriptor_pool as _descriptor_pool 7 | from google.protobuf import symbol_database as _symbol_database 8 | from google.protobuf.internal import builder as _builder 9 | # @@protoc_insertion_point(imports) 10 | 11 | _sym_db = _symbol_database.Default() 12 | 13 | 14 | 15 | 16 | DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\npolo.proto\x12\x12polo.wire.protobuf\"\xd1\x04\n\x0cOuterMessage\x12\x1b\n\x10protocol_version\x18\x01 \x02(\r:\x01\x31\x12\x37\n\x06status\x18\x02 \x02(\x0e\x32\'.polo.wire.protobuf.OuterMessage.Status\x12;\n\x0fpairing_request\x18\n \x01(\x0b\x32\".polo.wire.protobuf.PairingRequest\x12\x42\n\x13pairing_request_ack\x18\x0b \x01(\x0b\x32%.polo.wire.protobuf.PairingRequestAck\x12,\n\x07options\x18\x14 \x01(\x0b\x32\x1b.polo.wire.protobuf.Options\x12\x38\n\rconfiguration\x18\x1e \x01(\x0b\x32!.polo.wire.protobuf.Configuration\x12?\n\x11\x63onfiguration_ack\x18\x1f \x01(\x0b\x32$.polo.wire.protobuf.ConfigurationAck\x12*\n\x06secret\x18( \x01(\x0b\x32\x1a.polo.wire.protobuf.Secret\x12\x31\n\nsecret_ack\x18) \x01(\x0b\x32\x1d.polo.wire.protobuf.SecretAck\"b\n\x06Status\x12\x0e\n\tSTATUS_OK\x10\xc8\x01\x12\x11\n\x0cSTATUS_ERROR\x10\x90\x03\x12\x1d\n\x18STATUS_BAD_CONFIGURATION\x10\x91\x03\x12\x16\n\x11STATUS_BAD_SECRET\x10\x92\x03\";\n\x0ePairingRequest\x12\x14\n\x0cservice_name\x18\x01 \x02(\t\x12\x13\n\x0b\x63lient_name\x18\x02 \x01(\t\"(\n\x11PairingRequestAck\x12\x13\n\x0bserver_name\x18\x01 \x01(\t\"\x99\x04\n\x07Options\x12=\n\x0finput_encodings\x18\x01 \x03(\x0b\x32$.polo.wire.protobuf.Options.Encoding\x12>\n\x10output_encodings\x18\x02 \x03(\x0b\x32$.polo.wire.protobuf.Options.Encoding\x12<\n\x0epreferred_role\x18\x03 \x01(\x0e\x32$.polo.wire.protobuf.Options.RoleType\x1a\x82\x02\n\x08\x45ncoding\x12?\n\x04type\x18\x01 \x02(\x0e\x32\x31.polo.wire.protobuf.Options.Encoding.EncodingType\x12\x15\n\rsymbol_length\x18\x02 \x02(\r\"\x9d\x01\n\x0c\x45ncodingType\x12\x19\n\x15\x45NCODING_TYPE_UNKNOWN\x10\x00\x12\x1e\n\x1a\x45NCODING_TYPE_ALPHANUMERIC\x10\x01\x12\x19\n\x15\x45NCODING_TYPE_NUMERIC\x10\x02\x12\x1d\n\x19\x45NCODING_TYPE_HEXADECIMAL\x10\x03\x12\x18\n\x14\x45NCODING_TYPE_QRCODE\x10\x04\"L\n\x08RoleType\x12\x15\n\x11ROLE_TYPE_UNKNOWN\x10\x00\x12\x13\n\x0fROLE_TYPE_INPUT\x10\x01\x12\x14\n\x10ROLE_TYPE_OUTPUT\x10\x02\"\x82\x01\n\rConfiguration\x12\x36\n\x08\x65ncoding\x18\x01 \x02(\x0b\x32$.polo.wire.protobuf.Options.Encoding\x12\x39\n\x0b\x63lient_role\x18\x02 \x02(\x0e\x32$.polo.wire.protobuf.Options.RoleType\"\x12\n\x10\x43onfigurationAck\"\x18\n\x06Secret\x12\x0e\n\x06secret\x18\x01 \x02(\x0c\"\x1b\n\tSecretAck\x12\x0e\n\x06secret\x18\x01 \x02(\x0c\x42,\n\x1d\x63om.google.polo.wire.protobufB\tPoloProtoH\x03') 17 | 18 | _globals = globals() 19 | _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) 20 | _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'polo_pb2', _globals) 21 | if _descriptor._USE_C_DESCRIPTORS == False: 22 | DESCRIPTOR._options = None 23 | DESCRIPTOR._serialized_options = b'\n\035com.google.polo.wire.protobufB\tPoloProtoH\003' 24 | _globals['_OUTERMESSAGE']._serialized_start=35 25 | _globals['_OUTERMESSAGE']._serialized_end=628 26 | _globals['_OUTERMESSAGE_STATUS']._serialized_start=530 27 | _globals['_OUTERMESSAGE_STATUS']._serialized_end=628 28 | _globals['_PAIRINGREQUEST']._serialized_start=630 29 | _globals['_PAIRINGREQUEST']._serialized_end=689 30 | _globals['_PAIRINGREQUESTACK']._serialized_start=691 31 | _globals['_PAIRINGREQUESTACK']._serialized_end=731 32 | _globals['_OPTIONS']._serialized_start=734 33 | _globals['_OPTIONS']._serialized_end=1271 34 | _globals['_OPTIONS_ENCODING']._serialized_start=935 35 | _globals['_OPTIONS_ENCODING']._serialized_end=1193 36 | _globals['_OPTIONS_ENCODING_ENCODINGTYPE']._serialized_start=1036 37 | _globals['_OPTIONS_ENCODING_ENCODINGTYPE']._serialized_end=1193 38 | _globals['_OPTIONS_ROLETYPE']._serialized_start=1195 39 | _globals['_OPTIONS_ROLETYPE']._serialized_end=1271 40 | _globals['_CONFIGURATION']._serialized_start=1274 41 | _globals['_CONFIGURATION']._serialized_end=1404 42 | _globals['_CONFIGURATIONACK']._serialized_start=1406 43 | _globals['_CONFIGURATIONACK']._serialized_end=1424 44 | _globals['_SECRET']._serialized_start=1426 45 | _globals['_SECRET']._serialized_end=1450 46 | _globals['_SECRETACK']._serialized_start=1452 47 | _globals['_SECRETACK']._serialized_end=1479 48 | # @@protoc_insertion_point(module_scope) 49 | -------------------------------------------------------------------------------- /src/androidtvremote2/polo_pb2.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | @generated by mypy-protobuf. Do not edit manually! 3 | isort:skip_file 4 | Copyright 2009 Google Inc. All Rights Reserved.""" 5 | 6 | import builtins 7 | import collections.abc 8 | import google.protobuf.descriptor 9 | import google.protobuf.internal.containers 10 | import google.protobuf.internal.enum_type_wrapper 11 | import google.protobuf.message 12 | import sys 13 | import typing 14 | 15 | if sys.version_info >= (3, 10): 16 | import typing as typing_extensions 17 | else: 18 | import typing_extensions 19 | 20 | DESCRIPTOR: google.protobuf.descriptor.FileDescriptor 21 | 22 | @typing.final 23 | class OuterMessage(google.protobuf.message.Message): 24 | """OuterMessage - base outer message type used in the protocol.""" 25 | 26 | DESCRIPTOR: google.protobuf.descriptor.Descriptor 27 | 28 | class _Status: 29 | ValueType = typing.NewType("ValueType", builtins.int) 30 | V: typing_extensions.TypeAlias = ValueType 31 | 32 | class _StatusEnumTypeWrapper(google.protobuf.internal.enum_type_wrapper._EnumTypeWrapper[OuterMessage._Status.ValueType], builtins.type): 33 | DESCRIPTOR: google.protobuf.descriptor.EnumDescriptor 34 | STATUS_OK: OuterMessage._Status.ValueType # 200 35 | STATUS_ERROR: OuterMessage._Status.ValueType # 400 36 | STATUS_BAD_CONFIGURATION: OuterMessage._Status.ValueType # 401 37 | STATUS_BAD_SECRET: OuterMessage._Status.ValueType # 402 38 | 39 | class Status(_Status, metaclass=_StatusEnumTypeWrapper): 40 | """Protocol status states.""" 41 | 42 | STATUS_OK: OuterMessage.Status.ValueType # 200 43 | STATUS_ERROR: OuterMessage.Status.ValueType # 400 44 | STATUS_BAD_CONFIGURATION: OuterMessage.Status.ValueType # 401 45 | STATUS_BAD_SECRET: OuterMessage.Status.ValueType # 402 46 | 47 | PROTOCOL_VERSION_FIELD_NUMBER: builtins.int 48 | STATUS_FIELD_NUMBER: builtins.int 49 | PAIRING_REQUEST_FIELD_NUMBER: builtins.int 50 | PAIRING_REQUEST_ACK_FIELD_NUMBER: builtins.int 51 | OPTIONS_FIELD_NUMBER: builtins.int 52 | CONFIGURATION_FIELD_NUMBER: builtins.int 53 | CONFIGURATION_ACK_FIELD_NUMBER: builtins.int 54 | SECRET_FIELD_NUMBER: builtins.int 55 | SECRET_ACK_FIELD_NUMBER: builtins.int 56 | protocol_version: builtins.int 57 | status: global___OuterMessage.Status.ValueType 58 | """Protocol status. Any status other than STATUS_OK implies a fault.""" 59 | @property 60 | def pairing_request(self) -> global___PairingRequest: 61 | """Initialization phase""" 62 | 63 | @property 64 | def pairing_request_ack(self) -> global___PairingRequestAck: ... 65 | @property 66 | def options(self) -> global___Options: 67 | """Configuration phase""" 68 | 69 | @property 70 | def configuration(self) -> global___Configuration: ... 71 | @property 72 | def configuration_ack(self) -> global___ConfigurationAck: ... 73 | @property 74 | def secret(self) -> global___Secret: 75 | """Pairing phase""" 76 | 77 | @property 78 | def secret_ack(self) -> global___SecretAck: ... 79 | def __init__( 80 | self, 81 | *, 82 | protocol_version: builtins.int | None = ..., 83 | status: global___OuterMessage.Status.ValueType | None = ..., 84 | pairing_request: global___PairingRequest | None = ..., 85 | pairing_request_ack: global___PairingRequestAck | None = ..., 86 | options: global___Options | None = ..., 87 | configuration: global___Configuration | None = ..., 88 | configuration_ack: global___ConfigurationAck | None = ..., 89 | secret: global___Secret | None = ..., 90 | secret_ack: global___SecretAck | None = ..., 91 | ) -> None: ... 92 | def HasField(self, field_name: typing.Literal["configuration", b"configuration", "configuration_ack", b"configuration_ack", "options", b"options", "pairing_request", b"pairing_request", "pairing_request_ack", b"pairing_request_ack", "protocol_version", b"protocol_version", "secret", b"secret", "secret_ack", b"secret_ack", "status", b"status"]) -> builtins.bool: ... 93 | def ClearField(self, field_name: typing.Literal["configuration", b"configuration", "configuration_ack", b"configuration_ack", "options", b"options", "pairing_request", b"pairing_request", "pairing_request_ack", b"pairing_request_ack", "protocol_version", b"protocol_version", "secret", b"secret", "secret_ack", b"secret_ack", "status", b"status"]) -> None: ... 94 | 95 | global___OuterMessage = OuterMessage 96 | 97 | @typing.final 98 | class PairingRequest(google.protobuf.message.Message): 99 | """ 100 | Initialization messages 101 | """ 102 | 103 | DESCRIPTOR: google.protobuf.descriptor.Descriptor 104 | 105 | SERVICE_NAME_FIELD_NUMBER: builtins.int 106 | CLIENT_NAME_FIELD_NUMBER: builtins.int 107 | service_name: builtins.str 108 | """String name of the service to pair with. The name used should be an 109 | established convention of the application protocol. 110 | """ 111 | client_name: builtins.str 112 | """Descriptive name of the client.""" 113 | def __init__( 114 | self, 115 | *, 116 | service_name: builtins.str | None = ..., 117 | client_name: builtins.str | None = ..., 118 | ) -> None: ... 119 | def HasField(self, field_name: typing.Literal["client_name", b"client_name", "service_name", b"service_name"]) -> builtins.bool: ... 120 | def ClearField(self, field_name: typing.Literal["client_name", b"client_name", "service_name", b"service_name"]) -> None: ... 121 | 122 | global___PairingRequest = PairingRequest 123 | 124 | @typing.final 125 | class PairingRequestAck(google.protobuf.message.Message): 126 | DESCRIPTOR: google.protobuf.descriptor.Descriptor 127 | 128 | SERVER_NAME_FIELD_NUMBER: builtins.int 129 | server_name: builtins.str 130 | """Descriptive name of the server.""" 131 | def __init__( 132 | self, 133 | *, 134 | server_name: builtins.str | None = ..., 135 | ) -> None: ... 136 | def HasField(self, field_name: typing.Literal["server_name", b"server_name"]) -> builtins.bool: ... 137 | def ClearField(self, field_name: typing.Literal["server_name", b"server_name"]) -> None: ... 138 | 139 | global___PairingRequestAck = PairingRequestAck 140 | 141 | @typing.final 142 | class Options(google.protobuf.message.Message): 143 | """ 144 | Configuration messages 145 | """ 146 | 147 | DESCRIPTOR: google.protobuf.descriptor.Descriptor 148 | 149 | class _RoleType: 150 | ValueType = typing.NewType("ValueType", builtins.int) 151 | V: typing_extensions.TypeAlias = ValueType 152 | 153 | class _RoleTypeEnumTypeWrapper(google.protobuf.internal.enum_type_wrapper._EnumTypeWrapper[Options._RoleType.ValueType], builtins.type): 154 | DESCRIPTOR: google.protobuf.descriptor.EnumDescriptor 155 | ROLE_TYPE_UNKNOWN: Options._RoleType.ValueType # 0 156 | ROLE_TYPE_INPUT: Options._RoleType.ValueType # 1 157 | ROLE_TYPE_OUTPUT: Options._RoleType.ValueType # 2 158 | 159 | class RoleType(_RoleType, metaclass=_RoleTypeEnumTypeWrapper): ... 160 | ROLE_TYPE_UNKNOWN: Options.RoleType.ValueType # 0 161 | ROLE_TYPE_INPUT: Options.RoleType.ValueType # 1 162 | ROLE_TYPE_OUTPUT: Options.RoleType.ValueType # 2 163 | 164 | @typing.final 165 | class Encoding(google.protobuf.message.Message): 166 | DESCRIPTOR: google.protobuf.descriptor.Descriptor 167 | 168 | class _EncodingType: 169 | ValueType = typing.NewType("ValueType", builtins.int) 170 | V: typing_extensions.TypeAlias = ValueType 171 | 172 | class _EncodingTypeEnumTypeWrapper(google.protobuf.internal.enum_type_wrapper._EnumTypeWrapper[Options.Encoding._EncodingType.ValueType], builtins.type): 173 | DESCRIPTOR: google.protobuf.descriptor.EnumDescriptor 174 | ENCODING_TYPE_UNKNOWN: Options.Encoding._EncodingType.ValueType # 0 175 | ENCODING_TYPE_ALPHANUMERIC: Options.Encoding._EncodingType.ValueType # 1 176 | ENCODING_TYPE_NUMERIC: Options.Encoding._EncodingType.ValueType # 2 177 | ENCODING_TYPE_HEXADECIMAL: Options.Encoding._EncodingType.ValueType # 3 178 | ENCODING_TYPE_QRCODE: Options.Encoding._EncodingType.ValueType # 4 179 | 180 | class EncodingType(_EncodingType, metaclass=_EncodingTypeEnumTypeWrapper): ... 181 | ENCODING_TYPE_UNKNOWN: Options.Encoding.EncodingType.ValueType # 0 182 | ENCODING_TYPE_ALPHANUMERIC: Options.Encoding.EncodingType.ValueType # 1 183 | ENCODING_TYPE_NUMERIC: Options.Encoding.EncodingType.ValueType # 2 184 | ENCODING_TYPE_HEXADECIMAL: Options.Encoding.EncodingType.ValueType # 3 185 | ENCODING_TYPE_QRCODE: Options.Encoding.EncodingType.ValueType # 4 186 | 187 | TYPE_FIELD_NUMBER: builtins.int 188 | SYMBOL_LENGTH_FIELD_NUMBER: builtins.int 189 | type: global___Options.Encoding.EncodingType.ValueType 190 | symbol_length: builtins.int 191 | def __init__( 192 | self, 193 | *, 194 | type: global___Options.Encoding.EncodingType.ValueType | None = ..., 195 | symbol_length: builtins.int | None = ..., 196 | ) -> None: ... 197 | def HasField(self, field_name: typing.Literal["symbol_length", b"symbol_length", "type", b"type"]) -> builtins.bool: ... 198 | def ClearField(self, field_name: typing.Literal["symbol_length", b"symbol_length", "type", b"type"]) -> None: ... 199 | 200 | INPUT_ENCODINGS_FIELD_NUMBER: builtins.int 201 | OUTPUT_ENCODINGS_FIELD_NUMBER: builtins.int 202 | PREFERRED_ROLE_FIELD_NUMBER: builtins.int 203 | preferred_role: global___Options.RoleType.ValueType 204 | """Preferred role, if any.""" 205 | @property 206 | def input_encodings(self) -> google.protobuf.internal.containers.RepeatedCompositeFieldContainer[global___Options.Encoding]: 207 | """List of encodings this endpoint accepts when serving as an input device.""" 208 | 209 | @property 210 | def output_encodings(self) -> google.protobuf.internal.containers.RepeatedCompositeFieldContainer[global___Options.Encoding]: 211 | """List of encodings this endpoint can generate as an output device.""" 212 | 213 | def __init__( 214 | self, 215 | *, 216 | input_encodings: collections.abc.Iterable[global___Options.Encoding] | None = ..., 217 | output_encodings: collections.abc.Iterable[global___Options.Encoding] | None = ..., 218 | preferred_role: global___Options.RoleType.ValueType | None = ..., 219 | ) -> None: ... 220 | def HasField(self, field_name: typing.Literal["preferred_role", b"preferred_role"]) -> builtins.bool: ... 221 | def ClearField(self, field_name: typing.Literal["input_encodings", b"input_encodings", "output_encodings", b"output_encodings", "preferred_role", b"preferred_role"]) -> None: ... 222 | 223 | global___Options = Options 224 | 225 | @typing.final 226 | class Configuration(google.protobuf.message.Message): 227 | DESCRIPTOR: google.protobuf.descriptor.Descriptor 228 | 229 | ENCODING_FIELD_NUMBER: builtins.int 230 | CLIENT_ROLE_FIELD_NUMBER: builtins.int 231 | client_role: global___Options.RoleType.ValueType 232 | """The role of the client (ie, the one initiating pairing). This implies the 233 | peer (server) acts as the complementary role. 234 | """ 235 | @property 236 | def encoding(self) -> global___Options.Encoding: 237 | """The encoding to be used in this session.""" 238 | 239 | def __init__( 240 | self, 241 | *, 242 | encoding: global___Options.Encoding | None = ..., 243 | client_role: global___Options.RoleType.ValueType | None = ..., 244 | ) -> None: ... 245 | def HasField(self, field_name: typing.Literal["client_role", b"client_role", "encoding", b"encoding"]) -> builtins.bool: ... 246 | def ClearField(self, field_name: typing.Literal["client_role", b"client_role", "encoding", b"encoding"]) -> None: ... 247 | 248 | global___Configuration = Configuration 249 | 250 | @typing.final 251 | class ConfigurationAck(google.protobuf.message.Message): 252 | DESCRIPTOR: google.protobuf.descriptor.Descriptor 253 | 254 | def __init__( 255 | self, 256 | ) -> None: ... 257 | 258 | global___ConfigurationAck = ConfigurationAck 259 | 260 | @typing.final 261 | class Secret(google.protobuf.message.Message): 262 | """ 263 | Pairing messages 264 | """ 265 | 266 | DESCRIPTOR: google.protobuf.descriptor.Descriptor 267 | 268 | SECRET_FIELD_NUMBER: builtins.int 269 | secret: builtins.bytes 270 | def __init__( 271 | self, 272 | *, 273 | secret: builtins.bytes | None = ..., 274 | ) -> None: ... 275 | def HasField(self, field_name: typing.Literal["secret", b"secret"]) -> builtins.bool: ... 276 | def ClearField(self, field_name: typing.Literal["secret", b"secret"]) -> None: ... 277 | 278 | global___Secret = Secret 279 | 280 | @typing.final 281 | class SecretAck(google.protobuf.message.Message): 282 | DESCRIPTOR: google.protobuf.descriptor.Descriptor 283 | 284 | SECRET_FIELD_NUMBER: builtins.int 285 | secret: builtins.bytes 286 | def __init__( 287 | self, 288 | *, 289 | secret: builtins.bytes | None = ..., 290 | ) -> None: ... 291 | def HasField(self, field_name: typing.Literal["secret", b"secret"]) -> builtins.bool: ... 292 | def ClearField(self, field_name: typing.Literal["secret", b"secret"]) -> None: ... 293 | 294 | global___SecretAck = SecretAck 295 | -------------------------------------------------------------------------------- /src/androidtvremote2/remote.py: -------------------------------------------------------------------------------- 1 | """Remote protocol with an Android TV.""" 2 | 3 | # Based on: 4 | # https://github.com/louis49/androidtv-remote/tree/main/src/remote 5 | # https://github.com/farshid616/Android-TV-Remote-Controller-Python/blob/main/sending_keys.py 6 | 7 | from __future__ import annotations 8 | 9 | import asyncio 10 | from collections.abc import Callable 11 | from enum import IntFlag 12 | 13 | from google.protobuf import text_format 14 | from google.protobuf.message import DecodeError 15 | 16 | from .base import ProtobufProtocol 17 | from .const import LOGGER 18 | from .remotemessage_pb2 import ( 19 | RemoteDirection, 20 | RemoteEditInfo, 21 | RemoteImeBatchEdit, 22 | RemoteImeObject, 23 | RemoteKeyCode, 24 | RemoteMessage, 25 | ) 26 | 27 | LOG_PING_REQUESTS = False 28 | ERROR_SUGGESTION_MSG = ( 29 | "Try clearing the storage of the Android TV Remote Service system app. " 30 | "On the Android TV device, go to Settings > Apps > See all apps > Show system apps. " 31 | "Then, select Android TV Remote Service > Storage > Clear data/storage." 32 | ) 33 | KEYCODE_PREFIX = "KEYCODE_" 34 | TEXT_PREFIX = "text:" 35 | 36 | 37 | class Feature(IntFlag): 38 | """Supported features.""" 39 | 40 | PING = 2**0 41 | KEY = 2**1 42 | IME = 2**2 43 | POWER = 2**5 44 | VOLUME = 2**6 45 | APP_LINK = 2**9 46 | 47 | 48 | class RemoteProtocol(ProtobufProtocol): 49 | """Implement remote protocol with an Android TV. 50 | 51 | Messages transmitted between client and server are of type RemoteMessage, see remotemessage.proto. 52 | Protocol is described in 53 | https://github.com/Aymkdn/assistant-freebox-cloud/wiki/Google-TV-(aka-Android-TV)-Remote-Control-(v2) 54 | """ 55 | 56 | def __init__( 57 | self, 58 | on_con_lost: asyncio.Future, 59 | on_remote_started: asyncio.Future, 60 | on_is_on_updated: Callable, 61 | on_current_app_updated: Callable, 62 | on_volume_info_updated: Callable, 63 | loop: asyncio.AbstractEventLoop, 64 | enable_ime: bool, 65 | ) -> None: 66 | """Initialize. 67 | 68 | :param on_con_lost: callback for when the connection is lost or closed. 69 | :param on_remote_started: callback for when the Android TV is ready to receive commands. 70 | :param on_is_on_updated: callback for when is_on is updated. 71 | :param on_current_app_updated: callback for when current_app is updated. 72 | :param on_volume_info_updated: callback for when volume_info is updated. 73 | :param loop: event loop. 74 | :param enable_ime: Needed for getting current_app. 75 | Disable for devices that show 'Use keyboard on mobile device screen'. 76 | """ 77 | super().__init__(on_con_lost) 78 | self._on_remote_started = on_remote_started 79 | self._on_is_on_updated = on_is_on_updated 80 | self._on_current_app_updated = on_current_app_updated 81 | self._on_volume_info_updated = on_volume_info_updated 82 | self._active_features = ( 83 | Feature.PING 84 | | Feature.KEY 85 | | Feature.POWER 86 | | Feature.VOLUME 87 | | Feature.APP_LINK 88 | | (Feature.IME if enable_ime else 0) 89 | ) 90 | self.is_on = False 91 | self.current_app = "" 92 | self.device_info: dict[str, str] = {} 93 | self.volume_info: dict[str, str | bool | int] = {} 94 | self.ime_counter: int = 0 95 | self.ime_field_counter: int = 0 96 | self._loop = loop 97 | self._idle_disconnect_task: asyncio.Task | None = None 98 | self._reset_idle_disconnect_task() 99 | 100 | def send_key_command( 101 | self, key_code: int | str, direction: int | str = RemoteDirection.SHORT 102 | ) -> None: 103 | """Send a key press to Android TV. 104 | 105 | This does not block; it buffers the data and arranges for it to be sent out asynchronously. 106 | 107 | :param key_code: int (e.g. 26) or str (e.g. "KEYCODE_POWER" or just "POWER") from the enum 108 | RemoteKeyCode in remotemessage.proto or str prefixed with "text:" to pass 109 | to send_text. 110 | :param direction: "SHORT" (default) or "START_LONG" or "END_LONG". 111 | :raises ValueError: if key_code in str or direction isn't known. 112 | """ 113 | self._reset_idle_disconnect_task() 114 | msg = RemoteMessage() 115 | if isinstance(key_code, str): 116 | if key_code.lower().startswith(TEXT_PREFIX): 117 | return self.send_text(key_code[len(TEXT_PREFIX):]) 118 | if not key_code.startswith(KEYCODE_PREFIX): 119 | key_code = KEYCODE_PREFIX + key_code 120 | key_code = RemoteKeyCode.Value(key_code) 121 | if isinstance(direction, str): 122 | direction = RemoteDirection.Value(direction) 123 | msg.remote_key_inject.key_code = key_code # type: ignore[assignment] 124 | msg.remote_key_inject.direction = direction # type: ignore[assignment] 125 | self._send_message(msg) 126 | 127 | def send_text(self, text: str) -> None: 128 | """Send a text string to Android TV via the input method. 129 | 130 | The text length is used for both `start` and `end` in the RemoteImeObject. 131 | The `ime_counter` and `ime_field_counter` values are taken from self (batch_edit_info response), 132 | which is populated when a message with a remote_ime_batch_edit field is received. 133 | 134 | :param text: The text string to be sent. 135 | """ 136 | if not text: 137 | raise ValueError("Text cannot be empty") 138 | 139 | self._reset_idle_disconnect_task() 140 | msg = RemoteMessage() 141 | param_value = len(text) - 1 142 | ime_object = RemoteImeObject(start=param_value, end=param_value, value=text) 143 | edit_info = RemoteEditInfo(insert=1, text_field_status=ime_object) 144 | batch_edit = RemoteImeBatchEdit( 145 | ime_counter=self.ime_counter, 146 | field_counter=self.ime_field_counter, 147 | edit_info=[edit_info], 148 | ) 149 | msg.remote_ime_batch_edit.CopyFrom(batch_edit) 150 | self._send_message(msg) 151 | 152 | def send_launch_app_command(self, app_link: str) -> None: 153 | """Launch an app on Android TV. 154 | 155 | This does not block; it buffers the data and arranges for it to be sent out asynchronously. 156 | """ 157 | self._reset_idle_disconnect_task() 158 | msg = RemoteMessage() 159 | msg.remote_app_link_launch_request.app_link = app_link 160 | self._send_message(msg) 161 | 162 | def _handle_message(self, raw_msg: bytes) -> None: 163 | """Handle a message from the server.""" 164 | self._reset_idle_disconnect_task() 165 | msg = RemoteMessage() 166 | try: 167 | msg.ParseFromString(raw_msg) 168 | except DecodeError as exc: 169 | LOGGER.debug("Couldn't parse as RemoteMessage. %s", exc) 170 | return 171 | if LOG_PING_REQUESTS or not msg.HasField("remote_ping_request"): 172 | LOGGER.debug( 173 | "Received: %s", text_format.MessageToString(msg, as_one_line=True) 174 | ) 175 | 176 | new_msg = RemoteMessage() 177 | log_send = True 178 | 179 | if msg.HasField("remote_configure"): 180 | cfg = msg.remote_configure 181 | self.device_info = { 182 | "manufacturer": cfg.device_info.vendor, 183 | "model": cfg.device_info.model, 184 | "sw_version": cfg.device_info.app_version, 185 | } 186 | supported_features = Feature(cfg.code1) 187 | LOGGER.debug("Device supports: %s", [supported_features]) 188 | if Feature.KEY not in supported_features: 189 | LOGGER.error( 190 | "Device doesn't support sending keys. %s", ERROR_SUGGESTION_MSG 191 | ) 192 | if Feature.APP_LINK not in supported_features: 193 | LOGGER.error( 194 | "Device doesn't support sending app links. %s", ERROR_SUGGESTION_MSG 195 | ) 196 | self._active_features &= supported_features 197 | new_msg.remote_configure.code1 = self._active_features.value 198 | new_msg.remote_configure.device_info.unknown1 = 1 199 | new_msg.remote_configure.device_info.unknown2 = "1" 200 | new_msg.remote_configure.device_info.package_name = "atvremote" 201 | new_msg.remote_configure.device_info.app_version = "1.0.0" 202 | elif msg.HasField("remote_set_active"): 203 | new_msg.remote_set_active.active = self._active_features 204 | elif msg.HasField("remote_ime_key_inject"): 205 | self.current_app = msg.remote_ime_key_inject.app_info.app_package 206 | self._on_current_app_updated(self.current_app) 207 | elif msg.HasField("remote_ime_batch_edit"): 208 | self.ime_counter = msg.remote_ime_batch_edit.ime_counter 209 | self.ime_field_counter = msg.remote_ime_batch_edit.field_counter 210 | elif msg.HasField("remote_set_volume_level"): 211 | self.volume_info = { 212 | "level": msg.remote_set_volume_level.volume_level, 213 | "max": msg.remote_set_volume_level.volume_max, 214 | "muted": msg.remote_set_volume_level.volume_muted, 215 | } 216 | self._on_volume_info_updated(self.volume_info) 217 | elif msg.HasField("remote_start"): 218 | if not self._on_remote_started.done(): 219 | self._on_remote_started.set_result(True) 220 | self.is_on = msg.remote_start.started 221 | self._on_is_on_updated(self.is_on) 222 | elif msg.HasField("remote_ping_request"): 223 | new_msg.remote_ping_response.val1 = msg.remote_ping_request.val1 224 | log_send = LOG_PING_REQUESTS 225 | else: 226 | LOGGER.debug( 227 | "Unhandled: %s", text_format.MessageToString(msg, as_one_line=True) 228 | ) 229 | 230 | if new_msg != RemoteMessage(): 231 | self._send_message(new_msg, log_send) 232 | 233 | def _reset_idle_disconnect_task(self) -> None: 234 | if self._idle_disconnect_task is not None: 235 | self._idle_disconnect_task.cancel() 236 | self._idle_disconnect_task = self._loop.create_task( 237 | self._async_idle_disconnect() 238 | ) 239 | 240 | async def _async_idle_disconnect(self) -> None: 241 | # Disconnect if there is no message from the server or client within 242 | # 16 seconds. Server pings every 5 seconds if there is no command sent. 243 | # This is similar to the server behavior that closes connections after 3 244 | # unanswered pings. 245 | await asyncio.sleep(16) 246 | LOGGER.debug("Closing idle connection") 247 | if self.transport and not self.transport.is_closing(): 248 | self.transport.close() 249 | if not self.on_con_lost.done(): 250 | self.on_con_lost.set_result(Exception("Closed idle connection")) 251 | -------------------------------------------------------------------------------- /src/androidtvremote2/remotemessage.proto: -------------------------------------------------------------------------------- 1 | // Copied from https://github.com/louis49/androidtv-remote/blob/main/src/remote/remotemessage.proto 2 | // Comments on RemoteKeyCode enum added from https://android.googlesource.com/platform/frameworks/native/+/master/include/android/keycodes.h 3 | 4 | syntax = "proto3"; 5 | package remote; 6 | 7 | message RemoteAppLinkLaunchRequest { 8 | string app_link = 1; 9 | } 10 | 11 | message RemoteResetPreferredAudioDevice { 12 | 13 | } 14 | 15 | message RemoteSetPreferredAudioDevice { 16 | 17 | } 18 | 19 | message RemoteAdjustVolumeLevel { 20 | 21 | } 22 | 23 | message RemoteSetVolumeLevel { 24 | uint32 unknown1 = 1; 25 | uint32 unknown2 = 2; 26 | string player_model = 3; 27 | uint32 unknown4 = 4; 28 | uint32 unknown5 = 5; 29 | uint32 volume_max = 6; 30 | uint32 volume_level = 7; 31 | bool volume_muted = 8; 32 | } 33 | 34 | message RemoteStart { 35 | bool started = 1; 36 | } 37 | 38 | message RemoteVoiceEnd { 39 | 40 | } 41 | 42 | message RemoteVoicePayload { 43 | 44 | } 45 | 46 | message RemoteVoiceBegin { 47 | 48 | } 49 | 50 | message RemoteTextFieldStatus { 51 | int32 counter_field = 1; 52 | string value = 2; 53 | int32 start = 3; 54 | int32 end = 4; 55 | int32 int5 = 5; 56 | string label = 6; 57 | } 58 | 59 | message RemoteImeShowRequest { 60 | RemoteTextFieldStatus remote_text_field_status = 2; 61 | } 62 | 63 | message RemoteEditInfo { 64 | int32 insert = 1; 65 | RemoteImeObject text_field_status = 2; 66 | } 67 | 68 | message RemoteImeObject { 69 | int32 start = 1; 70 | int32 end = 2; 71 | string value = 3; 72 | } 73 | 74 | message RemoteImeBatchEdit { 75 | int32 ime_counter = 1; 76 | int32 field_counter = 2; 77 | repeated RemoteEditInfo edit_info = 3; 78 | } 79 | 80 | message RemoteAppInfo { 81 | int32 counter = 1; 82 | int32 int2 = 2; 83 | int32 int3 = 3; 84 | string int4 = 4; 85 | int32 int7 = 7; 86 | int32 int8 = 8; 87 | string label = 10; 88 | string app_package = 12; 89 | int32 int13 = 13; 90 | } 91 | 92 | message RemoteImeKeyInject { 93 | RemoteAppInfo app_info = 1; 94 | RemoteTextFieldStatus text_field_status = 2; 95 | } 96 | 97 | enum RemoteKeyCode { 98 | // Unknown key code. 99 | KEYCODE_UNKNOWN = 0; 100 | // Soft Left key. 101 | // Usually situated below the display on phones and used as a multi-function 102 | // feature key for selecting a software defined function shown on the bottom left 103 | // of the display. 104 | KEYCODE_SOFT_LEFT = 1; 105 | // Soft Right key. 106 | // Usually situated below the display on phones and used as a multi-function 107 | // feature key for selecting a software defined function shown on the bottom right 108 | // of the display. 109 | KEYCODE_SOFT_RIGHT = 2; 110 | // Home key. 111 | // This key is handled by the framework and is never delivered to applications. 112 | KEYCODE_HOME = 3; 113 | // Back key. 114 | KEYCODE_BACK = 4; 115 | // Call key. 116 | KEYCODE_CALL = 5; 117 | // End Call key. 118 | KEYCODE_ENDCALL = 6; 119 | // '0' key. 120 | KEYCODE_0 = 7; 121 | // '1' key. 122 | KEYCODE_1 = 8; 123 | // '2' key. 124 | KEYCODE_2 = 9; 125 | // '3' key. 126 | KEYCODE_3 = 10; 127 | // '4' key. 128 | KEYCODE_4 = 11; 129 | // '5' key. 130 | KEYCODE_5 = 12; 131 | // '6' key. 132 | KEYCODE_6 = 13; 133 | // '7' key. 134 | KEYCODE_7 = 14; 135 | // '8' key. 136 | KEYCODE_8 = 15; 137 | // '9' key. 138 | KEYCODE_9 = 16; 139 | // '*' key. 140 | KEYCODE_STAR = 17; 141 | // '#' key. 142 | KEYCODE_POUND = 18; 143 | // Directional Pad Up key. 144 | // May also be synthesized from trackball motions. 145 | KEYCODE_DPAD_UP = 19; 146 | // Directional Pad Down key. 147 | // May also be synthesized from trackball motions. 148 | KEYCODE_DPAD_DOWN = 20; 149 | // Directional Pad Left key. 150 | // May also be synthesized from trackball motions. 151 | KEYCODE_DPAD_LEFT = 21; 152 | // Directional Pad Right key. 153 | // May also be synthesized from trackball motions. 154 | KEYCODE_DPAD_RIGHT = 22; 155 | // Directional Pad Center key. 156 | // May also be synthesized from trackball motions. 157 | KEYCODE_DPAD_CENTER = 23; 158 | // Volume Up key. 159 | // Adjusts the speaker volume up. 160 | KEYCODE_VOLUME_UP = 24; 161 | // Volume Down key. 162 | // Adjusts the speaker volume down. 163 | KEYCODE_VOLUME_DOWN = 25; 164 | // Power key. 165 | KEYCODE_POWER = 26; 166 | // Camera key. 167 | // Used to launch a camera application or take pictures. 168 | KEYCODE_CAMERA = 27; 169 | // Clear key. 170 | KEYCODE_CLEAR = 28; 171 | // 'A' key. 172 | KEYCODE_A = 29; 173 | // 'B' key. 174 | KEYCODE_B = 30; 175 | // 'C' key. 176 | KEYCODE_C = 31; 177 | // 'D' key. 178 | KEYCODE_D = 32; 179 | // 'E' key. 180 | KEYCODE_E = 33; 181 | // 'F' key. 182 | KEYCODE_F = 34; 183 | // 'G' key. 184 | KEYCODE_G = 35; 185 | // 'H' key. 186 | KEYCODE_H = 36; 187 | // 'I' key. 188 | KEYCODE_I = 37; 189 | // 'J' key. 190 | KEYCODE_J = 38; 191 | // 'K' key. 192 | KEYCODE_K = 39; 193 | // 'L' key. 194 | KEYCODE_L = 40; 195 | // 'M' key. 196 | KEYCODE_M = 41; 197 | // 'N' key. 198 | KEYCODE_N = 42; 199 | // 'O' key. 200 | KEYCODE_O = 43; 201 | // 'P' key. 202 | KEYCODE_P = 44; 203 | // 'Q' key. 204 | KEYCODE_Q = 45; 205 | // 'R' key. 206 | KEYCODE_R = 46; 207 | // 'S' key. 208 | KEYCODE_S = 47; 209 | // 'T' key. 210 | KEYCODE_T = 48; 211 | // 'U' key. 212 | KEYCODE_U = 49; 213 | // 'V' key. 214 | KEYCODE_V = 50; 215 | // 'W' key. 216 | KEYCODE_W = 51; 217 | // 'X' key. 218 | KEYCODE_X = 52; 219 | // 'Y' key. 220 | KEYCODE_Y = 53; 221 | // 'Z' key. 222 | KEYCODE_Z = 54; 223 | // ',' key. 224 | KEYCODE_COMMA = 55; 225 | // '.' key. 226 | KEYCODE_PERIOD = 56; 227 | // Left Alt modifier key. 228 | KEYCODE_ALT_LEFT = 57; 229 | // Right Alt modifier key. 230 | KEYCODE_ALT_RIGHT = 58; 231 | // Left Shift modifier key. 232 | KEYCODE_SHIFT_LEFT = 59; 233 | // Right Shift modifier key. 234 | KEYCODE_SHIFT_RIGHT = 60; 235 | // Tab key. 236 | KEYCODE_TAB = 61; 237 | // Space key. 238 | KEYCODE_SPACE = 62; 239 | // Symbol modifier key. 240 | // Used to enter alternate symbols. 241 | KEYCODE_SYM = 63; 242 | // Explorer special function key. 243 | // Used to launch a browser application. 244 | KEYCODE_EXPLORER = 64; 245 | // Envelope special function key. 246 | // Used to launch a mail application. 247 | KEYCODE_ENVELOPE = 65; 248 | // Enter key. 249 | KEYCODE_ENTER = 66; 250 | // Backspace key. 251 | // Deletes characters before the insertion point, unlike KEYCODE_FORWARD_DEL. 252 | KEYCODE_DEL = 67; 253 | // '`' (backtick) key. 254 | KEYCODE_GRAVE = 68; 255 | // '-'. 256 | KEYCODE_MINUS = 69; 257 | // '=' key. 258 | KEYCODE_EQUALS = 70; 259 | // '[' key. 260 | KEYCODE_LEFT_BRACKET = 71; 261 | // ']' key. 262 | KEYCODE_RIGHT_BRACKET = 72; 263 | // '\' key. 264 | KEYCODE_BACKSLASH = 73; 265 | // ';' key. 266 | KEYCODE_SEMICOLON = 74; 267 | // ''' (apostrophe) key. 268 | KEYCODE_APOSTROPHE = 75; 269 | // '/' key. 270 | KEYCODE_SLASH = 76; 271 | // '@' key. 272 | KEYCODE_AT = 77; 273 | // Number modifier key. 274 | // Used to enter numeric symbols. 275 | // This key is not KEYCODE_NUM_LOCK; it is more like KEYCODE_ALT_LEFT. 276 | KEYCODE_NUM = 78; 277 | // Headset Hook key. 278 | // Used to hang up calls and stop media. 279 | KEYCODE_HEADSETHOOK = 79; 280 | // Camera Focus key. 281 | // Used to focus the camera. 282 | KEYCODE_FOCUS = 80; 283 | // '+' key. 284 | KEYCODE_PLUS = 81; 285 | // Menu key. 286 | KEYCODE_MENU = 82; 287 | // Notification key. 288 | KEYCODE_NOTIFICATION = 83; 289 | // Search key. 290 | KEYCODE_SEARCH = 84; 291 | // Play/Pause media key. 292 | KEYCODE_MEDIA_PLAY_PAUSE= 85; 293 | // Stop media key. 294 | KEYCODE_MEDIA_STOP = 86; 295 | // Play Next media key. 296 | KEYCODE_MEDIA_NEXT = 87; 297 | // Play Previous media key. 298 | KEYCODE_MEDIA_PREVIOUS = 88; 299 | // Rewind media key. 300 | KEYCODE_MEDIA_REWIND = 89; 301 | // Fast Forward media key. 302 | KEYCODE_MEDIA_FAST_FORWARD = 90; 303 | // Mute key. 304 | // Mutes the microphone, unlike KEYCODE_VOLUME_MUTE. 305 | KEYCODE_MUTE = 91; 306 | // Page Up key. 307 | KEYCODE_PAGE_UP = 92; 308 | // Page Down key. 309 | KEYCODE_PAGE_DOWN = 93; 310 | // Picture Symbols modifier key. 311 | // Used to switch symbol sets (Emoji, Kao-moji). 312 | KEYCODE_PICTSYMBOLS = 94; 313 | // Switch Charset modifier key. 314 | // Used to switch character sets (Kanji, Katakana). 315 | KEYCODE_SWITCH_CHARSET = 95; 316 | // A Button key. 317 | // On a game controller, the A button should be either the button labeled A 318 | // or the first button on the bottom row of controller buttons. 319 | KEYCODE_BUTTON_A = 96; 320 | // B Button key. 321 | // On a game controller, the B button should be either the button labeled B 322 | // or the second button on the bottom row of controller buttons. 323 | KEYCODE_BUTTON_B = 97; 324 | // C Button key. 325 | // On a game controller, the C button should be either the button labeled C 326 | // or the third button on the bottom row of controller buttons. 327 | KEYCODE_BUTTON_C = 98; 328 | // X Button key. 329 | // On a game controller, the X button should be either the button labeled X 330 | // or the first button on the upper row of controller buttons. 331 | KEYCODE_BUTTON_X = 99; 332 | // Y Button key. 333 | // On a game controller, the Y button should be either the button labeled Y 334 | // or the second button on the upper row of controller buttons. 335 | KEYCODE_BUTTON_Y = 100; 336 | // Z Button key. 337 | // On a game controller, the Z button should be either the button labeled Z 338 | // or the third button on the upper row of controller buttons. 339 | KEYCODE_BUTTON_Z = 101; 340 | // L1 Button key. 341 | // On a game controller, the L1 button should be either the button labeled L1 (or L) 342 | // or the top left trigger button. 343 | KEYCODE_BUTTON_L1 = 102; 344 | // R1 Button key. 345 | // On a game controller, the R1 button should be either the button labeled R1 (or R) 346 | // or the top right trigger button. 347 | KEYCODE_BUTTON_R1 = 103; 348 | // L2 Button key. 349 | // On a game controller, the L2 button should be either the button labeled L2 350 | // or the bottom left trigger button. 351 | KEYCODE_BUTTON_L2 = 104; 352 | // R2 Button key. 353 | // On a game controller, the R2 button should be either the button labeled R2 354 | // or the bottom right trigger button. 355 | KEYCODE_BUTTON_R2 = 105; 356 | // Left Thumb Button key. 357 | // On a game controller, the left thumb button indicates that the left (or only) 358 | // joystick is pressed. 359 | KEYCODE_BUTTON_THUMBL = 106; 360 | // Right Thumb Button key. 361 | // On a game controller, the right thumb button indicates that the right 362 | // joystick is pressed. 363 | KEYCODE_BUTTON_THUMBR = 107; 364 | // Start Button key. 365 | // On a game controller, the button labeled Start. 366 | KEYCODE_BUTTON_START = 108; 367 | // Select Button key. 368 | // On a game controller, the button labeled Select. 369 | KEYCODE_BUTTON_SELECT = 109; 370 | // Mode Button key. 371 | // On a game controller, the button labeled Mode. 372 | KEYCODE_BUTTON_MODE = 110; 373 | // Escape key. 374 | KEYCODE_ESCAPE = 111; 375 | // Forward Delete key. 376 | // Deletes characters ahead of the insertion point, unlike KEYCODE_DEL. 377 | KEYCODE_FORWARD_DEL = 112; 378 | // Left Control modifier key. 379 | KEYCODE_CTRL_LEFT = 113; 380 | // Right Control modifier key. 381 | KEYCODE_CTRL_RIGHT = 114; 382 | // Caps Lock key. 383 | KEYCODE_CAPS_LOCK = 115; 384 | // Scroll Lock key. 385 | KEYCODE_SCROLL_LOCK = 116; 386 | // Left Meta modifier key. 387 | KEYCODE_META_LEFT = 117; 388 | // Right Meta modifier key. 389 | KEYCODE_META_RIGHT = 118; 390 | // Function modifier key. 391 | KEYCODE_FUNCTION = 119; 392 | // System Request / Print Screen key. 393 | KEYCODE_SYSRQ = 120; 394 | // Break / Pause key. 395 | KEYCODE_BREAK = 121; 396 | // Home Movement key. 397 | // Used for scrolling or moving the cursor around to the start of a line 398 | // or to the top of a list. 399 | KEYCODE_MOVE_HOME = 122; 400 | // End Movement key. 401 | // Used for scrolling or moving the cursor around to the end of a line 402 | // or to the bottom of a list. 403 | KEYCODE_MOVE_END = 123; 404 | // Insert key. 405 | // Toggles insert / overwrite edit mode. 406 | KEYCODE_INSERT = 124; 407 | // Forward key. 408 | // Navigates forward in the history stack. Complement of KEYCODE_BACK. 409 | KEYCODE_FORWARD = 125; 410 | // Play media key. 411 | KEYCODE_MEDIA_PLAY = 126; 412 | // Pause media key. 413 | KEYCODE_MEDIA_PAUSE = 127; 414 | // Close media key. 415 | // May be used to close a CD tray, for example. 416 | KEYCODE_MEDIA_CLOSE = 128; 417 | // Eject media key. 418 | // May be used to eject a CD tray, for example. 419 | KEYCODE_MEDIA_EJECT = 129; 420 | // Record media key. 421 | KEYCODE_MEDIA_RECORD = 130; 422 | // F1 key. 423 | KEYCODE_F1 = 131; 424 | // F2 key. 425 | KEYCODE_F2 = 132; 426 | // F3 key. 427 | KEYCODE_F3 = 133; 428 | // F4 key. 429 | KEYCODE_F4 = 134; 430 | // F5 key. 431 | KEYCODE_F5 = 135; 432 | // F6 key. 433 | KEYCODE_F6 = 136; 434 | // F7 key. 435 | KEYCODE_F7 = 137; 436 | // F8 key. 437 | KEYCODE_F8 = 138; 438 | // F9 key. 439 | KEYCODE_F9 = 139; 440 | // F10 key. 441 | KEYCODE_F10 = 140; 442 | // F11 key. 443 | KEYCODE_F11 = 141; 444 | // F12 key. 445 | KEYCODE_F12 = 142; 446 | // Num Lock key. 447 | // This is the Num Lock key; it is different from KEYCODE_NUM. 448 | // This key alters the behavior of other keys on the numeric keypad. 449 | KEYCODE_NUM_LOCK = 143; 450 | // Numeric keypad '0' key. 451 | KEYCODE_NUMPAD_0 = 144; 452 | // Numeric keypad '1' key. 453 | KEYCODE_NUMPAD_1 = 145; 454 | // Numeric keypad '2' key. 455 | KEYCODE_NUMPAD_2 = 146; 456 | // Numeric keypad '3' key. 457 | KEYCODE_NUMPAD_3 = 147; 458 | // Numeric keypad '4' key. 459 | KEYCODE_NUMPAD_4 = 148; 460 | // Numeric keypad '5' key. 461 | KEYCODE_NUMPAD_5 = 149; 462 | // Numeric keypad '6' key. 463 | KEYCODE_NUMPAD_6 = 150; 464 | // Numeric keypad '7' key. 465 | KEYCODE_NUMPAD_7 = 151; 466 | // Numeric keypad '8' key. 467 | KEYCODE_NUMPAD_8 = 152; 468 | // Numeric keypad '9' key. 469 | KEYCODE_NUMPAD_9 = 153; 470 | // Numeric keypad '/' key (for division). 471 | KEYCODE_NUMPAD_DIVIDE = 154; 472 | // Numeric keypad '*' key (for multiplication). 473 | KEYCODE_NUMPAD_MULTIPLY = 155; 474 | // Numeric keypad '-' key (for subtraction). 475 | KEYCODE_NUMPAD_SUBTRACT = 156; 476 | // Numeric keypad '+' key (for addition). 477 | KEYCODE_NUMPAD_ADD = 157; 478 | // Numeric keypad '.' key (for decimals or digit grouping). 479 | KEYCODE_NUMPAD_DOT = 158; 480 | // Numeric keypad ',' key (for decimals or digit grouping). 481 | KEYCODE_NUMPAD_COMMA = 159; 482 | // Numeric keypad Enter key. 483 | KEYCODE_NUMPAD_ENTER = 160; 484 | // Numeric keypad '=' key. 485 | KEYCODE_NUMPAD_EQUALS = 161; 486 | // Numeric keypad '(' key. 487 | KEYCODE_NUMPAD_LEFT_PAREN = 162; 488 | // Numeric keypad ')' key. 489 | KEYCODE_NUMPAD_RIGHT_PAREN = 163; 490 | // Volume Mute key. 491 | // Mutes the speaker, unlike KEYCODE_MUTE. 492 | // This key should normally be implemented as a toggle such that the first press 493 | // mutes the speaker and the second press restores the original volume. 494 | KEYCODE_VOLUME_MUTE = 164; 495 | // Info key. 496 | // Common on TV remotes to show additional information related to what is 497 | // currently being viewed. 498 | KEYCODE_INFO = 165; 499 | // Channel up key. 500 | // On TV remotes, increments the television channel. 501 | KEYCODE_CHANNEL_UP = 166; 502 | // Channel down key. 503 | // On TV remotes, decrements the television channel. 504 | KEYCODE_CHANNEL_DOWN = 167; 505 | // Zoom in key. 506 | KEYCODE_ZOOM_IN = 168; 507 | // Zoom out key. 508 | KEYCODE_ZOOM_OUT = 169; 509 | // TV key. 510 | // On TV remotes, switches to viewing live TV. 511 | KEYCODE_TV = 170; 512 | // Window key. 513 | // On TV remotes, toggles picture-in-picture mode or other windowing functions. 514 | KEYCODE_WINDOW = 171; 515 | // Guide key. 516 | // On TV remotes, shows a programming guide. 517 | KEYCODE_GUIDE = 172; 518 | // DVR key. 519 | // On some TV remotes, switches to a DVR mode for recorded shows. 520 | KEYCODE_DVR = 173; 521 | // Bookmark key. 522 | // On some TV remotes, bookmarks content or web pages. 523 | KEYCODE_BOOKMARK = 174; 524 | // Toggle captions key. 525 | // Switches the mode for closed-captioning text, for example during television shows. 526 | KEYCODE_CAPTIONS = 175; 527 | // Settings key. 528 | // Starts the system settings activity. 529 | KEYCODE_SETTINGS = 176; 530 | // TV power key. 531 | // On TV remotes, toggles the power on a television screen. 532 | KEYCODE_TV_POWER = 177; 533 | // TV input key. 534 | // On TV remotes, switches the input on a television screen. 535 | KEYCODE_TV_INPUT = 178; 536 | // Set-top-box power key. 537 | // On TV remotes, toggles the power on an external Set-top-box. 538 | KEYCODE_STB_POWER = 179; 539 | // Set-top-box input key. 540 | // On TV remotes, switches the input mode on an external Set-top-box. 541 | KEYCODE_STB_INPUT = 180; 542 | // A/V Receiver power key. 543 | // On TV remotes, toggles the power on an external A/V Receiver. 544 | KEYCODE_AVR_POWER = 181; 545 | // A/V Receiver input key. 546 | // On TV remotes, switches the input mode on an external A/V Receiver. 547 | KEYCODE_AVR_INPUT = 182; 548 | // Red "programmable" key. 549 | // On TV remotes, acts as a contextual/programmable key. 550 | KEYCODE_PROG_RED = 183; 551 | // Green "programmable" key. 552 | // On TV remotes, actsas a contextual/programmable key. 553 | KEYCODE_PROG_GREEN = 184; 554 | // Yellow "programmable" key. 555 | // On TV remotes, acts as a contextual/programmable key. 556 | KEYCODE_PROG_YELLOW = 185; 557 | // Blue "programmable" key. 558 | // On TV remotes, acts as a contextual/programmable key. 559 | KEYCODE_PROG_BLUE = 186; 560 | // App switch key. 561 | // Should bring up the application switcher dialog. 562 | KEYCODE_APP_SWITCH = 187; 563 | // Generic Game Pad Button #1.*/ 564 | KEYCODE_BUTTON_1 = 188; 565 | // Generic Game Pad Button #2.*/ 566 | KEYCODE_BUTTON_2 = 189; 567 | // Generic Game Pad Button #3.*/ 568 | KEYCODE_BUTTON_3 = 190; 569 | // Generic Game Pad Button #4.*/ 570 | KEYCODE_BUTTON_4 = 191; 571 | // Generic Game Pad Button #5.*/ 572 | KEYCODE_BUTTON_5 = 192; 573 | // Generic Game Pad Button #6.*/ 574 | KEYCODE_BUTTON_6 = 193; 575 | // Generic Game Pad Button #7.*/ 576 | KEYCODE_BUTTON_7 = 194; 577 | // Generic Game Pad Button #8.*/ 578 | KEYCODE_BUTTON_8 = 195; 579 | // Generic Game Pad Button #9.*/ 580 | KEYCODE_BUTTON_9 = 196; 581 | // Generic Game Pad Button #10.*/ 582 | KEYCODE_BUTTON_10 = 197; 583 | // Generic Game Pad Button #11.*/ 584 | KEYCODE_BUTTON_11 = 198; 585 | // Generic Game Pad Button #12.*/ 586 | KEYCODE_BUTTON_12 = 199; 587 | // Generic Game Pad Button #13.*/ 588 | KEYCODE_BUTTON_13 = 200; 589 | // Generic Game Pad Button #14.*/ 590 | KEYCODE_BUTTON_14 = 201; 591 | // Generic Game Pad Button #15.*/ 592 | KEYCODE_BUTTON_15 = 202; 593 | // Generic Game Pad Button #16.*/ 594 | KEYCODE_BUTTON_16 = 203; 595 | // Language Switch key. 596 | // Toggles the current input language such as switching between English and Japanese on 597 | // a QWERTY keyboard. On some devices, the same function may be performed by 598 | // pressing Shift+Spacebar. 599 | KEYCODE_LANGUAGE_SWITCH = 204; 600 | // Manner Mode key. 601 | // Toggles silent or vibrate mode on and off to make the device behave more politely 602 | // in certain settings such as on a crowded train. On some devices, the key may only 603 | // operate when long-pressed. 604 | KEYCODE_MANNER_MODE = 205; 605 | // 3D Mode key. 606 | // Toggles the display between 2D and 3D mode. 607 | KEYCODE_3D_MODE = 206; 608 | // Contacts special function key. 609 | // Used to launch an address book application. 610 | KEYCODE_CONTACTS = 207; 611 | // Calendar special function key. 612 | // Used to launch a calendar application. 613 | KEYCODE_CALENDAR = 208; 614 | // Music special function key. 615 | // Used to launch a music player application. 616 | KEYCODE_MUSIC = 209; 617 | // Calculator special function key. 618 | // Used to launch a calculator application. 619 | KEYCODE_CALCULATOR = 210; 620 | // Japanese full-width / half-width key. 621 | KEYCODE_ZENKAKU_HANKAKU = 211; 622 | // Japanese alphanumeric key. 623 | KEYCODE_EISU = 212; 624 | // Japanese non-conversion key. 625 | KEYCODE_MUHENKAN = 213; 626 | // Japanese conversion key. 627 | KEYCODE_HENKAN = 214; 628 | // Japanese katakana / hiragana key. 629 | KEYCODE_KATAKANA_HIRAGANA = 215; 630 | // Japanese Yen key. 631 | KEYCODE_YEN = 216; 632 | // Japanese Ro key. 633 | KEYCODE_RO = 217; 634 | // Japanese kana key. 635 | KEYCODE_KANA = 218; 636 | // Assist key. 637 | // Launches the global assist activity. Not delivered to applications. 638 | KEYCODE_ASSIST = 219; 639 | // Brightness Down key. 640 | // Adjusts the screen brightness down. 641 | KEYCODE_BRIGHTNESS_DOWN = 220; 642 | // Brightness Up key. 643 | // Adjusts the screen brightness up. 644 | KEYCODE_BRIGHTNESS_UP = 221; 645 | // Audio Track key. 646 | // Switches the audio tracks. 647 | KEYCODE_MEDIA_AUDIO_TRACK = 222; 648 | // Sleep key. 649 | // Puts the device to sleep. Behaves somewhat like KEYCODE_POWER but it 650 | // has no effect if the device is already asleep. 651 | KEYCODE_SLEEP = 223; 652 | // Wakeup key. 653 | // Wakes up the device. Behaves somewhat like KEYCODE_POWER but it 654 | // has no effect if the device is already awake. 655 | KEYCODE_WAKEUP = 224; 656 | // Pairing key. 657 | // Initiates peripheral pairing mode. Useful for pairing remote control 658 | // devices or game controllers, especially if no other input mode is 659 | // available. 660 | KEYCODE_PAIRING = 225; 661 | // Media Top Menu key. 662 | // Goes to the top of media menu. 663 | KEYCODE_MEDIA_TOP_MENU = 226; 664 | // '11' key. 665 | KEYCODE_11 = 227; 666 | // '12' key. 667 | KEYCODE_12 = 228; 668 | // Last Channel key. 669 | // Goes to the last viewed channel. 670 | KEYCODE_LAST_CHANNEL = 229; 671 | // TV data service key. 672 | // Displays data services like weather, sports. 673 | KEYCODE_TV_DATA_SERVICE = 230; 674 | // Voice Assist key. 675 | // Launches the global voice assist activity. Not delivered to applications. 676 | KEYCODE_VOICE_ASSIST = 231; 677 | // Radio key. 678 | // Toggles TV service / Radio service. 679 | KEYCODE_TV_RADIO_SERVICE = 232; 680 | // Teletext key. 681 | // Displays Teletext service. 682 | KEYCODE_TV_TELETEXT = 233; 683 | // Number entry key. 684 | // Initiates to enter multi-digit channel nubmber when each digit key is assigned 685 | // for selecting separate channel. Corresponds to Number Entry Mode (0x1D) of CEC 686 | // User Control Code. 687 | KEYCODE_TV_NUMBER_ENTRY = 234; 688 | // Analog Terrestrial key. 689 | // Switches to analog terrestrial broadcast service. 690 | KEYCODE_TV_TERRESTRIAL_ANALOG = 235; 691 | // Digital Terrestrial key. 692 | // Switches to digital terrestrial broadcast service. 693 | KEYCODE_TV_TERRESTRIAL_DIGITAL = 236; 694 | // Satellite key. 695 | // Switches to digital satellite broadcast service. 696 | KEYCODE_TV_SATELLITE = 237; 697 | // BS key. 698 | // Switches to BS digital satellite broadcasting service available in Japan. 699 | KEYCODE_TV_SATELLITE_BS = 238; 700 | // CS key. 701 | // Switches to CS digital satellite broadcasting service available in Japan. 702 | KEYCODE_TV_SATELLITE_CS = 239; 703 | // BS/CS key. 704 | // Toggles between BS and CS digital satellite services. 705 | KEYCODE_TV_SATELLITE_SERVICE = 240; 706 | // Toggle Network key. 707 | // Toggles selecting broadcast services. 708 | KEYCODE_TV_NETWORK = 241; 709 | // Antenna/Cable key. 710 | // Toggles broadcast input source between antenna and cable. 711 | KEYCODE_TV_ANTENNA_CABLE = 242; 712 | // HDMI #1 key. 713 | // Switches to HDMI input #1. 714 | KEYCODE_TV_INPUT_HDMI_1 = 243; 715 | // HDMI #2 key. 716 | // Switches to HDMI input #2. 717 | KEYCODE_TV_INPUT_HDMI_2 = 244; 718 | // HDMI #3 key. 719 | // Switches to HDMI input #3. 720 | KEYCODE_TV_INPUT_HDMI_3 = 245; 721 | // HDMI #4 key. 722 | // Switches to HDMI input #4. 723 | KEYCODE_TV_INPUT_HDMI_4 = 246; 724 | // Composite #1 key. 725 | // Switches to composite video input #1. 726 | KEYCODE_TV_INPUT_COMPOSITE_1 = 247; 727 | // Composite #2 key. 728 | // Switches to composite video input #2. 729 | KEYCODE_TV_INPUT_COMPOSITE_2 = 248; 730 | // Component #1 key. 731 | // Switches to component video input #1. 732 | KEYCODE_TV_INPUT_COMPONENT_1 = 249; 733 | // Component #2 key. 734 | // Switches to component video input #2. 735 | KEYCODE_TV_INPUT_COMPONENT_2 = 250; 736 | // VGA #1 key. 737 | // Switches to VGA (analog RGB) input #1. 738 | KEYCODE_TV_INPUT_VGA_1 = 251; 739 | // Audio description key. 740 | // Toggles audio description off / on. 741 | KEYCODE_TV_AUDIO_DESCRIPTION = 252; 742 | // Audio description mixing volume up key. 743 | // Louden audio description volume as compared with normal audio volume. 744 | KEYCODE_TV_AUDIO_DESCRIPTION_MIX_UP = 253; 745 | // Audio description mixing volume down key. 746 | // Lessen audio description volume as compared with normal audio volume. 747 | KEYCODE_TV_AUDIO_DESCRIPTION_MIX_DOWN = 254; 748 | // Zoom mode key. 749 | // Changes Zoom mode (Normal, Full, Zoom, Wide-zoom, etc.) 750 | KEYCODE_TV_ZOOM_MODE = 255; 751 | // Contents menu key. 752 | // Goes to the title list. Corresponds to Contents Menu (0x0B) of CEC User Control 753 | // Code 754 | KEYCODE_TV_CONTENTS_MENU = 256; 755 | // Media context menu key. 756 | // Goes to the context menu of media contents. Corresponds to Media Context-sensitive 757 | // Menu (0x11) of CEC User Control Code. 758 | KEYCODE_TV_MEDIA_CONTEXT_MENU = 257; 759 | // Timer programming key. 760 | // Goes to the timer recording menu. Corresponds to Timer Programming (0x54) of 761 | // CEC User Control Code. 762 | KEYCODE_TV_TIMER_PROGRAMMING = 258; 763 | // Help key. 764 | KEYCODE_HELP = 259; 765 | KEYCODE_NAVIGATE_PREVIOUS = 260; 766 | KEYCODE_NAVIGATE_NEXT = 261; 767 | KEYCODE_NAVIGATE_IN = 262; 768 | KEYCODE_NAVIGATE_OUT = 263; 769 | // Primary stem key for Wear 770 | // Main power/reset button on watch. 771 | KEYCODE_STEM_PRIMARY = 264; 772 | // Generic stem key 1 for Wear 773 | KEYCODE_STEM_1 = 265; 774 | // Generic stem key 2 for Wear 775 | KEYCODE_STEM_2 = 266; 776 | // Generic stem key 3 for Wear 777 | KEYCODE_STEM_3 = 267; 778 | // Directional Pad Up-Left 779 | KEYCODE_DPAD_UP_LEFT = 268; 780 | // Directional Pad Down-Left 781 | KEYCODE_DPAD_DOWN_LEFT = 269; 782 | // Directional Pad Up-Right 783 | KEYCODE_DPAD_UP_RIGHT = 270; 784 | // Directional Pad Down-Right 785 | KEYCODE_DPAD_DOWN_RIGHT = 271; 786 | // Skip forward media key 787 | KEYCODE_MEDIA_SKIP_FORWARD = 272; 788 | // Skip backward media key 789 | KEYCODE_MEDIA_SKIP_BACKWARD = 273; 790 | // Step forward media key. 791 | // Steps media forward one from at a time. 792 | KEYCODE_MEDIA_STEP_FORWARD = 274; 793 | // Step backward media key. 794 | // Steps media backward one from at a time. 795 | KEYCODE_MEDIA_STEP_BACKWARD = 275; 796 | // Put device to sleep unless a wakelock is held. 797 | KEYCODE_SOFT_SLEEP = 276; 798 | // Cut key. 799 | KEYCODE_CUT = 277; 800 | // Copy key. 801 | KEYCODE_COPY = 278; 802 | // Paste key. 803 | KEYCODE_PASTE = 279; 804 | // fingerprint navigation key, up. 805 | KEYCODE_SYSTEM_NAVIGATION_UP = 280; 806 | // fingerprint navigation key, down. 807 | KEYCODE_SYSTEM_NAVIGATION_DOWN = 281; 808 | // fingerprint navigation key, left. 809 | KEYCODE_SYSTEM_NAVIGATION_LEFT = 282; 810 | // fingerprint navigation key, right. 811 | KEYCODE_SYSTEM_NAVIGATION_RIGHT = 283; 812 | // all apps 813 | KEYCODE_ALL_APPS = 284; 814 | // refresh key 815 | KEYCODE_REFRESH = 285; 816 | // Thumbs up key. Apps can use this to let user upvote content. 817 | KEYCODE_THUMBS_UP = 286; 818 | // Thumbs down key. Apps can use this to let user downvote content. 819 | KEYCODE_THUMBS_DOWN = 287; 820 | // Used to switch current account that is consuming content. 821 | // May be consumed by system to switch current viewer profile. 822 | KEYCODE_PROFILE_SWITCH = 288; 823 | KEYCODE_VIDEO_APP_1 = 289; 824 | KEYCODE_VIDEO_APP_2 = 290; 825 | KEYCODE_VIDEO_APP_3 = 291; 826 | KEYCODE_VIDEO_APP_4 = 292; 827 | KEYCODE_VIDEO_APP_5 = 293; 828 | KEYCODE_VIDEO_APP_6 = 294; 829 | KEYCODE_VIDEO_APP_7 = 295; 830 | KEYCODE_VIDEO_APP_8 = 296; 831 | KEYCODE_FEATURED_APP_1 = 297; 832 | KEYCODE_FEATURED_APP_2 = 298; 833 | KEYCODE_FEATURED_APP_3 = 299; 834 | KEYCODE_FEATURED_APP_4 = 300; 835 | KEYCODE_DEMO_APP_1 = 301; 836 | KEYCODE_DEMO_APP_2 = 302; 837 | KEYCODE_DEMO_APP_3 = 303; 838 | KEYCODE_DEMO_APP_4 = 304; 839 | } 840 | 841 | enum RemoteDirection { 842 | UNKNOWN_DIRECTION = 0; 843 | START_LONG = 1; 844 | END_LONG = 2; 845 | SHORT = 3; 846 | } 847 | 848 | message RemoteKeyInject { 849 | RemoteKeyCode key_code = 1; 850 | RemoteDirection direction = 2; 851 | } 852 | 853 | message RemotePingResponse { 854 | int32 val1 = 1; 855 | } 856 | 857 | message RemotePingRequest { 858 | int32 val1 = 1; 859 | int32 val2 = 2; 860 | } 861 | 862 | message RemoteSetActive { 863 | int32 active = 1; 864 | } 865 | 866 | message RemoteDeviceInfo { 867 | string model = 1; 868 | string vendor = 2; 869 | int32 unknown1 = 3; 870 | string unknown2 = 4; 871 | string package_name = 5; 872 | string app_version = 6; 873 | } 874 | 875 | message RemoteConfigure { 876 | int32 code1 = 1; 877 | RemoteDeviceInfo device_info = 2; 878 | } 879 | 880 | message RemoteError{ 881 | bool value = 1; 882 | RemoteMessage message = 2; 883 | } 884 | 885 | message RemoteMessage { 886 | RemoteConfigure remote_configure = 1; 887 | RemoteSetActive remote_set_active = 2; 888 | RemoteError remote_error = 3; 889 | RemotePingRequest remote_ping_request = 8; 890 | RemotePingResponse remote_ping_response = 9; 891 | RemoteKeyInject remote_key_inject = 10; 892 | RemoteImeKeyInject remote_ime_key_inject = 20; 893 | RemoteImeBatchEdit remote_ime_batch_edit = 21; 894 | RemoteImeShowRequest remote_ime_show_request = 22; 895 | RemoteVoiceBegin remote_voice_begin = 30; 896 | RemoteVoicePayload remote_voice_payload = 31; 897 | RemoteVoiceEnd remote_voice_end = 32; 898 | RemoteStart remote_start = 40; 899 | RemoteSetVolumeLevel remote_set_volume_level = 50; 900 | RemoteAdjustVolumeLevel remote_adjust_volume_level = 51; 901 | RemoteSetPreferredAudioDevice remote_set_preferred_audio_device = 60; 902 | RemoteResetPreferredAudioDevice remote_reset_preferred_audio_device = 61; 903 | RemoteAppLinkLaunchRequest remote_app_link_launch_request = 90; 904 | } 905 | -------------------------------------------------------------------------------- /src/androidtvremote2/remotemessage_pb2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by the protocol buffer compiler. DO NOT EDIT! 3 | # source: remotemessage.proto 4 | """Generated protocol buffer code.""" 5 | from google.protobuf import descriptor as _descriptor 6 | from google.protobuf import descriptor_pool as _descriptor_pool 7 | from google.protobuf import symbol_database as _symbol_database 8 | from google.protobuf.internal import builder as _builder 9 | # @@protoc_insertion_point(imports) 10 | 11 | _sym_db = _symbol_database.Default() 12 | 13 | 14 | 15 | 16 | DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x13remotemessage.proto\x12\x06remote\".\n\x1aRemoteAppLinkLaunchRequest\x12\x10\n\x08\x61pp_link\x18\x01 \x01(\t\"!\n\x1fRemoteResetPreferredAudioDevice\"\x1f\n\x1dRemoteSetPreferredAudioDevice\"\x19\n\x17RemoteAdjustVolumeLevel\"\xb4\x01\n\x14RemoteSetVolumeLevel\x12\x10\n\x08unknown1\x18\x01 \x01(\r\x12\x10\n\x08unknown2\x18\x02 \x01(\r\x12\x14\n\x0cplayer_model\x18\x03 \x01(\t\x12\x10\n\x08unknown4\x18\x04 \x01(\r\x12\x10\n\x08unknown5\x18\x05 \x01(\r\x12\x12\n\nvolume_max\x18\x06 \x01(\r\x12\x14\n\x0cvolume_level\x18\x07 \x01(\r\x12\x14\n\x0cvolume_muted\x18\x08 \x01(\x08\"\x1e\n\x0bRemoteStart\x12\x0f\n\x07started\x18\x01 \x01(\x08\"\x10\n\x0eRemoteVoiceEnd\"\x14\n\x12RemoteVoicePayload\"\x12\n\x10RemoteVoiceBegin\"v\n\x15RemoteTextFieldStatus\x12\x15\n\rcounter_field\x18\x01 \x01(\x05\x12\r\n\x05value\x18\x02 \x01(\t\x12\r\n\x05start\x18\x03 \x01(\x05\x12\x0b\n\x03\x65nd\x18\x04 \x01(\x05\x12\x0c\n\x04int5\x18\x05 \x01(\x05\x12\r\n\x05label\x18\x06 \x01(\t\"W\n\x14RemoteImeShowRequest\x12?\n\x18remote_text_field_status\x18\x02 \x01(\x0b\x32\x1d.remote.RemoteTextFieldStatus\"T\n\x0eRemoteEditInfo\x12\x0e\n\x06insert\x18\x01 \x01(\x05\x12\x32\n\x11text_field_status\x18\x02 \x01(\x0b\x32\x17.remote.RemoteImeObject\"<\n\x0fRemoteImeObject\x12\r\n\x05start\x18\x01 \x01(\x05\x12\x0b\n\x03\x65nd\x18\x02 \x01(\x05\x12\r\n\x05value\x18\x03 \x01(\t\"k\n\x12RemoteImeBatchEdit\x12\x13\n\x0bime_counter\x18\x01 \x01(\x05\x12\x15\n\rfield_counter\x18\x02 \x01(\x05\x12)\n\tedit_info\x18\x03 \x03(\x0b\x32\x16.remote.RemoteEditInfo\"\x99\x01\n\rRemoteAppInfo\x12\x0f\n\x07\x63ounter\x18\x01 \x01(\x05\x12\x0c\n\x04int2\x18\x02 \x01(\x05\x12\x0c\n\x04int3\x18\x03 \x01(\x05\x12\x0c\n\x04int4\x18\x04 \x01(\t\x12\x0c\n\x04int7\x18\x07 \x01(\x05\x12\x0c\n\x04int8\x18\x08 \x01(\x05\x12\r\n\x05label\x18\n \x01(\t\x12\x13\n\x0b\x61pp_package\x18\x0c \x01(\t\x12\r\n\x05int13\x18\r \x01(\x05\"w\n\x12RemoteImeKeyInject\x12\'\n\x08\x61pp_info\x18\x01 \x01(\x0b\x32\x15.remote.RemoteAppInfo\x12\x38\n\x11text_field_status\x18\x02 \x01(\x0b\x32\x1d.remote.RemoteTextFieldStatus\"f\n\x0fRemoteKeyInject\x12\'\n\x08key_code\x18\x01 \x01(\x0e\x32\x15.remote.RemoteKeyCode\x12*\n\tdirection\x18\x02 \x01(\x0e\x32\x17.remote.RemoteDirection\"\"\n\x12RemotePingResponse\x12\x0c\n\x04val1\x18\x01 \x01(\x05\"/\n\x11RemotePingRequest\x12\x0c\n\x04val1\x18\x01 \x01(\x05\x12\x0c\n\x04val2\x18\x02 \x01(\x05\"!\n\x0fRemoteSetActive\x12\x0e\n\x06\x61\x63tive\x18\x01 \x01(\x05\"\x80\x01\n\x10RemoteDeviceInfo\x12\r\n\x05model\x18\x01 \x01(\t\x12\x0e\n\x06vendor\x18\x02 \x01(\t\x12\x10\n\x08unknown1\x18\x03 \x01(\x05\x12\x10\n\x08unknown2\x18\x04 \x01(\t\x12\x14\n\x0cpackage_name\x18\x05 \x01(\t\x12\x13\n\x0b\x61pp_version\x18\x06 \x01(\t\"O\n\x0fRemoteConfigure\x12\r\n\x05\x63ode1\x18\x01 \x01(\x05\x12-\n\x0b\x64\x65vice_info\x18\x02 \x01(\x0b\x32\x18.remote.RemoteDeviceInfo\"D\n\x0bRemoteError\x12\r\n\x05value\x18\x01 \x01(\x08\x12&\n\x07message\x18\x02 \x01(\x0b\x32\x15.remote.RemoteMessage\"\xc1\x08\n\rRemoteMessage\x12\x31\n\x10remote_configure\x18\x01 \x01(\x0b\x32\x17.remote.RemoteConfigure\x12\x32\n\x11remote_set_active\x18\x02 \x01(\x0b\x32\x17.remote.RemoteSetActive\x12)\n\x0cremote_error\x18\x03 \x01(\x0b\x32\x13.remote.RemoteError\x12\x36\n\x13remote_ping_request\x18\x08 \x01(\x0b\x32\x19.remote.RemotePingRequest\x12\x38\n\x14remote_ping_response\x18\t \x01(\x0b\x32\x1a.remote.RemotePingResponse\x12\x32\n\x11remote_key_inject\x18\n \x01(\x0b\x32\x17.remote.RemoteKeyInject\x12\x39\n\x15remote_ime_key_inject\x18\x14 \x01(\x0b\x32\x1a.remote.RemoteImeKeyInject\x12\x39\n\x15remote_ime_batch_edit\x18\x15 \x01(\x0b\x32\x1a.remote.RemoteImeBatchEdit\x12=\n\x17remote_ime_show_request\x18\x16 \x01(\x0b\x32\x1c.remote.RemoteImeShowRequest\x12\x34\n\x12remote_voice_begin\x18\x1e \x01(\x0b\x32\x18.remote.RemoteVoiceBegin\x12\x38\n\x14remote_voice_payload\x18\x1f \x01(\x0b\x32\x1a.remote.RemoteVoicePayload\x12\x30\n\x10remote_voice_end\x18 \x01(\x0b\x32\x16.remote.RemoteVoiceEnd\x12)\n\x0cremote_start\x18( \x01(\x0b\x32\x13.remote.RemoteStart\x12=\n\x17remote_set_volume_level\x18\x32 \x01(\x0b\x32\x1c.remote.RemoteSetVolumeLevel\x12\x43\n\x1aremote_adjust_volume_level\x18\x33 \x01(\x0b\x32\x1f.remote.RemoteAdjustVolumeLevel\x12P\n!remote_set_preferred_audio_device\x18< \x01(\x0b\x32%.remote.RemoteSetPreferredAudioDevice\x12T\n#remote_reset_preferred_audio_device\x18= \x01(\x0b\x32\'.remote.RemoteResetPreferredAudioDevice\x12J\n\x1eremote_app_link_launch_request\x18Z \x01(\x0b\x32\".remote.RemoteAppLinkLaunchRequest*\xe7\x37\n\rRemoteKeyCode\x12\x13\n\x0fKEYCODE_UNKNOWN\x10\x00\x12\x15\n\x11KEYCODE_SOFT_LEFT\x10\x01\x12\x16\n\x12KEYCODE_SOFT_RIGHT\x10\x02\x12\x10\n\x0cKEYCODE_HOME\x10\x03\x12\x10\n\x0cKEYCODE_BACK\x10\x04\x12\x10\n\x0cKEYCODE_CALL\x10\x05\x12\x13\n\x0fKEYCODE_ENDCALL\x10\x06\x12\r\n\tKEYCODE_0\x10\x07\x12\r\n\tKEYCODE_1\x10\x08\x12\r\n\tKEYCODE_2\x10\t\x12\r\n\tKEYCODE_3\x10\n\x12\r\n\tKEYCODE_4\x10\x0b\x12\r\n\tKEYCODE_5\x10\x0c\x12\r\n\tKEYCODE_6\x10\r\x12\r\n\tKEYCODE_7\x10\x0e\x12\r\n\tKEYCODE_8\x10\x0f\x12\r\n\tKEYCODE_9\x10\x10\x12\x10\n\x0cKEYCODE_STAR\x10\x11\x12\x11\n\rKEYCODE_POUND\x10\x12\x12\x13\n\x0fKEYCODE_DPAD_UP\x10\x13\x12\x15\n\x11KEYCODE_DPAD_DOWN\x10\x14\x12\x15\n\x11KEYCODE_DPAD_LEFT\x10\x15\x12\x16\n\x12KEYCODE_DPAD_RIGHT\x10\x16\x12\x17\n\x13KEYCODE_DPAD_CENTER\x10\x17\x12\x15\n\x11KEYCODE_VOLUME_UP\x10\x18\x12\x17\n\x13KEYCODE_VOLUME_DOWN\x10\x19\x12\x11\n\rKEYCODE_POWER\x10\x1a\x12\x12\n\x0eKEYCODE_CAMERA\x10\x1b\x12\x11\n\rKEYCODE_CLEAR\x10\x1c\x12\r\n\tKEYCODE_A\x10\x1d\x12\r\n\tKEYCODE_B\x10\x1e\x12\r\n\tKEYCODE_C\x10\x1f\x12\r\n\tKEYCODE_D\x10 \x12\r\n\tKEYCODE_E\x10!\x12\r\n\tKEYCODE_F\x10\"\x12\r\n\tKEYCODE_G\x10#\x12\r\n\tKEYCODE_H\x10$\x12\r\n\tKEYCODE_I\x10%\x12\r\n\tKEYCODE_J\x10&\x12\r\n\tKEYCODE_K\x10\'\x12\r\n\tKEYCODE_L\x10(\x12\r\n\tKEYCODE_M\x10)\x12\r\n\tKEYCODE_N\x10*\x12\r\n\tKEYCODE_O\x10+\x12\r\n\tKEYCODE_P\x10,\x12\r\n\tKEYCODE_Q\x10-\x12\r\n\tKEYCODE_R\x10.\x12\r\n\tKEYCODE_S\x10/\x12\r\n\tKEYCODE_T\x10\x30\x12\r\n\tKEYCODE_U\x10\x31\x12\r\n\tKEYCODE_V\x10\x32\x12\r\n\tKEYCODE_W\x10\x33\x12\r\n\tKEYCODE_X\x10\x34\x12\r\n\tKEYCODE_Y\x10\x35\x12\r\n\tKEYCODE_Z\x10\x36\x12\x11\n\rKEYCODE_COMMA\x10\x37\x12\x12\n\x0eKEYCODE_PERIOD\x10\x38\x12\x14\n\x10KEYCODE_ALT_LEFT\x10\x39\x12\x15\n\x11KEYCODE_ALT_RIGHT\x10:\x12\x16\n\x12KEYCODE_SHIFT_LEFT\x10;\x12\x17\n\x13KEYCODE_SHIFT_RIGHT\x10<\x12\x0f\n\x0bKEYCODE_TAB\x10=\x12\x11\n\rKEYCODE_SPACE\x10>\x12\x0f\n\x0bKEYCODE_SYM\x10?\x12\x14\n\x10KEYCODE_EXPLORER\x10@\x12\x14\n\x10KEYCODE_ENVELOPE\x10\x41\x12\x11\n\rKEYCODE_ENTER\x10\x42\x12\x0f\n\x0bKEYCODE_DEL\x10\x43\x12\x11\n\rKEYCODE_GRAVE\x10\x44\x12\x11\n\rKEYCODE_MINUS\x10\x45\x12\x12\n\x0eKEYCODE_EQUALS\x10\x46\x12\x18\n\x14KEYCODE_LEFT_BRACKET\x10G\x12\x19\n\x15KEYCODE_RIGHT_BRACKET\x10H\x12\x15\n\x11KEYCODE_BACKSLASH\x10I\x12\x15\n\x11KEYCODE_SEMICOLON\x10J\x12\x16\n\x12KEYCODE_APOSTROPHE\x10K\x12\x11\n\rKEYCODE_SLASH\x10L\x12\x0e\n\nKEYCODE_AT\x10M\x12\x0f\n\x0bKEYCODE_NUM\x10N\x12\x17\n\x13KEYCODE_HEADSETHOOK\x10O\x12\x11\n\rKEYCODE_FOCUS\x10P\x12\x10\n\x0cKEYCODE_PLUS\x10Q\x12\x10\n\x0cKEYCODE_MENU\x10R\x12\x18\n\x14KEYCODE_NOTIFICATION\x10S\x12\x12\n\x0eKEYCODE_SEARCH\x10T\x12\x1c\n\x18KEYCODE_MEDIA_PLAY_PAUSE\x10U\x12\x16\n\x12KEYCODE_MEDIA_STOP\x10V\x12\x16\n\x12KEYCODE_MEDIA_NEXT\x10W\x12\x1a\n\x16KEYCODE_MEDIA_PREVIOUS\x10X\x12\x18\n\x14KEYCODE_MEDIA_REWIND\x10Y\x12\x1e\n\x1aKEYCODE_MEDIA_FAST_FORWARD\x10Z\x12\x10\n\x0cKEYCODE_MUTE\x10[\x12\x13\n\x0fKEYCODE_PAGE_UP\x10\\\x12\x15\n\x11KEYCODE_PAGE_DOWN\x10]\x12\x17\n\x13KEYCODE_PICTSYMBOLS\x10^\x12\x1a\n\x16KEYCODE_SWITCH_CHARSET\x10_\x12\x14\n\x10KEYCODE_BUTTON_A\x10`\x12\x14\n\x10KEYCODE_BUTTON_B\x10\x61\x12\x14\n\x10KEYCODE_BUTTON_C\x10\x62\x12\x14\n\x10KEYCODE_BUTTON_X\x10\x63\x12\x14\n\x10KEYCODE_BUTTON_Y\x10\x64\x12\x14\n\x10KEYCODE_BUTTON_Z\x10\x65\x12\x15\n\x11KEYCODE_BUTTON_L1\x10\x66\x12\x15\n\x11KEYCODE_BUTTON_R1\x10g\x12\x15\n\x11KEYCODE_BUTTON_L2\x10h\x12\x15\n\x11KEYCODE_BUTTON_R2\x10i\x12\x19\n\x15KEYCODE_BUTTON_THUMBL\x10j\x12\x19\n\x15KEYCODE_BUTTON_THUMBR\x10k\x12\x18\n\x14KEYCODE_BUTTON_START\x10l\x12\x19\n\x15KEYCODE_BUTTON_SELECT\x10m\x12\x17\n\x13KEYCODE_BUTTON_MODE\x10n\x12\x12\n\x0eKEYCODE_ESCAPE\x10o\x12\x17\n\x13KEYCODE_FORWARD_DEL\x10p\x12\x15\n\x11KEYCODE_CTRL_LEFT\x10q\x12\x16\n\x12KEYCODE_CTRL_RIGHT\x10r\x12\x15\n\x11KEYCODE_CAPS_LOCK\x10s\x12\x17\n\x13KEYCODE_SCROLL_LOCK\x10t\x12\x15\n\x11KEYCODE_META_LEFT\x10u\x12\x16\n\x12KEYCODE_META_RIGHT\x10v\x12\x14\n\x10KEYCODE_FUNCTION\x10w\x12\x11\n\rKEYCODE_SYSRQ\x10x\x12\x11\n\rKEYCODE_BREAK\x10y\x12\x15\n\x11KEYCODE_MOVE_HOME\x10z\x12\x14\n\x10KEYCODE_MOVE_END\x10{\x12\x12\n\x0eKEYCODE_INSERT\x10|\x12\x13\n\x0fKEYCODE_FORWARD\x10}\x12\x16\n\x12KEYCODE_MEDIA_PLAY\x10~\x12\x17\n\x13KEYCODE_MEDIA_PAUSE\x10\x7f\x12\x18\n\x13KEYCODE_MEDIA_CLOSE\x10\x80\x01\x12\x18\n\x13KEYCODE_MEDIA_EJECT\x10\x81\x01\x12\x19\n\x14KEYCODE_MEDIA_RECORD\x10\x82\x01\x12\x0f\n\nKEYCODE_F1\x10\x83\x01\x12\x0f\n\nKEYCODE_F2\x10\x84\x01\x12\x0f\n\nKEYCODE_F3\x10\x85\x01\x12\x0f\n\nKEYCODE_F4\x10\x86\x01\x12\x0f\n\nKEYCODE_F5\x10\x87\x01\x12\x0f\n\nKEYCODE_F6\x10\x88\x01\x12\x0f\n\nKEYCODE_F7\x10\x89\x01\x12\x0f\n\nKEYCODE_F8\x10\x8a\x01\x12\x0f\n\nKEYCODE_F9\x10\x8b\x01\x12\x10\n\x0bKEYCODE_F10\x10\x8c\x01\x12\x10\n\x0bKEYCODE_F11\x10\x8d\x01\x12\x10\n\x0bKEYCODE_F12\x10\x8e\x01\x12\x15\n\x10KEYCODE_NUM_LOCK\x10\x8f\x01\x12\x15\n\x10KEYCODE_NUMPAD_0\x10\x90\x01\x12\x15\n\x10KEYCODE_NUMPAD_1\x10\x91\x01\x12\x15\n\x10KEYCODE_NUMPAD_2\x10\x92\x01\x12\x15\n\x10KEYCODE_NUMPAD_3\x10\x93\x01\x12\x15\n\x10KEYCODE_NUMPAD_4\x10\x94\x01\x12\x15\n\x10KEYCODE_NUMPAD_5\x10\x95\x01\x12\x15\n\x10KEYCODE_NUMPAD_6\x10\x96\x01\x12\x15\n\x10KEYCODE_NUMPAD_7\x10\x97\x01\x12\x15\n\x10KEYCODE_NUMPAD_8\x10\x98\x01\x12\x15\n\x10KEYCODE_NUMPAD_9\x10\x99\x01\x12\x1a\n\x15KEYCODE_NUMPAD_DIVIDE\x10\x9a\x01\x12\x1c\n\x17KEYCODE_NUMPAD_MULTIPLY\x10\x9b\x01\x12\x1c\n\x17KEYCODE_NUMPAD_SUBTRACT\x10\x9c\x01\x12\x17\n\x12KEYCODE_NUMPAD_ADD\x10\x9d\x01\x12\x17\n\x12KEYCODE_NUMPAD_DOT\x10\x9e\x01\x12\x19\n\x14KEYCODE_NUMPAD_COMMA\x10\x9f\x01\x12\x19\n\x14KEYCODE_NUMPAD_ENTER\x10\xa0\x01\x12\x1a\n\x15KEYCODE_NUMPAD_EQUALS\x10\xa1\x01\x12\x1e\n\x19KEYCODE_NUMPAD_LEFT_PAREN\x10\xa2\x01\x12\x1f\n\x1aKEYCODE_NUMPAD_RIGHT_PAREN\x10\xa3\x01\x12\x18\n\x13KEYCODE_VOLUME_MUTE\x10\xa4\x01\x12\x11\n\x0cKEYCODE_INFO\x10\xa5\x01\x12\x17\n\x12KEYCODE_CHANNEL_UP\x10\xa6\x01\x12\x19\n\x14KEYCODE_CHANNEL_DOWN\x10\xa7\x01\x12\x14\n\x0fKEYCODE_ZOOM_IN\x10\xa8\x01\x12\x15\n\x10KEYCODE_ZOOM_OUT\x10\xa9\x01\x12\x0f\n\nKEYCODE_TV\x10\xaa\x01\x12\x13\n\x0eKEYCODE_WINDOW\x10\xab\x01\x12\x12\n\rKEYCODE_GUIDE\x10\xac\x01\x12\x10\n\x0bKEYCODE_DVR\x10\xad\x01\x12\x15\n\x10KEYCODE_BOOKMARK\x10\xae\x01\x12\x15\n\x10KEYCODE_CAPTIONS\x10\xaf\x01\x12\x15\n\x10KEYCODE_SETTINGS\x10\xb0\x01\x12\x15\n\x10KEYCODE_TV_POWER\x10\xb1\x01\x12\x15\n\x10KEYCODE_TV_INPUT\x10\xb2\x01\x12\x16\n\x11KEYCODE_STB_POWER\x10\xb3\x01\x12\x16\n\x11KEYCODE_STB_INPUT\x10\xb4\x01\x12\x16\n\x11KEYCODE_AVR_POWER\x10\xb5\x01\x12\x16\n\x11KEYCODE_AVR_INPUT\x10\xb6\x01\x12\x15\n\x10KEYCODE_PROG_RED\x10\xb7\x01\x12\x17\n\x12KEYCODE_PROG_GREEN\x10\xb8\x01\x12\x18\n\x13KEYCODE_PROG_YELLOW\x10\xb9\x01\x12\x16\n\x11KEYCODE_PROG_BLUE\x10\xba\x01\x12\x17\n\x12KEYCODE_APP_SWITCH\x10\xbb\x01\x12\x15\n\x10KEYCODE_BUTTON_1\x10\xbc\x01\x12\x15\n\x10KEYCODE_BUTTON_2\x10\xbd\x01\x12\x15\n\x10KEYCODE_BUTTON_3\x10\xbe\x01\x12\x15\n\x10KEYCODE_BUTTON_4\x10\xbf\x01\x12\x15\n\x10KEYCODE_BUTTON_5\x10\xc0\x01\x12\x15\n\x10KEYCODE_BUTTON_6\x10\xc1\x01\x12\x15\n\x10KEYCODE_BUTTON_7\x10\xc2\x01\x12\x15\n\x10KEYCODE_BUTTON_8\x10\xc3\x01\x12\x15\n\x10KEYCODE_BUTTON_9\x10\xc4\x01\x12\x16\n\x11KEYCODE_BUTTON_10\x10\xc5\x01\x12\x16\n\x11KEYCODE_BUTTON_11\x10\xc6\x01\x12\x16\n\x11KEYCODE_BUTTON_12\x10\xc7\x01\x12\x16\n\x11KEYCODE_BUTTON_13\x10\xc8\x01\x12\x16\n\x11KEYCODE_BUTTON_14\x10\xc9\x01\x12\x16\n\x11KEYCODE_BUTTON_15\x10\xca\x01\x12\x16\n\x11KEYCODE_BUTTON_16\x10\xcb\x01\x12\x1c\n\x17KEYCODE_LANGUAGE_SWITCH\x10\xcc\x01\x12\x18\n\x13KEYCODE_MANNER_MODE\x10\xcd\x01\x12\x14\n\x0fKEYCODE_3D_MODE\x10\xce\x01\x12\x15\n\x10KEYCODE_CONTACTS\x10\xcf\x01\x12\x15\n\x10KEYCODE_CALENDAR\x10\xd0\x01\x12\x12\n\rKEYCODE_MUSIC\x10\xd1\x01\x12\x17\n\x12KEYCODE_CALCULATOR\x10\xd2\x01\x12\x1c\n\x17KEYCODE_ZENKAKU_HANKAKU\x10\xd3\x01\x12\x11\n\x0cKEYCODE_EISU\x10\xd4\x01\x12\x15\n\x10KEYCODE_MUHENKAN\x10\xd5\x01\x12\x13\n\x0eKEYCODE_HENKAN\x10\xd6\x01\x12\x1e\n\x19KEYCODE_KATAKANA_HIRAGANA\x10\xd7\x01\x12\x10\n\x0bKEYCODE_YEN\x10\xd8\x01\x12\x0f\n\nKEYCODE_RO\x10\xd9\x01\x12\x11\n\x0cKEYCODE_KANA\x10\xda\x01\x12\x13\n\x0eKEYCODE_ASSIST\x10\xdb\x01\x12\x1c\n\x17KEYCODE_BRIGHTNESS_DOWN\x10\xdc\x01\x12\x1a\n\x15KEYCODE_BRIGHTNESS_UP\x10\xdd\x01\x12\x1e\n\x19KEYCODE_MEDIA_AUDIO_TRACK\x10\xde\x01\x12\x12\n\rKEYCODE_SLEEP\x10\xdf\x01\x12\x13\n\x0eKEYCODE_WAKEUP\x10\xe0\x01\x12\x14\n\x0fKEYCODE_PAIRING\x10\xe1\x01\x12\x1b\n\x16KEYCODE_MEDIA_TOP_MENU\x10\xe2\x01\x12\x0f\n\nKEYCODE_11\x10\xe3\x01\x12\x0f\n\nKEYCODE_12\x10\xe4\x01\x12\x19\n\x14KEYCODE_LAST_CHANNEL\x10\xe5\x01\x12\x1c\n\x17KEYCODE_TV_DATA_SERVICE\x10\xe6\x01\x12\x19\n\x14KEYCODE_VOICE_ASSIST\x10\xe7\x01\x12\x1d\n\x18KEYCODE_TV_RADIO_SERVICE\x10\xe8\x01\x12\x18\n\x13KEYCODE_TV_TELETEXT\x10\xe9\x01\x12\x1c\n\x17KEYCODE_TV_NUMBER_ENTRY\x10\xea\x01\x12\"\n\x1dKEYCODE_TV_TERRESTRIAL_ANALOG\x10\xeb\x01\x12#\n\x1eKEYCODE_TV_TERRESTRIAL_DIGITAL\x10\xec\x01\x12\x19\n\x14KEYCODE_TV_SATELLITE\x10\xed\x01\x12\x1c\n\x17KEYCODE_TV_SATELLITE_BS\x10\xee\x01\x12\x1c\n\x17KEYCODE_TV_SATELLITE_CS\x10\xef\x01\x12!\n\x1cKEYCODE_TV_SATELLITE_SERVICE\x10\xf0\x01\x12\x17\n\x12KEYCODE_TV_NETWORK\x10\xf1\x01\x12\x1d\n\x18KEYCODE_TV_ANTENNA_CABLE\x10\xf2\x01\x12\x1c\n\x17KEYCODE_TV_INPUT_HDMI_1\x10\xf3\x01\x12\x1c\n\x17KEYCODE_TV_INPUT_HDMI_2\x10\xf4\x01\x12\x1c\n\x17KEYCODE_TV_INPUT_HDMI_3\x10\xf5\x01\x12\x1c\n\x17KEYCODE_TV_INPUT_HDMI_4\x10\xf6\x01\x12!\n\x1cKEYCODE_TV_INPUT_COMPOSITE_1\x10\xf7\x01\x12!\n\x1cKEYCODE_TV_INPUT_COMPOSITE_2\x10\xf8\x01\x12!\n\x1cKEYCODE_TV_INPUT_COMPONENT_1\x10\xf9\x01\x12!\n\x1cKEYCODE_TV_INPUT_COMPONENT_2\x10\xfa\x01\x12\x1b\n\x16KEYCODE_TV_INPUT_VGA_1\x10\xfb\x01\x12!\n\x1cKEYCODE_TV_AUDIO_DESCRIPTION\x10\xfc\x01\x12(\n#KEYCODE_TV_AUDIO_DESCRIPTION_MIX_UP\x10\xfd\x01\x12*\n%KEYCODE_TV_AUDIO_DESCRIPTION_MIX_DOWN\x10\xfe\x01\x12\x19\n\x14KEYCODE_TV_ZOOM_MODE\x10\xff\x01\x12\x1d\n\x18KEYCODE_TV_CONTENTS_MENU\x10\x80\x02\x12\"\n\x1dKEYCODE_TV_MEDIA_CONTEXT_MENU\x10\x81\x02\x12!\n\x1cKEYCODE_TV_TIMER_PROGRAMMING\x10\x82\x02\x12\x11\n\x0cKEYCODE_HELP\x10\x83\x02\x12\x1e\n\x19KEYCODE_NAVIGATE_PREVIOUS\x10\x84\x02\x12\x1a\n\x15KEYCODE_NAVIGATE_NEXT\x10\x85\x02\x12\x18\n\x13KEYCODE_NAVIGATE_IN\x10\x86\x02\x12\x19\n\x14KEYCODE_NAVIGATE_OUT\x10\x87\x02\x12\x19\n\x14KEYCODE_STEM_PRIMARY\x10\x88\x02\x12\x13\n\x0eKEYCODE_STEM_1\x10\x89\x02\x12\x13\n\x0eKEYCODE_STEM_2\x10\x8a\x02\x12\x13\n\x0eKEYCODE_STEM_3\x10\x8b\x02\x12\x19\n\x14KEYCODE_DPAD_UP_LEFT\x10\x8c\x02\x12\x1b\n\x16KEYCODE_DPAD_DOWN_LEFT\x10\x8d\x02\x12\x1a\n\x15KEYCODE_DPAD_UP_RIGHT\x10\x8e\x02\x12\x1c\n\x17KEYCODE_DPAD_DOWN_RIGHT\x10\x8f\x02\x12\x1f\n\x1aKEYCODE_MEDIA_SKIP_FORWARD\x10\x90\x02\x12 \n\x1bKEYCODE_MEDIA_SKIP_BACKWARD\x10\x91\x02\x12\x1f\n\x1aKEYCODE_MEDIA_STEP_FORWARD\x10\x92\x02\x12 \n\x1bKEYCODE_MEDIA_STEP_BACKWARD\x10\x93\x02\x12\x17\n\x12KEYCODE_SOFT_SLEEP\x10\x94\x02\x12\x10\n\x0bKEYCODE_CUT\x10\x95\x02\x12\x11\n\x0cKEYCODE_COPY\x10\x96\x02\x12\x12\n\rKEYCODE_PASTE\x10\x97\x02\x12!\n\x1cKEYCODE_SYSTEM_NAVIGATION_UP\x10\x98\x02\x12#\n\x1eKEYCODE_SYSTEM_NAVIGATION_DOWN\x10\x99\x02\x12#\n\x1eKEYCODE_SYSTEM_NAVIGATION_LEFT\x10\x9a\x02\x12$\n\x1fKEYCODE_SYSTEM_NAVIGATION_RIGHT\x10\x9b\x02\x12\x15\n\x10KEYCODE_ALL_APPS\x10\x9c\x02\x12\x14\n\x0fKEYCODE_REFRESH\x10\x9d\x02\x12\x16\n\x11KEYCODE_THUMBS_UP\x10\x9e\x02\x12\x18\n\x13KEYCODE_THUMBS_DOWN\x10\x9f\x02\x12\x1b\n\x16KEYCODE_PROFILE_SWITCH\x10\xa0\x02\x12\x18\n\x13KEYCODE_VIDEO_APP_1\x10\xa1\x02\x12\x18\n\x13KEYCODE_VIDEO_APP_2\x10\xa2\x02\x12\x18\n\x13KEYCODE_VIDEO_APP_3\x10\xa3\x02\x12\x18\n\x13KEYCODE_VIDEO_APP_4\x10\xa4\x02\x12\x18\n\x13KEYCODE_VIDEO_APP_5\x10\xa5\x02\x12\x18\n\x13KEYCODE_VIDEO_APP_6\x10\xa6\x02\x12\x18\n\x13KEYCODE_VIDEO_APP_7\x10\xa7\x02\x12\x18\n\x13KEYCODE_VIDEO_APP_8\x10\xa8\x02\x12\x1b\n\x16KEYCODE_FEATURED_APP_1\x10\xa9\x02\x12\x1b\n\x16KEYCODE_FEATURED_APP_2\x10\xaa\x02\x12\x1b\n\x16KEYCODE_FEATURED_APP_3\x10\xab\x02\x12\x1b\n\x16KEYCODE_FEATURED_APP_4\x10\xac\x02\x12\x17\n\x12KEYCODE_DEMO_APP_1\x10\xad\x02\x12\x17\n\x12KEYCODE_DEMO_APP_2\x10\xae\x02\x12\x17\n\x12KEYCODE_DEMO_APP_3\x10\xaf\x02\x12\x17\n\x12KEYCODE_DEMO_APP_4\x10\xb0\x02*Q\n\x0fRemoteDirection\x12\x15\n\x11UNKNOWN_DIRECTION\x10\x00\x12\x0e\n\nSTART_LONG\x10\x01\x12\x0c\n\x08\x45ND_LONG\x10\x02\x12\t\n\x05SHORT\x10\x03\x62\x06proto3') 17 | 18 | _globals = globals() 19 | _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) 20 | _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'remotemessage_pb2', _globals) 21 | if _descriptor._USE_C_DESCRIPTORS == False: 22 | DESCRIPTOR._options = None 23 | _globals['_REMOTEKEYCODE']._serialized_start=2791 24 | _globals['_REMOTEKEYCODE']._serialized_end=9934 25 | _globals['_REMOTEDIRECTION']._serialized_start=9936 26 | _globals['_REMOTEDIRECTION']._serialized_end=10017 27 | _globals['_REMOTEAPPLINKLAUNCHREQUEST']._serialized_start=31 28 | _globals['_REMOTEAPPLINKLAUNCHREQUEST']._serialized_end=77 29 | _globals['_REMOTERESETPREFERREDAUDIODEVICE']._serialized_start=79 30 | _globals['_REMOTERESETPREFERREDAUDIODEVICE']._serialized_end=112 31 | _globals['_REMOTESETPREFERREDAUDIODEVICE']._serialized_start=114 32 | _globals['_REMOTESETPREFERREDAUDIODEVICE']._serialized_end=145 33 | _globals['_REMOTEADJUSTVOLUMELEVEL']._serialized_start=147 34 | _globals['_REMOTEADJUSTVOLUMELEVEL']._serialized_end=172 35 | _globals['_REMOTESETVOLUMELEVEL']._serialized_start=175 36 | _globals['_REMOTESETVOLUMELEVEL']._serialized_end=355 37 | _globals['_REMOTESTART']._serialized_start=357 38 | _globals['_REMOTESTART']._serialized_end=387 39 | _globals['_REMOTEVOICEEND']._serialized_start=389 40 | _globals['_REMOTEVOICEEND']._serialized_end=405 41 | _globals['_REMOTEVOICEPAYLOAD']._serialized_start=407 42 | _globals['_REMOTEVOICEPAYLOAD']._serialized_end=427 43 | _globals['_REMOTEVOICEBEGIN']._serialized_start=429 44 | _globals['_REMOTEVOICEBEGIN']._serialized_end=447 45 | _globals['_REMOTETEXTFIELDSTATUS']._serialized_start=449 46 | _globals['_REMOTETEXTFIELDSTATUS']._serialized_end=567 47 | _globals['_REMOTEIMESHOWREQUEST']._serialized_start=569 48 | _globals['_REMOTEIMESHOWREQUEST']._serialized_end=656 49 | _globals['_REMOTEEDITINFO']._serialized_start=658 50 | _globals['_REMOTEEDITINFO']._serialized_end=742 51 | _globals['_REMOTEIMEOBJECT']._serialized_start=744 52 | _globals['_REMOTEIMEOBJECT']._serialized_end=804 53 | _globals['_REMOTEIMEBATCHEDIT']._serialized_start=806 54 | _globals['_REMOTEIMEBATCHEDIT']._serialized_end=913 55 | _globals['_REMOTEAPPINFO']._serialized_start=916 56 | _globals['_REMOTEAPPINFO']._serialized_end=1069 57 | _globals['_REMOTEIMEKEYINJECT']._serialized_start=1071 58 | _globals['_REMOTEIMEKEYINJECT']._serialized_end=1190 59 | _globals['_REMOTEKEYINJECT']._serialized_start=1192 60 | _globals['_REMOTEKEYINJECT']._serialized_end=1294 61 | _globals['_REMOTEPINGRESPONSE']._serialized_start=1296 62 | _globals['_REMOTEPINGRESPONSE']._serialized_end=1330 63 | _globals['_REMOTEPINGREQUEST']._serialized_start=1332 64 | _globals['_REMOTEPINGREQUEST']._serialized_end=1379 65 | _globals['_REMOTESETACTIVE']._serialized_start=1381 66 | _globals['_REMOTESETACTIVE']._serialized_end=1414 67 | _globals['_REMOTEDEVICEINFO']._serialized_start=1417 68 | _globals['_REMOTEDEVICEINFO']._serialized_end=1545 69 | _globals['_REMOTECONFIGURE']._serialized_start=1547 70 | _globals['_REMOTECONFIGURE']._serialized_end=1626 71 | _globals['_REMOTEERROR']._serialized_start=1628 72 | _globals['_REMOTEERROR']._serialized_end=1696 73 | _globals['_REMOTEMESSAGE']._serialized_start=1699 74 | _globals['_REMOTEMESSAGE']._serialized_end=2788 75 | # @@protoc_insertion_point(module_scope) 76 | -------------------------------------------------------------------------------- /src/demo.py: -------------------------------------------------------------------------------- 1 | """Demo usage of AndroidTVRemote.""" 2 | 3 | import argparse 4 | import asyncio 5 | import logging 6 | from typing import cast 7 | 8 | from pynput import keyboard 9 | from zeroconf import ServiceStateChange, Zeroconf 10 | from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf 11 | 12 | from androidtvremote2 import ( 13 | AndroidTVRemote, 14 | CannotConnect, 15 | ConnectionClosed, 16 | InvalidAuth, 17 | ) 18 | 19 | _LOGGER = logging.getLogger(__name__) 20 | 21 | 22 | async def _bind_keyboard(remote: AndroidTVRemote) -> None: 23 | print( 24 | "\n\nYou can control the connected Android TV with:" 25 | "\n- arrow keys: move selected item" 26 | "\n- enter: run selected item" 27 | "\n- space: play/pause" 28 | "\n- home: go to the home screen" 29 | "\n- backspace or esc: go back" 30 | "\n- delete: power off/on" 31 | "\n- +/-: volume up/down" 32 | "\n- 'm': mute" 33 | "\n- 'y': YouTube" 34 | "\n- 'n': Netflix" 35 | "\n- 'd': Disney+" 36 | "\n- 'a': Amazon Prime Video" 37 | "\n- 'k': Kodi" 38 | "\n- 'q': quit" 39 | "\n- 't': send text 'Hello world' to Android TV\n\n" 40 | ) 41 | key_mappings = { 42 | keyboard.Key.up: "DPAD_UP", 43 | keyboard.Key.down: "DPAD_DOWN", 44 | keyboard.Key.left: "DPAD_LEFT", 45 | keyboard.Key.right: "DPAD_RIGHT", 46 | keyboard.Key.enter: "DPAD_CENTER", 47 | keyboard.Key.space: "MEDIA_PLAY_PAUSE", 48 | keyboard.Key.home: "HOME", 49 | keyboard.Key.backspace: "BACK", 50 | keyboard.Key.esc: "BACK", 51 | keyboard.Key.delete: "POWER", 52 | } 53 | 54 | def transmit_keys() -> asyncio.Queue: 55 | queue: asyncio.Queue = asyncio.Queue() 56 | loop = asyncio.get_event_loop() 57 | 58 | def on_press(key: keyboard.Key | keyboard.KeyCode | None) -> None: 59 | loop.call_soon_threadsafe(queue.put_nowait, key) 60 | 61 | keyboard.Listener(on_press=on_press).start() 62 | return queue 63 | 64 | key_queue = transmit_keys() 65 | while True: 66 | key = await key_queue.get() 67 | if key in key_mappings: 68 | remote.send_key_command(key_mappings[key]) 69 | if hasattr(key, "char"): 70 | if key.char == "q": 71 | remote.disconnect() 72 | return 73 | elif key.char == "m": 74 | remote.send_key_command("MUTE") 75 | elif key.char == "+": 76 | remote.send_key_command("VOLUME_UP") 77 | elif key.char == "-": 78 | remote.send_key_command("VOLUME_DOWN") 79 | elif key.char == "y": 80 | remote.send_launch_app_command("https://www.youtube.com") 81 | elif key.char == "n": 82 | remote.send_launch_app_command("com.netflix.ninja") 83 | elif key.char == "d": 84 | remote.send_launch_app_command("com.disney.disneyplus") 85 | elif key.char == "a": 86 | remote.send_launch_app_command("com.amazon.amazonvideo.livingroom") 87 | elif key.char == "k": 88 | remote.send_launch_app_command("org.xbmc.kodi") 89 | elif key.char == "t": 90 | remote.send_text("Hello World!") 91 | 92 | 93 | async def _host_from_zeroconf(timeout: float) -> str: 94 | def _async_on_service_state_change( 95 | zeroconf: Zeroconf, 96 | service_type: str, 97 | name: str, 98 | state_change: ServiceStateChange, 99 | ) -> None: 100 | if state_change is not ServiceStateChange.Added: 101 | return 102 | _ = asyncio.ensure_future( # noqa: RUF006 103 | async_display_service_info(zeroconf, service_type, name) 104 | ) 105 | 106 | async def async_display_service_info( 107 | zeroconf: Zeroconf, service_type: str, name: str 108 | ) -> None: 109 | info = AsyncServiceInfo(service_type, name) 110 | await info.async_request(zeroconf, 3000) 111 | if info: 112 | addresses = [ 113 | f"{addr}:{cast(int, info.port)}" 114 | for addr in info.parsed_scoped_addresses() 115 | ] 116 | print(f" Name: {name}") 117 | print(f" Addresses: {", ".join(addresses)}") 118 | if info.properties: 119 | print(" Properties:") 120 | for key, value in info.properties.items(): 121 | print(f" {key!r}: {value!r}") 122 | else: 123 | print(" No properties") 124 | else: 125 | print(" No info") 126 | print() 127 | 128 | zc = AsyncZeroconf() 129 | services = ["_androidtvremote2._tcp.local."] 130 | print( 131 | f"\nBrowsing {services} service(s) for {timeout} seconds, press Ctrl-C to exit...\n" 132 | ) 133 | browser = AsyncServiceBrowser( 134 | zc.zeroconf, services, handlers=[_async_on_service_state_change] 135 | ) 136 | await asyncio.sleep(timeout) 137 | 138 | await browser.async_cancel() 139 | await zc.async_close() 140 | 141 | return input("Enter IP address of Android TV to connect to: ").split(":")[0] 142 | 143 | 144 | async def _pair(remote: AndroidTVRemote) -> None: 145 | name, mac = await remote.async_get_name_and_mac() 146 | if ( 147 | input( 148 | f"Do you want to pair with {remote.host} {name} {mac}" 149 | " (this will turn on the Android TV)? y/n: " 150 | ) 151 | != "y" 152 | ): 153 | exit() 154 | await remote.async_start_pairing() 155 | while True: 156 | pairing_code = input("Enter pairing code: ") 157 | try: 158 | return await remote.async_finish_pairing(pairing_code) 159 | except InvalidAuth as exc: 160 | _LOGGER.error("Invalid pairing code. Error: %s", exc) 161 | continue 162 | except ConnectionClosed as exc: 163 | _LOGGER.error("Initialize pair again. Error: %s", exc) 164 | return await _pair(remote) 165 | 166 | 167 | async def _main() -> None: 168 | parser = argparse.ArgumentParser() 169 | parser.add_argument("--host", help="IP address of Android TV to connect to") 170 | parser.add_argument( 171 | "--certfile", 172 | help="filename that contains the client certificate in PEM format", 173 | default="cert.pem", 174 | ) 175 | parser.add_argument( 176 | "--keyfile", 177 | help="filename that contains the public key in PEM format", 178 | default="key.pem", 179 | ) 180 | parser.add_argument( 181 | "--client_name", 182 | help="shown on the Android TV during pairing", 183 | default="Android TV Remote demo", 184 | ) 185 | parser.add_argument( 186 | "--scan_timeout", 187 | type=float, 188 | help="zeroconf scan timeout in seconds", 189 | default=3, 190 | ) 191 | parser.add_argument( 192 | "-v", "--verbose", help="enable verbose logging", action="store_true" 193 | ) 194 | args = parser.parse_args() 195 | 196 | logging.basicConfig(level=logging.DEBUG if args.verbose else logging.INFO) 197 | 198 | host = args.host or await _host_from_zeroconf(args.scan_timeout) 199 | 200 | remote = AndroidTVRemote(args.client_name, args.certfile, args.keyfile, host) 201 | 202 | if await remote.async_generate_cert_if_missing(): 203 | _LOGGER.info("Generated new certificate") 204 | await _pair(remote) 205 | 206 | while True: 207 | try: 208 | await remote.async_connect() 209 | break 210 | except InvalidAuth as exc: 211 | _LOGGER.error("Need to pair again. Error: %s", exc) 212 | await _pair(remote) 213 | except (CannotConnect, ConnectionClosed) as exc: 214 | _LOGGER.error("Cannot connect, exiting. Error: %s", exc) 215 | return 216 | 217 | remote.keep_reconnecting() 218 | 219 | _LOGGER.info("device_info: %s", remote.device_info) 220 | _LOGGER.info("is_on: %s", remote.is_on) 221 | _LOGGER.info("current_app: %s", remote.current_app) 222 | _LOGGER.info("volume_info: %s", remote.volume_info) 223 | 224 | def is_on_updated(is_on: bool) -> None: 225 | _LOGGER.info("Notified that is_on: %s", is_on) 226 | 227 | def current_app_updated(current_app: str) -> None: 228 | _LOGGER.info("Notified that current_app: %s", current_app) 229 | 230 | def volume_info_updated(volume_info: dict[str, str | bool]) -> None: 231 | _LOGGER.info("Notified that volume_info: %s", volume_info) 232 | 233 | def is_available_updated(is_available: bool) -> None: 234 | _LOGGER.info("Notified that is_available: %s", is_available) 235 | 236 | remote.add_is_on_updated_callback(is_on_updated) 237 | remote.add_current_app_updated_callback(current_app_updated) 238 | remote.add_volume_info_updated_callback(volume_info_updated) 239 | remote.add_is_available_updated_callback(is_available_updated) 240 | 241 | await _bind_keyboard(remote) 242 | 243 | 244 | asyncio.run(_main(), debug=True) 245 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for androidtvremote2.""" 2 | -------------------------------------------------------------------------------- /tests/test_androidtv_remote.py: -------------------------------------------------------------------------------- 1 | """Tests for AndroidTVRemote.""" 2 | 3 | 4 | def test_dummy() -> None: 5 | """Test dummy.""" 6 | assert True 7 | --------------------------------------------------------------------------------