Add Gree Infrared integration (#177189)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dr.Blank
2026-09-17 22:04:29 +01:00
committed by GitHub
co-authored by Claude Opus 5
parent 6c2d4140cc
commit 26d9bfc7ef
20 changed files with 2039 additions and 4 deletions
+1
View File
@@ -257,6 +257,7 @@ homeassistant.components.google_sheets.*
homeassistant.components.google_weather.*
homeassistant.components.govee_ble.*
homeassistant.components.gpsd.*
homeassistant.components.gree_infrared.*
homeassistant.components.greeneye_monitor.*
homeassistant.components.group.*
homeassistant.components.guardian.*
Generated
+2
View File
@@ -720,6 +720,8 @@ CLAUDE.md @home-assistant/core
/tests/components/gpsd/ @fabaff @jrieger
/homeassistant/components/gree/ @cmroche
/tests/components/gree/ @cmroche
/homeassistant/components/gree_infrared/ @Dr-Blank
/tests/components/gree_infrared/ @Dr-Blank
/homeassistant/components/green_planet_energy/ @petschni
/tests/components/green_planet_energy/ @petschni
/homeassistant/components/greencell/ @BrzezowskiGC
+5
View File
@@ -0,0 +1,5 @@
{
"domain": "gree",
"name": "Gree",
"integrations": ["gree", "gree_infrared"]
}
@@ -0,0 +1,18 @@
"""Gree IR Remote integration for Home Assistant."""
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
PLATFORMS = [Platform.CLIMATE]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Gree IR from a config entry."""
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a Gree IR config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -0,0 +1,291 @@
"""Climate platform for Gree IR integration — Gree AC."""
from dataclasses import dataclass
from typing import Any, override
from infrared_protocols.commands.gree_ac import (
MAX_TEMP,
MIN_TEMP,
GreeAcCommand,
GreeAcFanSpeed,
GreeAcMode,
)
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 ExtraStoredData, RestoreEntity
from homeassistant.util.unit_conversion import TemperatureConverter
from .const import (
CONF_HVAC_MODES,
CONF_INFRARED_EMITTER_ENTITY_ID,
CONF_INFRARED_RECEIVER_ENTITY_ID,
)
from .entity import GreeIrEntity
PARALLEL_UPDATES = 1
_HA_FAN_TO_LIB: dict[str, GreeAcFanSpeed] = {
FAN_AUTO: GreeAcFanSpeed.AUTO,
FAN_LOW: GreeAcFanSpeed.LOW,
FAN_MEDIUM: GreeAcFanSpeed.MEDIUM,
FAN_HIGH: GreeAcFanSpeed.HIGH,
}
_LIB_FAN_TO_HA: dict[GreeAcFanSpeed, str] = {v: k for k, v in _HA_FAN_TO_LIB.items()}
# Every mode other than OFF; the protocol has no OFF mode of its own, power is a
# separate field, so this dict intentionally has no HVACMode.OFF entry.
_HA_MODE_TO_LIB: dict[HVACMode, GreeAcMode] = {
HVACMode.AUTO: GreeAcMode.AUTO,
HVACMode.COOL: GreeAcMode.COOL,
HVACMode.HEAT: GreeAcMode.HEAT,
HVACMode.DRY: GreeAcMode.DRY,
HVACMode.FAN_ONLY: GreeAcMode.FAN_ONLY,
}
_LIB_MODE_TO_HA: dict[GreeAcMode, HVACMode] = {v: k for k, v in _HA_MODE_TO_LIB.items()}
@dataclass
class _GreeAcExtraStoredData(ExtraStoredData):
"""Extra data restored alongside the entity's visible state.
Holds the mode the unit was last actively in. The visible state only records
OFF once the unit is off, but off frames still carry a mode field, so this
cannot be recovered from last_state.state alone.
"""
last_active_hvac_mode: str
@override
def as_dict(self) -> dict[str, Any]:
"""Return a dict representation for storage."""
return {"last_active_hvac_mode": self.last_active_hvac_mode}
@classmethod
def from_dict(cls, restored: dict[str, Any]) -> _GreeAcExtraStoredData | None:
"""Build from a stored dict, or None if it doesn't look valid."""
last_active_hvac_mode = restored.get("last_active_hvac_mode")
if not isinstance(last_active_hvac_mode, str):
return None
return cls(last_active_hvac_mode=last_active_hvac_mode)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Gree AC climate entity from config entry."""
emitter_entity_id = entry.data[CONF_INFRARED_EMITTER_ENTITY_ID]
if receiver_entity_id := entry.data.get(CONF_INFRARED_RECEIVER_ENTITY_ID):
async_add_entities(
[GreeAcClimateWithReceiver(entry, emitter_entity_id, receiver_entity_id)]
)
else:
async_add_entities([GreeAcClimateEntity(entry, emitter_entity_id)])
class GreeAcClimateEntity(
GreeIrEntity, InfraredEmitterConsumerEntity, ClimateEntity, RestoreEntity
):
"""Gree 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
# Every mode's frame carries a temperature and a fan field, so both features are
# always supported regardless of which modes are configured.
_attr_supported_features = (
ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE
)
_attr_fan_modes = [FAN_AUTO, FAN_LOW, FAN_MEDIUM, FAN_HIGH]
def __init__(self, entry: ConfigEntry, emitter_entity_id: str) -> None:
"""Initialize Gree AC climate entity."""
super().__init__(entry)
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
# Power-off frames still need a mode field; this tracks the mode to send it
# with, since the protocol has no dedicated OFF mode.
self._last_active_hvac_mode = self._attr_hvac_modes[1]
@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 not None and last_state.state not in (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
):
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(
round(
TemperatureConverter.convert(
float(temperature),
self.hass.config.units.temperature_unit,
self.temperature_unit,
)
)
)
current_mode = self._attr_hvac_mode
if current_mode is not None and current_mode is not HVACMode.OFF:
self._last_active_hvac_mode = current_mode
elif (last_extra_data := await self.async_get_last_extra_data()) is not None:
restored = _GreeAcExtraStoredData.from_dict(last_extra_data.as_dict())
if restored is not None and restored.last_active_hvac_mode in (
mode.value for mode in self._attr_hvac_modes if mode is not HVACMode.OFF
):
self._last_active_hvac_mode = HVACMode(restored.last_active_hvac_mode)
@property
@override
def extra_restore_state_data(self) -> ExtraStoredData:
"""Return extra data to be restored alongside the entity's state."""
return _GreeAcExtraStoredData(
last_active_hvac_mode=self._last_active_hvac_mode.value
)
async def _async_send_state(
self, hvac_mode: HVACMode, temp: int, fan_mode: str
) -> None:
"""Send a full-state frame for the given target state."""
power = hvac_mode is not HVACMode.OFF
active_hvac_mode = hvac_mode if power else self._last_active_hvac_mode
await self._send_command(
self._build_command(active_hvac_mode, power, temp, fan_mode)
)
if power:
self._last_active_hvac_mode = hvac_mode
@override
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set HVAC mode."""
await self._async_send_state(
hvac_mode,
int(self._attr_target_temperature or MIN_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 = round(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)
effective_mode = hvac_mode or self._attr_hvac_mode or HVACMode.OFF
# A temperature change on its own has nothing to send while the unit is off.
if effective_mode is not HVACMode.OFF or hvac_mode is HVACMode.OFF:
await self._async_send_state(
effective_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."""
hvac_mode = self._attr_hvac_mode
if hvac_mode is not None and hvac_mode is not HVACMode.OFF:
await self._async_send_state(
hvac_mode, int(self._attr_target_temperature or MIN_TEMP), fan_mode
)
self._attr_fan_mode = fan_mode
self.async_write_ha_state()
def _build_command(
self, hvac_mode: HVACMode, power: bool, temp: int, fan_mode: str
) -> GreeAcCommand:
"""Build a command from a mode, power state, a temperature and a fan mode."""
return GreeAcCommand(
power=power,
mode=_HA_MODE_TO_LIB[hvac_mode],
temperature=temp,
fan=_HA_FAN_TO_LIB[fan_mode],
swing_v=False,
swing_h=False,
turbo=False,
display=True,
blow=False,
)
class GreeAcClimateWithReceiver(GreeAcClimateEntity, InfraredReceiverConsumerEntity):
"""Gree 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 Gree 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 = GreeAcCommand.from_raw_timings(signal.timings)
if command is None:
return
# Off frames carry a mode field too, so the mode is recorded either way.
embedded_hvac_mode = _LIB_MODE_TO_HA[command.mode]
if embedded_hvac_mode in self._attr_hvac_modes:
self._last_active_hvac_mode = embedded_hvac_mode
elif command.power:
return
hvac_mode = embedded_hvac_mode if command.power else HVACMode.OFF
self._attr_hvac_mode = hvac_mode
self._attr_fan_mode = _LIB_FAN_TO_HA[command.fan]
self._attr_target_temperature = float(command.temperature)
self.async_write_ha_state()
@@ -0,0 +1,108 @@
"""Config flow for Gree IR integration."""
from typing import Any, override
import probatio
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,
EntitySelectorConfig,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import (
CONF_HVAC_MODES,
CONF_INFRARED_EMITTER_ENTITY_ID,
CONF_INFRARED_RECEIVER_ENTITY_ID,
DOMAIN,
)
_HVAC_MODE_OPTIONS = [
HVACMode.COOL,
HVACMode.HEAT,
HVACMode.DRY,
HVACMode.FAN_ONLY,
HVACMode.AUTO,
]
_DEFAULT_HVAC_MODES = [HVACMode.COOL, HVACMode.DRY]
@callback
def _user_schema(hass: HomeAssistant) -> probatio.Schema:
"""Return the emitter/receiver/mode selection schema."""
return probatio.Schema(
{
probatio.Required(CONF_INFRARED_EMITTER_ENTITY_ID): EntitySelector(
EntitySelectorConfig(
domain=INFRARED_DOMAIN,
include_entities=async_get_emitters(hass),
)
),
probatio.Optional(CONF_INFRARED_RECEIVER_ENTITY_ID): EntitySelector(
EntitySelectorConfig(
domain=INFRARED_DOMAIN,
include_entities=async_get_receivers(hass),
)
),
probatio.Required(
CONF_HVAC_MODES, default=_DEFAULT_HVAC_MODES
): probatio.All(
SelectSelector(
SelectSelectorConfig(
options=[mode.value for mode in _HVAC_MODE_OPTIONS],
translation_key=CONF_HVAC_MODES,
mode=SelectSelectorMode.LIST,
multiple=True,
)
),
probatio.Length(min=1, msg="no_hvac_modes"),
),
}
)
class GreeIrConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle config flow for Gree IR."""
VERSION = 1
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
@override
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle emitter, receiver and mode selection."""
if not async_get_emitters(self.hass):
return self.async_abort(reason="no_infrared_emitters")
if user_input is not None:
emitter_id = user_input[CONF_INFRARED_EMITTER_ENTITY_ID]
self._async_abort_entries_match(
{CONF_INFRARED_EMITTER_ENTITY_ID: emitter_id}
)
if receiver_id := user_input.get(CONF_INFRARED_RECEIVER_ENTITY_ID):
self._async_abort_entries_match(
{CONF_INFRARED_RECEIVER_ENTITY_ID: receiver_id}
)
return self.async_create_entry(
title=f"Gree AC via {self._entity_name(emitter_id)}",
data=user_input,
)
return self.async_show_form(step_id="user", data_schema=_user_schema(self.hass))
@@ -0,0 +1,6 @@
"""Constants for the Gree IR integration."""
DOMAIN = "gree_infrared"
CONF_INFRARED_EMITTER_ENTITY_ID = "infrared_emitter_entity_id"
CONF_INFRARED_RECEIVER_ENTITY_ID = "infrared_receiver_entity_id"
CONF_HVAC_MODES = "hvac_modes"
@@ -0,0 +1,31 @@
"""Common entity for Gree IR integration."""
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from .const import DOMAIN
class GreeIrEntity(Entity):
"""Gree IR base entity providing common device info."""
_attr_has_entity_name = True
def __init__(
self,
entry: ConfigEntry,
unique_id_suffix: str | None = None,
device_name: str = "Gree AC",
) -> None:
"""Initialize Gree IR entity."""
self._attr_unique_id = (
entry.entry_id
if unique_id_suffix is None
else f"{entry.entry_id}_{unique_id_suffix}"
)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
name=device_name,
manufacturer="Gree",
)
@@ -0,0 +1,11 @@
{
"domain": "gree_infrared",
"name": "Gree Infrared",
"codeowners": ["@Dr-Blank"],
"config_flow": true,
"dependencies": ["infrared"],
"documentation": "https://www.home-assistant.io/integrations/gree_infrared",
"integration_type": "device",
"iot_class": "assumed_state",
"quality_scale": "silver"
}
@@ -0,0 +1,125 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
This integration does not provide additional actions.
appropriate-polling:
status: exempt
comment: |
This integration does not poll.
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
This integration does not provide additional actions.
docs-conditions:
status: exempt
comment: This integration does not have any conditions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
docs-triggers:
status: exempt
comment: This integration does not have any triggers.
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data:
status: exempt
comment: |
This integration does not store runtime data.
test-before-configure:
status: exempt
comment: |
The config flow only selects an existing infrared emitter entity and the
supported modes, so there is no device connection to validate before
creating the entry.
test-before-setup:
status: exempt
comment: |
This integration only proxies commands through an existing infrared
entity, so there is no separate connection to validate during setup.
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: |
This integration does not register custom actions.
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow:
status: exempt
comment: |
This integration does not require authentication.
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: |
This integration does not support discovery.
discovery:
status: exempt
comment: |
This integration is configured manually via config flow.
docs-data-update:
status: exempt
comment: |
This integration does not fetch data from devices.
docs-examples: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: |
Each config entry creates a single device.
entity-category: done
entity-device-class: done
entity-disabled-by-default:
status: exempt
comment: |
No entities should be disabled by default.
entity-translations: done
exception-translations:
status: exempt
comment: |
This integration does not raise exceptions.
icon-translations:
status: exempt
comment: |
This integration does not use custom icons.
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: |
This integration does not have repairable issues.
stale-devices:
status: exempt
comment: |
Each config entry manages exactly one device.
# Platinum
async-dependency:
status: exempt
comment: |
This integration depends on infrared_protocols, which provides only code
definitions with no I/O, so async dependency does not apply.
inject-websession:
status: exempt
comment: |
This integration does not make HTTP requests.
strict-typing: done
@@ -0,0 +1,38 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"no_infrared_emitters": "[%key:common::config_flow::abort::no_infrared_emitters%]"
},
"error": {
"no_hvac_modes": "Select at least one supported mode."
},
"step": {
"user": {
"data": {
"hvac_modes": "Supported modes",
"infrared_emitter_entity_id": "Infrared emitter",
"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_emitter_entity_id": "The infrared emitter entity to use for sending commands.",
"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 Gree AC supports.",
"title": "Set up Gree AC"
}
}
},
"selector": {
"hvac_modes": {
"options": {
"auto": "[%key:common::state::auto%]",
"cool": "Cool",
"dry": "Dry",
"fan_only": "Fan only",
"heat": "Heat"
}
}
}
}
+1
View File
@@ -310,6 +310,7 @@ FLOWS = {
"gpsd",
"gpslogger",
"gree",
"gree_infrared",
"green_planet_energy",
"greencell",
"growatt_server",
+15 -4
View File
@@ -2787,10 +2787,21 @@
"iot_class": "local_push"
},
"gree": {
"name": "Gree Climate",
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_polling"
"name": "Gree",
"integrations": {
"gree": {
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_polling",
"name": "Gree Climate"
},
"gree_infrared": {
"integration_type": "device",
"config_flow": true,
"iot_class": "assumed_state",
"name": "Gree Infrared"
}
}
},
"green_planet_energy": {
"name": "Green Planet Energy",
Generated
+10
View File
@@ -2328,6 +2328,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.gree_infrared.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.greeneye_monitor.*]
check_untyped_defs = true
disallow_incomplete_defs = true
@@ -0,0 +1 @@
"""Tests for the Gree Infrared integration."""
@@ -0,0 +1,92 @@
"""Common fixtures for the Gree Infrared tests."""
from typing import Any
from unittest.mock import patch
import pytest
from homeassistant.components.climate import HVACMode
from homeassistant.components.gree_infrared import PLATFORMS
from homeassistant.components.gree_infrared.const import (
CONF_HVAC_MODES,
CONF_INFRARED_EMITTER_ENTITY_ID,
CONF_INFRARED_RECEIVER_ENTITY_ID,
DOMAIN,
)
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
from tests.components.infrared import (
EMITTER_ENTITY_ID as MOCK_INFRARED_EMITTER_ENTITY_ID,
RECEIVER_ENTITY_ID as MOCK_INFRARED_RECEIVER_ENTITY_ID,
)
from tests.components.infrared.common import (
MockInfraredEmitterEntity,
MockInfraredReceiverEntity,
)
ENTRY_ID = "01JTEST0000000000000000001"
@pytest.fixture
def hvac_modes() -> list[HVACMode]:
"""Return the HVAC modes configured on the 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(hvac_modes: list[HVACMode]) -> dict[str, Any]:
"""Return the config entry data beyond the emitter/receiver ids."""
return {CONF_HVAC_MODES: hvac_modes}
@pytest.fixture
def mock_config_entry(
extra_entry_data: dict[str, Any],
has_receiver: bool,
) -> MockConfigEntry:
"""Return a mock config entry for the Gree AC."""
data: dict[str, Any] = {
CONF_INFRARED_EMITTER_ENTITY_ID: MOCK_INFRARED_EMITTER_ENTITY_ID,
**extra_entry_data,
}
if has_receiver:
data[CONF_INFRARED_RECEIVER_ENTITY_ID] = MOCK_INFRARED_RECEIVER_ENTITY_ID
return MockConfigEntry(
domain=DOMAIN,
entry_id=ENTRY_ID,
title="Gree AC via Test IR emitter",
data=data,
)
@pytest.fixture
def platforms() -> list[Platform]:
"""Return platforms to set up."""
return PLATFORMS
@pytest.fixture
async def init_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
mock_infrared_receiver_entity: MockInfraredReceiverEntity,
platforms: list[Platform],
) -> MockConfigEntry:
"""Set up the Gree Infrared integration for testing."""
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.gree_infrared.PLATFORMS", platforms):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry
@@ -0,0 +1,85 @@
# serializer version: 1
# name: test_entities[climate.gree_ac-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<ClimateEntityCapabilityAttribute.FAN_MODES: 'fan_modes'>: list([
'auto',
'low',
'medium',
'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.gree_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': 'gree_infrared',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <ClimateEntityFeature: 9>,
'translation_key': None,
'unique_id': '01JTEST0000000000000000001',
'unit_of_measurement': None,
})
# ---
# name: test_entities[climate.gree_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',
'low',
'medium',
'high',
]),
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Gree 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.TARGET_TEMPERATURE: 'temperature'>: 16.0,
}),
'context': <ANY>,
'entity_id': 'climate.gree_ac',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
@@ -0,0 +1,978 @@
"""Tests for the Gree Infrared climate platform."""
from typing import Any
from unittest.mock import patch
from infrared_protocols.commands.gree_ac import (
MIN_TEMP,
GreeAcCommand,
GreeAcFanSpeed,
GreeAcMode,
)
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.const import (
ATTR_ENTITY_ID,
ATTR_TEMPERATURE,
STATE_UNAVAILABLE,
Platform,
)
from homeassistant.core import HomeAssistant, State
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import entity_registry as er
from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM
from tests.common import (
MockConfigEntry,
mock_restore_cache,
mock_restore_cache_with_extra_data,
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.gree_ac"
@pytest.fixture
def platforms() -> list[Platform]:
"""Return platforms to set up."""
return [Platform.CLIMATE]
@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", "mock_infrared_emitter_entity")
async def test_availability_follows_emitter(
hass: HomeAssistant,
) -> 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 a power-off frame with the default mode.
The protocol has no dedicated off mode, so the frame still carries a mode; before
any mode has been active, that is the first configured mode.
"""
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
== GreeAcCommand(
power=False,
mode=GreeAcMode.COOL,
temperature=MIN_TEMP,
fan=GreeAcFanSpeed.AUTO,
).get_raw_timings()
)
@pytest.mark.usefixtures("init_integration")
async def test_failed_send_does_not_become_the_last_active_mode(
hass: HomeAssistant,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
) -> None:
"""Test a mode whose frame never went out is not carried by a later off frame.
The unit only reaches a mode if its frame was actually transmitted, so a send that
raised must leave the remembered mode alone; otherwise the next off frame carries a
mode the unit was never put into.
"""
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()
with (
patch.object(
mock_infrared_emitter_entity,
"async_send_command",
side_effect=HomeAssistantError,
),
pytest.raises(HomeAssistantError),
):
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_HVAC_MODE,
{ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID, "hvac_mode": HVACMode.COOL},
blocking=True,
)
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
== GreeAcCommand(
power=False,
mode=GreeAcMode.DRY,
temperature=MIN_TEMP,
fan=GreeAcFanSpeed.AUTO,
).get_raw_timings()
)
@pytest.mark.usefixtures("init_integration")
async def test_set_hvac_mode_off_keeps_the_last_active_mode(
hass: HomeAssistant,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
) -> None:
"""Test a power-off frame carries the mode that was last active.
The mode field is part of every frame and the entity's own mode is off by then, so
the last active mode has to be tracked separately; dry here is deliberately not the
first configured mode, which is what an untracked implementation would fall back to.
"""
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_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
== GreeAcCommand(
power=False,
mode=GreeAcMode.DRY,
temperature=MIN_TEMP,
fan=GreeAcFanSpeed.AUTO,
).get_raw_timings()
)
@pytest.mark.usefixtures("init_integration")
@pytest.mark.parametrize(
("hvac_mode", "temp", "fan", "expected_cmd"),
[
pytest.param(
HVACMode.COOL,
24,
FAN_AUTO,
GreeAcCommand(
mode=GreeAcMode.COOL, temperature=24, fan=GreeAcFanSpeed.AUTO
),
id="cool_24_auto",
),
pytest.param(
HVACMode.COOL,
18,
FAN_LOW,
GreeAcCommand(mode=GreeAcMode.COOL, temperature=18, fan=GreeAcFanSpeed.LOW),
id="cool_18_low",
),
pytest.param(
HVACMode.COOL,
30,
FAN_HIGH,
GreeAcCommand(
mode=GreeAcMode.COOL, temperature=30, fan=GreeAcFanSpeed.HIGH
),
id="cool_30_high",
),
pytest.param(
HVACMode.DRY,
24,
FAN_MEDIUM,
GreeAcCommand(
mode=GreeAcMode.DRY, temperature=24, fan=GreeAcFanSpeed.MEDIUM
),
id="dry_24_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: GreeAcCommand,
) -> 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, HVACMode.AUTO]],
)
@pytest.mark.usefixtures("init_integration")
@pytest.mark.parametrize(
("hvac_mode", "expected_cmd"),
[
pytest.param(
HVACMode.HEAT,
GreeAcCommand(
mode=GreeAcMode.HEAT, temperature=MIN_TEMP, fan=GreeAcFanSpeed.AUTO
),
id="heat",
),
pytest.param(
HVACMode.FAN_ONLY,
GreeAcCommand(
mode=GreeAcMode.FAN_ONLY,
temperature=MIN_TEMP,
fan=GreeAcFanSpeed.AUTO,
),
id="fan_only",
),
pytest.param(
HVACMode.AUTO,
GreeAcCommand(
mode=GreeAcMode.AUTO, temperature=MIN_TEMP, fan=GreeAcFanSpeed.AUTO
),
id="auto",
),
],
)
async def test_set_hvac_mode_from_off_uses_defaults(
hass: HomeAssistant,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
hvac_mode: HVACMode,
expected_cmd: GreeAcCommand,
) -> None:
"""Test modes not reachable via the cool/dry default encode from 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
== GreeAcCommand(
mode=GreeAcMode.COOL, temperature=26, fan=GreeAcFanSpeed.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.parametrize("hvac_modes", [[HVACMode.DRY]])
@pytest.mark.usefixtures("init_integration")
async def test_set_temperature_sends_command_in_dry_mode(
hass: HomeAssistant,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
) -> None:
"""Test dry mode sends IR on temperature change.
The temperature field is present in every mode's frame, so a temperature change
is always transmittable.
"""
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) == 1
timings = mock_infrared_emitter_entity.send_command_calls[0].get_raw_timings()
assert (
timings
== GreeAcCommand(
mode=GreeAcMode.DRY, temperature=25, fan=GreeAcFanSpeed.AUTO
).get_raw_timings()
)
@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_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
== GreeAcCommand(
mode=GreeAcMode.COOL, temperature=MIN_TEMP, fan=GreeAcFanSpeed.HIGH
).get_raw_timings()
)
@pytest.mark.usefixtures("init_integration")
async def test_set_fan_mode_no_command_when_off(
hass: HomeAssistant,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
) -> None:
"""Test set_fan_mode updates state but sends no IR when AC is off."""
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) == 0
state = hass.states.get(_CLIMATE_ENTITY_ID)
assert state is not None
assert state.attributes["fan_mode"] == FAN_HIGH
@pytest.mark.parametrize("has_receiver", [True])
@pytest.mark.usefixtures("init_integration")
@pytest.mark.parametrize(
("lib_fan", "expected_fan_mode"),
[
pytest.param(GreeAcFanSpeed.AUTO, FAN_AUTO, id="auto"),
pytest.param(GreeAcFanSpeed.LOW, FAN_LOW, id="low"),
pytest.param(GreeAcFanSpeed.MEDIUM, FAN_MEDIUM, id="medium"),
pytest.param(GreeAcFanSpeed.HIGH, FAN_HIGH, id="high"),
],
)
async def test_receiver_updates_state_on_cool_signal(
hass: HomeAssistant,
mock_infrared_receiver_entity: MockInfraredReceiverEntity,
lib_fan: GreeAcFanSpeed,
expected_fan_mode: str,
) -> None:
"""Test that a received cool signal updates mode, temperature and every fan speed."""
timings = GreeAcCommand(
mode=GreeAcMode.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, preserving temperature and fan."""
mock_infrared_receiver_entity._handle_received_signal(
InfraredReceivedSignal(
timings=GreeAcCommand(
mode=GreeAcMode.COOL, temperature=24, fan=GreeAcFanSpeed.MEDIUM
).get_raw_timings()
)
)
await hass.async_block_till_done()
mock_infrared_receiver_entity._handle_received_signal(
InfraredReceivedSignal(
timings=GreeAcCommand(
power=False,
mode=GreeAcMode.COOL,
temperature=24,
fan=GreeAcFanSpeed.MEDIUM,
).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_set_hvac_mode_off_keeps_the_mode_seen_by_the_receiver(
hass: HomeAssistant,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
mock_infrared_receiver_entity: MockInfraredReceiverEntity,
) -> None:
"""Test a power-off frame carries the last mode the physical remote selected."""
mock_infrared_receiver_entity._handle_received_signal(
InfraredReceivedSignal(
timings=GreeAcCommand(
mode=GreeAcMode.DRY, temperature=24, fan=GreeAcFanSpeed.MEDIUM
).get_raw_timings()
)
)
await hass.async_block_till_done()
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
== GreeAcCommand(
power=False,
mode=GreeAcMode.DRY,
temperature=24,
fan=GreeAcFanSpeed.MEDIUM,
).get_raw_timings()
)
@pytest.mark.parametrize("has_receiver", [True])
@pytest.mark.usefixtures("init_integration")
async def test_receiver_off_signal_records_the_mode_it_carries(
hass: HomeAssistant,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
mock_infrared_receiver_entity: MockInfraredReceiverEntity,
) -> None:
"""Test an off frame seen before any on frame still records the mode it carries."""
mock_infrared_receiver_entity._handle_received_signal(
InfraredReceivedSignal(
timings=GreeAcCommand(
power=False,
mode=GreeAcMode.DRY,
temperature=24,
fan=GreeAcFanSpeed.MEDIUM,
).get_raw_timings()
)
)
await hass.async_block_till_done()
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
== GreeAcCommand(
power=False,
mode=GreeAcMode.DRY,
temperature=24,
fan=GreeAcFanSpeed.MEDIUM,
).get_raw_timings()
)
@pytest.mark.usefixtures("mock_infrared_emitter_entity")
async def test_last_active_mode_restored_on_restart_while_off(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
platforms: list[Platform],
) -> None:
"""Test an off frame after a restart carries the mode the unit was last in.
The visible state only records off, so without the extra restore data the off
frame would fall back to the first configured mode instead of the real one.
"""
mock_restore_cache_with_extra_data(
hass,
[
(
State(_CLIMATE_ENTITY_ID, HVACMode.OFF, {ATTR_TEMPERATURE: 24.0}),
{"last_active_hvac_mode": HVACMode.DRY.value},
)
],
)
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.gree_infrared.PLATFORMS", platforms):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
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
== GreeAcCommand(
power=False,
mode=GreeAcMode.DRY,
temperature=24,
fan=GreeAcFanSpeed.AUTO,
).get_raw_timings()
)
@pytest.mark.usefixtures("mock_infrared_emitter_entity")
async def test_last_active_mode_restored_from_unavailable_state(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
platforms: list[Platform],
) -> None:
"""Test the last active mode survives a restart from an unavailable state.
The visible state carries nothing usable once it is unavailable, so the extra
restore data has to be read regardless of what the visible state says.
"""
mock_restore_cache_with_extra_data(
hass,
[
(
State(_CLIMATE_ENTITY_ID, STATE_UNAVAILABLE),
{"last_active_hvac_mode": HVACMode.DRY.value},
)
],
)
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.gree_infrared.PLATFORMS", platforms):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
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
== GreeAcCommand(
power=False,
mode=GreeAcMode.DRY,
temperature=MIN_TEMP,
fan=GreeAcFanSpeed.AUTO,
).get_raw_timings()
)
@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=GreeAcCommand(
mode=GreeAcMode.HEAT, temperature=24, fan=GreeAcFanSpeed.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_gree_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.usefixtures("init_integration")
async def test_supported_features_always_include_target_temperature(
hass: HomeAssistant,
) -> None:
"""Test target temperature is always offered, since every mode's frame carries it."""
state = hass.states.get(_CLIMATE_ENTITY_ID)
assert state is not None
assert state.attributes["supported_features"] == (
ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE
)
@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",
),
],
)
@pytest.mark.usefixtures("mock_infrared_emitter_entity")
async def test_state_restored_on_restart(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
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.gree_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")
@pytest.mark.parametrize(
("hvac_mode", "expected_cmd"),
[
pytest.param(
HVACMode.COOL,
GreeAcCommand(
mode=GreeAcMode.COOL, temperature=24, fan=GreeAcFanSpeed.AUTO
),
id="cool",
),
pytest.param(
HVACMode.OFF,
GreeAcCommand(
power=False,
mode=GreeAcMode.COOL,
temperature=24,
fan=GreeAcFanSpeed.AUTO,
),
id="off",
),
],
)
async def test_set_temperature_with_hvac_mode(
hass: HomeAssistant,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
hvac_mode: HVACMode,
expected_cmd: GreeAcCommand,
) -> None:
"""Test set_temperature applies a given mode, off included, while off."""
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,
{
ATTR_ENTITY_ID: _CLIMATE_ENTITY_ID,
ATTR_TEMPERATURE: 24,
"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()
state = hass.states.get(_CLIMATE_ENTITY_ID)
assert state is not None
assert state.state == hvac_mode
assert state.attributes["temperature"] == 24.0
async def test_fahrenheit_temperatures_round_trip(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
platforms: list[Platform],
) -> None:
"""Test temperatures convert to Celsius on a Fahrenheit installation."""
hass.config.units = US_CUSTOMARY_SYSTEM
mock_restore_cache(
hass,
[
State(
_CLIMATE_ENTITY_ID,
HVACMode.COOL,
{"fan_mode": FAN_AUTO, "temperature": 75},
)
],
)
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.gree_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
assert state.attributes["temperature"] == 75
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: 75},
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
== GreeAcCommand(
mode=GreeAcMode.COOL, temperature=24, fan=GreeAcFanSpeed.AUTO
).get_raw_timings()
)
@pytest.mark.usefixtures("init_integration")
async def test_set_temperature_with_hvac_mode_off_while_active(
hass: HomeAssistant,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
) -> None:
"""Test requesting off alongside a temperature turns an active AC off."""
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,
"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
== GreeAcCommand(
power=False,
mode=GreeAcMode.COOL,
temperature=26,
fan=GreeAcFanSpeed.AUTO,
).get_raw_timings()
)
state = hass.states.get(_CLIMATE_ENTITY_ID)
assert state is not None
assert state.state == HVACMode.OFF
assert state.attributes["temperature"] == 26.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
@@ -0,0 +1,202 @@
"""Tests for the Gree Infrared config flow."""
import pytest
from homeassistant.components.climate import HVACMode
from homeassistant.components.gree_infrared.const import (
CONF_HVAC_MODES,
CONF_INFRARED_EMITTER_ENTITY_ID,
CONF_INFRARED_RECEIVER_ENTITY_ID,
DOMAIN,
)
from homeassistant.components.infrared import DATA_COMPONENT
from homeassistant.config_entries import SOURCE_USER
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType, InvalidData
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry
from tests.components.infrared import (
EMITTER_ENTITY_ID as mock_infrared_emitter_entity_id,
RECEIVER_ENTITY_ID as mock_infrared_receiver_entity_id,
)
from tests.components.infrared.common import MockInfraredEmitterEntity
@pytest.mark.usefixtures("mock_infrared_emitter_entity")
async def test_user_flow_success(hass: HomeAssistant) -> None:
"""Test successful config flow with default modes (cool + dry)."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_INFRARED_EMITTER_ENTITY_ID: mock_infrared_emitter_entity_id,
CONF_HVAC_MODES: [HVACMode.COOL, HVACMode.DRY],
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Gree AC via Test IR emitter"
assert result["data"] == {
CONF_INFRARED_EMITTER_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_with_heat_and_receiver(hass: HomeAssistant) -> None:
"""Test config flow with heat mode and optional receiver."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_INFRARED_EMITTER_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_requires_hvac_mode(hass: HomeAssistant) -> None:
"""Test the flow rejects an empty list of supported modes."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
with pytest.raises(InvalidData) as err:
await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_INFRARED_EMITTER_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")
async def test_user_flow_already_configured(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test the flow aborts when the emitter is already configured."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_INFRARED_EMITTER_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"
@pytest.mark.usefixtures(
"mock_infrared_emitter_entity", "mock_infrared_receiver_entity"
)
async def test_user_flow_receiver_already_configured(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test the flow aborts when the receiver is already configured."""
mock_config_entry.add_to_hass(hass)
await hass.data[DATA_COMPONENT].async_add_entities(
[MockInfraredEmitterEntity("second_ir_emitter", "Second IR emitter")]
)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_INFRARED_EMITTER_ENTITY_ID: "infrared.second_ir_emitter",
CONF_INFRARED_RECEIVER_ENTITY_ID: mock_infrared_receiver_entity_id,
CONF_HVAC_MODES: [HVACMode.COOL, HVACMode.DRY],
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@pytest.mark.usefixtures("init_infrared")
async def test_user_flow_no_emitters(hass: HomeAssistant) -> None:
"""Test the flow aborts when no infrared emitters exist."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_infrared_emitters"
@pytest.mark.usefixtures("mock_infrared_receiver_entity")
async def test_user_flow_no_emitters_receiver_only(hass: HomeAssistant) -> None:
"""Test the flow aborts when only a receiver is available, since AC needs an emitter."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_infrared_emitters"
@pytest.mark.usefixtures("mock_infrared_emitter_entity")
@pytest.mark.parametrize(
("entity_name", "expected_title"),
[
pytest.param(None, "Gree AC via Test IR emitter", id="original_name"),
pytest.param("AC IR emitter", "Gree AC via AC IR emitter", id="custom_name"),
],
)
async def test_user_flow_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."""
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 hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_INFRARED_EMITTER_ENTITY_ID: mock_infrared_emitter_entity_id,
CONF_HVAC_MODES: [HVACMode.COOL, HVACMode.DRY],
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == expected_title
@@ -0,0 +1,19 @@
"""Tests for the Gree Infrared integration setup."""
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def test_setup_and_unload_entry(
hass: HomeAssistant, init_integration: MockConfigEntry
) -> None:
"""Test setting up and unloading a config entry."""
entry = init_integration
assert entry.state is ConfigEntryState.LOADED
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.NOT_LOADED