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.
- {androidtvremote2-0.3.1/src/androidtvremote2.egg-info → androidtvremote2-0.3.3}/PKG-INFO +3 -2
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/README.md +1 -1
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/pyproject.toml +12 -1
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/src/androidtvremote2/__init__.py +2 -1
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/src/androidtvremote2/androidtv_remote.py +48 -21
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/src/androidtvremote2/base.py +28 -18
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/src/androidtvremote2/certificate_generator.py +3 -3
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/src/androidtvremote2/exceptions.py +4 -0
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/src/androidtvremote2/model.py +21 -21
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/src/androidtvremote2/pairing.py +29 -11
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/src/androidtvremote2/remote.py +72 -29
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.3/src/androidtvremote2.egg-info}/PKG-INFO +3 -2
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/src/androidtvremote2.egg-info/SOURCES.txt +6 -2
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/src/androidtvremote2.egg-info/requires.txt +1 -0
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/src/androidtvremote2.egg-info/top_level.txt +0 -1
- androidtvremote2-0.3.3/tests/test_androidtv_remote.py +415 -0
- androidtvremote2-0.3.3/tests/test_base.py +210 -0
- androidtvremote2-0.3.3/tests/test_certificate_generator.py +68 -0
- androidtvremote2-0.3.3/tests/test_pairing.py +292 -0
- androidtvremote2-0.3.3/tests/test_remote.py +602 -0
- androidtvremote2-0.3.3/tests/test_voice_stream.py +88 -0
- androidtvremote2-0.3.1/src/demo.py +0 -408
- androidtvremote2-0.3.1/tests/test_androidtv_remote.py +0 -6
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/LICENSE +0 -0
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/setup.cfg +0 -0
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/src/androidtvremote2/const.py +0 -0
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/src/androidtvremote2/polo_pb2.py +0 -0
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/src/androidtvremote2/polo_pb2.pyi +0 -0
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/src/androidtvremote2/py.typed +0 -0
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/src/androidtvremote2/remotemessage_pb2.py +0 -0
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/src/androidtvremote2/remotemessage_pb2.pyi +0 -0
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/src/androidtvremote2/voice_stream.py +0 -0
- {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.
|
|
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/
|
|
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/
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
|
430
|
-
|
|
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.
|
|
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
|
-
"""
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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."""
|
{androidtvremote2-0.3.1 → androidtvremote2-0.3.3}/src/androidtvremote2/certificate_generator.py
RENAMED
|
@@ -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
|
|
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.
|
|
32
|
+
now = datetime.now(timezone.utc)
|
|
33
33
|
cert = (
|
|
34
34
|
x509.CertificateBuilder()
|
|
35
35
|
.subject_name(name)
|
|
@@ -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(
|
|
113
|
-
h.update(
|
|
114
|
-
h.update(
|
|
115
|
-
h.update(
|
|
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
|
|
134
|
-
|
|
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:
|