androidtvremote2 0.3.1__tar.gz → 0.3.3__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.1/src/androidtvremote2.egg-info → androidtvremote2-0.3.3}/PKG-INFO +3 -2
  2. {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/README.md +1 -1
  3. {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/pyproject.toml +12 -1
  4. {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/src/androidtvremote2/__init__.py +2 -1
  5. {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/src/androidtvremote2/androidtv_remote.py +48 -21
  6. {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/src/androidtvremote2/base.py +28 -18
  7. {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/src/androidtvremote2/certificate_generator.py +3 -3
  8. {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/src/androidtvremote2/exceptions.py +4 -0
  9. {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/src/androidtvremote2/model.py +21 -21
  10. {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/src/androidtvremote2/pairing.py +29 -11
  11. {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/src/androidtvremote2/remote.py +72 -29
  12. {androidtvremote2-0.3.1 → androidtvremote2-0.3.3/src/androidtvremote2.egg-info}/PKG-INFO +3 -2
  13. {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/src/androidtvremote2.egg-info/SOURCES.txt +6 -2
  14. {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/src/androidtvremote2.egg-info/requires.txt +1 -0
  15. {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/src/androidtvremote2.egg-info/top_level.txt +0 -1
  16. androidtvremote2-0.3.3/tests/test_androidtv_remote.py +415 -0
  17. androidtvremote2-0.3.3/tests/test_base.py +210 -0
  18. androidtvremote2-0.3.3/tests/test_certificate_generator.py +68 -0
  19. androidtvremote2-0.3.3/tests/test_pairing.py +292 -0
  20. androidtvremote2-0.3.3/tests/test_remote.py +602 -0
  21. androidtvremote2-0.3.3/tests/test_voice_stream.py +88 -0
  22. androidtvremote2-0.3.1/src/demo.py +0 -408
  23. androidtvremote2-0.3.1/tests/test_androidtv_remote.py +0 -6
  24. {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/LICENSE +0 -0
  25. {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/setup.cfg +0 -0
  26. {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/src/androidtvremote2/const.py +0 -0
  27. {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/src/androidtvremote2/polo_pb2.py +0 -0
  28. {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/src/androidtvremote2/polo_pb2.pyi +0 -0
  29. {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/src/androidtvremote2/py.typed +0 -0
  30. {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/src/androidtvremote2/remotemessage_pb2.py +0 -0
  31. {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/src/androidtvremote2/remotemessage_pb2.pyi +0 -0
  32. {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/src/androidtvremote2/voice_stream.py +0 -0
  33. {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/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.1
3
+ Version: 0.3.3
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"
@@ -25,7 +26,7 @@ Dynamic: license-file
25
26
  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.
26
27
 
27
28
  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).
28
- For a full list see [remotemessage.proto](https://github.com/tronikos/androidtvremote2/blob/b4c49ac03043b1b9c40c2f2960e466d5a3b8bd67/src/androidtvremote2/remotemessage.proto#L90).
29
+ For a full list see [remotemessage.proto](https://github.com/tronikos/androidtvremote2/blob/main/src/androidtvremote2/remotemessage.proto).
29
30
  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.
30
31
 
31
32
  Voice commands can also be sent as PCM 16-bit mono 8 kHz audio data.
@@ -3,7 +3,7 @@
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 [remotemessage.proto](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/main/src/androidtvremote2/remotemessage.proto).
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
9
  Voice commands can also be sent as PCM 16-bit mono 8 kHz audio data.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "androidtvremote2"
3
- version = "0.3.1"
3
+ version = "0.3.3"
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
  ]
@@ -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
@@ -52,7 +77,7 @@ class AndroidTVRemote:
52
77
 
53
78
  :param client_name: client name. Will be shown on the Android TV during pairing.
54
79
  :param certfile: filename that contains the client certificate in PEM format.
55
- :param keyfile: filename that contains the public key in PEM format.
80
+ :param keyfile: filename that contains the private key in PEM format.
56
81
  :param host: IP address of the Android TV.
57
82
  :param api_port: port for connecting and sending commands.
58
83
  :param pair_port: port for pairing.
@@ -185,7 +210,7 @@ class AndroidTVRemote:
185
210
  self._is_available_updated_callbacks.remove(callback)
186
211
 
187
212
  async def async_generate_cert_if_missing(self) -> bool:
188
- """Generate client certificate and public key if missing.
213
+ """Generate client certificate and private key if missing.
189
214
 
190
215
  :returns: True if a new certificate was generated.
191
216
  """
@@ -196,6 +221,8 @@ class AndroidTVRemote:
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:
@@ -217,6 +244,8 @@ class AndroidTVRemote:
217
244
  :raises InvalidAuth: if pairing is needed first.
218
245
  """
219
246
  ssl_context = await self._create_ssl_context()
247
+ if self._remote_message_protocol:
248
+ self._remote_message_protocol.close()
220
249
  on_con_lost = self._loop.create_future()
221
250
  on_remote_started = self._loop.create_future()
222
251
  try:
@@ -244,8 +273,13 @@ class AndroidTVRemote:
244
273
  raise InvalidAuth("Need to pair") from exc
245
274
  raise CannotConnect(f"Couldn't connect to {self.host}:{self._api_port}") from exc
246
275
 
247
- await asyncio.wait((on_con_lost, on_remote_started), return_when=asyncio.FIRST_COMPLETED)
276
+ try:
277
+ await asyncio.wait((on_con_lost, on_remote_started), return_when=asyncio.FIRST_COMPLETED)
278
+ except BaseException:
279
+ self._remote_message_protocol.close()
280
+ raise
248
281
  if on_con_lost.done():
282
+ self._remote_message_protocol.close()
249
283
  con_lost_exc = on_con_lost.result()
250
284
  LOGGER.debug(
251
285
  "Couldn't connect to %s:%s. Error: %s",
@@ -290,15 +324,17 @@ class AndroidTVRemote:
290
324
 
291
325
  def keep_reconnecting(self, invalid_auth_callback: Callable[[], None] | None = None) -> None:
292
326
  """Create a task to keep reconnecting whenever connection is lost."""
327
+ if self._reconnect_task:
328
+ self._reconnect_task.cancel()
293
329
  self._reconnect_task = self._loop.create_task(self._async_reconnect(invalid_auth_callback))
294
330
 
295
331
  def disconnect(self) -> None:
296
332
  """Disconnect any open connections."""
297
333
  if self._reconnect_task:
298
334
  self._reconnect_task.cancel()
335
+ self._reconnect_task = None
299
336
  if self._remote_message_protocol:
300
- if self._remote_message_protocol.transport:
301
- self._remote_message_protocol.transport.close()
337
+ self._remote_message_protocol.close()
302
338
  self._remote_message_protocol = None
303
339
  if self._pairing_message_protocol:
304
340
  if self._pairing_message_protocol.transport:
@@ -318,20 +354,11 @@ class AndroidTVRemote:
318
354
  raise CannotConnect from exc
319
355
  server_cert_bytes = writer.transport.get_extra_info("ssl_object").getpeercert(True)
320
356
  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
357
+ try:
358
+ await writer.wait_closed()
359
+ except OSError as exc:
360
+ LOGGER.debug("Error closing connection to %s:%s. %s", self.host, self._pair_port, exc)
361
+ return _parse_name_and_mac(x509.load_der_x509_certificate(server_cert_bytes))
335
362
 
336
363
  async def async_start_pairing(self) -> None:
337
364
  """Start the pairing process.
@@ -426,8 +453,8 @@ class AndroidTVRemote:
426
453
 
427
454
  :param timeout: optional timeout for session readiness. Defaults to 2 seconds.
428
455
  :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.
456
+ :raises VoiceSessionInProgress: if a voice session is already in progress.
457
+ :raises asyncio.TimeoutError: if the device does not begin voice in time.
431
458
  """
432
459
  if not self._remote_message_protocol:
433
460
  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
@@ -18,7 +18,7 @@ from cryptography.x509.oid import NameOID
18
18
  def generate_selfsigned_cert(hostname: str) -> tuple[bytes, bytes]:
19
19
  """Generate self signed certificate for a hostname.
20
20
 
21
- :return: self signed certificate and public key in PEM format
21
+ :return: self signed certificate and private key in PEM format
22
22
  """
23
23
  key = rsa.generate_private_key(
24
24
  public_exponent=65537,
@@ -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."""
@@ -1,21 +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
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
@@ -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."""
@@ -30,6 +34,11 @@ def _create_message() -> OuterMessage:
30
34
  return msg
31
35
 
32
36
 
37
+ def _int_to_bytes(val: int) -> bytes:
38
+ """Convert a positive integer to big-endian bytes."""
39
+ return val.to_bytes((val.bit_length() + 7) // 8, "big")
40
+
41
+
33
42
  def _get_modulus_and_exponent(cert: x509.Certificate) -> tuple[int, int]:
34
43
  """Extract modulus and exponent from a certificate."""
35
44
  public_numbers = cert.public_key().public_numbers() # type: ignore[union-attr]
@@ -69,7 +78,7 @@ class PairingProtocol(ProtobufProtocol):
69
78
  async def async_start_pairing(self) -> None:
70
79
  """Start the pairing process.
71
80
 
72
- :raises ConnectionClosed: if connection was lost.
81
+ :raises ConnectionClosed: if connection was lost or the device didn't respond in time.
73
82
  """
74
83
  self._raise_if_not_connected()
75
84
  msg = _create_message()
@@ -86,7 +95,7 @@ class PairingProtocol(ProtobufProtocol):
86
95
  """Finish the pairing process.
87
96
 
88
97
  :param pairing_code: pairing code shown on the Android TV.
89
- :raises ConnectionClosed: if connection was lost.
98
+ :raises ConnectionClosed: if connection was lost or the device didn't respond in time.
90
99
  :raises InvalidAuth: if pairing was unsuccessful.
91
100
  """
92
101
  self._raise_if_not_connected()
@@ -109,10 +118,10 @@ class PairingProtocol(ProtobufProtocol):
109
118
  server_modulus, server_exponent = _get_modulus_and_exponent(server_cert)
110
119
 
111
120
  h = hashlib.sha256()
112
- h.update(bytes.fromhex(f"{client_modulus:X}"))
113
- h.update(bytes.fromhex(f"0{client_exponent:X}"))
114
- h.update(bytes.fromhex(f"{server_modulus:X}"))
115
- h.update(bytes.fromhex(f"0{server_exponent:X}"))
121
+ h.update(_int_to_bytes(client_modulus))
122
+ h.update(_int_to_bytes(client_exponent))
123
+ h.update(_int_to_bytes(server_modulus))
124
+ h.update(_int_to_bytes(server_exponent))
116
125
  h.update(bytes.fromhex(pairing_code[2:]))
117
126
  hash_result = h.digest()
118
127
 
@@ -129,9 +138,18 @@ class PairingProtocol(ProtobufProtocol):
129
138
  finally:
130
139
  self._on_pairing_finished = None
131
140
 
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)
141
+ async def _async_wait_for_future_or_con_lost(self, future: asyncio.Future[Any], timeout: float = PAIRING_TIMEOUT) -> None:
142
+ """Wait for future to finish, connection to be lost, or timeout to elapse.
143
+
144
+ :raises ConnectionClosed: if the connection was lost or the device stopped responding.
145
+ """
146
+ done, _pending = await asyncio.wait((self.on_con_lost, future), timeout=timeout, return_when=asyncio.FIRST_COMPLETED)
147
+ if not done:
148
+ LOGGER.debug("Timeout after %s seconds waiting for a response while pairing", timeout)
149
+ future.cancel()
150
+ if self.transport:
151
+ self.transport.close()
152
+ raise ConnectionClosed(f"No response from the Android TV within {timeout} seconds")
135
153
  if future.done():
136
154
  if future.exception():
137
155
  raise ConnectionClosed(future.exception())
@@ -177,11 +195,11 @@ class PairingProtocol(ProtobufProtocol):
177
195
  new_msg.configuration.encoding.type = Options.Encoding.ENCODING_TYPE_HEXADECIMAL
178
196
  new_msg.configuration.encoding.symbol_length = 6
179
197
  elif msg.HasField("configuration_ack"):
180
- if self._on_pairing_started:
198
+ if self._on_pairing_started and not self._on_pairing_started.done():
181
199
  self._on_pairing_started.set_result(True)
182
200
  return
183
201
  elif msg.HasField("secret_ack"):
184
- if self._on_pairing_finished:
202
+ if self._on_pairing_finished and not self._on_pairing_finished.done():
185
203
  self._on_pairing_finished.set_result(True)
186
204
  return
187
205
  else: