Add new sensor entities for MELCloud Air-to-Water (ATW/Ecodan) heat pump devices (#168105)

Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
This commit is contained in:
Raphael Hehl
2026-04-17 23:55:37 +02:00
committed by GitHub
co-authored by RaHehl
parent e0535fb1b2
commit 07db7f0024
7 changed files with 1544 additions and 90 deletions
@@ -21,11 +21,35 @@
}
},
"sensor": {
"daily_cooling_energy_consumed": {
"default": "mdi:snowflake"
},
"daily_cooling_energy_produced": {
"default": "mdi:snowflake"
},
"daily_heating_energy_consumed": {
"default": "mdi:fire"
},
"daily_heating_energy_produced": {
"default": "mdi:fire"
},
"daily_hot_water_energy_consumed": {
"default": "mdi:water-boiler"
},
"daily_hot_water_energy_produced": {
"default": "mdi:water-boiler"
},
"demand_percentage": {
"default": "mdi:gauge"
},
"energy_consumed": {
"default": "mdi:factory"
},
"fan_frequency": {
"default": "mdi:fan"
},
"mixing_tank_temperature": {
"default": "mdi:water-thermometer"
}
}
},
+149 -24
View File
@@ -16,6 +16,7 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.const import (
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
EntityCategory,
UnitOfEnergy,
@@ -34,7 +35,7 @@ from .entity import MelCloudEntity
class MelcloudSensorEntityDescription(SensorEntityDescription):
"""Describes Melcloud sensor entity."""
value_fn: Callable[[Any], float]
value_fn: Callable[[Any], float | None]
enabled: Callable[[Any], bool]
@@ -45,8 +46,8 @@ ATA_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda x: x.device.room_temperature,
enabled=lambda x: True,
value_fn=lambda data: data.device.room_temperature,
enabled=lambda data: True,
),
MelcloudSensorEntityDescription(
key="energy",
@@ -54,8 +55,8 @@ ATA_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda x: x.device.total_energy_consumed,
enabled=lambda x: x.device.has_energy_consumed_meter,
value_fn=lambda data: data.device.total_energy_consumed,
enabled=lambda data: data.device.has_energy_consumed_meter,
),
MelcloudSensorEntityDescription(
key="outside_temperature",
@@ -63,8 +64,8 @@ ATA_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda x: x.device.outdoor_temperature,
enabled=lambda x: x.device.has_outdoor_temperature,
value_fn=lambda data: data.device.outdoor_temperature,
enabled=lambda data: data.device.has_outdoor_temperature,
),
)
ATW_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = (
@@ -74,8 +75,8 @@ ATW_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda x: x.device.outside_temperature,
enabled=lambda x: True,
value_fn=lambda data: data.device.outside_temperature,
enabled=lambda data: True,
),
MelcloudSensorEntityDescription(
key="tank_temperature",
@@ -83,8 +84,58 @@ ATW_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda x: x.device.tank_temperature,
enabled=lambda x: True,
value_fn=lambda data: data.device.tank_temperature,
enabled=lambda data: True,
),
MelcloudSensorEntityDescription(
key="system_flow_temperature",
translation_key="flow_temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.device.flow_temperature,
enabled=lambda data: data.device.flow_temperature is not None,
),
MelcloudSensorEntityDescription(
key="system_return_temperature",
translation_key="return_temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.device.return_temperature,
enabled=lambda data: data.device.return_temperature is not None,
),
MelcloudSensorEntityDescription(
key="flow_temperature_boiler",
translation_key="flow_temperature_boiler",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.device.flow_temperature_boiler,
enabled=lambda data: data.device.flow_temperature_boiler is not None,
),
MelcloudSensorEntityDescription(
key="return_temperature_boiler",
translation_key="return_temperature_boiler",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.device.return_temperature_boiler,
enabled=lambda data: data.device.return_temperature_boiler is not None,
),
MelcloudSensorEntityDescription(
key="mixing_tank_temperature",
translation_key="mixing_tank_temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
value_fn=lambda data: data.device.mixing_tank_temperature,
enabled=lambda data: data.device.mixing_tank_temperature is not None,
),
MelcloudSensorEntityDescription(
key="condensing_temperature",
@@ -92,8 +143,9 @@ ATW_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda x: x.device.get_device_prop("CondensingTemperature"),
enabled=lambda x: True,
suggested_display_precision=1,
value_fn=lambda data: data.device.condensing_temperature,
enabled=lambda data: True,
),
MelcloudSensorEntityDescription(
key="fan_frequency",
@@ -101,8 +153,17 @@ ATW_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfFrequency.HERTZ,
device_class=SensorDeviceClass.FREQUENCY,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda x: x.device.get_device_prop("HeatPumpFrequency"),
enabled=lambda x: True,
value_fn=lambda data: data.device.heat_pump_frequency,
enabled=lambda data: True,
),
MelcloudSensorEntityDescription(
key="demand_percentage",
translation_key="demand_percentage",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
value_fn=lambda data: data.device.demand_percentage,
enabled=lambda data: data.device.demand_percentage is not None,
),
MelcloudSensorEntityDescription(
key="rssi",
@@ -110,16 +171,80 @@ ATW_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = (
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda x: x.device.wifi_signal,
enabled=lambda x: True,
value_fn=lambda data: data.device.wifi_signal,
enabled=lambda data: True,
),
MelcloudSensorEntityDescription(
key="energy_produced",
translation_key="energy_produced",
native_unit_of_measurement=UnitOfPower.KILO_WATT,
device_class=SensorDeviceClass.POWER,
value_fn=lambda x: x.device.get_device_prop("CurrentEnergyProduced"),
enabled=lambda x: True,
value_fn=lambda data: data.device.get_device_prop("CurrentEnergyProduced"),
enabled=lambda data: True,
),
MelcloudSensorEntityDescription(
key="daily_heating_energy_consumed",
translation_key="daily_heating_energy_consumed",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=1,
value_fn=lambda data: data.device.daily_heating_energy_consumed,
enabled=lambda data: data.device.daily_heating_energy_consumed is not None,
),
MelcloudSensorEntityDescription(
key="daily_heating_energy_produced",
translation_key="daily_heating_energy_produced",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=1,
entity_registry_enabled_default=False,
value_fn=lambda data: data.device.daily_heating_energy_produced,
enabled=lambda data: data.device.daily_heating_energy_produced is not None,
),
MelcloudSensorEntityDescription(
key="daily_cooling_energy_consumed",
translation_key="daily_cooling_energy_consumed",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=1,
entity_registry_enabled_default=False,
value_fn=lambda data: data.device.daily_cooling_energy_consumed,
enabled=lambda data: data.device.daily_cooling_energy_consumed is not None,
),
MelcloudSensorEntityDescription(
key="daily_cooling_energy_produced",
translation_key="daily_cooling_energy_produced",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=1,
entity_registry_enabled_default=False,
value_fn=lambda data: data.device.daily_cooling_energy_produced,
enabled=lambda data: data.device.daily_cooling_energy_produced is not None,
),
MelcloudSensorEntityDescription(
key="daily_hot_water_energy_consumed",
translation_key="daily_hot_water_energy_consumed",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=1,
value_fn=lambda data: data.device.daily_hot_water_energy_consumed,
enabled=lambda data: data.device.daily_hot_water_energy_consumed is not None,
),
MelcloudSensorEntityDescription(
key="daily_hot_water_energy_produced",
translation_key="daily_hot_water_energy_produced",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=1,
entity_registry_enabled_default=False,
value_fn=lambda data: data.device.daily_hot_water_energy_produced,
enabled=lambda data: data.device.daily_hot_water_energy_produced is not None,
),
)
ATW_ZONE_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = (
@@ -130,7 +255,7 @@ ATW_ZONE_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda zone: zone.room_temperature,
enabled=lambda x: True,
enabled=lambda data: True,
),
MelcloudSensorEntityDescription(
key="flow_temperature",
@@ -138,8 +263,8 @@ ATW_ZONE_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda zone: zone.flow_temperature,
enabled=lambda x: True,
value_fn=lambda zone: zone.zone_flow_temperature,
enabled=lambda data: True,
),
MelcloudSensorEntityDescription(
key="return_temperature",
@@ -147,8 +272,8 @@ ATW_ZONE_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda zone: zone.return_temperature,
enabled=lambda x: True,
value_fn=lambda zone: zone.zone_return_temperature,
enabled=lambda data: True,
),
)
+32 -2
View File
@@ -66,6 +66,27 @@
"condensing_temperature": {
"name": "Condensing temperature"
},
"daily_cooling_energy_consumed": {
"name": "Daily cooling energy consumed"
},
"daily_cooling_energy_produced": {
"name": "Daily cooling energy produced"
},
"daily_heating_energy_consumed": {
"name": "Daily heating energy consumed"
},
"daily_heating_energy_produced": {
"name": "Daily heating energy produced"
},
"daily_hot_water_energy_consumed": {
"name": "Daily hot water energy consumed"
},
"daily_hot_water_energy_produced": {
"name": "Daily hot water energy produced"
},
"demand_percentage": {
"name": "Demand percentage"
},
"energy_consumed": {
"name": "Energy consumed"
},
@@ -73,16 +94,25 @@
"name": "Energy produced"
},
"fan_frequency": {
"name": "Fan frequency"
"name": "Heat pump frequency"
},
"flow_temperature": {
"name": "Flow temperature"
},
"flow_temperature_boiler": {
"name": "Boiler flow temperature"
},
"mixing_tank_temperature": {
"name": "Mixing tank temperature"
},
"outside_temperature": {
"name": "Outside temperature"
},
"return_temperature": {
"name": "Flow return temperature"
"name": "Return temperature"
},
"return_temperature_boiler": {
"name": "Boiler return temperature"
},
"room_temperature": {
"name": "Room temperature"
+38 -1
View File
@@ -15,6 +15,17 @@ MOCK_SERIAL = "ABC123456"
MOCK_MAC = "AA:BB:CC:DD:EE:FF"
def _build_mock_zone(zone_index: int) -> MagicMock:
"""Build a mock Zone object."""
zone = MagicMock()
zone.zone_index = zone_index
zone.name = f"Zone {zone_index}"
zone.room_temperature = 21.5 + zone_index
zone.zone_flow_temperature = 35.0 + zone_index
zone.zone_return_temperature = 30.0 + zone_index
return zone
def _build_mock_atw_device() -> MagicMock:
"""Build a mock AtwDevice with all properties."""
device = MagicMock()
@@ -25,6 +36,7 @@ def _build_mock_atw_device() -> MagicMock:
device.name = "Ecodan"
device.units = [{"model": "ATW-Unit", "serial": "unit-serial-1"}]
# Binary sensor properties
device.boiler_status = True
device.booster_heater1_status = False
device.booster_heater2_status = None
@@ -37,7 +49,32 @@ def _build_mock_atw_device() -> MagicMock:
device.valve_3way_status = True
device.valve_2way_status = None
device.zones = []
# Existing ATW sensors
device.outside_temperature = 7.5
device.tank_temperature = 48.0
# New temperature sensors
device.flow_temperature = 38.5
device.return_temperature = 33.2
device.flow_temperature_boiler = 40.1
device.return_temperature_boiler = 35.3
device.mixing_tank_temperature = 42.0
device.condensing_temperature = 55.0
device.heat_pump_frequency = 52
device.demand_percentage = 75
device.wifi_signal = -65
device.get_device_prop = MagicMock(return_value=3.5)
# Daily energy
device.daily_heating_energy_consumed = 12.5
device.daily_heating_energy_produced = 35.0
device.daily_cooling_energy_consumed = 0.0
device.daily_cooling_energy_produced = 0.0
device.daily_hot_water_energy_consumed = 5.2
device.daily_hot_water_energy_produced = 14.8
# Zones
device.zones = [_build_mock_zone(1)]
device.update = AsyncMock()
File diff suppressed because it is too large Load Diff
@@ -1,63 +0,0 @@
"""Test the MELCloud ATW zone sensor."""
from unittest.mock import MagicMock, patch
import pytest
from homeassistant.components.melcloud.sensor import ATW_ZONE_SENSORS, AtwZoneSensor
@pytest.fixture
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."""
mock = MagicMock()
mock.zone_index = 1
return mock
@pytest.fixture
def mock_zone_2():
"""Mock zone 2."""
mock = MagicMock()
mock.zone_index = 2
return mock
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,
mock_zone_1,
ATW_ZONE_SENSORS[0], # room_temperature
)
assert sensor_1.unique_id == "1234-11:11:11:11:11:11-room_temperature"
sensor_2 = AtwZoneSensor(
mock_device,
mock_zone_2,
ATW_ZONE_SENSORS[0], # room_temperature
)
assert sensor_2.unique_id == "1234-11:11:11:11:11:11-room_temperature-zone-2"
+92
View File
@@ -0,0 +1,92 @@
"""Test the MELCloud sensor platform."""
from unittest.mock import MagicMock
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_platform
from .conftest import MOCK_MAC, MOCK_SERIAL
from tests.common import MockConfigEntry, snapshot_platform
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "mock_get_devices")
async def test_all_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test all sensor entities with snapshot."""
await setup_platform(hass, mock_config_entry, [Platform.SENSOR])
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.usefixtures("mock_get_devices")
async def test_zone_sensor_unique_ids(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_atw_device: MagicMock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test unique ID generation for zone sensors with multiple zones."""
zone_2 = MagicMock()
zone_2.zone_index = 2
zone_2.name = "Zone 2"
zone_2.room_temperature = 23.5
zone_2.zone_flow_temperature = 37.0
zone_2.zone_return_temperature = 32.0
mock_atw_device.zones = [mock_atw_device.zones[0], zone_2]
await setup_platform(hass, mock_config_entry, [Platform.SENSOR])
# Zone 1 sensors - no zone suffix in unique ID
entry = entity_registry.async_get("sensor.ecodan_zone_1_room_temperature")
assert entry is not None
assert entry.unique_id == f"{MOCK_SERIAL}-{MOCK_MAC}-room_temperature"
entry = entity_registry.async_get("sensor.ecodan_zone_1_flow_temperature")
assert entry is not None
assert entry.unique_id == f"{MOCK_SERIAL}-{MOCK_MAC}-flow_temperature"
entry = entity_registry.async_get("sensor.ecodan_zone_1_return_temperature")
assert entry is not None
assert entry.unique_id == f"{MOCK_SERIAL}-{MOCK_MAC}-return_temperature"
# Zone 2 sensors - with zone suffix in unique ID
entry = entity_registry.async_get("sensor.ecodan_zone_2_room_temperature")
assert entry is not None
assert entry.unique_id == f"{MOCK_SERIAL}-{MOCK_MAC}-room_temperature-zone-2"
entry = entity_registry.async_get("sensor.ecodan_zone_2_flow_temperature")
assert entry is not None
assert entry.unique_id == f"{MOCK_SERIAL}-{MOCK_MAC}-flow_temperature-zone-2"
entry = entity_registry.async_get("sensor.ecodan_zone_2_return_temperature")
assert entry is not None
assert entry.unique_id == f"{MOCK_SERIAL}-{MOCK_MAC}-return_temperature-zone-2"
@pytest.mark.usefixtures("mock_get_devices")
async def test_sensors_not_created_when_none(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_atw_device: MagicMock,
) -> None:
"""Test sensors with enabled check are not created when property is None."""
mock_atw_device.flow_temperature = None
mock_atw_device.mixing_tank_temperature = None
mock_atw_device.demand_percentage = None
mock_atw_device.daily_heating_energy_consumed = None
await setup_platform(hass, mock_config_entry, [Platform.SENSOR])
assert hass.states.get("sensor.ecodan_flow_temperature") is None
assert hass.states.get("sensor.ecodan_mixing_tank_temperature") is None
assert hass.states.get("sensor.ecodan_demand_percentage") is None
assert hass.states.get("sensor.ecodan_daily_heating_energy_consumed") is None