androidtvremote2 0.3.1__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.
- {androidtvremote2-0.3.1/src/androidtvremote2.egg-info → androidtvremote2-0.3.2}/PKG-INFO +2 -1
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.2}/pyproject.toml +12 -1
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.2}/src/androidtvremote2/__init__.py +2 -1
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.2}/src/androidtvremote2/androidtv_remote.py +35 -18
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.2}/src/androidtvremote2/base.py +28 -18
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.2}/src/androidtvremote2/certificate_generator.py +2 -2
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.2}/src/androidtvremote2/exceptions.py +4 -0
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.2}/src/androidtvremote2/pairing.py +20 -7
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.2}/src/androidtvremote2/remote.py +52 -22
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.2/src/androidtvremote2.egg-info}/PKG-INFO +2 -1
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.2}/src/androidtvremote2.egg-info/SOURCES.txt +6 -2
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.2}/src/androidtvremote2.egg-info/requires.txt +1 -0
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.2}/src/androidtvremote2.egg-info/top_level.txt +0 -1
- androidtvremote2-0.3.2/tests/test_androidtv_remote.py +361 -0
- androidtvremote2-0.3.2/tests/test_base.py +210 -0
- androidtvremote2-0.3.2/tests/test_certificate_generator.py +68 -0
- androidtvremote2-0.3.2/tests/test_pairing.py +271 -0
- androidtvremote2-0.3.2/tests/test_remote.py +559 -0
- androidtvremote2-0.3.2/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.2}/LICENSE +0 -0
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.2}/README.md +0 -0
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.2}/setup.cfg +0 -0
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.2}/src/androidtvremote2/const.py +0 -0
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.2}/src/androidtvremote2/model.py +0 -0
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.2}/src/androidtvremote2/polo_pb2.py +0 -0
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.2}/src/androidtvremote2/polo_pb2.pyi +0 -0
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.2}/src/androidtvremote2/py.typed +0 -0
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.2}/src/androidtvremote2/remotemessage_pb2.py +0 -0
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.2}/src/androidtvremote2/remotemessage_pb2.pyi +0 -0
- {androidtvremote2-0.3.1 → androidtvremote2-0.3.2}/src/androidtvremote2/voice_stream.py +0 -0
- {androidtvremote2-0.3.1 → 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.
|
|
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.
|
|
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
|
]
|
|
@@ -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
|
|
@@ -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:
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
430
|
-
|
|
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.
|
|
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.2}/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
|
|
@@ -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)
|
|
@@ -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
|
|
134
|
-
|
|
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.
|
|
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
|
|
201
|
-
|
|
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.
|
|
208
|
-
raise
|
|
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
|
-
|
|
223
|
-
|
|
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.
|
|
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 =
|
|
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
|
|
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.
|
|
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
|