androidtvremote2 0.3.0__tar.gz → 0.3.2__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 (33) hide show
  1. {androidtvremote2-0.3.0/src/androidtvremote2.egg-info → androidtvremote2-0.3.2}/PKG-INFO +2 -1
  2. {androidtvremote2-0.3.0 → androidtvremote2-0.3.2}/pyproject.toml +12 -1
  3. {androidtvremote2-0.3.0 → androidtvremote2-0.3.2}/src/androidtvremote2/__init__.py +2 -1
  4. {androidtvremote2-0.3.0 → androidtvremote2-0.3.2}/src/androidtvremote2/androidtv_remote.py +37 -20
  5. {androidtvremote2-0.3.0 → androidtvremote2-0.3.2}/src/androidtvremote2/base.py +28 -18
  6. {androidtvremote2-0.3.0 → androidtvremote2-0.3.2}/src/androidtvremote2/certificate_generator.py +2 -2
  7. {androidtvremote2-0.3.0 → androidtvremote2-0.3.2}/src/androidtvremote2/exceptions.py +4 -0
  8. {androidtvremote2-0.3.0 → androidtvremote2-0.3.2}/src/androidtvremote2/pairing.py +20 -7
  9. {androidtvremote2-0.3.0 → androidtvremote2-0.3.2}/src/androidtvremote2/remote.py +52 -22
  10. {androidtvremote2-0.3.0 → androidtvremote2-0.3.2/src/androidtvremote2.egg-info}/PKG-INFO +2 -1
  11. {androidtvremote2-0.3.0 → androidtvremote2-0.3.2}/src/androidtvremote2.egg-info/SOURCES.txt +6 -2
  12. {androidtvremote2-0.3.0 → androidtvremote2-0.3.2}/src/androidtvremote2.egg-info/requires.txt +1 -0
  13. {androidtvremote2-0.3.0 → androidtvremote2-0.3.2}/src/androidtvremote2.egg-info/top_level.txt +0 -1
  14. androidtvremote2-0.3.2/tests/test_androidtv_remote.py +361 -0
  15. androidtvremote2-0.3.2/tests/test_base.py +210 -0
  16. androidtvremote2-0.3.2/tests/test_certificate_generator.py +68 -0
  17. androidtvremote2-0.3.2/tests/test_pairing.py +271 -0
  18. androidtvremote2-0.3.2/tests/test_remote.py +559 -0
  19. androidtvremote2-0.3.2/tests/test_voice_stream.py +88 -0
  20. androidtvremote2-0.3.0/src/demo.py +0 -408
  21. androidtvremote2-0.3.0/tests/test_androidtv_remote.py +0 -6
  22. {androidtvremote2-0.3.0 → androidtvremote2-0.3.2}/LICENSE +0 -0
  23. {androidtvremote2-0.3.0 → androidtvremote2-0.3.2}/README.md +0 -0
  24. {androidtvremote2-0.3.0 → androidtvremote2-0.3.2}/setup.cfg +0 -0
  25. {androidtvremote2-0.3.0 → androidtvremote2-0.3.2}/src/androidtvremote2/const.py +0 -0
  26. {androidtvremote2-0.3.0 → androidtvremote2-0.3.2}/src/androidtvremote2/model.py +0 -0
  27. {androidtvremote2-0.3.0 → androidtvremote2-0.3.2}/src/androidtvremote2/polo_pb2.py +0 -0
  28. {androidtvremote2-0.3.0 → androidtvremote2-0.3.2}/src/androidtvremote2/polo_pb2.pyi +0 -0
  29. {androidtvremote2-0.3.0 → androidtvremote2-0.3.2}/src/androidtvremote2/py.typed +0 -0
  30. {androidtvremote2-0.3.0 → androidtvremote2-0.3.2}/src/androidtvremote2/remotemessage_pb2.py +0 -0
  31. {androidtvremote2-0.3.0 → androidtvremote2-0.3.2}/src/androidtvremote2/remotemessage_pb2.pyi +0 -0
  32. {androidtvremote2-0.3.0 → androidtvremote2-0.3.2}/src/androidtvremote2/voice_stream.py +0 -0
  33. {androidtvremote2-0.3.0 → androidtvremote2-0.3.2}/src/androidtvremote2.egg-info/dependency_links.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: androidtvremote2
