Add LG AC support to lg_infrared integration (#174142)

This commit is contained in:
Dr.Blank
2026-07-23 18:44:42 +01:00
committed by GitHub
parent 841c574ce1
commit 0e8bf166b8
10 changed files with 1276 additions and 125 deletions
@@ -6,7 +6,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
PLATFORMS = [Platform.BUTTON, Platform.EVENT, Platform.MEDIA_PLAYER]
PLATFORMS = [Platform.BUTTON, Platform.CLIMATE, Platform.EVENT, Platform.MEDIA_PLAYER]
_LOGGER = logging.getLogger(__name__)
@@ -0,0 +1,243 @@
"""Climate platform for LG IR integration — LG AC."""
from typing import Any, override
from infrared_protocols.commands.lg_ac import (
MAX_TEMP,
MIN_TEMP,
LgAcCommand,
LgAcFanSpeed,
LgAcMode,
)
from homeassistant.components.climate import (
ATTR_FAN_MODE,
ATTR_HVAC_MODE,
FAN_AUTO,
FAN_HIGH,
FAN_LOW,
FAN_MEDIUM,
ClimateEntity,
ClimateEntityFeature,
HVACMode,
)
from homeassistant.components.infrared import (
InfraredEmitterConsumerEntity,
InfraredReceivedSignal,
InfraredReceiverConsumerEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_TEMPERATURE,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
from .const import (
CONF_DEVICE_TYPE,
CONF_HVAC_MODES,
CONF_INFRARED_ENTITY_ID,
CONF_INFRARED_RECEIVER_ENTITY_ID,
LGDeviceType,
)
from .entity import LgIrEntity
PARALLEL_UPDATES = 1
FAN_QUIET = "quiet"
FAN_MEDIUM_LOW = "medium_low"
FAN_MEDIUM_HIGH = "medium_high"
_HA_FAN_TO_LIB: dict[str, LgAcFanSpeed] = {
FAN_AUTO: LgAcFanSpeed.AUTO,
FAN_QUIET: LgAcFanSpeed.QUIET,
FAN_LOW: LgAcFanSpeed.LOW,
FAN_MEDIUM_LOW: LgAcFanSpeed.MEDIUM_LOW,
FAN_MEDIUM: LgAcFanSpeed.MEDIUM,
FAN_MEDIUM_HIGH: LgAcFanSpeed.MEDIUM_HIGH,
FAN_HIGH: LgAcFanSpeed.HIGH,
}
_LIB_FAN_TO_HA: dict[LgAcFanSpeed, str] = {v: k for k, v in _HA_FAN_TO_LIB.items()}
_HA_MODE_TO_LIB: dict[HVACMode, LgAcMode] = {
HVACMode.OFF: LgAcMode.OFF,
HVACMode.COOL: LgAcMode.COOL,
HVACMode.HEAT: LgAcMode.HEAT,
HVACMode.DRY: LgAcMode.DRY,
HVACMode.FAN_ONLY: LgAcMode.FAN_ONLY,
}
_LIB_MODE_TO_HA: dict[LgAcMode, HVACMode] = {v: k for k, v in _HA_MODE_TO_LIB.items()}
# Only these modes carry a temperature in the LG AC protocol frame.
_TEMPERATURE_MODES = (LgAcMode.COOL, LgAcMode.HEAT)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up LG AC climate entity from config entry."""
if entry.data[CONF_DEVICE_TYPE] != LGDeviceType.AC:
return
emitter_entity_id = entry.data[CONF_INFRARED_ENTITY_ID]
if receiver_entity_id := entry.data.get(CONF_INFRARED_RECEIVER_ENTITY_ID):
async_add_entities(
[LgAcClimateWithReceiver(entry, emitter_entity_id, receiver_entity_id)]
)
else:
async_add_entities([LgAcClimateEntity(entry, emitter_entity_id)])
class LgAcClimateEntity(
LgIrEntity, InfraredEmitterConsumerEntity, ClimateEntity, RestoreEntity
):
"""LG AC climate entity controlled via infrared emitter."""
_attr_name = None
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_target_temperature_step = 1.0
_attr_min_temp = float(MIN_TEMP)
_attr_max_temp = float(MAX_TEMP)
_attr_should_poll = False
_attr_assumed_state = True
_attr_translation_key = "lg_ac"
_attr_fan_modes = [
FAN_AUTO,
FAN_QUIET,
FAN_LOW,
FAN_MEDIUM_LOW,
FAN_MEDIUM,
FAN_MEDIUM_HIGH,
FAN_HIGH,
]
def __init__(self, entry: ConfigEntry, emitter_entity_id: str) -> None:
"""Initialize LG AC climate entity."""
super().__init__(entry, unique_id_suffix="climate", device_name="LG AC")
self._infrared_emitter_entity_id = emitter_entity_id
configured_modes = entry.data.get(
CONF_HVAC_MODES, [HVACMode.COOL, HVACMode.DRY]
)
self._attr_hvac_modes = [HVACMode.OFF] + [HVACMode(m) for m in configured_modes]
self._attr_hvac_mode = HVACMode.OFF
self._attr_target_temperature = float(MIN_TEMP)
self._attr_fan_mode = FAN_AUTO
self._attr_supported_features = ClimateEntityFeature.FAN_MODE
# Without a temperature-carrying mode no target temperature can ever be sent.
if any(
_HA_MODE_TO_LIB[mode] in _TEMPERATURE_MODES
for mode in self._attr_hvac_modes
):
self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE
@override
async def async_added_to_hass(self) -> None:
"""Restore the assumed state, as infrared cannot read it back from the AC."""
await super().async_added_to_hass()
last_state = await self.async_get_last_state()
if last_state is None or last_state.state in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
):
return
if last_state.state in self._attr_hvac_modes:
self._attr_hvac_mode = HVACMode(last_state.state)
if (fan_mode := last_state.attributes.get(ATTR_FAN_MODE)) in _HA_FAN_TO_LIB:
self._attr_fan_mode = fan_mode
if (temperature := last_state.attributes.get(ATTR_TEMPERATURE)) is not None:
self._attr_target_temperature = float(temperature)
@override
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set HVAC mode."""
temp = int(self._attr_target_temperature or MIN_TEMP)
await self._send_command(
self._build_command(
_HA_MODE_TO_LIB[hvac_mode], temp, self._attr_fan_mode or FAN_AUTO
)
)
self._attr_hvac_mode = hvac_mode
self.async_write_ha_state()
@override
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set the target temperature, switching the HVAC mode when one is given."""
temp = int(kwargs[ATTR_TEMPERATURE])
hvac_mode: HVACMode | None = kwargs.get(ATTR_HVAC_MODE)
if hvac_mode is not None:
self._valid_mode_or_raise("hvac", hvac_mode, self.hvac_modes)
lib_mode = _HA_MODE_TO_LIB.get(
hvac_mode or self._attr_hvac_mode or HVACMode.OFF, LgAcMode.OFF
)
if hvac_mode is not None or lib_mode in _TEMPERATURE_MODES:
await self._send_command(
self._build_command(lib_mode, temp, self._attr_fan_mode or FAN_AUTO)
)
if hvac_mode is not None:
self._attr_hvac_mode = hvac_mode
self._attr_target_temperature = float(temp)
self.async_write_ha_state()
@override
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set fan mode."""
lib_mode = _HA_MODE_TO_LIB.get(
self._attr_hvac_mode or HVACMode.OFF, LgAcMode.OFF
)
if lib_mode is not LgAcMode.OFF:
temp = int(self._attr_target_temperature or MIN_TEMP)
await self._send_command(self._build_command(lib_mode, temp, fan_mode))
self._attr_fan_mode = fan_mode
self.async_write_ha_state()
def _build_command(self, mode: LgAcMode, temp: int, fan_mode: str) -> LgAcCommand:
"""Build a command from a mode, a temperature and a fan mode.
The library drops the temperature for the modes whose frames cannot carry one,
so it can be passed unconditionally.
"""
return LgAcCommand(mode=mode, temperature=temp, fan=_HA_FAN_TO_LIB[fan_mode])
class LgAcClimateWithReceiver(LgAcClimateEntity, InfraredReceiverConsumerEntity):
"""LG AC climate entity that also tracks a configured infrared receiver."""
def __init__(
self, entry: ConfigEntry, emitter_entity_id: str, receiver_entity_id: str
) -> None:
"""Initialize LG AC climate entity with a receiver."""
super().__init__(entry, emitter_entity_id)
self._infrared_receiver_entity_id = receiver_entity_id
@override
@callback
def _handle_signal(self, signal: InfraredReceivedSignal) -> None:
"""Update state from a physical remote signal."""
command = LgAcCommand.from_raw_timings(signal.timings)
if command is None:
return
hvac_mode = _LIB_MODE_TO_HA[command.mode]
if hvac_mode not in self._attr_hvac_modes:
return
self._attr_hvac_mode = hvac_mode
# Power-off frames omit fan and temperature, so preserve the last known values.
if command.fan is not None:
self._attr_fan_mode = _LIB_FAN_TO_HA[command.fan]
if command.temperature is not None:
self._attr_target_temperature = float(command.temperature)
self.async_write_ha_state()
@@ -4,12 +4,14 @@ from typing import TYPE_CHECKING, Any, override
import voluptuous as vol
from homeassistant.components.climate import HVACMode
from homeassistant.components.infrared import (
DOMAIN as INFRARED_DOMAIN,
async_get_emitters,
async_get_receivers,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.selector import (
EntitySelector,
@@ -21,6 +23,7 @@ from homeassistant.helpers.selector import (
from .const import (
CONF_DEVICE_TYPE,
CONF_HVAC_MODES,
CONF_INFRARED_ENTITY_ID,
CONF_INFRARED_RECEIVER_ENTITY_ID,
DOMAIN,
@@ -29,89 +32,140 @@ from .const import (
DEVICE_TYPE_NAMES: dict[LGDeviceType, str] = {
LGDeviceType.TV: "TV",
LGDeviceType.AC: "AC",
}
_HVAC_MODE_OPTIONS = [
HVACMode.COOL,
HVACMode.HEAT,
HVACMode.DRY,
HVACMode.FAN_ONLY,
]
_DEFAULT_HVAC_MODES = [HVACMode.COOL, HVACMode.DRY]
@callback
def _infrared_entity_schema(
hass: HomeAssistant, *, emitter_required: bool
) -> vol.Schema:
"""Return the emitter/receiver selection schema shared by every device type."""
emitter_marker = vol.Required if emitter_required else vol.Optional
return vol.Schema(
{
emitter_marker(CONF_INFRARED_ENTITY_ID): EntitySelector(
EntitySelectorConfig(
domain=INFRARED_DOMAIN,
include_entities=async_get_emitters(hass),
)
),
vol.Optional(CONF_INFRARED_RECEIVER_ENTITY_ID): EntitySelector(
EntitySelectorConfig(
domain=INFRARED_DOMAIN,
include_entities=async_get_receivers(hass),
)
),
}
)
class LgIrConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle config flow for LG IR."""
VERSION = 2
def _entity_name(self, entity_id: str) -> str:
ent_reg = er.async_get(self.hass)
entry = ent_reg.async_get(entity_id)
return entry.name or entry.original_name or entity_id if entry else entity_id
async def _async_create_device_entry(
self, device_type: LGDeviceType, user_input: dict[str, Any]
) -> ConfigFlowResult:
"""Abort on a duplicate IR entity and create the entry for the device."""
emitter_id = user_input.get(CONF_INFRARED_ENTITY_ID)
receiver_id = user_input.get(CONF_INFRARED_RECEIVER_ENTITY_ID)
if emitter_id:
self._async_abort_entries_match(
{CONF_DEVICE_TYPE: device_type, CONF_INFRARED_ENTITY_ID: emitter_id}
)
if receiver_id:
self._async_abort_entries_match(
{
CONF_DEVICE_TYPE: device_type,
CONF_INFRARED_RECEIVER_ENTITY_ID: receiver_id,
}
)
title_entity_id = emitter_id or receiver_id
if TYPE_CHECKING:
assert title_entity_id is not None
return self.async_create_entry(
title=f"LG {DEVICE_TYPE_NAMES[device_type]} via "
f"{self._entity_name(title_entity_id)}",
data={CONF_DEVICE_TYPE: device_type, **user_input},
)
@override
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
"""Handle device type selection."""
emitter_entity_ids = async_get_emitters(self.hass)
receiver_entity_ids = async_get_receivers(self.hass)
if not emitter_entity_ids and not receiver_entity_ids:
if not emitter_entity_ids and not async_get_receivers(self.hass):
return self.async_abort(reason="no_infrared_entities")
menu_options = [LGDeviceType.TV.value]
# The AC step requires an emitter, so offering it without one dead-ends.
if emitter_entity_ids:
menu_options.append(LGDeviceType.AC.value)
return self.async_show_menu(step_id="user", menu_options=menu_options)
async def async_step_tv(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle TV entity selection."""
errors: dict[str, str] = {}
if user_input is not None:
emitter_id = user_input.get(CONF_INFRARED_ENTITY_ID)
receiver_id = user_input.get(CONF_INFRARED_RECEIVER_ENTITY_ID)
if emitter_id or receiver_id:
device_type = user_input[CONF_DEVICE_TYPE]
if emitter_id:
self._async_abort_entries_match(
{
CONF_DEVICE_TYPE: device_type,
CONF_INFRARED_ENTITY_ID: emitter_id,
}
)
if receiver_id:
self._async_abort_entries_match(
{
CONF_DEVICE_TYPE: device_type,
CONF_INFRARED_RECEIVER_ENTITY_ID: receiver_id,
}
)
# Get entity name for the title
title_entity_id = emitter_id or receiver_id
if TYPE_CHECKING:
assert title_entity_id is not None
ent_reg = er.async_get(self.hass)
entry = ent_reg.async_get(title_entity_id)
title_entity_name = (
entry.name or entry.original_name or title_entity_id
if entry
else title_entity_id
if user_input.get(CONF_INFRARED_ENTITY_ID) or user_input.get(
CONF_INFRARED_RECEIVER_ENTITY_ID
):
return await self._async_create_device_entry(
LGDeviceType.TV, user_input
)
device_type_name = DEVICE_TYPE_NAMES[LGDeviceType(device_type)]
title = f"LG {device_type_name} via {title_entity_name}"
return self.async_create_entry(title=title, data=user_input)
errors["base"] = "missing_infrared_entity"
schema_dict: dict[vol.Marker, Any] = {
vol.Required(CONF_DEVICE_TYPE): SelectSelector(
SelectSelectorConfig(
options=[device_type.value for device_type in LGDeviceType],
translation_key=CONF_DEVICE_TYPE,
mode=SelectSelectorMode.DROPDOWN,
)
),
vol.Optional(CONF_INFRARED_ENTITY_ID): EntitySelector(
EntitySelectorConfig(
domain=INFRARED_DOMAIN,
include_entities=emitter_entity_ids,
)
),
vol.Optional(CONF_INFRARED_RECEIVER_ENTITY_ID): EntitySelector(
EntitySelectorConfig(
domain=INFRARED_DOMAIN,
include_entities=receiver_entity_ids,
)
),
}
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(schema_dict),
step_id="tv",
data_schema=_infrared_entity_schema(self.hass, emitter_required=False),
errors=errors,
)
async def async_step_ac(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle AC entity and mode selection."""
if user_input is not None:
return await self._async_create_device_entry(LGDeviceType.AC, user_input)
return self.async_show_form(
step_id="ac",
data_schema=_infrared_entity_schema(
self.hass, emitter_required=True
).extend(
{
vol.Required(CONF_HVAC_MODES, default=_DEFAULT_HVAC_MODES): vol.All(
SelectSelector(
SelectSelectorConfig(
options=[mode.value for mode in _HVAC_MODE_OPTIONS],
translation_key=CONF_HVAC_MODES,
mode=SelectSelectorMode.LIST,
multiple=True,
)
),
vol.Length(min=1, msg="no_hvac_modes"),
)
}
),
)
@@ -6,9 +6,11 @@ DOMAIN = "lg_infrared"
CONF_INFRARED_ENTITY_ID = "infrared_entity_id"
CONF_INFRARED_RECEIVER_ENTITY_ID = "infrared_receiver_entity_id"
CONF_DEVICE_TYPE = "device_type"
CONF_HVAC_MODES = "hvac_modes"
class LGDeviceType(StrEnum):
"""LG device types."""
TV = "tv"
AC = "ac"
@@ -12,9 +12,16 @@ class LgIrEntity(Entity):
_attr_has_entity_name = True
def __init__(self, entry: ConfigEntry, unique_id_suffix: str) -> None:
def __init__(
self,
entry: ConfigEntry,
unique_id_suffix: str,
device_name: str = "LG TV",
) -> None:
"""Initialize LG IR entity."""
self._attr_unique_id = f"{entry.entry_id}_{unique_id_suffix}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)}, name="LG TV", manufacturer="LG"
identifiers={(DOMAIN, entry.entry_id)},
name=device_name,
manufacturer="LG",
)
@@ -5,21 +5,42 @@
"no_infrared_entities": "[%key:common::config_flow::abort::no_infrared_entities%]"
},
"error": {
"missing_infrared_entity": "Select an infrared emitter or receiver."
"missing_infrared_entity": "Select an infrared emitter or receiver.",
"no_hvac_modes": "Select at least one supported mode."
},
"step": {
"user": {
"ac": {
"data": {
"hvac_modes": "Supported modes",
"infrared_entity_id": "[%key:common::config_flow::data::infrared_entity_id%]",
"infrared_receiver_entity_id": "[%key:common::config_flow::data::infrared_receiver_entity_id%]"
},
"data_description": {
"hvac_modes": "Select the operating modes your AC unit supports. Heat is not available on all models.",
"infrared_entity_id": "[%key:common::config_flow::data_description::infrared_entity_id%]",
"infrared_receiver_entity_id": "Optional — allows the integration to update state when the physical remote is used."
},
"description": "Select an infrared emitter and the modes your LG AC supports.",
"title": "Set up LG AC"
},
"tv": {
"data": {
"device_type": "[%key:common::generic::device_type%]",
"infrared_entity_id": "[%key:common::config_flow::data::infrared_entity_id%]",
"infrared_receiver_entity_id": "[%key:common::config_flow::data::infrared_receiver_entity_id%]"
},
"data_description": {
"device_type": "The type of LG device to control.",
"infrared_entity_id": "[%key:common::config_flow::data_description::infrared_entity_id%]",
"infrared_receiver_entity_id": "The infrared receiver entity to use for receiving signals."
},
"description": "Select the device type and at least one infrared emitter or receiver to use with your LG device.",
"description": "Select at least one infrared emitter or receiver to use with your LG TV.",
"title": "Set up LG TV"
},
"user": {
"description": "Select the type of LG device to set up.",
"menu_options": {
"ac": "Air conditioner",
"tv": "[%key:common::generic::tv%]"
},
"title": "Set up LG IR Remote"
}
}
@@ -114,6 +135,19 @@
"name": "[%key:common::entity::button::up::name%]"
}
},
"climate": {
"lg_ac": {
"state_attributes": {
"fan_mode": {
"state": {
"medium_high": "Medium high",
"medium_low": "Medium low",
"quiet": "Quiet"
}
}
}
}
},
"event": {
"received_command": {
"name": "Received command",
@@ -179,9 +213,12 @@
}
},
"selector": {
"device_type": {
"hvac_modes": {
"options": {
"tv": "[%key:common::generic::tv%]"
"cool": "Cool",
"dry": "Dry",
"fan_only": "Fan only",
"heat": "Heat"
}
}
}
+53 -9
View File
@@ -1,13 +1,17 @@
"""Common fixtures for the LG Infrared tests."""
from collections.abc import Generator
from typing import Any
from unittest.mock import patch
import pytest
from homeassistant.components.climate import HVACMode
from homeassistant.components.lg_infrared import PLATFORMS
from homeassistant.components.lg_infrared.config_flow import DEVICE_TYPE_NAMES
from homeassistant.components.lg_infrared.const import (
CONF_DEVICE_TYPE,
CONF_HVAC_MODES,
CONF_INFRARED_ENTITY_ID,
CONF_INFRARED_RECEIVER_ENTITY_ID,
DOMAIN,
@@ -26,19 +30,59 @@ from tests.components.infrared.common import (
MockInfraredReceiverEntity,
)
ENTRY_ID = "01JTEST0000000000000000000"
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return a mock config entry."""
def device_type() -> LGDeviceType:
"""Return the device type of the config entry."""
return LGDeviceType.TV
@pytest.fixture
def hvac_modes() -> list[HVACMode]:
"""Return the HVAC modes configured on an AC config entry."""
return [HVACMode.COOL, HVACMode.DRY]
@pytest.fixture
def has_receiver() -> bool:
"""Return whether the config entry has an infrared receiver configured."""
return True
@pytest.fixture
def extra_entry_data(
device_type: LGDeviceType, hvac_modes: list[HVACMode]
) -> dict[str, Any]:
"""Return the device type specific config entry data."""
return {
LGDeviceType.TV: {},
LGDeviceType.AC: {CONF_HVAC_MODES: hvac_modes},
}[device_type]
@pytest.fixture
def mock_config_entry(
device_type: LGDeviceType,
extra_entry_data: dict[str, Any],
has_receiver: bool,
) -> MockConfigEntry:
"""Return a mock config entry for the configured device type."""
data: dict[str, Any] = {
CONF_DEVICE_TYPE: device_type,
CONF_INFRARED_ENTITY_ID: MOCK_INFRARED_EMITTER_ENTITY_ID,
**extra_entry_data,
}
if has_receiver:
data[CONF_INFRARED_RECEIVER_ENTITY_ID] = MOCK_INFRARED_RECEIVER_ENTITY_ID
device_name = DEVICE_TYPE_NAMES[device_type]
return MockConfigEntry(
domain=DOMAIN,
entry_id="01JTEST0000000000000000000",
title="LG TV via Test IR emitter",
data={
CONF_DEVICE_TYPE: LGDeviceType.TV,
CONF_INFRARED_ENTITY_ID: MOCK_INFRARED_EMITTER_ENTITY_ID,
CONF_INFRARED_RECEIVER_ENTITY_ID: MOCK_INFRARED_RECEIVER_ENTITY_ID,
},
entry_id=ENTRY_ID,
title=f"LG {device_name} via Test IR emitter",
data=data,
)
@@ -0,0 +1,91 @@
# serializer version: 1
# name: test_entities[climate.lg_ac-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<ClimateEntityCapabilityAttribute.FAN_MODES: 'fan_modes'>: list([
'auto',
'quiet',
'low',
'medium_low',
'medium',
'medium_high',
'high',
]),
<ClimateEntityCapabilityAttribute.HVAC_MODES: 'hvac_modes'>: list([
<HVACMode.OFF: 'off'>,
<HVACMode.COOL: 'cool'>,
<HVACMode.DRY: 'dry'>,
]),
<ClimateEntityCapabilityAttribute.MAX_TEMP: 'max_temp'>: 30.0,
<ClimateEntityCapabilityAttribute.MIN_TEMP: 'min_temp'>: 16.0,
<ClimateEntityCapabilityAttribute.TARGET_TEMP_STEP: 'target_temp_step'>: 1.0,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.lg_ac',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'lg_infrared',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <ClimateEntityFeature: 9>,
'translation_key': 'lg_ac',
'unique_id': '01JTEST0000000000000000000_climate',
'unit_of_measurement': None,
})
# ---
# name: test_entities[climate.lg_ac-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.ASSUMED_STATE: 'assumed_state'>: True,
<ClimateEntityStateAttribute.CURRENT_TEMPERATURE: 'current_temperature'>: None,
<ClimateEntityStateAttribute.FAN_MODE: 'fan_mode'>: 'auto',
<ClimateEntityCapabilityAttribute.FAN_MODES: 'fan_modes'>: list([
'auto',
'quiet',
'low',
'medium_low',
'medium',
'medium_high',
'high',
]),
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'LG AC',
<ClimateEntityCapabilityAttribute.HVAC_MODES: 'hvac_modes'>: list([
<HVACMode.OFF: 'off'>,
<HVACMode.COOL: 'cool'>,
<HVACMode.DRY: 'dry'>,
]),
<ClimateEntityCapabilityAttribute.MAX_TEMP: 'max_temp'>: 30.0,
<ClimateEntityCapabilityAttribute.MIN_TEMP: 'min_temp'>: 16.0,
<EntityStateAttribute.SUPPORTED_FEATURES: 'supported_features'>: <ClimateEntityFeature: 9>,
<ClimateEntityCapabilityAttribute.TARGET_TEMP_STEP: 'target_temp_step'>: 1.0,
<ClimateEntityStateAttribute.TEMPERATURE: 'temperature'>: 16.0,
}),
'context': <ANY>,
'entity_id': 'climate.lg_ac',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
@@ -0,0 +1,565 @@
"""Tests for the LG Infrared climate platform."""
from typing import Any
from unittest.mock import patch
from infrared_protocols.commands.lg_ac import (
MIN_TEMP,
LgAcCommand,
LgAcFanSpeed,
LgAcMode,
)
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.climate import (
DOMAIN as CLIMATE_DOMAIN,
FAN_AUTO,
FAN_HIGH,
FAN_LOW,
FAN_MEDIUM,
SERVICE_SET_FAN_MODE,
SERVICE_SET_HVAC_MODE,
SERVICE_SET_TEMPERATURE,
ClimateEntityFeature,
HVACMode,
)
from homeassistant.components.infrared import InfraredReceivedSignal
from homeassistant.components.lg_infrared.climate import (
FAN_MEDIUM_HIGH,
FAN_MEDIUM_LOW,
FAN_QUIET,
)
from homeassistant.components.lg_infrared.const import LGDeviceType
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_TEMPERATURE,
STATE_UNAVAILABLE,
Platform,
)
from homeassistant.core import HomeAssistant, State
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, mock_restore_cache, snapshot_platform
from tests.components.common import assert_availability_follows_source_entity
from tests.components.infrared import EMITTER_ENTITY_ID
from tests.components.infrared.common import (
MockInfraredEmitterEntity,
MockInfraredReceiverEntity,
)
_CLIMATE_ENTITY_ID = "climate.lg_ac"
@pytest.fixture
def platforms() -> list[Platform]:
"""Return platforms to set up."""
return [Platform.CLIMATE]
@pytest.fixture
def device_type() -> LGDeviceType:
"""Return the device type of the config entry."""
return LGDeviceType.AC
@pytest.fixture
def has_receiver() -> bool:
"""Return whether the config entry has an infrared receiver configured."""
return False
@pytest.mark.usefixtures("init_integration")
async def test_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test entity state and registry snapshot."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.usefixtures("init_integration")
async def test_availability_follows_emitter(
hass: HomeAssistant,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
) -> None:
"""Test climate entity availability follows the infrared emitter."""
await assert_availability_follows_source_entity(
hass, _CLIMATE_ENTITY_ID, EMITTER_ENTITY_ID
)
@pytest.mark.usefixtures("init_integration")
async def test_set_hvac_mode_off(
hass: HomeAssistant,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
) -> None:
"""Test setting HVAC mode to off sends power-off timings."""
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE,
{ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, "hvac_mode": HVACMode.OFF},
blocking=True,
)
assert len(mock_infrared_emitter_entity.send_command_calls) == 1
timings = mock_infrared_emitter_entity.send_command_calls[0].get_raw_timings()
assert timings == LgAcCommand(mode=LgAcMode.OFF).get_raw_timings()
@pytest.mark.usefixtures("init_integration")
@pytest.mark.parametrize(
("hvac_mode", "temp", "fan", "expected_cmd"),
[
pytest.param(
HVACMode.COOL,
24,
FAN_AUTO,
LgAcCommand(mode=LgAcMode.COOL, temperature=24, fan=LgAcFanSpeed.AUTO),
id="cool_24_auto",
),
pytest.param(
HVACMode.COOL,
18,
FAN_LOW,
LgAcCommand(mode=LgAcMode.COOL, temperature=18, fan=LgAcFanSpeed.LOW),
id="cool_18_low",
),
pytest.param(
HVACMode.COOL,
30,
FAN_HIGH,
LgAcCommand(mode=LgAcMode.COOL, temperature=30, fan=LgAcFanSpeed.HIGH),
id="cool_30_high",
),
pytest.param(
HVACMode.DRY,
24,
FAN_MEDIUM,
LgAcCommand(mode=LgAcMode.DRY, fan=LgAcFanSpeed.MEDIUM),
id="dry_medium",
),
],
)
async def test_set_hvac_mode_encodes_correctly(
hass: HomeAssistant,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
hvac_mode: HVACMode,
temp: int,
fan: str,
expected_cmd: LgAcCommand,
) -> None:
"""Test that set_hvac_mode sends correctly encoded timings."""
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,
{ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, ATTR_TEMPERATURE: temp},
blocking=True,
)
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_FAN_MODE,
{ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, "fan_mode": fan},
blocking=True,
)
mock_infrared_emitter_entity.send_command_calls.clear()
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE,
{ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, "hvac_mode": hvac_mode},
blocking=True,
)
assert len(mock_infrared_emitter_entity.send_command_calls) == 1
timings = mock_infrared_emitter_entity.send_command_calls[0].get_raw_timings()
assert timings == expected_cmd.get_raw_timings()
@pytest.mark.parametrize(
"hvac_modes",
[[HVACMode.COOL, HVACMode.HEAT, HVACMode.DRY, HVACMode.FAN_ONLY]],
)
@pytest.mark.usefixtures("init_integration")
@pytest.mark.parametrize(
("hvac_mode", "expected_cmd"),
[
pytest.param(
HVACMode.HEAT,
LgAcCommand(
mode=LgAcMode.HEAT, temperature=MIN_TEMP, fan=LgAcFanSpeed.AUTO
),
id="heat",
),
pytest.param(
HVACMode.FAN_ONLY,
LgAcCommand(mode=LgAcMode.FAN_ONLY, fan=LgAcFanSpeed.AUTO),
id="fan_only",
),
],
)
async def test_set_hvac_mode_heat_and_fan_only(
hass: HomeAssistant,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
hvac_mode: HVACMode,
expected_cmd: LgAcCommand,
) -> None:
"""Test heat and fan-only modes encode from the entity defaults."""
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE,
{ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, "hvac_mode": hvac_mode},
blocking=True,
)
assert len(mock_infrared_emitter_entity.send_command_calls) == 1
timings = mock_infrared_emitter_entity.send_command_calls[0].get_raw_timings()
assert timings == expected_cmd.get_raw_timings()
@pytest.mark.usefixtures("init_integration")
async def test_set_temperature_sends_command_when_active(
hass: HomeAssistant,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
) -> None:
"""Test set_temperature sends IR when AC is on."""
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE,
{ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, "hvac_mode": HVACMode.COOL},
blocking=True,
)
mock_infrared_emitter_entity.send_command_calls.clear()
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,
{ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, ATTR_TEMPERATURE: 26},
blocking=True,
)
assert len(mock_infrared_emitter_entity.send_command_calls) == 1
timings = mock_infrared_emitter_entity.send_command_calls[0].get_raw_timings()
assert (
timings
== LgAcCommand(
mode=LgAcMode.COOL, temperature=26, fan=LgAcFanSpeed.AUTO
).get_raw_timings()
)
state = hass.states.get(_CLIMATE_ENTITY_ID)
assert state is not None
assert float(state.attributes["temperature"]) == 26.0
@pytest.mark.usefixtures("init_integration")
async def test_set_temperature_no_command_when_off(
hass: HomeAssistant,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
) -> None:
"""Test set_temperature updates state but sends no IR when AC is off."""
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,
{ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, ATTR_TEMPERATURE: 22},
blocking=True,
)
assert len(mock_infrared_emitter_entity.send_command_calls) == 0
state = hass.states.get(_CLIMATE_ENTITY_ID)
assert state is not None
assert float(state.attributes["temperature"]) == 22.0
@pytest.mark.usefixtures("init_integration")
async def test_set_temperature_no_command_in_dry_mode(
hass: HomeAssistant,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
) -> None:
"""Test set_temperature sends no IR in dry mode (protocol-fixed temp)."""
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE,
{ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, "hvac_mode": HVACMode.DRY},
blocking=True,
)
mock_infrared_emitter_entity.send_command_calls.clear()
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,
{ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, ATTR_TEMPERATURE: 25},
blocking=True,
)
assert len(mock_infrared_emitter_entity.send_command_calls) == 0
@pytest.mark.usefixtures("init_integration")
async def test_set_fan_mode_sends_command_when_active(
hass: HomeAssistant,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
) -> None:
"""Test set_fan_mode sends IR when AC is on."""
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE,
{ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, "hvac_mode": HVACMode.COOL},
blocking=True,
)
mock_infrared_emitter_entity.send_command_calls.clear()
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_FAN_MODE,
{ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, "fan_mode": FAN_HIGH},
blocking=True,
)
assert len(mock_infrared_emitter_entity.send_command_calls) == 1
timings = mock_infrared_emitter_entity.send_command_calls[0].get_raw_timings()
assert (
timings
== LgAcCommand(
mode=LgAcMode.COOL, temperature=MIN_TEMP, fan=LgAcFanSpeed.HIGH
).get_raw_timings()
)
@pytest.mark.parametrize("has_receiver", [True])
@pytest.mark.usefixtures("init_integration")
@pytest.mark.parametrize(
("lib_fan", "expected_fan_mode"),
[
pytest.param(LgAcFanSpeed.QUIET, FAN_QUIET, id="quiet"),
pytest.param(LgAcFanSpeed.LOW, FAN_LOW, id="low"),
pytest.param(LgAcFanSpeed.MEDIUM_LOW, FAN_MEDIUM_LOW, id="medium_low"),
pytest.param(LgAcFanSpeed.MEDIUM, FAN_MEDIUM, id="medium"),
pytest.param(LgAcFanSpeed.MEDIUM_HIGH, FAN_MEDIUM_HIGH, id="medium_high"),
pytest.param(LgAcFanSpeed.HIGH, FAN_HIGH, id="high"),
pytest.param(LgAcFanSpeed.AUTO, FAN_AUTO, id="auto"),
],
)
async def test_receiver_updates_state_on_cool_signal(
hass: HomeAssistant,
mock_infrared_receiver_entity: MockInfraredReceiverEntity,
lib_fan: LgAcFanSpeed,
expected_fan_mode: str,
) -> None:
"""Test that a received cool signal updates mode, temperature and every fan speed."""
timings = LgAcCommand(
mode=LgAcMode.COOL, temperature=24, fan=lib_fan
).get_raw_timings()
signal = InfraredReceivedSignal(timings=timings)
mock_infrared_receiver_entity._handle_received_signal(signal)
await hass.async_block_till_done()
state = hass.states.get(_CLIMATE_ENTITY_ID)
assert state is not None
assert state.state == HVACMode.COOL
assert state.attributes["fan_mode"] == expected_fan_mode
assert float(state.attributes["temperature"]) == 24.0
@pytest.mark.parametrize("has_receiver", [True])
@pytest.mark.usefixtures("init_integration")
async def test_receiver_updates_state_on_off_signal(
hass: HomeAssistant,
mock_infrared_receiver_entity: MockInfraredReceiverEntity,
) -> None:
"""Test a received off signal sets mode to off, keeping temperature and fan.
Power-off is a fixed code carrying neither, so they must survive it.
"""
mock_infrared_receiver_entity._handle_received_signal(
InfraredReceivedSignal(
timings=LgAcCommand(
mode=LgAcMode.COOL, temperature=24, fan=LgAcFanSpeed.MEDIUM
).get_raw_timings()
)
)
await hass.async_block_till_done()
mock_infrared_receiver_entity._handle_received_signal(
InfraredReceivedSignal(timings=LgAcCommand(mode=LgAcMode.OFF).get_raw_timings())
)
await hass.async_block_till_done()
state = hass.states.get(_CLIMATE_ENTITY_ID)
assert state is not None
assert state.state == HVACMode.OFF
assert state.attributes["fan_mode"] == FAN_MEDIUM
assert float(state.attributes["temperature"]) == 24.0
@pytest.mark.parametrize("has_receiver", [True])
@pytest.mark.usefixtures("init_integration")
async def test_receiver_ignores_unconfigured_hvac_mode(
hass: HomeAssistant,
mock_infrared_receiver_entity: MockInfraredReceiverEntity,
) -> None:
"""Test a signal for a mode the user did not configure does not change state."""
mock_infrared_receiver_entity._handle_received_signal(
InfraredReceivedSignal(
timings=LgAcCommand(
mode=LgAcMode.HEAT, temperature=24, fan=LgAcFanSpeed.HIGH
).get_raw_timings()
)
)
await hass.async_block_till_done()
state = hass.states.get(_CLIMATE_ENTITY_ID)
assert state is not None
assert state.state == HVACMode.OFF
assert state.attributes["fan_mode"] == FAN_AUTO
@pytest.mark.parametrize("has_receiver", [True])
@pytest.mark.usefixtures("init_integration")
async def test_receiver_ignores_non_lg_ac_signal(
hass: HomeAssistant,
mock_infrared_receiver_entity: MockInfraredReceiverEntity,
) -> None:
"""Test that an unrecognised IR signal does not change state."""
mock_infrared_receiver_entity._handle_received_signal(
InfraredReceivedSignal(timings=[500, -500, 300, -300])
)
await hass.async_block_till_done()
state = hass.states.get(_CLIMATE_ENTITY_ID)
assert state is not None
assert state.state == HVACMode.OFF
@pytest.mark.parametrize(
("hvac_modes", "expected_features"),
[
pytest.param(
[HVACMode.COOL, HVACMode.DRY],
ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE,
id="with_temperature_mode",
),
pytest.param(
[HVACMode.DRY, HVACMode.FAN_ONLY],
ClimateEntityFeature.FAN_MODE,
id="without_temperature_mode",
),
],
)
@pytest.mark.usefixtures("init_integration")
async def test_target_temperature_feature_follows_configured_modes(
hass: HomeAssistant,
expected_features: ClimateEntityFeature,
) -> None:
"""Test target temperature is only offered when a mode can carry one."""
state = hass.states.get(_CLIMATE_ENTITY_ID)
assert state is not None
assert state.attributes["supported_features"] == expected_features
@pytest.mark.parametrize(
("restored_state", "restored_attributes", "expected"),
[
pytest.param(
HVACMode.COOL,
{"fan_mode": FAN_HIGH, "temperature": 29.0},
(HVACMode.COOL, FAN_HIGH, 29.0),
id="full_state",
),
pytest.param(
STATE_UNAVAILABLE,
{},
(HVACMode.OFF, FAN_AUTO, float(MIN_TEMP)),
id="unavailable_falls_back_to_defaults",
),
pytest.param(
HVACMode.HEAT,
{"fan_mode": FAN_HIGH, "temperature": 29.0},
(HVACMode.OFF, FAN_HIGH, 29.0),
id="mode_no_longer_configured_is_ignored",
),
],
)
async def test_state_restored_on_restart(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
platforms: list[Platform],
restored_state: str,
restored_attributes: dict[str, Any],
expected: tuple[HVACMode, str, float],
) -> None:
"""Test the assumed state is restored, since infrared cannot read it back."""
mock_restore_cache(
hass, [State(_CLIMATE_ENTITY_ID, restored_state, restored_attributes)]
)
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.lg_infrared.PLATFORMS", platforms):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
state = hass.states.get(_CLIMATE_ENTITY_ID)
assert state is not None
expected_mode, expected_fan, expected_temp = expected
assert state.state == expected_mode
assert state.attributes["fan_mode"] == expected_fan
assert state.attributes["temperature"] == expected_temp
@pytest.mark.usefixtures("init_integration")
async def test_set_temperature_with_hvac_mode(
hass: HomeAssistant,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
) -> None:
"""Test set_temperature switches mode when one is given, even while off."""
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,
{
ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID,
ATTR_TEMPERATURE: 24,
"hvac_mode": HVACMode.COOL,
},
blocking=True,
)
assert len(mock_infrared_emitter_entity.send_command_calls) == 1
timings = mock_infrared_emitter_entity.send_command_calls[0].get_raw_timings()
assert (
timings
== LgAcCommand(
mode=LgAcMode.COOL, temperature=24, fan=LgAcFanSpeed.AUTO
).get_raw_timings()
)
state = hass.states.get(_CLIMATE_ENTITY_ID)
assert state is not None
assert state.state == HVACMode.COOL
assert state.attributes["temperature"] == 24.0
@pytest.mark.usefixtures("init_integration")
async def test_set_temperature_with_unsupported_hvac_mode(
hass: HomeAssistant,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
) -> None:
"""Test a mode the unit does not support is rejected instead of sent."""
with pytest.raises(ServiceValidationError):
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,
{
ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID,
ATTR_TEMPERATURE: 24,
"hvac_mode": HVACMode.HEAT,
},
blocking=True,
)
assert len(mock_infrared_emitter_entity.send_command_calls) == 0
+152 -44
View File
@@ -2,16 +2,18 @@
import pytest
from homeassistant.components.climate import HVACMode
from homeassistant.components.lg_infrared.const import (
CONF_DEVICE_TYPE,
CONF_HVAC_MODES,
CONF_INFRARED_ENTITY_ID,
CONF_INFRARED_RECEIVER_ENTITY_ID,
DOMAIN,
LGDeviceType,
)
from homeassistant.config_entries import SOURCE_USER
from homeassistant.config_entries import SOURCE_USER, ConfigFlowResult
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.data_entry_flow import FlowResultType, InvalidData
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry
@@ -21,65 +23,80 @@ from tests.components.infrared import (
)
async def _async_start_flow(
hass: HomeAssistant, device_type: LGDeviceType
) -> ConfigFlowResult:
"""Start the user flow and pick a device type from the menu."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["step_id"] == "user"
return await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"next_step_id": device_type.value}
)
@pytest.mark.parametrize(
("config", "expected_title"),
("tv_config", "expected_title"),
[
(
pytest.param(
{CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_id},
"LG TV via Test IR emitter",
id="emitter_only",
),
(
pytest.param(
{
CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_id,
CONF_INFRARED_RECEIVER_ENTITY_ID: mock_infrared_receiver_entity_id,
},
"LG TV via Test IR emitter",
id="emitter_and_receiver",
),
(
pytest.param(
{CONF_INFRARED_RECEIVER_ENTITY_ID: mock_infrared_receiver_entity_id},
"LG TV via Test IR receiver",
id="receiver_only",
),
],
)
@pytest.mark.usefixtures(
"mock_infrared_emitter_entity", "mock_infrared_receiver_entity"
)
async def test_user_flow_success(
async def test_user_flow_tv_success(
hass: HomeAssistant,
config: dict[str, str],
tv_config: dict[str, str],
expected_title: str,
) -> None:
"""Test successful user config flow."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
"""Test successful TV config flow."""
result = await _async_start_flow(hass, LGDeviceType.TV)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["step_id"] == "tv"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_DEVICE_TYPE: LGDeviceType.TV, **config},
result["flow_id"], user_input=tv_config
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == expected_title
assert result["data"] == {CONF_DEVICE_TYPE: LGDeviceType.TV, **config}
assert result["data"] == {CONF_DEVICE_TYPE: LGDeviceType.TV, **tv_config}
assert result["result"].unique_id is None
@pytest.mark.usefixtures("mock_infrared_emitter_entity")
async def test_user_flow_requires_emitter_or_receiver(
async def test_user_flow_tv_requires_emitter_or_receiver(
hass: HomeAssistant,
) -> None:
"""Test user flow requires an infrared emitter or receiver."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
"""Test TV flow shows error when neither emitter nor receiver is selected."""
result = await _async_start_flow(hass, LGDeviceType.TV)
assert result["step_id"] == "tv"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_DEVICE_TYPE: LGDeviceType.TV},
result["flow_id"], user_input={}
)
assert result["type"] is FlowResultType.FORM
@@ -114,18 +131,12 @@ async def test_user_flow_already_configured(
mock_config_entry: MockConfigEntry,
user_input: dict[str, str],
) -> None:
"""Test user flow aborts when entry is already configured."""
"""Test TV flow aborts when entry is already configured."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
result = await _async_start_flow(hass, LGDeviceType.TV)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_DEVICE_TYPE: LGDeviceType.TV, **user_input},
result["flow_id"], user_input=user_input
)
assert result["type"] is FlowResultType.ABORT
@@ -134,7 +145,7 @@ async def test_user_flow_already_configured(
@pytest.mark.usefixtures("init_infrared")
async def test_user_flow_no_emitters_receivers(hass: HomeAssistant) -> None:
"""Test user flow aborts when no infrared emitters or receivers exist."""
"""Test flow aborts when no infrared emitters or receivers exist."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
@@ -143,34 +154,131 @@ async def test_user_flow_no_emitters_receivers(hass: HomeAssistant) -> None:
assert result["reason"] == "no_infrared_entities"
@pytest.mark.usefixtures("mock_infrared_receiver_entity")
async def test_user_flow_menu_hides_ac_without_emitter(hass: HomeAssistant) -> None:
"""Test the AC option is hidden when only a receiver is available."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.MENU
assert result["menu_options"] == [LGDeviceType.TV.value]
@pytest.mark.usefixtures("mock_infrared_emitter_entity")
@pytest.mark.parametrize(
("entity_name", "expected_title"),
[
(None, "LG TV via Test IR emitter"),
("AC IR emitter", "LG TV via AC IR emitter"),
pytest.param(None, "LG TV via Test IR emitter", id="original_name"),
pytest.param("AC IR emitter", "LG TV via AC IR emitter", id="custom_name"),
],
)
async def test_user_flow_title_from_entity_name(
async def test_user_flow_tv_title_from_entity_name(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
entity_name: str | None,
expected_title: str,
) -> None:
"""Test config entry title uses the entity name."""
"""Test TV config entry title uses the entity name."""
entity_registry.async_update_entity(
mock_infrared_emitter_entity_id, name=entity_name
)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await _async_start_flow(hass, LGDeviceType.TV)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_DEVICE_TYPE: LGDeviceType.TV,
CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_id,
},
user_input={CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_id},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == expected_title
@pytest.mark.usefixtures("mock_infrared_emitter_entity")
async def test_user_flow_ac_success(hass: HomeAssistant) -> None:
"""Test successful AC config flow with default modes (cool + dry)."""
result = await _async_start_flow(hass, LGDeviceType.AC)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "ac"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_id,
CONF_HVAC_MODES: [HVACMode.COOL, HVACMode.DRY],
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "LG AC via Test IR emitter"
assert result["data"] == {
CONF_DEVICE_TYPE: LGDeviceType.AC,
CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_id,
CONF_HVAC_MODES: [HVACMode.COOL, HVACMode.DRY],
}
assert result["result"].unique_id is None
@pytest.mark.usefixtures(
"mock_infrared_emitter_entity", "mock_infrared_receiver_entity"
)
async def test_user_flow_ac_with_heat_and_receiver(hass: HomeAssistant) -> None:
"""Test AC flow with heat mode and optional receiver."""
result = await _async_start_flow(hass, LGDeviceType.AC)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_id,
CONF_INFRARED_RECEIVER_ENTITY_ID: mock_infrared_receiver_entity_id,
CONF_HVAC_MODES: [HVACMode.COOL, HVACMode.HEAT, HVACMode.DRY],
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["data"][CONF_HVAC_MODES] == [
HVACMode.COOL,
HVACMode.HEAT,
HVACMode.DRY,
]
assert result["data"][CONF_INFRARED_RECEIVER_ENTITY_ID] == (
mock_infrared_receiver_entity_id
)
@pytest.mark.usefixtures("mock_infrared_emitter_entity")
async def test_user_flow_ac_requires_hvac_mode(hass: HomeAssistant) -> None:
"""Test AC flow rejects an empty list of supported modes."""
result = await _async_start_flow(hass, LGDeviceType.AC)
with pytest.raises(InvalidData) as err:
await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_id,
CONF_HVAC_MODES: [],
},
)
assert err.value.schema_errors == {CONF_HVAC_MODES: "no_hvac_modes"}
@pytest.mark.usefixtures("mock_infrared_emitter_entity")
@pytest.mark.parametrize("device_type", [LGDeviceType.AC])
@pytest.mark.parametrize("has_receiver", [False])
async def test_user_flow_ac_already_configured(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test AC flow aborts when entry is already configured."""
mock_config_entry.add_to_hass(hass)
result = await _async_start_flow(hass, LGDeviceType.AC)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_INFRARED_ENTITY_ID: mock_infrared_emitter_entity_id,
CONF_HVAC_MODES: [HVACMode.COOL, HVACMode.DRY],
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"