diff --git a/.strict-typing b/.strict-typing index 8b1f044aee5e..d3213c0cd8e3 100644 --- a/.strict-typing +++ b/.strict-typing @@ -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.* diff --git a/CODEOWNERS b/CODEOWNERS index e2bc0385e518..444ba17de980 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/brands/gree.json b/homeassistant/brands/gree.json new file mode 100644 index 000000000000..b622e0303682 --- /dev/null +++ b/homeassistant/brands/gree.json @@ -0,0 +1,5 @@ +{ + "domain": "gree", + "name": "Gree", + "integrations": ["gree", "gree_infrared"] +} diff --git a/homeassistant/components/gree_infrared/__init__.py b/homeassistant/components/gree_infrared/__init__.py new file mode 100644 index 000000000000..a225466303aa --- /dev/null +++ b/homeassistant/components/gree_infrared/__init__.py @@ -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) diff --git a/homeassistant/components/gree_infrared/climate.py b/homeassistant/components/gree_infrared/climate.py new file mode 100644 index 000000000000..68289b50da35 --- /dev/null +++ b/homeassistant/components/gree_infrared/climate.py @@ -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() diff --git a/homeassistant/components/gree_infrared/config_flow.py b/homeassistant/components/gree_infrared/config_flow.py new file mode 100644 index 000000000000..9e5c8c69191b --- /dev/null +++ b/homeassistant/components/gree_infrared/config_flow.py @@ -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)) diff --git a/homeassistant/components/gree_infrared/const.py b/homeassistant/components/gree_infrared/const.py new file mode 100644 index 000000000000..90e887758927 --- /dev/null +++ b/homeassistant/components/gree_infrared/const.py @@ -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" diff --git a/homeassistant/components/gree_infrared/entity.py b/homeassistant/components/gree_infrared/entity.py new file mode 100644 index 000000000000..45db4ef1a802 --- /dev/null +++ b/homeassistant/components/gree_infrared/entity.py @@ -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", + ) diff --git a/homeassistant/components/gree_infrared/manifest.json b/homeassistant/components/gree_infrared/manifest.json new file mode 100644 index 000000000000..531de602455b --- /dev/null +++ b/homeassistant/components/gree_infrared/manifest.json @@ -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" +} diff --git a/homeassistant/components/gree_infrared/quality_scale.yaml b/homeassistant/components/gree_infrared/quality_scale.yaml new file mode 100644 index 000000000000..bb30f5992276 --- /dev/null +++ b/homeassistant/components/gree_infrared/quality_scale.yaml @@ -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 diff --git a/homeassistant/components/gree_infrared/strings.json b/homeassistant/components/gree_infrared/strings.json new file mode 100644 index 000000000000..1bffd00302e8 --- /dev/null +++ b/homeassistant/components/gree_infrared/strings.json @@ -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" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index fc8bc986acd6..b09efe44e6b1 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -310,6 +310,7 @@ FLOWS = { "gpsd", "gpslogger", "gree", + "gree_infrared", "green_planet_energy", "greencell", "growatt_server", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 0dd5dfea3416..b0bd1e42431d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -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", diff --git a/mypy.ini b/mypy.ini index b25989bc0da8..489d9261dafe 100644 --- a/mypy.ini +++ b/mypy.ini @@ -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 diff --git a/tests/components/gree_infrared/__init__.py b/tests/components/gree_infrared/__init__.py new file mode 100644 index 000000000000..84ab15c1c479 --- /dev/null +++ b/tests/components/gree_infrared/__init__.py @@ -0,0 +1 @@ +"""Tests for the Gree Infrared integration.""" diff --git a/tests/components/gree_infrared/conftest.py b/tests/components/gree_infrared/conftest.py new file mode 100644 index 000000000000..8d63bb44d127 --- /dev/null +++ b/tests/components/gree_infrared/conftest.py @@ -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 diff --git a/tests/components/gree_infrared/snapshots/test_climate.ambr b/tests/components/gree_infrared/snapshots/test_climate.ambr new file mode 100644 index 000000000000..3449e9616aee --- /dev/null +++ b/tests/components/gree_infrared/snapshots/test_climate.ambr @@ -0,0 +1,85 @@ +# serializer version: 1 +# name: test_entities[climate.gree_ac-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + : list([ + , + , + , + ]), + : 30.0, + : 16.0, + : 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.gree_ac', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + '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': , + 'translation_key': None, + 'unique_id': '01JTEST0000000000000000001', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[climate.gree_ac-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : True, + : None, + : 'auto', + : list([ + 'auto', + 'low', + 'medium', + 'high', + ]), + : 'Gree AC', + : list([ + , + , + , + ]), + : 30.0, + : 16.0, + : , + : 1.0, + : 16.0, + }), + 'context': , + 'entity_id': 'climate.gree_ac', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/gree_infrared/test_climate.py b/tests/components/gree_infrared/test_climate.py new file mode 100644 index 000000000000..6a03ed7bd79b --- /dev/null +++ b/tests/components/gree_infrared/test_climate.py @@ -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 diff --git a/tests/components/gree_infrared/test_config_flow.py b/tests/components/gree_infrared/test_config_flow.py new file mode 100644 index 000000000000..9466329d3333 --- /dev/null +++ b/tests/components/gree_infrared/test_config_flow.py @@ -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 diff --git a/tests/components/gree_infrared/test_init.py b/tests/components/gree_infrared/test_init.py new file mode 100644 index 000000000000..34fa62d547d2 --- /dev/null +++ b/tests/components/gree_infrared/test_init.py @@ -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