mirror of
https://github.com/home-assistant/core.git
synced 2026-05-23 17:25:10 +02:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a084cbe01 | |||
| 346d21d77b | |||
| 782ffc7675 | |||
| f83adf8f22 | |||
| 25b307924b | |||
| 867b617b60 | |||
| 7e7590c8e2 | |||
| 49ab12c950 | |||
| 5d65d3e27b | |||
| 7eeea9060d | |||
| 4086d43a1b | |||
| 62dc48ddd3 |
@@ -53,7 +53,7 @@ def async_static_info_updated(
|
||||
platform: entity_platform.EntityPlatform,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
info_type: type[_InfoT],
|
||||
entity_type: type[_EntityT],
|
||||
entity_type: Callable[[RuntimeEntryData, EntityInfo, type[_StateT]], _EntityT],
|
||||
state_type: type[_StateT],
|
||||
infos: list[EntityInfo],
|
||||
) -> None:
|
||||
@@ -188,7 +188,7 @@ async def platform_async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
*,
|
||||
info_type: type[_InfoT],
|
||||
entity_type: type[_EntityT],
|
||||
entity_type: Callable[[RuntimeEntryData, EntityInfo, type[_StateT]], _EntityT],
|
||||
state_type: type[_StateT],
|
||||
info_filter: Callable[[_InfoT], bool] | None = None,
|
||||
) -> None:
|
||||
@@ -196,6 +196,11 @@ async def platform_async_setup_entry(
|
||||
|
||||
This method is in charge of receiving, distributing and storing
|
||||
info and state updates.
|
||||
|
||||
`entity_type` is any callable that builds an entity from
|
||||
`(entry_data, info, state_type)`. A regular entity class satisfies this,
|
||||
and platforms with multiple entity classes can pass a factory function
|
||||
that picks the class per static info.
|
||||
"""
|
||||
entry_data = entry.runtime_data
|
||||
entry_data.info[info_type] = {}
|
||||
|
||||
@@ -1,28 +1,34 @@
|
||||
"""Infrared platform for ESPHome."""
|
||||
|
||||
from functools import partial
|
||||
import functools
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aioesphomeapi import EntityState, InfraredCapability, InfraredInfo
|
||||
from aioesphomeapi import EntityInfo, EntityState, InfraredCapability, InfraredInfo
|
||||
from aioesphomeapi.client import InfraredRFReceiveEventModel
|
||||
|
||||
from homeassistant.components.infrared import InfraredCommand, InfraredEmitterEntity
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.components.infrared import (
|
||||
InfraredCommand,
|
||||
InfraredEmitterEntity,
|
||||
InfraredReceivedSignal,
|
||||
InfraredReceiverEntity,
|
||||
)
|
||||
from homeassistant.core import CALLBACK_TYPE, callback
|
||||
|
||||
from .entity import (
|
||||
EsphomeEntity,
|
||||
convert_api_error_ha_error,
|
||||
platform_async_setup_entry,
|
||||
)
|
||||
from .entry_data import RuntimeEntryData
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
class EsphomeInfraredEntity(
|
||||
EsphomeEntity[InfraredInfo, EntityState], InfraredEmitterEntity
|
||||
):
|
||||
"""ESPHome infrared emitter entity using native API."""
|
||||
class _EsphomeInfraredEntity(EsphomeEntity[InfraredInfo, EntityState]):
|
||||
"""Common base for ESPHome infrared entities."""
|
||||
|
||||
@callback
|
||||
def _on_device_update(self) -> None:
|
||||
@@ -32,6 +38,10 @@ class EsphomeInfraredEntity(
|
||||
# Infrared entities should go available as soon as the device comes online
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
class EsphomeInfraredEmitterEntity(_EsphomeInfraredEntity, InfraredEmitterEntity):
|
||||
"""ESPHome infrared emitter entity using native API."""
|
||||
|
||||
@convert_api_error_ha_error
|
||||
async def async_send_command(self, command: InfraredCommand) -> None:
|
||||
"""Send an IR command."""
|
||||
@@ -46,10 +56,77 @@ class EsphomeInfraredEntity(
|
||||
)
|
||||
|
||||
|
||||
async_setup_entry = partial(
|
||||
class EsphomeInfraredReceiverEntity(_EsphomeInfraredEntity, InfraredReceiverEntity):
|
||||
"""ESPHome infrared receiver entity using native API."""
|
||||
|
||||
_unsub_receive: CALLBACK_TYPE | None = None
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks including IR receive subscription."""
|
||||
await super().async_added_to_hass()
|
||||
self._async_subscribe_receive()
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Unsubscribe from the device on entity removal."""
|
||||
await super().async_will_remove_from_hass()
|
||||
if self._unsub_receive is not None:
|
||||
self._unsub_receive()
|
||||
self._unsub_receive = None
|
||||
|
||||
@callback
|
||||
def _async_subscribe_receive(self) -> None:
|
||||
"""Subscribe to IR receive events if the device is connected."""
|
||||
# Subscribing requires an active API connection; defer to
|
||||
# _on_device_update when the device is not (yet) available.
|
||||
if self._unsub_receive is not None or not self._entry_data.available:
|
||||
return
|
||||
self._unsub_receive = self._client.subscribe_infrared_rf_receive(
|
||||
self._on_infrared_rf_receive
|
||||
)
|
||||
|
||||
@callback
|
||||
def _on_device_update(self) -> None:
|
||||
"""Call when device updates or entry data changes."""
|
||||
super()._on_device_update()
|
||||
if self._entry_data.available:
|
||||
self._async_subscribe_receive()
|
||||
elif self._unsub_receive is not None:
|
||||
self._unsub_receive = None
|
||||
|
||||
@callback
|
||||
def _on_infrared_rf_receive(self, event: InfraredRFReceiveEventModel) -> None:
|
||||
"""Handle a received IR signal from the device."""
|
||||
if (
|
||||
event.key != self._static_info.key
|
||||
or event.device_id != self._static_info.device_id
|
||||
):
|
||||
return
|
||||
self._handle_received_signal(InfraredReceivedSignal(timings=event.timings))
|
||||
|
||||
|
||||
def _make_infrared_entity(
|
||||
entry_data: RuntimeEntryData,
|
||||
info: EntityInfo,
|
||||
state_type: type[EntityState],
|
||||
) -> _EsphomeInfraredEntity:
|
||||
"""Build the right infrared entity based on the InfraredInfo capabilities."""
|
||||
if TYPE_CHECKING:
|
||||
assert isinstance(info, InfraredInfo)
|
||||
cls = (
|
||||
EsphomeInfraredReceiverEntity
|
||||
if info.capabilities & InfraredCapability.RECEIVER
|
||||
else EsphomeInfraredEmitterEntity
|
||||
)
|
||||
return cls(entry_data, info, state_type)
|
||||
|
||||
|
||||
async_setup_entry = functools.partial(
|
||||
platform_async_setup_entry,
|
||||
info_type=InfraredInfo,
|
||||
entity_type=EsphomeInfraredEntity,
|
||||
entity_type=_make_infrared_entity,
|
||||
state_type=EntityState,
|
||||
info_filter=lambda info: bool(info.capabilities & InfraredCapability.TRANSMITTER),
|
||||
info_filter=lambda info: bool(
|
||||
info.capabilities
|
||||
& (InfraredCapability.TRANSMITTER | InfraredCapability.RECEIVER)
|
||||
),
|
||||
)
|
||||
|
||||
@@ -6,10 +6,17 @@ from aioesphomeapi import (
|
||||
InfraredCapability,
|
||||
InfraredInfo,
|
||||
)
|
||||
from aioesphomeapi.client import InfraredRFReceiveEventModel
|
||||
from infrared_protocols.commands.nec import NECCommand
|
||||
import pytest
|
||||
|
||||
from homeassistant.components import infrared
|
||||
from homeassistant.components.infrared import (
|
||||
DATA_COMPONENT,
|
||||
InfraredDeviceClass,
|
||||
InfraredReceivedSignal,
|
||||
InfraredReceiverEntity,
|
||||
)
|
||||
from homeassistant.const import STATE_UNAVAILABLE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -33,32 +40,47 @@ async def _mock_ir_device(
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("capabilities", "entity_created"),
|
||||
("capabilities", "expected_device_class", "emitter_count", "receiver_count"),
|
||||
[
|
||||
(InfraredCapability.TRANSMITTER, True),
|
||||
(InfraredCapability.RECEIVER, False),
|
||||
(InfraredCapability.TRANSMITTER | InfraredCapability.RECEIVER, True),
|
||||
(InfraredCapability(0), False),
|
||||
pytest.param(
|
||||
InfraredCapability.TRANSMITTER,
|
||||
InfraredDeviceClass.EMITTER,
|
||||
1,
|
||||
0,
|
||||
id="transmitter",
|
||||
),
|
||||
pytest.param(
|
||||
InfraredCapability.RECEIVER,
|
||||
InfraredDeviceClass.RECEIVER,
|
||||
0,
|
||||
1,
|
||||
id="receiver",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_infrared_entity_transmitter(
|
||||
async def test_infrared_entity_single_capability(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
capabilities: InfraredCapability,
|
||||
entity_created: bool,
|
||||
expected_device_class: InfraredDeviceClass,
|
||||
emitter_count: int,
|
||||
receiver_count: int,
|
||||
) -> None:
|
||||
"""Test infrared entity with transmitter capability is created."""
|
||||
"""Test infrared entity is created with the right device class per capability."""
|
||||
await _mock_ir_device(mock_esphome_device, mock_client, capabilities)
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert (state is not None) == entity_created
|
||||
assert (state is not None) == (expected_device_class is not None)
|
||||
assert state.attributes["device_class"] == expected_device_class
|
||||
|
||||
emitters = infrared.async_get_emitters(hass)
|
||||
assert (len(emitters) == 1) == entity_created
|
||||
assert len(emitters) == emitter_count
|
||||
receivers = infrared.async_get_receivers(hass)
|
||||
assert len(receivers) == receiver_count
|
||||
|
||||
|
||||
async def test_infrared_multiple_entities_mixed_capabilities(
|
||||
async def test_infrared_entity_dual_capability(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
@@ -77,12 +99,6 @@ async def test_infrared_multiple_entities_mixed_capabilities(
|
||||
name="IR Receiver",
|
||||
capabilities=InfraredCapability.RECEIVER,
|
||||
),
|
||||
InfraredInfo(
|
||||
object_id="ir_transceiver",
|
||||
key=3,
|
||||
name="IR Transceiver",
|
||||
capabilities=InfraredCapability.TRANSMITTER | InfraredCapability.RECEIVER,
|
||||
),
|
||||
]
|
||||
await mock_esphome_device(
|
||||
mock_client=mock_client,
|
||||
@@ -90,13 +106,18 @@ async def test_infrared_multiple_entities_mixed_capabilities(
|
||||
states=[],
|
||||
)
|
||||
|
||||
# Only transmitter and transceiver should be created
|
||||
assert hass.states.get("infrared.test_ir_transmitter") is not None
|
||||
assert hass.states.get("infrared.test_ir_receiver") is None
|
||||
assert hass.states.get("infrared.test_ir_transceiver") is not None
|
||||
transmitter_state = hass.states.get("infrared.test_ir_transmitter")
|
||||
assert transmitter_state is not None
|
||||
assert transmitter_state.attributes["device_class"] == InfraredDeviceClass.EMITTER
|
||||
|
||||
receiver_state = hass.states.get("infrared.test_ir_receiver")
|
||||
assert receiver_state is not None
|
||||
assert receiver_state.attributes["device_class"] == InfraredDeviceClass.RECEIVER
|
||||
|
||||
emitters = infrared.async_get_emitters(hass)
|
||||
assert len(emitters) == 2
|
||||
assert len(emitters) == 1
|
||||
receivers = infrared.async_get_receivers(hass)
|
||||
assert len(receivers) == 1
|
||||
|
||||
|
||||
async def test_infrared_send_command_success(
|
||||
@@ -146,6 +167,77 @@ async def test_infrared_send_command_failure(
|
||||
assert exc_info.value.translation_key == "error_communicating_with_device"
|
||||
|
||||
|
||||
async def test_infrared_receiver_signal_dispatched(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test receiver subscribes to events and dispatches received signals."""
|
||||
await _mock_ir_device(
|
||||
mock_esphome_device, mock_client, capabilities=InfraredCapability.RECEIVER
|
||||
)
|
||||
|
||||
mock_client.subscribe_infrared_rf_receive.assert_called_once()
|
||||
on_event = mock_client.subscribe_infrared_rf_receive.call_args[0][0]
|
||||
|
||||
receiver = hass.data[DATA_COMPONENT].get_entity(ENTITY_ID)
|
||||
assert isinstance(receiver, InfraredReceiverEntity)
|
||||
received_signals: list[InfraredReceivedSignal] = []
|
||||
receiver.async_subscribe_received_signal(received_signals.append)
|
||||
|
||||
timings = [100, -200, 300]
|
||||
on_event(InfraredRFReceiveEventModel(key=1, device_id=0, timings=timings))
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert received_signals == [InfraredReceivedSignal(timings=timings)]
|
||||
assert hass.states.get(ENTITY_ID).state != STATE_UNAVAILABLE
|
||||
|
||||
# Test events with wrong key/device_id are ignored
|
||||
on_event(InfraredRFReceiveEventModel(key=99, device_id=0, timings=timings))
|
||||
on_event(InfraredRFReceiveEventModel(key=1, device_id=42, timings=timings))
|
||||
await hass.async_block_till_done()
|
||||
assert len(received_signals) == 1
|
||||
|
||||
|
||||
async def test_infrared_receiver_unsubscribes_on_unload(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test receiver unsubscribes from device events when its entry is unloaded."""
|
||||
mock_device = await _mock_ir_device(
|
||||
mock_esphome_device, mock_client, capabilities=InfraredCapability.RECEIVER
|
||||
)
|
||||
|
||||
unsub = mock_client.subscribe_infrared_rf_receive.return_value
|
||||
unsub.assert_not_called()
|
||||
|
||||
await hass.config_entries.async_unload(mock_device.entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
unsub.assert_called_once()
|
||||
|
||||
|
||||
async def test_infrared_receiver_resubscribes_on_reconnect(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test receiver re-subscribes to events after a reconnect."""
|
||||
mock_device = await _mock_ir_device(
|
||||
mock_esphome_device, mock_client, capabilities=InfraredCapability.RECEIVER
|
||||
)
|
||||
|
||||
assert mock_client.subscribe_infrared_rf_receive.call_count == 1
|
||||
|
||||
await mock_device.mock_disconnect(False)
|
||||
await hass.async_block_till_done()
|
||||
await mock_device.mock_connect()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_client.subscribe_infrared_rf_receive.call_count == 2
|
||||
|
||||
|
||||
async def test_infrared_entity_availability(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
|
||||
Reference in New Issue
Block a user