diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py index d78807106c1..89e9155712f 100644 --- a/homeassistant/components/melcloud/__init__.py +++ b/homeassistant/components/melcloud/__init__.py @@ -4,45 +4,64 @@ from __future__ import annotations import asyncio from datetime import timedelta -import logging -from typing import Any from aiohttp import ClientConnectionError, ClientResponseError -from pymelcloud import Device, get_devices -from pymelcloud.atw_device import Zone +from pymelcloud import get_devices from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TOKEN, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo -from homeassistant.util import Throttle +from homeassistant.helpers.update_coordinator import UpdateFailed -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) +from .coordinator import MelCloudConfigEntry, MelCloudDeviceUpdateCoordinator PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER] -type MelCloudConfigEntry = ConfigEntry[dict[str, list[MelCloudDevice]]] - async def async_setup_entry(hass: HomeAssistant, entry: MelCloudConfigEntry) -> bool: """Establish connection with MELCloud.""" - conf = entry.data - try: - mel_devices = await mel_devices_setup(hass, conf[CONF_TOKEN]) - except ClientResponseError as ex: - if isinstance(ex, ClientResponseError) and ex.code == 401: - raise ConfigEntryAuthFailed from ex - raise ConfigEntryNotReady from ex - except (TimeoutError, ClientConnectionError) as ex: - raise ConfigEntryNotReady from ex + token = entry.data[CONF_TOKEN] + session = async_get_clientsession(hass) - entry.runtime_data = mel_devices + try: + async with asyncio.timeout(10): + all_devices = await get_devices( + token, + session, + conf_update_interval=timedelta(minutes=30), + device_set_debounce=timedelta(seconds=2), + ) + except ClientResponseError as ex: + if ex.status in (401, 403): + raise ConfigEntryAuthFailed from ex + if ex.status == 429: + raise UpdateFailed( + "MELCloud rate limit exceeded. Your account may be temporarily blocked" + ) from ex + raise UpdateFailed(f"Error communicating with MELCloud: {ex}") from ex + except (TimeoutError, ClientConnectionError) as ex: + raise UpdateFailed(f"Error communicating with MELCloud: {ex}") from ex + + # Create per-device coordinators + coordinators: dict[str, list[MelCloudDeviceUpdateCoordinator]] = {} + device_registry = dr.async_get(hass) + for device_type, devices in all_devices.items(): + coordinators[device_type] = [] + for device in devices: + coordinator = MelCloudDeviceUpdateCoordinator(hass, device, entry) + # Perform initial refresh for this device + await coordinator.async_config_entry_first_refresh() + coordinators[device_type].append(coordinator) + # Register parent device now so zone entities can reference it via via_device + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + **coordinator.device_info, + ) + + entry.runtime_data = coordinators await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -50,90 +69,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: MelCloudConfigEntry) -> async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) - - -class MelCloudDevice: - """MELCloud Device instance.""" - - def __init__(self, device: Device) -> None: - """Construct a device wrapper.""" - self.device = device - self.name = device.name - self._available = True - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - async def async_update(self, **kwargs): - """Pull the latest data from MELCloud.""" - try: - await self.device.update() - self._available = True - except ClientConnectionError: - _LOGGER.warning("Connection failed for %s", self.name) - self._available = False - - async def async_set(self, properties: dict[str, Any]): - """Write state changes to the MELCloud API.""" - try: - await self.device.set(properties) - self._available = True - except ClientConnectionError: - _LOGGER.warning("Connection failed for %s", self.name) - self._available = False - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._available - - @property - def device_id(self): - """Return device ID.""" - return self.device.device_id - - @property - def building_id(self): - """Return building ID of the device.""" - return self.device.building_id - - @property - def device_info(self) -> DeviceInfo: - """Return a device description for device registry.""" - model = None - if (unit_infos := self.device.units) is not None: - model = ", ".join([x["model"] for x in unit_infos if x["model"]]) - return DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, self.device.mac)}, - identifiers={(DOMAIN, f"{self.device.mac}-{self.device.serial}")}, - manufacturer="Mitsubishi Electric", - model=model, - name=self.name, - ) - - def zone_device_info(self, zone: Zone) -> DeviceInfo: - """Return a zone device description for device registry.""" - dev = self.device - return DeviceInfo( - identifiers={(DOMAIN, f"{dev.mac}-{dev.serial}-{zone.zone_index}")}, - manufacturer="Mitsubishi Electric", - model="ATW zone device", - name=f"{self.name} {zone.name}", - via_device=(DOMAIN, f"{dev.mac}-{dev.serial}"), - ) - - -async def mel_devices_setup( - hass: HomeAssistant, token: str -) -> dict[str, list[MelCloudDevice]]: - """Query connected devices from MELCloud.""" - session = async_get_clientsession(hass) - async with asyncio.timeout(10): - all_devices = await get_devices( - token, - session, - conf_update_interval=timedelta(minutes=30), - device_set_debounce=timedelta(seconds=2), - ) - wrapped_devices: dict[str, list[MelCloudDevice]] = {} - for device_type, devices in all_devices.items(): - wrapped_devices[device_type] = [MelCloudDevice(device) for device in devices] - return wrapped_devices diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py index 47a96d03f06..488268a3295 100644 --- a/homeassistant/components/melcloud/climate.py +++ b/homeassistant/components/melcloud/climate.py @@ -2,7 +2,6 @@ from __future__ import annotations -from datetime import timedelta from typing import Any, cast from pymelcloud import DEVICE_TYPE_ATA, DEVICE_TYPE_ATW, AtaDevice, AtwDevice @@ -29,7 +28,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import MelCloudConfigEntry, MelCloudDevice from .const import ( ATTR_STATUS, ATTR_VANE_HORIZONTAL, @@ -40,9 +38,8 @@ from .const import ( SERVICE_SET_VANE_HORIZONTAL, SERVICE_SET_VANE_VERTICAL, ) - -SCAN_INTERVAL = timedelta(seconds=60) - +from .coordinator import MelCloudConfigEntry, MelCloudDeviceUpdateCoordinator +from .entity import MelCloudEntity ATA_HVAC_MODE_LOOKUP = { ata.OPERATION_MODE_HEAT: HVACMode.HEAT, @@ -74,27 +71,24 @@ ATW_ZONE_HVAC_ACTION_LOOKUP = { async def async_setup_entry( - hass: HomeAssistant, + _hass: HomeAssistant, entry: MelCloudConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MelCloud device climate based on config_entry.""" - mel_devices = entry.runtime_data + coordinators = entry.runtime_data entities: list[AtaDeviceClimate | AtwDeviceZoneClimate] = [ - AtaDeviceClimate(mel_device, mel_device.device) - for mel_device in mel_devices[DEVICE_TYPE_ATA] + AtaDeviceClimate(coordinator, coordinator.device) + for coordinator in coordinators.get(DEVICE_TYPE_ATA, []) ] entities.extend( [ - AtwDeviceZoneClimate(mel_device, mel_device.device, zone) - for mel_device in mel_devices[DEVICE_TYPE_ATW] - for zone in mel_device.device.zones + AtwDeviceZoneClimate(coordinator, coordinator.device, zone) + for coordinator in coordinators.get(DEVICE_TYPE_ATW, []) + for zone in coordinator.device.zones ] ) - async_add_entities( - entities, - True, - ) + async_add_entities(entities) platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( @@ -109,21 +103,19 @@ async def async_setup_entry( ) -class MelCloudClimate(ClimateEntity): +class MelCloudClimate(MelCloudEntity, ClimateEntity): """Base climate device.""" _attr_temperature_unit = UnitOfTemperature.CELSIUS - _attr_has_entity_name = True _attr_name = None - def __init__(self, device: MelCloudDevice) -> None: + def __init__( + self, + coordinator: MelCloudDeviceUpdateCoordinator, + ) -> None: """Initialize the climate.""" - self.api = device - self._base_device = self.api.device - - async def async_update(self) -> None: - """Update state from MELCloud.""" - await self.api.async_update() + super().__init__(coordinator) + self._base_device = self.coordinator.device @property def target_temperature_step(self) -> float | None: @@ -142,26 +134,29 @@ class AtaDeviceClimate(MelCloudClimate): | ClimateEntityFeature.TURN_ON ) - def __init__(self, device: MelCloudDevice, ata_device: AtaDevice) -> None: + def __init__( + self, + coordinator: MelCloudDeviceUpdateCoordinator, + ata_device: AtaDevice, + ) -> None: """Initialize the climate.""" - super().__init__(device) + super().__init__(coordinator) self._device = ata_device - self._attr_unique_id = f"{self.api.device.serial}-{self.api.device.mac}" - self._attr_device_info = self.api.device_info + self._attr_unique_id = ( + f"{self.coordinator.device.serial}-{self.coordinator.device.mac}" + ) + self._attr_device_info = self.coordinator.device_info - async def async_added_to_hass(self) -> None: - """When entity is added to hass.""" - await super().async_added_to_hass() - - # We can only check for vane_horizontal once we fetch the device data from the cloud + # Add horizontal swing if device supports it if self._device.vane_horizontal: self._attr_supported_features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return the optional state attributes with device specific additions.""" - attr = {} + attr: dict[str, Any] = {} + attr.update(self.coordinator.extra_attributes) if vane_horizontal := self._device.vane_horizontal: attr.update( @@ -208,7 +203,7 @@ class AtaDeviceClimate(MelCloudClimate): """Set new target hvac mode.""" set_dict: dict[str, Any] = {} self._apply_set_hvac_mode(hvac_mode, set_dict) - await self._device.set(set_dict) + await self.coordinator.async_set(set_dict) @property def hvac_modes(self) -> list[HVACMode]: @@ -241,7 +236,7 @@ class AtaDeviceClimate(MelCloudClimate): set_dict["target_temperature"] = kwargs.get(ATTR_TEMPERATURE) if set_dict: - await self._device.set(set_dict) + await self.coordinator.async_set(set_dict) @property def fan_mode(self) -> str | None: @@ -250,7 +245,7 @@ class AtaDeviceClimate(MelCloudClimate): async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" - await self._device.set({"fan_speed": fan_mode}) + await self.coordinator.async_set({"fan_speed": fan_mode}) @property def fan_modes(self) -> list[str] | None: @@ -264,7 +259,7 @@ class AtaDeviceClimate(MelCloudClimate): f"Invalid horizontal vane position {position}. Valid positions:" f" [{self._device.vane_horizontal_positions}]." ) - await self._device.set({ata.PROPERTY_VANE_HORIZONTAL: position}) + await self.coordinator.async_set({ata.PROPERTY_VANE_HORIZONTAL: position}) async def async_set_vane_vertical(self, position: str) -> None: """Set vertical vane position.""" @@ -273,7 +268,7 @@ class AtaDeviceClimate(MelCloudClimate): f"Invalid vertical vane position {position}. Valid positions:" f" [{self._device.vane_vertical_positions}]." ) - await self._device.set({ata.PROPERTY_VANE_VERTICAL: position}) + await self.coordinator.async_set({ata.PROPERTY_VANE_VERTICAL: position}) @property def swing_mode(self) -> str | None: @@ -305,11 +300,11 @@ class AtaDeviceClimate(MelCloudClimate): async def async_turn_on(self) -> None: """Turn the entity on.""" - await self._device.set({"power": True}) + await self.coordinator.async_set({"power": True}) async def async_turn_off(self) -> None: """Turn the entity off.""" - await self._device.set({"power": False}) + await self.coordinator.async_set({"power": False}) @property def min_temp(self) -> float: @@ -338,15 +333,18 @@ class AtwDeviceZoneClimate(MelCloudClimate): _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE def __init__( - self, device: MelCloudDevice, atw_device: AtwDevice, atw_zone: Zone + self, + coordinator: MelCloudDeviceUpdateCoordinator, + atw_device: AtwDevice, + atw_zone: Zone, ) -> None: """Initialize the climate.""" - super().__init__(device) + super().__init__(coordinator) self._device = atw_device self._zone = atw_zone - self._attr_unique_id = f"{self.api.device.serial}-{atw_zone.zone_index}" - self._attr_device_info = self.api.zone_device_info(atw_zone) + self._attr_unique_id = f"{self.coordinator.device.serial}-{atw_zone.zone_index}" + self._attr_device_info = self.coordinator.zone_device_info(atw_zone) @property def extra_state_attributes(self) -> dict[str, Any]: @@ -360,15 +358,16 @@ class AtwDeviceZoneClimate(MelCloudClimate): @property def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool mode.""" - mode = self._zone.operation_mode - if not self._device.power or mode is None: + # Use zone status (heat/cool/idle) not operation_mode (heat-thermostat/etc.) + status = self._zone.status + if not self._device.power or status is None: return HVACMode.OFF - return ATW_ZONE_HVAC_MODE_LOOKUP.get(mode, HVACMode.OFF) + return ATW_ZONE_HVAC_MODE_LOOKUP.get(status, HVACMode.OFF) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" if hvac_mode == HVACMode.OFF: - await self._device.set({"power": False}) + await self.coordinator.async_set({"power": False}) return operation_mode = ATW_ZONE_HVAC_MODE_REVERSE_LOOKUP.get(hvac_mode) @@ -381,7 +380,7 @@ class AtwDeviceZoneClimate(MelCloudClimate): props = {PROPERTY_ZONE_2_OPERATION_MODE: operation_mode} if self.hvac_mode == HVACMode.OFF: props["power"] = True - await self._device.set(props) + await self.coordinator.async_set(props) @property def hvac_modes(self) -> list[HVACMode]: @@ -410,3 +409,4 @@ class AtwDeviceZoneClimate(MelCloudClimate): await self._zone.set_target_temperature( kwargs.get(ATTR_TEMPERATURE, self.target_temperature) ) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/melcloud/config_flow.py b/homeassistant/components/melcloud/config_flow.py index d2c9d67f29a..ff0b06d3775 100644 --- a/homeassistant/components/melcloud/config_flow.py +++ b/homeassistant/components/melcloud/config_flow.py @@ -60,6 +60,10 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): return self.async_abort(reason="cannot_connect") except (TimeoutError, ClientError): return self.async_abort(reason="cannot_connect") + except AttributeError: + # python-melcloud library bug: login() raises AttributeError on invalid + # credentials when API response doesn't contain expected "LoginData" key + return self.async_abort(reason="invalid_auth") return await self._create_entry(username, acquired_token) diff --git a/homeassistant/components/melcloud/coordinator.py b/homeassistant/components/melcloud/coordinator.py new file mode 100644 index 00000000000..3b4c6f57f5b --- /dev/null +++ b/homeassistant/components/melcloud/coordinator.py @@ -0,0 +1,193 @@ +"""DataUpdateCoordinator for the MELCloud integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Any + +from aiohttp import ClientConnectionError, ClientResponseError +from pymelcloud import Device +from pymelcloud.atw_device import Zone + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +# Delay before refreshing after a state change to allow device to process +# and avoid race conditions with rapid sequential changes +REQUEST_REFRESH_DELAY = 1.5 + +# Default update interval in minutes (matches upstream Throttle value) +DEFAULT_UPDATE_INTERVAL = 15 + +# Retry interval in seconds for transient failures +RETRY_INTERVAL_SECONDS = 30 + +# Number of consecutive failures before marking device unavailable +MAX_CONSECUTIVE_FAILURES = 3 + + +class MelCloudDeviceUpdateCoordinator(DataUpdateCoordinator[None]): + """Per-device coordinator for MELCloud data updates.""" + + def __init__( + self, + hass: HomeAssistant, + device: Device, + config_entry: ConfigEntry, + ) -> None: + """Initialize the per-device coordinator.""" + self.device = device + self.device_available = True + self._consecutive_failures = 0 + + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=f"{DOMAIN}_{device.name}", + update_interval=timedelta(minutes=DEFAULT_UPDATE_INTERVAL), + always_update=True, + request_refresh_debouncer=Debouncer( + hass, + _LOGGER, + cooldown=REQUEST_REFRESH_DELAY, + immediate=False, + ), + ) + + @property + def extra_attributes(self) -> dict[str, Any]: + """Return extra device attributes.""" + data: dict[str, Any] = { + "device_id": self.device.device_id, + "serial": self.device.serial, + "mac": self.device.mac, + } + if (unit_infos := self.device.units) is not None: + for i, unit in enumerate(unit_infos[:2]): + data[f"unit_{i}_model"] = unit.get("model") + data[f"unit_{i}_serial"] = unit.get("serial") + return data + + @property + def device_id(self) -> str: + """Return device ID.""" + return self.device.device_id + + @property + def building_id(self) -> str: + """Return building ID of the device.""" + return self.device.building_id + + @property + def device_info(self) -> DeviceInfo: + """Return a device description for device registry.""" + model = None + if (unit_infos := self.device.units) is not None: + model = ", ".join([x["model"] for x in unit_infos if x["model"]]) + return DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, self.device.mac)}, + identifiers={(DOMAIN, f"{self.device.mac}-{self.device.serial}")}, + manufacturer="Mitsubishi Electric", + model=model, + name=self.device.name, + ) + + def zone_device_info(self, zone: Zone) -> DeviceInfo: + """Return a zone device description for device registry.""" + dev = self.device + return DeviceInfo( + identifiers={(DOMAIN, f"{dev.mac}-{dev.serial}-{zone.zone_index}")}, + manufacturer="Mitsubishi Electric", + model="ATW zone device", + name=f"{self.device.name} {zone.name}", + via_device=(DOMAIN, f"{dev.mac}-{dev.serial}"), + ) + + async def _async_update_data(self) -> None: + """Fetch data for this specific device from MELCloud.""" + try: + await self.device.update() + # Success - reset failure counter and restore normal interval + if self._consecutive_failures > 0: + _LOGGER.info( + "Connection restored for %s after %d failed attempt(s)", + self.device.name, + self._consecutive_failures, + ) + self._consecutive_failures = 0 + self.update_interval = timedelta(minutes=DEFAULT_UPDATE_INTERVAL) + self.device_available = True + except ClientResponseError as ex: + if ex.status in (401, 403): + raise ConfigEntryAuthFailed from ex + if ex.status == 429: + _LOGGER.error( + "MELCloud rate limit exceeded for %s. Your account may be " + "temporarily blocked", + self.device.name, + ) + # Rate limit - mark unavailable immediately + self.device_available = False + raise UpdateFailed( + f"Rate limit exceeded for {self.device.name}" + ) from ex + # Other HTTP errors - use retry logic + self._handle_failure(f"Error updating {self.device.name}: {ex}", ex) + except ClientConnectionError as ex: + self._handle_failure(f"Connection failed for {self.device.name}: {ex}", ex) + + def _handle_failure(self, message: str, exception: Exception | None = None) -> None: + """Handle a connection failure with retry logic. + + For transient failures, entities remain available with their last known + values for up to MAX_CONSECUTIVE_FAILURES attempts. During retries, the + update interval is shortened to RETRY_INTERVAL_SECONDS for faster recovery. + After the threshold is reached, entities are marked unavailable. + """ + self._consecutive_failures += 1 + + if self._consecutive_failures < MAX_CONSECUTIVE_FAILURES: + # Keep entities available with cached data, use shorter retry interval + _LOGGER.warning( + "%s (attempt %d/%d, retrying in %ds)", + message, + self._consecutive_failures, + MAX_CONSECUTIVE_FAILURES, + RETRY_INTERVAL_SECONDS, + ) + self.update_interval = timedelta(seconds=RETRY_INTERVAL_SECONDS) + else: + # Threshold reached - mark unavailable and restore normal interval + _LOGGER.warning( + "%s (attempt %d/%d, marking unavailable)", + message, + self._consecutive_failures, + MAX_CONSECUTIVE_FAILURES, + ) + self.device_available = False + self.update_interval = timedelta(minutes=DEFAULT_UPDATE_INTERVAL) + raise UpdateFailed(message) from exception + + async def async_set(self, properties: dict[str, Any]) -> None: + """Write state changes to the MELCloud API.""" + try: + await self.device.set(properties) + self.device_available = True + except ClientConnectionError: + _LOGGER.warning("Connection failed for %s", self.device.name) + self.device_available = False + + await self.async_request_refresh() + + +type MelCloudConfigEntry = ConfigEntry[dict[str, list[MelCloudDeviceUpdateCoordinator]]] diff --git a/homeassistant/components/melcloud/diagnostics.py b/homeassistant/components/melcloud/diagnostics.py index 4606b7c25e5..c601f886470 100644 --- a/homeassistant/components/melcloud/diagnostics.py +++ b/homeassistant/components/melcloud/diagnostics.py @@ -9,7 +9,7 @@ from homeassistant.const import CONF_TOKEN, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import MelCloudConfigEntry +from .coordinator import MelCloudConfigEntry TO_REDACT = { CONF_USERNAME, diff --git a/homeassistant/components/melcloud/entity.py b/homeassistant/components/melcloud/entity.py new file mode 100644 index 00000000000..b0d9b839481 --- /dev/null +++ b/homeassistant/components/melcloud/entity.py @@ -0,0 +1,18 @@ +"""Base entity for MELCloud integration.""" + +from __future__ import annotations + +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import MelCloudDeviceUpdateCoordinator + + +class MelCloudEntity(CoordinatorEntity[MelCloudDeviceUpdateCoordinator]): + """Base class for MELCloud entities.""" + + _attr_has_entity_name = True + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return super().available and self.coordinator.device_available diff --git a/homeassistant/components/melcloud/manifest.json b/homeassistant/components/melcloud/manifest.json index c68b9cab3c3..b683ee6671a 100644 --- a/homeassistant/components/melcloud/manifest.json +++ b/homeassistant/components/melcloud/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/melcloud", "integration_type": "device", "iot_class": "cloud_polling", - "loggers": ["pymelcloud"], + "loggers": ["melcloud"], "requirements": ["python-melcloud==0.1.2"] } diff --git a/homeassistant/components/melcloud/sensor.py b/homeassistant/components/melcloud/sensor.py index 1d36c74f27c..f88150ac6cd 100644 --- a/homeassistant/components/melcloud/sensor.py +++ b/homeassistant/components/melcloud/sensor.py @@ -19,7 +19,8 @@ from homeassistant.const import UnitOfEnergy, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import MelCloudConfigEntry, MelCloudDevice +from .coordinator import MelCloudConfigEntry, MelCloudDeviceUpdateCoordinator +from .entity import MelCloudEntity @dataclasses.dataclass(frozen=True, kw_only=True) @@ -111,70 +112,67 @@ ATW_ZONE_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, + _hass: HomeAssistant, entry: MelCloudConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MELCloud device sensors based on config_entry.""" - mel_devices = entry.runtime_data + coordinators = entry.runtime_data entities: list[MelDeviceSensor] = [ - MelDeviceSensor(mel_device, description) + MelDeviceSensor(coordinator, description) for description in ATA_SENSORS - for mel_device in mel_devices[DEVICE_TYPE_ATA] - if description.enabled(mel_device) + for coordinator in coordinators.get(DEVICE_TYPE_ATA, []) + if description.enabled(coordinator) ] + [ - MelDeviceSensor(mel_device, description) + MelDeviceSensor(coordinator, description) for description in ATW_SENSORS - for mel_device in mel_devices[DEVICE_TYPE_ATW] - if description.enabled(mel_device) + for coordinator in coordinators.get(DEVICE_TYPE_ATW, []) + if description.enabled(coordinator) ] entities.extend( [ - AtwZoneSensor(mel_device, zone, description) - for mel_device in mel_devices[DEVICE_TYPE_ATW] - for zone in mel_device.device.zones + AtwZoneSensor(coordinator, zone, description) + for coordinator in coordinators.get(DEVICE_TYPE_ATW, []) + for zone in coordinator.device.zones for description in ATW_ZONE_SENSORS if description.enabled(zone) ] ) - async_add_entities(entities, True) + async_add_entities(entities) -class MelDeviceSensor(SensorEntity): +class MelDeviceSensor(MelCloudEntity, SensorEntity): """Representation of a Sensor.""" entity_description: MelcloudSensorEntityDescription - _attr_has_entity_name = True def __init__( self, - api: MelCloudDevice, + coordinator: MelCloudDeviceUpdateCoordinator, description: MelcloudSensorEntityDescription, ) -> None: """Initialize the sensor.""" - self._api = api + super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{api.device.serial}-{api.device.mac}-{description.key}" - self._attr_device_info = api.device_info + self._attr_unique_id = ( + f"{coordinator.device.serial}-{coordinator.device.mac}-{description.key}" + ) + self._attr_device_info = coordinator.device_info @property def native_value(self) -> float | None: """Return the state of the sensor.""" - return self.entity_description.value_fn(self._api) - - async def async_update(self) -> None: - """Retrieve latest state.""" - await self._api.async_update() + return self.entity_description.value_fn(self.coordinator) class AtwZoneSensor(MelDeviceSensor): - """Air-to-Air device sensor.""" + """Air-to-Water zone sensor.""" def __init__( self, - api: MelCloudDevice, + coordinator: MelCloudDeviceUpdateCoordinator, zone: Zone, description: MelcloudSensorEntityDescription, ) -> None: @@ -184,9 +182,9 @@ class AtwZoneSensor(MelDeviceSensor): description, key=f"{description.key}-zone-{zone.zone_index}", ) - super().__init__(api, description) + super().__init__(coordinator, description) - self._attr_device_info = api.zone_device_info(zone) + self._attr_device_info = coordinator.zone_device_info(zone) self._zone = zone @property diff --git a/homeassistant/components/melcloud/strings.json b/homeassistant/components/melcloud/strings.json index b670530283f..c8a1d14c214 100644 --- a/homeassistant/components/melcloud/strings.json +++ b/homeassistant/components/melcloud/strings.json @@ -43,6 +43,9 @@ }, "entity": { "sensor": { + "energy_consumed": { + "name": "Energy consumed" + }, "flow_temperature": { "name": "Flow temperature" }, diff --git a/homeassistant/components/melcloud/water_heater.py b/homeassistant/components/melcloud/water_heater.py index f006df2478e..6b91ef4a353 100644 --- a/homeassistant/components/melcloud/water_heater.py +++ b/homeassistant/components/melcloud/water_heater.py @@ -21,27 +21,27 @@ from homeassistant.const import UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import MelCloudConfigEntry, MelCloudDevice from .const import ATTR_STATUS +from .coordinator import MelCloudConfigEntry, MelCloudDeviceUpdateCoordinator +from .entity import MelCloudEntity async def async_setup_entry( - hass: HomeAssistant, + _hass: HomeAssistant, entry: MelCloudConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up MelCloud device climate based on config_entry.""" - mel_devices = entry.runtime_data + coordinators = entry.runtime_data async_add_entities( [ - AtwWaterHeater(mel_device, mel_device.device) - for mel_device in mel_devices[DEVICE_TYPE_ATW] - ], - True, + AtwWaterHeater(coordinator, coordinator.device) + for coordinator in coordinators.get(DEVICE_TYPE_ATW, []) + ] ) -class AtwWaterHeater(WaterHeaterEntity): +class AtwWaterHeater(MelCloudEntity, WaterHeaterEntity): """Air-to-Water water heater.""" _attr_supported_features = ( @@ -49,27 +49,26 @@ class AtwWaterHeater(WaterHeaterEntity): | WaterHeaterEntityFeature.ON_OFF | WaterHeaterEntityFeature.OPERATION_MODE ) - _attr_has_entity_name = True _attr_name = None - def __init__(self, api: MelCloudDevice, device: AtwDevice) -> None: + def __init__( + self, + coordinator: MelCloudDeviceUpdateCoordinator, + device: AtwDevice, + ) -> None: """Initialize water heater device.""" - self._api = api + super().__init__(coordinator) self._device = device - self._attr_unique_id = api.device.serial - self._attr_device_info = api.device_info + self._attr_unique_id = coordinator.device.serial + self._attr_device_info = coordinator.device_info - async def async_update(self) -> None: - """Update state from MELCloud.""" - await self._api.async_update() - - async def async_turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **_kwargs: Any) -> None: """Turn the entity on.""" - await self._device.set({PROPERTY_POWER: True}) + await self.coordinator.async_set({PROPERTY_POWER: True}) - async def async_turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **_kwargs: Any) -> None: """Turn the entity off.""" - await self._device.set({PROPERTY_POWER: False}) + await self.coordinator.async_set({PROPERTY_POWER: False}) @property def extra_state_attributes(self) -> dict[str, Any] | None: @@ -103,7 +102,7 @@ class AtwWaterHeater(WaterHeaterEntity): async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" - await self._device.set( + await self.coordinator.async_set( { PROPERTY_TARGET_TANK_TEMPERATURE: kwargs.get( "temperature", self.target_temperature @@ -113,7 +112,7 @@ class AtwWaterHeater(WaterHeaterEntity): async def async_set_operation_mode(self, operation_mode: str) -> None: """Set new target operation mode.""" - await self._device.set({PROPERTY_OPERATION_MODE: operation_mode}) + await self.coordinator.async_set({PROPERTY_OPERATION_MODE: operation_mode}) @property def min_temp(self) -> float: diff --git a/tests/components/melcloud/test_atw_zone_sensor.py b/tests/components/melcloud/test_atw_zone_sensor.py index 5ffb6dd7ff5..0f947eefdaf 100644 --- a/tests/components/melcloud/test_atw_zone_sensor.py +++ b/tests/components/melcloud/test_atw_zone_sensor.py @@ -1,6 +1,6 @@ """Test the MELCloud ATW zone sensor.""" -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest @@ -8,32 +8,45 @@ from homeassistant.components.melcloud.sensor import ATW_ZONE_SENSORS, AtwZoneSe @pytest.fixture -def mock_device(): - """Mock MELCloud device.""" - with patch("homeassistant.components.melcloud.MelCloudDevice") as mock: - mock.name = "name" - mock.device.serial = 1234 - mock.device.mac = "11:11:11:11:11:11" +def mock_coordinator(): + """Mock MELCloud coordinator.""" + with patch( + "homeassistant.components.melcloud.coordinator.MelCloudDeviceUpdateCoordinator" + ) as mock: yield mock +@pytest.fixture +def mock_device(mock_coordinator): + """Mock MELCloud device.""" + mock = MagicMock() + mock.name = "name" + mock.device.serial = 1234 + mock.device.mac = "11:11:11:11:11:11" + mock.zone_device_info.return_value = {} + mock.coordinator = mock_coordinator + return mock + + @pytest.fixture def mock_zone_1(): """Mock zone 1.""" - with patch("pymelcloud.atw_device.Zone") as mock: - mock.zone_index = 1 - yield mock + mock = MagicMock() + mock.zone_index = 1 + return mock @pytest.fixture def mock_zone_2(): """Mock zone 2.""" - with patch("pymelcloud.atw_device.Zone") as mock: - mock.zone_index = 2 - yield mock + mock = MagicMock() + mock.zone_index = 2 + return mock -def test_zone_unique_ids(mock_device, mock_zone_1, mock_zone_2) -> None: +def test_zone_unique_ids( + mock_coordinator, mock_device, mock_zone_1, mock_zone_2 +) -> None: """Test unique id generation correctness.""" sensor_1 = AtwZoneSensor( mock_device, diff --git a/tests/components/melcloud/test_config_flow.py b/tests/components/melcloud/test_config_flow.py index 3f6e42ac264..618d132fb23 100644 --- a/tests/components/melcloud/test_config_flow.py +++ b/tests/components/melcloud/test_config_flow.py @@ -75,7 +75,11 @@ async def test_form(hass: HomeAssistant, mock_login, mock_get_devices) -> None: @pytest.mark.parametrize( ("error", "reason"), - [(ClientError(), "cannot_connect"), (TimeoutError(), "cannot_connect")], + [ + (ClientError(), "cannot_connect"), + (TimeoutError(), "cannot_connect"), + (AttributeError(), "invalid_auth"), + ], ) async def test_form_errors( hass: HomeAssistant, mock_login, mock_get_devices, error, reason