diff --git a/homeassistant/components/androidtv_remote/remote.py b/homeassistant/components/androidtv_remote/remote.py index 7ecf4b3edf66..8a22a3fbccfc 100644 --- a/homeassistant/components/androidtv_remote/remote.py +++ b/homeassistant/components/androidtv_remote/remote.py @@ -2,7 +2,7 @@ import asyncio from collections.abc import Iterable -from typing import Any, override +from typing import Any, Final, override from homeassistant.components.remote import ( ATTR_ACTIVITY, @@ -16,14 +16,35 @@ from homeassistant.components.remote import ( RemoteEntityFeature, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import CONF_APP_NAME +from .const import CONF_APP_NAME, DOMAIN from .entity import AndroidTVRemoteBaseEntity from .helpers import AndroidTVRemoteConfigEntry PARALLEL_UPDATES = 0 +PREFIX_SEPARATOR: Final[str] = ":" +# Only direction prefixes are stripped; other colon conventions (e.g. text:) pass through to the library unchanged. +VALID_PREFIXES: Final[frozenset[str]] = frozenset( + { + "SHORT", + "START_LONG", + "END_LONG", + } +) + + +def _parse_command(single_command: str) -> tuple[str, str | None]: + """Split an optional prefix from the key code.""" + prefix, separator, rest = single_command.partition(PREFIX_SEPARATOR) + if separator: + normalized = prefix.upper() + if normalized in VALID_PREFIXES: + return rest, normalized + return single_command, None + async def async_setup_entry( hass: HomeAssistant, @@ -105,10 +126,24 @@ class AndroidTVRemoteEntity(AndroidTVRemoteBaseEntity, RemoteEntity): for _ in range(num_repeats): for single_command in command: + key_code, direction = _parse_command(single_command) + if direction is not None: + if not key_code: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="empty_key_code", + translation_placeholders={"command": single_command}, + ) + if hold_secs: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="direction_prefix_with_hold_secs", + translation_placeholders={"command": single_command}, + ) if hold_secs: - self._send_key_command(single_command, "START_LONG") + self._send_key_command(key_code, "START_LONG") await asyncio.sleep(hold_secs) - self._send_key_command(single_command, "END_LONG") + self._send_key_command(key_code, "END_LONG") else: - self._send_key_command(single_command, "SHORT") + self._send_key_command(key_code, direction or "SHORT") await asyncio.sleep(delay_secs) diff --git a/homeassistant/components/androidtv_remote/strings.json b/homeassistant/components/androidtv_remote/strings.json index e1d768f0adc4..e71f0ed7644b 100644 --- a/homeassistant/components/androidtv_remote/strings.json +++ b/homeassistant/components/androidtv_remote/strings.json @@ -56,6 +56,12 @@ "connection_closed": { "message": "Connection to the Android TV device is closed" }, + "direction_prefix_with_hold_secs": { + "message": "Command \"{command}\" combines a direction prefix with hold_secs; specify only one" + }, + "empty_key_code": { + "message": "Command \"{command}\" is missing a key code after the direction prefix" + }, "invalid_channel": { "message": "Channel must be numeric: {media_id}" }, diff --git a/tests/components/androidtv_remote/test_remote.py b/tests/components/androidtv_remote/test_remote.py index 9bd86bb3d856..2e006c4514aa 100644 --- a/tests/components/androidtv_remote/test_remote.py +++ b/tests/components/androidtv_remote/test_remote.py @@ -8,7 +8,7 @@ import pytest from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from tests.common import MockConfigEntry @@ -174,6 +174,171 @@ async def test_remote_send_command_with_hold_secs( ] +@pytest.mark.parametrize( + ("command", "expected_call"), + [ + ("start_long:DPAD_DOWN", call("DPAD_DOWN", "START_LONG")), + ("end_long:DPAD_DOWN", call("DPAD_DOWN", "END_LONG")), + ("short:DPAD_DOWN", call("DPAD_DOWN", "SHORT")), + ("START_LONG:DPAD_DOWN", call("DPAD_DOWN", "START_LONG")), + ], + ids=["start", "end", "short", "uppercase_prefix"], +) +async def test_remote_send_command_with_direction_prefix( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api: MagicMock, + command: str, + expected_call: object, +) -> None: + """Test remote.send_command emits a single directional event for prefixed commands.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.services.async_call( + "remote", + "send_command", + { + "entity_id": REMOTE_ENTITY, + "command": command, + "delay_secs": 0.01, + }, + blocking=True, + ) + assert mock_api.send_key_command.mock_calls == [expected_call] + + +@pytest.mark.parametrize( + "command", + [ + "text:hello world", + "voice:something", + "DPAD_DOWN:WITH_COLON", + ":leading_colon", + ], + ids=["text_prefix", "unknown_prefix", "embedded_colon", "leading_colon"], +) +async def test_remote_send_command_unknown_prefix_passes_through( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api: MagicMock, + command: str, +) -> None: + """Test that commands with non-direction colon prefixes are forwarded verbatim. + + The integration only strips prefixes that match the allowlist; + other colon-using conventions (notably the lib's own ``text:`` prefix for + keyboard text) must reach the underlying library unchanged so it can apply + its own routing. + """ + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.services.async_call( + "remote", + "send_command", + { + "entity_id": REMOTE_ENTITY, + "command": command, + "delay_secs": 0.01, + }, + blocking=True, + ) + assert mock_api.send_key_command.mock_calls == [call(command, "SHORT")] + + +async def test_remote_send_command_direction_prefix_pair( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test that a press-down/release-up pair produces exactly two events. + + This is the live-press scenario: a UI sends START_LONG on pointerdown and + END_LONG on pointerup as separate service calls. Together they must produce + no extra SHORT or sleep-driven events. + """ + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.services.async_call( + "remote", + "send_command", + { + "entity_id": REMOTE_ENTITY, + "command": "start_long:DPAD_CENTER", + "delay_secs": 0.01, + }, + blocking=True, + ) + await hass.services.async_call( + "remote", + "send_command", + { + "entity_id": REMOTE_ENTITY, + "command": "end_long:DPAD_CENTER", + "delay_secs": 0.01, + }, + blocking=True, + ) + assert mock_api.send_key_command.mock_calls == [ + call("DPAD_CENTER", "START_LONG"), + call("DPAD_CENTER", "END_LONG"), + ] + + +async def test_remote_send_command_direction_prefix_with_hold_secs_raises( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test that combining a direction prefix with hold_secs raises.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + + with pytest.raises( + ServiceValidationError, + match='Command "start_long:DPAD_RIGHT" combines a direction prefix with hold_secs', + ): + await hass.services.async_call( + "remote", + "send_command", + { + "entity_id": REMOTE_ENTITY, + "command": "start_long:DPAD_RIGHT", + "delay_secs": 0.01, + "hold_secs": 0.01, + }, + blocking=True, + ) + assert mock_api.send_key_command.mock_calls == [] + + +async def test_remote_send_command_empty_key_code_raises( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test that a direction prefix without a key code raises.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + + with pytest.raises( + ServiceValidationError, + match='Command "SHORT:" is missing a key code after the direction prefix', + ): + await hass.services.async_call( + "remote", + "send_command", + { + "entity_id": REMOTE_ENTITY, + "command": "SHORT:", + "delay_secs": 0.01, + }, + blocking=True, + ) + assert mock_api.send_key_command.mock_calls == [] + + async def test_remote_connection_closed( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock ) -> None: