diff --git a/homeassistant/components/esphome/infrared.py b/homeassistant/components/esphome/infrared.py index 34bfdcf6f891..e8b40642ddae 100644 --- a/homeassistant/components/esphome/infrared.py +++ b/homeassistant/components/esphome/infrared.py @@ -24,6 +24,8 @@ PARALLEL_UPDATES = 0 class EsphomeInfraredEntity(EsphomeEntity[InfraredInfo, EntityState], InfraredEntity): """ESPHome infrared entity using native API.""" + _attr_supported_commands = frozenset({InfraredCommand}) + @callback def _on_device_update(self) -> None: """Call when device updates or entry data changes.""" diff --git a/homeassistant/components/infrared/__init__.py b/homeassistant/components/infrared/__init__.py index 44adbe154cc8..98f6e0031d5f 100644 --- a/homeassistant/components/infrared/__init__.py +++ b/homeassistant/components/infrared/__init__.py @@ -25,6 +25,7 @@ from .const import DOMAIN __all__ = [ "DOMAIN", + "InfraredCommand", "InfraredEntity", "InfraredEntityDescription", "async_get_emitters", @@ -79,7 +80,8 @@ async def async_send_command( """Send an IR command to the specified infrared entity. Raises: - HomeAssistantError: If the infrared entity is not found. + HomeAssistantError: If the infrared entity is not found, or if the entity + does not support the given command type. """ component = hass.data.get(DATA_COMPONENT) if component is None: @@ -98,6 +100,16 @@ async def async_send_command( translation_placeholders={"entity_id": entity_id}, ) + if not isinstance(command, tuple(entity.supported_commands)): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_not_supported", + translation_placeholders={ + "entity_id": entity_id, + "command_type": type(command).__name__, + }, + ) + if context is not None: entity.async_set_context(context) @@ -114,9 +126,15 @@ class InfraredEntity(RestoreEntity): entity_description: InfraredEntityDescription _attr_should_poll = False _attr_state: None = None + _attr_supported_commands: frozenset[type[InfraredCommand]] = frozenset() __last_command_sent: str | None = None + @property + def supported_commands(self) -> frozenset[type[InfraredCommand]]: + """Return the command types this entity can transmit.""" + return self._attr_supported_commands + @property @final def state(self) -> str | None: diff --git a/homeassistant/components/infrared/strings.json b/homeassistant/components/infrared/strings.json index c4cf75cf1f3c..013b9bd1dbc1 100644 --- a/homeassistant/components/infrared/strings.json +++ b/homeassistant/components/infrared/strings.json @@ -5,6 +5,9 @@ }, "entity_not_found": { "message": "Infrared entity `{entity_id}` not found" + }, + "command_not_supported": { + "message": "Infrared entity `{entity_id}` does not support commands of type `{command_type}`" } } } diff --git a/homeassistant/components/kitchen_sink/infrared.py b/homeassistant/components/kitchen_sink/infrared.py index 437a993559a8..4fe1478d7638 100644 --- a/homeassistant/components/kitchen_sink/infrared.py +++ b/homeassistant/components/kitchen_sink/infrared.py @@ -38,6 +38,7 @@ class DemoInfrared(InfraredEntity): _attr_has_entity_name = True _attr_should_poll = False + _attr_supported_commands = frozenset({infrared_protocols.Command}) def __init__( self, diff --git a/homeassistant/components/smlight/infrared.py b/homeassistant/components/smlight/infrared.py index 6f6cd1851738..412e89e99c98 100644 --- a/homeassistant/components/smlight/infrared.py +++ b/homeassistant/components/smlight/infrared.py @@ -33,6 +33,7 @@ class SmInfraredEntity(SmEntity, InfraredEntity): """Representation of a SLZB-Ultima infrared.""" _attr_translation_key = "infrared_emitter" + _attr_supported_commands = frozenset({InfraredCommand}) def __init__(self, coordinator: SmDataUpdateCoordinator) -> None: """Initialize the SLZB-Ultima infrared.""" diff --git a/tests/components/infrared/conftest.py b/tests/components/infrared/conftest.py index b1df1681893c..b48cd29b5a49 100644 --- a/tests/components/infrared/conftest.py +++ b/tests/components/infrared/conftest.py @@ -21,6 +21,7 @@ class MockInfraredEntity(InfraredEntity): _attr_has_entity_name = True _attr_name = "Test IR transmitter" + _attr_supported_commands = frozenset({InfraredCommand}) def __init__(self, unique_id: str) -> None: """Initialize mock entity.""" diff --git a/tests/components/infrared/test_init.py b/tests/components/infrared/test_init.py index d8653db986ce..5abb5594958e 100644 --- a/tests/components/infrared/test_init.py +++ b/tests/components/infrared/test_init.py @@ -125,6 +125,30 @@ async def test_async_send_command_component_not_loaded(hass: HomeAssistant) -> N await async_send_command(hass, "infrared.some_entity", command) +@pytest.mark.usefixtures("init_integration") +async def test_async_send_command_unsupported_command( + hass: HomeAssistant, + mock_infrared_entity: MockInfraredEntity, +) -> None: + """Test async_send_command raises error when command is not supported.""" + mock_infrared_entity._attr_supported_commands = frozenset() + component = hass.data[DATA_COMPONENT] + await component.async_add_entities([mock_infrared_entity]) + + command = NECCommand(address=0x04FB, command=0x08F7, modulation=38000) + + with pytest.raises( + HomeAssistantError, + match=( + "Infrared entity `infrared.test_ir_transmitter` does not support " + "commands of type `NECCommand`" + ), + ): + await async_send_command(hass, mock_infrared_entity.entity_id, command) + + assert len(mock_infrared_entity.send_command_calls) == 0 + + @pytest.mark.parametrize( ("restored_value", "expected_state"), [ diff --git a/tests/components/lg_infrared/conftest.py b/tests/components/lg_infrared/conftest.py index ffb68fb35d4c..2feae8a836fc 100644 --- a/tests/components/lg_infrared/conftest.py +++ b/tests/components/lg_infrared/conftest.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Generator from unittest.mock import patch from infrared_protocols import Command as InfraredCommand @@ -34,6 +33,7 @@ class MockInfraredEntity(InfraredEntity): _attr_has_entity_name = True _attr_name = "Test IR transmitter" + _attr_supported_commands = frozenset({InfraredCommand}) def __init__(self, unique_id: str) -> None: """Initialize mock entity.""" @@ -72,26 +72,11 @@ def platforms() -> list[Platform]: return PLATFORMS -@pytest.fixture -def mock_make_lg_tv_command() -> Generator[None]: - """Patch make_command to return the LGTVCode directly. - - This allows tests to assert on the high-level code enum value - rather than the raw NEC timings. - """ - with patch( - "homeassistant.components.lg_infrared.entity.make_lg_tv_command", - side_effect=lambda code, **kwargs: code, - ): - yield - - @pytest.fixture async def init_integration( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_infrared_entity: MockInfraredEntity, - mock_make_lg_tv_command: None, platforms: list[Platform], ) -> MockConfigEntry: """Set up the LG Infrared integration for testing.""" diff --git a/tests/components/lg_infrared/test_button.py b/tests/components/lg_infrared/test_button.py index c1079c13f954..c45babb04b6f 100644 --- a/tests/components/lg_infrared/test_button.py +++ b/tests/components/lg_infrared/test_button.py @@ -2,6 +2,7 @@ from __future__ import annotations +from infrared_protocols import NECCommand from infrared_protocols.codes.lg.tv import LGTVCode import pytest from syrupy.assertion import SnapshotAssertion @@ -95,7 +96,9 @@ async def test_button_press_sends_correct_code( ) assert len(mock_infrared_entity.send_command_calls) == 1 - assert mock_infrared_entity.send_command_calls[0] == expected_code + sent = mock_infrared_entity.send_command_calls[0] + assert isinstance(sent, NECCommand) + assert sent.command == expected_code @pytest.mark.usefixtures("init_integration") diff --git a/tests/components/lg_infrared/test_media_player.py b/tests/components/lg_infrared/test_media_player.py index 8c4c7100afd0..202d37fa5512 100644 --- a/tests/components/lg_infrared/test_media_player.py +++ b/tests/components/lg_infrared/test_media_player.py @@ -2,6 +2,7 @@ from __future__ import annotations +from infrared_protocols import NECCommand from infrared_protocols.codes.lg.tv import LGTVCode import pytest from syrupy.assertion import SnapshotAssertion @@ -92,7 +93,9 @@ async def test_media_player_action_sends_correct_code( ) assert len(mock_infrared_entity.send_command_calls) == 1 - assert mock_infrared_entity.send_command_calls[0] == expected_code + sent = mock_infrared_entity.send_command_calls[0] + assert isinstance(sent, NECCommand) + assert sent.command == expected_code @pytest.mark.usefixtures("init_integration")