Refactor MELCloud integration to use DataUpdateCoordinator (#160131)

Co-authored-by: divers33 <divers33@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
This commit is contained in:
divers33
2026-01-13 18:52:37 +01:00
committed by GitHub
parent 41bbfb8725
commit 58ef925a07
12 changed files with 394 additions and 230 deletions

View File

@@ -4,128 +4,29 @@ 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
entry.runtime_data = mel_devices
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
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."""
token = entry.data[CONF_TOKEN]
session = async_get_clientsession(hass)
try:
async with asyncio.timeout(10):
all_devices = await get_devices(
token,
@@ -133,7 +34,38 @@ async def mel_devices_setup(
conf_update_interval=timedelta(minutes=30),
device_set_debounce=timedelta(seconds=2),
)
wrapped_devices: dict[str, list[MelCloudDevice]] = {}
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():
wrapped_devices[device_type] = [MelCloudDevice(device) for device in devices]
return wrapped_devices
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
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)

View File

@@ -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()

View File

@@ -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)

View File

@@ -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]]]

View File

@@ -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,

View File

@@ -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

View File

@@ -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"]
}

View File

@@ -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

View File

@@ -43,6 +43,9 @@
},
"entity": {
"sensor": {
"energy_consumed": {
"name": "Energy consumed"
},
"flow_temperature": {
"name": "Flow temperature"
},

View File

@@ -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:

View File

@@ -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():
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."""
with patch("homeassistant.components.melcloud.MelCloudDevice") as mock:
mock = MagicMock()
mock.name = "name"
mock.device.serial = 1234
mock.device.mac = "11:11:11:11:11:11"
yield mock
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 = MagicMock()
mock.zone_index = 1
yield mock
return mock
@pytest.fixture
def mock_zone_2():
"""Mock zone 2."""
with patch("pymelcloud.atw_device.Zone") as mock:
mock = MagicMock()
mock.zone_index = 2
yield mock
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,

View File

@@ -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