diff --git a/.strict-typing b/.strict-typing index c7a1b604fac7e..afe8fcf283e86 100644 --- a/.strict-typing +++ b/.strict-typing @@ -393,6 +393,7 @@ homeassistant.components.miele.* homeassistant.components.mikrotik.* homeassistant.components.min_max.* homeassistant.components.minecraft_server.* +homeassistant.components.mitsubishi_wf_rac.* homeassistant.components.mjpeg.* homeassistant.components.modbus.* homeassistant.components.modem_callerid.* diff --git a/CODEOWNERS b/CODEOWNERS index f25b6c4f27264..3498c0044c520 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1175,6 +1175,8 @@ CLAUDE.md @home-assistant/core /tests/components/minio/ @tkislan /homeassistant/components/mitsubishi_comfort/ @nikolairahimi /tests/components/mitsubishi_comfort/ @nikolairahimi +/homeassistant/components/mitsubishi_wf_rac/ @blues-sechseck +/tests/components/mitsubishi_wf_rac/ @blues-sechseck /homeassistant/components/moat/ @bdraco /tests/components/moat/ @bdraco /homeassistant/components/mobile_app/ @home-assistant/core diff --git a/homeassistant/components/mitsubishi_wf_rac/__init__.py b/homeassistant/components/mitsubishi_wf_rac/__init__.py new file mode 100644 index 0000000000000..ce0a8b27c0553 --- /dev/null +++ b/homeassistant/components/mitsubishi_wf_rac/__init__.py @@ -0,0 +1,233 @@ +"""The Mitsubishi WF-RAC integration.""" + +import logging + +from homeassistant.const import CONF_DEVICE_ID, CONF_HOST, CONF_PORT, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import issue_registry as ir + +from .const import ( + CONF_AIRCO_ID, + CONF_AVAILABILITY_CHECK, + CONF_AVAILABILITY_RETRY_LIMIT, + CONF_CONNECTION_METHOD, + CONF_OPERATOR_ID, + DOMAIN, +) +from .coordinator import ( + AVAILABILITY_FAILURE_LIMIT_MIN, + Device, + MitsubishiWfRacConfigEntry, + MitsubishiWfRacData, + registration_full_issue_id, +) + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [Platform.CLIMATE] + + +async def async_migrate_entry( + hass: HomeAssistant, entry: MitsubishiWfRacConfigEntry +) -> bool: + """Migrate old config entry.""" + + if entry.version == 1: + new_data = entry.data.copy() + new_options = { + CONF_HOST: new_data.pop(CONF_HOST), + CONF_AVAILABILITY_CHECK: False, + CONF_AVAILABILITY_RETRY_LIMIT: 3, + } + + hass.config_entries.async_update_entry( + entry, data=new_data, options=new_options, version=2 + ) + if entry.version == 2: + # This step used to write an "availability_retry" key that nothing ever + # reads, and to reset CONF_AVAILABILITY_RETRY_LIMIT back to 3 over any + # value the user had picked. Both are gone; the version bump is all that + # is left. Entries that already ran the old step get the stale key + # cleaned up by the v3 -> v4 step below. + hass.config_entries.async_update_entry(entry, version=3) + if entry.version == 3: + new_options = dict(entry.options) + new_options.pop("availability_retry", None) + # The v1 -> v2 step above hard-set CONF_AVAILABILITY_CHECK to False at a + # time when the flag was dead code (see create_device_from_entry), so + # every entry predating v2 has been running with no retry tolerance at + # all: one failed poll marks the device unavailable. The WF-RAC module + # reassociates on its own roughly once an hour, which a 60s poll + # interval turns into a visible outage. Turn the check on, and lift + # limits below 2, which are equivalent to it being off (Device. + # _set_availability() needs limit-1 consecutive failures to tolerate). + new_options[CONF_AVAILABILITY_CHECK] = True + if new_options.get(CONF_AVAILABILITY_RETRY_LIMIT, 3) < 2: + new_options[CONF_AVAILABILITY_RETRY_LIMIT] = 3 + + hass.config_entries.async_update_entry(entry, options=new_options, version=4) + if entry.version == 4: + # Drop the on/off toggle and put a floor under the retry limit. The + # toggle was never a defensible choice - the module's hourly + # reassociation makes some tolerance always right, and switching it off + # was arithmetically identical to a limit of 1. Raising the limit is a + # real choice on a weak link, so the number stays; only values below + # AVAILABILITY_FAILURE_LIMIT_MIN are lifted, which is what the v3 -> v4 + # step above was already having to do by hand. + new_options = dict(entry.options) + new_options.pop(CONF_AVAILABILITY_CHECK, None) + new_options[CONF_AVAILABILITY_RETRY_LIMIT] = max( + AVAILABILITY_FAILURE_LIMIT_MIN, + new_options.get( + CONF_AVAILABILITY_RETRY_LIMIT, AVAILABILITY_FAILURE_LIMIT_MIN + ), + ) + + hass.config_entries.async_update_entry(entry, options=new_options, version=5) + if entry.version == 5: + # Move the host back into entry.data, where connection-critical data + # belongs. It lived in options since v2 so it could be edited there, + # and that was the wrong home for a second reason: the discovery + # helper that refreshes a changed address + # (_abort_if_unique_id_configured(updates=...)) only ever merges into + # entry.data, so the refresh wrote a key setup never read and the + # address silently stayed stale. + new_data = dict(entry.data) + new_options = dict(entry.options) + if CONF_HOST in new_options: + new_data[CONF_HOST] = new_options.pop(CONF_HOST) + + hass.config_entries.async_update_entry( + entry, data=new_data, options=new_options, version=6 + ) + if entry.version == 6: + # Entries added by hand never got a unique id: the manual step checked + # for a duplicate airco itself instead of registering one. Without it + # zeroconf cannot recognise the entry, so a unit that moved was offered + # as a new discovery and its address was never refreshed. The module + # announces itself as .local and the airco id is that same MAC, so + # this is the identity discovery already matches on. + hass.config_entries.async_update_entry( + entry, unique_id=entry.data[CONF_AIRCO_ID].lower(), version=7 + ) + + return True + + +async def async_setup_entry( + hass: HomeAssistant, entry: MitsubishiWfRacConfigEntry +) -> bool: + """Establish connection with mitsubishi-wf-rac.""" + device: str = entry.data[CONF_HOST] + _device = await create_device_from_entry(entry, hass) + + await _device.update() # initial update to get fresh values + # update() catches its own errors and reflects them via .available instead + # of raising (see coordinator.py) - check that instead of try/except so a + # device that's unreachable at startup gets HA's automatic retry-with-backoff + # rather than a silently "loaded" entry with no working entities. + if not _device.available: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"device": device}, + ) + + # Persist the discovered connection method (http/https) so we can skip + # protocol discovery (and its potential extra round-trip) after the next + # restart. Nothing listens for entry updates, so this does not reload the + # entry that is still setting up. + method = _device.connection_method + if method and entry.data.get(CONF_CONNECTION_METHOD) != method: + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_CONNECTION_METHOD: method} + ) + _LOGGER.debug( + "Persisted connection method [%s] for device [%s]", method, device + ) + + entry.runtime_data = MitsubishiWfRacData(_device) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def create_device_from_entry( + entry: MitsubishiWfRacConfigEntry, hass: HomeAssistant +) -> Device: + """Build the coordinator for a config entry.""" + device: str = entry.data[CONF_HOST] + name: str = entry.title + device_id: str = entry.data[CONF_DEVICE_ID] + operator_id: str = entry.data[CONF_OPERATOR_ID] + port: int = entry.data[CONF_PORT] + airco_id: str = entry.data[CONF_AIRCO_ID] + # Only entries carried over from the custom component that used to own + # this domain can name a limit; nothing offers to set one here. Floored in + # Device itself, so one that predates the v4 -> v5 migration cannot run + # with less tolerance than the module needs. + availability_failure_limit: int = entry.options.get( + CONF_AVAILABILITY_RETRY_LIMIT, AVAILABILITY_FAILURE_LIMIT_MIN + ) + connection_method: str | None = entry.data.get(CONF_CONNECTION_METHOD) + return Device( + hass, + entry, + name, + device, + port, + device_id, + operator_id, + airco_id, + availability_failure_limit=availability_failure_limit, + connection_method=connection_method, + ) + + +async def async_unload_entry( + hass: HomeAssistant, entry: MitsubishiWfRacConfigEntry +) -> bool: + """Handle unload of entry.""" + + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + # Only tear the coordinator down once the entities are really gone: if + # unloading the platforms failed they stay loaded, and stopping their + # coordinator would leave a loaded entry that never updates again. + # An entry whose setup never got as far as storing its runtime data can + # still be unloaded - there is simply no coordinator to shut down then. + if unload_ok and (data := getattr(entry, "runtime_data", None)) is not None: + await data.device.async_shutdown() + + if unload_ok: + _LOGGER.info("Unloaded entry for device [%s]", entry.title) + else: + _LOGGER.warning("Failed to unload entry for device [%s]", entry.title) + + return unload_ok + + +async def async_remove_entry( + hass: HomeAssistant, entry: MitsubishiWfRacConfigEntry +) -> None: + """Handle removal of an entry.""" + + temp_device = await create_device_from_entry(entry, hass) + # delete_account() catches its own errors and returns None on failure (see + # coordinator.py) rather than raising, so check the result instead of + # try/except - the previous try/except here could never actually trigger, + # and the "Deleted" log below used to fire unconditionally even on failure. + result = await temp_device.delete_account() + if result is not None: + _LOGGER.info("Released the controller slot on airco [%s]", temp_device.airco_id) + else: + _LOGGER.warning( + "Could not release the controller slot on airco [%s]. Free it in " + "the manufacturer's app if you want it back", + temp_device.airco_id, + ) + + # Entry-scoped, so it would otherwise dangle in the repair list forever + # pointing at an entry_id that no longer resolves to anything. + ir.async_delete_issue(hass, DOMAIN, registration_full_issue_id(entry.entry_id)) diff --git a/homeassistant/components/mitsubishi_wf_rac/climate.py b/homeassistant/components/mitsubishi_wf_rac/climate.py new file mode 100644 index 0000000000000..7759f0561919a --- /dev/null +++ b/homeassistant/components/mitsubishi_wf_rac/climate.py @@ -0,0 +1,424 @@ +"""for Climate integration.""" + +import logging +from typing import Any, override + +from pywfrac import AIRFLOW_UNKNOWN, Aircon, AirconCommands + +from homeassistant.components.climate import ( + FAN_AUTO, + PRESET_AWAY, + PRESET_NONE, + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import MitsubishiWfRacConfigEntry +from .const import ( + DOMAIN, + FAN_MODE_TRANSLATION, + HOME_LEAVE_TEMP_COOL, + HOME_LEAVE_TEMP_HEAT, + HVAC_TRANSLATION, + NORMAL_TEMP, + SUPPORT_FLAGS, + SUPPORT_SWING_HORIZONTAL_MODES, + SUPPORT_SWING_MODES, + SUPPORTED_FAN_MODES, + SUPPORTED_HVAC_MODES, + SWING_3D_AUTO, + SWING_HORIZONTAL_AUTO, + SWING_HORIZONTAL_MODE_TRANSLATION, + SWING_MODE_TRANSLATION, + SWING_VERTICAL_AUTO, +) +from .coordinator import Device +from .entity import WfRacEntity + +_LOGGER = logging.getLogger(__name__) +# Zero, not one, although this platform writes: the serialisation the module +# needs already lives in the coordinator, which holds a send lock around the +# request and spaces requests by MIN_TIME_BETWEEN_REQUESTS. A platform +# semaphore on top of that only stops actions issued together - a scene, an +# automation step that fans out - from reaching the coordinator's +# consolidation window together, and those are exactly the ones worth +# merging into a single frame. +PARALLEL_UPDATES = 0 + +# The modes whose setpoint the unit actually regulates on. Off and fan-only +# have no setpoint of their own - see _setpoint_range_for_mode. +REGULATING_HVAC_MODES = (HVACMode.AUTO, HVACMode.COOL, HVACMode.HEAT, HVACMode.DRY) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: MitsubishiWfRacConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the climate entity.""" + device: Device = entry.runtime_data.device + async_add_entities([AircoClimate(device)]) + + +class AircoClimate(WfRacEntity, ClimateEntity): + """Representation of a climate entity.""" + + _attr_supported_features: ClimateEntityFeature = SUPPORT_FLAGS + _attr_temperature_unit: str = UnitOfTemperature.CELSIUS + _attr_hvac_modes: list[HVACMode] = SUPPORTED_HVAC_MODES + _attr_fan_modes: list[str] = SUPPORTED_FAN_MODES + _attr_hvac_action: HVACAction | None = None + _attr_fan_mode: str = FAN_AUTO + _attr_swing_mode: str | None = SWING_VERTICAL_AUTO + _attr_swing_modes: list[str] | None = SUPPORT_SWING_MODES + _attr_swing_horizontal_mode: str | None = SWING_HORIZONTAL_AUTO + _attr_swing_horizontal_modes: list[str] | None = SUPPORT_SWING_HORIZONTAL_MODES + # The setpoint byte is int(PresetTemp / 0.5), which truncates. Without + # declaring the step, HA offers 0.1 K and the unit drops the remainder - + # 21.4 arrives as 21.0. + _attr_target_temperature_step: float = 0.5 + # Only filled in when the model reports VacantProperty (see __init__); + # ClimateEntity has no class-level default for either of these. + _attr_preset_modes: list[str] | None = None + _attr_preset_mode: str | None = None + _attr_translation_key = "mitsubishi_wf_rac" + # The airco itself is the device, and this entity is the device - so it + # carries the device name alone rather than a suffix behind it. + _attr_has_entity_name = True + _attr_name = None + + def __init__(self, device: Device) -> None: + """Initialize the climate entity.""" + super().__init__(device) + # The domain and platform segments are redundant for the registry, + # but this id is already stored in ~1900 installations of the custom + # component that share this domain; shortening it would orphan every + # entity they have named, hidden or wired into an automation. + # pylint: disable-next=home-assistant-entity-unique-id-redundant-domain,home-assistant-entity-unique-id-redundant-platform + self._attr_unique_id = f"{DOMAIN}-{self._device.airco_id}-climate" + # Away is the unit's own Home Leave mode, offered here as the preset a + # thermostat card and a voice assistant already know how to ask for. + if device.airco.Capabilities.vacant_property: + self._attr_supported_features = ( + SUPPORT_FLAGS | ClimateEntityFeature.PRESET_MODE + ) + self._attr_preset_modes = [PRESET_NONE, PRESET_AWAY] + self._apply_state() + + @override + async def async_added_to_hass(self) -> None: + """Register with the coordinator and publish the first state.""" + await super().async_added_to_hass() + self._apply_state() + + def _min_temp_for_mode(self, hvac_mode: HVACMode) -> float: + """Minimum setpoint depends on hvac_mode. + + Per Mitsubishi Heavy Industries' official operable table ('21 + SRK-T-324, models SRK60ZSX-W/A and SRK100ZR-W): indoor unit only + accepts 18-30C. Cooling reliably goes lower than that in practice + regardless of model, so that override applies unconditionally. + Models with the app's PresetTempRange2 capability (`ModelNoType`/ + `TempItemType` in the app, see pywfrac's capabilities module) go further, + per the app's own table (Constants.java TempItemType.getMin/getMax): + Auto/Cool/Dry down to 16, Heat down to 10. That 10C heating floor is + unconfirmed on real hardware - the plain-setpoint reset to 18C after a + power cycle that's documented for the default range was only ever + observed on hardware without this capability. + """ + if self._device.airco.Capabilities.preset_temp_range_2: + if hvac_mode == HVACMode.HEAT: + return 10 + if hvac_mode in (HVACMode.COOL, HVACMode.DRY, HVACMode.AUTO): + return 16 + return 16 if hvac_mode == HVACMode.COOL else 18 + + def _max_temp_for_mode(self, hvac_mode: HVACMode) -> float: + """Return the highest setpoint this hvac_mode allows. + + Depends on hvac_mode for PresetTempRange2 models - see + _min_temp_for_mode. + """ + if self._device.airco.Capabilities.preset_temp_range_2 and hvac_mode in ( + HVACMode.COOL, + HVACMode.DRY, + ): + return 33 + return 30 + + def _setpoint_range_for_mode( + self, hvac_mode: HVACMode | None + ) -> tuple[float, float]: + """The range a setpoint is held to, for display and before sending. + + A regulating mode is held to its own range. Off, fan-only and a mode + we could not read have none: + the value applies to whichever regulating mode is turned on next, often + in the very next step of the same automation. Holding it to the default + 18C floor there rejects a cooling setpoint the unit takes happily once + it is cooling. + """ + if hvac_mode in REGULATING_HVAC_MODES: + return ( + self._min_temp_for_mode(hvac_mode), + self._max_temp_for_mode(hvac_mode), + ) + return ( + min(self._min_temp_for_mode(mode) for mode in REGULATING_HVAC_MODES), + max(self._max_temp_for_mode(mode) for mode in REGULATING_HVAC_MODES), + ) + + @override + @property + def min_temp(self) -> float: + """Return the lowest setpoint the current mode allows.""" + return self._setpoint_range_for_mode(self._attr_hvac_mode)[0] + + @override + @property + def max_temp(self) -> float: + """Return the highest setpoint the current mode allows.""" + return self._setpoint_range_for_mode(self._attr_hvac_mode)[1] + + @override + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + set_temp = kwargs[ATTR_TEMPERATURE] + + # If this call also switches hvac_mode, the minimum must reflect the mode + # being switched to, not the (still stale until the next poll) current one. + target_hvac_mode = kwargs.get("hvac_mode", self._attr_hvac_mode) + target_hvac_mode = ( + HVACMode.OFF if target_hvac_mode is None else target_hvac_mode + ) + min_temp, max_temp = self._setpoint_range_for_mode(target_hvac_mode) + + # Naming the mode is the whole message: the range depends on it, and + # an automation that sets a setpoint before switching mode gets + # measured against the mode it is leaving. Saying so - and that + # hvac_mode belongs in the same call - is the difference between a + # rejection and a fix. + if set_temp < min_temp: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="temperature_below_minimum", + translation_placeholders={ + "temperature": str(set_temp), + "min_temp": str(min_temp), + "hvac_mode": str(target_hvac_mode), + }, + ) + + if set_temp > max_temp: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="temperature_above_maximum", + translation_placeholders={ + "temperature": str(set_temp), + "max_temp": str(max_temp), + "hvac_mode": str(target_hvac_mode), + }, + ) + + opts: dict[AirconCommands, Any] = {AirconCommands.PresetTemp: set_temp} + + if "hvac_mode" in kwargs: + opts.update( + { + AirconCommands.OperationMode: self._device.airco.OperationMode + if target_hvac_mode == HVACMode.OFF + else HVAC_TRANSLATION[target_hvac_mode], + AirconCommands.Operation: target_hvac_mode != HVACMode.OFF, + } + ) + + await self._device.async_queue_command(opts) + + @override + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set new target fan mode.""" + await self._device.async_queue_command( + {AirconCommands.AirFlow: FAN_MODE_TRANSLATION[fan_mode]} + ) + + @override + async def async_turn_on(self) -> None: + """Turn the entity on.""" + await self._device.async_queue_command({AirconCommands.Operation: True}) + + @override + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + await self._device.async_queue_command( + { + AirconCommands.OperationMode: self._device.airco.OperationMode + if hvac_mode == HVACMode.OFF + else HVAC_TRANSLATION[hvac_mode], + AirconCommands.Operation: hvac_mode != HVACMode.OFF, + } + ) + + @override + async def async_set_swing_mode(self, swing_mode: str) -> None: + """Set new target swing operation.""" + _swing_auto = swing_mode == SWING_3D_AUTO + if _swing_auto: + await self._device.async_queue_command( + { + AirconCommands.Entrust: _swing_auto, + } + ) + else: + await self._device.async_queue_command( + { + AirconCommands.WindDirectionUD: SWING_MODE_TRANSLATION[swing_mode], + AirconCommands.Entrust: False, + } + ) + + @override + async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None: + """Set new target horizontal swing operation.""" + swing_mode = swing_horizontal_mode + _swing_auto = swing_mode == SWING_3D_AUTO + if _swing_auto: + await self._device.async_queue_command( + { + AirconCommands.Entrust: _swing_auto, + } + ) + else: + await self._device.async_queue_command( + { + AirconCommands.WindDirectionLR: SWING_HORIZONTAL_MODE_TRANSLATION[ + swing_mode + ], + AirconCommands.Entrust: False, + } + ) + + @override + async def async_turn_off(self) -> None: + """Turn the entity off.""" + await self._device.async_queue_command({AirconCommands.Operation: False}) + + @override + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Enter or leave the unit's Home Leave mode. + + The unit has no single "away" command: it enters the mode when it is + given the away target of the direction it is running in, which is why + the current hvac_mode decides between them. A unit in auto, dry or + fan-only has no such target to send, and guessing the direction would + be as likely to fight the unit as to help it. + """ + if preset_mode == PRESET_NONE: + await self._device.async_queue_command( + {AirconCommands.PresetTemp: NORMAL_TEMP} + ) + return + + if self._attr_hvac_mode == HVACMode.COOL: + away_temp = HOME_LEAVE_TEMP_COOL + elif self._attr_hvac_mode == HVACMode.HEAT: + away_temp = HOME_LEAVE_TEMP_HEAT + else: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="preset_away_needs_cool_or_heat", + translation_placeholders={"hvac_mode": str(self._attr_hvac_mode)}, + ) + + await self._device.async_queue_command( + { + AirconCommands.Operation: True, + AirconCommands.OperationMode: HVAC_TRANSLATION[self._attr_hvac_mode], + AirconCommands.PresetTemp: away_temp, + } + ) + + @override + def _mark_state_unknown(self) -> None: + self._attr_hvac_mode = None + + @override + def _update_state(self) -> None: + """Private update attributes.""" + airco = self._device.airco + + # OperationMode keeps reporting the underlying cool/heat mode while the + # unit is off, which is what the displayed hvac_mode is derived from. + mode_from_operation = self._hvac_mode_from_operation + + self._attr_target_temperature = airco.PresetTemp + self._attr_current_temperature = airco.IndoorTemp + # Named rather than left to index past the end of the list: the + # library says so itself when it could not read the unit's fan step, + # and a sixth fan mode here would otherwise turn that marker into a + # real one and lose the unknown state without a sound. + if airco.AirFlow == AIRFLOW_UNKNOWN: + raise IndexError("the unit reported a fan step pywfrac cannot read") + self._attr_fan_mode = list(FAN_MODE_TRANSLATION.keys())[airco.AirFlow] + self._attr_swing_mode = ( + SWING_3D_AUTO + if airco.Entrust + else list(SWING_MODE_TRANSLATION.keys())[airco.WindDirectionUD] + ) + self._attr_swing_horizontal_mode = ( + SWING_3D_AUTO + if airco.Entrust + else list(SWING_HORIZONTAL_MODE_TRANSLATION.keys())[airco.WindDirectionLR] + ) + self._attr_hvac_mode = mode_from_operation + + if airco.Operation is False: + self._attr_hvac_mode = HVACMode.OFF + self._attr_hvac_action = HVACAction.OFF + else: + self._attr_hvac_action = self._determine_hvac_action(airco) + + # Read back from the Vacant bit, so the preset also follows a Home + # Leave entered from the official app or the IR remote. + if self.supported_features & ClimateEntityFeature.PRESET_MODE: + self._attr_preset_mode = PRESET_AWAY if airco.Vacant else PRESET_NONE + + def _determine_hvac_action(self, airco: Aircon) -> HVACAction: + """Determine the current HVAC action from operation mode and state. + + CoolHotJudge reflects what the unit's own AUTO logic is doing. Mind + the inversion: the parser reads it as (content[8] & 8) == 0, so the + raw bit set means COOLING and the resulting flag is then False - + a true CoolHotJudge is HEATING. CompressorRunning + (content[9] & 2) distinguishes "unit on" from "compressor actually + running" (e.g. setpoint satisfied) - used here so COOL/HEAT/AUTO can + report IDLE instead of claiming to cool/heat while the compressor is + stopped. + + Only called while the unit is on, and only with an OperationMode of + 0-4: anything else has already raised in _hvac_mode_from_operation. + """ + _mode = airco.OperationMode + + if _mode == 3: + return HVACAction.FAN + + if _mode == 4: + return HVACAction.DRYING + + if not airco.CompressorRunning: + return HVACAction.IDLE + + # AUTO leaves the direction to the unit, so ask it what it picked. + if _mode == 0: + return HVACAction.HEATING if airco.CoolHotJudge else HVACAction.COOLING + + if _mode == 1: + return HVACAction.COOLING + + return HVACAction.HEATING diff --git a/homeassistant/components/mitsubishi_wf_rac/config_flow.py b/homeassistant/components/mitsubishi_wf_rac/config_flow.py new file mode 100644 index 0000000000000..f9c3f1817a473 --- /dev/null +++ b/homeassistant/components/mitsubishi_wf_rac/config_flow.py @@ -0,0 +1,431 @@ +"""Config flow WF-RAC.""" + +from collections.abc import Callable +from functools import partial +import logging +from typing import Any, override +from uuid import uuid4 + +from pywfrac import Repository, WfRacError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.const import ( + CONF_BASE, + CONF_DEVICE_ID, + CONF_FORCE_UPDATE, + CONF_HOST, + CONF_PORT, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import AbortFlow +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from .const import ( + AC_CERT_FILENAME, + CONF_AIRCO_ID, + CONF_OPERATOR_ID, + DEFAULT_PORT, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +class WfRacConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 7 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + _discovery_info: dict[str, Any] = {} + DOMAIN = DOMAIN + + def __init__(self) -> None: + """Start a flow with no identifiers generated yet.""" + self._generated_operator_id: str | None = None + self._generated_device_id: str | None = None + + @override + def is_matching(self, other_flow: WfRacConfigFlow) -> bool: + """Return True if two flows are attempting to configure the same device.""" + # Compare based on unique IDs if available, otherwise compare context data + if self.unique_id and other_flow.unique_id: + return self.unique_id == other_flow.unique_id + # For flows without unique IDs, consider them non-matching + return False + + def _find_entry_matching( + self, key: str, matches: Callable[[Any], bool] + ) -> config_entries.ConfigEntry | None: + """Returns the first entry where matches(entry.data[key]) returns True.""" + for entry in self._async_current_entries(): + if key in entry.data and matches(entry.data[key]): + return entry + return None + + async def _async_register_airco( + self, + hass: HomeAssistant, + data: dict[str, Any], + allow_port_fallback: bool = False, + ) -> dict[str, Any]: + """Validate the user input allows us to connect, and register with the airco device. + + allow_port_fallback belongs to discovery only: a port the module + announced may be wrong, a port a person typed is their decision. + """ + if len(data[CONF_HOST]) < 3: + raise InvalidHost + + if not data.get(CONF_FORCE_UPDATE): + # Is this hostname or IP address already configured? + existing_entry = self._find_entry_matching( + CONF_HOST, lambda h: h == data[CONF_HOST] + ) + if existing_entry: + raise HostAlreadyConfigured(error_name=existing_entry.title) + + repository = Repository( + async_get_clientsession(hass), + data[CONF_HOST], + data[CONF_PORT], + data[CONF_OPERATOR_ID], + data[CONF_DEVICE_ID], + cert_path=hass.config.path(AC_CERT_FILENAME), + ) + + try: + airco_id = await repository.get_airco_id() + except (WfRacError, KeyError, TypeError) as query_failed: + # A discovery announcement has been seen carrying a port the module + # does not serve. The port is fixed in the firmware and not + # user-settable, so rather than failing on a value the device + # cannot have meant, try the one it always listens on. Only the + # announced value is second-guessed - a port the user typed is + # taken at face value. + if not allow_port_fallback or data[CONF_PORT] == DEFAULT_PORT: + raise CannotConnect(reason=str(query_failed)) from query_failed + _LOGGER.warning( + "No answer on announced port %s, retrying on %s. Please report " + "this with the discovery details - the announced port is " + "supposed to be %s on every firmware branch", + data[CONF_PORT], + DEFAULT_PORT, + DEFAULT_PORT, + ) + repository = Repository( + async_get_clientsession(hass), + data[CONF_HOST], + DEFAULT_PORT, + data[CONF_OPERATOR_ID], + data[CONF_DEVICE_ID], + cert_path=hass.config.path(AC_CERT_FILENAME), + ) + try: + airco_id = await repository.get_airco_id() + except (WfRacError, KeyError, TypeError) as retry_failed: + raise CannotConnect(reason=str(retry_failed)) from retry_failed + data[CONF_PORT] = DEFAULT_PORT + + data[CONF_AIRCO_ID] = airco_id + if not airco_id: + raise CannotConnect(reason="unknown reason") + + _LOGGER.debug("Registering with airco [%s]", data[CONF_AIRCO_ID]) + try: + result = await repository.update_account_info( + airco_id, hass.config.time_zone + ) + except (WfRacError, KeyError, TypeError) as registration_failed: + raise CannotConnect( + reason=str(registration_failed) + ) from registration_failed + if not result: + raise CannotConnect(reason="no answer to the registration request") + # The answer comes from the module, so a missing key is a connection + # problem to report, not an unexpected error to crash the flow on. + code = result.get("result") + if code is None: + raise CannotConnect(reason="registration answered without a result code") + if int(code) == 2: + raise TooManyDevicesRegistered + + return data + + async def _async_fetch_operator_id(self) -> str: + """Fetch UUID operator id if exists otherwise create it.""" + entry = self._find_entry_matching(CONF_OPERATOR_ID, bool) + if entry: + return str(entry.data[CONF_OPERATOR_ID]) + # Generated once per flow, not once per submission: the module keeps + # four account slots, and a registration whose answer was lost has + # still taken one. Retrying the form with a fresh id would take + # another, and enough retries would leave no slot to set up with. + if self._generated_operator_id is None: + self._generated_operator_id = f"hassio-{str(uuid4())[7:]}" + return self._generated_operator_id + + async def _async_fetch_device_id(self) -> str: + """Fetch unique device id if exists otherwise create it.""" + entry = self._find_entry_matching(CONF_DEVICE_ID, bool) + if entry: + return str(entry.data[CONF_DEVICE_ID]) + if self._generated_device_id is None: + self._generated_device_id = f"homeassistant-device-{uuid4().hex[21:]}" + return self._generated_device_id + + async def _async_create_common( + self, + step_id: str, + data_schema: vol.Schema, + user_input: dict[str, Any] | None = None, + description_placeholders: dict[str, str] | None = None, + allow_port_fallback: bool = False, + ) -> ConfigFlowResult: + """Create a new entry.""" + errors: dict[str, str] = {} + description_placeholders = description_placeholders or {} + + if user_input: + description_placeholders["error_name"] = "" + try: + user_input[CONF_OPERATOR_ID] = await self._async_fetch_operator_id() + user_input[CONF_DEVICE_ID] = await self._async_fetch_device_id() + + info = await self._async_register_airco( + self.hass, user_input, allow_port_fallback=allow_port_fallback + ) + + # The airco id is the unit's own identity, and the one + # zeroconf keys on: the module announces itself as + # .local and the airco id is that same MAC. Registering + # it here is what lets a discovery recognise a manually added + # entry later - and it aborts a unit reached at a second + # address, which would otherwise become a second entry whose + # entities collide with the first one's. + await self.async_set_unique_id(info[CONF_AIRCO_ID].lower()) + self._abort_if_unique_id_configured() + + data_input = user_input.copy() + # Form-only: it decides whether a duplicate host is accepted + # while adding, and means nothing to a stored entry. + data_input.pop(CONF_FORCE_UPDATE, None) + + # Named after the unit rather than asked for: config flows do + # not collect entry names, and renaming is Home Assistant's + # own. The last four characters of the airco id are enough to + # tell two units apart and to match one against the label on + # the module, while the whole id stays out of the device name + # and the entity id that people paste into issue reports. + return self.async_create_entry( + title=f"WF-RAC {info[CONF_AIRCO_ID][-4:]}", + data=data_input, + ) + except KnownError as error: + # Expected outcomes of user input, not faults: the user sees + # them in the form, and a stack trace in the log would only + # be noise. + _LOGGER.debug("Create failed: %s", error) + errors, placeholders = error.get_errors_and_placeholders( + data_schema.schema + ) + description_placeholders.update( + {k: str(v) for k, v in placeholders.items()} + ) + except AbortFlow: + # How the helpers end a step. It is the flow working, not a + # fault, and the broad clause below would turn it into an + # "unexpected_error" form. + raise + except Exception: # pylint: disable=broad-except + # Intentionally broad: this is the outermost boundary of the config + # flow step, so any bug here should show the user a graceful + # "unexpected_error" instead of crashing the flow. + _LOGGER.exception("Unexpected exception") + errors[CONF_BASE] = "unexpected_error" + + # If there is no user input or there were errors, show the form again, including any errors + # that were found with the input. + return self.async_show_form( + step_id=step_id, + data_schema=data_schema, + errors=errors, + description_placeholders=description_placeholders, + ) + + @staticmethod + def _field( + user_input: dict[str, Any] | None, + name: str, + which: Callable[..., Any], + default: Any = None, + ) -> Any: + """Helper for creating schema fields.""" + value = user_input.get(name, default) if user_input else default + description = None + if value is not None: + description = {"suggested_value": value} + if default is None: + return which(name, description=description) + # A suggestion only pre-fills the form. Without a schema default the + # key is simply absent when the field is cleared, and the port is read + # with [] - so clearing it ended the flow in "unexpected_error". + return which(name, description=description, default=default) + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle adding device discovered by zeroconf.""" + + description_placeholders = { + "id": self._discovery_info[CONF_AIRCO_ID], + "host": self._discovery_info[CONF_HOST], + "port": self._discovery_info[CONF_PORT], + } + + if user_input: + user_input[CONF_HOST] = self._discovery_info[CONF_HOST] + user_input.setdefault(CONF_PORT, self._discovery_info[CONF_PORT]) + + field = partial(self._field, user_input) + data_schema = vol.Schema( + { + field( + CONF_PORT, vol.Optional, self._discovery_info[CONF_PORT] + ): cv.port, + } + ) + + return await self._async_create_common( + step_id="discovery_confirm", + data_schema=data_schema, + user_input=user_input, + description_placeholders=description_placeholders, + allow_port_fallback=True, + ) + + @override + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle adding device manually.""" + + field = partial(self._field, user_input) + data_schema = vol.Schema( + { + field(CONF_HOST, vol.Required): cv.string, + field(CONF_PORT, vol.Optional, DEFAULT_PORT): cv.port, + field(CONF_FORCE_UPDATE, vol.Optional, False): cv.boolean, + } + ) + + return await self._async_create_common( + step_id="user", data_schema=data_schema, user_input=user_input + ) + + @override + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + + local_name = discovery_info.hostname.rstrip(".") + node_name = local_name.removesuffix(".local") + host = discovery_info.host + port = discovery_info.port + + _LOGGER.debug( + "zeroconf discovery: hostname=%r, host=%r, port=%r", + discovery_info.hostname, + discovery_info.host, + discovery_info.port, + ) + + # Lower case on both sides: this id comes from the announced hostname + # while every other path takes it from the airconId the unit reports, + # and a difference in case would leave discovery unable to recognise + # an entry it had matched on before. + await self.async_set_unique_id(node_name.lower()) + # The address only. A module that moved gets followed; its port is + # what setup was configured with, and modules have been seen + # announcing 5353 - the mDNS port itself - in the SRV record where the + # API port belongs, which would take a working entry offline. + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) + + info = {CONF_HOST: host, CONF_PORT: port} + + existing_entry = self._find_entry_matching(CONF_HOST, lambda h: h == host) + if existing_entry: + _LOGGER.debug("already configured!") + return self.async_abort(reason="already_configured") + + info[CONF_AIRCO_ID] = node_name + self._discovery_info = info + + return await self.async_step_discovery_confirm() + + +class KnownError(Exception): + """Base class for errors known to this config flow. + + Deliberately not a HomeAssistantError: none of these ever leaves the flow. + Every one is caught here and turned into an entry in the [errors] dict + that async_show_form renders from strings.json, so they carry an + error_name rather than a translation key. + + [error_name] is the value passed to [errors] in async_show_form, which should match a key + under "error" in strings.json + + [applies_to_field] is the name of the field name that contains the error (for + async_show_form); if the field doesn't exist in the form CONF_BASE will be used instead. + """ + + error_name = "unknown_error" + applies_to_field = CONF_BASE + + def __init__(self, *args: object, **kwargs: str) -> None: + """Keep the placeholders the message needs alongside the error.""" + super().__init__(*args) + self._extra_info = kwargs + + def get_errors_and_placeholders( + self, schema: Any + ) -> tuple[dict[str, str], dict[str, str]]: + """Return dicts of errors and description_placeholders, for adding to async_show_form.""" + key = self.applies_to_field + # Errors will only be displayed to the user if the key is actually in the form (or + # CONF_BASE for a general error), so we'll check the schema (seems weird there + # isn't a more efficient way to do this...) + if key not in {k.schema for k in schema}: + key = CONF_BASE + return ({key: self.error_name}, self._extra_info or {}) + + +class CannotConnect(KnownError): + """Error to indicate we cannot connect.""" + + error_name = "cannot_connect" + + +class InvalidHost(KnownError): + """Error to indicate there is an invalid hostname.""" + + error_name = "invalid_host" + applies_to_field = CONF_HOST + + +class HostAlreadyConfigured(KnownError): + """Error to indicate there is an duplicate hostname.""" + + error_name = "host_already_configured" + applies_to_field = CONF_HOST + + +class TooManyDevicesRegistered(KnownError): + """Error to indicate that there are too many devices registered.""" + + error_name = "too_many_devices_registered" + applies_to_field = CONF_BASE diff --git a/homeassistant/components/mitsubishi_wf_rac/const.py b/homeassistant/components/mitsubishi_wf_rac/const.py new file mode 100644 index 0000000000000..fdd5a30ec4776 --- /dev/null +++ b/homeassistant/components/mitsubishi_wf_rac/const.py @@ -0,0 +1,157 @@ +"""Constants used by the mitsubishi-wf-rac component.""" + +from datetime import timedelta + +from homeassistant.components.climate import ( + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + ClimateEntityFeature, + HVACMode, +) + +DOMAIN = "mitsubishi_wf_rac" + +# The module serves its API here on every firmware branch, and the port cannot +# be changed on the device - only the scheme differs (plain http on the older +# WF-RAC branch). Used as the manual-setup default and as the fallback when a +# discovery announcement carries something else. +DEFAULT_PORT = 51443 + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) + +CONF_OPERATOR_ID = "operator_id" +CONF_AIRCO_ID = "airco_id" +# Removed option, kept only so async_migrate_entry can strip it from entries +# that predate v5. Nothing outside the migration reads it. +CONF_AVAILABILITY_CHECK = "availability_check" +# Consecutive failed polls before the device is reported unavailable; floored +# at coordinator.py's AVAILABILITY_FAILURE_LIMIT_MIN. +CONF_AVAILABILITY_RETRY_LIMIT = "availability_retry_limit" +CONF_CONNECTION_METHOD = "connection_method" + + +# Heating uses the unit's own Heating TempSetting (10.0°C), which matches +# HOME_LEAVE_TEMP_HEAT exactly. Cooling does not: the unit's Cooling +# TempSetting reads 33.0°C, but the temperature actually applied while the +# official app's away-cool mode is running is 31.0°C - so this hardcodes the +# applied value rather than trusting the configured TempSetting, since only +# the applied value is known to flip Vacant. +HOME_LEAVE_TEMP_HEAT = 10.0 +HOME_LEAVE_TEMP_COOL = 31.0 +# Temperature to restore when leaving Home Leave mode. There's no reliable way +# to recall whatever temperature was set before Home Leave was turned on (the +# unit itself doesn't report it), so this is a plain, reasonable default. +NORMAL_TEMP = 21.0 + + +SUPPORT_FLAGS = ( + ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.SWING_HORIZONTAL_MODE + | ClimateEntityFeature.SWING_MODE + | ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TURN_ON +) + +SUPPORTED_HVAC_MODES = [ + HVACMode.OFF, + HVACMode.AUTO, + HVACMode.COOL, + HVACMode.DRY, + HVACMode.HEAT, + HVACMode.FAN_ONLY, +] + +HVAC_TRANSLATION = { + HVACMode.AUTO: 0, + HVACMode.COOL: 1, + HVACMode.HEAT: 2, + HVACMode.FAN_ONLY: 3, + HVACMode.DRY: 4, +} + +SWING_3D_AUTO = "3d_auto" +SWING_VERTICAL_POSITION_1 = "highest" +SWING_VERTICAL_POSITION_2 = "middle" +SWING_VERTICAL_POSITION_3 = "normal" +SWING_VERTICAL_POSITION_4 = "lowest" +SWING_VERTICAL_AUTO = "up_down_auto" + +SWING_HORIZONTAL_POSITION_1 = "left_left" +SWING_HORIZONTAL_POSITION_2 = "left_center" +SWING_HORIZONTAL_POSITION_3 = "center_center" +SWING_HORIZONTAL_POSITION_4 = "center_right" +SWING_HORIZONTAL_POSITION_5 = "right_right" +SWING_HORIZONTAL_POSITION_6 = "left_right" +SWING_HORIZONTAL_POSITION_7 = "right_left" +SWING_HORIZONTAL_AUTO = "left_right_auto" + + +SWING_MODE_TRANSLATION = { + SWING_VERTICAL_AUTO: 0, + SWING_VERTICAL_POSITION_1: 1, + SWING_VERTICAL_POSITION_2: 2, + SWING_VERTICAL_POSITION_3: 3, + SWING_VERTICAL_POSITION_4: 4, +} + +SUPPORT_SWING_MODES = [ + SWING_VERTICAL_AUTO, + SWING_VERTICAL_POSITION_1, + SWING_VERTICAL_POSITION_2, + SWING_VERTICAL_POSITION_3, + SWING_VERTICAL_POSITION_4, + SWING_3D_AUTO, +] + +SWING_HORIZONTAL_MODE_TRANSLATION = { + SWING_HORIZONTAL_AUTO: 0, + SWING_HORIZONTAL_POSITION_1: 1, + SWING_HORIZONTAL_POSITION_2: 2, + SWING_HORIZONTAL_POSITION_3: 3, + SWING_HORIZONTAL_POSITION_4: 4, + SWING_HORIZONTAL_POSITION_5: 5, + SWING_HORIZONTAL_POSITION_6: 6, + SWING_HORIZONTAL_POSITION_7: 7, +} + +SUPPORT_SWING_HORIZONTAL_MODES = [ + SWING_HORIZONTAL_AUTO, + SWING_HORIZONTAL_POSITION_1, + SWING_HORIZONTAL_POSITION_2, + SWING_HORIZONTAL_POSITION_3, + SWING_HORIZONTAL_POSITION_4, + SWING_HORIZONTAL_POSITION_5, + SWING_HORIZONTAL_POSITION_6, + SWING_HORIZONTAL_POSITION_7, + SWING_3D_AUTO, +] + + +FAN_QUIET = "quiet" + +FAN_MODE_TRANSLATION = { + FAN_AUTO: 0, + FAN_QUIET: 1, + FAN_LOW: 2, + FAN_MEDIUM: 3, + FAN_HIGH: 4, +} + +SUPPORTED_FAN_MODES = [ + FAN_AUTO, + FAN_QUIET, + FAN_LOW, + FAN_MEDIUM, + FAN_HIGH, +] + + +# Optional certificate for the unit's HTTPS stack, looked up in the HA config +# directory. Without it the connection falls back to a permissive SSL context. +# Create it by running this in that directory: +# openssl s_client -connect :51443 -showcerts /dev/null \ +# | openssl x509 -outform PEM > ac_cert.pem +AC_CERT_FILENAME = "ac_cert.pem" diff --git a/homeassistant/components/mitsubishi_wf_rac/coordinator.py b/homeassistant/components/mitsubishi_wf_rac/coordinator.py new file mode 100644 index 0000000000000..5809634b03330 --- /dev/null +++ b/homeassistant/components/mitsubishi_wf_rac/coordinator.py @@ -0,0 +1,613 @@ +"""Device module.""" + +import asyncio +from contextlib import suppress +from dataclasses import dataclass +from datetime import timedelta +import logging +import re +import time +from typing import Any, override + +from pywfrac import ( + Aircon, + AirconCommands, + AirconStat, + RacParser, + Repository, + WfRacConnectionError, + WfRacError, + WfRacRegistrationError, + WfRacWriteRefusedError, +) +from pywfrac.repository import MIN_TIME_BETWEEN_REQUESTS, REQUEST_TIMEOUT + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import ( + CONNECTION_NETWORK_MAC, + DeviceInfo, + format_mac, +) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import AC_CERT_FILENAME, DOMAIN, MIN_TIME_BETWEEN_UPDATES + +_LOGGER = logging.getLogger(__name__) + +# Commands issued within this window of each other (from any entity) are +# coalesced into a single set_airco() call instead of being sent as separate +# requests. The unit expects a full state block per request, so two +# near-simultaneous separate commands can otherwise overwrite each other +# instead of merging (e.g. a fan-speed change followed shortly by a +# temperature change loses the fan change). +UPDATE_CONSOLIDATION_PERIOD = timedelta(milliseconds=500) + + +# Room for both legs of protocol discovery plus the minimum spacing between +# requests, so a poll that has to fall back to the other protocol is not +# cancelled halfway through. +# +# Sized as more than a single per-request timeout: a unit that accepts a +# plaintext connection without answering it consumes the whole window on the +# first leg, so an equal-sized budget would never reach the second leg. A +# unit that only speaks the second protocol would then fail every poll the +# same way and never recover on its own. +# +# Stays under MIN_TIME_BETWEEN_UPDATES so a slow poll cannot still be running +# when the next one is due. +POLL_TIMEOUT = 2 * REQUEST_TIMEOUT + MIN_TIME_BETWEEN_REQUESTS + timedelta(seconds=4) + +# Consecutive failed polls before the device is reported unavailable, and the +# floor under the configurable value. The module reassociates to WiFi about +# once an hour and is unreachable while it does (see the README's +# Troubleshooting section); reporting that as an outage every time is noise. +# Three polls at MIN_TIME_BETWEEN_UPDATES is roughly three minutes of grace, +# which rides through the reassociation without hiding a device that is +# genuinely gone. Raising it is a legitimate choice on a weak link; lowering it +# only ever produced the phantom outages this floor exists to prevent. +AVAILABILITY_FAILURE_LIMIT_MIN = 3 + + +def registration_full_issue_id(entry_id: str) -> str: + """Repair-issue id for a full account table on this entry's airco. + + Shared between Device (which raises/clears it) and async_unload_entry + (which clears it on removal, so a deleted entry doesn't leave a dangling + issue behind) - one format, so the two can never drift apart. + """ + return f"too_many_devices_{entry_id}" + + +# One retry for a user command refused because someone else holds the lock, +# timed to land just after the lock lapses (see _async_write_lock_delay). Used +# as-is only when the remaining lock time cannot be established, where a short +# retry is still worth more than none: the common case is an app action already +# most of the way through its 60s. A retry that still fails is reported rather +# than repeated - two clients are genuinely fighting over the unit at that +# point. +WRITE_LOCK_RETRY_DELAY = timedelta(seconds=10) + +# The lock runs 60 seconds, so a longer wait than that means the deadline was +# stamped by a client whose clock is off rather than that the lock is really +# still running - cap it instead of leaving a service call hanging on someone +# else's clock. See _async_write_lock_delay(). +WRITE_LOCK_MAX_WAIT = timedelta(seconds=61) + + +@dataclass +class MitsubishiWfRacData: + """Runtime data of a configured airco.""" + + device: Device + + +type MitsubishiWfRacConfigEntry = ConfigEntry[MitsubishiWfRacData] + + +class Device(DataUpdateCoordinator[Aircon]): + """Device Class.""" + + config_entry: MitsubishiWfRacConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: MitsubishiWfRacConfigEntry, + name: str, + hostname: str, + port: int, + device_id: str, + operator_id: str, + airco_id: str, + availability_failure_limit: int = AVAILABILITY_FAILURE_LIMIT_MIN, + connection_method: str | None = None, + ) -> None: + """Set up the coordinator for one airco.""" + self._api = Repository( + async_get_clientsession(hass), + hostname, + port, + operator_id, + device_id, + method=connection_method, + cert_path=hass.config.path(AC_CERT_FILENAME), + ) + self._parser = RacParser() + self._hass = hass + + self._airco = Aircon() + self._operator_id = operator_id + self._device_id = device_id + self._host = hostname + self._airco_id = airco_id + self._available = False + self._name = name + self._firmware = "" + self._consecutive_failures = 0 + # Clamped rather than validated: an entry can carry a lower value from + # an older version, and refusing to set up over it would be worse than + # quietly giving it the tolerance it should have had. + self._availability_failure_limit = max( + AVAILABILITY_FAILURE_LIMIT_MIN, availability_failure_limit + ) + # Serializes a poll and a command against each other, end to end. A + # command frame is a full state block built from self._airco, so it + # may not be encoded from a snapshot that a poll is about to replace: + # the module takes one connection at a time, so the write queues + # behind the poll already on the wire, and by the time it goes out it + # would put every field back the way it was before that poll - undoing + # whatever the app or the remote had just changed. + self._send_lock = asyncio.Lock() + self._consolidated_params: dict[AirconCommands, Any] = {} + self._consolidation_task: asyncio.Task[None] | None = None + # Every flush still running. _consolidation_task is only the one + # still accepting parameters; a flush that has taken its own and + # is on the wire has already let go of it. + self._running_flushes: set[asyncio.Task[None]] = set() + + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=name, + update_interval=MIN_TIME_BETWEEN_UPDATES, + ) + + @property + def entry_id(self) -> str: + """Id of the config entry that owns this device.""" + return self.config_entry.entry_id + + @override + async def async_shutdown(self) -> None: + """Shut the coordinator down. + + Flushes are created on hass, not owned by DataUpdateCoordinator, so + they have to be cancelled here: otherwise a command queued moments + before the entry unloads would still be sent afterwards and publish + data to entities that are already gone. On this module that also + collides with the reload behind the unload, which opens its own + connection - and the module takes one at a time. + """ + self._consolidation_task = None + flushes = list(self._running_flushes) + for flush in flushes: + flush.cancel() + for flush in flushes: + with suppress(asyncio.CancelledError): + await flush + await super().async_shutdown() + + async def update(self) -> bool: + """Update the device information from API. + + Called both directly (initial fetch in __init__.py before entities + exist, and set_airco()'s own fallback fetch) and by the coordinator + via _async_update_data() below. Deliberately does not call + async_refresh()/async_set_updated_data() itself: on the coordinator + poll path, listeners are already notified automatically once + _async_update_data() returns, and calling async_refresh() here would + re-enter _async_update_data() -> update() from within that same path. + The other two call sites don't need a notification either - the + initial fetch runs before any entity/listener exists, and + set_airco()'s fallback fetch is immediately followed by a command + whose completion already triggers async_set_updated_data() (see + Device.async_queue_command()). + + Holds the send lock for the request and the state write together, so a + command cannot snapshot state this poll is about to replace. The cost + is that a command issued while a poll is on the wire waits for it - + which it did anyway, one connection at a time, only without the + snapshot being any good. + """ + + async with self._send_lock: + return await self._async_fetch_state() + + async def _async_fetch_state(self) -> bool: + """Fetch and apply one status block. Caller holds the send lock.""" + try: + response = await self._api.get_aircon_stats(self._airco_id) + + except WfRacConnectionError as ex: + self._record_failed_poll(ex) + return False + except (WfRacError, KeyError) as ex: + self._record_failed_poll(ex) + # The WF-RAC module keeps only a small, fixed-size table of registered + # accounts (operator ids). Opening the official app or adding phones can + # silently evict Home Assistant from that table, after which polls fail + # until the integration is reloaded. Proactively re-register our account + # on failure so we recover automatically on the next poll if we were + # evicted. An evicted account still answers (HTTP 400 / result:2, see + # Repository.get_aircon_stats), so this is skipped above when the unit + # was simply unreachable - re-registering can't succeed over a + # connection that isn't there. add_account() swallows its own errors. + await self.add_account() + return False + + try: + self._airco = self._parser.translate_bytes(response["airconStat"]) + became_available = self._set_availability(True) + if became_available: + _LOGGER.info("Airco [%s] is available again", self.device_name) + except (KeyError, TypeError, ValueError) as ex: + self._record_failed_poll(ex) + return False + + # Some firmware revisions omit the "mcu"/"wireless" sub-keys entirely, + # so their versions fall back to "unknown" rather than failing the + # update over a string that only ends up in the device registry. + firm_type = response.get("firmType", "unknown") + mcu_ver = (response.get("mcu") or {}).get("firmVer", "unknown") + wireless_ver = (response.get("wireless") or {}).get("firmVer", "unknown") + self._firmware = f"{firm_type}, mcu: {mcu_ver}, wireless: {wireless_ver}" + + return True + + def _encode_command(self, params: dict[AirconCommands, Any]) -> str: + """Build the frame for a command. + + The module takes a full state block, not a delta, so every field the + caller did not name is sent back as we last saw it. + """ + airco_stat = AirconStat.from_aircon(self._airco) + for key, value in params.items(): + setattr(airco_stat, key, value) + return self._parser.to_base64(airco_stat) + + async def _async_write_lock_delay(self) -> float: + """Seconds to wait before retrying a write the unit just refused. + + The refusal carries no deadline with it, and the `expires` from the + last poll is our own stale one - the lock in the way was taken after + that poll, which is why we did not see it coming. So ask: a + getAirconStat is cheap and takes no lock of its own, and it reports + when the lock currently held lapses. + + That deadline can be read against our own clock directly, because the + module has none: it takes its time from the `timestamp` field of every + request it receives, so the request asking the question sets the clock + the answer is measured against. What that cannot fix is a deadline + stamped by a client whose own clock was off - hence the cap. + + The answer is kept, not just its deadline: it carries what the other + client wrote under the lock we are waiting out, and the retry sends a + full state block. Encoding that block from what we held before the + refusal would hand their changes straight back. + + Falls back to WRITE_LOCK_RETRY_DELAY when the unit does not answer or + reports no `expires` at all. + """ + try: + response = await self._api.get_aircon_stats(self._airco_id) + self._airco = self._parser.translate_bytes(response["airconStat"]) + expires = response["expires"] + except WfRacError, KeyError, TypeError, ValueError: + return WRITE_LOCK_RETRY_DELAY.total_seconds() + if not isinstance(expires, int): + return WRITE_LOCK_RETRY_DELAY.total_seconds() + # The module compares whole seconds and refuses while `expires` still + # equals the current one, so land on the far side of the lapse. + # Against the epoch clock: a naive local datetime read back through + # timestamp() is an hour out for the repeated hour when DST ends. + remaining = expires - time.time() + 1 + return max(0.0, min(remaining, WRITE_LOCK_MAX_WAIT.total_seconds())) + + async def delete_account(self) -> dict[str, Any] | None: + """Delete account (operator id) from the airco.""" + try: + return await self._api.del_account_info(self._airco_id) + except WfRacError, KeyError, TypeError: + _LOGGER.warning("Could not delete account from airco %s", self._airco_id) + return None + + async def add_account(self) -> dict[str, Any] | None: + """Add account (operator id) from the airco.""" + try: + result = await self._api.update_account_info( + self._airco_id, self._hass.config.time_zone + ) + except WfRacError, KeyError, TypeError: + _LOGGER.debug("Could not add account from airco %s", self._airco_id) + return None + + # On updateAccountInfo specifically, result:2 does mean the account + # table is full: the module answers it when no slot matches our id and + # none is free. (The same code means other things on setAirconStat - + # see RESULT_CODES - but this endpoint never talks to the indoor unit, + # so those paths cannot reach it here.) + # + # Nothing frees a slot on its own: registrations do not expire and are + # never evicted, so re-registering cannot succeed until someone + # removes one from the official app - or the module is set up afresh. + # That is a standing condition worth a repair issue rather than a + # warning that scrolls out of the log every cycle; a normal-looking + # response means whatever caused it is gone, so the issue (if any) + # clears itself. + if result and int(result.get("result", 0)) == 2: + self._report_registration_full() + else: + self._clear_registration_full_issue() + return result + + def _report_registration_full(self) -> None: + ir.async_create_issue( + self._hass, + DOMAIN, + registration_full_issue_id(self.entry_id), + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="too_many_devices", + translation_placeholders={"device_name": self.device_name}, + ) + + def _clear_registration_full_issue(self) -> None: + ir.async_delete_issue( + self._hass, DOMAIN, registration_full_issue_id(self.entry_id) + ) + + async def set_airco(self, params: dict[AirconCommands, Any]) -> None: + """Send one command frame to the airco.""" + _LOGGER.debug("Setting airco: %s", params) + # Held for the whole read-modify-send-update sequence, not just the + # send: the snapshot below must only ever be built from self._airco + # once no other set_airco() call is still in flight, otherwise a + # queued command (see async_queue_command()) could snapshot state + # from before a concurrent call's response landed and, once sent, + # silently revert whatever that call had just changed. + async with self._send_lock: + try: + command = self._encode_command(params) + try: + response = await self._api.send_airco_command( + self._airco_id, command + ) + except WfRacWriteRefusedError: + # Most likely another client's 60-second write lock - the + # Smart M-Air app was used moments ago. Waiting it + # out is the only thing that helps: our registration is + # fine, so re-registering would just cost a request. One + # retry, placed where the lock lapses rather than at a + # guessed interval - a retry that lands inside the same + # lock is a request spent on a refusal that was certain. + await asyncio.sleep(await self._async_write_lock_delay()) + # Re-encoded, because that wait refreshed the state: the + # frame is a full block, and the one built before the + # refusal would revert what the other client wrote. + response = await self._api.send_airco_command( + self._airco_id, self._encode_command(params) + ) + except WfRacRegistrationError: + # Our operator id is not in the airco's account table. + # Re-register and try once more rather than losing the + # command outright. If the table is full instead, + # add_account() has already raised the repair issue. + await self.add_account() + response = await self._api.send_airco_command( + self._airco_id, command + ) + new_airco = self._parser.translate_bytes(response) + self._airco = new_airco + except (WfRacError, KeyError, TypeError, ValueError) as ex: + _LOGGER.warning("Could not send airco data: %s", str(ex)) + # The action that issued this command awaits it, so hand it + # something it can show the user rather than a library error. + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="command_failed", + translation_placeholders={ + "device": self.device_name, + "error": str(ex), + }, + ) from ex + + async def async_queue_command(self, params: dict[AirconCommands, Any]) -> None: + """Queue an airco command, coalescing calls made close together. + + Calls within UPDATE_CONSOLIDATION_PERIOD become a single set_airco() + call. Used by all + entities instead of calling set_airco() directly, so that e.g. a fan + speed change and a temperature change issued moments apart end up in + the same request instead of racing each other. + """ + self._consolidated_params.update(params) + if (flush := self._consolidation_task) is None: + flush = self.hass.async_create_task(self._async_flush_queued_command()) + self._consolidation_task = flush + self._running_flushes.add(flush) + flush.add_done_callback(self._running_flushes.discard) + # Every caller awaits the one flush its parameters ended up in, so a + # refusal by the unit reaches the action that caused it instead of + # being logged into the void - which is what action-exceptions asks + # for. Shielded because the task is shared: a caller giving up (a + # cancelled service call) must not take the other callers' command + # down with it. + await asyncio.shield(flush) + + async def _async_flush_queued_command(self) -> None: + await asyncio.sleep(UPDATE_CONSOLIDATION_PERIOD.total_seconds()) + params = self._consolidated_params.copy() + self._consolidated_params.clear() + # The parameters are taken, so anything queued from here needs a + # window of its own. This task stays in _running_flushes until the + # send is done, which is what shutdown waits on. + self._consolidation_task = None + try: + await self.set_airco(params) + except HomeAssistantError: + # Already logged in set_airco(). Push the current state out first + # so entities pick up self.available if the same failure flipped + # it, then re-raise: async_queue_command() awaits this task, so + # the error lands on the action that issued the command instead + # of becoming an orphaned "Task exception was never retrieved". + self.async_set_updated_data(self._airco) + raise + # Immediately push the (possibly unchanged, on failure) state to all + # entities instead of leaving them to wait for the next poll (up to + # MIN_TIME_BETWEEN_UPDATES later). + self.async_set_updated_data(self._airco) + + def _set_availability(self, available: bool) -> bool: + """Record one poll result and update the availability flag. + + Return True only when the failure threshold is first reached or a + later successful poll recovers from that threshold. Keeping the + counter saturated while offline prevents a long outage from looking + like a new transition every few polls. + """ + if available: + became_available = ( + self._consecutive_failures >= self._availability_failure_limit + ) + self._consecutive_failures = 0 + self._available = True + return became_available + + previous_failures = self._consecutive_failures + self._consecutive_failures = min( + previous_failures + 1, self._availability_failure_limit + ) + if self._consecutive_failures >= self._availability_failure_limit: + self._available = False + return ( + previous_failures + < self._availability_failure_limit + <= self._consecutive_failures + ) + + def _record_failed_poll(self, error: BaseException) -> None: + """Count one failed poll, and log it at the level it deserves. + + Every poll still reaches entities (_async_update_data returns the last + data on an expected failure), so crossing the threshold needs no + notification of its own - only the line that says it happened, once. + The condition holds until the unit answers again, and these modules + drop off for a minute or so every hour on their own, so a line per + poll would bury the one that matters. + """ + became_unavailable = self._set_availability(False) + if became_unavailable: + _LOGGER.info( + "Airco [%s] is unavailable after %s failed polls: %s", + self.device_name, + self._availability_failure_limit, + error, + ) + _LOGGER.debug("Update of [%s] failed", self.device_name, exc_info=error) + else: + _LOGGER.debug("Could not reach the airco [%s]: %s", self.device_name, error) + + @property + def device_info(self) -> DeviceInfo: + """Return a device description for device registry. + + No "model": the only model field the protocol offers is ModelNr, a + capability grouping (0/1/2/3/64...), not a type name - it would put a + bare digit where users expect "SRK35ZS-WF". It goes into model_id + instead, which is what a machine-readable model identifier is for. + """ + info: DeviceInfo = { + "sw_version": self._firmware, + "identifiers": {(DOMAIN, self.airco_id)}, + "manufacturer": "Mitsubishi Heavy Industries", + "name": self.device_name, + } + # airconId is MAC-derived, and on every module seen so far it is the + # bare MAC. Only claim it when it has exactly that shape - a differently + # shaped id would otherwise register as somebody else's hardware and + # merge two unrelated devices in the registry. + if re.fullmatch(r"[0-9a-fA-F]{12}", self.airco_id): + info["connections"] = {(CONNECTION_NETWORK_MAC, format_mac(self.airco_id))} + model_nr = getattr(self.airco, "ModelNrRaw", None) + if model_nr is not None: + info["model_id"] = str(model_nr) + return info + + @property + def device_name(self) -> str: + """Get given Airco name.""" + return self._name + + @property + def airco_id(self) -> str: + """Return Airco ID.""" + return self._airco_id + + @property + def airco(self) -> Aircon: + """Return parsed Aircon object if set otherwise None.""" + return self._airco + + @property + def available(self) -> bool: + """Return True if device is available.""" + return self._available + + @property + def connection_method(self) -> str | None: + """Return the discovered/persisted communication method (http/https), if known.""" + return self._api.method + + @override + async def _async_update_data(self) -> Aircon: + """Update data via library. + + A missed poll is not an update failure. These modules restart their + WiFi about once an hour on their own, so single failures are routine + and carry no consequence: _set_availability() rides them out, and + entities follow Device.available rather than the coordinator's own + success flag. Raising UpdateFailed for one would put an error in every + user's log once an hour for a condition nobody can act on - and the + entities would flick to unavailable a poll before our own threshold + says they should. So an expected failure returns the last data instead, + and only the availability transition is worth a line. + """ + try: + async with asyncio.timeout(POLL_TIMEOUT.total_seconds()): + await self.update() + except TimeoutError: + # The outer deadline can expire before the repository's individual + # connection attempts do. Treat that exactly like any other missed + # poll so transient outages stay quiet and the entity only becomes + # unavailable at the configured threshold. + self._record_failed_poll( + WfRacConnectionError( + f"did not answer within {POLL_TIMEOUT.total_seconds():.0f}s" + ) + ) + except Exception as error: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + translation_placeholders={ + "device": self.device_name, + "error": str(error), + }, + ) from error + + return self._airco diff --git a/homeassistant/components/mitsubishi_wf_rac/entity.py b/homeassistant/components/mitsubishi_wf_rac/entity.py new file mode 100644 index 0000000000000..1ce404d09efad --- /dev/null +++ b/homeassistant/components/mitsubishi_wf_rac/entity.py @@ -0,0 +1,106 @@ +"""Shared base entity for all WF-RAC platform entities.""" + +import logging +from typing import override + +from homeassistant.components.climate import HVACMode +from homeassistant.core import callback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import HVAC_TRANSLATION +from .coordinator import Device + +_LOGGER = logging.getLogger(__name__) + + +class WfRacEntity(CoordinatorEntity[Device]): + """Wires an entity to the shared Device coordinator. + + Subclasses implement _update_state() and call _apply_state() once at the + end of their own __init__ for the initial state; this base class + re-invokes it whenever the coordinator notifies listeners - either from + its own poll or from Device.async_set_updated_data() right after a + command completes. + """ + + def __init__(self, device: Device) -> None: + """Wire the entity to the shared coordinator.""" + super().__init__(device) + self._device = device + self._attr_device_info = device.device_info + self._state_unreadable = False + + @property + def _hvac_mode_from_operation(self) -> HVACMode: + """The unit's underlying cool/heat mode. + + airco.OperationMode keeps reporting it while the unit is off, so this + is what the displayed mode falls back to - the climate entity's own + hvac_mode is forced to OFF in that case. + """ + return list(HVAC_TRANSLATION.keys())[self._device.airco.OperationMode] + + @override + @property + def available(self) -> bool: + """Return whether the airco is currently reachable.""" + # Device tracks its own retry-tolerant availability (see + # Device._set_availability()): an expected missed poll leaves the + # coordinator successful on purpose, so last_update_success alone + # would not hold the entity up. It still has to be honoured, though - + # an unexpected failure raises UpdateFailed and only shows there. + return super().available and self._device.available + + def _mark_state_unknown(self) -> None: + """Drop the attributes that carry this entity's state. + + Overridden per platform. Called when a frame arrives that the entity + cannot read: the unit answered and still takes commands, so it is not + unavailable - its state is merely unknown until a frame it can read + comes along. + """ + raise NotImplementedError + + def _update_state(self) -> None: + """Refresh entity state from the coordinator. + + Every concrete subclass overrides this; never invoked through this + base implementation. + """ + raise NotImplementedError + + def _apply_state(self) -> None: + """Read the current frame into this entity, or mark it unknown. + + Every read goes through here, the very first one included. A frame + can decode cleanly and still carry a value this entity cannot + translate, and letting that escape a constructor is not the same + failure as letting it escape a poll: the platform never finishes + setting up, so the config entry loads with no entity at all and only + a traceback to say why. The same value arriving one frame later + merely makes the state unknown. + """ + try: + self._update_state() + except IndexError, KeyError, AttributeError, ValueError: + # Once, with the traceback: which field was missing is the whole + # diagnosis, and the condition holds until the unit sends + # something else - a line per poll would say nothing more. + if not self._state_unreadable: + # entity_id is only assigned once the entity is added, so on + # the first read the unique id is all there is to name it by. + _LOGGER.warning( + "Could not update %s", + self.entity_id or self._attr_unique_id, + exc_info=True, + ) + self._state_unreadable = True + self._mark_state_unknown() + else: + self._state_unreadable = False + + @override + @callback + def _handle_coordinator_update(self) -> None: + self._apply_state() + self.async_write_ha_state() diff --git a/homeassistant/components/mitsubishi_wf_rac/icons.json b/homeassistant/components/mitsubishi_wf_rac/icons.json new file mode 100644 index 0000000000000..8ac9f2ffd28ac --- /dev/null +++ b/homeassistant/components/mitsubishi_wf_rac/icons.json @@ -0,0 +1,37 @@ +{ + "entity": { + "climate": { + "mitsubishi_wf_rac": { + "state_attributes": { + "fan_mode": { + "state": { + "quiet": "mdi:fan-minus" + } + }, + "swing_horizontal_mode": { + "state": { + "3d_auto": "mdi:video-3d", + "center_right": "mdi:arrow-right", + "left_center": "mdi:arrow-left", + "left_left": "mdi:arrow-left-thick", + "left_right": "mdi:arrow-left-right", + "left_right_auto": "mdi:refresh-auto", + "right_left": "mdi:arrow-left-right", + "right_right": "mdi:arrow-right-thick" + } + }, + "swing_mode": { + "state": { + "3d_auto": "mdi:video-3d", + "highest": "mdi:arrow-up-thick", + "lowest": "mdi:arrow-down", + "middle": "mdi:arrow-up", + "normal": "mdi:circle-medium", + "up_down_auto": "mdi:arrow-up-down" + } + } + } + } + } + } +} diff --git a/homeassistant/components/mitsubishi_wf_rac/manifest.json b/homeassistant/components/mitsubishi_wf_rac/manifest.json new file mode 100644 index 0000000000000..d9720b7135d88 --- /dev/null +++ b/homeassistant/components/mitsubishi_wf_rac/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "mitsubishi_wf_rac", + "name": "Mitsubishi WF-RAC", + "codeowners": ["@blues-sechseck"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/mitsubishi_wf_rac", + "integration_type": "device", + "iot_class": "local_polling", + "loggers": ["pywfrac"], + "quality_scale": "bronze", + "requirements": ["pywfrac==0.1.3"], + "zeroconf": ["_beaver._tcp.local."] +} diff --git a/homeassistant/components/mitsubishi_wf_rac/quality_scale.yaml b/homeassistant/components/mitsubishi_wf_rac/quality_scale.yaml new file mode 100644 index 0000000000000..c744b26d117a2 --- /dev/null +++ b/homeassistant/components/mitsubishi_wf_rac/quality_scale.yaml @@ -0,0 +1,79 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: The integration registers no actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: exempt + comment: The integration registers no actions. + docs-conditions: + status: exempt + comment: The integration registers no conditions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + docs-triggers: + status: exempt + comment: The integration registers no triggers. + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: The integration has no options flow. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: + status: exempt + comment: >- + No credentials to renew - the operatorId registration is a device-side + account slot, not a login. + test-coverage: done + # Gold + devices: done + diagnostics: todo + discovery: done + discovery-update-info: done + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: One device per config entry; no dynamically appearing sub-devices. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: done + stale-devices: + status: exempt + comment: One device per config entry; nothing goes stale independently of it. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/mitsubishi_wf_rac/strings.json b/homeassistant/components/mitsubishi_wf_rac/strings.json new file mode 100644 index 0000000000000..082403302697c --- /dev/null +++ b/homeassistant/components/mitsubishi_wf_rac/strings.json @@ -0,0 +1,106 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "Could not connect with the Airco: {reason}", + "host_already_configured": "Airco IP already configured as [{error_name}]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "too_many_devices_registered": "There are too many devices registered for this airco. Please delete a device (in the app) or do a factory reset of the module.", + "unexpected_error": "An unexpected error occurred. Please check the log for details." + }, + "step": { + "discovery_confirm": { + "data": { + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "port": "The port the module announced. It is normally 51443 - correct it here if the value above looks different." + }, + "description": "The airco with id: {id}, IP: {host} and port: {port} was discovered. The port is normally 51443 — correct it below if it looks different.", + "title": "Discovered WF-RAC airco" + }, + "user": { + "data": { + "force_update": "Ignore duplicate IP address", + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "force_update": "Add this airco even though another entry already uses this IP address. Meant for re-adding a unit whose old entry went missing: the module accepts one connection at a time, so two entries polling it will produce errors in the log.", + "host": "The local IP address of the airco's wireless module. A changed address is picked up when the module announces itself again; give the module a fixed address in your router, or correct it here if it does not.", + "port": "The port the module's local API listens on. Leave this at 51443 unless your module announces a different one." + }, + "description": "Please fill in the correct information to add the Airco Manually", + "title": "WF-RAC AC connection info" + } + } + }, + "entity": { + "climate": { + "mitsubishi_wf_rac": { + "state_attributes": { + "fan_mode": { + "state": { + "auto": "Auto", + "high": "High", + "low": "Low", + "medium": "Medium", + "quiet": "Quiet" + } + }, + "swing_horizontal_mode": { + "state": { + "3d_auto": "3D Auto", + "center_center": "Center-Center", + "center_right": "Center-Right", + "left_center": "Left-Center", + "left_left": "Left-Left", + "left_right": "Left-Right", + "left_right_auto": "Left/Right Auto", + "right_left": "Right-Left", + "right_right": "Right-Right" + } + }, + "swing_mode": { + "state": { + "3d_auto": "3D Auto", + "highest": "Highest", + "lowest": "Lowest", + "middle": "Middle", + "normal": "Normal", + "up_down_auto": "Up/Down Auto" + } + } + } + } + } + }, + "exceptions": { + "cannot_connect": { + "message": "Could not reach the Airco at {device}." + }, + "command_failed": { + "message": "Sending the command to the Airco at {device} failed: {error}" + }, + "preset_away_needs_cool_or_heat": { + "message": "Home Leave mode is only available while cooling or heating, not in {hvac_mode}. Switch the unit to cool or heat first, so the direction it should hold is unambiguous." + }, + "temperature_above_maximum": { + "message": "{temperature} °C is above the {max_temp} °C maximum for hvac_mode {hvac_mode}. Include hvac_mode in the same action call to check against the mode you are switching to instead." + }, + "temperature_below_minimum": { + "message": "{temperature} °C is below the {min_temp} °C minimum for hvac_mode {hvac_mode}. Include hvac_mode in the same action call to check against the mode you are switching to instead." + }, + "update_failed": { + "message": "Polling the Airco at {device} failed: {error}" + } + }, + "issues": { + "too_many_devices": { + "description": "This unit has room for four registered accounts and all four are taken, so Home Assistant cannot register itself. The module never frees a slot by itself: registrations do not expire, and a new one is refused rather than replacing an older one. In the Smart M-Air app, remove this unit from a phone that no longer needs it - each account can only remove itself, and several phones sharing one Smart M-Air account take up a single slot between them. Then reload this integration. If nobody can free a slot - a phone that is long gone still holds one - the module has to be set up again from scratch.", + "title": "Too many accounts registered on {device_name}" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 022efbd91edf2..9e60f82605d7c 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -489,6 +489,7 @@ "mill", "minecraft_server", "mitsubishi_comfort", + "mitsubishi_wf_rac", "mjpeg", "moat", "mobile_app", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index b98961014781d..74bc8b50d1fbc 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4527,6 +4527,12 @@ } } }, + "mitsubishi_wf_rac": { + "name": "Mitsubishi WF-RAC", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "mjpeg": { "name": "MJPEG IP Camera", "integration_type": "hub", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index f6dc0f2dfa19b..c5e367653c66f 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -428,6 +428,11 @@ "domain": "blebox", }, ], + "_beaver._tcp.local.": [ + { + "domain": "mitsubishi_wf_rac", + }, + ], "_bond._tcp.local.": [ { "domain": "bond", diff --git a/mypy.ini b/mypy.ini index 55d9509291bce..0c2b0b5994cf6 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3688,6 +3688,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.mitsubishi_wf_rac.*] +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.mjpeg.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 852f6d61b9f2a..f18d33e603b27 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2895,6 +2895,9 @@ pywebpush==2.3.0 # homeassistant.components.wemo pywemo==1.4.0 +# homeassistant.components.mitsubishi_wf_rac +pywfrac==0.1.3 + # homeassistant.components.wilight pywilight==0.0.74 diff --git a/tests/components/mitsubishi_wf_rac/__init__.py b/tests/components/mitsubishi_wf_rac/__init__.py new file mode 100644 index 0000000000000..d4eace231b40d --- /dev/null +++ b/tests/components/mitsubishi_wf_rac/__init__.py @@ -0,0 +1,20 @@ +"""Tests for the Mitsubishi WF-RAC integration.""" + +from homeassistant.components.mitsubishi_wf_rac.const import ( + CONF_AIRCO_ID, + CONF_OPERATOR_ID, +) +from homeassistant.const import CONF_DEVICE_ID, CONF_HOST, CONF_PORT + +AIRCO_ID = "0011223344aa" +HOST = "192.168.1.4" +PORT = 51443 + +ENTRY_DATA = { + CONF_HOST: HOST, + CONF_DEVICE_ID: "homeassistant-device-0123456789a", + CONF_OPERATOR_ID: "hassio-00000000-0000-0000-0000-000000000000", + CONF_PORT: PORT, + CONF_AIRCO_ID: AIRCO_ID, +} +ENTRY_OPTIONS = {"availability_retry_limit": 3} diff --git a/tests/components/mitsubishi_wf_rac/conftest.py b/tests/components/mitsubishi_wf_rac/conftest.py new file mode 100644 index 0000000000000..823afb9070890 --- /dev/null +++ b/tests/components/mitsubishi_wf_rac/conftest.py @@ -0,0 +1,83 @@ +"""Fixtures for the Mitsubishi WF-RAC integration.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.mitsubishi_wf_rac.const import DOMAIN +from homeassistant.core import HomeAssistant + +from . import AIRCO_ID, ENTRY_DATA, ENTRY_OPTIONS + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.mitsubishi_wf_rac.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def aircon_stat() -> dict: + """Return one getAirconStat response, as the module sends it.""" + return load_json_object_fixture("aircon_stat.json", DOMAIN) + + +@pytest.fixture +def mock_repository(aircon_stat: dict) -> Generator[AsyncMock]: + """Patch pywfrac's Repository everywhere the integration builds one. + + Both modules import the class by name, so patching the library itself + would leave those references untouched. + """ + repository = AsyncMock() + repository.get_airco_id.return_value = AIRCO_ID + repository.update_account_info.return_value = {"result": 0} + repository.del_account_info.return_value = {"result": 0} + repository.get_aircon_stats.return_value = aircon_stat + repository.send_airco_command.return_value = aircon_stat["airconStat"] + repository.method = "https" + + with ( + patch( + "homeassistant.components.mitsubishi_wf_rac.coordinator.Repository", + return_value=repository, + ), + patch( + "homeassistant.components.mitsubishi_wf_rac.config_flow.Repository", + return_value=repository, + ), + ): + yield repository + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a config entry at the current version.""" + return MockConfigEntry( + domain=DOMAIN, + title="Living room", + data=ENTRY_DATA, + options=ENTRY_OPTIONS, + unique_id=AIRCO_ID, + version=7, + ) + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_repository: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> MockConfigEntry: + """Set up the integration with a reachable airco.""" + mock_config_entry.add_to_hass(hass) + 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/mitsubishi_wf_rac/fixtures/aircon_stat.json b/tests/components/mitsubishi_wf_rac/fixtures/aircon_stat.json new file mode 100644 index 0000000000000..e4ed6bb8f44a4 --- /dev/null +++ b/tests/components/mitsubishi_wf_rac/fixtures/aircon_stat.json @@ -0,0 +1,17 @@ +{ + "airconId": "0011223344aa", + "airconStat": "AAAAAAD/AAAIAAAAAAAAAAAAAf////+XyIEECAAsngAAiAAABAAAAAAAAAOAIJ7/gBC3/5QQGABIRA==", + "logStat": 0, + "updatedBy": "local", + "expires": 1788630278, + "ledStat": 1, + "autoHeating": 0, + "highTemp": "AB", + "lowTemp": "66", + "wireless": { "firmVer": "025" }, + "mcu": { "firmVer": "200" }, + "timezone": "Europe/Berlin", + "remoteList": ["", "", "", ""], + "numOfAccount": 2, + "firmType": "WF-RAC-HTTPS" +} diff --git a/tests/components/mitsubishi_wf_rac/snapshots/test_climate.ambr b/tests/components/mitsubishi_wf_rac/snapshots/test_climate.ambr new file mode 100644 index 0000000000000..4363b650ea2a0 --- /dev/null +++ b/tests/components/mitsubishi_wf_rac/snapshots/test_climate.ambr @@ -0,0 +1,142 @@ +# serializer version: 1 +# name: test_entity[climate.living_room-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'auto', + 'quiet', + 'low', + 'medium', + 'high', + ]), + : list([ + , + , + , + , + , + , + ]), + : 30, + : 16, + : list([ + 'none', + 'away', + ]), + : list([ + 'left_right_auto', + 'left_left', + 'left_center', + 'center_center', + 'center_right', + 'right_right', + 'left_right', + 'right_left', + '3d_auto', + ]), + : list([ + 'up_down_auto', + 'highest', + 'middle', + 'normal', + 'lowest', + '3d_auto', + ]), + : 0.5, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.living_room', + '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': 'mitsubishi_wf_rac', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'mitsubishi_wf_rac', + 'unique_id': 'mitsubishi_wf_rac-0011223344aa-climate', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity[climate.living_room-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 24.7, + : 'quiet', + : list([ + 'auto', + 'quiet', + 'low', + 'medium', + 'high', + ]), + : 'Living room', + : , + : list([ + , + , + , + , + , + , + ]), + : 30, + : 16, + : 'none', + : list([ + 'none', + 'away', + ]), + : , + : 'right_right', + : list([ + 'left_right_auto', + 'left_left', + 'left_center', + 'center_center', + 'center_right', + 'right_right', + 'left_right', + 'right_left', + '3d_auto', + ]), + : 'highest', + : list([ + 'up_down_auto', + 'highest', + 'middle', + 'normal', + 'lowest', + '3d_auto', + ]), + : 0.5, + : 22.0, + }), + 'context': , + 'entity_id': 'climate.living_room', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/mitsubishi_wf_rac/test_climate.py b/tests/components/mitsubishi_wf_rac/test_climate.py new file mode 100644 index 0000000000000..b223d8e992693 --- /dev/null +++ b/tests/components/mitsubishi_wf_rac/test_climate.py @@ -0,0 +1,658 @@ +"""Test the Mitsubishi WF-RAC climate platform.""" + +import asyncio +from dataclasses import replace +from unittest.mock import AsyncMock, patch + +import pytest +from pywfrac import AIRFLOW_UNKNOWN, Aircon, RacParser, WfRacError +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.climate import ( + ATTR_FAN_MODE, + ATTR_HVAC_MODE, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ATTR_PRESET_MODE, + ATTR_SWING_HORIZONTAL_MODE, + ATTR_SWING_MODE, + DOMAIN as CLIMATE_DOMAIN, + PRESET_AWAY, + PRESET_NONE, + SERVICE_SET_FAN_MODE, + SERVICE_SET_HVAC_MODE, + SERVICE_SET_PRESET_MODE, + SERVICE_SET_SWING_HORIZONTAL_MODE, + SERVICE_SET_SWING_MODE, + SERVICE_SET_TEMPERATURE, + HVACAction, + HVACMode, +) +from homeassistant.components.mitsubishi_wf_rac.const import ( + HOME_LEAVE_TEMP_COOL, + HOME_LEAVE_TEMP_HEAT, + SWING_3D_AUTO, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_TEMPERATURE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + +ENTITY_ID = "climate.living_room" + + +def _sent_command(mock_repository: AsyncMock) -> Aircon: + """Decode the frame the integration last put on the wire. + + Command and status frames share a layout, so the same parser reads + back what was encoded - which is what makes the protocol mapping + (mode, setpoint, fan, louvers) assertable at all. + """ + return RacParser().translate_bytes( + mock_repository.send_airco_command.await_args.args[1] + ) + + +async def test_entity( + hass: HomeAssistant, + init_integration: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """The climate entity reflects the state the module reported.""" + await snapshot_platform(hass, entity_registry, snapshot, init_integration.entry_id) + + +async def test_state_from_the_module( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """The captured frame has the unit off, in cool, set to 22 degrees.""" + state = hass.states.get(ENTITY_ID) + + assert state is not None + assert state.state == HVACMode.OFF + assert state.attributes[ATTR_TEMPERATURE] == 22.0 + assert state.attributes["current_temperature"] == 24.7 + + +@pytest.mark.parametrize( + ("service", "data"), + [ + (SERVICE_SET_HVAC_MODE, {ATTR_HVAC_MODE: HVACMode.COOL}), + (SERVICE_SET_TEMPERATURE, {ATTR_TEMPERATURE: 21.0}), + (SERVICE_SET_FAN_MODE, {ATTR_FAN_MODE: "auto"}), + (SERVICE_SET_SWING_MODE, {ATTR_SWING_MODE: "highest"}), + ], +) +async def test_commands_reach_the_module( + hass: HomeAssistant, + mock_repository: AsyncMock, + init_integration: MockConfigEntry, + service: str, + data: dict, +) -> None: + """Every setter ends up as one frame sent to the airco.""" + mock_repository.send_airco_command.reset_mock() + + await hass.services.async_call( + CLIMATE_DOMAIN, + service, + {ATTR_ENTITY_ID: ENTITY_ID, **data}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_repository.send_airco_command.assert_awaited() + + +async def test_temperature_outside_the_units_range_is_refused( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """Refuse a setpoint the unit itself does not offer. + + Asking past the reported range is an error, not a value quietly clamped + behind the user's back. + """ + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 40.0}, + blocking=True, + ) + + +async def test_set_temperature_without_a_single_setpoint( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """A range call has no single setpoint to send. + + The unit takes one target temperature, so the high/low pair the climate + schema also accepts is refused rather than silently reduced to one of the + two. + """ + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: ENTITY_ID, + "target_temp_low": 20.0, + "target_temp_high": 24.0, + }, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("operation_mode", "hvac_mode", "away_temp"), + [ + pytest.param(1, HVACMode.COOL, HOME_LEAVE_TEMP_COOL, id="cooling"), + pytest.param(2, HVACMode.HEAT, HOME_LEAVE_TEMP_HEAT, id="heating"), + ], +) +async def test_preset_away_switches_the_unit_to_home_leave( + hass: HomeAssistant, + mock_repository: AsyncMock, + init_integration: MockConfigEntry, + operation_mode: int, + hvac_mode: HVACMode, + away_temp: float, +) -> None: + """Hand the away preset to the unit's own Home Leave mode. + + It is the unit's mode rather than a setpoint we invent: the unit enters it + when it is given the away target of the direction it is running in, so the + setpoint on the wire is what makes this work at all - and it differs + between cooling and heating. + + The running state is set on the coordinator rather than driven through a + command: the mocked module echoes one fixed status frame back, so a write + would not change what the next read reports. + """ + device = init_integration.runtime_data.device + device.airco.Operation = True + device.airco.OperationMode = operation_mode + device.async_set_updated_data(device.airco) + await hass.async_block_till_done() + assert hass.states.get(ENTITY_ID).state == hvac_mode + mock_repository.send_airco_command.reset_mock() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_AWAY}, + blocking=True, + ) + await hass.async_block_till_done() + + assert _sent_command(mock_repository).PresetTemp == away_temp + + +async def test_preset_away_needs_a_direction( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """While the unit is off there is no cool-or-heat for Home Leave to mean.""" + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_AWAY}, + blocking=True, + ) + + +async def test_turn_on_and_off( + hass: HomeAssistant, + mock_repository: AsyncMock, + init_integration: MockConfigEntry, +) -> None: + """Turning off keeps the mode, so turning on again returns to it.""" + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + mock_repository.send_airco_command.assert_awaited() + + mock_repository.send_airco_command.reset_mock() + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + await hass.async_block_till_done() + mock_repository.send_airco_command.assert_awaited() + + +async def test_horizontal_swing( + hass: HomeAssistant, + mock_repository: AsyncMock, + init_integration: MockConfigEntry, +) -> None: + """The left/right louver is its own axis on this hardware.""" + mock_repository.send_airco_command.reset_mock() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_HORIZONTAL_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_SWING_HORIZONTAL_MODE: "left_left"}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_repository.send_airco_command.assert_awaited() + + +async def test_a_refused_command_reaches_the_caller( + hass: HomeAssistant, + mock_repository: AsyncMock, + init_integration: MockConfigEntry, +) -> None: + """A blocking action reports a write the unit did not take. + + The command is queued and flushed on a task, so this only holds because + the caller awaits that task - see Device.async_queue_command(). + """ + mock_repository.send_airco_command.side_effect = WfRacError("refused") + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: "auto"}, + blocking=True, + ) + + +async def test_commands_issued_together_become_one_frame( + hass: HomeAssistant, + mock_repository: AsyncMock, + init_integration: MockConfigEntry, +) -> None: + """Two actions issued together still leave as one frame. + + Every command is awaited to its result now, so this only holds because + the platform does not serialise them on top of that: with + PARALLEL_UPDATES = 1 the second call would not start until the first had + been sent, and the consolidation window would be over. Commands issued + one after another - a script awaiting each step - do leave separately; + there is no window to join once the first has been sent and answered. + """ + mock_repository.send_airco_command.reset_mock() + + await asyncio.gather( + hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.HEAT}, + blocking=True, + ), + hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: "auto"}, + blocking=True, + ), + ) + await hass.async_block_till_done() + + assert mock_repository.send_airco_command.await_count == 1 + + +@pytest.mark.parametrize( + ("operation_mode", "compressor", "cool_hot_judge", "expected"), + [ + (3, False, False, HVACAction.FAN), + (4, False, False, HVACAction.DRYING), + (1, False, False, HVACAction.IDLE), + (0, True, True, HVACAction.HEATING), + (0, True, False, HVACAction.COOLING), + (1, True, False, HVACAction.COOLING), + (2, True, False, HVACAction.HEATING), + ], + ids=["fan", "dry", "satisfied", "auto-heat", "auto-cool", "cool", "heat"], +) +async def test_hvac_action_while_running( + hass: HomeAssistant, + init_integration: MockConfigEntry, + operation_mode: int, + compressor: bool, + cool_hot_judge: bool, + expected: HVACAction, +) -> None: + """What the unit reports it is doing, per mode. + + CoolHotJudge is inverted against its raw bit, which is why the two AUTO + cases are spelled out rather than left to the reader. + """ + device = init_integration.runtime_data.device + device.airco.Operation = True + device.airco.OperationMode = operation_mode + device.airco.CompressorRunning = compressor + device.airco.CoolHotJudge = cool_hot_judge + device.async_set_updated_data(device.airco) + await hass.async_block_till_done() + + assert hass.states.get(ENTITY_ID).attributes["hvac_action"] is expected + + +async def test_hvac_action_is_off_while_the_unit_is( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """The captured frame has the unit off.""" + assert hass.states.get(ENTITY_ID).attributes["hvac_action"] is HVACAction.OFF + + +@pytest.mark.parametrize( + ("operation_mode", "expected"), + [ + (0, HVACMode.AUTO), + (1, HVACMode.COOL), + (2, HVACMode.HEAT), + (3, HVACMode.FAN_ONLY), + (4, HVACMode.DRY), + ], +) +async def test_every_operation_mode_maps_to_an_hvac_mode( + hass: HomeAssistant, + init_integration: MockConfigEntry, + operation_mode: int, + expected: HVACMode, +) -> None: + """The unit's mode byte, as Home Assistant names it.""" + device = init_integration.runtime_data.device + device.airco.Operation = True + device.airco.OperationMode = operation_mode + device.async_set_updated_data(device.airco) + await hass.async_block_till_done() + + assert hass.states.get(ENTITY_ID).state == expected + + +async def test_temperature_below_the_units_range_is_refused( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """The floor depends on the mode, and naming it is the whole message.""" + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_TEMPERATURE: 5.0, + ATTR_HVAC_MODE: HVACMode.HEAT, + }, + blocking=True, + ) + + +async def test_setting_temperature_and_mode_together( + hass: HomeAssistant, + mock_repository: AsyncMock, + init_integration: MockConfigEntry, +) -> None: + """A setpoint measured against the mode the call switches to. + + The range depends on the mode, and an automation that sets both at once + must not be judged against the mode the unit is leaving. + """ + mock_repository.send_airco_command.reset_mock() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_TEMPERATURE: 19.0, + ATTR_HVAC_MODE: HVACMode.HEAT, + }, + blocking=True, + ) + await hass.async_block_till_done() + + mock_repository.send_airco_command.assert_awaited() + + +async def test_preset_none_returns_the_unit_to_a_normal_setpoint( + hass: HomeAssistant, + mock_repository: AsyncMock, + init_integration: MockConfigEntry, +) -> None: + """Leaving Home Leave is a setpoint, not a mode of its own.""" + mock_repository.send_airco_command.reset_mock() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_PRESET_MODE: PRESET_NONE}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_repository.send_airco_command.assert_awaited() + + +@pytest.mark.parametrize( + ("service", "attribute"), + [ + (SERVICE_SET_SWING_MODE, ATTR_SWING_MODE), + (SERVICE_SET_SWING_HORIZONTAL_MODE, ATTR_SWING_HORIZONTAL_MODE), + ], +) +async def test_3d_auto_hands_both_louvers_to_the_unit( + hass: HomeAssistant, + mock_repository: AsyncMock, + init_integration: MockConfigEntry, + service: str, + attribute: str, +) -> None: + """3D auto is the unit's own vane logic, entrusted from either axis.""" + mock_repository.send_airco_command.reset_mock() + + await hass.services.async_call( + CLIMATE_DOMAIN, + service, + {ATTR_ENTITY_ID: ENTITY_ID, attribute: SWING_3D_AUTO}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_repository.send_airco_command.assert_awaited() + + +async def test_a_model_with_the_wider_heating_range( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """PresetTempRange2 models heat down to 10 degrees, not 18.""" + device = init_integration.runtime_data.device + device.airco.Capabilities = replace( + device.airco.Capabilities, preset_temp_range_2=True + ) + device.airco.Operation = True + device.airco.OperationMode = 2 + device.async_set_updated_data(device.airco) + await hass.async_block_till_done() + + assert hass.states.get(ENTITY_ID).attributes["min_temp"] == 10 + + +async def test_the_wider_range_leaves_cooling_alone( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """Only the heating floor moves - the cooling floor is the same 16.""" + device = init_integration.runtime_data.device + device.airco.Capabilities = replace( + device.airco.Capabilities, preset_temp_range_2=True + ) + device.airco.Operation = True + device.airco.OperationMode = 1 + device.async_set_updated_data(device.airco) + await hass.async_block_till_done() + + assert hass.states.get(ENTITY_ID).attributes["min_temp"] == 16 + + +@pytest.mark.parametrize( + ("operation_mode", "max_temp"), + [ + pytest.param(1, 33, id="cooling"), + pytest.param(4, 33, id="drying"), + pytest.param(2, 30, id="heating"), + pytest.param(0, 30, id="auto"), + ], +) +async def test_the_wider_range_only_lifts_the_cooling_ceiling( + hass: HomeAssistant, + init_integration: MockConfigEntry, + operation_mode: int, + max_temp: int, +) -> None: + """PresetTempRange2 models take 33 in cooling and dry, 30 everywhere else.""" + device = init_integration.runtime_data.device + device.airco.Capabilities = replace( + device.airco.Capabilities, preset_temp_range_2=True + ) + device.airco.Operation = True + device.airco.OperationMode = operation_mode + device.async_set_updated_data(device.airco) + await hass.async_block_till_done() + + assert hass.states.get(ENTITY_ID).attributes["max_temp"] == max_temp + + +async def test_a_frame_the_entity_cannot_read_makes_its_state_unknown( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """A frame the entity cannot read ends at the entity. + + Unknown rather than unavailable: the unit answered and still takes + commands, and Home Assistant leaves unavailable entities out of entity + service calls - so reporting unavailable would take the controls away + from a unit that is right there. + """ + device = init_integration.runtime_data.device + device.airco.OperationMode = 99 + device.async_set_updated_data(device.airco) + await hass.async_block_till_done() + + assert hass.states.get(ENTITY_ID).state == STATE_UNKNOWN + + # And back, without waiting out anything: the next frame it can read is + # all it needs. + device.airco.OperationMode = 1 + device.async_set_updated_data(device.airco) + await hass.async_block_till_done() + + assert hass.states.get(ENTITY_ID).state == HVACMode.OFF + + +@pytest.mark.parametrize( + ("capability", "temperature", "hvac_mode"), + [ + pytest.param(False, 17.0, HVACMode.HEAT, id="below_the_heating_floor"), + pytest.param(True, 32.0, HVACMode.HEAT, id="above_the_heating_ceiling"), + ], +) +async def test_a_setpoint_is_measured_against_the_mode_being_switched_to( + hass: HomeAssistant, + init_integration: MockConfigEntry, + capability: bool, + temperature: float, + hvac_mode: HVACMode, +) -> None: + """One call that sets both is measured against the mode it is switching to. + + While the unit is off the range spans every regulating mode, so these + values pass the entity's own min_temp/max_temp - and would arrive at a + unit that does not take them. Naming the mode in the refusal is the point: + the same value is fine in the mode the automation was leaving. + """ + device = init_integration.runtime_data.device + device.airco.Capabilities = replace( + device.airco.Capabilities, preset_temp_range_2=capability + ) + device.async_set_updated_data(device.airco) + await hass.async_block_till_done() + + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_TEMPERATURE: temperature, + ATTR_HVAC_MODE: hvac_mode, + }, + blocking=True, + ) + + +async def test_a_frame_the_entity_cannot_read_still_produces_an_entity( + hass: HomeAssistant, + mock_repository: AsyncMock, + mock_config_entry: MockConfigEntry, + aircon_stat: dict, +) -> None: + """An unreadable first frame makes the state unknown, not the entity absent. + + The first read happens in the constructor, so an exception there does not + just make one entity unavailable - it aborts the platform setup and leaves + the config entry loaded with no entities at all, and nothing but a + traceback to say why. A fan value the library could not translate is the + one such value a real frame can carry. + """ + airco = RacParser().translate_bytes(aircon_stat["airconStat"]) + airco.AirFlow = AIRFLOW_UNKNOWN + with patch.object(RacParser, "translate_bytes", return_value=airco): + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + + +async def test_a_setpoint_sent_in_fan_only_is_held_to_every_modes_range( + hass: HomeAssistant, + mock_repository: AsyncMock, + init_integration: MockConfigEntry, +) -> None: + """Fan-only has no setpoint range of its own, so the union applies. + + The value is stored for whichever regulating mode is turned on next, and + holding it to the default floor would refuse a cooling setpoint the unit + takes happily once it is cooling. + """ + device = init_integration.runtime_data.device + device.airco.Operation = True + device.airco.OperationMode = 3 + device.async_set_updated_data(device.airco) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state.state == HVACMode.FAN_ONLY + assert state.attributes[ATTR_MIN_TEMP] == 16.0 + assert state.attributes[ATTR_MAX_TEMP] == 30.0 + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 16.0}, + blocking=True, + ) + await hass.async_block_till_done() + + assert _sent_command(mock_repository).PresetTemp == 16.0 diff --git a/tests/components/mitsubishi_wf_rac/test_config_flow.py b/tests/components/mitsubishi_wf_rac/test_config_flow.py new file mode 100644 index 0000000000000..7c78265b16b89 --- /dev/null +++ b/tests/components/mitsubishi_wf_rac/test_config_flow.py @@ -0,0 +1,520 @@ +"""Test the Mitsubishi WF-RAC config flow.""" + +from typing import Any +from unittest.mock import AsyncMock, patch +from uuid import uuid4 + +import pytest +from pywfrac import WfRacConnectionError + +from homeassistant.components.mitsubishi_wf_rac.config_flow import WfRacConfigFlow +from homeassistant.components.mitsubishi_wf_rac.const import ( + CONF_AIRCO_ID, + DEFAULT_PORT, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from . import AIRCO_ID, HOST, PORT + +from tests.common import MockConfigEntry + +USER_INPUT = {CONF_HOST: HOST, CONF_PORT: PORT} + + +def _discovery_info( + port: int = PORT, host: str = HOST, airco_id: str = AIRCO_ID +) -> ZeroconfServiceInfo: + return ZeroconfServiceInfo( + ip_address=host, + ip_addresses=[host], + hostname=f"{airco_id}.local.", + name=f"{airco_id}._beaver._tcp.local.", + port=port, + type="_beaver._tcp.local.", + properties={}, + ) + + +async def test_user_flow( + hass: HomeAssistant, mock_repository: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """A manually added airco is queried, registered and stored.""" + 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 + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + # Named after the unit, not by the user: the flow does not ask for a name, + # and nothing it stores carries one. Four characters of the airco id are + # enough to tell two units apart without putting the whole one in the + # device name and every entity id built from it. + assert result["title"] == f"WF-RAC {AIRCO_ID[-4:]}" + assert AIRCO_ID not in result["title"] + assert CONF_NAME not in result["data"] + assert result["data"][CONF_AIRCO_ID] == AIRCO_ID + assert result["data"][CONF_HOST] == HOST + mock_repository.update_account_info.assert_awaited_once() + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + (WfRacConnectionError("no route"), "cannot_connect"), + (KeyError("airconId"), "cannot_connect"), + ], +) +async def test_user_flow_connection_errors( + hass: HomeAssistant, + mock_repository: AsyncMock, + mock_setup_entry: AsyncMock, + side_effect: Exception, + error: str, +) -> None: + """An unreachable airco shows the form again, then recovers.""" + mock_repository.get_airco_id.side_effect = side_effect + + 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 + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == error + + mock_repository.get_airco_id.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_user_flow_empty_airco_id( + hass: HomeAssistant, mock_repository: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """A module that answers without an airconId is not usable.""" + mock_repository.get_airco_id.return_value = "" + + 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 + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == "cannot_connect" + + +async def test_user_flow_account_table_full( + hass: HomeAssistant, mock_repository: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """result:2 from updateAccountInfo means no slot is free.""" + mock_repository.update_account_info.return_value = {"result": 2} + + 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 + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == "too_many_devices_registered" + + +async def test_user_flow_registration_answer_without_a_result_code( + hass: HomeAssistant, mock_repository: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """A module that answers registration without a result code is unreachable. + + Reading the code straight out of the answer would end the flow as an + unexpected error instead of one the form can explain. + """ + mock_repository.update_account_info.return_value = {"unexpected": "shape"} + + 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 + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == "cannot_connect" + + +async def test_user_flow_registration_refused( + hass: HomeAssistant, mock_repository: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """An empty registration response is treated as a failed connection.""" + mock_repository.update_account_info.return_value = {} + + 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 + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == "cannot_connect" + + +async def test_user_flow_input_validation( + hass: HomeAssistant, + mock_repository: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """The host is checked before the airco is contacted. + + The error lands on its own field rather than on the form as a whole. + """ + 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_HOST: "ab"} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"][CONF_HOST] == "invalid_host" + + +async def test_user_flow_duplicate_host( + hass: HomeAssistant, + mock_repository: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """A second entry on the same address is refused unless forced.""" + 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 + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"][CONF_HOST] == "host_already_configured" + + +async def test_zeroconf_flow( + hass: HomeAssistant, mock_repository: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """A discovered airco only needs a name.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=_discovery_info() + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PORT: PORT} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_AIRCO_ID] == AIRCO_ID + + +async def test_zeroconf_flow_port_fallback( + hass: HomeAssistant, mock_repository: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """An announced port the module does not serve falls back to 51443. + + Only the announced value is second-guessed; the entry is stored with the + port that actually answered. + """ + mock_repository.get_airco_id.side_effect = [WfRacConnectionError("x"), AIRCO_ID] + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=_discovery_info(port=5353) + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PORT: 5353} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_PORT] == DEFAULT_PORT + + +async def test_zeroconf_flow_already_configured( + hass: HomeAssistant, + mock_repository: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """A rediscovered airco aborts and refreshes the stored address. + + The address only: an announcement carrying 5353 - the mDNS port itself, + in the SRV record where the API port belongs - would otherwise be + written into a working entry and take it offline. The port a configured + entry has is the one setup established. + """ + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=_discovery_info(port=5353, host="192.168.1.9"), + ) + + assert result["type"] is FlowResultType.ABORT + assert mock_config_entry.data[CONF_HOST] == "192.168.1.9" + assert mock_config_entry.data[CONF_PORT] == PORT + + +async def test_a_shouted_hostname_still_matches_the_entry( + hass: HomeAssistant, + mock_repository: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """The unique id is one case, whoever supplied it. + + Discovery takes it from the announced hostname and every other path from + the airconId the unit reports. Compared as they arrive, a difference in + case would offer a configured unit as a new discovery and never refresh + its address. + """ + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=_discovery_info(host="192.168.1.9", airco_id=AIRCO_ID.upper()), + ) + + assert result["type"] is FlowResultType.ABORT + assert mock_config_entry.data[CONF_HOST] == "192.168.1.9" + + +async def test_zeroconf_flow_port_fallback_also_fails( + hass: HomeAssistant, mock_repository: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """When 51443 does not answer either, the fallback stops guessing.""" + mock_repository.get_airco_id.side_effect = WfRacConnectionError("no route") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=_discovery_info(port=5353) + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PORT: 5353} + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == "cannot_connect" + + +@pytest.mark.parametrize( + ("source", "discovery", "user_input"), + [ + pytest.param(SOURCE_USER, None, USER_INPUT, id="manual"), + pytest.param( + SOURCE_ZEROCONF, + _discovery_info(), + {CONF_PORT: PORT}, + id="discovered", + ), + ], +) +async def test_unexpected_error_is_shown_not_raised( + hass: HomeAssistant, + mock_repository: AsyncMock, + mock_setup_entry: AsyncMock, + source: str, + discovery: ZeroconfServiceInfo | None, + user_input: dict[str, Any], +) -> None: + """A bug behind the form must not take the whole flow down.""" + mock_repository.get_airco_id.side_effect = RuntimeError("boom") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": source}, data=discovery + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == "unexpected_error" + + +async def test_two_discovery_flows_for_one_airco_match( + hass: HomeAssistant, mock_repository: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """A second announcement joins the flow already in progress.""" + first = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=_discovery_info() + ) + assert first["type"] is FlowResultType.FORM + + second = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=_discovery_info() + ) + + assert second["type"] is FlowResultType.ABORT + assert second["reason"] == "already_in_progress" + + +async def test_zeroconf_flow_host_taken_by_another_airco( + hass: HomeAssistant, + mock_repository: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """A new airco announcing an address another entry already uses aborts. + + Two entries polling one address is the failure the manual flow's + duplicate-IP switch exists to override; discovery does not offer that. + """ + mock_config_entry.add_to_hass(hass) + + discovery = _discovery_info() + other = ZeroconfServiceInfo( + ip_address=discovery.ip_address, + ip_addresses=discovery.ip_addresses, + hostname="bbccddee1122.local.", + name="bbccddee1122._beaver._tcp.local.", + port=PORT, + type="_beaver._tcp.local.", + properties={}, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=other + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_is_matching_compares_unique_ids(hass: HomeAssistant) -> None: + """Discovery dedup rests on the airco id, and refuses to guess without it.""" + flow = WfRacConfigFlow() + other = WfRacConfigFlow() + + flow.context = {"unique_id": AIRCO_ID} + other.context = {"unique_id": AIRCO_ID} + assert flow.is_matching(other) is True + + other.context = {"unique_id": "bbccddee1122"} + assert flow.is_matching(other) is False + + other.context = {} + assert flow.is_matching(other) is False + + +async def test_user_flow_refuses_a_unit_that_is_already_configured( + hass: HomeAssistant, + mock_repository: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """One unit reached at a second address is not a second airco. + + The manual step has no unique id to abort on, so the identity it matches + on is the airco id the module reports - the duplicate-host check the user + can override does not see this case at all. + """ + 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_HOST: "192.168.1.9"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_the_port_can_be_cleared_and_falls_back_to_the_fixed_one( + hass: HomeAssistant, mock_repository: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """A pre-filled value is a suggestion, and a form field can be emptied. + + The port is read with [] during registration, so without a schema default + an empty field ended the flow in "unexpected_error" rather than using the + port every firmware branch serves. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: HOST} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_PORT] == DEFAULT_PORT + + +async def test_a_retried_submission_keeps_the_identifiers_it_generated( + hass: HomeAssistant, mock_repository: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """The module has four account slots and never frees one by itself. + + A registration whose answer was lost has still taken one. Generating a + fresh operator id per submission would take another on every retry, and + enough retries would leave no slot to set up with. + """ + mock_repository.get_airco_id.side_effect = [WfRacConnectionError("lost"), AIRCO_ID] + + with patch( + "homeassistant.components.mitsubishi_wf_rac.config_flow.uuid4", wraps=uuid4 + ) as generate: + 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 + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + # One operator id and one device id for the whole flow, not a fresh pair + # for every submission. + assert generate.call_count == 2 + + +async def test_a_registration_that_cannot_be_reached_says_so( + hass: HomeAssistant, mock_repository: AsyncMock, mock_setup_entry: AsyncMock +) -> None: + """Registration is a second request, and the unit can go away between them. + + That is the same connection problem as the query before it, and has to + read as one instead of as a bug in the flow. + """ + mock_repository.update_account_info.side_effect = WfRacConnectionError("gone") + + 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 + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"]["base"] == "cannot_connect" diff --git a/tests/components/mitsubishi_wf_rac/test_coordinator.py b/tests/components/mitsubishi_wf_rac/test_coordinator.py new file mode 100644 index 0000000000000..23dfd9dead1d9 --- /dev/null +++ b/tests/components/mitsubishi_wf_rac/test_coordinator.py @@ -0,0 +1,630 @@ +"""Test the Mitsubishi WF-RAC coordinator.""" + +import asyncio +from contextlib import suppress +from datetime import timedelta +import logging +import time +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from pywfrac import ( + Aircon, + AirconStat, + RacParser, + WfRacConnectionError, + WfRacError, + WfRacRegistrationError, + WfRacWriteRefusedError, +) + +from homeassistant.components.climate import ( + ATTR_FAN_MODE, + DOMAIN as CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, +) +from homeassistant.components.mitsubishi_wf_rac.const import DOMAIN +from homeassistant.components.mitsubishi_wf_rac.coordinator import ( + WRITE_LOCK_RETRY_DELAY, + registration_full_issue_id, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed + +DOMAIN_LOGGER = "homeassistant.components.mitsubishi_wf_rac" +ENTITY_ID = "climate.living_room" +POLL = timedelta(seconds=60) + + +async def _advance(hass: HomeAssistant, freezer: FrozenDateTimeFactory, polls: int): + for _ in range(polls): + freezer.tick(POLL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + +async def test_a_missed_poll_does_not_go_unavailable( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_repository: AsyncMock, + init_integration: MockConfigEntry, +) -> None: + """The module reassociates with the WiFi about once an hour on its own. + + Going unavailable on the first missed poll would report an outage every + hour that nobody can act on, so the retry limit has to be spent first. + """ + mock_repository.get_aircon_stats.side_effect = WfRacConnectionError("no route") + + await _advance(hass, freezer, 2) + assert hass.states.get(ENTITY_ID).state != STATE_UNAVAILABLE + + await _advance(hass, freezer, 1) + assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE + + +async def test_the_airco_comes_back( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_repository: AsyncMock, + aircon_stat: dict, + init_integration: MockConfigEntry, +) -> None: + """One good poll is enough to be available again.""" + mock_repository.get_aircon_stats.side_effect = WfRacConnectionError("no route") + await _advance(hass, freezer, 3) + assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE + + mock_repository.get_aircon_stats.side_effect = None + mock_repository.get_aircon_stats.return_value = aircon_stat + await _advance(hass, freezer, 1) + + assert hass.states.get(ENTITY_ID).state != STATE_UNAVAILABLE + + +async def test_an_evicted_account_re_registers_itself( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_repository: AsyncMock, + init_integration: MockConfigEntry, +) -> None: + """Register again after being evicted from the account table. + + Opening the manufacturer's app can push Home Assistant out of it. An + evicted account still answers, so the failure is answered by registering + again rather than by waiting. + """ + mock_repository.update_account_info.reset_mock() + mock_repository.get_aircon_stats.side_effect = KeyError("airconStat") + + await _advance(hass, freezer, 1) + + mock_repository.update_account_info.assert_awaited() + + +async def test_an_unreachable_airco_does_not_re_register( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_repository: AsyncMock, + init_integration: MockConfigEntry, +) -> None: + """Do not re-register over an outage. + + Registering cannot succeed over a connection that is not there, so a plain + outage must not spend a request on it. + """ + mock_repository.update_account_info.reset_mock() + mock_repository.get_aircon_stats.side_effect = WfRacConnectionError("no route") + + await _advance(hass, freezer, 1) + + mock_repository.update_account_info.assert_not_awaited() + + +async def test_a_refused_write_is_retried_once_the_lock_lapses( + hass: HomeAssistant, + mock_repository: AsyncMock, + init_integration: MockConfigEntry, +) -> None: + """Another client's 60-second write lock is waited out, not fought. + + The delay comes from the unit's own `expires`, so the retry lands on the + far side of the lapse instead of at a guessed interval. + """ + aircon_stat = mock_repository.get_aircon_stats.return_value + mock_repository.send_airco_command.side_effect = [ + WfRacWriteRefusedError("locked"), + aircon_stat["airconStat"], + ] + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: "auto"}, + blocking=True, + ) + await hass.async_block_till_done() + + assert mock_repository.send_airco_command.await_count == 2 + + +async def test_the_retry_waits_out_what_is_left_of_the_lock( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + aircon_stat: dict, + mock_repository: AsyncMock, + init_integration: MockConfigEntry, +) -> None: + """The wait comes from the unit's own `expires`, not from a fixed interval. + + Getting the arithmetic wrong is invisible in the retry count: the command + is sent either way, just back into a lock that has not lapsed yet. + """ + freezer.move_to("2026-09-06T12:00:00+00:00") + aircon_stat["expires"] = int(dt_util.utcnow().timestamp()) + 20 + mock_repository.send_airco_command.side_effect = [ + WfRacWriteRefusedError("locked"), + aircon_stat["airconStat"], + ] + + with patch( + "homeassistant.components.mitsubishi_wf_rac.coordinator.asyncio.sleep" + ) as sleep: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: "auto"}, + blocking=True, + ) + await hass.async_block_till_done() + + # 20 seconds left on the lock, plus the second that puts the retry on the + # far side of the lapse. + assert 21 in [call.args[0] for call in sleep.await_args_list] + + +async def test_an_evicted_account_re_registers_before_retrying( + hass: HomeAssistant, + mock_repository: AsyncMock, + init_integration: MockConfigEntry, +) -> None: + """Losing the account slot mid-command costs a registration, not the command.""" + aircon_stat = mock_repository.get_aircon_stats.return_value + mock_repository.send_airco_command.side_effect = [ + WfRacRegistrationError("evicted"), + aircon_stat["airconStat"], + ] + mock_repository.update_account_info.reset_mock() + + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: "auto"}, + blocking=True, + ) + await hass.async_block_till_done() + + mock_repository.update_account_info.assert_awaited() + assert mock_repository.send_airco_command.await_count == 2 + + +async def test_a_full_account_table_raises_a_repair_issue( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_repository: AsyncMock, + init_integration: MockConfigEntry, +) -> None: + """result:2 means the module has no free account slot left. + + Nothing the integration can do about it from here, so it says so in + Repairs rather than retrying forever. + """ + device = init_integration.runtime_data.device + mock_repository.update_account_info.return_value = {"result": 2} + + await device.add_account() + + assert issue_registry.async_get_issue( + DOMAIN, registration_full_issue_id(init_integration.entry_id) + ) + + +async def test_a_freed_account_table_clears_the_repair_issue( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, + mock_repository: AsyncMock, + init_integration: MockConfigEntry, +) -> None: + """The issue must not outlive the condition that raised it.""" + device = init_integration.runtime_data.device + mock_repository.update_account_info.return_value = {"result": 2} + await device.add_account() + + mock_repository.update_account_info.return_value = {"result": 0} + await device.add_account() + + assert not issue_registry.async_get_issue( + DOMAIN, registration_full_issue_id(init_integration.entry_id) + ) + + +@pytest.mark.parametrize( + ("method", "mocked"), + [("add_account", "update_account_info"), ("delete_account", "del_account_info")], +) +async def test_account_calls_swallow_their_errors( + hass: HomeAssistant, + mock_repository: AsyncMock, + init_integration: MockConfigEntry, + method: str, + mocked: str, +) -> None: + """Both run on paths that have nothing better to do with a failure.""" + device = init_integration.runtime_data.device + getattr(mock_repository, mocked).side_effect = WfRacError("no answer") + + assert await getattr(device, method)() is None + + +async def test_unparseable_data_marks_the_airco_unavailable( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_repository: AsyncMock, + init_integration: MockConfigEntry, +) -> None: + """A frame that answers but does not parse is a failed poll like any other.""" + mock_repository.get_aircon_stats.return_value = {"airconStat": "not base64"} + + await _advance(hass, freezer, 3) + + assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE + + +async def test_a_poll_that_never_answers_counts_as_a_missed_poll( + hass: HomeAssistant, + mock_repository: AsyncMock, + init_integration: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """The outer deadline can expire before the request's own does. + + That has to stay as quiet as any other missed poll, or a transient outage + would mark the airco unavailable ahead of the configured threshold - and + it must not become an update failure either, which is the difference + between this and any other exception leaving the poll. + """ + + async def _never_answers(*args: object, **kwargs: object) -> None: + await asyncio.sleep(3600) + + mock_repository.get_aircon_stats.side_effect = _never_answers + + caplog.set_level(logging.DEBUG) + + with patch( + "homeassistant.components.mitsubishi_wf_rac.coordinator.POLL_TIMEOUT", + timedelta(seconds=0), + ): + await init_integration.runtime_data.device.async_refresh() + + assert "did not answer within 0s" in caplog.text + assert hass.states.get(ENTITY_ID).state != STATE_UNAVAILABLE + assert init_integration.runtime_data.device.last_update_success + + +async def test_a_poll_that_fails_unexpectedly_is_an_update_failure( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_repository: AsyncMock, + init_integration: MockConfigEntry, +) -> None: + """Only the expected failures are ridden out quietly. + + update() answers the ones this module knows about itself; anything else + reaching the poll is a fault rather than the hourly reassociation, and has + to be reported as one instead of being swallowed. + """ + mock_repository.get_aircon_stats.side_effect = RuntimeError("boom") + + await _advance(hass, freezer, 1) + + assert not init_integration.runtime_data.device.last_update_success + + +@pytest.mark.parametrize( + ("stats", "side_effect"), + [ + pytest.param(None, WfRacError("no answer"), id="unit_does_not_answer"), + pytest.param({"airconId": "0011223344aa"}, None, id="no_expires_reported"), + ], +) +async def test_a_refused_write_falls_back_when_the_deadline_is_unreadable( + hass: HomeAssistant, + mock_repository: AsyncMock, + init_integration: MockConfigEntry, + stats: dict | None, + side_effect: Exception | None, +) -> None: + """Without a readable deadline the retry waits the fixed interval. + + The lock in the way was taken after the last poll, so the only deadline + worth having comes from asking again - and when that answer is unusable + there is nothing left to compute a wait from. + """ + aircon_stat = mock_repository.get_aircon_stats.return_value + mock_repository.send_airco_command.side_effect = [ + WfRacWriteRefusedError("locked"), + aircon_stat["airconStat"], + ] + mock_repository.get_aircon_stats.return_value = stats + mock_repository.get_aircon_stats.side_effect = side_effect + + with patch( + "homeassistant.components.mitsubishi_wf_rac.coordinator.asyncio.sleep" + ) as sleep: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: "auto"}, + blocking=True, + ) + await hass.async_block_till_done() + + assert WRITE_LOCK_RETRY_DELAY.total_seconds() in [ + call.args[0] for call in sleep.await_args_list + ] + + +async def test_shutdown_waits_for_a_command_already_on_the_wire( + hass: HomeAssistant, + mock_repository: AsyncMock, + init_integration: MockConfigEntry, +) -> None: + """A flush that has taken its parameters still has to be shut down. + + It lets go of _consolidation_task at that point so a later command opens + its own window, which used to leave shutdown with nothing to cancel: the + send finished afterwards and published to entities that were gone. The + module accepts one connection at a time and an unload is usually followed + by a reload, so the orphan collides with the coordinator replacing it. + """ + device = init_integration.runtime_data.device + on_the_wire = asyncio.Event() + + async def _never_returns(*args: object, **kwargs: object) -> None: + on_the_wire.set() + await asyncio.Event().wait() + + with patch.object(device, "set_airco", side_effect=_never_returns): + caller = asyncio.create_task( + hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: "auto"}, + blocking=True, + ) + ) + await asyncio.wait_for(on_the_wire.wait(), timeout=5) + + await device.async_shutdown() + + assert not device._running_flushes + caller.cancel() + with suppress(asyncio.CancelledError): + await caller + + +async def test_an_unreadable_frame_survives_the_polls_that_carry_it( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_repository: AsyncMock, + init_integration: MockConfigEntry, +) -> None: + """The poll delivering a bad frame is itself a success. + + So it resets the device's missed-poll counter before the entities read + the frame. An entity that counted its own decoding failure into that + counter could never reach the threshold, and would keep reporting stale + state as current however long the condition lasted. + """ + device = init_integration.runtime_data.device + decode = device._parser.translate_bytes + + def _unreadable(raw: str) -> Aircon: + airco = decode(raw) + airco.OperationMode = 99 + return airco + + with patch.object(device._parser, "translate_bytes", side_effect=_unreadable): + await _advance(hass, freezer, 3) + + assert device.available + assert hass.states.get(ENTITY_ID).state == STATE_UNKNOWN + + +async def test_an_unexpected_poll_failure_takes_the_entities_with_it( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_repository: AsyncMock, + init_integration: MockConfigEntry, +) -> None: + """A missed poll is ridden out; a fault is not. + + Only the expected failures leave the coordinator successful, so an + entity that reads Device.available alone would keep showing stale state + as current after an UpdateFailed. + """ + mock_repository.get_aircon_stats.side_effect = RuntimeError("boom") + + await _advance(hass, freezer, 1) + + assert not init_integration.runtime_data.device.last_update_success + assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE + + +async def test_a_retried_write_does_not_revert_the_client_it_waited_for( + hass: HomeAssistant, + mock_repository: AsyncMock, + init_integration: MockConfigEntry, +) -> None: + """The frame is a full state block, not a delta. + + A refusal means another client holds the write lock, and by the time it + lapses that client has changed something. Re-sending the block encoded + before the refusal would send every one of those fields back as it was. + """ + aircon_stat = mock_repository.get_aircon_stats.return_value + theirs = RacParser().translate_bytes(aircon_stat["airconStat"]) + theirs.PresetTemp = 27.0 + mock_repository.get_aircon_stats.return_value = { + **aircon_stat, + "airconStat": RacParser().to_base64(AirconStat.from_aircon(theirs)), + "expires": int(time.time()), + } + mock_repository.send_airco_command.side_effect = [ + WfRacWriteRefusedError("locked"), + aircon_stat["airconStat"], + ] + + with patch("homeassistant.components.mitsubishi_wf_rac.coordinator.asyncio.sleep"): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: "auto"}, + blocking=True, + ) + await hass.async_block_till_done() + + retried = RacParser().translate_bytes( + mock_repository.send_airco_command.await_args.args[1] + ) + assert retried.PresetTemp == 27.0 + + +async def test_a_deadline_that_is_not_a_timestamp_falls_back_too( + hass: HomeAssistant, + mock_repository: AsyncMock, + init_integration: MockConfigEntry, +) -> None: + """The answer is usable, its deadline is not. + + Split from the cases above because this one needs a frame the retry can + still be encoded from - only the deadline is unreadable. + """ + aircon_stat = mock_repository.get_aircon_stats.return_value + mock_repository.get_aircon_stats.return_value = {**aircon_stat, "expires": "soon"} + mock_repository.send_airco_command.side_effect = [ + WfRacWriteRefusedError("locked"), + aircon_stat["airconStat"], + ] + + with patch( + "homeassistant.components.mitsubishi_wf_rac.coordinator.asyncio.sleep" + ) as sleep: + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: "auto"}, + blocking=True, + ) + await hass.async_block_till_done() + + assert WRITE_LOCK_RETRY_DELAY.total_seconds() in [ + call.args[0] for call in sleep.await_args_list + ] + + +async def test_a_command_issued_during_a_poll_waits_for_what_it_brings( + hass: HomeAssistant, + mock_repository: AsyncMock, + init_integration: MockConfigEntry, +) -> None: + """A poll on the wire is about to replace the state a command builds from. + + The frame is a full state block and the module takes one connection at a + time, so a command encoded before that poll lands would queue behind it + and then put every field back the way it was - undoing whatever the app + or the remote had just changed. The same revert as on the refusal path, + on the path a poll opens. + """ + original = mock_repository.get_aircon_stats.return_value + theirs = RacParser().translate_bytes(original["airconStat"]) + theirs.PresetTemp = 27.0 + fresh = { + **original, + "airconStat": RacParser().to_base64(AirconStat.from_aircon(theirs)), + } + + polling = asyncio.Event() + let_the_poll_answer = asyncio.Event() + + async def _poll_in_flight(*args: object, **kwargs: object) -> dict: + polling.set() + await let_the_poll_answer.wait() + return fresh + + mock_repository.get_aircon_stats.side_effect = _poll_in_flight + mock_repository.send_airco_command.return_value = fresh["airconStat"] + + poll = asyncio.create_task(init_integration.runtime_data.device.async_refresh()) + await asyncio.wait_for(polling.wait(), timeout=5) + + # Without the consolidation window the command reaches the point where it + # encodes right away, which is what has to happen while the poll is still + # on the wire for this to say anything. + with patch( + "homeassistant.components.mitsubishi_wf_rac.coordinator.UPDATE_CONSOLIDATION_PERIOD", + timedelta(0), + ): + command = asyncio.create_task( + hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: "auto"}, + blocking=True, + ) + ) + for _ in range(10): + await asyncio.sleep(0) + + let_the_poll_answer.set() + await poll + await command + await hass.async_block_till_done() + + sent = RacParser().translate_bytes( + mock_repository.send_airco_command.await_args.args[1] + ) + assert sent.PresetTemp == 27.0 + + +async def test_an_evicted_account_is_reported_once_not_every_minute( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_repository: AsyncMock, + init_integration: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Being dropped from the account table is one outage, not one per poll. + + The unit answers throughout, so re-registration is attempted on every + poll - and if the table is full it cannot succeed. Saying so once a + minute for as long as that lasts buries the line that matters. + """ + mock_repository.get_aircon_stats.side_effect = WfRacError("result 2") + mock_repository.update_account_info.side_effect = WfRacError("table full") + caplog.set_level(logging.INFO) + + await _advance(hass, freezer, 6) + + assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE + outage = [r for r in caplog.records if "is unavailable after" in r.message] + assert len(outage) == 1 + assert outage[0].levelno == logging.INFO + ours = [r for r in caplog.records if r.name.startswith(DOMAIN_LOGGER)] + assert not [r for r in ours if r.levelno >= logging.WARNING] diff --git a/tests/components/mitsubishi_wf_rac/test_init.py b/tests/components/mitsubishi_wf_rac/test_init.py new file mode 100644 index 0000000000000..d3451622ea434 --- /dev/null +++ b/tests/components/mitsubishi_wf_rac/test_init.py @@ -0,0 +1,238 @@ +"""Test the Mitsubishi WF-RAC setup, unload and migrations.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from pywfrac import WfRacConnectionError, WfRacError + +from homeassistant.components.mitsubishi_wf_rac.const import ( + CONF_AIRCO_ID, + CONF_OPERATOR_ID, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_DEVICE_ID, CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import AIRCO_ID, ENTRY_DATA, ENTRY_OPTIONS, HOST, PORT + +from tests.common import MockConfigEntry + + +async def test_setup_and_unload( + hass: HomeAssistant, init_integration: MockConfigEntry +) -> None: + """A reachable airco loads, and unloading releases the coordinator.""" + assert init_integration.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(init_integration.entry_id) + await hass.async_block_till_done() + assert init_integration.state is ConfigEntryState.NOT_LOADED + + +async def test_setup_retries_when_unreachable( + hass: HomeAssistant, + mock_repository: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Retry rather than load half an entry. + + An airco that does not answer at startup gets Home Assistant's automatic + retry instead of a "loaded" entry with no working entities. + """ + mock_repository.get_aircon_stats.side_effect = WfRacConnectionError("no route") + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_device_registry_entry( + hass: HomeAssistant, + init_integration: MockConfigEntry, + device_registry: dr.DeviceRegistry, +) -> None: + """The airco registers with its MAC, and without a model name. + + ModelNr is a capability grouping rather than a type name, so it goes into + model_id; "model" staying empty is the point of the assertion. + """ + device = device_registry.async_get_device_by_identifier( + (DOMAIN, AIRCO_ID), init_integration.entry_id + ) + + assert device is not None + assert device.connections == {(dr.CONNECTION_NETWORK_MAC, "00:11:22:33:44:aa")} + assert device.model is None + assert device.model_id == "1" + assert device.sw_version == "WF-RAC-HTTPS, mcu: 200, wireless: 025" + + +async def test_remove_entry_releases_the_account_slot( + hass: HomeAssistant, + mock_repository: AsyncMock, + init_integration: MockConfigEntry, +) -> None: + """The module keeps a small table of controllers; removal frees ours.""" + await hass.config_entries.async_remove(init_integration.entry_id) + await hass.async_block_till_done() + + mock_repository.del_account_info.assert_awaited_with(AIRCO_ID) + + +async def test_migration_from_version_1( + hass: HomeAssistant, mock_repository: AsyncMock +) -> None: + """A v1 entry moves its host into options and gains retry tolerance. + + Entries this old exist in the wild through the custom-component release of + this integration, which shares this domain. + """ + entry = MockConfigEntry( + domain=DOMAIN, + title="Living room", + data={ + CONF_NAME: "Living room", + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_DEVICE_ID: ENTRY_DATA[CONF_DEVICE_ID], + CONF_OPERATOR_ID: ENTRY_DATA[CONF_OPERATOR_ID], + CONF_AIRCO_ID: AIRCO_ID, + }, + unique_id=AIRCO_ID, + version=1, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.version == 7 + assert entry.state is ConfigEntryState.LOADED + assert entry.data[CONF_HOST] == HOST + assert CONF_HOST not in entry.options + # v1 entries ran with no tolerance at all; the module reassociates hourly. + assert entry.options["availability_retry_limit"] == 3 + + +async def test_migration_lifts_a_retry_limit_below_the_floor( + hass: HomeAssistant, mock_repository: AsyncMock +) -> None: + """A stored limit under the minimum is raised rather than refused.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Living room", + data=ENTRY_DATA, + options={**ENTRY_OPTIONS, "availability_retry_limit": 1}, + unique_id=AIRCO_ID, + version=4, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.version == 7 + assert entry.options["availability_retry_limit"] == 3 + + +async def test_migration_lifts_a_retry_limit_the_old_toggle_left_behind( + hass: HomeAssistant, mock_repository: AsyncMock +) -> None: + """A v3 entry that ran with no tolerance at all gets some. + + The v1 -> v2 step set the availability check to False while the flag was + dead code, so these entries went unavailable on the first missed poll - + which the module's hourly reassociation produces on its own. + """ + entry = MockConfigEntry( + domain=DOMAIN, + title="Living room", + data=ENTRY_DATA, + options={"availability_retry_limit": 1, "availability_retry": 1}, + unique_id=AIRCO_ID, + version=3, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.version == 7 + assert entry.options["availability_retry_limit"] == 3 + # The key nothing ever read is gone with the step that wrote it. + assert "availability_retry" not in entry.options + + +async def test_a_failed_platform_unload_keeps_the_coordinator( + hass: HomeAssistant, + init_integration: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Entities that stayed loaded must keep the coordinator that feeds them. + + Shutting it down anyway would leave a loaded entry that never updates + again. + """ + device = init_integration.runtime_data.device + + with patch.object( + hass.config_entries, "async_unload_platforms", return_value=False + ): + assert not await hass.config_entries.async_unload(init_integration.entry_id) + await hass.async_block_till_done() + + assert "Failed to unload entry" in caplog.text + assert device.last_update_success + + +async def test_removal_says_so_when_the_slot_is_not_released( + hass: HomeAssistant, + mock_repository: AsyncMock, + init_integration: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """The module keeps a small account table, and it can refuse to free ours. + + Nothing here can fix that - the slot has to be freed from the official + app - so the removal goes through and says what was left behind. + """ + mock_repository.del_account_info.side_effect = WfRacError("no answer") + + await hass.config_entries.async_remove(init_integration.entry_id) + await hass.async_block_till_done() + + assert "Could not release the controller slot" in caplog.text + # Kept out of the message on purpose: a log this ends up in is usually + # attached to an issue report. + assert ENTRY_DATA[CONF_OPERATOR_ID] not in caplog.text + + +async def test_migration_gives_a_hand_added_entry_the_identity_discovery_uses( + hass: HomeAssistant, mock_repository: AsyncMock +) -> None: + """Entries added by hand never registered one. + + The manual step checked for a duplicate airco itself instead, so zeroconf + could not recognise the entry: a unit that moved was offered as a new + discovery and its address was never refreshed. The module announces + itself as .local and the airco id is that same MAC. + """ + entry = MockConfigEntry( + domain=DOMAIN, + title="Living room", + data=ENTRY_DATA, + options=ENTRY_OPTIONS, + unique_id=None, + version=6, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.version == 7 + assert entry.unique_id == AIRCO_ID