From 215bed68e8e2e6f9a80b4265d33e11dd3ff56bc0 Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Thu, 30 Oct 2025 14:26:41 +0100 Subject: [PATCH 1/2] Add streaming voice command support Support sending voice commands with the RemoteVoice* messages. Audio data needs to be 16-bit PCM, mono, 8000 Hz. The Android TV Remote service appears to support audio configuration in the RemoteVoiceBegin message, but no information could be found so far. Add voice features with PyAudio to the demo application to easily test the voice command feature: - Record and stream voice from the default audio input - Record a playback a local WAV file - Send a pre-recorded WAV file as a voice command No new dependencies are required for the library, only for the demo app. --- .gitignore | 3 + README.md | 6 + pyproject.toml | 5 + src/androidtvremote2/__init__.py | 2 + src/androidtvremote2/androidtv_remote.py | 34 +++- src/androidtvremote2/remote.py | 128 ++++++++++++++- src/androidtvremote2/remotemessage.proto | 11 +- src/androidtvremote2/remotemessage_pb2.py | 84 +++++----- src/androidtvremote2/remotemessage_pb2.pyi | 25 +++ src/androidtvremote2/voice_stream.py | 74 +++++++++ src/demo.py | 181 ++++++++++++++++++++- 11 files changed, 501 insertions(+), 52 deletions(-) create mode 100644 src/androidtvremote2/voice_stream.py diff --git a/.gitignore b/.gitignore index 67da198..191f6d6 100644 --- a/.gitignore +++ b/.gitignore @@ -130,3 +130,6 @@ dmypy.json # Certificates generated by demo.py *.pem + +# recorded voice command with demo app +voice_command.wav diff --git a/README.md b/README.md index 9b8daf0..10f1077 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ For a list of the most common commands you can send to the Android TV see: [TvKe For a full list see [remotemessage.proto](https://github.com/tronikos/androidtvremote2/blob/b4c49ac03043b1b9c40c2f2960e466d5a3b8bd67/src/androidtvremote2/remotemessage.proto#L90). 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. +Voice commands can also be sent as PCM 16-bit mono 8 kHz audio data. + ## Credits - Official [implementation](https://android.googlesource.com/platform/external/google-tv-pairing-protocol/+/refs/heads/master) of the pairing protocol in Java @@ -52,3 +54,7 @@ python src/demo.py python -m pip install build python -m build ``` + +The voice demo requires the [PyAudio](https://pypi.org/project/PyAudio/) library. +Depending on the target platform, [PortAudio](https://www.portaudio.com/) might have to be installed manually, +see [PyAudio installation](https://people.csail.mit.edu/hubert/pyaudio/) for more information. diff --git a/pyproject.toml b/pyproject.toml index 631bee4..b990dc6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ test = [ "pytest", ] demo = [ + "pyaudio", "pynput", "zeroconf", ] @@ -87,6 +88,10 @@ show_error_codes = true warn_incomplete_stub = true enable_error_code = ["ignore-without-code", "redundant-self", "truthy-iterable"] +[[tool.mypy.overrides]] +module = ["pyaudio"] +ignore_missing_imports = true + [tool.codespell] skip = "*.json,*.csv,*.lock,./.git/*,./.venv/*" check-filenames = true diff --git a/src/androidtvremote2/__init__.py b/src/androidtvremote2/__init__.py index bf6c514..98a4434 100644 --- a/src/androidtvremote2/__init__.py +++ b/src/androidtvremote2/__init__.py @@ -3,6 +3,7 @@ from .androidtv_remote import AndroidTVRemote from .exceptions import CannotConnect, ConnectionClosed, InvalidAuth from .model import DeviceInfo, VolumeInfo +from .voice_stream import VoiceStream __all__ = [ "AndroidTVRemote", @@ -10,5 +11,6 @@ "ConnectionClosed", "DeviceInfo", "InvalidAuth", + "VoiceStream", "VolumeInfo", ] diff --git a/src/androidtvremote2/androidtv_remote.py b/src/androidtvremote2/androidtv_remote.py index a29cb98..c1354f4 100644 --- a/src/androidtvremote2/androidtv_remote.py +++ b/src/androidtvremote2/androidtv_remote.py @@ -15,8 +15,9 @@ from .const import LOGGER from .exceptions import CannotConnect, ConnectionClosed, InvalidAuth from .pairing import PairingProtocol -from .remote import RemoteProtocol +from .remote import VOICE_SESSION_TIMEOUT, RemoteProtocol from .remotemessage_pb2 import RemoteDirection +from .voice_stream import VoiceStream if TYPE_CHECKING: from collections.abc import Callable @@ -45,6 +46,7 @@ def __init__( pair_port: int = 6467, loop: asyncio.AbstractEventLoop | None = None, enable_ime: bool = True, + enable_voice: bool = False, ) -> None: """Initialize. @@ -57,6 +59,7 @@ def __init__( :param loop: event loop. Used for connections and futures. :param enable_ime: Needed for getting current_app. Disable for devices that show 'Use keyboard on mobile device screen'. + :param enable_voice: Enable sending voice commands to the device. """ self._client_name = client_name self._certfile = certfile @@ -66,6 +69,7 @@ def __init__( self._pair_port = pair_port self._loop = loop or asyncio.get_running_loop() self._enable_ime = enable_ime + self._enable_voice = enable_voice self._transport: asyncio.Transport | None = None self._remote_message_protocol: RemoteProtocol | None = None self._pairing_message_protocol: PairingProtocol | None = None @@ -125,6 +129,17 @@ def volume_info(self) -> VolumeInfo | None: return None return self._remote_message_protocol.volume_info + @property + def is_voice_enabled(self) -> bool | None: + """Whether voice commands are enabled on the Android TV. + + Depends on the requested feature at AndroidTVRemote initialization and the supported + features of the device. + """ + if not self._remote_message_protocol: + return None + return self._remote_message_protocol.is_voice_enabled + def add_is_on_updated_callback(self, callback: Callable[[bool], None]) -> None: """Add a callback for when is_on is updated.""" self._is_on_updated_callbacks.append(callback) @@ -217,6 +232,7 @@ async def async_connect(self) -> None: self._on_volume_info_updated, self._loop, self._enable_ime, + self._enable_voice, ), self.host, self._api_port, @@ -400,3 +416,19 @@ def send_launch_app_command(self, app_link_or_app_id: str) -> None: raise ConnectionClosed("Called send_launch_app_command after disconnect") prefix = "" if urlparse(app_link_or_app_id).scheme else "market://launch?id=" self._remote_message_protocol.send_launch_app_command(f"{prefix}{app_link_or_app_id}") + + async def start_voice(self, timeout: float = VOICE_SESSION_TIMEOUT) -> VoiceStream: + """Start a streaming voice session. + + A ``VoiceStream`` wrapper is returned if the voice session can be established within the + given timeout. + + :param timeout: optional timeout for session readiness. Defaults to 2 seconds. + :raises ConnectionClosed: if client is disconnected. + :raises asyncio.TimeoutError: if the device does not begin voice in time. + """ + if not self._remote_message_protocol: + LOGGER.debug("Called start_voice after disconnect") + raise ConnectionClosed("Called start_voice after disconnect") + session_id = await self._remote_message_protocol.start_voice(timeout) + return VoiceStream(self._remote_message_protocol, session_id) diff --git a/src/androidtvremote2/remote.py b/src/androidtvremote2/remote.py index 5dc545f..0b3eef9 100644 --- a/src/androidtvremote2/remote.py +++ b/src/androidtvremote2/remote.py @@ -8,13 +8,14 @@ import asyncio from enum import IntFlag -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from google.protobuf import text_format from google.protobuf.message import DecodeError from .base import ProtobufProtocol from .const import LOGGER +from .exceptions import ConnectionClosed from .remotemessage_pb2 import ( RemoteDirection, RemoteEditInfo, @@ -38,6 +39,13 @@ KEYCODE_PREFIX = "KEYCODE_" TEXT_PREFIX = "text:" +# Timeout in seconds to wait for `remote_voice_begin` after sending KEYCODE_SEARCH. +VOICE_SESSION_TIMEOUT = 2.0 +# Voice data chunk size in bytes for the `remote_voice_payload` message. +VOICE_CHUNK_SIZE = 20 * 1024 +# Minimum voice data chunk size in bytes. Shield TV did not accept lower chunk sizes. +VOICE_CHUNK_MIN_SIZE = 8 * 1024 + class Feature(IntFlag): """Supported features.""" @@ -45,6 +53,9 @@ class Feature(IntFlag): PING = 2**0 KEY = 2**1 IME = 2**2 + VOICE = 2**3 + """Enables remote_voice_begin after sending KEYCODE_SEARCH""" + UNKNOWN_1 = 2**4 POWER = 2**5 VOLUME = 2**6 APP_LINK = 2**9 @@ -67,6 +78,7 @@ def __init__( on_volume_info_updated: Callable[[VolumeInfo], None], loop: asyncio.AbstractEventLoop, enable_ime: bool, + enable_voice: bool, ) -> None: """Initialize. @@ -78,6 +90,7 @@ def __init__( :param loop: event loop. :param enable_ime: Needed for getting current_app. Disable for devices that show 'Use keyboard on mobile device screen'. + :param enable_voice: Enable sending voice commands to the device. """ super().__init__(on_con_lost) self._on_remote_started = on_remote_started @@ -85,7 +98,13 @@ def __init__( self._on_current_app_updated = on_current_app_updated self._on_volume_info_updated = on_volume_info_updated self._active_features = ( - Feature.PING | Feature.KEY | Feature.POWER | Feature.VOLUME | Feature.APP_LINK | (Feature.IME if enable_ime else 0) + Feature.PING + | Feature.KEY + | Feature.POWER + | Feature.VOLUME + | Feature.APP_LINK + | (Feature.IME if enable_ime else 0) + | (Feature.VOICE if enable_voice else 0) ) self.is_on = False self.current_app = "" @@ -96,6 +115,16 @@ def __init__( self._loop = loop self._idle_disconnect_task: asyncio.Task[None] | None = None self._reset_idle_disconnect_task() + self._on_voice_begin: asyncio.Future[int] | None = None + + @property + def is_voice_enabled(self) -> bool: + """Voice commands enabled. + + Determined from requested features at initialization and the supported features on the + device. + """ + return self._active_features & Feature.VOICE == Feature.VOICE def send_key_command(self, key_code: int | str, direction: int | str = RemoteDirection.SHORT) -> None: """Send a key press to Android TV. @@ -158,7 +187,67 @@ def send_launch_app_command(self, app_link: str) -> None: msg.remote_app_link_launch_request.app_link = app_link self._send_message(msg) - def _handle_message(self, raw_msg: bytes) -> None: # noqa: PLR0912 + async def start_voice(self, timeout: float = VOICE_SESSION_TIMEOUT) -> int: + """Initiate a voice session and return the session id when ready. + + Sends ``KEYCODE_SEARCH`` and waits for ``remote_voice_begin``. Also sends the + initial ``remote_voice_begin`` message back to the device, as required by the + protocol, so the caller can start streaming audio chunks. + + :param timeout: Optional timeout in seconds for session readiness. Defaults to 2 seconds. + :raises ConnectionClosed: If the connection is lost. + :raises asyncio.TimeoutError: If the operation times out. + :return: The voice session id, which must be used in later calls to ``send_voice_chunk``. + """ + if self.transport is None or self.transport.is_closing(): + raise ConnectionClosed("Connection has been lost") + + self._on_voice_begin = self._loop.create_future() + try: + self.send_key_command(RemoteKeyCode.KEYCODE_SEARCH) + session_id = await self._async_wait_for_future_or_con_lost(self._on_voice_begin, timeout) + if session_id is None: + raise ConnectionClosed("No voice session available") + + begin = RemoteMessage() + begin.remote_voice_begin.session_id = session_id + self._send_message(begin) + return int(session_id) + except: + self._on_voice_begin = None + raise + + def send_voice_chunk(self, chunk: bytes, session_id: int) -> None: + """Send a chunk of PCM audio for the active voice session. + + :param chunk: The PCM audio data chunk to be sent. + :param session_id: The voice session id. + :raises ConnectionClosed: If the connection is lost. + """ + if self.transport is None or self.transport.is_closing(): + raise ConnectionClosed("Connection has been lost") + + # Pad chunk to minimum chunk size + if len(chunk) < VOICE_CHUNK_MIN_SIZE: + chunk = chunk + b"\x00" * (VOICE_CHUNK_MIN_SIZE - len(chunk)) + + # Limit chunk size, otherwise Android TV will close the connection + for i in range(0, len(chunk), VOICE_CHUNK_SIZE): + msg = RemoteMessage() + msg.remote_voice_payload.session_id = session_id + msg.remote_voice_payload.samples = chunk[i : i + VOICE_CHUNK_SIZE] + self._send_message(msg, False) # disable logging of voice data + + def end_voice(self, session_id: int) -> None: + """End the specified voice session. + + :param session_id: The voice session id. + """ + end = RemoteMessage() + end.remote_voice_end.session_id = session_id + self._send_message(end) + + def _handle_message(self, raw_msg: bytes) -> None: # noqa: PLR0912,PLR0915 """Handle a message from the server.""" self._reset_idle_disconnect_task() msg = RemoteMessage() @@ -215,6 +304,11 @@ def _handle_message(self, raw_msg: bytes) -> None: # noqa: PLR0912 elif msg.HasField("remote_ping_request"): new_msg.remote_ping_response.val1 = msg.remote_ping_request.val1 log_send = LOG_PING_REQUESTS + elif msg.HasField("remote_voice_begin"): + if self._on_voice_begin and not self._on_voice_begin.done(): + self._on_voice_begin.set_result(msg.remote_voice_begin.session_id) + else: + LOGGER.debug("Ignoring remote_voice_begin: no client request available") else: LOGGER.debug("Unhandled: %s", text_format.MessageToString(msg, as_one_line=True)) @@ -237,3 +331,31 @@ async def _async_idle_disconnect(self) -> None: self.transport.close() if not self.on_con_lost.done(): self.on_con_lost.set_result(Exception("Closed idle connection")) + + async def _async_wait_for_future_or_con_lost(self, future: asyncio.Future[Any], timeout: float) -> Any: + """Wait for the future to finish, connection to be lost, or timeout occurs. + + :param future: The future to wait for. + :param timeout: Timeout in seconds. + + :raises ConnectionClosed: If the connection is lost or the future has an exception. + :raises asyncio.TimeoutError: If timeout is reached before completion. + """ + tasks = {self.on_con_lost, future} + + done, _pending = await asyncio.wait(tasks, timeout=timeout, return_when=asyncio.FIRST_COMPLETED) + + # Check if timeout occurred (no tasks completed) + if not done: + if not future.done(): + future.cancel() + LOGGER.debug("Timeout reached after %s seconds", timeout) + raise asyncio.TimeoutError(f"Operation timed out after {timeout} seconds") + + # Check if future completed successfully + if future.done(): + if future.exception(): + raise ConnectionClosed(future.exception()) + return future.result() + + raise ConnectionClosed("Connection has been lost") diff --git a/src/androidtvremote2/remotemessage.proto b/src/androidtvremote2/remotemessage.proto index 1fd63fb..8925fa9 100644 --- a/src/androidtvremote2/remotemessage.proto +++ b/src/androidtvremote2/remotemessage.proto @@ -36,15 +36,20 @@ message RemoteStart { } message RemoteVoiceEnd { - + int32 session_id = 1; } message RemoteVoicePayload { - + int32 session_id = 1; + // Audio configuration in RemoteVoiceBegin is unknown. + // Default audio sample payload is a sequence of 16-bit PCM, 8 kHz, mono samples, split into 20 KB messages. + bytes samples = 2; } message RemoteVoiceBegin { - + int32 session_id = 1; + // Package name is sent from the Android device as a response to sending KEYCODE_SEARCH and not required when sending audio. + string package_name = 2; } message RemoteTextFieldStatus { diff --git a/src/androidtvremote2/remotemessage_pb2.py b/src/androidtvremote2/remotemessage_pb2.py index 3f6941f..e8127e3 100644 --- a/src/androidtvremote2/remotemessage_pb2.py +++ b/src/androidtvremote2/remotemessage_pb2.py @@ -13,17 +13,17 @@ -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x13remotemessage.proto\x12\x06remote\".\n\x1aRemoteAppLinkLaunchRequest\x12\x10\n\x08\x61pp_link\x18\x01 \x01(\t\"!\n\x1fRemoteResetPreferredAudioDevice\"\x1f\n\x1dRemoteSetPreferredAudioDevice\"\x19\n\x17RemoteAdjustVolumeLevel\"\xb4\x01\n\x14RemoteSetVolumeLevel\x12\x10\n\x08unknown1\x18\x01 \x01(\r\x12\x10\n\x08unknown2\x18\x02 \x01(\r\x12\x14\n\x0cplayer_model\x18\x03 \x01(\t\x12\x10\n\x08unknown4\x18\x04 \x01(\r\x12\x10\n\x08unknown5\x18\x05 \x01(\r\x12\x12\n\nvolume_max\x18\x06 \x01(\r\x12\x14\n\x0cvolume_level\x18\x07 \x01(\r\x12\x14\n\x0cvolume_muted\x18\x08 \x01(\x08\"\x1e\n\x0bRemoteStart\x12\x0f\n\x07started\x18\x01 \x01(\x08\"\x10\n\x0eRemoteVoiceEnd\"\x14\n\x12RemoteVoicePayload\"\x12\n\x10RemoteVoiceBegin\"v\n\x15RemoteTextFieldStatus\x12\x15\n\rcounter_field\x18\x01 \x01(\x05\x12\r\n\x05value\x18\x02 \x01(\t\x12\r\n\x05start\x18\x03 \x01(\x05\x12\x0b\n\x03\x65nd\x18\x04 \x01(\x05\x12\x0c\n\x04int5\x18\x05 \x01(\x05\x12\r\n\x05label\x18\x06 \x01(\t\"W\n\x14RemoteImeShowRequest\x12?\n\x18remote_text_field_status\x18\x02 \x01(\x0b\x32\x1d.remote.RemoteTextFieldStatus\"T\n\x0eRemoteEditInfo\x12\x0e\n\x06insert\x18\x01 \x01(\x05\x12\x32\n\x11text_field_status\x18\x02 \x01(\x0b\x32\x17.remote.RemoteImeObject\"<\n\x0fRemoteImeObject\x12\r\n\x05start\x18\x01 \x01(\x05\x12\x0b\n\x03\x65nd\x18\x02 \x01(\x05\x12\r\n\x05value\x18\x03 \x01(\t\"k\n\x12RemoteImeBatchEdit\x12\x13\n\x0bime_counter\x18\x01 \x01(\x05\x12\x15\n\rfield_counter\x18\x02 \x01(\x05\x12)\n\tedit_info\x18\x03 \x03(\x0b\x32\x16.remote.RemoteEditInfo\"\x99\x01\n\rRemoteAppInfo\x12\x0f\n\x07\x63ounter\x18\x01 \x01(\x05\x12\x0c\n\x04int2\x18\x02 \x01(\x05\x12\x0c\n\x04int3\x18\x03 \x01(\x05\x12\x0c\n\x04int4\x18\x04 \x01(\t\x12\x0c\n\x04int7\x18\x07 \x01(\x05\x12\x0c\n\x04int8\x18\x08 \x01(\x05\x12\r\n\x05label\x18\n \x01(\t\x12\x13\n\x0b\x61pp_package\x18\x0c \x01(\t\x12\r\n\x05int13\x18\r \x01(\x05\"w\n\x12RemoteImeKeyInject\x12\'\n\x08\x61pp_info\x18\x01 \x01(\x0b\x32\x15.remote.RemoteAppInfo\x12\x38\n\x11text_field_status\x18\x02 \x01(\x0b\x32\x1d.remote.RemoteTextFieldStatus\"f\n\x0fRemoteKeyInject\x12\'\n\x08key_code\x18\x01 \x01(\x0e\x32\x15.remote.RemoteKeyCode\x12*\n\tdirection\x18\x02 \x01(\x0e\x32\x17.remote.RemoteDirection\"\"\n\x12RemotePingResponse\x12\x0c\n\x04val1\x18\x01 \x01(\x05\"/\n\x11RemotePingRequest\x12\x0c\n\x04val1\x18\x01 \x01(\x05\x12\x0c\n\x04val2\x18\x02 \x01(\x05\"!\n\x0fRemoteSetActive\x12\x0e\n\x06\x61\x63tive\x18\x01 \x01(\x05\"\x80\x01\n\x10RemoteDeviceInfo\x12\r\n\x05model\x18\x01 \x01(\t\x12\x0e\n\x06vendor\x18\x02 \x01(\t\x12\x10\n\x08unknown1\x18\x03 \x01(\x05\x12\x10\n\x08unknown2\x18\x04 \x01(\t\x12\x14\n\x0cpackage_name\x18\x05 \x01(\t\x12\x13\n\x0b\x61pp_version\x18\x06 \x01(\t\"O\n\x0fRemoteConfigure\x12\r\n\x05\x63ode1\x18\x01 \x01(\x05\x12-\n\x0b\x64\x65vice_info\x18\x02 \x01(\x0b\x32\x18.remote.RemoteDeviceInfo\"D\n\x0bRemoteError\x12\r\n\x05value\x18\x01 \x01(\x08\x12&\n\x07message\x18\x02 \x01(\x0b\x32\x15.remote.RemoteMessage\"\xc1\x08\n\rRemoteMessage\x12\x31\n\x10remote_configure\x18\x01 \x01(\x0b\x32\x17.remote.RemoteConfigure\x12\x32\n\x11remote_set_active\x18\x02 \x01(\x0b\x32\x17.remote.RemoteSetActive\x12)\n\x0cremote_error\x18\x03 \x01(\x0b\x32\x13.remote.RemoteError\x12\x36\n\x13remote_ping_request\x18\x08 \x01(\x0b\x32\x19.remote.RemotePingRequest\x12\x38\n\x14remote_ping_response\x18\t \x01(\x0b\x32\x1a.remote.RemotePingResponse\x12\x32\n\x11remote_key_inject\x18\n \x01(\x0b\x32\x17.remote.RemoteKeyInject\x12\x39\n\x15remote_ime_key_inject\x18\x14 \x01(\x0b\x32\x1a.remote.RemoteImeKeyInject\x12\x39\n\x15remote_ime_batch_edit\x18\x15 \x01(\x0b\x32\x1a.remote.RemoteImeBatchEdit\x12=\n\x17remote_ime_show_request\x18\x16 \x01(\x0b\x32\x1c.remote.RemoteImeShowRequest\x12\x34\n\x12remote_voice_begin\x18\x1e \x01(\x0b\x32\x18.remote.RemoteVoiceBegin\x12\x38\n\x14remote_voice_payload\x18\x1f \x01(\x0b\x32\x1a.remote.RemoteVoicePayload\x12\x30\n\x10remote_voice_end\x18 \x01(\x0b\x32\x16.remote.RemoteVoiceEnd\x12)\n\x0cremote_start\x18( \x01(\x0b\x32\x13.remote.RemoteStart\x12=\n\x17remote_set_volume_level\x18\x32 \x01(\x0b\x32\x1c.remote.RemoteSetVolumeLevel\x12\x43\n\x1aremote_adjust_volume_level\x18\x33 \x01(\x0b\x32\x1f.remote.RemoteAdjustVolumeLevel\x12P\n!remote_set_preferred_audio_device\x18< \x01(\x0b\x32%.remote.RemoteSetPreferredAudioDevice\x12T\n#remote_reset_preferred_audio_device\x18= \x01(\x0b\x32\'.remote.RemoteResetPreferredAudioDevice\x12J\n\x1eremote_app_link_launch_request\x18Z \x01(\x0b\x32\".remote.RemoteAppLinkLaunchRequest*\xe7\x37\n\rRemoteKeyCode\x12\x13\n\x0fKEYCODE_UNKNOWN\x10\x00\x12\x15\n\x11KEYCODE_SOFT_LEFT\x10\x01\x12\x16\n\x12KEYCODE_SOFT_RIGHT\x10\x02\x12\x10\n\x0cKEYCODE_HOME\x10\x03\x12\x10\n\x0cKEYCODE_BACK\x10\x04\x12\x10\n\x0cKEYCODE_CALL\x10\x05\x12\x13\n\x0fKEYCODE_ENDCALL\x10\x06\x12\r\n\tKEYCODE_0\x10\x07\x12\r\n\tKEYCODE_1\x10\x08\x12\r\n\tKEYCODE_2\x10\t\x12\r\n\tKEYCODE_3\x10\n\x12\r\n\tKEYCODE_4\x10\x0b\x12\r\n\tKEYCODE_5\x10\x0c\x12\r\n\tKEYCODE_6\x10\r\x12\r\n\tKEYCODE_7\x10\x0e\x12\r\n\tKEYCODE_8\x10\x0f\x12\r\n\tKEYCODE_9\x10\x10\x12\x10\n\x0cKEYCODE_STAR\x10\x11\x12\x11\n\rKEYCODE_POUND\x10\x12\x12\x13\n\x0fKEYCODE_DPAD_UP\x10\x13\x12\x15\n\x11KEYCODE_DPAD_DOWN\x10\x14\x12\x15\n\x11KEYCODE_DPAD_LEFT\x10\x15\x12\x16\n\x12KEYCODE_DPAD_RIGHT\x10\x16\x12\x17\n\x13KEYCODE_DPAD_CENTER\x10\x17\x12\x15\n\x11KEYCODE_VOLUME_UP\x10\x18\x12\x17\n\x13KEYCODE_VOLUME_DOWN\x10\x19\x12\x11\n\rKEYCODE_POWER\x10\x1a\x12\x12\n\x0eKEYCODE_CAMERA\x10\x1b\x12\x11\n\rKEYCODE_CLEAR\x10\x1c\x12\r\n\tKEYCODE_A\x10\x1d\x12\r\n\tKEYCODE_B\x10\x1e\x12\r\n\tKEYCODE_C\x10\x1f\x12\r\n\tKEYCODE_D\x10 \x12\r\n\tKEYCODE_E\x10!\x12\r\n\tKEYCODE_F\x10\"\x12\r\n\tKEYCODE_G\x10#\x12\r\n\tKEYCODE_H\x10$\x12\r\n\tKEYCODE_I\x10%\x12\r\n\tKEYCODE_J\x10&\x12\r\n\tKEYCODE_K\x10\'\x12\r\n\tKEYCODE_L\x10(\x12\r\n\tKEYCODE_M\x10)\x12\r\n\tKEYCODE_N\x10*\x12\r\n\tKEYCODE_O\x10+\x12\r\n\tKEYCODE_P\x10,\x12\r\n\tKEYCODE_Q\x10-\x12\r\n\tKEYCODE_R\x10.\x12\r\n\tKEYCODE_S\x10/\x12\r\n\tKEYCODE_T\x10\x30\x12\r\n\tKEYCODE_U\x10\x31\x12\r\n\tKEYCODE_V\x10\x32\x12\r\n\tKEYCODE_W\x10\x33\x12\r\n\tKEYCODE_X\x10\x34\x12\r\n\tKEYCODE_Y\x10\x35\x12\r\n\tKEYCODE_Z\x10\x36\x12\x11\n\rKEYCODE_COMMA\x10\x37\x12\x12\n\x0eKEYCODE_PERIOD\x10\x38\x12\x14\n\x10KEYCODE_ALT_LEFT\x10\x39\x12\x15\n\x11KEYCODE_ALT_RIGHT\x10:\x12\x16\n\x12KEYCODE_SHIFT_LEFT\x10;\x12\x17\n\x13KEYCODE_SHIFT_RIGHT\x10<\x12\x0f\n\x0bKEYCODE_TAB\x10=\x12\x11\n\rKEYCODE_SPACE\x10>\x12\x0f\n\x0bKEYCODE_SYM\x10?\x12\x14\n\x10KEYCODE_EXPLORER\x10@\x12\x14\n\x10KEYCODE_ENVELOPE\x10\x41\x12\x11\n\rKEYCODE_ENTER\x10\x42\x12\x0f\n\x0bKEYCODE_DEL\x10\x43\x12\x11\n\rKEYCODE_GRAVE\x10\x44\x12\x11\n\rKEYCODE_MINUS\x10\x45\x12\x12\n\x0eKEYCODE_EQUALS\x10\x46\x12\x18\n\x14KEYCODE_LEFT_BRACKET\x10G\x12\x19\n\x15KEYCODE_RIGHT_BRACKET\x10H\x12\x15\n\x11KEYCODE_BACKSLASH\x10I\x12\x15\n\x11KEYCODE_SEMICOLON\x10J\x12\x16\n\x12KEYCODE_APOSTROPHE\x10K\x12\x11\n\rKEYCODE_SLASH\x10L\x12\x0e\n\nKEYCODE_AT\x10M\x12\x0f\n\x0bKEYCODE_NUM\x10N\x12\x17\n\x13KEYCODE_HEADSETHOOK\x10O\x12\x11\n\rKEYCODE_FOCUS\x10P\x12\x10\n\x0cKEYCODE_PLUS\x10Q\x12\x10\n\x0cKEYCODE_MENU\x10R\x12\x18\n\x14KEYCODE_NOTIFICATION\x10S\x12\x12\n\x0eKEYCODE_SEARCH\x10T\x12\x1c\n\x18KEYCODE_MEDIA_PLAY_PAUSE\x10U\x12\x16\n\x12KEYCODE_MEDIA_STOP\x10V\x12\x16\n\x12KEYCODE_MEDIA_NEXT\x10W\x12\x1a\n\x16KEYCODE_MEDIA_PREVIOUS\x10X\x12\x18\n\x14KEYCODE_MEDIA_REWIND\x10Y\x12\x1e\n\x1aKEYCODE_MEDIA_FAST_FORWARD\x10Z\x12\x10\n\x0cKEYCODE_MUTE\x10[\x12\x13\n\x0fKEYCODE_PAGE_UP\x10\\\x12\x15\n\x11KEYCODE_PAGE_DOWN\x10]\x12\x17\n\x13KEYCODE_PICTSYMBOLS\x10^\x12\x1a\n\x16KEYCODE_SWITCH_CHARSET\x10_\x12\x14\n\x10KEYCODE_BUTTON_A\x10`\x12\x14\n\x10KEYCODE_BUTTON_B\x10\x61\x12\x14\n\x10KEYCODE_BUTTON_C\x10\x62\x12\x14\n\x10KEYCODE_BUTTON_X\x10\x63\x12\x14\n\x10KEYCODE_BUTTON_Y\x10\x64\x12\x14\n\x10KEYCODE_BUTTON_Z\x10\x65\x12\x15\n\x11KEYCODE_BUTTON_L1\x10\x66\x12\x15\n\x11KEYCODE_BUTTON_R1\x10g\x12\x15\n\x11KEYCODE_BUTTON_L2\x10h\x12\x15\n\x11KEYCODE_BUTTON_R2\x10i\x12\x19\n\x15KEYCODE_BUTTON_THUMBL\x10j\x12\x19\n\x15KEYCODE_BUTTON_THUMBR\x10k\x12\x18\n\x14KEYCODE_BUTTON_START\x10l\x12\x19\n\x15KEYCODE_BUTTON_SELECT\x10m\x12\x17\n\x13KEYCODE_BUTTON_MODE\x10n\x12\x12\n\x0eKEYCODE_ESCAPE\x10o\x12\x17\n\x13KEYCODE_FORWARD_DEL\x10p\x12\x15\n\x11KEYCODE_CTRL_LEFT\x10q\x12\x16\n\x12KEYCODE_CTRL_RIGHT\x10r\x12\x15\n\x11KEYCODE_CAPS_LOCK\x10s\x12\x17\n\x13KEYCODE_SCROLL_LOCK\x10t\x12\x15\n\x11KEYCODE_META_LEFT\x10u\x12\x16\n\x12KEYCODE_META_RIGHT\x10v\x12\x14\n\x10KEYCODE_FUNCTION\x10w\x12\x11\n\rKEYCODE_SYSRQ\x10x\x12\x11\n\rKEYCODE_BREAK\x10y\x12\x15\n\x11KEYCODE_MOVE_HOME\x10z\x12\x14\n\x10KEYCODE_MOVE_END\x10{\x12\x12\n\x0eKEYCODE_INSERT\x10|\x12\x13\n\x0fKEYCODE_FORWARD\x10}\x12\x16\n\x12KEYCODE_MEDIA_PLAY\x10~\x12\x17\n\x13KEYCODE_MEDIA_PAUSE\x10\x7f\x12\x18\n\x13KEYCODE_MEDIA_CLOSE\x10\x80\x01\x12\x18\n\x13KEYCODE_MEDIA_EJECT\x10\x81\x01\x12\x19\n\x14KEYCODE_MEDIA_RECORD\x10\x82\x01\x12\x0f\n\nKEYCODE_F1\x10\x83\x01\x12\x0f\n\nKEYCODE_F2\x10\x84\x01\x12\x0f\n\nKEYCODE_F3\x10\x85\x01\x12\x0f\n\nKEYCODE_F4\x10\x86\x01\x12\x0f\n\nKEYCODE_F5\x10\x87\x01\x12\x0f\n\nKEYCODE_F6\x10\x88\x01\x12\x0f\n\nKEYCODE_F7\x10\x89\x01\x12\x0f\n\nKEYCODE_F8\x10\x8a\x01\x12\x0f\n\nKEYCODE_F9\x10\x8b\x01\x12\x10\n\x0bKEYCODE_F10\x10\x8c\x01\x12\x10\n\x0bKEYCODE_F11\x10\x8d\x01\x12\x10\n\x0bKEYCODE_F12\x10\x8e\x01\x12\x15\n\x10KEYCODE_NUM_LOCK\x10\x8f\x01\x12\x15\n\x10KEYCODE_NUMPAD_0\x10\x90\x01\x12\x15\n\x10KEYCODE_NUMPAD_1\x10\x91\x01\x12\x15\n\x10KEYCODE_NUMPAD_2\x10\x92\x01\x12\x15\n\x10KEYCODE_NUMPAD_3\x10\x93\x01\x12\x15\n\x10KEYCODE_NUMPAD_4\x10\x94\x01\x12\x15\n\x10KEYCODE_NUMPAD_5\x10\x95\x01\x12\x15\n\x10KEYCODE_NUMPAD_6\x10\x96\x01\x12\x15\n\x10KEYCODE_NUMPAD_7\x10\x97\x01\x12\x15\n\x10KEYCODE_NUMPAD_8\x10\x98\x01\x12\x15\n\x10KEYCODE_NUMPAD_9\x10\x99\x01\x12\x1a\n\x15KEYCODE_NUMPAD_DIVIDE\x10\x9a\x01\x12\x1c\n\x17KEYCODE_NUMPAD_MULTIPLY\x10\x9b\x01\x12\x1c\n\x17KEYCODE_NUMPAD_SUBTRACT\x10\x9c\x01\x12\x17\n\x12KEYCODE_NUMPAD_ADD\x10\x9d\x01\x12\x17\n\x12KEYCODE_NUMPAD_DOT\x10\x9e\x01\x12\x19\n\x14KEYCODE_NUMPAD_COMMA\x10\x9f\x01\x12\x19\n\x14KEYCODE_NUMPAD_ENTER\x10\xa0\x01\x12\x1a\n\x15KEYCODE_NUMPAD_EQUALS\x10\xa1\x01\x12\x1e\n\x19KEYCODE_NUMPAD_LEFT_PAREN\x10\xa2\x01\x12\x1f\n\x1aKEYCODE_NUMPAD_RIGHT_PAREN\x10\xa3\x01\x12\x18\n\x13KEYCODE_VOLUME_MUTE\x10\xa4\x01\x12\x11\n\x0cKEYCODE_INFO\x10\xa5\x01\x12\x17\n\x12KEYCODE_CHANNEL_UP\x10\xa6\x01\x12\x19\n\x14KEYCODE_CHANNEL_DOWN\x10\xa7\x01\x12\x14\n\x0fKEYCODE_ZOOM_IN\x10\xa8\x01\x12\x15\n\x10KEYCODE_ZOOM_OUT\x10\xa9\x01\x12\x0f\n\nKEYCODE_TV\x10\xaa\x01\x12\x13\n\x0eKEYCODE_WINDOW\x10\xab\x01\x12\x12\n\rKEYCODE_GUIDE\x10\xac\x01\x12\x10\n\x0bKEYCODE_DVR\x10\xad\x01\x12\x15\n\x10KEYCODE_BOOKMARK\x10\xae\x01\x12\x15\n\x10KEYCODE_CAPTIONS\x10\xaf\x01\x12\x15\n\x10KEYCODE_SETTINGS\x10\xb0\x01\x12\x15\n\x10KEYCODE_TV_POWER\x10\xb1\x01\x12\x15\n\x10KEYCODE_TV_INPUT\x10\xb2\x01\x12\x16\n\x11KEYCODE_STB_POWER\x10\xb3\x01\x12\x16\n\x11KEYCODE_STB_INPUT\x10\xb4\x01\x12\x16\n\x11KEYCODE_AVR_POWER\x10\xb5\x01\x12\x16\n\x11KEYCODE_AVR_INPUT\x10\xb6\x01\x12\x15\n\x10KEYCODE_PROG_RED\x10\xb7\x01\x12\x17\n\x12KEYCODE_PROG_GREEN\x10\xb8\x01\x12\x18\n\x13KEYCODE_PROG_YELLOW\x10\xb9\x01\x12\x16\n\x11KEYCODE_PROG_BLUE\x10\xba\x01\x12\x17\n\x12KEYCODE_APP_SWITCH\x10\xbb\x01\x12\x15\n\x10KEYCODE_BUTTON_1\x10\xbc\x01\x12\x15\n\x10KEYCODE_BUTTON_2\x10\xbd\x01\x12\x15\n\x10KEYCODE_BUTTON_3\x10\xbe\x01\x12\x15\n\x10KEYCODE_BUTTON_4\x10\xbf\x01\x12\x15\n\x10KEYCODE_BUTTON_5\x10\xc0\x01\x12\x15\n\x10KEYCODE_BUTTON_6\x10\xc1\x01\x12\x15\n\x10KEYCODE_BUTTON_7\x10\xc2\x01\x12\x15\n\x10KEYCODE_BUTTON_8\x10\xc3\x01\x12\x15\n\x10KEYCODE_BUTTON_9\x10\xc4\x01\x12\x16\n\x11KEYCODE_BUTTON_10\x10\xc5\x01\x12\x16\n\x11KEYCODE_BUTTON_11\x10\xc6\x01\x12\x16\n\x11KEYCODE_BUTTON_12\x10\xc7\x01\x12\x16\n\x11KEYCODE_BUTTON_13\x10\xc8\x01\x12\x16\n\x11KEYCODE_BUTTON_14\x10\xc9\x01\x12\x16\n\x11KEYCODE_BUTTON_15\x10\xca\x01\x12\x16\n\x11KEYCODE_BUTTON_16\x10\xcb\x01\x12\x1c\n\x17KEYCODE_LANGUAGE_SWITCH\x10\xcc\x01\x12\x18\n\x13KEYCODE_MANNER_MODE\x10\xcd\x01\x12\x14\n\x0fKEYCODE_3D_MODE\x10\xce\x01\x12\x15\n\x10KEYCODE_CONTACTS\x10\xcf\x01\x12\x15\n\x10KEYCODE_CALENDAR\x10\xd0\x01\x12\x12\n\rKEYCODE_MUSIC\x10\xd1\x01\x12\x17\n\x12KEYCODE_CALCULATOR\x10\xd2\x01\x12\x1c\n\x17KEYCODE_ZENKAKU_HANKAKU\x10\xd3\x01\x12\x11\n\x0cKEYCODE_EISU\x10\xd4\x01\x12\x15\n\x10KEYCODE_MUHENKAN\x10\xd5\x01\x12\x13\n\x0eKEYCODE_HENKAN\x10\xd6\x01\x12\x1e\n\x19KEYCODE_KATAKANA_HIRAGANA\x10\xd7\x01\x12\x10\n\x0bKEYCODE_YEN\x10\xd8\x01\x12\x0f\n\nKEYCODE_RO\x10\xd9\x01\x12\x11\n\x0cKEYCODE_KANA\x10\xda\x01\x12\x13\n\x0eKEYCODE_ASSIST\x10\xdb\x01\x12\x1c\n\x17KEYCODE_BRIGHTNESS_DOWN\x10\xdc\x01\x12\x1a\n\x15KEYCODE_BRIGHTNESS_UP\x10\xdd\x01\x12\x1e\n\x19KEYCODE_MEDIA_AUDIO_TRACK\x10\xde\x01\x12\x12\n\rKEYCODE_SLEEP\x10\xdf\x01\x12\x13\n\x0eKEYCODE_WAKEUP\x10\xe0\x01\x12\x14\n\x0fKEYCODE_PAIRING\x10\xe1\x01\x12\x1b\n\x16KEYCODE_MEDIA_TOP_MENU\x10\xe2\x01\x12\x0f\n\nKEYCODE_11\x10\xe3\x01\x12\x0f\n\nKEYCODE_12\x10\xe4\x01\x12\x19\n\x14KEYCODE_LAST_CHANNEL\x10\xe5\x01\x12\x1c\n\x17KEYCODE_TV_DATA_SERVICE\x10\xe6\x01\x12\x19\n\x14KEYCODE_VOICE_ASSIST\x10\xe7\x01\x12\x1d\n\x18KEYCODE_TV_RADIO_SERVICE\x10\xe8\x01\x12\x18\n\x13KEYCODE_TV_TELETEXT\x10\xe9\x01\x12\x1c\n\x17KEYCODE_TV_NUMBER_ENTRY\x10\xea\x01\x12\"\n\x1dKEYCODE_TV_TERRESTRIAL_ANALOG\x10\xeb\x01\x12#\n\x1eKEYCODE_TV_TERRESTRIAL_DIGITAL\x10\xec\x01\x12\x19\n\x14KEYCODE_TV_SATELLITE\x10\xed\x01\x12\x1c\n\x17KEYCODE_TV_SATELLITE_BS\x10\xee\x01\x12\x1c\n\x17KEYCODE_TV_SATELLITE_CS\x10\xef\x01\x12!\n\x1cKEYCODE_TV_SATELLITE_SERVICE\x10\xf0\x01\x12\x17\n\x12KEYCODE_TV_NETWORK\x10\xf1\x01\x12\x1d\n\x18KEYCODE_TV_ANTENNA_CABLE\x10\xf2\x01\x12\x1c\n\x17KEYCODE_TV_INPUT_HDMI_1\x10\xf3\x01\x12\x1c\n\x17KEYCODE_TV_INPUT_HDMI_2\x10\xf4\x01\x12\x1c\n\x17KEYCODE_TV_INPUT_HDMI_3\x10\xf5\x01\x12\x1c\n\x17KEYCODE_TV_INPUT_HDMI_4\x10\xf6\x01\x12!\n\x1cKEYCODE_TV_INPUT_COMPOSITE_1\x10\xf7\x01\x12!\n\x1cKEYCODE_TV_INPUT_COMPOSITE_2\x10\xf8\x01\x12!\n\x1cKEYCODE_TV_INPUT_COMPONENT_1\x10\xf9\x01\x12!\n\x1cKEYCODE_TV_INPUT_COMPONENT_2\x10\xfa\x01\x12\x1b\n\x16KEYCODE_TV_INPUT_VGA_1\x10\xfb\x01\x12!\n\x1cKEYCODE_TV_AUDIO_DESCRIPTION\x10\xfc\x01\x12(\n#KEYCODE_TV_AUDIO_DESCRIPTION_MIX_UP\x10\xfd\x01\x12*\n%KEYCODE_TV_AUDIO_DESCRIPTION_MIX_DOWN\x10\xfe\x01\x12\x19\n\x14KEYCODE_TV_ZOOM_MODE\x10\xff\x01\x12\x1d\n\x18KEYCODE_TV_CONTENTS_MENU\x10\x80\x02\x12\"\n\x1dKEYCODE_TV_MEDIA_CONTEXT_MENU\x10\x81\x02\x12!\n\x1cKEYCODE_TV_TIMER_PROGRAMMING\x10\x82\x02\x12\x11\n\x0cKEYCODE_HELP\x10\x83\x02\x12\x1e\n\x19KEYCODE_NAVIGATE_PREVIOUS\x10\x84\x02\x12\x1a\n\x15KEYCODE_NAVIGATE_NEXT\x10\x85\x02\x12\x18\n\x13KEYCODE_NAVIGATE_IN\x10\x86\x02\x12\x19\n\x14KEYCODE_NAVIGATE_OUT\x10\x87\x02\x12\x19\n\x14KEYCODE_STEM_PRIMARY\x10\x88\x02\x12\x13\n\x0eKEYCODE_STEM_1\x10\x89\x02\x12\x13\n\x0eKEYCODE_STEM_2\x10\x8a\x02\x12\x13\n\x0eKEYCODE_STEM_3\x10\x8b\x02\x12\x19\n\x14KEYCODE_DPAD_UP_LEFT\x10\x8c\x02\x12\x1b\n\x16KEYCODE_DPAD_DOWN_LEFT\x10\x8d\x02\x12\x1a\n\x15KEYCODE_DPAD_UP_RIGHT\x10\x8e\x02\x12\x1c\n\x17KEYCODE_DPAD_DOWN_RIGHT\x10\x8f\x02\x12\x1f\n\x1aKEYCODE_MEDIA_SKIP_FORWARD\x10\x90\x02\x12 \n\x1bKEYCODE_MEDIA_SKIP_BACKWARD\x10\x91\x02\x12\x1f\n\x1aKEYCODE_MEDIA_STEP_FORWARD\x10\x92\x02\x12 \n\x1bKEYCODE_MEDIA_STEP_BACKWARD\x10\x93\x02\x12\x17\n\x12KEYCODE_SOFT_SLEEP\x10\x94\x02\x12\x10\n\x0bKEYCODE_CUT\x10\x95\x02\x12\x11\n\x0cKEYCODE_COPY\x10\x96\x02\x12\x12\n\rKEYCODE_PASTE\x10\x97\x02\x12!\n\x1cKEYCODE_SYSTEM_NAVIGATION_UP\x10\x98\x02\x12#\n\x1eKEYCODE_SYSTEM_NAVIGATION_DOWN\x10\x99\x02\x12#\n\x1eKEYCODE_SYSTEM_NAVIGATION_LEFT\x10\x9a\x02\x12$\n\x1fKEYCODE_SYSTEM_NAVIGATION_RIGHT\x10\x9b\x02\x12\x15\n\x10KEYCODE_ALL_APPS\x10\x9c\x02\x12\x14\n\x0fKEYCODE_REFRESH\x10\x9d\x02\x12\x16\n\x11KEYCODE_THUMBS_UP\x10\x9e\x02\x12\x18\n\x13KEYCODE_THUMBS_DOWN\x10\x9f\x02\x12\x1b\n\x16KEYCODE_PROFILE_SWITCH\x10\xa0\x02\x12\x18\n\x13KEYCODE_VIDEO_APP_1\x10\xa1\x02\x12\x18\n\x13KEYCODE_VIDEO_APP_2\x10\xa2\x02\x12\x18\n\x13KEYCODE_VIDEO_APP_3\x10\xa3\x02\x12\x18\n\x13KEYCODE_VIDEO_APP_4\x10\xa4\x02\x12\x18\n\x13KEYCODE_VIDEO_APP_5\x10\xa5\x02\x12\x18\n\x13KEYCODE_VIDEO_APP_6\x10\xa6\x02\x12\x18\n\x13KEYCODE_VIDEO_APP_7\x10\xa7\x02\x12\x18\n\x13KEYCODE_VIDEO_APP_8\x10\xa8\x02\x12\x1b\n\x16KEYCODE_FEATURED_APP_1\x10\xa9\x02\x12\x1b\n\x16KEYCODE_FEATURED_APP_2\x10\xaa\x02\x12\x1b\n\x16KEYCODE_FEATURED_APP_3\x10\xab\x02\x12\x1b\n\x16KEYCODE_FEATURED_APP_4\x10\xac\x02\x12\x17\n\x12KEYCODE_DEMO_APP_1\x10\xad\x02\x12\x17\n\x12KEYCODE_DEMO_APP_2\x10\xae\x02\x12\x17\n\x12KEYCODE_DEMO_APP_3\x10\xaf\x02\x12\x17\n\x12KEYCODE_DEMO_APP_4\x10\xb0\x02*Q\n\x0fRemoteDirection\x12\x15\n\x11UNKNOWN_DIRECTION\x10\x00\x12\x0e\n\nSTART_LONG\x10\x01\x12\x0c\n\x08\x45ND_LONG\x10\x02\x12\t\n\x05SHORT\x10\x03\x62\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x13remotemessage.proto\x12\x06remote\".\n\x1aRemoteAppLinkLaunchRequest\x12\x10\n\x08\x61pp_link\x18\x01 \x01(\t\"!\n\x1fRemoteResetPreferredAudioDevice\"\x1f\n\x1dRemoteSetPreferredAudioDevice\"\x19\n\x17RemoteAdjustVolumeLevel\"\xb4\x01\n\x14RemoteSetVolumeLevel\x12\x10\n\x08unknown1\x18\x01 \x01(\r\x12\x10\n\x08unknown2\x18\x02 \x01(\r\x12\x14\n\x0cplayer_model\x18\x03 \x01(\t\x12\x10\n\x08unknown4\x18\x04 \x01(\r\x12\x10\n\x08unknown5\x18\x05 \x01(\r\x12\x12\n\nvolume_max\x18\x06 \x01(\r\x12\x14\n\x0cvolume_level\x18\x07 \x01(\r\x12\x14\n\x0cvolume_muted\x18\x08 \x01(\x08\"\x1e\n\x0bRemoteStart\x12\x0f\n\x07started\x18\x01 \x01(\x08\"$\n\x0eRemoteVoiceEnd\x12\x12\n\nsession_id\x18\x01 \x01(\x05\"9\n\x12RemoteVoicePayload\x12\x12\n\nsession_id\x18\x01 \x01(\x05\x12\x0f\n\x07samples\x18\x02 \x01(\x0c\"<\n\x10RemoteVoiceBegin\x12\x12\n\nsession_id\x18\x01 \x01(\x05\x12\x14\n\x0cpackage_name\x18\x02 \x01(\t\"v\n\x15RemoteTextFieldStatus\x12\x15\n\rcounter_field\x18\x01 \x01(\x05\x12\r\n\x05value\x18\x02 \x01(\t\x12\r\n\x05start\x18\x03 \x01(\x05\x12\x0b\n\x03\x65nd\x18\x04 \x01(\x05\x12\x0c\n\x04int5\x18\x05 \x01(\x05\x12\r\n\x05label\x18\x06 \x01(\t\"W\n\x14RemoteImeShowRequest\x12?\n\x18remote_text_field_status\x18\x02 \x01(\x0b\x32\x1d.remote.RemoteTextFieldStatus\"T\n\x0eRemoteEditInfo\x12\x0e\n\x06insert\x18\x01 \x01(\x05\x12\x32\n\x11text_field_status\x18\x02 \x01(\x0b\x32\x17.remote.RemoteImeObject\"<\n\x0fRemoteImeObject\x12\r\n\x05start\x18\x01 \x01(\x05\x12\x0b\n\x03\x65nd\x18\x02 \x01(\x05\x12\r\n\x05value\x18\x03 \x01(\t\"k\n\x12RemoteImeBatchEdit\x12\x13\n\x0bime_counter\x18\x01 \x01(\x05\x12\x15\n\rfield_counter\x18\x02 \x01(\x05\x12)\n\tedit_info\x18\x03 \x03(\x0b\x32\x16.remote.RemoteEditInfo\"\x99\x01\n\rRemoteAppInfo\x12\x0f\n\x07\x63ounter\x18\x01 \x01(\x05\x12\x0c\n\x04int2\x18\x02 \x01(\x05\x12\x0c\n\x04int3\x18\x03 \x01(\x05\x12\x0c\n\x04int4\x18\x04 \x01(\t\x12\x0c\n\x04int7\x18\x07 \x01(\x05\x12\x0c\n\x04int8\x18\x08 \x01(\x05\x12\r\n\x05label\x18\n \x01(\t\x12\x13\n\x0b\x61pp_package\x18\x0c \x01(\t\x12\r\n\x05int13\x18\r \x01(\x05\"w\n\x12RemoteImeKeyInject\x12\'\n\x08\x61pp_info\x18\x01 \x01(\x0b\x32\x15.remote.RemoteAppInfo\x12\x38\n\x11text_field_status\x18\x02 \x01(\x0b\x32\x1d.remote.RemoteTextFieldStatus\"f\n\x0fRemoteKeyInject\x12\'\n\x08key_code\x18\x01 \x01(\x0e\x32\x15.remote.RemoteKeyCode\x12*\n\tdirection\x18\x02 \x01(\x0e\x32\x17.remote.RemoteDirection\"\"\n\x12RemotePingResponse\x12\x0c\n\x04val1\x18\x01 \x01(\x05\"/\n\x11RemotePingRequest\x12\x0c\n\x04val1\x18\x01 \x01(\x05\x12\x0c\n\x04val2\x18\x02 \x01(\x05\"!\n\x0fRemoteSetActive\x12\x0e\n\x06\x61\x63tive\x18\x01 \x01(\x05\"\x80\x01\n\x10RemoteDeviceInfo\x12\r\n\x05model\x18\x01 \x01(\t\x12\x0e\n\x06vendor\x18\x02 \x01(\t\x12\x10\n\x08unknown1\x18\x03 \x01(\x05\x12\x10\n\x08unknown2\x18\x04 \x01(\t\x12\x14\n\x0cpackage_name\x18\x05 \x01(\t\x12\x13\n\x0b\x61pp_version\x18\x06 \x01(\t\"O\n\x0fRemoteConfigure\x12\r\n\x05\x63ode1\x18\x01 \x01(\x05\x12-\n\x0b\x64\x65vice_info\x18\x02 \x01(\x0b\x32\x18.remote.RemoteDeviceInfo\"D\n\x0bRemoteError\x12\r\n\x05value\x18\x01 \x01(\x08\x12&\n\x07message\x18\x02 \x01(\x0b\x32\x15.remote.RemoteMessage\"\xc1\x08\n\rRemoteMessage\x12\x31\n\x10remote_configure\x18\x01 \x01(\x0b\x32\x17.remote.RemoteConfigure\x12\x32\n\x11remote_set_active\x18\x02 \x01(\x0b\x32\x17.remote.RemoteSetActive\x12)\n\x0cremote_error\x18\x03 \x01(\x0b\x32\x13.remote.RemoteError\x12\x36\n\x13remote_ping_request\x18\x08 \x01(\x0b\x32\x19.remote.RemotePingRequest\x12\x38\n\x14remote_ping_response\x18\t \x01(\x0b\x32\x1a.remote.RemotePingResponse\x12\x32\n\x11remote_key_inject\x18\n \x01(\x0b\x32\x17.remote.RemoteKeyInject\x12\x39\n\x15remote_ime_key_inject\x18\x14 \x01(\x0b\x32\x1a.remote.RemoteImeKeyInject\x12\x39\n\x15remote_ime_batch_edit\x18\x15 \x01(\x0b\x32\x1a.remote.RemoteImeBatchEdit\x12=\n\x17remote_ime_show_request\x18\x16 \x01(\x0b\x32\x1c.remote.RemoteImeShowRequest\x12\x34\n\x12remote_voice_begin\x18\x1e \x01(\x0b\x32\x18.remote.RemoteVoiceBegin\x12\x38\n\x14remote_voice_payload\x18\x1f \x01(\x0b\x32\x1a.remote.RemoteVoicePayload\x12\x30\n\x10remote_voice_end\x18 \x01(\x0b\x32\x16.remote.RemoteVoiceEnd\x12)\n\x0cremote_start\x18( \x01(\x0b\x32\x13.remote.RemoteStart\x12=\n\x17remote_set_volume_level\x18\x32 \x01(\x0b\x32\x1c.remote.RemoteSetVolumeLevel\x12\x43\n\x1aremote_adjust_volume_level\x18\x33 \x01(\x0b\x32\x1f.remote.RemoteAdjustVolumeLevel\x12P\n!remote_set_preferred_audio_device\x18< \x01(\x0b\x32%.remote.RemoteSetPreferredAudioDevice\x12T\n#remote_reset_preferred_audio_device\x18= \x01(\x0b\x32\'.remote.RemoteResetPreferredAudioDevice\x12J\n\x1eremote_app_link_launch_request\x18Z \x01(\x0b\x32\".remote.RemoteAppLinkLaunchRequest*\xe7\x37\n\rRemoteKeyCode\x12\x13\n\x0fKEYCODE_UNKNOWN\x10\x00\x12\x15\n\x11KEYCODE_SOFT_LEFT\x10\x01\x12\x16\n\x12KEYCODE_SOFT_RIGHT\x10\x02\x12\x10\n\x0cKEYCODE_HOME\x10\x03\x12\x10\n\x0cKEYCODE_BACK\x10\x04\x12\x10\n\x0cKEYCODE_CALL\x10\x05\x12\x13\n\x0fKEYCODE_ENDCALL\x10\x06\x12\r\n\tKEYCODE_0\x10\x07\x12\r\n\tKEYCODE_1\x10\x08\x12\r\n\tKEYCODE_2\x10\t\x12\r\n\tKEYCODE_3\x10\n\x12\r\n\tKEYCODE_4\x10\x0b\x12\r\n\tKEYCODE_5\x10\x0c\x12\r\n\tKEYCODE_6\x10\r\x12\r\n\tKEYCODE_7\x10\x0e\x12\r\n\tKEYCODE_8\x10\x0f\x12\r\n\tKEYCODE_9\x10\x10\x12\x10\n\x0cKEYCODE_STAR\x10\x11\x12\x11\n\rKEYCODE_POUND\x10\x12\x12\x13\n\x0fKEYCODE_DPAD_UP\x10\x13\x12\x15\n\x11KEYCODE_DPAD_DOWN\x10\x14\x12\x15\n\x11KEYCODE_DPAD_LEFT\x10\x15\x12\x16\n\x12KEYCODE_DPAD_RIGHT\x10\x16\x12\x17\n\x13KEYCODE_DPAD_CENTER\x10\x17\x12\x15\n\x11KEYCODE_VOLUME_UP\x10\x18\x12\x17\n\x13KEYCODE_VOLUME_DOWN\x10\x19\x12\x11\n\rKEYCODE_POWER\x10\x1a\x12\x12\n\x0eKEYCODE_CAMERA\x10\x1b\x12\x11\n\rKEYCODE_CLEAR\x10\x1c\x12\r\n\tKEYCODE_A\x10\x1d\x12\r\n\tKEYCODE_B\x10\x1e\x12\r\n\tKEYCODE_C\x10\x1f\x12\r\n\tKEYCODE_D\x10 \x12\r\n\tKEYCODE_E\x10!\x12\r\n\tKEYCODE_F\x10\"\x12\r\n\tKEYCODE_G\x10#\x12\r\n\tKEYCODE_H\x10$\x12\r\n\tKEYCODE_I\x10%\x12\r\n\tKEYCODE_J\x10&\x12\r\n\tKEYCODE_K\x10\'\x12\r\n\tKEYCODE_L\x10(\x12\r\n\tKEYCODE_M\x10)\x12\r\n\tKEYCODE_N\x10*\x12\r\n\tKEYCODE_O\x10+\x12\r\n\tKEYCODE_P\x10,\x12\r\n\tKEYCODE_Q\x10-\x12\r\n\tKEYCODE_R\x10.\x12\r\n\tKEYCODE_S\x10/\x12\r\n\tKEYCODE_T\x10\x30\x12\r\n\tKEYCODE_U\x10\x31\x12\r\n\tKEYCODE_V\x10\x32\x12\r\n\tKEYCODE_W\x10\x33\x12\r\n\tKEYCODE_X\x10\x34\x12\r\n\tKEYCODE_Y\x10\x35\x12\r\n\tKEYCODE_Z\x10\x36\x12\x11\n\rKEYCODE_COMMA\x10\x37\x12\x12\n\x0eKEYCODE_PERIOD\x10\x38\x12\x14\n\x10KEYCODE_ALT_LEFT\x10\x39\x12\x15\n\x11KEYCODE_ALT_RIGHT\x10:\x12\x16\n\x12KEYCODE_SHIFT_LEFT\x10;\x12\x17\n\x13KEYCODE_SHIFT_RIGHT\x10<\x12\x0f\n\x0bKEYCODE_TAB\x10=\x12\x11\n\rKEYCODE_SPACE\x10>\x12\x0f\n\x0bKEYCODE_SYM\x10?\x12\x14\n\x10KEYCODE_EXPLORER\x10@\x12\x14\n\x10KEYCODE_ENVELOPE\x10\x41\x12\x11\n\rKEYCODE_ENTER\x10\x42\x12\x0f\n\x0bKEYCODE_DEL\x10\x43\x12\x11\n\rKEYCODE_GRAVE\x10\x44\x12\x11\n\rKEYCODE_MINUS\x10\x45\x12\x12\n\x0eKEYCODE_EQUALS\x10\x46\x12\x18\n\x14KEYCODE_LEFT_BRACKET\x10G\x12\x19\n\x15KEYCODE_RIGHT_BRACKET\x10H\x12\x15\n\x11KEYCODE_BACKSLASH\x10I\x12\x15\n\x11KEYCODE_SEMICOLON\x10J\x12\x16\n\x12KEYCODE_APOSTROPHE\x10K\x12\x11\n\rKEYCODE_SLASH\x10L\x12\x0e\n\nKEYCODE_AT\x10M\x12\x0f\n\x0bKEYCODE_NUM\x10N\x12\x17\n\x13KEYCODE_HEADSETHOOK\x10O\x12\x11\n\rKEYCODE_FOCUS\x10P\x12\x10\n\x0cKEYCODE_PLUS\x10Q\x12\x10\n\x0cKEYCODE_MENU\x10R\x12\x18\n\x14KEYCODE_NOTIFICATION\x10S\x12\x12\n\x0eKEYCODE_SEARCH\x10T\x12\x1c\n\x18KEYCODE_MEDIA_PLAY_PAUSE\x10U\x12\x16\n\x12KEYCODE_MEDIA_STOP\x10V\x12\x16\n\x12KEYCODE_MEDIA_NEXT\x10W\x12\x1a\n\x16KEYCODE_MEDIA_PREVIOUS\x10X\x12\x18\n\x14KEYCODE_MEDIA_REWIND\x10Y\x12\x1e\n\x1aKEYCODE_MEDIA_FAST_FORWARD\x10Z\x12\x10\n\x0cKEYCODE_MUTE\x10[\x12\x13\n\x0fKEYCODE_PAGE_UP\x10\\\x12\x15\n\x11KEYCODE_PAGE_DOWN\x10]\x12\x17\n\x13KEYCODE_PICTSYMBOLS\x10^\x12\x1a\n\x16KEYCODE_SWITCH_CHARSET\x10_\x12\x14\n\x10KEYCODE_BUTTON_A\x10`\x12\x14\n\x10KEYCODE_BUTTON_B\x10\x61\x12\x14\n\x10KEYCODE_BUTTON_C\x10\x62\x12\x14\n\x10KEYCODE_BUTTON_X\x10\x63\x12\x14\n\x10KEYCODE_BUTTON_Y\x10\x64\x12\x14\n\x10KEYCODE_BUTTON_Z\x10\x65\x12\x15\n\x11KEYCODE_BUTTON_L1\x10\x66\x12\x15\n\x11KEYCODE_BUTTON_R1\x10g\x12\x15\n\x11KEYCODE_BUTTON_L2\x10h\x12\x15\n\x11KEYCODE_BUTTON_R2\x10i\x12\x19\n\x15KEYCODE_BUTTON_THUMBL\x10j\x12\x19\n\x15KEYCODE_BUTTON_THUMBR\x10k\x12\x18\n\x14KEYCODE_BUTTON_START\x10l\x12\x19\n\x15KEYCODE_BUTTON_SELECT\x10m\x12\x17\n\x13KEYCODE_BUTTON_MODE\x10n\x12\x12\n\x0eKEYCODE_ESCAPE\x10o\x12\x17\n\x13KEYCODE_FORWARD_DEL\x10p\x12\x15\n\x11KEYCODE_CTRL_LEFT\x10q\x12\x16\n\x12KEYCODE_CTRL_RIGHT\x10r\x12\x15\n\x11KEYCODE_CAPS_LOCK\x10s\x12\x17\n\x13KEYCODE_SCROLL_LOCK\x10t\x12\x15\n\x11KEYCODE_META_LEFT\x10u\x12\x16\n\x12KEYCODE_META_RIGHT\x10v\x12\x14\n\x10KEYCODE_FUNCTION\x10w\x12\x11\n\rKEYCODE_SYSRQ\x10x\x12\x11\n\rKEYCODE_BREAK\x10y\x12\x15\n\x11KEYCODE_MOVE_HOME\x10z\x12\x14\n\x10KEYCODE_MOVE_END\x10{\x12\x12\n\x0eKEYCODE_INSERT\x10|\x12\x13\n\x0fKEYCODE_FORWARD\x10}\x12\x16\n\x12KEYCODE_MEDIA_PLAY\x10~\x12\x17\n\x13KEYCODE_MEDIA_PAUSE\x10\x7f\x12\x18\n\x13KEYCODE_MEDIA_CLOSE\x10\x80\x01\x12\x18\n\x13KEYCODE_MEDIA_EJECT\x10\x81\x01\x12\x19\n\x14KEYCODE_MEDIA_RECORD\x10\x82\x01\x12\x0f\n\nKEYCODE_F1\x10\x83\x01\x12\x0f\n\nKEYCODE_F2\x10\x84\x01\x12\x0f\n\nKEYCODE_F3\x10\x85\x01\x12\x0f\n\nKEYCODE_F4\x10\x86\x01\x12\x0f\n\nKEYCODE_F5\x10\x87\x01\x12\x0f\n\nKEYCODE_F6\x10\x88\x01\x12\x0f\n\nKEYCODE_F7\x10\x89\x01\x12\x0f\n\nKEYCODE_F8\x10\x8a\x01\x12\x0f\n\nKEYCODE_F9\x10\x8b\x01\x12\x10\n\x0bKEYCODE_F10\x10\x8c\x01\x12\x10\n\x0bKEYCODE_F11\x10\x8d\x01\x12\x10\n\x0bKEYCODE_F12\x10\x8e\x01\x12\x15\n\x10KEYCODE_NUM_LOCK\x10\x8f\x01\x12\x15\n\x10KEYCODE_NUMPAD_0\x10\x90\x01\x12\x15\n\x10KEYCODE_NUMPAD_1\x10\x91\x01\x12\x15\n\x10KEYCODE_NUMPAD_2\x10\x92\x01\x12\x15\n\x10KEYCODE_NUMPAD_3\x10\x93\x01\x12\x15\n\x10KEYCODE_NUMPAD_4\x10\x94\x01\x12\x15\n\x10KEYCODE_NUMPAD_5\x10\x95\x01\x12\x15\n\x10KEYCODE_NUMPAD_6\x10\x96\x01\x12\x15\n\x10KEYCODE_NUMPAD_7\x10\x97\x01\x12\x15\n\x10KEYCODE_NUMPAD_8\x10\x98\x01\x12\x15\n\x10KEYCODE_NUMPAD_9\x10\x99\x01\x12\x1a\n\x15KEYCODE_NUMPAD_DIVIDE\x10\x9a\x01\x12\x1c\n\x17KEYCODE_NUMPAD_MULTIPLY\x10\x9b\x01\x12\x1c\n\x17KEYCODE_NUMPAD_SUBTRACT\x10\x9c\x01\x12\x17\n\x12KEYCODE_NUMPAD_ADD\x10\x9d\x01\x12\x17\n\x12KEYCODE_NUMPAD_DOT\x10\x9e\x01\x12\x19\n\x14KEYCODE_NUMPAD_COMMA\x10\x9f\x01\x12\x19\n\x14KEYCODE_NUMPAD_ENTER\x10\xa0\x01\x12\x1a\n\x15KEYCODE_NUMPAD_EQUALS\x10\xa1\x01\x12\x1e\n\x19KEYCODE_NUMPAD_LEFT_PAREN\x10\xa2\x01\x12\x1f\n\x1aKEYCODE_NUMPAD_RIGHT_PAREN\x10\xa3\x01\x12\x18\n\x13KEYCODE_VOLUME_MUTE\x10\xa4\x01\x12\x11\n\x0cKEYCODE_INFO\x10\xa5\x01\x12\x17\n\x12KEYCODE_CHANNEL_UP\x10\xa6\x01\x12\x19\n\x14KEYCODE_CHANNEL_DOWN\x10\xa7\x01\x12\x14\n\x0fKEYCODE_ZOOM_IN\x10\xa8\x01\x12\x15\n\x10KEYCODE_ZOOM_OUT\x10\xa9\x01\x12\x0f\n\nKEYCODE_TV\x10\xaa\x01\x12\x13\n\x0eKEYCODE_WINDOW\x10\xab\x01\x12\x12\n\rKEYCODE_GUIDE\x10\xac\x01\x12\x10\n\x0bKEYCODE_DVR\x10\xad\x01\x12\x15\n\x10KEYCODE_BOOKMARK\x10\xae\x01\x12\x15\n\x10KEYCODE_CAPTIONS\x10\xaf\x01\x12\x15\n\x10KEYCODE_SETTINGS\x10\xb0\x01\x12\x15\n\x10KEYCODE_TV_POWER\x10\xb1\x01\x12\x15\n\x10KEYCODE_TV_INPUT\x10\xb2\x01\x12\x16\n\x11KEYCODE_STB_POWER\x10\xb3\x01\x12\x16\n\x11KEYCODE_STB_INPUT\x10\xb4\x01\x12\x16\n\x11KEYCODE_AVR_POWER\x10\xb5\x01\x12\x16\n\x11KEYCODE_AVR_INPUT\x10\xb6\x01\x12\x15\n\x10KEYCODE_PROG_RED\x10\xb7\x01\x12\x17\n\x12KEYCODE_PROG_GREEN\x10\xb8\x01\x12\x18\n\x13KEYCODE_PROG_YELLOW\x10\xb9\x01\x12\x16\n\x11KEYCODE_PROG_BLUE\x10\xba\x01\x12\x17\n\x12KEYCODE_APP_SWITCH\x10\xbb\x01\x12\x15\n\x10KEYCODE_BUTTON_1\x10\xbc\x01\x12\x15\n\x10KEYCODE_BUTTON_2\x10\xbd\x01\x12\x15\n\x10KEYCODE_BUTTON_3\x10\xbe\x01\x12\x15\n\x10KEYCODE_BUTTON_4\x10\xbf\x01\x12\x15\n\x10KEYCODE_BUTTON_5\x10\xc0\x01\x12\x15\n\x10KEYCODE_BUTTON_6\x10\xc1\x01\x12\x15\n\x10KEYCODE_BUTTON_7\x10\xc2\x01\x12\x15\n\x10KEYCODE_BUTTON_8\x10\xc3\x01\x12\x15\n\x10KEYCODE_BUTTON_9\x10\xc4\x01\x12\x16\n\x11KEYCODE_BUTTON_10\x10\xc5\x01\x12\x16\n\x11KEYCODE_BUTTON_11\x10\xc6\x01\x12\x16\n\x11KEYCODE_BUTTON_12\x10\xc7\x01\x12\x16\n\x11KEYCODE_BUTTON_13\x10\xc8\x01\x12\x16\n\x11KEYCODE_BUTTON_14\x10\xc9\x01\x12\x16\n\x11KEYCODE_BUTTON_15\x10\xca\x01\x12\x16\n\x11KEYCODE_BUTTON_16\x10\xcb\x01\x12\x1c\n\x17KEYCODE_LANGUAGE_SWITCH\x10\xcc\x01\x12\x18\n\x13KEYCODE_MANNER_MODE\x10\xcd\x01\x12\x14\n\x0fKEYCODE_3D_MODE\x10\xce\x01\x12\x15\n\x10KEYCODE_CONTACTS\x10\xcf\x01\x12\x15\n\x10KEYCODE_CALENDAR\x10\xd0\x01\x12\x12\n\rKEYCODE_MUSIC\x10\xd1\x01\x12\x17\n\x12KEYCODE_CALCULATOR\x10\xd2\x01\x12\x1c\n\x17KEYCODE_ZENKAKU_HANKAKU\x10\xd3\x01\x12\x11\n\x0cKEYCODE_EISU\x10\xd4\x01\x12\x15\n\x10KEYCODE_MUHENKAN\x10\xd5\x01\x12\x13\n\x0eKEYCODE_HENKAN\x10\xd6\x01\x12\x1e\n\x19KEYCODE_KATAKANA_HIRAGANA\x10\xd7\x01\x12\x10\n\x0bKEYCODE_YEN\x10\xd8\x01\x12\x0f\n\nKEYCODE_RO\x10\xd9\x01\x12\x11\n\x0cKEYCODE_KANA\x10\xda\x01\x12\x13\n\x0eKEYCODE_ASSIST\x10\xdb\x01\x12\x1c\n\x17KEYCODE_BRIGHTNESS_DOWN\x10\xdc\x01\x12\x1a\n\x15KEYCODE_BRIGHTNESS_UP\x10\xdd\x01\x12\x1e\n\x19KEYCODE_MEDIA_AUDIO_TRACK\x10\xde\x01\x12\x12\n\rKEYCODE_SLEEP\x10\xdf\x01\x12\x13\n\x0eKEYCODE_WAKEUP\x10\xe0\x01\x12\x14\n\x0fKEYCODE_PAIRING\x10\xe1\x01\x12\x1b\n\x16KEYCODE_MEDIA_TOP_MENU\x10\xe2\x01\x12\x0f\n\nKEYCODE_11\x10\xe3\x01\x12\x0f\n\nKEYCODE_12\x10\xe4\x01\x12\x19\n\x14KEYCODE_LAST_CHANNEL\x10\xe5\x01\x12\x1c\n\x17KEYCODE_TV_DATA_SERVICE\x10\xe6\x01\x12\x19\n\x14KEYCODE_VOICE_ASSIST\x10\xe7\x01\x12\x1d\n\x18KEYCODE_TV_RADIO_SERVICE\x10\xe8\x01\x12\x18\n\x13KEYCODE_TV_TELETEXT\x10\xe9\x01\x12\x1c\n\x17KEYCODE_TV_NUMBER_ENTRY\x10\xea\x01\x12\"\n\x1dKEYCODE_TV_TERRESTRIAL_ANALOG\x10\xeb\x01\x12#\n\x1eKEYCODE_TV_TERRESTRIAL_DIGITAL\x10\xec\x01\x12\x19\n\x14KEYCODE_TV_SATELLITE\x10\xed\x01\x12\x1c\n\x17KEYCODE_TV_SATELLITE_BS\x10\xee\x01\x12\x1c\n\x17KEYCODE_TV_SATELLITE_CS\x10\xef\x01\x12!\n\x1cKEYCODE_TV_SATELLITE_SERVICE\x10\xf0\x01\x12\x17\n\x12KEYCODE_TV_NETWORK\x10\xf1\x01\x12\x1d\n\x18KEYCODE_TV_ANTENNA_CABLE\x10\xf2\x01\x12\x1c\n\x17KEYCODE_TV_INPUT_HDMI_1\x10\xf3\x01\x12\x1c\n\x17KEYCODE_TV_INPUT_HDMI_2\x10\xf4\x01\x12\x1c\n\x17KEYCODE_TV_INPUT_HDMI_3\x10\xf5\x01\x12\x1c\n\x17KEYCODE_TV_INPUT_HDMI_4\x10\xf6\x01\x12!\n\x1cKEYCODE_TV_INPUT_COMPOSITE_1\x10\xf7\x01\x12!\n\x1cKEYCODE_TV_INPUT_COMPOSITE_2\x10\xf8\x01\x12!\n\x1cKEYCODE_TV_INPUT_COMPONENT_1\x10\xf9\x01\x12!\n\x1cKEYCODE_TV_INPUT_COMPONENT_2\x10\xfa\x01\x12\x1b\n\x16KEYCODE_TV_INPUT_VGA_1\x10\xfb\x01\x12!\n\x1cKEYCODE_TV_AUDIO_DESCRIPTION\x10\xfc\x01\x12(\n#KEYCODE_TV_AUDIO_DESCRIPTION_MIX_UP\x10\xfd\x01\x12*\n%KEYCODE_TV_AUDIO_DESCRIPTION_MIX_DOWN\x10\xfe\x01\x12\x19\n\x14KEYCODE_TV_ZOOM_MODE\x10\xff\x01\x12\x1d\n\x18KEYCODE_TV_CONTENTS_MENU\x10\x80\x02\x12\"\n\x1dKEYCODE_TV_MEDIA_CONTEXT_MENU\x10\x81\x02\x12!\n\x1cKEYCODE_TV_TIMER_PROGRAMMING\x10\x82\x02\x12\x11\n\x0cKEYCODE_HELP\x10\x83\x02\x12\x1e\n\x19KEYCODE_NAVIGATE_PREVIOUS\x10\x84\x02\x12\x1a\n\x15KEYCODE_NAVIGATE_NEXT\x10\x85\x02\x12\x18\n\x13KEYCODE_NAVIGATE_IN\x10\x86\x02\x12\x19\n\x14KEYCODE_NAVIGATE_OUT\x10\x87\x02\x12\x19\n\x14KEYCODE_STEM_PRIMARY\x10\x88\x02\x12\x13\n\x0eKEYCODE_STEM_1\x10\x89\x02\x12\x13\n\x0eKEYCODE_STEM_2\x10\x8a\x02\x12\x13\n\x0eKEYCODE_STEM_3\x10\x8b\x02\x12\x19\n\x14KEYCODE_DPAD_UP_LEFT\x10\x8c\x02\x12\x1b\n\x16KEYCODE_DPAD_DOWN_LEFT\x10\x8d\x02\x12\x1a\n\x15KEYCODE_DPAD_UP_RIGHT\x10\x8e\x02\x12\x1c\n\x17KEYCODE_DPAD_DOWN_RIGHT\x10\x8f\x02\x12\x1f\n\x1aKEYCODE_MEDIA_SKIP_FORWARD\x10\x90\x02\x12 \n\x1bKEYCODE_MEDIA_SKIP_BACKWARD\x10\x91\x02\x12\x1f\n\x1aKEYCODE_MEDIA_STEP_FORWARD\x10\x92\x02\x12 \n\x1bKEYCODE_MEDIA_STEP_BACKWARD\x10\x93\x02\x12\x17\n\x12KEYCODE_SOFT_SLEEP\x10\x94\x02\x12\x10\n\x0bKEYCODE_CUT\x10\x95\x02\x12\x11\n\x0cKEYCODE_COPY\x10\x96\x02\x12\x12\n\rKEYCODE_PASTE\x10\x97\x02\x12!\n\x1cKEYCODE_SYSTEM_NAVIGATION_UP\x10\x98\x02\x12#\n\x1eKEYCODE_SYSTEM_NAVIGATION_DOWN\x10\x99\x02\x12#\n\x1eKEYCODE_SYSTEM_NAVIGATION_LEFT\x10\x9a\x02\x12$\n\x1fKEYCODE_SYSTEM_NAVIGATION_RIGHT\x10\x9b\x02\x12\x15\n\x10KEYCODE_ALL_APPS\x10\x9c\x02\x12\x14\n\x0fKEYCODE_REFRESH\x10\x9d\x02\x12\x16\n\x11KEYCODE_THUMBS_UP\x10\x9e\x02\x12\x18\n\x13KEYCODE_THUMBS_DOWN\x10\x9f\x02\x12\x1b\n\x16KEYCODE_PROFILE_SWITCH\x10\xa0\x02\x12\x18\n\x13KEYCODE_VIDEO_APP_1\x10\xa1\x02\x12\x18\n\x13KEYCODE_VIDEO_APP_2\x10\xa2\x02\x12\x18\n\x13KEYCODE_VIDEO_APP_3\x10\xa3\x02\x12\x18\n\x13KEYCODE_VIDEO_APP_4\x10\xa4\x02\x12\x18\n\x13KEYCODE_VIDEO_APP_5\x10\xa5\x02\x12\x18\n\x13KEYCODE_VIDEO_APP_6\x10\xa6\x02\x12\x18\n\x13KEYCODE_VIDEO_APP_7\x10\xa7\x02\x12\x18\n\x13KEYCODE_VIDEO_APP_8\x10\xa8\x02\x12\x1b\n\x16KEYCODE_FEATURED_APP_1\x10\xa9\x02\x12\x1b\n\x16KEYCODE_FEATURED_APP_2\x10\xaa\x02\x12\x1b\n\x16KEYCODE_FEATURED_APP_3\x10\xab\x02\x12\x1b\n\x16KEYCODE_FEATURED_APP_4\x10\xac\x02\x12\x17\n\x12KEYCODE_DEMO_APP_1\x10\xad\x02\x12\x17\n\x12KEYCODE_DEMO_APP_2\x10\xae\x02\x12\x17\n\x12KEYCODE_DEMO_APP_3\x10\xaf\x02\x12\x17\n\x12KEYCODE_DEMO_APP_4\x10\xb0\x02*Q\n\x0fRemoteDirection\x12\x15\n\x11UNKNOWN_DIRECTION\x10\x00\x12\x0e\n\nSTART_LONG\x10\x01\x12\x0c\n\x08\x45ND_LONG\x10\x02\x12\t\n\x05SHORT\x10\x03\x62\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'remotemessage_pb2', _globals) -if _descriptor._USE_C_DESCRIPTORS == False: - DESCRIPTOR._options = None - _globals['_REMOTEKEYCODE']._serialized_start=2791 - _globals['_REMOTEKEYCODE']._serialized_end=9934 - _globals['_REMOTEDIRECTION']._serialized_start=9936 - _globals['_REMOTEDIRECTION']._serialized_end=10017 +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_REMOTEKEYCODE']._serialized_start=2890 + _globals['_REMOTEKEYCODE']._serialized_end=10033 + _globals['_REMOTEDIRECTION']._serialized_start=10035 + _globals['_REMOTEDIRECTION']._serialized_end=10116 _globals['_REMOTEAPPLINKLAUNCHREQUEST']._serialized_start=31 _globals['_REMOTEAPPLINKLAUNCHREQUEST']._serialized_end=77 _globals['_REMOTERESETPREFERREDAUDIODEVICE']._serialized_start=79 @@ -37,39 +37,39 @@ _globals['_REMOTESTART']._serialized_start=357 _globals['_REMOTESTART']._serialized_end=387 _globals['_REMOTEVOICEEND']._serialized_start=389 - _globals['_REMOTEVOICEEND']._serialized_end=405 - _globals['_REMOTEVOICEPAYLOAD']._serialized_start=407 - _globals['_REMOTEVOICEPAYLOAD']._serialized_end=427 - _globals['_REMOTEVOICEBEGIN']._serialized_start=429 - _globals['_REMOTEVOICEBEGIN']._serialized_end=447 - _globals['_REMOTETEXTFIELDSTATUS']._serialized_start=449 - _globals['_REMOTETEXTFIELDSTATUS']._serialized_end=567 - _globals['_REMOTEIMESHOWREQUEST']._serialized_start=569 - _globals['_REMOTEIMESHOWREQUEST']._serialized_end=656 - _globals['_REMOTEEDITINFO']._serialized_start=658 - _globals['_REMOTEEDITINFO']._serialized_end=742 - _globals['_REMOTEIMEOBJECT']._serialized_start=744 - _globals['_REMOTEIMEOBJECT']._serialized_end=804 - _globals['_REMOTEIMEBATCHEDIT']._serialized_start=806 - _globals['_REMOTEIMEBATCHEDIT']._serialized_end=913 - _globals['_REMOTEAPPINFO']._serialized_start=916 - _globals['_REMOTEAPPINFO']._serialized_end=1069 - _globals['_REMOTEIMEKEYINJECT']._serialized_start=1071 - _globals['_REMOTEIMEKEYINJECT']._serialized_end=1190 - _globals['_REMOTEKEYINJECT']._serialized_start=1192 - _globals['_REMOTEKEYINJECT']._serialized_end=1294 - _globals['_REMOTEPINGRESPONSE']._serialized_start=1296 - _globals['_REMOTEPINGRESPONSE']._serialized_end=1330 - _globals['_REMOTEPINGREQUEST']._serialized_start=1332 - _globals['_REMOTEPINGREQUEST']._serialized_end=1379 - _globals['_REMOTESETACTIVE']._serialized_start=1381 - _globals['_REMOTESETACTIVE']._serialized_end=1414 - _globals['_REMOTEDEVICEINFO']._serialized_start=1417 - _globals['_REMOTEDEVICEINFO']._serialized_end=1545 - _globals['_REMOTECONFIGURE']._serialized_start=1547 - _globals['_REMOTECONFIGURE']._serialized_end=1626 - _globals['_REMOTEERROR']._serialized_start=1628 - _globals['_REMOTEERROR']._serialized_end=1696 - _globals['_REMOTEMESSAGE']._serialized_start=1699 - _globals['_REMOTEMESSAGE']._serialized_end=2788 + _globals['_REMOTEVOICEEND']._serialized_end=425 + _globals['_REMOTEVOICEPAYLOAD']._serialized_start=427 + _globals['_REMOTEVOICEPAYLOAD']._serialized_end=484 + _globals['_REMOTEVOICEBEGIN']._serialized_start=486 + _globals['_REMOTEVOICEBEGIN']._serialized_end=546 + _globals['_REMOTETEXTFIELDSTATUS']._serialized_start=548 + _globals['_REMOTETEXTFIELDSTATUS']._serialized_end=666 + _globals['_REMOTEIMESHOWREQUEST']._serialized_start=668 + _globals['_REMOTEIMESHOWREQUEST']._serialized_end=755 + _globals['_REMOTEEDITINFO']._serialized_start=757 + _globals['_REMOTEEDITINFO']._serialized_end=841 + _globals['_REMOTEIMEOBJECT']._serialized_start=843 + _globals['_REMOTEIMEOBJECT']._serialized_end=903 + _globals['_REMOTEIMEBATCHEDIT']._serialized_start=905 + _globals['_REMOTEIMEBATCHEDIT']._serialized_end=1012 + _globals['_REMOTEAPPINFO']._serialized_start=1015 + _globals['_REMOTEAPPINFO']._serialized_end=1168 + _globals['_REMOTEIMEKEYINJECT']._serialized_start=1170 + _globals['_REMOTEIMEKEYINJECT']._serialized_end=1289 + _globals['_REMOTEKEYINJECT']._serialized_start=1291 + _globals['_REMOTEKEYINJECT']._serialized_end=1393 + _globals['_REMOTEPINGRESPONSE']._serialized_start=1395 + _globals['_REMOTEPINGRESPONSE']._serialized_end=1429 + _globals['_REMOTEPINGREQUEST']._serialized_start=1431 + _globals['_REMOTEPINGREQUEST']._serialized_end=1478 + _globals['_REMOTESETACTIVE']._serialized_start=1480 + _globals['_REMOTESETACTIVE']._serialized_end=1513 + _globals['_REMOTEDEVICEINFO']._serialized_start=1516 + _globals['_REMOTEDEVICEINFO']._serialized_end=1644 + _globals['_REMOTECONFIGURE']._serialized_start=1646 + _globals['_REMOTECONFIGURE']._serialized_end=1725 + _globals['_REMOTEERROR']._serialized_start=1727 + _globals['_REMOTEERROR']._serialized_end=1795 + _globals['_REMOTEMESSAGE']._serialized_start=1798 + _globals['_REMOTEMESSAGE']._serialized_end=2887 # @@protoc_insertion_point(module_scope) diff --git a/src/androidtvremote2/remotemessage_pb2.pyi b/src/androidtvremote2/remotemessage_pb2.pyi index 80c1a2e..f6eda85 100644 --- a/src/androidtvremote2/remotemessage_pb2.pyi +++ b/src/androidtvremote2/remotemessage_pb2.pyi @@ -1863,9 +1863,14 @@ global___RemoteStart = RemoteStart class RemoteVoiceEnd(google.protobuf.message.Message): DESCRIPTOR: google.protobuf.descriptor.Descriptor + SESSION_ID_FIELD_NUMBER: builtins.int + session_id: builtins.int def __init__( self, + *, + session_id: builtins.int = ..., ) -> None: ... + def ClearField(self, field_name: typing.Literal["session_id", b"session_id"]) -> None: ... global___RemoteVoiceEnd = RemoteVoiceEnd @@ -1873,9 +1878,20 @@ global___RemoteVoiceEnd = RemoteVoiceEnd class RemoteVoicePayload(google.protobuf.message.Message): DESCRIPTOR: google.protobuf.descriptor.Descriptor + SESSION_ID_FIELD_NUMBER: builtins.int + SAMPLES_FIELD_NUMBER: builtins.int + session_id: builtins.int + samples: builtins.bytes + """Audio configuration in RemoteVoiceBegin is unknown. + Default audio sample payload is a sequence of 16-bit PCM, 8 kHz, mono samples, split into 20 KB messages. + """ def __init__( self, + *, + session_id: builtins.int = ..., + samples: builtins.bytes = ..., ) -> None: ... + def ClearField(self, field_name: typing.Literal["samples", b"samples", "session_id", b"session_id"]) -> None: ... global___RemoteVoicePayload = RemoteVoicePayload @@ -1883,9 +1899,18 @@ global___RemoteVoicePayload = RemoteVoicePayload class RemoteVoiceBegin(google.protobuf.message.Message): DESCRIPTOR: google.protobuf.descriptor.Descriptor + SESSION_ID_FIELD_NUMBER: builtins.int + PACKAGE_NAME_FIELD_NUMBER: builtins.int + session_id: builtins.int + package_name: builtins.str + """Package name is sent from the Android device as a response to sending KEYCODE_SEARCH and not required when sending audio.""" def __init__( self, + *, + session_id: builtins.int = ..., + package_name: builtins.str = ..., ) -> None: ... + def ClearField(self, field_name: typing.Literal["package_name", b"package_name", "session_id", b"session_id"]) -> None: ... global___RemoteVoiceBegin = RemoteVoiceBegin diff --git a/src/androidtvremote2/voice_stream.py b/src/androidtvremote2/voice_stream.py new file mode 100644 index 0000000..10ab8f2 --- /dev/null +++ b/src/androidtvremote2/voice_stream.py @@ -0,0 +1,74 @@ +"""High-level voice streaming session wrapper.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from androidtvremote2.const import LOGGER + +if TYPE_CHECKING: + from types import TracebackType + + from androidtvremote2.remote import RemoteProtocol + + +class VoiceStream: + """High-level voice streaming session wrapper. + + Obtained from AndroidTVRemote.start_voice(). + """ + + def __init__(self, proto: RemoteProtocol, session_id: int) -> None: + """Initialize. + + param proto: RemoteProtocol instance. + param session_id: voice session id. + """ + self._proto = proto + self.session_id = session_id + self._closed = False + + def send_chunk(self, chunk: bytes) -> bool: + """Send a chunk of audio data. + + - The audio data must be 16-bit PCM, mono, 8000 Hz. + - The chunk size should be at least 8 KB. Smaller chunks will be padded with zeros. + - Chunk sizes larger than 20 KB will be split into multiple chunks. + + :param chunk: The PCM audio data chunk to be sent. Should be a multiple of 8 KB. + :return: False if a voice session is closed. + :raises ConnectionClosed: If the connection is lost. + """ + if self._closed: + LOGGER.debug("VoiceStream already closed") + return False + self._proto.send_voice_chunk(chunk, self.session_id) + return True + + def end(self) -> None: + """End the voice stream.""" + if not self._closed: + self._proto.end_voice(self.session_id) + self._closed = True + + async def __aenter__(self) -> VoiceStream: + """Enter async context manager.""" + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + """End the asynchronous context manager session. + + This method implements the asynchronous exit for a context manager. It is called + when execution leaves the `async with` block that the instance of the class + is managing. + + :param exc_type: The exception type raised within the context block if an exception occurred. + :param exc: The exception object raised within the context block if an exception was raised. + :param tb: The traceback object associated with the raised exception, if any. + """ + self.end() diff --git a/src/demo.py b/src/demo.py index 6af4845..abbdc73 100644 --- a/src/demo.py +++ b/src/demo.py @@ -1,12 +1,16 @@ -# ruff: noqa: T201, PLR0912 +# ruff: noqa: T201, PLR0912, PLR0915 """Demo usage of AndroidTVRemote.""" import argparse import asyncio import logging +import os import sys +import time +import wave from typing import cast +import pyaudio from pynput import keyboard from zeroconf import ServiceStateChange, Zeroconf from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf @@ -21,6 +25,14 @@ _LOGGER = logging.getLogger(__name__) +VOICE_ENABLED = True +VOICE_RECORD_SECONDS = 5 +VOICE_STREAM_SECONDS = 10 +VOICE_FORMAT = pyaudio.paInt16 +VOICE_CHANNELS = 1 +VOICE_RATE = 8000 +VOICE_FILE = "voice_command.wav" + async def _bind_keyboard(remote: AndroidTVRemote) -> None: print( @@ -39,7 +51,11 @@ async def _bind_keyboard(remote: AndroidTVRemote) -> None: "\n- 'a': Amazon Prime Video" "\n- 'k': Kodi" "\n- 'q': quit" - "\n- 't': send text 'Hello world' to Android TV\n\n" + "\n- 't': send text 'Hello world' to Android TV" + "\n- 'v': stream a voice command from the default audio input. Press v again to stop streaming." + "\n- 'r': record a " + str(VOICE_RECORD_SECONDS) + "s voice command" + "\n- 'p': play back pre-recorded voice command" + "\n- 'w': send pre-recorded voice command in " + VOICE_FILE + " to Android TV\n\n" ) key_mappings = { keyboard.Key.up: "DPAD_UP", @@ -65,6 +81,8 @@ def on_press(key: keyboard.Key | keyboard.KeyCode | None) -> None: return queue key_queue = transmit_keys() + voice_task: asyncio.Task[None] | None = None + voice_stop_event = asyncio.Event() while True: key = await key_queue.get() if key is None: @@ -94,6 +112,20 @@ def on_press(key: keyboard.Key | keyboard.KeyCode | None) -> None: remote.send_launch_app_command("org.xbmc.kodi") elif key.char == "t": remote.send_text("Hello World!") + if key.char == "r": + _record_voice_command(VOICE_FILE) + elif key.char == "p": + _play_voice_command(VOICE_FILE) + elif key.char == "v": + if voice_task is not None and not voice_task.done(): + print("Stopping voice recording") + voice_stop_event.set() + continue + print("Starting voice recording. Press v again to stop. Auto stop after " + str(VOICE_STREAM_SECONDS) + "s") + voice_stop_event.clear() + voice_task = asyncio.get_event_loop().create_task(_stream_voice(remote, voice_stop_event)) + elif key.char == "w": + await _send_voice(VOICE_FILE, remote) async def _host_from_zeroconf(timeout: float) -> str: @@ -186,7 +218,7 @@ async def _main() -> None: host = args.host or await _host_from_zeroconf(args.scan_timeout) - remote = AndroidTVRemote(args.client_name, args.certfile, args.keyfile, host) + remote = AndroidTVRemote(args.client_name, args.certfile, args.keyfile, host, enable_voice=VOICE_ENABLED) if await remote.async_generate_cert_if_missing(): _LOGGER.info("Generated new certificate") @@ -209,6 +241,7 @@ async def _main() -> None: _LOGGER.info("is_on: %s", remote.is_on) _LOGGER.info("current_app: %s", remote.current_app) _LOGGER.info("volume_info: %s", remote.volume_info) + _LOGGER.info("voice enabled: %s", remote.is_voice_enabled) def is_on_updated(is_on: bool) -> None: _LOGGER.info("Notified that is_on: %s", is_on) @@ -230,4 +263,146 @@ def is_available_updated(is_available: bool) -> None: await _bind_keyboard(remote) +async def _send_voice(wav_file: str, remote: AndroidTVRemote) -> None: + """Send a WAV file as a voice command.""" + if not os.path.isfile(wav_file): + _LOGGER.error("WAV file not found: %s", wav_file) + return + if not remote.is_voice_enabled: + _LOGGER.warning("Voice feature is not enabled in the client or not supported on the device") + return + + try: + with wave.open(wav_file, "rb") as wf: + if wf.getnchannels() != 1: + _LOGGER.error("Only mono WAV files are supported") + return + if wf.getsampwidth() != 2: + _LOGGER.error("Only 16-bit WAV files are supported") + return + if wf.getframerate() != 8000: + _LOGGER.error("Only 8 kHz WAV files are supported") + return + nframes = wf.getnframes() + pcm_data = wf.readframes(nframes) + + _LOGGER.debug("Loaded WAV file '%s': frames=%d, bytes=%d", wav_file, nframes, len(pcm_data)) + + async with await remote.start_voice() as session: + session.send_chunk(pcm_data) + except FileNotFoundError: + _LOGGER.exception("WAV file not found") + except wave.Error: + _LOGGER.exception("Invalid/unsupported WAV file %s", wav_file) + except asyncio.TimeoutError: + _LOGGER.warning("Timeout: could not start voice session") + except Exception: + _LOGGER.exception("Unexpected error in send_voice") + + +def _record_voice_command(wav_file: str) -> None: + """Record a WAV file from the default audio input.""" + with wave.open(wav_file, "wb") as wf: + p = pyaudio.PyAudio() + wf.setnchannels(VOICE_CHANNELS) + wf.setsampwidth(p.get_sample_size(VOICE_FORMAT)) + wf.setframerate(VOICE_RATE) + + def callback(in_data: bytes, frame_count: object, time_info: object, status: object) -> tuple[bytes | None, int]: + wf.writeframes(in_data) + return None, pyaudio.paContinue + + stream = p.open(format=VOICE_FORMAT, channels=VOICE_CHANNELS, rate=VOICE_RATE, input=True, stream_callback=callback) + + print("Recording " + str(VOICE_RECORD_SECONDS) + "s voice command to:", wav_file) + start = time.time() + while stream.is_active() and (time.time() - start) < VOICE_RECORD_SECONDS: + time.sleep(0.1) + print("Recording stopped") + + stream.close() + p.terminate() + + +def _play_voice_command(wav_file: str) -> None: + """Play a WAV file on the default audio output.""" + if not os.path.isfile(wav_file): + _LOGGER.error("WAV file not found: %s", wav_file) + return + print("Playing back recorded voice command:", wav_file) + with wave.open(wav_file, "rb") as wf: + p = pyaudio.PyAudio() + stream = p.open( + format=p.get_format_from_width(wf.getsampwidth()), channels=wf.getnchannels(), rate=wf.getframerate(), output=True + ) + + chunk_size = 1024 + while len(data := wf.readframes(chunk_size)): + stream.write(data) + + stream.close() + p.terminate() + + +async def _stream_voice(remote: AndroidTVRemote, stop_event: asyncio.Event) -> None: + """Record from the default audio input and stream as a voice command.""" + chunk_size = 8 * 1024 + + if not remote.is_voice_enabled: + _LOGGER.warning("Voice feature is not enabled in the client or not supported on the device") + return + + # Start a streaming voice session + # Context manager calls session.end() automatically + try: + async with await remote.start_voice() as session: + if not remote.is_voice_enabled: + _LOGGER.warning("Voice feature is not enabled in the client or not supported on the device") + return + + def callback(in_data: bytes, frame_count: object, time_info: object, status: object) -> tuple[bytes | None, int]: + _LOGGER.debug("MIC callback: frame_count=%d, time_info=%s, status=%s", frame_count, time_info, status) + session.send_chunk(in_data) + return None, pyaudio.paContinue + + _LOGGER.info("Voice session established, opening microphone...") + p = pyaudio.PyAudio() + stream = p.open( + format=VOICE_FORMAT, + channels=VOICE_CHANNELS, + rate=VOICE_RATE, + input=True, + frames_per_buffer=chunk_size, + stream_callback=callback, + ) + + _LOGGER.info("Recording started, sending data to Android TV...") + start = time.time() + + # Use run_in_executor to check stream status without blocking + loop = asyncio.get_event_loop() + # Wait until timeout, stop_event is set, or stream becomes inactive + while (time.time() - start) < VOICE_RECORD_SECONDS: + if stop_event.is_set(): + _LOGGER.debug("Recording stopped by external event") + break + + if not await loop.run_in_executor(None, stream.is_active): + _LOGGER.debug("Recording stopped: stream became inactive") + break + + try: + await asyncio.wait_for(stop_event.wait(), timeout=0.25) + break + except asyncio.TimeoutError: + pass + + print("Voice data sent, closing microphone") + + stream.close() + p.terminate() + except asyncio.TimeoutError: + print("Timeout: could not start voice session") + + asyncio.run(_main(), debug=True) From 13cdad707b6bcfb029c5616a414f3413ec457df8 Mon Sep 17 00:00:00 2001 From: Markus Zehnder Date: Sat, 1 Nov 2025 10:44:34 +0100 Subject: [PATCH 2/2] fixup! Add streaming voice command support PR feedback --- src/androidtvremote2/androidtv_remote.py | 8 +++++--- src/androidtvremote2/remote.py | 13 +++++++++++-- src/demo.py | 4 ++-- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/androidtvremote2/androidtv_remote.py b/src/androidtvremote2/androidtv_remote.py index c1354f4..ff0e4be 100644 --- a/src/androidtvremote2/androidtv_remote.py +++ b/src/androidtvremote2/androidtv_remote.py @@ -420,12 +420,14 @@ def send_launch_app_command(self, app_link_or_app_id: str) -> None: async def start_voice(self, timeout: float = VOICE_SESSION_TIMEOUT) -> VoiceStream: """Start a streaming voice session. - A ``VoiceStream`` wrapper is returned if the voice session can be established within the - given timeout. + A ``VoiceStream`` session wrapper is returned if the voice session can be established + within the given timeout. The session needs to be closed with ``end()`` (or through the + asynchronous context manager) before a new session is started. :param timeout: optional timeout for session readiness. Defaults to 2 seconds. :raises ConnectionClosed: if client is disconnected. - :raises asyncio.TimeoutError: if the device does not begin voice in time. + :raises asyncio.TimeoutError: if the device does not begin voice in time, or a voice + session is already in progress. """ if not self._remote_message_protocol: LOGGER.debug("Called start_voice after disconnect") diff --git a/src/androidtvremote2/remote.py b/src/androidtvremote2/remote.py index 0b3eef9..ae2d137 100644 --- a/src/androidtvremote2/remote.py +++ b/src/androidtvremote2/remote.py @@ -115,6 +115,7 @@ def __init__( self._loop = loop self._idle_disconnect_task: asyncio.Task[None] | None = None self._reset_idle_disconnect_task() + self._voice_lock = asyncio.Lock() self._on_voice_begin: asyncio.Future[int] | None = None @property @@ -196,12 +197,18 @@ async def start_voice(self, timeout: float = VOICE_SESSION_TIMEOUT) -> int: :param timeout: Optional timeout in seconds for session readiness. Defaults to 2 seconds. :raises ConnectionClosed: If the connection is lost. - :raises asyncio.TimeoutError: If the operation times out. + :raises asyncio.TimeoutError: If the operation times out or a voice session is already in + progress. :return: The voice session id, which must be used in later calls to ``send_voice_chunk``. """ if self.transport is None or self.transport.is_closing(): raise ConnectionClosed("Connection has been lost") + if self._voice_lock.locked(): + raise asyncio.TimeoutError("Voice session already in progress") + + await self._voice_lock.acquire() + self._on_voice_begin = self._loop.create_future() try: self.send_key_command(RemoteKeyCode.KEYCODE_SEARCH) @@ -216,6 +223,8 @@ async def start_voice(self, timeout: float = VOICE_SESSION_TIMEOUT) -> int: except: self._on_voice_begin = None raise + finally: + self._voice_lock.release() def send_voice_chunk(self, chunk: bytes, session_id: int) -> None: """Send a chunk of PCM audio for the active voice session. @@ -355,7 +364,7 @@ async def _async_wait_for_future_or_con_lost(self, future: asyncio.Future[Any], # Check if future completed successfully if future.done(): if future.exception(): - raise ConnectionClosed(future.exception()) + raise ConnectionClosed("Waiting for future failed") from future.exception() return future.result() raise ConnectionClosed("Connection has been lost") diff --git a/src/demo.py b/src/demo.py index abbdc73..34604c6 100644 --- a/src/demo.py +++ b/src/demo.py @@ -401,8 +401,8 @@ def callback(in_data: bytes, frame_count: object, time_info: object, status: obj stream.close() p.terminate() - except asyncio.TimeoutError: - print("Timeout: could not start voice session") + except asyncio.TimeoutError as e: + print("Timeout: could not start voice session.", e) asyncio.run(_main(), debug=True)