mirror of
https://github.com/home-assistant/core.git
synced 2026-09-25 17:04:04 -04:00
Add Gree Infrared integration (#177189)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6c2d4140cc
commit
26d9bfc7ef
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1
@@ -310,6 +310,7 @@ FLOWS = {
|
||||
"gpsd",
|
||||
"gpslogger",
|
||||
"gree",
|
||||
"gree_infrared",
|
||||
"green_planet_energy",
|
||||
"greencell",
|
||||
"growatt_server",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user