androidtvremote2 0.2.2__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {androidtvremote2-0.2.2/src/androidtvremote2.egg-info → androidtvremote2-0.3.0}/PKG-INFO +18 -11
- {androidtvremote2-0.2.2 → androidtvremote2-0.3.0}/README.md +10 -9
- androidtvremote2-0.3.0/pyproject.toml +98 -0
- androidtvremote2-0.3.0/setup.cfg +4 -0
- {androidtvremote2-0.2.2 → androidtvremote2-0.3.0}/src/androidtvremote2/__init__.py +5 -0
- {androidtvremote2-0.2.2 → androidtvremote2-0.3.0}/src/androidtvremote2/androidtv_remote.py +71 -58
- {androidtvremote2-0.2.2 → androidtvremote2-0.3.0}/src/androidtvremote2/base.py +7 -7
- androidtvremote2-0.3.0/src/androidtvremote2/model.py +21 -0
- {androidtvremote2-0.2.2 → androidtvremote2-0.3.0}/src/androidtvremote2/pairing.py +10 -21
- androidtvremote2-0.3.0/src/androidtvremote2/py.typed +0 -0
- {androidtvremote2-0.2.2 → androidtvremote2-0.3.0}/src/androidtvremote2/remote.py +149 -29
- {androidtvremote2-0.2.2 → androidtvremote2-0.3.0}/src/androidtvremote2/remotemessage_pb2.py +42 -42
- {androidtvremote2-0.2.2 → androidtvremote2-0.3.0}/src/androidtvremote2/remotemessage_pb2.pyi +25 -0
- androidtvremote2-0.3.0/src/androidtvremote2/voice_stream.py +74 -0
- {androidtvremote2-0.2.2 → androidtvremote2-0.3.0/src/androidtvremote2.egg-info}/PKG-INFO +18 -11
- {androidtvremote2-0.2.2 → androidtvremote2-0.3.0}/src/androidtvremote2.egg-info/SOURCES.txt +3 -1
- androidtvremote2-0.3.0/src/androidtvremote2.egg-info/requires.txt +11 -0
- androidtvremote2-0.3.0/src/demo.py +408 -0
- androidtvremote2-0.2.2/pyproject.toml +0 -80
- androidtvremote2-0.2.2/setup.cfg +0 -16
- androidtvremote2-0.2.2/src/androidtvremote2.egg-info/requires.txt +0 -3
- androidtvremote2-0.2.2/src/demo.py +0 -244
- {androidtvremote2-0.2.2 → androidtvremote2-0.3.0}/LICENSE +0 -0
- {androidtvremote2-0.2.2 → androidtvremote2-0.3.0}/src/androidtvremote2/certificate_generator.py +0 -0
- {androidtvremote2-0.2.2 → androidtvremote2-0.3.0}/src/androidtvremote2/const.py +0 -0
- {androidtvremote2-0.2.2 → androidtvremote2-0.3.0}/src/androidtvremote2/exceptions.py +0 -0
- {androidtvremote2-0.2.2 → androidtvremote2-0.3.0}/src/androidtvremote2/polo_pb2.py +0 -0
- {androidtvremote2-0.2.2 → androidtvremote2-0.3.0}/src/androidtvremote2/polo_pb2.pyi +0 -0
- {androidtvremote2-0.2.2 → androidtvremote2-0.3.0}/src/androidtvremote2.egg-info/dependency_links.txt +0 -0
- {androidtvremote2-0.2.2 → androidtvremote2-0.3.0}/src/androidtvremote2.egg-info/top_level.txt +0 -0
- {androidtvremote2-0.2.2 → androidtvremote2-0.3.0}/tests/test_androidtv_remote.py +0 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: androidtvremote2
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: A Python library for interacting with Android TV using the Android TV Remote protocol v2
|
|
5
5
|
Author-email: tronikos <tronikos@gmail.com>
|
|
6
|
-
License: Apache-2.0
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
7
|
Project-URL: Homepage, https://github.com/tronikos/androidtvremote2
|
|
8
8
|
Project-URL: Bug Tracker, https://github.com/tronikos/androidtvremote2/issues
|
|
9
9
|
Requires-Python: >=3.10
|
|
@@ -12,6 +12,12 @@ License-File: LICENSE
|
|
|
12
12
|
Requires-Dist: aiofiles>=0.8
|
|
13
13
|
Requires-Dist: cryptography>=3
|
|
14
14
|
Requires-Dist: protobuf>=4.21
|
|
15
|
+
Provides-Extra: test
|
|
16
|
+
Requires-Dist: pytest; extra == "test"
|
|
17
|
+
Provides-Extra: demo
|
|
18
|
+
Requires-Dist: pyaudio; extra == "demo"
|
|
19
|
+
Requires-Dist: pynput; extra == "demo"
|
|
20
|
+
Requires-Dist: zeroconf; extra == "demo"
|
|
15
21
|
Dynamic: license-file
|
|
16
22
|
|
|
17
23
|
# androidtvremote2
|
|
@@ -19,9 +25,11 @@ Dynamic: license-file
|
|
|
19
25
|
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.
|
|
20
26
|
|
|
21
27
|
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).
|
|
22
|
-
For a full list see [
|
|
28
|
+
For a full list see [remotemessage.proto](https://github.com/tronikos/androidtvremote2/blob/b4c49ac03043b1b9c40c2f2960e466d5a3b8bd67/src/androidtvremote2/remotemessage.proto#L90).
|
|
23
29
|
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.
|
|
24
30
|
|
|
31
|
+
Voice commands can also be sent as PCM 16-bit mono 8 kHz audio data.
|
|
32
|
+
|
|
25
33
|
## Credits
|
|
26
34
|
|
|
27
35
|
- Official [implementation](https://android.googlesource.com/platform/external/google-tv-pairing-protocol/+/refs/heads/master) of the pairing protocol in Java
|
|
@@ -45,7 +53,7 @@ source .venv/bin/activate
|
|
|
45
53
|
|
|
46
54
|
# Install dependencies
|
|
47
55
|
python -m pip install --upgrade pip
|
|
48
|
-
python -m pip install .
|
|
56
|
+
python -m pip install -e .
|
|
49
57
|
|
|
50
58
|
# Generate *_pb2.py from *.proto
|
|
51
59
|
python -m pip install grpcio-tools mypy-protobuf
|
|
@@ -53,23 +61,22 @@ python -m grpc_tools.protoc src/androidtvremote2/*.proto --python_out=src/androi
|
|
|
53
61
|
|
|
54
62
|
# Run pre-commit
|
|
55
63
|
python -m pip install pre-commit
|
|
56
|
-
pre-commit autoupdate
|
|
57
64
|
pre-commit install
|
|
58
65
|
pre-commit run --all-files
|
|
59
66
|
|
|
60
|
-
# Alternative: run formatter, lint, and type checking
|
|
61
|
-
python -m pip install isort black flake8 ruff mypy
|
|
62
|
-
isort . ; black . ; flake8 . ; ruff check . --fix ; mypy --install-types .
|
|
63
|
-
|
|
64
67
|
# Run tests
|
|
65
|
-
python -m pip install
|
|
68
|
+
python -m pip install -e ".[test]"
|
|
66
69
|
pytest
|
|
67
70
|
|
|
68
71
|
# Run demo
|
|
69
|
-
python -m pip install
|
|
72
|
+
python -m pip install -e ".[demo]"
|
|
70
73
|
python src/demo.py
|
|
71
74
|
|
|
72
75
|
# Build package
|
|
73
76
|
python -m pip install build
|
|
74
77
|
python -m build
|
|
75
78
|
```
|
|
79
|
+
|
|
80
|
+
The voice demo requires the [PyAudio](https://pypi.org/project/PyAudio/) library.
|
|
81
|
+
Depending on the target platform, [PortAudio](https://www.portaudio.com/) might have to be installed manually,
|
|
82
|
+
see [PyAudio installation](https://people.csail.mit.edu/hubert/pyaudio/) for more information.
|
|
@@ -3,9 +3,11 @@
|
|
|
3
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
4
|
|
|
5
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 [
|
|
6
|
+
For a full list see [remotemessage.proto](https://github.com/tronikos/androidtvremote2/blob/b4c49ac03043b1b9c40c2f2960e466d5a3b8bd67/src/androidtvremote2/remotemessage.proto#L90).
|
|
7
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
8
|
|
|
9
|
+
Voice commands can also be sent as PCM 16-bit mono 8 kHz audio data.
|
|
10
|
+
|
|
9
11
|
## Credits
|
|
10
12
|
|
|
11
13
|
- Official [implementation](https://android.googlesource.com/platform/external/google-tv-pairing-protocol/+/refs/heads/master) of the pairing protocol in Java
|
|
@@ -29,7 +31,7 @@ source .venv/bin/activate
|
|
|
29
31
|
|
|
30
32
|
# Install dependencies
|
|
31
33
|
python -m pip install --upgrade pip
|
|
32
|
-
python -m pip install .
|
|
34
|
+
python -m pip install -e .
|
|
33
35
|
|
|
34
36
|
# Generate *_pb2.py from *.proto
|
|
35
37
|
python -m pip install grpcio-tools mypy-protobuf
|
|
@@ -37,23 +39,22 @@ python -m grpc_tools.protoc src/androidtvremote2/*.proto --python_out=src/androi
|
|
|
37
39
|
|
|
38
40
|
# Run pre-commit
|
|
39
41
|
python -m pip install pre-commit
|
|
40
|
-
pre-commit autoupdate
|
|
41
42
|
pre-commit install
|
|
42
43
|
pre-commit run --all-files
|
|
43
44
|
|
|
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
45
|
# Run tests
|
|
49
|
-
python -m pip install
|
|
46
|
+
python -m pip install -e ".[test]"
|
|
50
47
|
pytest
|
|
51
48
|
|
|
52
49
|
# Run demo
|
|
53
|
-
python -m pip install
|
|
50
|
+
python -m pip install -e ".[demo]"
|
|
54
51
|
python src/demo.py
|
|
55
52
|
|
|
56
53
|
# Build package
|
|
57
54
|
python -m pip install build
|
|
58
55
|
python -m build
|
|
59
56
|
```
|
|
57
|
+
|
|
58
|
+
The voice demo requires the [PyAudio](https://pypi.org/project/PyAudio/) library.
|
|
59
|
+
Depending on the target platform, [PortAudio](https://www.portaudio.com/) might have to be installed manually,
|
|
60
|
+
see [PyAudio installation](https://people.csail.mit.edu/hubert/pyaudio/) for more information.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "androidtvremote2"
|
|
3
|
+
version = "0.3.0"
|
|
4
|
+
license = "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.optional-dependencies]
|
|
18
|
+
test = [
|
|
19
|
+
"pytest",
|
|
20
|
+
]
|
|
21
|
+
demo = [
|
|
22
|
+
"pyaudio",
|
|
23
|
+
"pynput",
|
|
24
|
+
"zeroconf",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
"Homepage" = "https://github.com/tronikos/androidtvremote2"
|
|
30
|
+
"Bug Tracker" = "https://github.com/tronikos/androidtvremote2/issues"
|
|
31
|
+
|
|
32
|
+
[build-system]
|
|
33
|
+
requires = ["setuptools"]
|
|
34
|
+
build-backend = "setuptools.build_meta"
|
|
35
|
+
|
|
36
|
+
[tool.ruff]
|
|
37
|
+
target-version = "py310"
|
|
38
|
+
line-length = 127
|
|
39
|
+
exclude = [
|
|
40
|
+
".git", ".mypy_cache", ".ruff_cache", ".venv", "__pypackages__",
|
|
41
|
+
"build", "dist", "venv", "*_pb2.py", "*_pb2_grpc.py", "*.pyi"
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[tool.ruff.lint]
|
|
45
|
+
select = [
|
|
46
|
+
"A", # flake8-builtins
|
|
47
|
+
"ASYNC", # flake8-async
|
|
48
|
+
"B", # flake8-bugbear
|
|
49
|
+
"C", # flake8-mccabe
|
|
50
|
+
"C4", # flake8-comprehensions
|
|
51
|
+
"D", # pydocstyle
|
|
52
|
+
"E", # pycodestyle (error)
|
|
53
|
+
"F", # pyflakes
|
|
54
|
+
"G", # flake8-logging-format
|
|
55
|
+
"I", # isort
|
|
56
|
+
"PGH", # pygrep-hooks
|
|
57
|
+
"PL", # Pylint
|
|
58
|
+
"RET", # flake8-return
|
|
59
|
+
"RUF", # Ruff-specific rules
|
|
60
|
+
"S", # flake8-bandit (security)
|
|
61
|
+
"SIM", # flake8-simplify
|
|
62
|
+
"T20", # flake8-print
|
|
63
|
+
"TCH", # flake8-type-checking
|
|
64
|
+
"TRY", # tryceratops
|
|
65
|
+
"UP", # pyupgrade
|
|
66
|
+
"W", # pycodestyle (warning)
|
|
67
|
+
]
|
|
68
|
+
ignore = [
|
|
69
|
+
"S101", # Ignore assert
|
|
70
|
+
"D203", # 1 blank line required before class docstring
|
|
71
|
+
"D213", # Multi-line docstring summary should start at the second line
|
|
72
|
+
"PLR0913", # Too many arguments in function definition
|
|
73
|
+
"PLR1714", # Consider merging multiple comparisons
|
|
74
|
+
"PLR2004", # Magic value used in comparison
|
|
75
|
+
"TRY003", # Avoid specifying long messages outside the exception class
|
|
76
|
+
]
|
|
77
|
+
fixable = ["ALL"]
|
|
78
|
+
|
|
79
|
+
[tool.ruff.lint.mccabe]
|
|
80
|
+
max-complexity = 25
|
|
81
|
+
|
|
82
|
+
[tool.mypy]
|
|
83
|
+
python_version = "3.10"
|
|
84
|
+
exclude = [".venv", "venv", "build"]
|
|
85
|
+
pretty = true
|
|
86
|
+
strict = true
|
|
87
|
+
show_error_codes = true
|
|
88
|
+
warn_incomplete_stub = true
|
|
89
|
+
enable_error_code = ["ignore-without-code", "redundant-self", "truthy-iterable"]
|
|
90
|
+
|
|
91
|
+
[[tool.mypy.overrides]]
|
|
92
|
+
module = ["pyaudio"]
|
|
93
|
+
ignore_missing_imports = true
|
|
94
|
+
|
|
95
|
+
[tool.codespell]
|
|
96
|
+
skip = "*.json,*.csv,*.lock,./.git/*,./.venv/*"
|
|
97
|
+
check-filenames = true
|
|
98
|
+
check-hidden = true
|
|
@@ -2,10 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
from .androidtv_remote import AndroidTVRemote
|
|
4
4
|
from .exceptions import CannotConnect, ConnectionClosed, InvalidAuth
|
|
5
|
+
from .model import DeviceInfo, VolumeInfo
|
|
6
|
+
from .voice_stream import VoiceStream
|
|
5
7
|
|
|
6
8
|
__all__ = [
|
|
7
9
|
"AndroidTVRemote",
|
|
8
10
|
"CannotConnect",
|
|
9
11
|
"ConnectionClosed",
|
|
12
|
+
"DeviceInfo",
|
|
10
13
|
"InvalidAuth",
|
|
14
|
+
"VoiceStream",
|
|
15
|
+
"VolumeInfo",
|
|
11
16
|
]
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
-
from collections.abc import Callable
|
|
7
6
|
import os
|
|
8
7
|
import ssl
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
9
|
from urllib.parse import urlparse
|
|
10
10
|
|
|
11
11
|
import aiofiles
|
|
@@ -15,8 +15,14 @@ from .certificate_generator import generate_selfsigned_cert
|
|
|
15
15
|
from .const import LOGGER
|
|
16
16
|
from .exceptions import CannotConnect, ConnectionClosed, InvalidAuth
|
|
17
17
|
from .pairing import PairingProtocol
|
|
18
|
-
from .remote import RemoteProtocol
|
|
18
|
+
from .remote import VOICE_SESSION_TIMEOUT, RemoteProtocol
|
|
19
19
|
from .remotemessage_pb2 import RemoteDirection
|
|
20
|
+
from .voice_stream import VoiceStream
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
|
|
25
|
+
from .model import DeviceInfo, VolumeInfo
|
|
20
26
|
|
|
21
27
|
|
|
22
28
|
def _load_cert_chain(certfile: str, keyfile: str) -> ssl.SSLContext:
|
|
@@ -40,6 +46,7 @@ class AndroidTVRemote:
|
|
|
40
46
|
pair_port: int = 6467,
|
|
41
47
|
loop: asyncio.AbstractEventLoop | None = None,
|
|
42
48
|
enable_ime: bool = True,
|
|
49
|
+
enable_voice: bool = False,
|
|
43
50
|
) -> None:
|
|
44
51
|
"""Initialize.
|
|
45
52
|
|
|
@@ -52,6 +59,7 @@ class AndroidTVRemote:
|
|
|
52
59
|
:param loop: event loop. Used for connections and futures.
|
|
53
60
|
:param enable_ime: Needed for getting current_app.
|
|
54
61
|
Disable for devices that show 'Use keyboard on mobile device screen'.
|
|
62
|
+
:param enable_voice: Enable sending voice commands to the device.
|
|
55
63
|
"""
|
|
56
64
|
self._client_name = client_name
|
|
57
65
|
self._certfile = certfile
|
|
@@ -61,15 +69,16 @@ class AndroidTVRemote:
|
|
|
61
69
|
self._pair_port = pair_port
|
|
62
70
|
self._loop = loop or asyncio.get_running_loop()
|
|
63
71
|
self._enable_ime = enable_ime
|
|
72
|
+
self._enable_voice = enable_voice
|
|
64
73
|
self._transport: asyncio.Transport | None = None
|
|
65
74
|
self._remote_message_protocol: RemoteProtocol | None = None
|
|
66
75
|
self._pairing_message_protocol: PairingProtocol | None = None
|
|
67
|
-
self._reconnect_task: asyncio.Task | None = None
|
|
76
|
+
self._reconnect_task: asyncio.Task[None] | None = None
|
|
68
77
|
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] = []
|
|
78
|
+
self._is_on_updated_callbacks: list[Callable[[bool], None]] = []
|
|
79
|
+
self._current_app_updated_callbacks: list[Callable[[str], None]] = []
|
|
80
|
+
self._volume_info_updated_callbacks: list[Callable[[VolumeInfo], None]] = []
|
|
81
|
+
self._is_available_updated_callbacks: list[Callable[[bool], None]] = []
|
|
73
82
|
|
|
74
83
|
def is_on_updated(is_on: bool) -> None:
|
|
75
84
|
for callback in self._is_on_updated_callbacks:
|
|
@@ -79,7 +88,7 @@ class AndroidTVRemote:
|
|
|
79
88
|
for callback in self._current_app_updated_callbacks:
|
|
80
89
|
callback(current_app)
|
|
81
90
|
|
|
82
|
-
def volume_info_updated(volume_info:
|
|
91
|
+
def volume_info_updated(volume_info: VolumeInfo) -> None:
|
|
83
92
|
for callback in self._volume_info_updated_callbacks:
|
|
84
93
|
callback(volume_info)
|
|
85
94
|
|
|
@@ -107,57 +116,68 @@ class AndroidTVRemote:
|
|
|
107
116
|
return self._remote_message_protocol.current_app
|
|
108
117
|
|
|
109
118
|
@property
|
|
110
|
-
def device_info(self) ->
|
|
119
|
+
def device_info(self) -> DeviceInfo | None:
|
|
111
120
|
"""Device info (manufacturer, model, sw_version)."""
|
|
112
121
|
if not self._remote_message_protocol:
|
|
113
122
|
return None
|
|
114
123
|
return self._remote_message_protocol.device_info
|
|
115
124
|
|
|
116
125
|
@property
|
|
117
|
-
def volume_info(self) ->
|
|
126
|
+
def volume_info(self) -> VolumeInfo | None:
|
|
118
127
|
"""Volume info (level, max, muted)."""
|
|
119
128
|
if not self._remote_message_protocol:
|
|
120
129
|
return None
|
|
121
130
|
return self._remote_message_protocol.volume_info
|
|
122
131
|
|
|
123
|
-
|
|
132
|
+
@property
|
|
133
|
+
def is_voice_enabled(self) -> bool | None:
|
|
134
|
+
"""Whether voice commands are enabled on the Android TV.
|
|
135
|
+
|
|
136
|
+
Depends on the requested feature at AndroidTVRemote initialization and the supported
|
|
137
|
+
features of the device.
|
|
138
|
+
"""
|
|
139
|
+
if not self._remote_message_protocol:
|
|
140
|
+
return None
|
|
141
|
+
return self._remote_message_protocol.is_voice_enabled
|
|
142
|
+
|
|
143
|
+
def add_is_on_updated_callback(self, callback: Callable[[bool], None]) -> None:
|
|
124
144
|
"""Add a callback for when is_on is updated."""
|
|
125
145
|
self._is_on_updated_callbacks.append(callback)
|
|
126
146
|
|
|
127
|
-
def remove_is_on_updated_callback(self, callback: Callable) -> None:
|
|
147
|
+
def remove_is_on_updated_callback(self, callback: Callable[[bool], None]) -> None:
|
|
128
148
|
"""Remove a callback previously added via add_is_on_updated_callback.
|
|
129
149
|
|
|
130
150
|
:raises ValueError: if callback not previously added.
|
|
131
151
|
"""
|
|
132
152
|
self._is_on_updated_callbacks.remove(callback)
|
|
133
153
|
|
|
134
|
-
def add_current_app_updated_callback(self, callback: Callable) -> None:
|
|
154
|
+
def add_current_app_updated_callback(self, callback: Callable[[str], None]) -> None:
|
|
135
155
|
"""Add a callback for when current_app is updated."""
|
|
136
156
|
self._current_app_updated_callbacks.append(callback)
|
|
137
157
|
|
|
138
|
-
def remove_current_app_updated_callback(self, callback: Callable) -> None:
|
|
158
|
+
def remove_current_app_updated_callback(self, callback: Callable[[str], None]) -> None:
|
|
139
159
|
"""Remove a callback previously added via add_current_app_updated_callback.
|
|
140
160
|
|
|
141
161
|
:raises ValueError: if callback not previously added.
|
|
142
162
|
"""
|
|
143
163
|
self._current_app_updated_callbacks.remove(callback)
|
|
144
164
|
|
|
145
|
-
def add_volume_info_updated_callback(self, callback: Callable) -> None:
|
|
165
|
+
def add_volume_info_updated_callback(self, callback: Callable[[VolumeInfo], None]) -> None:
|
|
146
166
|
"""Add a callback for when volume_info is updated."""
|
|
147
167
|
self._volume_info_updated_callbacks.append(callback)
|
|
148
168
|
|
|
149
|
-
def remove_volume_info_updated_callback(self, callback: Callable) -> None:
|
|
169
|
+
def remove_volume_info_updated_callback(self, callback: Callable[[VolumeInfo], None]) -> None:
|
|
150
170
|
"""Remove a callback previously added via add_volume_info_updated_callback.
|
|
151
171
|
|
|
152
172
|
:raises ValueError: if callback not previously added.
|
|
153
173
|
"""
|
|
154
174
|
self._volume_info_updated_callbacks.remove(callback)
|
|
155
175
|
|
|
156
|
-
def add_is_available_updated_callback(self, callback: Callable) -> None:
|
|
176
|
+
def add_is_available_updated_callback(self, callback: Callable[[bool], None]) -> None:
|
|
157
177
|
"""Add a callback for when the Android TV is ready to receive commands or is unavailable."""
|
|
158
178
|
self._is_available_updated_callbacks.append(callback)
|
|
159
179
|
|
|
160
|
-
def remove_is_available_updated_callback(self, callback: Callable) -> None:
|
|
180
|
+
def remove_is_available_updated_callback(self, callback: Callable[[bool], None]) -> None:
|
|
161
181
|
"""Remove a callback previously added via add_is_available_updated_callback.
|
|
162
182
|
|
|
163
183
|
:raises ValueError: if callback not previously added.
|
|
@@ -182,9 +202,7 @@ class AndroidTVRemote:
|
|
|
182
202
|
if self._ssl_context:
|
|
183
203
|
return self._ssl_context
|
|
184
204
|
try:
|
|
185
|
-
ssl_context = await self._loop.run_in_executor(
|
|
186
|
-
None, _load_cert_chain, self._certfile, self._keyfile
|
|
187
|
-
)
|
|
205
|
+
ssl_context = await self._loop.run_in_executor(None, _load_cert_chain, self._certfile, self._keyfile)
|
|
188
206
|
except FileNotFoundError as exc:
|
|
189
207
|
LOGGER.debug("Missing certificate. Error: %s", exc)
|
|
190
208
|
raise InvalidAuth from exc
|
|
@@ -214,24 +232,19 @@ class AndroidTVRemote:
|
|
|
214
232
|
self._on_volume_info_updated,
|
|
215
233
|
self._loop,
|
|
216
234
|
self._enable_ime,
|
|
235
|
+
self._enable_voice,
|
|
217
236
|
),
|
|
218
237
|
self.host,
|
|
219
238
|
self._api_port,
|
|
220
239
|
ssl=ssl_context,
|
|
221
240
|
)
|
|
222
241
|
except OSError as exc:
|
|
223
|
-
LOGGER.debug(
|
|
224
|
-
"Couldn't connect to %s:%s. Error: %s", self.host, self._api_port, exc
|
|
225
|
-
)
|
|
242
|
+
LOGGER.debug("Couldn't connect to %s:%s. Error: %s", self.host, self._api_port, exc)
|
|
226
243
|
if isinstance(exc, ssl.SSLError):
|
|
227
244
|
raise InvalidAuth("Need to pair") from exc
|
|
228
|
-
raise CannotConnect(
|
|
229
|
-
f"Couldn't connect to {self.host}:{self._api_port}"
|
|
230
|
-
) from exc
|
|
245
|
+
raise CannotConnect(f"Couldn't connect to {self.host}:{self._api_port}") from exc
|
|
231
246
|
|
|
232
|
-
await asyncio.wait(
|
|
233
|
-
(on_con_lost, on_remote_started), return_when=asyncio.FIRST_COMPLETED
|
|
234
|
-
)
|
|
247
|
+
await asyncio.wait((on_con_lost, on_remote_started), return_when=asyncio.FIRST_COMPLETED)
|
|
235
248
|
if on_con_lost.done():
|
|
236
249
|
con_lost_exc = on_con_lost.result()
|
|
237
250
|
LOGGER.debug(
|
|
@@ -244,17 +257,13 @@ class AndroidTVRemote:
|
|
|
244
257
|
raise InvalidAuth("Need to pair again") from con_lost_exc
|
|
245
258
|
raise ConnectionClosed("Connection closed") from con_lost_exc
|
|
246
259
|
|
|
247
|
-
async def _async_reconnect(
|
|
248
|
-
self, invalid_auth_callback: Callable | None = None
|
|
249
|
-
) -> None:
|
|
260
|
+
async def _async_reconnect(self, invalid_auth_callback: Callable[[], None] | None = None) -> None:
|
|
250
261
|
while self._remote_message_protocol:
|
|
251
262
|
exc = await self._remote_message_protocol.on_con_lost
|
|
252
263
|
self._on_is_available_updated(False)
|
|
253
264
|
LOGGER.debug("Disconnected from %s. Error: %s", self.host, exc)
|
|
254
265
|
delay_seconds = 0.1
|
|
255
|
-
LOGGER.debug(
|
|
256
|
-
"Trying to reconnect to %s in %s seconds", self.host, delay_seconds
|
|
257
|
-
)
|
|
266
|
+
LOGGER.debug("Trying to reconnect to %s in %s seconds", self.host, delay_seconds)
|
|
258
267
|
while self._remote_message_protocol:
|
|
259
268
|
await asyncio.sleep(delay_seconds)
|
|
260
269
|
try:
|
|
@@ -279,11 +288,9 @@ class AndroidTVRemote:
|
|
|
279
288
|
invalid_auth_callback()
|
|
280
289
|
return
|
|
281
290
|
|
|
282
|
-
def keep_reconnecting(self, invalid_auth_callback: Callable | None = None) -> None:
|
|
291
|
+
def keep_reconnecting(self, invalid_auth_callback: Callable[[], None] | None = None) -> None:
|
|
283
292
|
"""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
|
-
)
|
|
293
|
+
self._reconnect_task = self._loop.create_task(self._async_reconnect(invalid_auth_callback))
|
|
287
294
|
|
|
288
295
|
def disconnect(self) -> None:
|
|
289
296
|
"""Disconnect any open connections."""
|
|
@@ -305,17 +312,11 @@ class AndroidTVRemote:
|
|
|
305
312
|
"""
|
|
306
313
|
ssl_context = await self._create_ssl_context()
|
|
307
314
|
try:
|
|
308
|
-
_, writer = await asyncio.open_connection(
|
|
309
|
-
self.host, self._pair_port, ssl=ssl_context
|
|
310
|
-
)
|
|
315
|
+
_, writer = await asyncio.open_connection(self.host, self._pair_port, ssl=ssl_context)
|
|
311
316
|
except OSError as exc:
|
|
312
|
-
LOGGER.debug(
|
|
313
|
-
"Couldn't connect to %s:%s. %s", self.host, self._pair_port, exc
|
|
314
|
-
)
|
|
317
|
+
LOGGER.debug("Couldn't connect to %s:%s. %s", self.host, self._pair_port, exc)
|
|
315
318
|
raise CannotConnect from exc
|
|
316
|
-
server_cert_bytes = writer.transport.get_extra_info("ssl_object").getpeercert(
|
|
317
|
-
True
|
|
318
|
-
)
|
|
319
|
+
server_cert_bytes = writer.transport.get_extra_info("ssl_object").getpeercert(True)
|
|
319
320
|
writer.close()
|
|
320
321
|
server_cert = x509.load_der_x509_certificate(server_cert_bytes)
|
|
321
322
|
# NVIDIA SHIELD example:
|
|
@@ -357,9 +358,7 @@ class AndroidTVRemote:
|
|
|
357
358
|
ssl=ssl_context,
|
|
358
359
|
)
|
|
359
360
|
except OSError as exc:
|
|
360
|
-
LOGGER.debug(
|
|
361
|
-
"Couldn't connect to %s:%s. %s", self.host, self._pair_port, exc
|
|
362
|
-
)
|
|
361
|
+
LOGGER.debug("Couldn't connect to %s:%s. %s", self.host, self._pair_port, exc)
|
|
363
362
|
raise CannotConnect from exc
|
|
364
363
|
await self._pairing_message_protocol.async_start_pairing()
|
|
365
364
|
|
|
@@ -376,9 +375,7 @@ class AndroidTVRemote:
|
|
|
376
375
|
await self._pairing_message_protocol.async_finish_pairing(pairing_code)
|
|
377
376
|
self.disconnect()
|
|
378
377
|
|
|
379
|
-
def send_key_command(
|
|
380
|
-
self, key_code: int | str, direction: int | str = RemoteDirection.SHORT
|
|
381
|
-
) -> None:
|
|
378
|
+
def send_key_command(self, key_code: int | str, direction: int | str = RemoteDirection.SHORT) -> None:
|
|
382
379
|
"""Send a key press to Android TV.
|
|
383
380
|
|
|
384
381
|
This does not block; it buffers the data and arranges for it to be sent out asynchronously.
|
|
@@ -418,6 +415,22 @@ class AndroidTVRemote:
|
|
|
418
415
|
LOGGER.debug("Called send_launch_app_command after disconnect")
|
|
419
416
|
raise ConnectionClosed("Called send_launch_app_command after disconnect")
|
|
420
417
|
prefix = "" if urlparse(app_link_or_app_id).scheme else "market://launch?id="
|
|
421
|
-
self._remote_message_protocol.send_launch_app_command(
|
|
422
|
-
|
|
423
|
-
|
|
418
|
+
self._remote_message_protocol.send_launch_app_command(f"{prefix}{app_link_or_app_id}")
|
|
419
|
+
|
|
420
|
+
async def start_voice(self, timeout: float = VOICE_SESSION_TIMEOUT) -> VoiceStream:
|
|
421
|
+
"""Start a streaming voice session.
|
|
422
|
+
|
|
423
|
+
A ``VoiceStream`` session wrapper is returned if the voice session can be established
|
|
424
|
+
within the given timeout. The session needs to be closed with ``end()`` (or through the
|
|
425
|
+
asynchronous context manager) before a new session is started.
|
|
426
|
+
|
|
427
|
+
:param timeout: optional timeout for session readiness. Defaults to 2 seconds.
|
|
428
|
+
:raises ConnectionClosed: if client is disconnected.
|
|
429
|
+
:raises asyncio.TimeoutError: if the device does not begin voice in time, or a voice
|
|
430
|
+
session is already in progress.
|
|
431
|
+
"""
|
|
432
|
+
if not self._remote_message_protocol:
|
|
433
|
+
LOGGER.debug("Called start_voice after disconnect")
|
|
434
|
+
raise ConnectionClosed("Called start_voice after disconnect")
|
|
435
|
+
session_id = await self._remote_message_protocol.start_voice(timeout)
|
|
436
|
+
return VoiceStream(self._remote_message_protocol, session_id)
|
|
@@ -3,20 +3,22 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
-
from typing import cast
|
|
6
|
+
from typing import TYPE_CHECKING, cast
|
|
7
7
|
|
|
8
8
|
from google.protobuf import text_format
|
|
9
9
|
from google.protobuf.internal.decoder import _DecodeVarint # type: ignore[attr-defined]
|
|
10
10
|
from google.protobuf.internal.encoder import _EncodeVarint # type: ignore[attr-defined]
|
|
11
|
-
from google.protobuf.message import Message
|
|
12
11
|
|
|
13
12
|
from .const import LOGGER
|
|
14
13
|
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from google.protobuf.message import Message
|
|
16
|
+
|
|
15
17
|
|
|
16
18
|
class ProtobufProtocol(asyncio.Protocol):
|
|
17
19
|
"""Protocol for receiving and sending protobuf messages."""
|
|
18
20
|
|
|
19
|
-
def __init__(self, on_con_lost: asyncio.Future) -> None:
|
|
21
|
+
def __init__(self, on_con_lost: asyncio.Future[Exception | None]) -> None:
|
|
20
22
|
"""Initialize.
|
|
21
23
|
|
|
22
24
|
:param on_con_lost: callback for when the connection is lost or closed.
|
|
@@ -29,7 +31,7 @@ class ProtobufProtocol(asyncio.Protocol):
|
|
|
29
31
|
def connection_made(self, transport: asyncio.BaseTransport) -> None:
|
|
30
32
|
"""Store transport when a connection is made."""
|
|
31
33
|
LOGGER.debug("Connected to %s", transport.get_extra_info("peername"))
|
|
32
|
-
self.transport = cast(asyncio.Transport, transport)
|
|
34
|
+
self.transport = cast("asyncio.Transport", transport)
|
|
33
35
|
|
|
34
36
|
def connection_lost(self, exc: Exception | None) -> None:
|
|
35
37
|
"""Notify on_con_lost when the connection is lost or closed."""
|
|
@@ -69,9 +71,7 @@ class ProtobufProtocol(asyncio.Protocol):
|
|
|
69
71
|
This does not block; it buffers the data and arranges for it to be sent out asynchronously.
|
|
70
72
|
"""
|
|
71
73
|
if should_debug_log:
|
|
72
|
-
LOGGER.debug(
|
|
73
|
-
"Sending: %s", text_format.MessageToString(msg, as_one_line=True)
|
|
74
|
-
)
|
|
74
|
+
LOGGER.debug("Sending: %s", text_format.MessageToString(msg, as_one_line=True))
|
|
75
75
|
if not self.transport or self.transport.is_closing():
|
|
76
76
|
LOGGER.debug("Connection is closed!")
|
|
77
77
|
return
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Data models for AndroidTVRemote."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TypedDict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DeviceInfo(TypedDict):
|
|
9
|
+
"""A TypedDict for device information."""
|
|
10
|
+
|
|
11
|
+
manufacturer: str
|
|
12
|
+
model: str
|
|
13
|
+
sw_version: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class VolumeInfo(TypedDict):
|
|
17
|
+
"""A TypedDict for volume information."""
|
|
18
|
+
|
|
19
|
+
level: int
|
|
20
|
+
max: int
|
|
21
|
+
muted: bool
|