Compare commits

...

1 Commits

Author SHA1 Message Date
Claude 361e62d33c Validate command type in infrared async_send_command
Add a `supported_commands` attribute to `InfraredEntity` so each
platform can declare which `InfraredCommand` subclasses it can transmit.
The module-level `async_send_command` helper now rejects commands whose
type is not supported by the target entity with a translated
`HomeAssistantError`, surfacing the contract violation early instead of
letting the platform attempt (and likely fail) to send it.
2026-04-22 13:09:50 +00:00
10 changed files with 60 additions and 19 deletions
@@ -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."""
+19 -1
View File
@@ -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:
@@ -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}`"
}
}
}
@@ -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,
@@ -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."""
+1
View File
@@ -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."""
+24
View File
@@ -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"),
[
+1 -16
View File
@@ -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."""
+4 -1
View File
@@ -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")
@@ -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")