mirror of
https://github.com/home-assistant/core.git
synced 2026-08-21 05:41:29 +01:00
Add direction prefix to androidtv_remote send_command (#170117)
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user