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.
Files changed (31) hide show
  1. {androidtvremote2-0.2.2/src/androidtvremote2.egg-info → androidtvremote2-0.3.0}/PKG-INFO +18 -11
  2. {androidtvremote2-0.2.2 → androidtvremote2-0.3.0}/README.md +10 -9
  3. androidtvremote2-0.3.0/pyproject.toml +98 -0
  4. androidtvremote2-0.3.0/setup.cfg +4 -0
  5. {androidtvremote2-0.2.2 → androidtvremote2-0.3.0}/src/androidtvremote2/__init__.py +5 -0
  6. {androidtvremote2-0.2.2 → androidtvremote2-0.3.0}/src/androidtvremote2/androidtv_remote.py +71 -58
  7. {androidtvremote2-0.2.2 → androidtvremote2-0.3.0}/src/androidtvremote2/base.py +7 -7
  8. androidtvremote2-0.3.0/src/androidtvremote2/model.py +21 -0
  9. {androidtvremote2-0.2.2 → androidtvremote2-0.3.0}/src/androidtvremote2/pairing.py +10 -21
  10. androidtvremote2-0.3.0/src/androidtvremote2/py.typed +0 -0
  11. {androidtvremote2-0.2.2 → androidtvremote2-0.3.0}/src/androidtvremote2/remote.py +149 -29
  12. {androidtvremote2-0.2.2 → androidtvremote2-0.3.0}/src/androidtvremote2/remotemessage_pb2.py +42 -42
  13. {androidtvremote2-0.2.2 → androidtvremote2-0.3.0}/src/androidtvremote2/remotemessage_pb2.pyi +25 -0
  14. androidtvremote2-0.3.0/src/androidtvremote2/voice_stream.py +74 -0
  15. {androidtvremote2-0.2.2 → androidtvremote2-0.3.0/src/androidtvremote2.egg-info}/PKG-INFO +18 -11
  16. {androidtvremote2-0.2.2 → androidtvremote2-0.3.0}/src/androidtvremote2.egg-info/SOURCES.txt +3 -1
  17. androidtvremote2-0.3.0/src/androidtvremote2.egg-info/requires.txt +11 -0
  18. androidtvremote2-0.3.0/src/demo.py +408 -0
  19. androidtvremote2-0.2.2/pyproject.toml +0 -80
  20. androidtvremote2-0.2.2/setup.cfg +0 -16
  21. androidtvremote2-0.2.2/src/androidtvremote2.egg-info/requires.txt +0 -3
  22. androidtvremote2-0.2.2/src/demo.py +0 -244
  23. {androidtvremote2-0.2.2 → androidtvremote2-0.3.0}/LICENSE +0 -0
  24. {androidtvremote2-0.2.2 → androidtvremote2-0.3.0}/src/androidtvremote2/certificate_generator.py +0 -0
  25. {androidtvremote2-0.2.2 → androidtvremote2-0.3.0}/src/androidtvremote2/const.py +0 -0
  26. {androidtvremote2-0.2.2 → androidtvremote2-0.3.0}/src/androidtvremote2/exceptions.py +0 -0
  27. {androidtvremote2-0.2.2 → androidtvremote2-0.3.0}/src/androidtvremote2/polo_pb2.py +0 -0
  28. {androidtvremote2-0.2.2 → androidtvremote2-0.3.0}/src/androidtvremote2/polo_pb2.pyi +0 -0
  29. {androidtvremote2-0.2.2 → androidtvremote2-0.3.0}/src/androidtvremote2.egg-info/dependency_links.txt +0 -0
  30. {androidtvremote2-0.2.2 → androidtvremote2-0.3.0}/src/androidtvremote2.egg-info/top_level.txt +0 -0
  31. {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.2.2
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 [here](https://github.com/tronikos/androidtvremote2/blob/b4c49ac03043b1b9c40c2f2960e466d5a3b8bd67/src/androidtvremote2/remotemessage.proto#L90).
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 pytest
68
+ python -m pip install -e ".[test]"
66
69
  pytest
67
70
 
68
71
  # Run demo
69
- python -m pip install pynput zeroconf
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 [here](https://github.com/tronikos/androidtvremote2/blob/b4c49ac03043b1b9c40c2f2960e466d5a3b8bd67/src/androidtvremote2/remotemessage.proto#L90).
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 pytest
46
+ python -m pip install -e ".[test]"
50
47
  pytest
51
48
 
52
49
  # Run demo
53
- python -m pip install pynput zeroconf
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
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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: dict[str, str | bool]) -> None:
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) -> dict[str, str] | None:
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) -> dict[str, str | bool | int] | None:
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
- def add_is_on_updated_callback(self, callback: Callable) -> None:
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
- f"{prefix}{app_link_or_app_id}"
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