mirror of
https://github.com/home-assistant/core.git
synced 2026-08-24 10:13:52 -05:00
Add Mitsubishi Comfort integration (#167472)
Co-authored-by: Nikolai Rahimi <nikolairahimi@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
co-authored by
Nikolai Rahimi
Copilot
Joostlek
parent
71494b6c97
commit
597d9a2ada
Generated
+2
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1
@@ -446,6 +446,7 @@ FLOWS = {
|
||||
"mikrotik",
|
||||
"mill",
|
||||
"minecraft_server",
|
||||
"mitsubishi_comfort",
|
||||
"mjpeg",
|
||||
"moat",
|
||||
"mobile_app",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the Mitsubishi Comfort integration."""
|
||||
@@ -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
|
||||
@@ -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([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.COOL: 'cool'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
<HVACMode.DRY: 'dry'>,
|
||||
<HVACMode.FAN_ONLY: 'fan_only'>,
|
||||
<HVACMode.HEAT_COOL: 'heat_cool'>,
|
||||
]),
|
||||
'max_temp': 30.0,
|
||||
'min_temp': 18.0,
|
||||
'swing_modes': list([
|
||||
'horizontal',
|
||||
'auto',
|
||||
'swing',
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'climate',
|
||||
'entity_category': None,
|
||||
'entity_id': 'climate.living_room',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': None,
|
||||
'platform': 'mitsubishi_comfort',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': <ClimateEntityFeature: 171>,
|
||||
'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': <HVACAction.COOLING: 'cooling'>,
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.COOL: 'cool'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
<HVACMode.DRY: 'dry'>,
|
||||
<HVACMode.FAN_ONLY: 'fan_only'>,
|
||||
<HVACMode.HEAT_COOL: 'heat_cool'>,
|
||||
]),
|
||||
'max_temp': 30.0,
|
||||
'min_temp': 18.0,
|
||||
'supported_features': <ClimateEntityFeature: 171>,
|
||||
'swing_mode': 'auto',
|
||||
'swing_modes': list([
|
||||
'horizontal',
|
||||
'auto',
|
||||
'swing',
|
||||
]),
|
||||
'target_temp_high': None,
|
||||
'target_temp_low': None,
|
||||
'temperature': 24.0,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'climate.living_room',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'cool',
|
||||
})
|
||||
# ---
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user