diff --git a/CODEOWNERS b/CODEOWNERS index ee0d64341ec405..76e02ea3546de4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1092,6 +1092,8 @@ CLAUDE.md @home-assistant/core /tests/components/minecraft_server/ @elmurato @zachdeibert /homeassistant/components/minio/ @tkislan /tests/components/minio/ @tkislan +/homeassistant/components/mitsubishi_comfort/ @nikolairahimi +/tests/components/mitsubishi_comfort/ @nikolairahimi /homeassistant/components/moat/ @bdraco /tests/components/moat/ @bdraco /homeassistant/components/mobile_app/ @home-assistant/core diff --git a/homeassistant/components/husqvarna_automower/manifest.json b/homeassistant/components/husqvarna_automower/manifest.json index 203aa52be56103..51b0eee72f6daf 100644 --- a/homeassistant/components/husqvarna_automower/manifest.json +++ b/homeassistant/components/husqvarna_automower/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_push", "loggers": ["aioautomower"], "quality_scale": "silver", - "requirements": ["aioautomower==2.7.4"] + "requirements": ["aioautomower==2.7.5"] } diff --git a/homeassistant/components/mitsubishi_comfort/__init__.py b/homeassistant/components/mitsubishi_comfort/__init__.py new file mode 100644 index 00000000000000..780c1cb1deb15b --- /dev/null +++ b/homeassistant/components/mitsubishi_comfort/__init__.py @@ -0,0 +1,97 @@ +"""Mitsubishi Comfort integration for Home Assistant.""" + +from __future__ import annotations + +import asyncio +import logging + +from mitsubishi_comfort import ( + DeviceInfo, + IndoorUnit, + KumoStation, + MitsubishiCloudAccount, +) +from mitsubishi_comfort.exceptions import AuthenticationError, DeviceConnectionError + +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DEFAULT_CONNECT_TIMEOUT, DEFAULT_RESPONSE_TIMEOUT, DOMAIN, PLATFORMS +from .coordinator import MitsubishiComfortConfigEntry, MitsubishiComfortCoordinator + +_LOGGER = logging.getLogger(__name__) + + +def _make_device( + info: DeviceInfo, + serial: str, + session, +) -> IndoorUnit | KumoStation: + """Create the appropriate device instance from DeviceInfo.""" + cls = IndoorUnit if info.is_indoor_unit else KumoStation + return cls( + name=info.label, + address=info.address, + password_b64=info.password, + crypto_serial_hex=info.crypto_serial, + serial=serial, + connect_timeout=DEFAULT_CONNECT_TIMEOUT, + response_timeout=DEFAULT_RESPONSE_TIMEOUT, + session=session, + ) + + +async def async_setup_entry( + hass: HomeAssistant, entry: MitsubishiComfortConfigEntry +) -> bool: + """Set up Mitsubishi Comfort from a config entry.""" + session = async_get_clientsession(hass) + account = MitsubishiCloudAccount( + entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], session=session + ) + + try: + await account.login() + devices = await account.discover_devices() + except AuthenticationError as err: + raise ConfigEntryError("Mitsubishi cloud authentication failed") from err + except DeviceConnectionError as err: + raise ConfigEntryNotReady("Cannot reach Mitsubishi cloud") from err + + if not devices: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="no_devices", + ) + + coordinators: dict[str, MitsubishiComfortCoordinator] = {} + for serial, info in devices.items(): + if not info.address or not info.password or not info.crypto_serial: + _LOGGER.warning("Device %s missing credentials, skipping", info.label) + continue + device = _make_device(info, serial, session) + coordinators[serial] = MitsubishiComfortCoordinator( + hass, entry, device, info.mac + ) + + await asyncio.gather( + *(c.async_config_entry_first_refresh() for c in coordinators.values()) + ) + + entry.runtime_data = coordinators + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: MitsubishiComfortConfigEntry +) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + await asyncio.gather( + *(c.device.close() for c in entry.runtime_data.values()), + return_exceptions=True, + ) + return unload_ok diff --git a/homeassistant/components/mitsubishi_comfort/climate.py b/homeassistant/components/mitsubishi_comfort/climate.py new file mode 100644 index 00000000000000..22b3bd1e2bad4d --- /dev/null +++ b/homeassistant/components/mitsubishi_comfort/climate.py @@ -0,0 +1,289 @@ +"""Climate entity for Mitsubishi Comfort integration.""" + +from __future__ import annotations + +from typing import Any + +from mitsubishi_comfort import FanSpeed, IndoorUnit, Mode, VaneDirection + +from homeassistant.components.climate import ( + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ClimateEntity, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import MitsubishiComfortConfigEntry, MitsubishiComfortCoordinator +from .entity import MitsubishiComfortEntity + +_MODE_TO_HVAC: dict[str, HVACMode] = { + "off": HVACMode.OFF, + "cool": HVACMode.COOL, + "heat": HVACMode.HEAT, + "dry": HVACMode.DRY, + "vent": HVACMode.FAN_ONLY, + "auto": HVACMode.HEAT_COOL, + "autoCool": HVACMode.HEAT_COOL, + "autoHeat": HVACMode.HEAT_COOL, +} + +_HVAC_TO_MODE: dict[HVACMode, Mode] = { + HVACMode.OFF: Mode.OFF, + HVACMode.COOL: Mode.COOL, + HVACMode.HEAT: Mode.HEAT, + HVACMode.DRY: Mode.DRY, + HVACMode.FAN_ONLY: Mode.FAN, + HVACMode.HEAT_COOL: Mode.AUTO, +} + +_LIB_MODE_TO_HVAC: dict[Mode, HVACMode] = {v: k for k, v in _HVAC_TO_MODE.items()} + +_MODE_TO_ACTION: dict[str, HVACAction] = { + "off": HVACAction.OFF, + "cool": HVACAction.COOLING, + "heat": HVACAction.HEATING, + "dry": HVACAction.DRYING, + "vent": HVACAction.FAN, + "auto": HVACAction.IDLE, + "autoCool": HVACAction.COOLING, + "autoHeat": HVACAction.HEATING, +} + +_FAN_SPEED_MAP: dict[str, FanSpeed] = {s.value: s for s in FanSpeed} +_VANE_DIR_MAP: dict[str, VaneDirection] = {d.value: d for d in VaneDirection} + +_OPT_MODE = "mode" +_OPT_COOL_SETPOINT = "cool_setpoint" +_OPT_HEAT_SETPOINT = "heat_setpoint" +_OPT_FAN_SPEED = "fan_speed" +_OPT_VANE_DIRECTION = "vane_direction" + + +async def async_setup_entry( + hass: HomeAssistant, + entry: MitsubishiComfortConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Mitsubishi Comfort climate entities.""" + coordinators = entry.runtime_data + async_add_entities( + MitsubishiComfortClimate(coordinator) + for coordinator in coordinators.values() + if isinstance(coordinator.device, IndoorUnit) + ) + + +class MitsubishiComfortClimate(MitsubishiComfortEntity, ClimateEntity): + """Climate entity for a Mitsubishi indoor unit.""" + + _attr_name = None + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _enable_turn_on_off_backwards_compatibility = False + + def __init__(self, coordinator: MitsubishiComfortCoordinator) -> None: + """Initialize.""" + super().__init__(coordinator) + self._attr_unique_id = self._device.serial + self._optimistic: dict[str, Any] = {} + + def _handle_coordinator_update(self) -> None: + """Clear optimistic state when real data arrives from device.""" + self._optimistic.clear() + super()._handle_coordinator_update() + + @property + def _effective_mode(self) -> str | None: + return self._optimistic.get(_OPT_MODE, self._device.status.mode) + + @property + def hvac_mode(self) -> HVACMode | None: + """Return the current HVAC mode.""" + mode = self._effective_mode + return _MODE_TO_HVAC.get(mode) if mode else None + + @property + def hvac_action(self) -> HVACAction | None: + """Return the current HVAC action.""" + mode = self._effective_mode + if mode and self._device.status.standby: + return HVACAction.IDLE + return _MODE_TO_ACTION.get(mode) if mode else None + + @property + def hvac_modes(self) -> list[HVACMode]: + """Return the list of available HVAC modes.""" + return [ + _LIB_MODE_TO_HVAC[m] + for m in self._device.supported_modes + if m in _LIB_MODE_TO_HVAC + ] + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + return self._device.status.room_temperature + + @property + def current_humidity(self) -> float | None: + """Return the current humidity.""" + return self._device.status.current_humidity + + @property + def target_temperature(self) -> float | None: + """Return the target temperature.""" + mode = self._effective_mode + if mode in ("cool", "autoCool"): + return self._optimistic.get( + _OPT_COOL_SETPOINT, self._device.status.cool_setpoint + ) + if mode in ("heat", "autoHeat"): + return self._optimistic.get( + _OPT_HEAT_SETPOINT, self._device.status.heat_setpoint + ) + return None + + @property + def target_temperature_high(self) -> float | None: + """Return the upper bound target temperature.""" + if self._effective_mode in ("auto", "autoCool", "autoHeat"): + return self._optimistic.get( + _OPT_COOL_SETPOINT, self._device.status.cool_setpoint + ) + return None + + @property + def target_temperature_low(self) -> float | None: + """Return the lower bound target temperature.""" + if self._effective_mode in ("auto", "autoCool", "autoHeat"): + return self._optimistic.get( + _OPT_HEAT_SETPOINT, self._device.status.heat_setpoint + ) + return None + + @property + def fan_mode(self) -> str | None: + """Return the current fan mode.""" + return self._optimistic.get(_OPT_FAN_SPEED, self._device.status.fan_speed) + + @property + def fan_modes(self) -> list[str]: + """Return the list of available fan modes.""" + return [s.value for s in self._device.supported_fan_speeds] + + @property + def swing_mode(self) -> str | None: + """Return the current swing mode.""" + return self._optimistic.get( + _OPT_VANE_DIRECTION, self._device.status.vane_direction + ) + + @property + def swing_modes(self) -> list[str]: + """Return the list of available swing modes.""" + return [d.value for d in self._device.supported_vane_directions] + + @property + def min_temp(self) -> float: + """Return the minimum temperature.""" + if self._effective_mode in ("heat", "autoHeat"): + if self._device.status.min_heat_setpoint is not None: + return self._device.status.min_heat_setpoint + if self._device.status.min_cool_setpoint is not None: + return self._device.status.min_cool_setpoint + return super().min_temp + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + if self._effective_mode in ("heat", "autoHeat"): + if self._device.status.max_heat_setpoint is not None: + return self._device.status.max_heat_setpoint + if self._device.status.max_cool_setpoint is not None: + return self._device.status.max_cool_setpoint + return super().max_temp + + @property + def supported_features(self) -> ClimateEntityFeature: + """Return the list of supported features.""" + features = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.TURN_OFF + ) + if Mode.AUTO in self._device.supported_modes: + features |= ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + if self._device.supported_vane_directions: + features |= ClimateEntityFeature.SWING_MODE + return features + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set the HVAC mode.""" + lib_mode = _HVAC_TO_MODE.get(hvac_mode) + if lib_mode is None: + return + result = await self._device.set_mode(lib_mode) + if result.success: + self._optimistic[_OPT_MODE] = result.value + self.async_write_ha_state() + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set the target temperature.""" + mode = self._effective_mode + wrote = False + + if ATTR_TARGET_TEMP_HIGH in kwargs: + result = await self._device.set_cool_setpoint(kwargs[ATTR_TARGET_TEMP_HIGH]) + if result.success: + self._optimistic[_OPT_COOL_SETPOINT] = result.value + wrote = True + + if ATTR_TARGET_TEMP_LOW in kwargs: + result = await self._device.set_heat_setpoint(kwargs[ATTR_TARGET_TEMP_LOW]) + if result.success: + self._optimistic[_OPT_HEAT_SETPOINT] = result.value + wrote = True + + temp = kwargs.get(ATTR_TEMPERATURE) + if temp is not None: + if mode in ("cool", "autoCool"): + result = await self._device.set_cool_setpoint(temp) + if result.success: + self._optimistic[_OPT_COOL_SETPOINT] = result.value + wrote = True + elif mode in ("heat", "autoHeat"): + result = await self._device.set_heat_setpoint(temp) + if result.success: + self._optimistic[_OPT_HEAT_SETPOINT] = result.value + wrote = True + + if wrote: + self.async_write_ha_state() + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set the fan mode.""" + speed = _FAN_SPEED_MAP.get(fan_mode) + if speed is None: + return + result = await self._device.set_fan_speed(speed) + if result.success: + self._optimistic[_OPT_FAN_SPEED] = result.value + self.async_write_ha_state() + + async def async_set_swing_mode(self, swing_mode: str) -> None: + """Set the swing mode.""" + direction = _VANE_DIR_MAP.get(swing_mode) + if direction is None: + return + result = await self._device.set_vane_direction(direction) + if result.success: + self._optimistic[_OPT_VANE_DIRECTION] = result.value + self.async_write_ha_state() + + async def async_turn_off(self) -> None: + """Turn the entity off.""" + await self.async_set_hvac_mode(HVACMode.OFF) diff --git a/homeassistant/components/mitsubishi_comfort/config_flow.py b/homeassistant/components/mitsubishi_comfort/config_flow.py new file mode 100644 index 00000000000000..f0175547a760bf --- /dev/null +++ b/homeassistant/components/mitsubishi_comfort/config_flow.py @@ -0,0 +1,75 @@ +"""Config flow for Mitsubishi Comfort integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from mitsubishi_comfort import MitsubishiCloudAccount +from mitsubishi_comfort.exceptions import AuthenticationError, DeviceConnectionError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +USER_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class MitsubishiComfortConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle config flow for Mitsubishi Comfort.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the user setup step.""" + errors: dict[str, str] = {} + + if user_input is not None: + account = MitsubishiCloudAccount( + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + session=async_get_clientsession(self.hass), + ) + + devices: dict = {} + try: + await account.login() + devices = await account.discover_devices() + except AuthenticationError: + errors["base"] = "invalid_auth" + except DeviceConnectionError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected error during setup") + errors["base"] = "unknown" + + if not errors: + await self.async_set_unique_id(account.user_id) + self._abort_if_unique_id_configured() + + if not devices: + errors["base"] = "no_devices" + else: + return self.async_create_entry( + title=f"Mitsubishi Comfort ({user_input[CONF_USERNAME]})", + data={ + CONF_USERNAME: user_input[CONF_USERNAME], + CONF_PASSWORD: user_input[CONF_PASSWORD], + }, + ) + + return self.async_show_form( + step_id="user", data_schema=USER_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/mitsubishi_comfort/const.py b/homeassistant/components/mitsubishi_comfort/const.py new file mode 100644 index 00000000000000..c2d74d0959acb6 --- /dev/null +++ b/homeassistant/components/mitsubishi_comfort/const.py @@ -0,0 +1,12 @@ +"""Constants for the Mitsubishi Comfort integration.""" + +from datetime import timedelta +from typing import Final + +from homeassistant.const import Platform + +DOMAIN: Final = "mitsubishi_comfort" +PLATFORMS: Final = [Platform.CLIMATE] +DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) +DEFAULT_CONNECT_TIMEOUT: Final = 1.2 +DEFAULT_RESPONSE_TIMEOUT: Final = 8.0 diff --git a/homeassistant/components/mitsubishi_comfort/coordinator.py b/homeassistant/components/mitsubishi_comfort/coordinator.py new file mode 100644 index 00000000000000..47c230f9050893 --- /dev/null +++ b/homeassistant/components/mitsubishi_comfort/coordinator.py @@ -0,0 +1,58 @@ +"""DataUpdateCoordinator for Mitsubishi Comfort devices.""" + +from __future__ import annotations + +import logging + +from mitsubishi_comfort import IndoorUnit, KumoStation + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +type MitsubishiComfortConfigEntry = ConfigEntry[dict[str, MitsubishiComfortCoordinator]] + + +class MitsubishiComfortCoordinator(DataUpdateCoordinator[IndoorUnit | KumoStation]): + """Coordinator to poll a single Mitsubishi device.""" + + def __init__( + self, + hass: HomeAssistant, + entry: MitsubishiComfortConfigEntry, + device: IndoorUnit | KumoStation, + mac: str, + ) -> None: + """Initialize.""" + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=f"mitsubishi_comfort_{device.serial}", + update_interval=DEFAULT_SCAN_INTERVAL, + ) + self.device = device + self.mac = mac + self.data = device + + async def _async_update_data(self) -> IndoorUnit | KumoStation: + """Poll the device and return it.""" + try: + success = await self.device.update_status() + except Exception as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="communication_error", + translation_placeholders={"device_name": self.device.name}, + ) from err + if not success: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="update_failed", + translation_placeholders={"device_name": self.device.name}, + ) + return self.device diff --git a/homeassistant/components/mitsubishi_comfort/entity.py b/homeassistant/components/mitsubishi_comfort/entity.py new file mode 100644 index 00000000000000..283af2b2f97260 --- /dev/null +++ b/homeassistant/components/mitsubishi_comfort/entity.py @@ -0,0 +1,36 @@ +"""Base entity for Mitsubishi Comfort integration.""" + +from __future__ import annotations + +from mitsubishi_comfort import IndoorUnit, KumoStation + +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import MitsubishiComfortCoordinator + + +class MitsubishiComfortEntity(CoordinatorEntity[MitsubishiComfortCoordinator]): + """Base class for all Mitsubishi Comfort entities.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: MitsubishiComfortCoordinator) -> None: + """Initialize.""" + super().__init__(coordinator) + device = coordinator.device + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.serial)}, + connections={(CONNECTION_NETWORK_MAC, coordinator.mac)}, + name=device.name, + manufacturer="Mitsubishi", + serial_number=device.serial, + sw_version=device.status.firmware_version, + hw_version=device.status.hardware_version, + ) + + @property + def _device(self) -> IndoorUnit | KumoStation: + """Return the underlying device from coordinator data.""" + return self.coordinator.data diff --git a/homeassistant/components/mitsubishi_comfort/manifest.json b/homeassistant/components/mitsubishi_comfort/manifest.json new file mode 100644 index 00000000000000..b78251480826f7 --- /dev/null +++ b/homeassistant/components/mitsubishi_comfort/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "mitsubishi_comfort", + "name": "Mitsubishi Comfort", + "codeowners": ["@nikolairahimi"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/mitsubishi_comfort", + "integration_type": "hub", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["mitsubishi-comfort==0.3.0"] +} diff --git a/homeassistant/components/mitsubishi_comfort/quality_scale.yaml b/homeassistant/components/mitsubishi_comfort/quality_scale.yaml new file mode 100644 index 00000000000000..ec0bbccff4a9c0 --- /dev/null +++ b/homeassistant/components/mitsubishi_comfort/quality_scale.yaml @@ -0,0 +1,72 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: No service actions registered. + appropriate-polling: done + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: exempt + comment: No service actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + 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 + config-entry-unloading: done + log-when-unavailable: done + entity-unavailable: done + action-exceptions: + status: exempt + comment: No service actions registered. + reauthentication-flow: todo + parallel-updates: todo + test-coverage: todo + integration-owner: done + docs-installation-parameters: done + docs-configuration-parameters: + status: exempt + comment: No options flow. + + # Gold + entity-translations: todo + entity-device-class: todo + devices: done + entity-category: + status: exempt + comment: Single climate entity per device, no diagnostic entities yet. + entity-disabled-by-default: + status: exempt + comment: Single climate entity per device, enabled by default. + discovery: todo + stale-devices: todo + diagnostics: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + dynamic-devices: todo + discovery-update-info: todo + repair-issues: todo + docs-use-cases: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: done + docs-data-update: done + docs-known-limitations: done + docs-examples: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/mitsubishi_comfort/strings.json b/homeassistant/components/mitsubishi_comfort/strings.json new file mode 100644 index 00000000000000..18dcd5dcdf0229 --- /dev/null +++ b/homeassistant/components/mitsubishi_comfort/strings.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "no_devices": "No devices were found on this account", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "password": "[%key:common::config_flow::data::password%]", + "username": "[%key:common::config_flow::data::username%]" + }, + "data_description": { + "password": "The password for your Kumo Cloud account.", + "username": "The email address for your Kumo Cloud account." + } + } + } + }, + "exceptions": { + "communication_error": { + "message": "Error communicating with {device_name}" + }, + "no_devices": { + "message": "No devices were found in your Mitsubishi Comfort account" + }, + "update_failed": { + "message": "{device_name} returned no data" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 2478179f02373d..9ddd5528fd34ba 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -446,6 +446,7 @@ "mikrotik", "mill", "minecraft_server", + "mitsubishi_comfort", "mjpeg", "moat", "mobile_app", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index acd878735efb54..867cc7a1124318 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4303,6 +4303,12 @@ "config_flow": false, "iot_class": "cloud_push" }, + "mitsubishi_comfort": { + "name": "Mitsubishi Comfort", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "mjpeg": { "name": "MJPEG IP Camera", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 650e7db072351e..7b0625d746192e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -209,7 +209,7 @@ aioaseko==1.0.0 aioasuswrt==1.5.4 # homeassistant.components.husqvarna_automower -aioautomower==2.7.4 +aioautomower==2.7.5 # homeassistant.components.azure_devops aioazuredevops==2.2.2 @@ -1555,6 +1555,9 @@ millheater==0.14.1 # homeassistant.components.minio minio==7.1.12 +# homeassistant.components.mitsubishi_comfort +mitsubishi-comfort==0.3.0 + # homeassistant.components.moat moat-ble==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 15ccc2bcd3ba1e..45f6ddcd308db8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -200,7 +200,7 @@ aioaseko==1.0.0 aioasuswrt==1.5.4 # homeassistant.components.husqvarna_automower -aioautomower==2.7.4 +aioautomower==2.7.5 # homeassistant.components.azure_devops aioazuredevops==2.2.2 @@ -1368,6 +1368,9 @@ millheater==0.14.1 # homeassistant.components.minio minio==7.1.12 +# homeassistant.components.mitsubishi_comfort +mitsubishi-comfort==0.3.0 + # homeassistant.components.moat moat-ble==0.1.1 diff --git a/tests/components/husqvarna_automower/test_init.py b/tests/components/husqvarna_automower/test_init.py index 47c01cd0eeda8a..812c8ea955d41a 100644 --- a/tests/components/husqvarna_automower/test_init.py +++ b/tests/components/husqvarna_automower/test_init.py @@ -14,8 +14,13 @@ HusqvarnaTimeoutError, HusqvarnaWSServerHandshakeError, ) -from aioautomower.model import Calendar, MowerAttributes, MowerStates, WorkArea -from aioautomower.model.model_work_areas import Type +from aioautomower.model import ( + Calendar, + MowerAttributes, + MowerStates, + WorkArea, + WorkAreaType, +) from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -464,7 +469,7 @@ def fake_register_websocket_response( last_time_completed=datetime( 2024, 10, 1, 11, 11, 0, tzinfo=dt_util.get_default_time_zone() ), - type=Type.RANDOM, + type=WorkAreaType.RANDOM, use_global_cutting_height=False, ) } diff --git a/tests/components/mitsubishi_comfort/__init__.py b/tests/components/mitsubishi_comfort/__init__.py new file mode 100644 index 00000000000000..d5e4010c6524dc --- /dev/null +++ b/tests/components/mitsubishi_comfort/__init__.py @@ -0,0 +1 @@ +"""Tests for the Mitsubishi Comfort integration.""" diff --git a/tests/components/mitsubishi_comfort/conftest.py b/tests/components/mitsubishi_comfort/conftest.py new file mode 100644 index 00000000000000..56f269afc9b60e --- /dev/null +++ b/tests/components/mitsubishi_comfort/conftest.py @@ -0,0 +1,180 @@ +"""Test fixtures for Mitsubishi Comfort integration.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from mitsubishi_comfort import ( + CommandResult, + DeviceInfo, + DeviceStatus, + FanSpeed, + IndoorUnit, + Mode, + VaneDirection, +) +import pytest + +from homeassistant.components.mitsubishi_comfort.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry + +MOCK_USERNAME = "test@test.com" +MOCK_PASSWORD = "testpass" + + +def _make_device_status( + mode: str | None = "cool", + standby: bool = False, + vane_left_right: str | None = "auto", + current_humidity: float | None = 45.0, + min_cool_setpoint: float | None = 18.0, + max_cool_setpoint: float | None = 30.0, + min_heat_setpoint: float | None = 16.0, + max_heat_setpoint: float | None = 28.0, +) -> DeviceStatus: + """Create a DeviceStatus with sensible defaults.""" + return DeviceStatus( + mode=mode, + standby=standby, + heat_setpoint=21.0, + cool_setpoint=24.0, + room_temperature=23.5, + fan_speed="auto", + vane_direction="auto", + filter_dirty=False, + defrost=False, + current_humidity=current_humidity, + outdoor_temperature=30.0, + wifi_rssi=-55, + sensor_battery=80, + sensor_rssi=-60, + run_state="on", + vane_left_right=vane_left_right, + uptime=86400, + firmware_version="2.1.0", + hardware_version="1.0.0", + min_cool_setpoint=min_cool_setpoint, + max_cool_setpoint=max_cool_setpoint, + min_heat_setpoint=min_heat_setpoint, + max_heat_setpoint=max_heat_setpoint, + ) + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + }, + unique_id="user-12345", + ) + + +@pytest.fixture +def mock_device_info() -> DeviceInfo: + """Return a mock DeviceInfo.""" + return DeviceInfo( + serial="SERIAL001", + label="Living Room", + address="192.168.1.100", + mac="AA:BB:CC:DD:EE:FF", + unit_type="ductless", + password="dGVzdHBhc3M=", + crypto_serial="0102030405060708090a", + ) + + +@pytest.fixture +def mock_device_status() -> DeviceStatus: + """Return a realistic DeviceStatus.""" + return _make_device_status() + + +def create_mock_indoor_unit() -> MagicMock: + """Create a mock IndoorUnit with realistic attributes.""" + device = MagicMock(spec=IndoorUnit) + device.serial = "SERIAL001" + device.name = "Living Room" + device.status = _make_device_status() + device.update_status = AsyncMock(return_value=True) + device.close = AsyncMock() + device.supported_modes = [ + Mode.OFF, + Mode.COOL, + Mode.HEAT, + Mode.DRY, + Mode.FAN, + Mode.AUTO, + ] + device.supported_fan_speeds = [FanSpeed.QUIET, FanSpeed.LOW, FanSpeed.AUTO] + device.supported_vane_directions = [ + VaneDirection.HORIZONTAL, + VaneDirection.AUTO, + VaneDirection.SWING, + ] + device.set_mode = AsyncMock(return_value=CommandResult(success=True, value="cool")) + device.set_cool_setpoint = AsyncMock( + return_value=CommandResult(success=True, value=24.0) + ) + device.set_heat_setpoint = AsyncMock( + return_value=CommandResult(success=True, value=21.0) + ) + device.set_fan_speed = AsyncMock( + return_value=CommandResult(success=True, value="auto") + ) + device.set_vane_direction = AsyncMock( + return_value=CommandResult(success=True, value="auto") + ) + return device + + +@pytest.fixture +def mock_indoor_unit() -> MagicMock: + """Return a mock IndoorUnit.""" + return create_mock_indoor_unit() + + +@pytest.fixture +def mock_cloud_account(mock_device_info: DeviceInfo) -> Generator[AsyncMock]: + """Mock MitsubishiCloudAccount for both main code and config flow.""" + with ( + patch( + "homeassistant.components.mitsubishi_comfort.MitsubishiCloudAccount", + autospec=True, + ) as mock_cls, + patch( + "homeassistant.components.mitsubishi_comfort.config_flow.MitsubishiCloudAccount", + new=mock_cls, + ), + ): + account = mock_cls.return_value + account.login.return_value = None + account.discover_devices.return_value = {"SERIAL001": mock_device_info} + account.get_passwords_via_websocket.return_value = {} + account.user_id = "user-12345" + yield account + + +@pytest.fixture +def mock_setup_integration( + mock_cloud_account: AsyncMock, + mock_indoor_unit: MagicMock, +) -> Generator[tuple[AsyncMock, MagicMock]]: + """Patch IndoorUnit and KumoStation for full integration setup tests.""" + with ( + patch( + "homeassistant.components.mitsubishi_comfort.IndoorUnit", + return_value=mock_indoor_unit, + ), + patch( + "homeassistant.components.mitsubishi_comfort.KumoStation", + return_value=mock_indoor_unit, + ), + ): + yield mock_cloud_account, mock_indoor_unit diff --git a/tests/components/mitsubishi_comfort/snapshots/test_climate.ambr b/tests/components/mitsubishi_comfort/snapshots/test_climate.ambr new file mode 100644 index 00000000000000..0105461e33aa59 --- /dev/null +++ b/tests/components/mitsubishi_comfort/snapshots/test_climate.ambr @@ -0,0 +1,101 @@ +# serializer version: 1 +# name: test_climate_entity[climate.living_room-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'fan_modes': list([ + 'quiet', + 'low', + 'auto', + ]), + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 18.0, + 'swing_modes': list([ + 'horizontal', + 'auto', + 'swing', + ]), + }), + '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_comfort', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'SERIAL001', + 'unit_of_measurement': None, + }) +# --- +# name: test_climate_entity[climate.living_room-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_humidity': 45.0, + 'current_temperature': 23.5, + 'fan_mode': 'auto', + 'fan_modes': list([ + 'quiet', + 'low', + 'auto', + ]), + 'friendly_name': 'Living Room', + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + , + , + ]), + 'max_temp': 30.0, + 'min_temp': 18.0, + 'supported_features': , + 'swing_mode': 'auto', + 'swing_modes': list([ + 'horizontal', + 'auto', + 'swing', + ]), + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 24.0, + }), + 'context': , + 'entity_id': 'climate.living_room', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'cool', + }) +# --- diff --git a/tests/components/mitsubishi_comfort/test_climate.py b/tests/components/mitsubishi_comfort/test_climate.py new file mode 100644 index 00000000000000..d12419825e17d8 --- /dev/null +++ b/tests/components/mitsubishi_comfort/test_climate.py @@ -0,0 +1,784 @@ +"""Tests for the Mitsubishi Comfort climate entity.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +from mitsubishi_comfort import CommandResult, FanSpeed, Mode, VaneDirection +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.climate import ( + ATTR_FAN_MODE, + ATTR_HVAC_ACTION, + ATTR_HVAC_MODE, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ATTR_SWING_MODE, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.components.mitsubishi_comfort.const import DEFAULT_SCAN_INTERVAL +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + ATTR_TEMPERATURE, + STATE_UNAVAILABLE, + STATE_UNKNOWN, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er + +from .conftest import _make_device_status + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +ENTITY_ID = "climate.living_room" + + +@pytest.fixture +async def setup_climate( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_integration: tuple[AsyncMock, MagicMock], +) -> MagicMock: + """Set up the integration and return the mock device.""" + _, mock_device = mock_setup_integration + 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_device + + +async def _refresh(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: + """Advance time past the scan interval to trigger a coordinator refresh.""" + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + +# -- Snapshot of default state and registry -- + + +async def test_climate_entity( + hass: HomeAssistant, + setup_climate: MagicMock, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test that a climate entity is created for an indoor unit.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +# -- Mode-driven attribute behavior -- + + +@pytest.mark.parametrize( + ("device_mode", "expected_hvac"), + [ + ("off", HVACMode.OFF), + ("heat", HVACMode.HEAT), + ("dry", HVACMode.DRY), + ("vent", HVACMode.FAN_ONLY), + ("auto", HVACMode.HEAT_COOL), + ("autoCool", HVACMode.HEAT_COOL), + ("autoHeat", HVACMode.HEAT_COOL), + ], +) +async def test_hvac_mode_mappings( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_climate: MagicMock, + device_mode: str, + expected_hvac: HVACMode, +) -> None: + """Test HVAC mode mappings from device mode strings.""" + setup_climate.status = _make_device_status(mode=device_mode) + await _refresh(hass, freezer) + + assert hass.states.get(ENTITY_ID).state == expected_hvac + + +async def test_hvac_mode_unknown_when_no_mode( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_climate: MagicMock, +) -> None: + """Test entity reports unknown when device has no mode.""" + setup_climate.status = _make_device_status(mode=None) + await _refresh(hass, freezer) + + assert hass.states.get(ENTITY_ID).state == STATE_UNKNOWN + + +@pytest.mark.parametrize( + ("device_mode", "standby", "expected_action"), + [ + ("off", False, HVACAction.OFF), + ("heat", False, HVACAction.HEATING), + ("dry", False, HVACAction.DRYING), + ("vent", False, HVACAction.FAN), + ("auto", False, HVACAction.IDLE), + ("autoCool", False, HVACAction.COOLING), + ("autoHeat", False, HVACAction.HEATING), + ("cool", True, HVACAction.IDLE), + ], +) +async def test_hvac_action( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_climate: MagicMock, + device_mode: str, + standby: bool, + expected_action: HVACAction, +) -> None: + """Test HVAC action mappings for all modes and standby.""" + setup_climate.status = _make_device_status(mode=device_mode, standby=standby) + await _refresh(hass, freezer) + + assert hass.states.get(ENTITY_ID).attributes[ATTR_HVAC_ACTION] is expected_action + + +async def test_hvac_action_unknown_when_no_mode( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_climate: MagicMock, +) -> None: + """Test HVAC action is None when device has no mode.""" + setup_climate.status = _make_device_status(mode=None) + await _refresh(hass, freezer) + + assert hass.states.get(ENTITY_ID).attributes.get(ATTR_HVAC_ACTION) is None + + +@pytest.mark.parametrize( + ("device_mode", "expected_temp"), + [ + ("heat", 21.0), + ("autoCool", 24.0), + ("autoHeat", 21.0), + ], +) +async def test_target_temperature( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_climate: MagicMock, + device_mode: str, + expected_temp: float, +) -> None: + """Test target temperature follows the active mode's setpoint.""" + setup_climate.status = _make_device_status(mode=device_mode) + await _refresh(hass, freezer) + + assert hass.states.get(ENTITY_ID).attributes[ATTR_TEMPERATURE] == expected_temp + + +async def test_target_temperature_none_in_dry_mode( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_climate: MagicMock, +) -> None: + """Test target temperature is None in dry mode.""" + setup_climate.status = _make_device_status(mode="dry") + await _refresh(hass, freezer) + + assert hass.states.get(ENTITY_ID).attributes.get(ATTR_TEMPERATURE) is None + + +async def test_target_temperature_high_low_auto( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_climate: MagicMock, +) -> None: + """Test target_temperature_high/low track setpoints in auto mode.""" + setup_climate.status = _make_device_status(mode="auto") + await _refresh(hass, freezer) + + state = hass.states.get(ENTITY_ID) + assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 24.0 + assert state.attributes[ATTR_TARGET_TEMP_LOW] == 21.0 + + +# -- Min/max temperature behavior -- + + +@pytest.mark.parametrize( + ("device_mode", "expected_min", "expected_max"), + [ + ("cool", 18.0, 30.0), + ("heat", 16.0, 28.0), + ], +) +async def test_min_max_temp( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_climate: MagicMock, + device_mode: str, + expected_min: float, + expected_max: float, +) -> None: + """Test min/max temp track the active mode's setpoint bounds.""" + setup_climate.status = _make_device_status(mode=device_mode) + await _refresh(hass, freezer) + + state = hass.states.get(ENTITY_ID) + assert state.attributes[ATTR_MIN_TEMP] == expected_min + assert state.attributes[ATTR_MAX_TEMP] == expected_max + + +async def test_min_max_temp_fallback_when_none( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_climate: MagicMock, +) -> None: + """Test min/max temp fall back to climate defaults when setpoints are None.""" + setup_climate.status = _make_device_status( + min_cool_setpoint=None, + max_cool_setpoint=None, + min_heat_setpoint=None, + max_heat_setpoint=None, + ) + await _refresh(hass, freezer) + + state = hass.states.get(ENTITY_ID) + assert state.attributes[ATTR_MIN_TEMP] == 7 + assert state.attributes[ATTR_MAX_TEMP] == 35 + + +async def test_min_max_temp_heat_falls_back_to_cool( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_climate: MagicMock, +) -> None: + """Test min/max temp in heat mode fall back to cool setpoints when heat is None.""" + setup_climate.status = _make_device_status( + mode="heat", + min_heat_setpoint=None, + max_heat_setpoint=None, + ) + await _refresh(hass, freezer) + + state = hass.states.get(ENTITY_ID) + assert state.attributes[ATTR_MIN_TEMP] == 18.0 + assert state.attributes[ATTR_MAX_TEMP] == 30.0 + + +# -- Supported features -- + + +async def test_supported_features_no_auto( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_climate: MagicMock, +) -> None: + """Test supported features without auto mode lack temp range.""" + setup_climate.supported_modes = [Mode.OFF, Mode.COOL, Mode.HEAT] + await _refresh(hass, freezer) + + features = hass.states.get(ENTITY_ID).attributes[ATTR_SUPPORTED_FEATURES] + assert features & ClimateEntityFeature.TARGET_TEMPERATURE + assert not (features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE) + + +async def test_supported_features_no_vane( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_climate: MagicMock, +) -> None: + """Test supported features without vane directions lack swing mode.""" + setup_climate.supported_vane_directions = [] + await _refresh(hass, freezer) + + features = hass.states.get(ENTITY_ID).attributes[ATTR_SUPPORTED_FEATURES] + assert not (features & ClimateEntityFeature.SWING_MODE) + + +# -- Service calls -- + + +async def test_set_hvac_mode( + hass: HomeAssistant, + setup_climate: MagicMock, +) -> None: + """Test setting HVAC mode via service call updates device and state.""" + device = setup_climate + device.set_mode = AsyncMock(return_value=CommandResult(success=True, value="heat")) + + await hass.services.async_call( + "climate", + "set_hvac_mode", + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.HEAT}, + blocking=True, + ) + + device.set_mode.assert_awaited_once_with(Mode.HEAT) + assert hass.states.get(ENTITY_ID).state == HVACMode.HEAT + + +async def test_set_hvac_mode_failed_keeps_state( + hass: HomeAssistant, + setup_climate: MagicMock, +) -> None: + """Test failed set_hvac_mode does not change reported state.""" + device = setup_climate + device.set_mode = AsyncMock(return_value=CommandResult(success=False)) + + await hass.services.async_call( + "climate", + "set_hvac_mode", + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.HEAT}, + blocking=True, + ) + + assert hass.states.get(ENTITY_ID).state == HVACMode.COOL + + +async def test_set_hvac_mode_unmapped_returns( + hass: HomeAssistant, + setup_climate: MagicMock, +) -> None: + """Test setting an unmapped HVACMode does not call the device.""" + device = setup_climate + + with patch.dict( + "homeassistant.components.mitsubishi_comfort.climate._HVAC_TO_MODE", + {HVACMode.COOL: Mode.COOL}, + clear=True, + ): + await hass.services.async_call( + "climate", + "set_hvac_mode", + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.HEAT}, + blocking=True, + ) + + device.set_mode.assert_not_awaited() + + +async def test_set_temperature_cool_mode( + hass: HomeAssistant, + setup_climate: MagicMock, +) -> None: + """Test setting temperature in cool mode updates the cool setpoint.""" + device = setup_climate + device.set_cool_setpoint = AsyncMock( + return_value=CommandResult(success=True, value=22.0) + ) + + await hass.services.async_call( + "climate", + "set_temperature", + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 22.0}, + blocking=True, + ) + + device.set_cool_setpoint.assert_awaited_once_with(22.0) + assert hass.states.get(ENTITY_ID).attributes[ATTR_TEMPERATURE] == 22.0 + + +async def test_set_temperature_heat_mode( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_climate: MagicMock, +) -> None: + """Test setting temperature in heat mode updates the heat setpoint.""" + device = setup_climate + device.status = _make_device_status(mode="heat") + await _refresh(hass, freezer) + device.set_heat_setpoint = AsyncMock( + return_value=CommandResult(success=True, value=20.0) + ) + + await hass.services.async_call( + "climate", + "set_temperature", + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 20.0}, + blocking=True, + ) + + device.set_heat_setpoint.assert_awaited_once_with(20.0) + assert hass.states.get(ENTITY_ID).attributes[ATTR_TEMPERATURE] == 20.0 + + +async def test_set_temperature_high_low( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_climate: MagicMock, +) -> None: + """Test setting high and low temperatures in auto mode.""" + device = setup_climate + device.status = _make_device_status(mode="auto") + await _refresh(hass, freezer) + device.set_cool_setpoint = AsyncMock( + return_value=CommandResult(success=True, value=25.0) + ) + device.set_heat_setpoint = AsyncMock( + return_value=CommandResult(success=True, value=19.0) + ) + + await hass.services.async_call( + "climate", + "set_temperature", + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_TARGET_TEMP_HIGH: 25.0, + ATTR_TARGET_TEMP_LOW: 19.0, + }, + blocking=True, + ) + + device.set_cool_setpoint.assert_awaited_once_with(25.0) + device.set_heat_setpoint.assert_awaited_once_with(19.0) + state = hass.states.get(ENTITY_ID) + assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 25.0 + assert state.attributes[ATTR_TARGET_TEMP_LOW] == 19.0 + + +async def test_set_temperature_failed_keeps_state( + hass: HomeAssistant, + setup_climate: MagicMock, +) -> None: + """Test failed set_temperature does not change reported setpoint.""" + device = setup_climate + device.set_cool_setpoint = AsyncMock(return_value=CommandResult(success=False)) + + await hass.services.async_call( + "climate", + "set_temperature", + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 22.0}, + blocking=True, + ) + + assert hass.states.get(ENTITY_ID).attributes[ATTR_TEMPERATURE] == 24.0 + + +@pytest.mark.parametrize( + ("device_mode", "set_method"), + [ + ("autoCool", "set_cool_setpoint"), + ("autoHeat", "set_heat_setpoint"), + ], +) +async def test_set_temperature_in_auto_mode( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_climate: MagicMock, + device_mode: str, + set_method: str, +) -> None: + """Test set_temperature in autoCool/autoHeat targets the right setpoint.""" + device = setup_climate + device.status = _make_device_status(mode=device_mode) + await _refresh(hass, freezer) + setattr( + device, + set_method, + AsyncMock(return_value=CommandResult(success=True, value=22.0)), + ) + + await hass.services.async_call( + "climate", + "set_temperature", + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 22.0}, + blocking=True, + ) + + getattr(device, set_method).assert_awaited_once_with(22.0) + + +async def test_set_fan_mode( + hass: HomeAssistant, + setup_climate: MagicMock, +) -> None: + """Test setting fan mode via service call updates device and state.""" + device = setup_climate + device.set_fan_speed = AsyncMock( + return_value=CommandResult(success=True, value="quiet") + ) + + await hass.services.async_call( + "climate", + "set_fan_mode", + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: "quiet"}, + blocking=True, + ) + + device.set_fan_speed.assert_awaited_once_with(FanSpeed.QUIET) + assert hass.states.get(ENTITY_ID).attributes[ATTR_FAN_MODE] == "quiet" + + +async def test_set_fan_mode_unknown( + hass: HomeAssistant, + setup_climate: MagicMock, +) -> None: + """Test setting an unknown fan mode raises ServiceValidationError.""" + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + "climate", + "set_fan_mode", + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: "turbo"}, + blocking=True, + ) + + +async def test_set_fan_mode_failed_keeps_state( + hass: HomeAssistant, + setup_climate: MagicMock, +) -> None: + """Test failed set_fan_mode does not change reported fan mode.""" + device = setup_climate + device.set_fan_speed = AsyncMock(return_value=CommandResult(success=False)) + + await hass.services.async_call( + "climate", + "set_fan_mode", + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: "quiet"}, + blocking=True, + ) + + assert hass.states.get(ENTITY_ID).attributes[ATTR_FAN_MODE] == "auto" + + +async def test_set_swing_mode( + hass: HomeAssistant, + setup_climate: MagicMock, +) -> None: + """Test setting swing mode via service call updates device and state.""" + device = setup_climate + device.set_vane_direction = AsyncMock( + return_value=CommandResult(success=True, value="swing") + ) + + await hass.services.async_call( + "climate", + "set_swing_mode", + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_SWING_MODE: "swing"}, + blocking=True, + ) + + device.set_vane_direction.assert_awaited_once_with(VaneDirection.SWING) + assert hass.states.get(ENTITY_ID).attributes[ATTR_SWING_MODE] == "swing" + + +async def test_set_swing_mode_unknown( + hass: HomeAssistant, + setup_climate: MagicMock, +) -> None: + """Test setting an unknown swing mode raises ServiceValidationError.""" + with pytest.raises(ServiceValidationError): + await hass.services.async_call( + "climate", + "set_swing_mode", + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_SWING_MODE: "unknown_direction"}, + blocking=True, + ) + + +async def test_set_swing_mode_failed_keeps_state( + hass: HomeAssistant, + setup_climate: MagicMock, +) -> None: + """Test failed set_swing_mode does not change reported swing mode.""" + device = setup_climate + device.set_vane_direction = AsyncMock(return_value=CommandResult(success=False)) + + await hass.services.async_call( + "climate", + "set_swing_mode", + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_SWING_MODE: "swing"}, + blocking=True, + ) + + assert hass.states.get(ENTITY_ID).attributes[ATTR_SWING_MODE] == "auto" + + +async def test_turn_off( + hass: HomeAssistant, + setup_climate: MagicMock, +) -> None: + """Test turning off the entity via service call.""" + device = setup_climate + device.set_mode = AsyncMock(return_value=CommandResult(success=True, value="off")) + + await hass.services.async_call( + "climate", + "turn_off", + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + device.set_mode.assert_awaited_once_with(Mode.OFF) + assert hass.states.get(ENTITY_ID).state == HVACMode.OFF + + +# -- Optimistic state cleared on next refresh -- + + +async def test_optimistic_mode_cleared_on_refresh( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_climate: MagicMock, +) -> None: + """Test optimistic mode is cleared when a fresh device status arrives.""" + device = setup_climate + device.set_mode = AsyncMock(return_value=CommandResult(success=True, value="heat")) + + await hass.services.async_call( + "climate", + "set_hvac_mode", + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.HEAT}, + blocking=True, + ) + assert hass.states.get(ENTITY_ID).state == HVACMode.HEAT + + await _refresh(hass, freezer) + assert hass.states.get(ENTITY_ID).state == HVACMode.COOL + + +async def test_optimistic_setpoint_cleared_on_refresh( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_climate: MagicMock, +) -> None: + """Test optimistic setpoint is cleared when a fresh device status arrives.""" + device = setup_climate + device.set_cool_setpoint = AsyncMock( + return_value=CommandResult(success=True, value=22.0) + ) + + await hass.services.async_call( + "climate", + "set_temperature", + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 22.0}, + blocking=True, + ) + assert hass.states.get(ENTITY_ID).attributes[ATTR_TEMPERATURE] == 22.0 + + await _refresh(hass, freezer) + assert hass.states.get(ENTITY_ID).attributes[ATTR_TEMPERATURE] == 24.0 + + +async def test_optimistic_fan_speed_cleared_on_refresh( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_climate: MagicMock, +) -> None: + """Test optimistic fan speed is cleared when a fresh device status arrives.""" + device = setup_climate + device.set_fan_speed = AsyncMock( + return_value=CommandResult(success=True, value="quiet") + ) + + await hass.services.async_call( + "climate", + "set_fan_mode", + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_FAN_MODE: "quiet"}, + blocking=True, + ) + assert hass.states.get(ENTITY_ID).attributes[ATTR_FAN_MODE] == "quiet" + + await _refresh(hass, freezer) + assert hass.states.get(ENTITY_ID).attributes[ATTR_FAN_MODE] == "auto" + + +async def test_optimistic_swing_cleared_on_refresh( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_climate: MagicMock, +) -> None: + """Test optimistic swing mode is cleared when a fresh device status arrives.""" + device = setup_climate + device.set_vane_direction = AsyncMock( + return_value=CommandResult(success=True, value="swing") + ) + + await hass.services.async_call( + "climate", + "set_swing_mode", + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_SWING_MODE: "swing"}, + blocking=True, + ) + assert hass.states.get(ENTITY_ID).attributes[ATTR_SWING_MODE] == "swing" + + await _refresh(hass, freezer) + assert hass.states.get(ENTITY_ID).attributes[ATTR_SWING_MODE] == "auto" + + +async def test_optimistic_temp_high_low_in_auto( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_climate: MagicMock, +) -> None: + """Test optimistic high/low setpoints reflect immediately in auto mode.""" + device = setup_climate + device.status = _make_device_status(mode="auto") + await _refresh(hass, freezer) + device.set_cool_setpoint = AsyncMock( + return_value=CommandResult(success=True, value=26.0) + ) + device.set_heat_setpoint = AsyncMock( + return_value=CommandResult(success=True, value=18.0) + ) + + await hass.services.async_call( + "climate", + "set_temperature", + { + ATTR_ENTITY_ID: ENTITY_ID, + ATTR_TARGET_TEMP_HIGH: 26.0, + ATTR_TARGET_TEMP_LOW: 18.0, + }, + blocking=True, + ) + + state = hass.states.get(ENTITY_ID) + assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 26.0 + assert state.attributes[ATTR_TARGET_TEMP_LOW] == 18.0 + + +# -- Coordinator availability -- + + +async def test_coordinator_update_failure_makes_unavailable( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_climate: MagicMock, +) -> None: + """Test that a failed coordinator update makes the entity unavailable.""" + setup_climate.update_status = AsyncMock(return_value=False) + await _refresh(hass, freezer) + + assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE + + +async def test_coordinator_update_exception_makes_unavailable( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_climate: MagicMock, +) -> None: + """Test that an exception during update makes the entity unavailable.""" + setup_climate.update_status = AsyncMock(side_effect=TimeoutError("timeout")) + await _refresh(hass, freezer) + + assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE + + +async def test_coordinator_recovery_restores_available( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + setup_climate: MagicMock, +) -> None: + """Test that a successful update after failure restores availability.""" + setup_climate.update_status = AsyncMock(return_value=False) + await _refresh(hass, freezer) + assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE + + setup_climate.update_status = AsyncMock(return_value=True) + await _refresh(hass, freezer) + assert hass.states.get(ENTITY_ID).state != STATE_UNAVAILABLE diff --git a/tests/components/mitsubishi_comfort/test_config_flow.py b/tests/components/mitsubishi_comfort/test_config_flow.py new file mode 100644 index 00000000000000..ced2b8aa3ac67b --- /dev/null +++ b/tests/components/mitsubishi_comfort/test_config_flow.py @@ -0,0 +1,116 @@ +"""Tests for the Mitsubishi Comfort config flow.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from mitsubishi_comfort.exceptions import AuthenticationError, DeviceConnectionError +import pytest + +from homeassistant import config_entries +from homeassistant.components.mitsubishi_comfort.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +MOCK_USERNAME = "test@test.com" +MOCK_PASSWORD = "testpass" + + +@pytest.fixture(autouse=True) +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry and async_unload_entry.""" + with ( + patch( + "homeassistant.components.mitsubishi_comfort.async_setup_entry", + return_value=True, + ) as mock, + patch( + "homeassistant.components.mitsubishi_comfort.async_unload_entry", + return_value=True, + ), + ): + yield mock + + +async def test_user_step_success( + hass: HomeAssistant, + mock_cloud_account: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test successful config flow shows form then creates entry.""" + # First call with no input shows form + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # Submit credentials creates entry + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == f"Mitsubishi Comfort ({MOCK_USERNAME})" + assert result["data"] == { + CONF_USERNAME: MOCK_USERNAME, + CONF_PASSWORD: MOCK_PASSWORD, + } + mock_setup_entry.assert_called_once() + + +@pytest.mark.parametrize( + ("side_effect", "discover_return", "expected_error"), + [ + (AuthenticationError("bad creds"), None, "invalid_auth"), + (DeviceConnectionError("nope"), None, "cannot_connect"), + (RuntimeError("Unexpected"), None, "unknown"), + (None, {}, "no_devices"), + ], + ids=["invalid_auth", "cannot_connect", "unknown_error", "no_devices"], +) +async def test_user_step_errors( + hass: HomeAssistant, + mock_cloud_account: AsyncMock, + side_effect: Exception | None, + discover_return: dict | None, + expected_error: str, +) -> None: + """Test config flow error handling.""" + if side_effect: + mock_cloud_account.login.side_effect = side_effect + elif discover_return is not None: + mock_cloud_account.discover_devices.return_value = discover_return + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + + +async def test_user_step_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_cloud_account: AsyncMock, +) -> None: + """Test that duplicate config is rejected.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_USERNAME: MOCK_USERNAME, CONF_PASSWORD: MOCK_PASSWORD}, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/mitsubishi_comfort/test_init.py b/tests/components/mitsubishi_comfort/test_init.py new file mode 100644 index 00000000000000..5df54d606a896f --- /dev/null +++ b/tests/components/mitsubishi_comfort/test_init.py @@ -0,0 +1,142 @@ +"""Tests for the Mitsubishi Comfort integration setup.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +from mitsubishi_comfort import DeviceInfo +from mitsubishi_comfort.exceptions import AuthenticationError, DeviceConnectionError +import pytest + +from homeassistant.components.mitsubishi_comfort.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +async def test_setup_entry_success( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_setup_integration: tuple[AsyncMock, MagicMock], +) -> None: + """Test successful setup of a config entry.""" + 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.LOADED + assert entity_registry.async_get_entity_id("climate", DOMAIN, "SERIAL001") + + +@pytest.mark.parametrize( + ("exception", "expected_state"), + [ + (AuthenticationError("bad creds"), ConfigEntryState.SETUP_ERROR), + (DeviceConnectionError("Connection refused"), ConfigEntryState.SETUP_RETRY), + ], +) +async def test_setup_entry_login_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_cloud_account: AsyncMock, + exception: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test setup translates login failures into the expected config entry state.""" + mock_config_entry.add_to_hass(hass) + mock_cloud_account.login.side_effect = exception + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is expected_state + + +async def test_setup_entry_no_devices_raises( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_cloud_account: AsyncMock, +) -> None: + """Test setup raises a setup error when no devices are found.""" + mock_config_entry.add_to_hass(hass) + mock_cloud_account.discover_devices.return_value = {} + + 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_ERROR + + +async def test_setup_entry_incomplete_credentials_loads_empty( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_device_info: DeviceInfo, + mock_cloud_account: AsyncMock, +) -> None: + """Test setup loads with no entities when devices have incomplete credentials.""" + mock_config_entry.add_to_hass(hass) + mock_device_info.password = "" + mock_device_info.address = "" + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert not er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + + +async def test_setup_entry_skips_incomplete_devices( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_device_info: DeviceInfo, + mock_setup_integration: tuple[AsyncMock, MagicMock], +) -> None: + """Test setup skips incomplete devices and only creates entities for complete ones.""" + incomplete_info = DeviceInfo( + serial="SERIAL002", + label="Bedroom", + address="", + mac="11:22:33:44:55:66", + unit_type="ductless", + password="", + crypto_serial="", + ) + mock_account, _ = mock_setup_integration + mock_account.discover_devices.return_value = { + "SERIAL001": mock_device_info, + "SERIAL002": incomplete_info, + } + 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.LOADED + assert entity_registry.async_get_entity_id("climate", DOMAIN, "SERIAL001") + assert entity_registry.async_get_entity_id("climate", DOMAIN, "SERIAL002") is None + + +async def test_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_integration: tuple[AsyncMock, MagicMock], +) -> None: + """Test unloading a config entry.""" + 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.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED