Add outdoor temperature to MELCloud Home (#181216)

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Erwin Douna
2026-09-10 20:07:23 +02:00
committed by GitHub
co-authored by Copilot Autofix powered by AI
parent a9337d2a8f
commit fc78d26a74
11 changed files with 250 additions and 68 deletions
@@ -9,8 +9,8 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import (
MelCloudHomeConfigEntry,
MelCloudHomeCoordinator,
MelCloudHomeEnergyCoordinator,
MelCloudHomeRuntimeData,
MelCloudHomeTelemetryCoordinator,
)
PLATFORMS: list[Platform] = [
@@ -35,14 +35,15 @@ async def async_setup_entry(
client = MELCloudHome(auth=auth, session=session)
coordinator = MelCloudHomeCoordinator(hass, entry, client)
energy_coordinator = MelCloudHomeEnergyCoordinator(hass, entry, client)
telemetry_coordinator = MelCloudHomeTelemetryCoordinator(hass, entry, client)
# It has to be this order, to avoid a race condition
await coordinator.async_config_entry_first_refresh()
await energy_coordinator.async_config_entry_first_refresh()
await telemetry_coordinator.async_config_entry_first_refresh()
entry.runtime_data = MelCloudHomeRuntimeData(
coordinator=coordinator, energy_coordinator=energy_coordinator
coordinator=coordinator,
telemetry_coordinator=telemetry_coordinator,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -26,7 +26,7 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
UPDATE_INTERVAL = timedelta(seconds=60)
ENERGY_UPDATE_INTERVAL = timedelta(minutes=15)
TELEMETRY_UPDATE_INTERVAL = timedelta(minutes=15)
@dataclass(kw_only=True, frozen=True)
@@ -34,7 +34,7 @@ class MelCloudHomeRuntimeData:
"""Runtime data for the MELCloud Home config entry."""
coordinator: MelCloudHomeCoordinator
energy_coordinator: MelCloudHomeEnergyCoordinator
telemetry_coordinator: MelCloudHomeTelemetryCoordinator
type MelCloudHomeConfigEntry = ConfigEntry[MelCloudHomeRuntimeData]
@@ -150,8 +150,18 @@ class MelCloudHomeCoordinator(DataUpdateCoordinator[UserContext]):
self._notify_new_units(self.data)
class MelCloudHomeEnergyCoordinator(DataUpdateCoordinator[dict[str, float | None]]):
"""Coordinator to manage fetching MELCloud Home energy telemetry."""
@dataclass(kw_only=True, frozen=True)
class MelCloudHomeTelemetryData:
"""Telemetry data fetched periodically for MELCloud Home units."""
energy: dict[str, float | None]
outdoor_temperature: dict[str, float | None]
class MelCloudHomeTelemetryCoordinator(
DataUpdateCoordinator[MelCloudHomeTelemetryData]
):
"""Coordinator to manage fetching MELCloud Home energy and outdoor temperature telemetry."""
config_entry: MelCloudHomeConfigEntry
@@ -166,8 +176,8 @@ class MelCloudHomeEnergyCoordinator(DataUpdateCoordinator[dict[str, float | None
hass,
_LOGGER,
config_entry=entry,
name=f"{DOMAIN}_energy",
update_interval=ENERGY_UPDATE_INTERVAL,
name=f"{DOMAIN}_telemetry",
update_interval=TELEMETRY_UPDATE_INTERVAL,
)
self.client = client
@@ -188,9 +198,21 @@ class MelCloudHomeEnergyCoordinator(DataUpdateCoordinator[dict[str, float | None
return None
return sum(float(e.value) for e in energy)
async def _async_get_outdoor_temperature(self, unit_id: str) -> float | None:
"""Fetch outdoor temperature for a unit without failing the whole update."""
try:
return await self.client.get_outdoor_temperature(unit_id)
except (
MelCloudHomeAuthenticationError,
MelCloudHomeConnectionError,
MelCloudHomeTimeoutError,
):
_LOGGER.warning("Failed to fetch outdoor temperature for %s", unit_id)
return None
@override
async def _async_update_data(self) -> dict[str, float | None]:
"""Fetch energy telemetry for all units with an energy meter."""
async def _async_update_data(self) -> MelCloudHomeTelemetryData:
"""Fetch energy and outdoor temperature telemetry for all supported units."""
try:
data = await self.client.get_context()
except MelCloudHomeAuthenticationError as err:
@@ -215,6 +237,9 @@ class MelCloudHomeEnergyCoordinator(DataUpdateCoordinator[dict[str, float | None
now = utcnow()
energy_coroutines: dict[str, Coroutine[None, None, float | None]] = {}
outdoor_temperature_coroutine: dict[
str, Coroutine[None, None, float | None]
] = {}
for building in data.buildings:
for ata_unit in building.air_to_air_units:
if (
@@ -224,6 +249,14 @@ class MelCloudHomeEnergyCoordinator(DataUpdateCoordinator[dict[str, float | None
energy_coroutines[ata_unit.id] = self._async_get_energy(
ata_unit.id, start_of_month, now
)
if (
ata_unit.capabilities
and ata_unit.capabilities.has_outdoor_temperature_sensor
):
outdoor_temperature_coroutine[ata_unit.id] = (
self._async_get_outdoor_temperature(ata_unit.id)
)
for atw_unit in building.air_to_water_units:
if (
atw_unit.capabilities
@@ -233,10 +266,18 @@ class MelCloudHomeEnergyCoordinator(DataUpdateCoordinator[dict[str, float | None
atw_unit.id, start_of_month, now
)
return dict(
zip(
energy_coroutines,
await asyncio.gather(*energy_coroutines.values()),
strict=True,
)
energy_values, outdoor_temperature_values = await asyncio.gather(
asyncio.gather(*energy_coroutines.values()),
asyncio.gather(*outdoor_temperature_coroutine.values()),
)
return MelCloudHomeTelemetryData(
energy=dict(zip(energy_coroutines, energy_values, strict=True)),
outdoor_temperature=dict(
zip(
outdoor_temperature_coroutine,
outdoor_temperature_values,
strict=True,
)
),
)
@@ -1,5 +1,6 @@
"""Diagnostics for MELCloud Home integration."""
from dataclasses import asdict
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
@@ -30,7 +31,7 @@ async def async_get_config_entry_diagnostics(
config_entry.runtime_data.coordinator.data.model_dump(mode="json"),
TO_REDACT,
),
"energy_coordinator": async_redact_data(
config_entry.runtime_data.energy_coordinator.data, TO_REDACT
"telemetry_coordinator": async_redact_data(
asdict(config_entry.runtime_data.telemetry_coordinator.data), TO_REDACT
),
}
@@ -44,6 +44,9 @@
}
},
"sensor": {
"outdoor_temperature": {
"default": "mdi:thermometer"
},
"room_temperature": {
"default": "mdi:home-thermometer"
},
@@ -29,7 +29,7 @@ from .common import async_setup_unit_entities
from .coordinator import (
MelCloudHomeConfigEntry,
MelCloudHomeCoordinator,
MelCloudHomeEnergyCoordinator,
MelCloudHomeTelemetryCoordinator,
)
from .entity import MelCloudHomeATAUnitEntity, MelCloudHomeATWUnitEntity
@@ -44,6 +44,15 @@ ENERGY_CONSUMED_DESCRIPTION = SensorEntityDescription(
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
)
OUTDOOR_TEMPERATURE_DESCRIPTION = SensorEntityDescription(
key="outdoor_temperature",
translation_key="outdoor_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
suggested_display_precision=1,
)
@dataclass(frozen=True, kw_only=True)
class MelCloudHomeSensorEntityDescription[_UnitT: ATAUnit | ATWUnit](
@@ -128,7 +137,7 @@ async def async_setup_entry(
) -> None:
"""Set up MELCloud Home sensors."""
coordinator = entry.runtime_data.coordinator
energy_coordinator = entry.runtime_data.energy_coordinator
telemetry_coordinator = entry.runtime_data.telemetry_coordinator
async_setup_unit_entities(
coordinator,
@@ -141,11 +150,15 @@ async def async_setup_entry(
if entity_description.exists_fn(unit)
),
(
ATAEnergySensor(coordinator, energy_coordinator, unit)
ATAEnergySensor(coordinator, telemetry_coordinator, unit)
for unit in units
if bool(
unit.capabilities and unit.capabilities.has_energy_consumed_meter
)
if unit.capabilities and unit.capabilities.has_energy_consumed_meter
),
(
ATAOutdoorTemperatureSensor(coordinator, telemetry_coordinator, unit)
for unit in units
if unit.capabilities
and unit.capabilities.has_outdoor_temperature_sensor
),
),
lambda units: chain(
@@ -156,11 +169,9 @@ async def async_setup_entry(
if entity_description.exists_fn(unit)
),
(
ATWEnergySensor(coordinator, energy_coordinator, unit)
ATWEnergySensor(coordinator, telemetry_coordinator, unit)
for unit in units
if bool(
unit.capabilities and unit.capabilities.has_energy_consumed_meter
)
if unit.capabilities and unit.capabilities.has_energy_consumed_meter
),
),
)
@@ -212,41 +223,47 @@ class ATWSensor(MelCloudHomeATWUnitEntity, SensorEntity):
return self.entity_description.value_fn(self.unit, self.coordinator)
class ATAEnergySensor(MelCloudHomeATAUnitEntity, SensorEntity):
"""Representation of a MELCloud Home ATA energy sensor."""
entity_description = ENERGY_CONSUMED_DESCRIPTION
class MelCloudHomeATATelemetrySensor(MelCloudHomeATAUnitEntity, SensorEntity):
"""Base class for MELCloud Home ATA sensors backed by the telemetry coordinator."""
def __init__(
self,
coordinator: MelCloudHomeCoordinator,
energy_coordinator: MelCloudHomeEnergyCoordinator,
telemetry_coordinator: MelCloudHomeTelemetryCoordinator,
unit: ATAUnit,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator, unit)
self._energy_coordinator = energy_coordinator
self._attr_unique_id = f"{unit.id}_{ENERGY_CONSUMED_DESCRIPTION.key}"
self._telemetry_coordinator = telemetry_coordinator
self._attr_unique_id = f"{unit.id}_{self.entity_description.key}"
@override
async def async_added_to_hass(self) -> None:
"""Also react to updates from the energy coordinator."""
"""Also react to updates from the telemetry coordinator."""
await super().async_added_to_hass()
self.async_on_remove(
self._energy_coordinator.async_add_listener(self._handle_coordinator_update)
self._telemetry_coordinator.async_add_listener(
self._handle_coordinator_update
)
)
@property
@override
def available(self) -> bool:
"""Return if the entity is available."""
return super().available and self._energy_coordinator.last_update_success
return super().available and self._telemetry_coordinator.last_update_success
class ATAEnergySensor(MelCloudHomeATATelemetrySensor):
"""Representation of a MELCloud Home ATA energy sensor."""
entity_description = ENERGY_CONSUMED_DESCRIPTION
@property
@override
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self._energy_coordinator.data.get(self._unit_id)
return self._telemetry_coordinator.data.energy.get(self._unit_id)
@property
@override
@@ -255,6 +272,18 @@ class ATAEnergySensor(MelCloudHomeATAUnitEntity, SensorEntity):
return utcnow().replace(day=1, hour=0, minute=0, second=0, microsecond=0)
class ATAOutdoorTemperatureSensor(MelCloudHomeATATelemetrySensor):
"""Representation of a MELCloud Home ATA outdoor temperature sensor."""
entity_description = OUTDOOR_TEMPERATURE_DESCRIPTION
@property
@override
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self._telemetry_coordinator.data.outdoor_temperature.get(self._unit_id)
class ATWEnergySensor(MelCloudHomeATWUnitEntity, SensorEntity):
"""Representation of a MELCloud Home ATW energy sensor."""
@@ -263,33 +292,35 @@ class ATWEnergySensor(MelCloudHomeATWUnitEntity, SensorEntity):
def __init__(
self,
coordinator: MelCloudHomeCoordinator,
energy_coordinator: MelCloudHomeEnergyCoordinator,
telemetry_coordinator: MelCloudHomeTelemetryCoordinator,
unit: ATWUnit,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator, unit)
self._energy_coordinator = energy_coordinator
self._telemetry_coordinator = telemetry_coordinator
self._attr_unique_id = f"{unit.id}_{ENERGY_CONSUMED_DESCRIPTION.key}"
@override
async def async_added_to_hass(self) -> None:
"""Also react to updates from the energy coordinator."""
"""Also react to updates from the telemetry coordinator."""
await super().async_added_to_hass()
self.async_on_remove(
self._energy_coordinator.async_add_listener(self._handle_coordinator_update)
self._telemetry_coordinator.async_add_listener(
self._handle_coordinator_update
)
)
@property
@override
def available(self) -> bool:
"""Return if the entity is available."""
return super().available and self._energy_coordinator.last_update_success
return super().available and self._telemetry_coordinator.last_update_success
@property
@override
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self._energy_coordinator.data.get(self._unit_id)
return self._telemetry_coordinator.data.energy.get(self._unit_id)
@property
@override
@@ -124,6 +124,9 @@
"energy_consumed": {
"name": "Energy consumed (monthly)"
},
"outdoor_temperature": {
"name": "Outdoor temperature"
},
"room_temperature": {
"name": "Room temperature"
},
@@ -47,6 +47,7 @@ def mock_melcloud_client() -> Generator[AsyncMock]:
TelemetryValue.model_validate(value)
for value in load_json_array_fixture("energy.json", DOMAIN)
]
client.get_outdoor_temperature.return_value = 19.5
with (
patch(
@@ -43,7 +43,7 @@
"hasDemandSideControl": false,
"hasHalfDegreeIncrements": true,
"supportsWideVane": false,
"hasOutdoorTemperatureSensor": false,
"hasOutdoorTemperatureSensor": true,
"hasVaneVertical": true,
"hasVaneHorizontal": true,
"hasStandbyMode": false
@@ -36,7 +36,7 @@
'has_energy_consumed_meter': True,
'has_fan_operation_mode': None,
'has_half_degree_increments': True,
'has_outdoor_temperature_sensor': False,
'has_outdoor_temperature_sensor': True,
'has_standby_mode': False,
'has_vane_horizontal': True,
'has_vane_vertical': True,
@@ -269,9 +269,14 @@
'number_of_guest_devices_allowed': 10,
'number_of_guests_allowed_per_unit': 5,
}),
'energy_coordinator': dict({
'ata-unit-uuid-1': 450.5,
'atw-unit-uuid-1': 450.5,
'telemetry_coordinator': dict({
'energy': dict({
'ata-unit-uuid-1': 450.5,
'atw-unit-uuid-1': 450.5,
}),
'outdoor_temperature': dict({
'ata-unit-uuid-1': 19.5,
}),
}),
})
# ---
@@ -352,6 +352,64 @@
'state': '0.4505',
})
# ---
# name: test_all_entities[sensor.living_room_ac_outdoor_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.living_room_ac_outdoor_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Outdoor temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Outdoor temperature',
'platform': 'melcloud_home',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'outdoor_temperature',
'unique_id': 'ata-unit-uuid-1_outdoor_temperature',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_all_entities[sensor.living_room_ac_outdoor_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'temperature',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Living Room AC Outdoor temperature',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.living_room_ac_outdoor_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '19.5',
})
# ---
# name: test_all_entities[sensor.living_room_ac_room_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
+54 -16
View File
@@ -13,7 +13,7 @@ import pytest
from homeassistant.components.melcloud_home.const import DOMAIN
from homeassistant.components.melcloud_home.coordinator import (
ENERGY_UPDATE_INTERVAL,
TELEMETRY_UPDATE_INTERVAL,
UPDATE_INTERVAL,
)
from homeassistant.config_entries import ConfigEntryState
@@ -216,27 +216,27 @@ async def test_energy_update_cycle_fails(
) -> None:
"""Test that a failing energy fetch clears the value without unloading the entry."""
await setup_integration(hass, mock_config_entry)
energy_coordinator = mock_config_entry.runtime_data.energy_coordinator
telemetry_coordinator = mock_config_entry.runtime_data.telemetry_coordinator
assert energy_coordinator.data["ata-unit-uuid-1"] is not None
assert energy_coordinator.data["atw-unit-uuid-1"] is not None
assert telemetry_coordinator.data.energy["ata-unit-uuid-1"] is not None
assert telemetry_coordinator.data.energy["atw-unit-uuid-1"] is not None
mock_melcloud_client.get_energy_telemetry.side_effect = exception
freezer.tick(ENERGY_UPDATE_INTERVAL)
freezer.tick(TELEMETRY_UPDATE_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
assert energy_coordinator.data["ata-unit-uuid-1"] is None
assert energy_coordinator.data["atw-unit-uuid-1"] is None
assert telemetry_coordinator.data.energy["ata-unit-uuid-1"] is None
assert telemetry_coordinator.data.energy["atw-unit-uuid-1"] is None
# Demonstrate a recovery
mock_melcloud_client.get_energy_telemetry.side_effect = None
freezer.tick(ENERGY_UPDATE_INTERVAL)
freezer.tick(TELEMETRY_UPDATE_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
assert energy_coordinator.data["ata-unit-uuid-1"] is not None
assert energy_coordinator.data["atw-unit-uuid-1"] is not None
assert telemetry_coordinator.data.energy["ata-unit-uuid-1"] is not None
assert telemetry_coordinator.data.energy["atw-unit-uuid-1"] is not None
@pytest.mark.parametrize(
@@ -258,11 +258,13 @@ async def test_energy_telemetry_fetch_failure(
await setup_integration(hass, mock_config_entry)
mock_melcloud_client.get_energy_telemetry.side_effect = exception
freezer.tick(ENERGY_UPDATE_INTERVAL)
freezer.tick(TELEMETRY_UPDATE_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_config_entry.runtime_data.energy_coordinator.last_update_success is True
assert (
mock_config_entry.runtime_data.telemetry_coordinator.last_update_success is True
)
assert mock_config_entry.runtime_data.coordinator.last_update_success is True
@@ -274,19 +276,19 @@ async def test_energy_telemetry_fetch_failure(
pytest.param(MelCloudHomeTimeoutError("timeout"), id="timeout"),
],
)
async def test_energy_coordinator_context_fetch_failure(
async def test_telemetry_coordinator_context_fetch_failure(
hass: HomeAssistant,
mock_melcloud_client: AsyncMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
exception: Exception,
) -> None:
"""Test that a failing energy coordinator refresh doesn't affect the main coordinator."""
"""Test that a failing telemetry coordinator refresh doesn't affect the main coordinator."""
await setup_integration(hass, mock_config_entry)
# Split the margin so the main coordinator's rescheduled refresh doesn't land
# exactly on the energy coordinator's, which would make both fail below.
freezer.tick(ENERGY_UPDATE_INTERVAL - UPDATE_INTERVAL / 2)
# exactly on the telemetry coordinator's, which would make both fail below.
freezer.tick(TELEMETRY_UPDATE_INTERVAL - UPDATE_INTERVAL / 2)
async_fire_time_changed(hass)
await hass.async_block_till_done()
@@ -308,3 +310,39 @@ async def test_energy_coordinator_context_fetch_failure(
)
)
assert room_temperature_sensor.state != STATE_UNAVAILABLE
@pytest.mark.parametrize(
"exception",
[
pytest.param(MelCloudHomeAuthenticationError("bad creds"), id="auth"),
pytest.param(MelCloudHomeConnectionError("cannot connect"), id="connection"),
pytest.param(MelCloudHomeTimeoutError("timeout"), id="timeout"),
],
)
async def test_outdoor_temperature_update_cycle_fails(
hass: HomeAssistant,
mock_melcloud_client: AsyncMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
exception: Exception,
) -> None:
"""Test that a failing outdoor temperature fetch clears the value without unloading the entry."""
await setup_integration(hass, mock_config_entry)
telemetry_coordinator = mock_config_entry.runtime_data.telemetry_coordinator
assert telemetry_coordinator.data.outdoor_temperature["ata-unit-uuid-1"] is not None
mock_melcloud_client.get_outdoor_temperature.side_effect = exception
freezer.tick(TELEMETRY_UPDATE_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
assert telemetry_coordinator.data.outdoor_temperature["ata-unit-uuid-1"] is None
mock_melcloud_client.get_outdoor_temperature.side_effect = None
freezer.tick(TELEMETRY_UPDATE_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
assert telemetry_coordinator.data.outdoor_temperature["ata-unit-uuid-1"] is not None