androidtvremote2 0.2.3__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.3/src/androidtvremote2.egg-info → androidtvremote2-0.3.0}/PKG-INFO +17 -10
  2. {androidtvremote2-0.2.3 → androidtvremote2-0.3.0}/README.md +9 -8
  3. androidtvremote2-0.3.0/pyproject.toml +98 -0
  4. androidtvremote2-0.3.0/setup.cfg +4 -0
  5. {androidtvremote2-0.2.3 → androidtvremote2-0.3.0}/src/androidtvremote2/__init__.py +2 -0
  6. {androidtvremote2-0.2.3 → androidtvremote2-0.3.0}/src/androidtvremote2/androidtv_remote.py +60 -60
  7. {androidtvremote2-0.2.3 → androidtvremote2-0.3.0}/src/androidtvremote2/base.py +6 -6
  8. {androidtvremote2-0.2.3 → androidtvremote2-0.3.0}/src/androidtvremote2/pairing.py +6 -20
  9. {androidtvremote2-0.2.3 → androidtvremote2-0.3.0}/src/androidtvremote2/remote.py +140 -21
  10. {androidtvremote2-0.2.3 → androidtvremote2-0.3.0}/src/androidtvremote2/remotemessage_pb2.py +42 -42
  11. {androidtvremote2-0.2.3 → androidtvremote2-0.3.0}/src/androidtvremote2/remotemessage_pb2.pyi +25 -0
  12. androidtvremote2-0.3.0/src/androidtvremote2/voice_stream.py +74 -0
  13. {androidtvremote2-0.2.3 → androidtvremote2-0.3.0/src/androidtvremote2.egg-info}/PKG-INFO +17 -10
  14. {androidtvremote2-0.2.3 → androidtvremote2-0.3.0}/src/androidtvremote2.egg-info/SOURCES.txt +1 -1
  15. androidtvremote2-0.3.0/src/androidtvremote2.egg-info/requires.txt +11 -0
  16. androidtvremote2-0.3.0/src/demo.py +408 -0
  17. androidtvremote2-0.2.3/pyproject.toml +0 -79
  18. androidtvremote2-0.2.3/setup.cfg +0 -16
  19. androidtvremote2-0.2.3/src/androidtvremote2.egg-info/requires.txt +0 -3
  20. androidtvremote2-0.2.3/src/demo.py +0 -248
  21. {androidtvremote2-0.2.3 → androidtvremote2-0.3.0}/LICENSE +0 -0
  22. {androidtvremote2-0.2.3 → androidtvremote2-0.3.0}/src/androidtvremote2/certificate_generator.py +0 -0
  23. {androidtvremote2-0.2.3 → androidtvremote2-0.3.0}/src/androidtvremote2/const.py +0 -0
  24. {androidtvremote2-0.2.3 → androidtvremote2-0.3.0}/src/androidtvremote2/exceptions.py +0 -0
  25. {androidtvremote2-0.2.3 → androidtvremote2-0.3.0}/src/androidtvremote2/model.py +0 -0
  26. {androidtvremote2-0.2.3 → androidtvremote2-0.3.0}/src/androidtvremote2/polo_pb2.py +0 -0
  27. {androidtvremote2-0.2.3 → androidtvremote2-0.3.0}/src/androidtvremote2/polo_pb2.pyi +0 -0
  28. {androidtvremote2-0.2.3 → androidtvremote2-0.3.0}/src/androidtvremote2/py.typed +0 -0
  29. {androidtvremote2-0.2.3 → androidtvremote2-0.3.0}/src/androidtvremote2.egg-info/dependency_links.txt +0 -0
  30. {androidtvremote2-0.2.3 → androidtvremote2-0.3.0}/src/androidtvremote2.egg-info/top_level.txt +0 -0
  31. {androidtvremote2-0.2.3 → 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.3
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
@@ -22,6 +28,8 @@ For a list of the most common commands you can send to the Android TV see: [TvKe
22
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.
@@ -6,6 +6,8 @@ For a list of the most common commands you can send to the Android TV see: [TvKe
6
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
+
@@ -3,6 +3,7 @@
3
3
  from .androidtv_remote import AndroidTVRemote
4
4
  from .exceptions import CannotConnect, ConnectionClosed, InvalidAuth
5
5
  from .model import DeviceInfo, VolumeInfo
6
+ from .voice_stream import VoiceStream
6
7
 
7
8
  __all__ = [
8
9
  "AndroidTVRemote",
@@ -10,5 +11,6 @@ __all__ = [
10
11
  "ConnectionClosed",
11
12
  "DeviceInfo",
12
13
  "InvalidAuth",
14
+ "VoiceStream",
13
15
  "VolumeInfo",
14
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
@@ -14,10 +14,15 @@ from cryptography import x509
14
14
  from .certificate_generator import generate_selfsigned_cert
15
15
  from .const import LOGGER
16
16
  from .exceptions import CannotConnect, ConnectionClosed, InvalidAuth
17
- from .model import DeviceInfo, VolumeInfo
18
17
  from .pairing import PairingProtocol
19
- from .remote import RemoteProtocol
18
+ from .remote import VOICE_SESSION_TIMEOUT, RemoteProtocol
20
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
21
26
 
22
27
 
23
28
  def _load_cert_chain(certfile: str, keyfile: str) -> ssl.SSLContext:
@@ -41,6 +46,7 @@ class AndroidTVRemote:
41
46
  pair_port: int = 6467,
42
47
  loop: asyncio.AbstractEventLoop | None = None,
43
48
  enable_ime: bool = True,
49
+ enable_voice: bool = False,
44
50
  ) -> None:
45
51
  """Initialize.
46
52
 
@@ -53,6 +59,7 @@ class AndroidTVRemote:
53
59
  :param loop: event loop. Used for connections and futures.
54
60
  :param enable_ime: Needed for getting current_app.
55
61
  Disable for devices that show 'Use keyboard on mobile device screen'.
62
+ :param enable_voice: Enable sending voice commands to the device.
56
63
  """
57
64
  self._client_name = client_name
58
65
  self._certfile = certfile
@@ -62,6 +69,7 @@ class AndroidTVRemote:
62
69
  self._pair_port = pair_port
63
70
  self._loop = loop or asyncio.get_running_loop()
64
71
  self._enable_ime = enable_ime
72
+ self._enable_voice = enable_voice
65
73
  self._transport: asyncio.Transport | None = None
66
74
  self._remote_message_protocol: RemoteProtocol | None = None
67
75
  self._pairing_message_protocol: PairingProtocol | None = None
@@ -121,6 +129,17 @@ class AndroidTVRemote:
121
129
  return None
122
130
  return self._remote_message_protocol.volume_info
123
131
 
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
+
124
143
  def add_is_on_updated_callback(self, callback: Callable[[bool], None]) -> None:
125
144
  """Add a callback for when is_on is updated."""
126
145
  self._is_on_updated_callbacks.append(callback)
@@ -136,39 +155,29 @@ class AndroidTVRemote:
136
155
  """Add a callback for when current_app is updated."""
137
156
  self._current_app_updated_callbacks.append(callback)
138
157
 
139
- def remove_current_app_updated_callback(
140
- self, callback: Callable[[str], None]
141
- ) -> None:
158
+ def remove_current_app_updated_callback(self, callback: Callable[[str], None]) -> None:
142
159
  """Remove a callback previously added via add_current_app_updated_callback.
143
160
 
144
161
  :raises ValueError: if callback not previously added.
145
162
  """
146
163
  self._current_app_updated_callbacks.remove(callback)
147
164
 
148
- def add_volume_info_updated_callback(
149
- self, callback: Callable[[VolumeInfo], None]
150
- ) -> None:
165
+ def add_volume_info_updated_callback(self, callback: Callable[[VolumeInfo], None]) -> None:
151
166
  """Add a callback for when volume_info is updated."""
152
167
  self._volume_info_updated_callbacks.append(callback)
153
168
 
154
- def remove_volume_info_updated_callback(
155
- self, callback: Callable[[VolumeInfo], None]
156
- ) -> None:
169
+ def remove_volume_info_updated_callback(self, callback: Callable[[VolumeInfo], None]) -> None:
157
170
  """Remove a callback previously added via add_volume_info_updated_callback.
158
171
 
159
172
  :raises ValueError: if callback not previously added.
160
173
  """
161
174
  self._volume_info_updated_callbacks.remove(callback)
162
175
 
163
- def add_is_available_updated_callback(
164
- self, callback: Callable[[bool], None]
165
- ) -> None:
176
+ def add_is_available_updated_callback(self, callback: Callable[[bool], None]) -> None:
166
177
  """Add a callback for when the Android TV is ready to receive commands or is unavailable."""
167
178
  self._is_available_updated_callbacks.append(callback)
168
179
 
169
- def remove_is_available_updated_callback(
170
- self, callback: Callable[[bool], None]
171
- ) -> None:
180
+ def remove_is_available_updated_callback(self, callback: Callable[[bool], None]) -> None:
172
181
  """Remove a callback previously added via add_is_available_updated_callback.
173
182
 
174
183
  :raises ValueError: if callback not previously added.
@@ -193,9 +202,7 @@ class AndroidTVRemote:
193
202
  if self._ssl_context:
194
203
  return self._ssl_context
195
204
  try:
196
- ssl_context = await self._loop.run_in_executor(
197
- None, _load_cert_chain, self._certfile, self._keyfile
198
- )
205
+ ssl_context = await self._loop.run_in_executor(None, _load_cert_chain, self._certfile, self._keyfile)
199
206
  except FileNotFoundError as exc:
200
207
  LOGGER.debug("Missing certificate. Error: %s", exc)
201
208
  raise InvalidAuth from exc
@@ -225,24 +232,19 @@ class AndroidTVRemote:
225
232
  self._on_volume_info_updated,
226
233
  self._loop,
227
234
  self._enable_ime,
235
+ self._enable_voice,
228
236
  ),
229
237
  self.host,
230
238
  self._api_port,
231
239
  ssl=ssl_context,
232
240
  )
233
241
  except OSError as exc:
234
- LOGGER.debug(
235
- "Couldn't connect to %s:%s. Error: %s", self.host, self._api_port, exc
236
- )
242
+ LOGGER.debug("Couldn't connect to %s:%s. Error: %s", self.host, self._api_port, exc)
237
243
  if isinstance(exc, ssl.SSLError):
238
244
  raise InvalidAuth("Need to pair") from exc
239
- raise CannotConnect(
240
- f"Couldn't connect to {self.host}:{self._api_port}"
241
- ) from exc
245
+ raise CannotConnect(f"Couldn't connect to {self.host}:{self._api_port}") from exc
242
246
 
243
- await asyncio.wait(
244
- (on_con_lost, on_remote_started), return_when=asyncio.FIRST_COMPLETED
245
- )
247
+ await asyncio.wait((on_con_lost, on_remote_started), return_when=asyncio.FIRST_COMPLETED)
246
248
  if on_con_lost.done():
247
249
  con_lost_exc = on_con_lost.result()
248
250
  LOGGER.debug(
@@ -255,17 +257,13 @@ class AndroidTVRemote:
255
257
  raise InvalidAuth("Need to pair again") from con_lost_exc
256
258
  raise ConnectionClosed("Connection closed") from con_lost_exc
257
259
 
258
- async def _async_reconnect(
259
- self, invalid_auth_callback: Callable[[], None] | None = None
260
- ) -> None:
260
+ async def _async_reconnect(self, invalid_auth_callback: Callable[[], None] | None = None) -> None:
261
261
  while self._remote_message_protocol:
262
262
  exc = await self._remote_message_protocol.on_con_lost
263
263
  self._on_is_available_updated(False)
264
264
  LOGGER.debug("Disconnected from %s. Error: %s", self.host, exc)
265
265
  delay_seconds = 0.1
266
- LOGGER.debug(
267
- "Trying to reconnect to %s in %s seconds", self.host, delay_seconds
268
- )
266
+ LOGGER.debug("Trying to reconnect to %s in %s seconds", self.host, delay_seconds)
269
267
  while self._remote_message_protocol:
270
268
  await asyncio.sleep(delay_seconds)
271
269
  try:
@@ -290,13 +288,9 @@ class AndroidTVRemote:
290
288
  invalid_auth_callback()
291
289
  return
292
290
 
293
- def keep_reconnecting(
294
- self, invalid_auth_callback: Callable[[], None] | None = None
295
- ) -> None:
291
+ def keep_reconnecting(self, invalid_auth_callback: Callable[[], None] | None = None) -> None:
296
292
  """Create a task to keep reconnecting whenever connection is lost."""
297
- self._reconnect_task = self._loop.create_task(
298
- self._async_reconnect(invalid_auth_callback)
299
- )
293
+ self._reconnect_task = self._loop.create_task(self._async_reconnect(invalid_auth_callback))
300
294
 
301
295
  def disconnect(self) -> None:
302
296
  """Disconnect any open connections."""
@@ -318,17 +312,11 @@ class AndroidTVRemote:
318
312
  """
319
313
  ssl_context = await self._create_ssl_context()
320
314
  try:
321
- _, writer = await asyncio.open_connection(
322
- self.host, self._pair_port, ssl=ssl_context
323
- )
315
+ _, writer = await asyncio.open_connection(self.host, self._pair_port, ssl=ssl_context)
324
316
  except OSError as exc:
325
- LOGGER.debug(
326
- "Couldn't connect to %s:%s. %s", self.host, self._pair_port, exc
327
- )
317
+ LOGGER.debug("Couldn't connect to %s:%s. %s", self.host, self._pair_port, exc)
328
318
  raise CannotConnect from exc
329
- server_cert_bytes = writer.transport.get_extra_info("ssl_object").getpeercert(
330
- True
331
- )
319
+ server_cert_bytes = writer.transport.get_extra_info("ssl_object").getpeercert(True)
332
320
  writer.close()
333
321
  server_cert = x509.load_der_x509_certificate(server_cert_bytes)
334
322
  # NVIDIA SHIELD example:
@@ -370,9 +358,7 @@ class AndroidTVRemote:
370
358
  ssl=ssl_context,
371
359
  )
372
360
  except OSError as exc:
373
- LOGGER.debug(
374
- "Couldn't connect to %s:%s. %s", self.host, self._pair_port, exc
375
- )
361
+ LOGGER.debug("Couldn't connect to %s:%s. %s", self.host, self._pair_port, exc)
376
362
  raise CannotConnect from exc
377
363
  await self._pairing_message_protocol.async_start_pairing()
378
364
 
@@ -389,9 +375,7 @@ class AndroidTVRemote:
389
375
  await self._pairing_message_protocol.async_finish_pairing(pairing_code)
390
376
  self.disconnect()
391
377
 
392
- def send_key_command(
393
- self, key_code: int | str, direction: int | str = RemoteDirection.SHORT
394
- ) -> None:
378
+ def send_key_command(self, key_code: int | str, direction: int | str = RemoteDirection.SHORT) -> None:
395
379
  """Send a key press to Android TV.
396
380
 
397
381
  This does not block; it buffers the data and arranges for it to be sent out asynchronously.
@@ -431,6 +415,22 @@ class AndroidTVRemote:
431
415
  LOGGER.debug("Called send_launch_app_command after disconnect")
432
416
  raise ConnectionClosed("Called send_launch_app_command after disconnect")
433
417
  prefix = "" if urlparse(app_link_or_app_id).scheme else "market://launch?id="
434
- self._remote_message_protocol.send_launch_app_command(
435
- f"{prefix}{app_link_or_app_id}"
436
- )
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,15 +3,17 @@
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."""
@@ -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
@@ -104,9 +104,7 @@ class PairingProtocol(ProtobufProtocol):
104
104
  client_modulus, client_exponent = _get_modulus_and_exponent(client_cert)
105
105
 
106
106
  assert self.transport
107
- server_cert_bytes = self.transport.get_extra_info("ssl_object").getpeercert(
108
- True
109
- )
107
+ server_cert_bytes = self.transport.get_extra_info("ssl_object").getpeercert(True)
110
108
  server_cert = x509.load_der_x509_certificate(server_cert_bytes)
111
109
  server_modulus, server_exponent = _get_modulus_and_exponent(server_cert)
112
110
 
@@ -131,13 +129,9 @@ class PairingProtocol(ProtobufProtocol):
131
129
  finally:
132
130
  self._on_pairing_finished = None
133
131
 
134
- async def _async_wait_for_future_or_con_lost(
135
- self, future: asyncio.Future[Any]
136
- ) -> None:
132
+ async def _async_wait_for_future_or_con_lost(self, future: asyncio.Future[Any]) -> None:
137
133
  """Wait for future to finish or connection to be lost."""
138
- await asyncio.wait(
139
- (self.on_con_lost, future), return_when=asyncio.FIRST_COMPLETED
140
- )
134
+ await asyncio.wait((self.on_con_lost, future), return_when=asyncio.FIRST_COMPLETED)
141
135
  if future.done():
142
136
  if future.exception():
143
137
  raise ConnectionClosed(future.exception())
@@ -180,9 +174,7 @@ class PairingProtocol(ProtobufProtocol):
180
174
  enc.symbol_length = 6
181
175
  elif msg.HasField("options"):
182
176
  new_msg.configuration.client_role = Options.RoleType.ROLE_TYPE_INPUT
183
- new_msg.configuration.encoding.type = (
184
- Options.Encoding.ENCODING_TYPE_HEXADECIMAL
185
- )
177
+ new_msg.configuration.encoding.type = Options.Encoding.ENCODING_TYPE_HEXADECIMAL
186
178
  new_msg.configuration.encoding.symbol_length = 6
187
179
  elif msg.HasField("configuration_ack"):
188
180
  if self._on_pairing_started:
@@ -193,14 +185,8 @@ class PairingProtocol(ProtobufProtocol):
193
185
  self._on_pairing_finished.set_result(True)
194
186
  return
195
187
  else:
196
- LOGGER.debug(
197
- "Unhandled msg: %s", text_format.MessageToString(msg, as_one_line=True)
198
- )
199
- self._handle_error(
200
- Exception(
201
- f"Unhandled msg: {text_format.MessageToString(msg, as_one_line=True)}"
202
- )
203
- )
188
+ LOGGER.debug("Unhandled msg: %s", text_format.MessageToString(msg, as_one_line=True))
189
+ self._handle_error(Exception(f"Unhandled msg: {text_format.MessageToString(msg, as_one_line=True)}"))
204
190
  return
205
191
 
206
192
  self._send_message(new_msg)