diff --git a/homeassistant/components/lyngdorf/const.py b/homeassistant/components/lyngdorf/const.py index 753f8a4560db..809e3edd3f34 100644 --- a/homeassistant/components/lyngdorf/const.py +++ b/homeassistant/components/lyngdorf/const.py @@ -8,6 +8,7 @@ DEFAULT_DEVICE_NAME = "Lyngdorf" PLATFORMS: list[Platform] = [ Platform.MEDIA_PLAYER, Platform.NUMBER, + Platform.REMOTE, Platform.SENSOR, ] CONF_SERIAL_NUMBER = "serial_number" diff --git a/homeassistant/components/lyngdorf/remote.py b/homeassistant/components/lyngdorf/remote.py new file mode 100644 index 000000000000..701d48562417 --- /dev/null +++ b/homeassistant/components/lyngdorf/remote.py @@ -0,0 +1,88 @@ +"""Remote platform for Lyngdorf integration.""" + +from collections.abc import Iterable +from typing import TYPE_CHECKING, Any, override + +from lyngdorf.device import Receiver +from lyngdorf.exceptions import LyngdorfUnsupportedError + +from homeassistant.components.remote import ATTR_NUM_REPEATS, RemoteEntity +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .entity import LyngdorfEntity +from .models import LyngdorfConfigEntry + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: LyngdorfConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Lyngdorf remote from a config entry.""" + runtime_data = config_entry.runtime_data + receiver = runtime_data.receiver + + # The TDAI family has no remote keys at all, so it gets no remote entity. + if not receiver.has_remote_keys: + return + + async_add_entities( + [LyngdorfRemote(receiver, config_entry, runtime_data.device_info)] + ) + + +class LyngdorfRemote(LyngdorfEntity, RemoteEntity): + """Lyngdorf remote entity.""" + + _attr_name = None + + def __init__( + self, + receiver: Receiver, + config_entry: LyngdorfConfigEntry, + device_info: DeviceInfo, + ) -> None: + """Initialize the remote.""" + super().__init__(receiver, device_info) + if TYPE_CHECKING: + assert config_entry.unique_id + self._attr_unique_id = config_entry.unique_id + + @override + @property + def is_on(self) -> bool | None: + """Return whether the device is on.""" + return self._receiver.power_on + + @override + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the device on.""" + self._receiver.power_on = True + + @override + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + self._receiver.power_on = False + + @override + async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: + """Send a sequence of remote keys to the device.""" + # delay_secs is dropped: the library already paces its own writes. + try: + self._receiver.send_remote_commands( + command, num_repeats=kwargs[ATTR_NUM_REPEATS] + ) + except LyngdorfUnsupportedError as err: + # The member value is what a caller sends: DIGIT_0 is "0", not "digit_0". + keys = sorted(key.value for key in self._receiver.available_remote_keys) + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="unsupported_remote_key", + translation_placeholders={"keys": ", ".join(keys)}, + ) from err diff --git a/homeassistant/components/lyngdorf/strings.json b/homeassistant/components/lyngdorf/strings.json index 6d314636f59d..958970773bf7 100644 --- a/homeassistant/components/lyngdorf/strings.json +++ b/homeassistant/components/lyngdorf/strings.json @@ -106,6 +106,9 @@ }, "setup_timeout": { "message": "Timeout connecting to {host}" + }, + "unsupported_remote_key": { + "message": "This device does not have that remote key. It supports: {keys}" } } } diff --git a/tests/components/lyngdorf/conftest.py b/tests/components/lyngdorf/conftest.py index 0b9fcd0aca4d..e1d7d25d6ea7 100644 --- a/tests/components/lyngdorf/conftest.py +++ b/tests/components/lyngdorf/conftest.py @@ -8,6 +8,7 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch from lyngdorf.const import LyngdorfModel from lyngdorf.device import Receiver from lyngdorf.models.base import NumericRange +from lyngdorf.remote import RemoteKey import pytest from homeassistant.components.lyngdorf.const import ( @@ -63,7 +64,16 @@ def mock_receiver() -> Generator[MagicMock]: receiver = MagicMock(spec=Receiver) receiver.name = "Mock Lyngdorf" receiver.connected = True - receiver.model = LyngdorfModel.MP_60 + receiver.has_remote_keys = True + receiver.available_remote_keys = frozenset( + { + RemoteKey.UP, + RemoteKey.DOWN, + RemoteKey.ENTER, + RemoteKey.MENU, + RemoteKey.DIGIT_0, + } + ) # Diagnostics reports the whole receiver, so every property it reads # needs a value here; an unset one is a mock the response cannot encode. diff --git a/tests/components/lyngdorf/snapshots/test_remote.ambr b/tests/components/lyngdorf/snapshots/test_remote.ambr new file mode 100644 index 000000000000..b2a9bb173da9 --- /dev/null +++ b/tests/components/lyngdorf/snapshots/test_remote.ambr @@ -0,0 +1,52 @@ +# serializer version: 1 +# name: test_entities[remote.mock_lyngdorf-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'remote', + 'entity_category': None, + 'entity_id': 'remote.mock_lyngdorf', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'lyngdorf', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '0050c27c76b2', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[remote.mock_lyngdorf-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Mock Lyngdorf', + : , + }), + 'context': , + 'entity_id': 'remote.mock_lyngdorf', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/lyngdorf/test_remote.py b/tests/components/lyngdorf/test_remote.py new file mode 100644 index 000000000000..d38ac70c5424 --- /dev/null +++ b/tests/components/lyngdorf/test_remote.py @@ -0,0 +1,163 @@ +"""Tests for the Lyngdorf remote platform.""" + +from unittest.mock import MagicMock, patch + +from lyngdorf.const import LyngdorfModel +from lyngdorf.exceptions import LyngdorfUnsupportedError +from lyngdorf.remote import RemoteKey, resolve_remote_key +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.remote import ( + ATTR_COMMAND, + ATTR_NUM_REPEATS, + DOMAIN as REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + +REMOTE = "remote.mock_lyngdorf" + + +@pytest.fixture +def platforms() -> list[Platform]: + """Only load the remote platform.""" + return [Platform.REMOTE] + + +async def test_entities( + hass: HomeAssistant, + init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test the remote entity.""" + await snapshot_platform(hass, entity_registry, snapshot, init_integration.entry_id) + + +@pytest.mark.usefixtures("init_integration") +async def test_send_command( + hass: HomeAssistant, + mock_receiver: MagicMock, +) -> None: + """Test sending a sequence of remote keys.""" + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, + { + ATTR_ENTITY_ID: REMOTE, + ATTR_COMMAND: [RemoteKey.MENU, RemoteKey.DOWN, RemoteKey.ENTER], + }, + blocking=True, + ) + + mock_receiver.send_remote_commands.assert_called_once_with( + [RemoteKey.MENU, RemoteKey.DOWN, RemoteKey.ENTER], num_repeats=1 + ) + + +@pytest.mark.usefixtures("init_integration") +async def test_send_command_repeats( + hass: HomeAssistant, + mock_receiver: MagicMock, +) -> None: + """Test the repeat count is passed through to the library.""" + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, + { + ATTR_ENTITY_ID: REMOTE, + ATTR_COMMAND: [RemoteKey.DOWN], + ATTR_NUM_REPEATS: 3, + }, + blocking=True, + ) + + mock_receiver.send_remote_commands.assert_called_once_with( + [RemoteKey.DOWN], num_repeats=3 + ) + + +@pytest.mark.usefixtures("init_integration") +async def test_send_unsupported_command( + hass: HomeAssistant, + mock_receiver: MagicMock, +) -> None: + """Test a key the model does not have is reported to the user.""" + mock_receiver.send_remote_commands.side_effect = LyngdorfUnsupportedError( + "no such key" + ) + + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, + {ATTR_ENTITY_ID: REMOTE, ATTR_COMMAND: ["nonsense"]}, + blocking=True, + ) + + assert err.value.translation_key == "unsupported_remote_key" + # Every key named in the message must be one the library will accept. + listed = err.value.translation_placeholders["keys"].split(", ") + assert listed + assert all(resolve_remote_key(key) is not None for key in listed) + + +@pytest.mark.parametrize( + ("service", "expected"), + [ + (SERVICE_TURN_ON, True), + (SERVICE_TURN_OFF, False), + ], +) +@pytest.mark.usefixtures("init_integration") +async def test_power( + hass: HomeAssistant, + mock_receiver: MagicMock, + service: str, + expected: bool, +) -> None: + """Test turning the device on and off from the remote.""" + await hass.services.async_call( + REMOTE_DOMAIN, + service, + {ATTR_ENTITY_ID: REMOTE}, + blocking=True, + ) + + assert mock_receiver.power_on is expected + + +@pytest.mark.usefixtures("mock_receiver") +async def test_no_entity_for_model_without_remote_keys( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_receiver: MagicMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test no remote entity is created for a model with no remote keys.""" + mock_receiver.has_remote_keys = False + mock_config_entry.add_to_hass(hass) + + with ( + patch( + "homeassistant.components.lyngdorf.lookup_receiver_model", + return_value=LyngdorfModel.TDAI_3400, + ), + patch("homeassistant.components.lyngdorf.PLATFORMS", [Platform.REMOTE]), + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(REMOTE) is None + assert entity_registry.async_get(REMOTE) is None