Add direction prefix to androidtv_remote send_command (#170117)

This commit is contained in:
Paul Bütof
2026-08-20 03:32:42 -04:00
committed by GitHub
parent 62c8b01066
commit e778e673ef
3 changed files with 212 additions and 6 deletions
@@ -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)
@@ -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}"
},
@@ -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: