diff --git a/homeassistant/components/smlight/__init__.py b/homeassistant/components/smlight/__init__.py index a6d7bbd14ea..b815b4d74a2 100644 --- a/homeassistant/components/smlight/__init__.py +++ b/homeassistant/components/smlight/__init__.py @@ -19,6 +19,7 @@ PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.LIGHT, + Platform.REMOTE, Platform.SENSOR, Platform.SWITCH, Platform.UPDATE, diff --git a/homeassistant/components/smlight/remote.py b/homeassistant/components/smlight/remote.py new file mode 100644 index 00000000000..4976c7688f2 --- /dev/null +++ b/homeassistant/components/smlight/remote.py @@ -0,0 +1,70 @@ +"""Remote platform for SLZB-Ultima.""" + +import asyncio +from collections.abc import Iterable +from typing import Any + +from pysmlight.exceptions import SmlightError +from pysmlight.models import IRPayload + +from homeassistant.components.remote import ( + ATTR_DELAY_SECS, + ATTR_NUM_REPEATS, + DEFAULT_DELAY_SECS, + DEFAULT_NUM_REPEATS, + RemoteEntity, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN +from .coordinator import SmConfigEntry, SmDataUpdateCoordinator +from .entity import SmEntity + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: SmConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Initialize remote for SLZB-Ultima device.""" + coordinator = entry.runtime_data.data + + if coordinator.data.info.has_peripherals: + async_add_entities([SmRemoteEntity(coordinator)]) + + +class SmRemoteEntity(SmEntity, RemoteEntity): + """Representation of a SLZB-Ultima remote.""" + + _attr_translation_key = "remote" + _attr_is_on = True + + def __init__(self, coordinator: SmDataUpdateCoordinator) -> None: + """Initialize the SLZB-Ultima remote.""" + super().__init__(coordinator) + self._attr_unique_id = f"{coordinator.unique_id}-remote" + + async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: + """Send a sequence of commands to a device.""" + num_repeats = kwargs.get(ATTR_NUM_REPEATS, DEFAULT_NUM_REPEATS) + delay_secs = kwargs.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS) + + for _ in range(num_repeats): + for cmd in command: + try: + await self.coordinator.async_execute_command( + self.coordinator.client.actions.send_ir_code, + IRPayload(code=cmd), + ) + except SmlightError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="send_ir_code_failed", + translation_placeholders={"error": str(err)}, + ) from err + + await asyncio.sleep(delay_secs) diff --git a/homeassistant/components/smlight/strings.json b/homeassistant/components/smlight/strings.json index 6fbac239207..10310d4c6ef 100644 --- a/homeassistant/components/smlight/strings.json +++ b/homeassistant/components/smlight/strings.json @@ -84,6 +84,11 @@ "name": "Ambilight" } }, + "remote": { + "remote": { + "name": "IR Remote" + } + }, "sensor": { "core_temperature": { "name": "Core chip temp" @@ -159,6 +164,9 @@ }, "firmware_update_failed": { "message": "Firmware update failed for {device_name}." + }, + "send_ir_code_failed": { + "message": "Failed to send IR code: {error}." } }, "issues": { diff --git a/tests/components/smlight/test_remote.py b/tests/components/smlight/test_remote.py new file mode 100644 index 00000000000..26382f08337 --- /dev/null +++ b/tests/components/smlight/test_remote.py @@ -0,0 +1,157 @@ +"""Tests for SLZB-Ultima remote entity.""" + +from unittest.mock import MagicMock, patch + +from pysmlight import Info +from pysmlight.exceptions import SmlightError +from pysmlight.models import IRPayload +import pytest + +from homeassistant.components.remote import ( + ATTR_COMMAND, + ATTR_DELAY_SECS, + ATTR_NUM_REPEATS, + DOMAIN as REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from .conftest import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.fixture +def platforms() -> list[Platform]: + """Platforms, which should be loaded during the test.""" + return [Platform.REMOTE] + + +MOCK_ULTIMA = Info( + MAC="AA:BB:CC:DD:EE:FF", + model="SLZB-Ultima3", +) + + +async def test_remote_setup_ultima( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test remote entity is created for Ultima devices.""" + mock_smlight_client.get_info.side_effect = None + mock_smlight_client.get_info.return_value = MOCK_ULTIMA + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("remote.mock_title_ir_remote") + assert state is not None + + +async def test_remote_not_created_non_ultima( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test remote entity is not created for non-Ultima devices.""" + mock_smlight_client.get_info.side_effect = None + mock_smlight_client.get_info.return_value = Info( + MAC="AA:BB:CC:DD:EE:FF", + model="SLZB-MR1", + ) + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("remote.mock_title_ir_remote") + assert state is None + + +async def test_remote_send_command( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test sending IR command.""" + mock_smlight_client.get_info.side_effect = None + mock_smlight_client.get_info.return_value = MOCK_ULTIMA + await setup_integration(hass, mock_config_entry) + + entity_id = "remote.mock_title_ir_remote" + state = hass.states.get(entity_id) + assert state is not None + + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, + { + ATTR_ENTITY_ID: entity_id, + ATTR_COMMAND: ["my_code", "another_code"], + ATTR_DELAY_SECS: 0, + }, + blocking=True, + ) + + assert mock_smlight_client.actions.send_ir_code.call_count == 2 + mock_smlight_client.actions.send_ir_code.assert_any_call(IRPayload(code="my_code")) + mock_smlight_client.actions.send_ir_code.assert_any_call( + IRPayload(code="another_code") + ) + + +async def test_remote_send_command_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test connection error handling.""" + mock_smlight_client.get_info.side_effect = None + mock_smlight_client.get_info.return_value = MOCK_ULTIMA + await setup_integration(hass, mock_config_entry) + + entity_id = "remote.mock_title_ir_remote" + state = hass.states.get(entity_id) + assert state is not None + + mock_smlight_client.actions.send_ir_code.side_effect = SmlightError("Failed") + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, + {ATTR_ENTITY_ID: entity_id, ATTR_COMMAND: ["my_code"]}, + blocking=True, + ) + assert exc_info.value.translation_key == "send_ir_code_failed" + + +@patch("homeassistant.components.smlight.remote.asyncio.sleep") +async def test_remote_send_command_repeats( + mock_sleep: MagicMock, + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_smlight_client: MagicMock, +) -> None: + """Test sending IR command with repeats and delay.""" + mock_smlight_client.get_info.side_effect = None + mock_smlight_client.get_info.return_value = MOCK_ULTIMA + await setup_integration(hass, mock_config_entry) + + entity_id = "remote.mock_title_ir_remote" + state = hass.states.get(entity_id) + assert state is not None + + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, + { + ATTR_ENTITY_ID: entity_id, + ATTR_COMMAND: ["my_code", "another_code"], + ATTR_NUM_REPEATS: 2, + ATTR_DELAY_SECS: 0.5, + }, + blocking=True, + ) + + assert mock_smlight_client.actions.send_ir_code.call_count == 4 + assert mock_sleep.call_count == 5 + mock_sleep.assert_called_with(0.5)