3
- Version: 0.3.0
3
+ Version: 0.3.2
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
6
  License-Expression: Apache-2.0
@@ -14,6 +14,7 @@ Requires-Dist: cryptography>=3
14
14
  Requires-Dist: protobuf>=4.21
15
15
  Provides-Extra: test
16
16
  Requires-Dist: pytest; extra == "test"
17
+ Requires-Dist: pytest-asyncio; extra == "test"
17
18
  Provides-Extra: demo
18
19
  Requires-Dist: pyaudio; extra == "demo"
19
20
  Requires-Dist: pynput; extra == "demo"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "androidtvremote2"
3
- version = "0.3.0"
3
+ version = "0.3.2"
4
4
  license = "Apache-2.0"
5
5
  authors = [
6
6
  { name="tronikos", email="tronikos@gmail.com" },
@@ -17,6 +17,7 @@ dependencies = [
17
17
  [project.optional-dependencies]
18
18
  test = [
19
19
  "pytest",
20
+ "pytest-asyncio",
20
21
  ]
21
22
  demo = [
22
23
  "pyaudio",
@@ -33,6 +34,15 @@ demo = [
33
34
  requires = ["setuptools"]
34
35
  build-backend = "setuptools.build_meta"
35
36
 
37
+ # Explicit discovery. Without it setuptools also picks up src/demo.py and installs it
38
+ # as a top level "demo" module.
39
+ [tool.setuptools.packages.find]
40
+ where = ["src"]
41
+
42
+ [tool.pytest.ini_options]
43
+ asyncio_mode = "auto"
44
+ asyncio_default_fixture_loop_scope = "function"
45
+
36
46
  [tool.ruff]
37
47
  target-version = "py310"
38
48
  line-length = 127
@@ -70,6 +80,7 @@ ignore = [
70
80
  "D203", # 1 blank line required before class docstring
71
81
  "D213", # Multi-line docstring summary should start at the second line
72
82
  "PLR0913", # Too many arguments in function definition
83
+ "PLR0917", # Too many positional arguments in function definition
73
84
  "PLR1714", # Consider merging multiple comparisons
74
85
  "PLR2004", # Magic value used in comparison
75
86
  "TRY003", # Avoid specifying long messages outside the exception class
@@ -1,7 +1,7 @@
1
1
  """Library implementing the Android TV Remote protocol."""
2
2
 
3
3
  from .androidtv_remote import AndroidTVRemote
4
- from .exceptions import CannotConnect, ConnectionClosed, InvalidAuth
4
+ from .exceptions import CannotConnect, ConnectionClosed, InvalidAuth, VoiceSessionInProgress
5
5
  from .model import DeviceInfo, VolumeInfo
6
6
  from .voice_stream import VoiceStream
7
7
 
@@ -11,6 +11,7 @@ __all__ = [
11
11
  "ConnectionClosed",
12
12
  "DeviceInfo",
13
13
  "InvalidAuth",
14
+ "VoiceSessionInProgress",
14
15
  "VoiceStream",
15
16
  "VolumeInfo",
16
17
  ]
@@ -3,12 +3,12 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
- import os
7
6
  import ssl
8
7
  from typing import TYPE_CHECKING
9
8
  from urllib.parse import urlparse
10
9
 
11
10
  import aiofiles
11
+ from aiofiles import os as aos
12
12
  from cryptography import x509
13
13
 
14
14
  from .certificate_generator import generate_selfsigned_cert
@@ -25,6 +25,31 @@ if TYPE_CHECKING:
25
25
  from .model import DeviceInfo, VolumeInfo
26
26
 
27
27
 
28
+ def _parse_name_and_mac(server_cert: x509.Certificate) -> tuple[str, str]:
29
+ """Extract the name and MAC address from the subject of an Android TV certificate.
30
+
31
+ NVIDIA SHIELD example:
32
+ CN=atvremote/darcy/darcy/SHIELD Android TV/XX:XX:XX:XX:XX:XX
33
+ Nexus Player example:
34
+ dnQualifier=fugu/fugu/Nexus Player/CN=atvremote/XX:XX:XX:XX:XX:XX
35
+ """
36
+ common_name = server_cert.subject.get_attributes_for_oid(x509.OID_COMMON_NAME)
37
+ common_name_str = str(common_name[0].value) if common_name else ""
38
+ dn_qualifier = server_cert.subject.get_attributes_for_oid(x509.OID_DN_QUALIFIER)
39
+ dn_qualifier_str = str(dn_qualifier[0].value) if dn_qualifier else ""
40
+ common_name_parts = common_name_str.split("/")
41
+ dn_qualifier_parts = dn_qualifier_str.split("/")
42
+ if dn_qualifier_str:
43
+ name = dn_qualifier_parts[-1]
44
+ elif len(common_name_parts) > 1:
45
+ name = common_name_parts[-2]
46
+ else:
47
+ # Unexpected subject, e.g. a common name without any "/" separator.
48
+ name = common_name_str
49
+ mac = common_name_parts[-1]
50
+ return name, mac
51
+
52
+
28
53
  def _load_cert_chain(certfile: str, keyfile: str) -> ssl.SSLContext:
29
54
  ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
30
55
  ssl_context.check_hostname = False
@@ -189,13 +214,15 @@ class AndroidTVRemote:
189
214
 
190
215
  :returns: True if a new certificate was generated.
191
216
  """
192
- if os.path.isfile(self._certfile) and os.path.isfile(self._keyfile):
217
+ if await aos.path.isfile(self._certfile) and await aos.path.isfile(self._keyfile):
193
218
  return False
194
219
  cert_pem, key_pem = generate_selfsigned_cert(self._client_name)
195
220
  async with aiofiles.open(self._certfile, "w", encoding="utf-8") as out:
196
221
  await out.write(cert_pem.decode("utf-8"))
197
222
  async with aiofiles.open(self._keyfile, "w", encoding="utf-8") as out:
198
223
  await out.write(key_pem.decode("utf-8"))
224
+ # The cached context, if any, was loaded from the previous certificate.
225
+ self._ssl_context = None
199
226
  return True
200
227
 
201
228
  async def _create_ssl_context(self) -> ssl.SSLContext:
@@ -297,8 +324,7 @@ class AndroidTVRemote:
297
324
  if self._reconnect_task:
298
325
  self._reconnect_task.cancel()
299
326
  if self._remote_message_protocol:
300
- if self._remote_message_protocol.transport:
301
- self._remote_message_protocol.transport.close()
327
+ self._remote_message_protocol.close()
302
328
  self._remote_message_protocol = None
303
329
  if self._pairing_message_protocol:
304
330
  if self._pairing_message_protocol.transport:
@@ -318,20 +344,11 @@ class AndroidTVRemote:
318
344
  raise CannotConnect from exc
319
345
  server_cert_bytes = writer.transport.get_extra_info("ssl_object").getpeercert(True)
320
346
  writer.close()
321
- server_cert = x509.load_der_x509_certificate(server_cert_bytes)
322
- # NVIDIA SHIELD example:
323
- # CN=atvremote/darcy/darcy/SHIELD Android TV/XX:XX:XX:XX:XX:XX
324
- # Nexus Player example:
325
- # dnQualifier=fugu/fugu/Nexus Player/CN=atvremote/XX:XX:XX:XX:XX:XX
326
- common_name = server_cert.subject.get_attributes_for_oid(x509.OID_COMMON_NAME)
327
- common_name_str = str(common_name[0].value) if common_name else ""
328
- dn_qualifier = server_cert.subject.get_attributes_for_oid(x509.OID_DN_QUALIFIER)
329
- dn_qualifier_str = str(dn_qualifier[0].value) if dn_qualifier else ""
330
- common_name_parts = common_name_str.split("/")
331
- dn_qualifier_parts = dn_qualifier_str.split("/")
332
- name = dn_qualifier_parts[-1] if dn_qualifier_str else common_name_parts[-2]
333
- mac = common_name_parts[-1]
334
- return name, mac
347
+ try:
348
+ await writer.wait_closed()
349
+ except OSError as exc:
350
+ LOGGER.debug("Error closing connection to %s:%s. %s", self.host, self._pair_port, exc)
351
+ return _parse_name_and_mac(x509.load_der_x509_certificate(server_cert_bytes))
335
352
 
336
353
  async def async_start_pairing(self) -> None:
337
354
  """Start the pairing process.
@@ -426,8 +443,8 @@ class AndroidTVRemote:
426
443
 
427
444
  :param timeout: optional timeout for session readiness. Defaults to 2 seconds.
428
445
  :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.
446
+ :raises VoiceSessionInProgress: if a voice session is already in progress.
447
+ :raises asyncio.TimeoutError: if the device does not begin voice in time.
431
448
  """
432
449
  if not self._remote_message_protocol:
433
450
  LOGGER.debug("Called start_voice after disconnect")
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, cast
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 DecodeError
11
12
 
12
13
  from .const import LOGGER
13
14
 
@@ -25,8 +26,7 @@ class ProtobufProtocol(asyncio.Protocol):
25
26
  """
26
27
  self.on_con_lost = on_con_lost
27
28
  self.transport: asyncio.Transport | None = None
28
- self._raw_msg_len = -1
29
- self._raw_msg = b""
29
+ self._buffer = bytearray()
30
30
 
31
31
  def connection_made(self, transport: asyncio.BaseTransport) -> None:
32
32
  """Store transport when a connection is made."""
@@ -40,27 +40,37 @@ class ProtobufProtocol(asyncio.Protocol):
40
40
  self.on_con_lost.set_result(exc)
41
41
 
42
42
  def data_received(self, data: bytes) -> None:
43
- """Receive data until a full protobuf is received and pass it to _handle_message."""
43
+ """Buffer data and pass every complete protobuf message to _handle_message.
44
+
45
+ Messages are length delimited by a varint. A single call may deliver a partial
46
+ message, several messages, or anything in between, and the split can happen
47
+ anywhere, including in the middle of the varint.
48
+ """
44
49
  if not data:
45
50
  LOGGER.debug("No data received")
46
51
  return
47
- if self._raw_msg_len < 0:
48
- self._raw_msg_len, pos = _DecodeVarint(data, 0)
49
- pos_end = pos + self._raw_msg_len
50
- self._raw_msg += data[pos:pos_end]
51
- remaining_data = data[pos_end:]
52
- else:
53
- pos_end = self._raw_msg_len - len(self._raw_msg)
54
- self._raw_msg += data[:pos_end]
55
- remaining_data = data[pos_end:]
56
- if self._raw_msg_len == len(self._raw_msg):
57
- raw_msg = self._raw_msg
58
- self._raw_msg_len = -1
59
- self._raw_msg = b""
52
+ self._buffer += data
53
+ while self._buffer:
54
+ try:
55
+ msg_len, pos = _DecodeVarint(self._buffer, 0)
56
+ except IndexError:
57
+ # The varint with the message length was not fully received yet.
58
+ return
59
+ except DecodeError as exc:
60
+ # The stream cannot be resynchronized after a corrupt length.
61
+ LOGGER.debug("Couldn't decode the message length. %s", exc)
62
+ self._buffer.clear()
63
+ if self.transport and not self.transport.is_closing():
64
+ self.transport.close()
65
+ return
66
+ pos_end = pos + msg_len
67
+ if len(self._buffer) < pos_end:
68
+ # The message was not fully received yet.
69
+ return
70
+ raw_msg = bytes(self._buffer[pos:pos_end])
71
+ del self._buffer[:pos_end]
60
72
  # LOGGER.debug("Received: %s", raw_msg)
61
73
  self._handle_message(raw_msg)
62
- if remaining_data:
63
- self.data_received(remaining_data)
64
74
 
65
75
  def _handle_message(self, raw_msg: bytes) -> None:
66
76
  """Handle a message from the server. Message needs to be parsed to the appropriate protobuf."""
@@ -6,7 +6,7 @@
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- from datetime import datetime, timedelta
9
+ from datetime import datetime, timedelta, timezone
10
10
 
11
11
  from cryptography import x509
12
12
  from cryptography.hazmat.backends import default_backend
@@ -29,7 +29,7 @@ def generate_selfsigned_cert(hostname: str) -> tuple[bytes, bytes]:
29
29
  alt_names = [x509.DNSName(hostname)]
30
30
  san = x509.SubjectAlternativeName(alt_names)
31
31
  basic_constraints = x509.BasicConstraints(ca=True, path_length=0)
32
- now = datetime.utcnow()
32
+ now = datetime.now(timezone.utc)
33
33
  cert = (
34
34
  x509.CertificateBuilder()
35
35
  .subject_name(name)
@@ -11,3 +11,7 @@ class ConnectionClosed(Exception):
11
11
 
12
12
  class InvalidAuth(Exception):
13
13
  """Error to indicate there is invalid auth."""
14
+
15
+
16
+ class VoiceSessionInProgress(Exception):
17
+ """Error to indicate a voice session is already in progress."""
@@ -21,6 +21,10 @@ from .const import LOGGER
21
21
  from .exceptions import ConnectionClosed, InvalidAuth
22
22
  from .polo_pb2 import Options, OuterMessage
23
23
 
24
+ # Timeout in seconds to wait for a response from the Android TV. Every step of the
25
+ # pairing handshake is answered by the device itself, so this doesn't wait on a human.
26
+ PAIRING_TIMEOUT = 10.0
27
+
24
28
 
25
29
  def _create_message() -> OuterMessage:
26
30
  """Create an OuterMessage with default values."""
@@ -69,7 +73,7 @@ class PairingProtocol(ProtobufProtocol):
69
73
  async def async_start_pairing(self) -> None:
70
74
  """Start the pairing process.
71
75
 
72
- :raises ConnectionClosed: if connection was lost.
76
+ :raises ConnectionClosed: if connection was lost or the device didn't respond in time.
73
77
  """
74
78
  self._raise_if_not_connected()
75
79
  msg = _create_message()
@@ -86,7 +90,7 @@ class PairingProtocol(ProtobufProtocol):
86
90
  """Finish the pairing process.
87
91
 
88
92
  :param pairing_code: pairing code shown on the Android TV.
89
- :raises ConnectionClosed: if connection was lost.
93
+ :raises ConnectionClosed: if connection was lost or the device didn't respond in time.
90
94
  :raises InvalidAuth: if pairing was unsuccessful.
91
95
  """
92
96
  self._raise_if_not_connected()
@@ -129,9 +133,18 @@ class PairingProtocol(ProtobufProtocol):
129
133
  finally:
130
134
  self._on_pairing_finished = None
131
135
 
132
- async def _async_wait_for_future_or_con_lost(self, future: asyncio.Future[Any]) -> None:
133
- """Wait for future to finish or connection to be lost."""
134
- await asyncio.wait((self.on_con_lost, future), return_when=asyncio.FIRST_COMPLETED)
136
+ async def _async_wait_for_future_or_con_lost(self, future: asyncio.Future[Any], timeout: float = PAIRING_TIMEOUT) -> None:
137
+ """Wait for future to finish, connection to be lost, or timeout to elapse.
138
+
139
+ :raises ConnectionClosed: if the connection was lost or the device stopped responding.
140
+ """
141
+ done, _pending = await asyncio.wait((self.on_con_lost, future), timeout=timeout, return_when=asyncio.FIRST_COMPLETED)
142
+ if not done:
143
+ LOGGER.debug("Timeout after %s seconds waiting for a response while pairing", timeout)
144
+ future.cancel()
145
+ if self.transport:
146
+ self.transport.close()
147
+ raise ConnectionClosed(f"No response from the Android TV within {timeout} seconds")
135
148
  if future.done():
136
149
  if future.exception():
137
150
  raise ConnectionClosed(future.exception())
@@ -177,11 +190,11 @@ class PairingProtocol(ProtobufProtocol):
177
190
  new_msg.configuration.encoding.type = Options.Encoding.ENCODING_TYPE_HEXADECIMAL
178
191
  new_msg.configuration.encoding.symbol_length = 6
179
192
  elif msg.HasField("configuration_ack"):
180
- if self._on_pairing_started:
193
+ if self._on_pairing_started and not self._on_pairing_started.done():
181
194
  self._on_pairing_started.set_result(True)
182
195
  return
183
196
  elif msg.HasField("secret_ack"):
184
- if self._on_pairing_finished:
197
+ if self._on_pairing_finished and not self._on_pairing_finished.done():
185
198
  self._on_pairing_finished.set_result(True)
186
199
  return
187
200
  else:
@@ -15,7 +15,7 @@ from google.protobuf.message import DecodeError
15
15
 
16
16
  from .base import ProtobufProtocol
17
17
  from .const import LOGGER
18
- from .exceptions import ConnectionClosed
18
+ from .exceptions import ConnectionClosed, VoiceSessionInProgress
19
19
  from .remotemessage_pb2 import (
20
20
  RemoteDirection,
21
21
  RemoteEditInfo,
@@ -115,9 +115,22 @@ class RemoteProtocol(ProtobufProtocol):
115
115
  self._loop = loop
116
116
  self._idle_disconnect_task: asyncio.Task[None] | None = None
117
117
  self._reset_idle_disconnect_task()
118
- self._voice_lock = asyncio.Lock()
118
+ self._voice_starting = False
119
+ self._voice_session_id: int | None = None
119
120
  self._on_voice_begin: asyncio.Future[int] | None = None
120
121
 
122
+ def connection_lost(self, exc: Exception | None) -> None:
123
+ """Cancel the idle disconnect task and drop any voice session."""
124
+ self._cancel_idle_disconnect_task()
125
+ self._voice_session_id = None
126
+ super().connection_lost(exc)
127
+
128
+ def close(self) -> None:
129
+ """Close the connection and stop the idle disconnect task."""
130
+ self._cancel_idle_disconnect_task()
131
+ if self.transport and not self.transport.is_closing():
132
+ self.transport.close()
133
+
121
134
  @property
122
135
  def is_voice_enabled(self) -> bool:
123
136
  """Voice commands enabled.
@@ -134,7 +147,7 @@ class RemoteProtocol(ProtobufProtocol):
134
147
 
135
148
  :param key_code: int (e.g. 26) or str (e.g. "KEYCODE_POWER" or just "POWER") from the enum
136
149
  RemoteKeyCode in remotemessage.proto or str prefixed with "text:" to pass
137
- to send_text.
150
+ to send_text. Key codes and directions are case insensitive.
138
151
  :param direction: "SHORT" (default) or "START_LONG" or "END_LONG".
139
152
  :raises ValueError: if key_code in str or direction isn't known.
140
153
  """
@@ -143,11 +156,12 @@ class RemoteProtocol(ProtobufProtocol):
143
156
  if isinstance(key_code, str):
144
157
  if key_code.lower().startswith(TEXT_PREFIX):
145
158
  return self.send_text(key_code[len(TEXT_PREFIX) :])
159
+ key_code = key_code.upper()
146
160
  if not key_code.startswith(KEYCODE_PREFIX):
147
161
  key_code = KEYCODE_PREFIX + key_code
148
162
  key_code = RemoteKeyCode.Value(key_code)
149
163
  if isinstance(direction, str):
150
- direction = RemoteDirection.Value(direction)
164
+ direction = RemoteDirection.Value(direction.upper())
151
165
  msg.remote_key_inject.key_code = key_code # type: ignore[assignment]
152
166
  msg.remote_key_inject.direction = direction # type: ignore[assignment]
153
167
  self._send_message(msg)
@@ -156,7 +170,7 @@ class RemoteProtocol(ProtobufProtocol):
156
170
  def send_text(self, text: str) -> None:
157
171
  """Send a text string to Android TV via the input method.
158
172
 
159
- The text length is used for both `start` and `end` in the RemoteImeObject.
173
+ The text length minus one is used for both `start` and `end` in the RemoteImeObject.
160
174
  The `ime_counter` and `ime_field_counter` values are taken from self (batch_edit_info response),
161
175
  which is populated when a message with a remote_ime_batch_edit field is received.
162
176
 
@@ -195,20 +209,22 @@ class RemoteProtocol(ProtobufProtocol):
195
209
  initial ``remote_voice_begin`` message back to the device, as required by the
196
210
  protocol, so the caller can start streaming audio chunks.
197
211
 
212
+ The session stays open until :meth:`end_voice` is called for it, and only one
213
+ session can be open at a time.
214
+
198
215
  :param timeout: Optional timeout in seconds for session readiness. Defaults to 2 seconds.
199
216
  :raises ConnectionClosed: If the connection is lost.
200
- :raises asyncio.TimeoutError: If the operation times out or a voice session is already in
201
- progress.
217
+ :raises VoiceSessionInProgress: If a voice session is already in progress.
218
+ :raises asyncio.TimeoutError: If the operation times out.
202
219
  :return: The voice session id, which must be used in later calls to ``send_voice_chunk``.
203
220
  """
204
221
  if self.transport is None or self.transport.is_closing():
205
222
  raise ConnectionClosed("Connection has been lost")
206
223
 
207
- if self._voice_lock.locked():
208
- raise asyncio.TimeoutError("Voice session already in progress")
209
-
210
- await self._voice_lock.acquire()
224
+ if self._voice_starting or self._voice_session_id is not None:
225
+ raise VoiceSessionInProgress("Voice session already in progress")
211
226
 
227
+ self._voice_starting = True
212
228
  self._on_voice_begin = self._loop.create_future()
213
229
  try:
214
230
  self.send_key_command(RemoteKeyCode.KEYCODE_SEARCH)
@@ -219,12 +235,11 @@ class RemoteProtocol(ProtobufProtocol):
219
235
  begin = RemoteMessage()
220
236
  begin.remote_voice_begin.session_id = session_id
221
237
  self._send_message(begin)
222
- return int(session_id)
223
- except:
224
- self._on_voice_begin = None
225
- raise
238
+ self._voice_session_id = int(session_id)
239
+ return self._voice_session_id
226
240
  finally:
227
- self._voice_lock.release()
241
+ self._voice_starting = False
242
+ self._on_voice_begin = None
228
243
 
229
244
  def send_voice_chunk(self, chunk: bytes, session_id: int) -> None:
230
245
  """Send a chunk of PCM audio for the active voice session.
@@ -236,15 +251,16 @@ class RemoteProtocol(ProtobufProtocol):
236
251
  if self.transport is None or self.transport.is_closing():
237
252
  raise ConnectionClosed("Connection has been lost")
238
253
 
239
- # Pad chunk to minimum chunk size
240
- if len(chunk) < VOICE_CHUNK_MIN_SIZE:
241
- chunk = chunk + b"\x00" * (VOICE_CHUNK_MIN_SIZE - len(chunk))
242
-
243
254
  # Limit chunk size, otherwise Android TV will close the connection
244
255
  for i in range(0, len(chunk), VOICE_CHUNK_SIZE):
256
+ samples = bytes(chunk[i : i + VOICE_CHUNK_SIZE])
257
+ # Pad to the minimum chunk size. This has to happen after splitting, since
258
+ # the last piece of a larger chunk can be smaller than the minimum as well.
259
+ if len(samples) < VOICE_CHUNK_MIN_SIZE:
260
+ samples = samples.ljust(VOICE_CHUNK_MIN_SIZE, b"\x00")
245
261
  msg = RemoteMessage()
246
262
  msg.remote_voice_payload.session_id = session_id
247
- msg.remote_voice_payload.samples = chunk[i : i + VOICE_CHUNK_SIZE]
263
+ msg.remote_voice_payload.samples = samples
248
264
  self._send_message(msg, False) # disable logging of voice data
249
265
 
250
266
  def end_voice(self, session_id: int) -> None:
@@ -255,6 +271,8 @@ class RemoteProtocol(ProtobufProtocol):
255
271
  end = RemoteMessage()
256
272
  end.remote_voice_end.session_id = session_id
257
273
  self._send_message(end)
274
+ if self._voice_session_id == session_id:
275
+ self._voice_session_id = None
258
276
 
259
277
  def _handle_message(self, raw_msg: bytes) -> None: # noqa: PLR0912,PLR0915
260
278
  """Handle a message from the server."""
@@ -313,6 +331,11 @@ class RemoteProtocol(ProtobufProtocol):
313
331
  elif msg.HasField("remote_ping_request"):
314
332
  new_msg.remote_ping_response.val1 = msg.remote_ping_request.val1
315
333
  log_send = LOG_PING_REQUESTS
334
+ elif msg.HasField("remote_error"):
335
+ LOGGER.error(
336
+ "Received an error from the device: %s",
337
+ text_format.MessageToString(msg.remote_error, as_one_line=True),
338
+ )
316
339
  elif msg.HasField("remote_voice_begin"):
317
340
  if self._on_voice_begin and not self._on_voice_begin.done():
318
341
  self._on_voice_begin.set_result(msg.remote_voice_begin.session_id)
@@ -324,9 +347,16 @@ class RemoteProtocol(ProtobufProtocol):
324
347
  if new_msg != RemoteMessage():
325
348
  self._send_message(new_msg, log_send)
326
349
 
327
- def _reset_idle_disconnect_task(self) -> None:
350
+ def _cancel_idle_disconnect_task(self) -> None:
328
351
  if self._idle_disconnect_task is not None:
329
352
  self._idle_disconnect_task.cancel()
353
+ self._idle_disconnect_task = None
354
+
355
+ def _reset_idle_disconnect_task(self) -> None:
356
+ self._cancel_idle_disconnect_task()
357
+ if self.transport is not None and self.transport.is_closing():
358
+ # Don't keep a task alive for a connection that is going away.
359
+ return
330
360
  self._idle_disconnect_task = self._loop.create_task(self._async_idle_disconnect())
331
361
 
332
362
  async def _async_idle_disconnect(self) -> None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: androidtvremote2
3
- Version: 0.3.0
3
+ Version: 0.3.2
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
6
  License-Expression: Apache-2.0
@@ -14,6 +14,7 @@ Requires-Dist: cryptography>=3
14
14
  Requires-Dist: protobuf>=4.21
15
15
  Provides-Extra: test
16
16
  Requires-Dist: pytest; extra == "test"
17
+ Requires-Dist: pytest-asyncio; extra == "test"
17
18
  Provides-Extra: demo
18
19
  Requires-Dist: pyaudio; extra == "demo"
19
20
  Requires-Dist: pynput; extra == "demo"
@@ -1,7 +1,6 @@
1
1
  LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
- src/demo.py
5
4
  src/androidtvremote2/__init__.py
6
5
  src/androidtvremote2/androidtv_remote.py
7
6
  src/androidtvremote2/base.py
@@ -22,4 +21,9 @@ src/androidtvremote2.egg-info/SOURCES.txt
22
21
  src/androidtvremote2.egg-info/dependency_links.txt
23
22
  src/androidtvremote2.egg-info/requires.txt
24
23
  src/androidtvremote2.egg-info/top_level.txt
25
- tests/test_androidtv_remote.py
24
+ tests/test_androidtv_remote.py
25
+ tests/test_base.py
26
+ tests/test_certificate_generator.py
27
+ tests/test_pairing.py
28
+ tests/test_remote.py
29
+ tests/test_voice_stream.py
@@ -9,3 +9,4 @@ zeroconf
9
9
 
10
10
  [test]
11
11
  pytest
12
+ pytest-asyncio