Add binary sensor platform for MELCloud ATW devices (#168128)

Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
This commit is contained in:
Raphael Hehl
2026-04-14 17:28:56 +02:00
committed by GitHub
co-authored by RaHehl
parent 8217d3683a
commit 939412717f
8 changed files with 651 additions and 1 deletions
@@ -19,7 +19,12 @@ from homeassistant.helpers.update_coordinator import UpdateFailed
from .coordinator import MelCloudConfigEntry, MelCloudDeviceUpdateCoordinator
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR, Platform.WATER_HEATER]
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.CLIMATE,
Platform.SENSOR,
Platform.WATER_HEATER,
]
async def async_setup_entry(hass: HomeAssistant, entry: MelCloudConfigEntry) -> bool:
@@ -0,0 +1,175 @@
"""Support for MelCloud device binary sensors."""
from __future__ import annotations
from collections.abc import Callable
import dataclasses
from typing import Any
from pymelcloud import DEVICE_TYPE_ATW
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import MelCloudConfigEntry, MelCloudDeviceUpdateCoordinator
from .entity import MelCloudEntity
@dataclasses.dataclass(frozen=True, kw_only=True)
class MelcloudBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Describes Melcloud binary sensor entity."""
value_fn: Callable[[Any], bool | None]
enabled: Callable[[Any], bool]
ATW_BINARY_SENSORS: tuple[MelcloudBinarySensorEntityDescription, ...] = (
MelcloudBinarySensorEntityDescription(
key="boiler_status",
translation_key="boiler_status",
device_class=BinarySensorDeviceClass.RUNNING,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.device.boiler_status,
enabled=lambda data: data.device.boiler_status is not None,
),
MelcloudBinarySensorEntityDescription(
key="booster_heater1_status",
translation_key="booster_heater_status",
translation_placeholders={"number": "1"},
device_class=BinarySensorDeviceClass.RUNNING,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.device.booster_heater1_status,
enabled=lambda data: data.device.booster_heater1_status is not None,
),
MelcloudBinarySensorEntityDescription(
key="booster_heater2_status",
translation_key="booster_heater_status",
translation_placeholders={"number": "2"},
device_class=BinarySensorDeviceClass.RUNNING,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: data.device.booster_heater2_status,
enabled=lambda data: data.device.booster_heater2_status is not None,
),
MelcloudBinarySensorEntityDescription(
key="booster_heater2plus_status",
translation_key="booster_heater_status",
translation_placeholders={"number": "2+"},
device_class=BinarySensorDeviceClass.RUNNING,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: data.device.booster_heater2plus_status,
enabled=lambda data: data.device.booster_heater2plus_status is not None,
),
MelcloudBinarySensorEntityDescription(
key="immersion_heater_status",
translation_key="immersion_heater_status",
device_class=BinarySensorDeviceClass.RUNNING,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.device.immersion_heater_status,
enabled=lambda data: data.device.immersion_heater_status is not None,
),
MelcloudBinarySensorEntityDescription(
key="water_pump1_status",
translation_key="water_pump_status",
translation_placeholders={"number": "1"},
device_class=BinarySensorDeviceClass.RUNNING,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.device.water_pump1_status,
enabled=lambda data: data.device.water_pump1_status is not None,
),
MelcloudBinarySensorEntityDescription(
key="water_pump2_status",
translation_key="water_pump_status",
translation_placeholders={"number": "2"},
device_class=BinarySensorDeviceClass.RUNNING,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.device.water_pump2_status,
enabled=lambda data: data.device.water_pump2_status is not None,
),
MelcloudBinarySensorEntityDescription(
key="water_pump3_status",
translation_key="water_pump_status",
translation_placeholders={"number": "3"},
device_class=BinarySensorDeviceClass.RUNNING,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: data.device.water_pump3_status,
enabled=lambda data: data.device.water_pump3_status is not None,
),
MelcloudBinarySensorEntityDescription(
key="water_pump4_status",
translation_key="water_pump_status",
translation_placeholders={"number": "4"},
device_class=BinarySensorDeviceClass.RUNNING,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: data.device.water_pump4_status,
enabled=lambda data: data.device.water_pump4_status is not None,
),
MelcloudBinarySensorEntityDescription(
key="valve_3way_status",
translation_key="valve_3way_status",
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.device.valve_3way_status,
enabled=lambda data: data.device.valve_3way_status is not None,
),
MelcloudBinarySensorEntityDescription(
key="valve_2way_status",
translation_key="valve_2way_status",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: data.device.valve_2way_status,
enabled=lambda data: data.device.valve_2way_status is not None,
),
)
async def async_setup_entry(
_hass: HomeAssistant,
entry: MelCloudConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up MELCloud device binary sensors based on config_entry."""
coordinator = entry.runtime_data
if DEVICE_TYPE_ATW not in coordinator:
return
entities: list[MelDeviceBinarySensor] = [
MelDeviceBinarySensor(coord, description)
for description in ATW_BINARY_SENSORS
for coord in coordinator[DEVICE_TYPE_ATW]
if description.enabled(coord)
]
async_add_entities(entities)
class MelDeviceBinarySensor(MelCloudEntity, BinarySensorEntity):
"""Representation of a Binary Sensor."""
entity_description: MelcloudBinarySensorEntityDescription
def __init__(
self,
coordinator: MelCloudDeviceUpdateCoordinator,
description: MelcloudBinarySensorEntityDescription,
) -> None:
"""Initialize the binary sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = (
f"{coordinator.device.serial}-{coordinator.device.mac}-{description.key}"
)
self._attr_device_info = coordinator.device_info
@property
def is_on(self) -> bool | None:
"""Return the state of the binary sensor."""
return self.entity_description.value_fn(self.coordinator)
@@ -1,5 +1,25 @@
{
"entity": {
"binary_sensor": {
"boiler_status": {
"default": "mdi:water-boiler-off",
"state": {
"on": "mdi:water-boiler"
}
},
"valve_2way_status": {
"default": "mdi:valve-closed",
"state": {
"on": "mdi:valve-open"
}
},
"valve_3way_status": {
"default": "mdi:valve-closed",
"state": {
"on": "mdi:valve-open"
}
}
},
"sensor": {
"energy_consumed": {
"default": "mdi:factory"
@@ -42,6 +42,26 @@
}
},
"entity": {
"binary_sensor": {
"boiler_status": {
"name": "Boiler"
},
"booster_heater_status": {
"name": "Booster heater {number}"
},
"immersion_heater_status": {
"name": "Immersion heater"
},
"valve_2way_status": {
"name": "2-way valve"
},
"valve_3way_status": {
"name": "3-way valve"
},
"water_pump_status": {
"name": "Water pump {number}"
}
},
"sensor": {
"condensing_temperature": {
"name": "Condensing temperature"
+18
View File
@@ -1 +1,19 @@
"""Tests for the MELCloud integration."""
from unittest.mock import patch
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def setup_platform(
hass: HomeAssistant, config_entry: MockConfigEntry, platforms: list[Platform]
) -> None:
"""Set up the MELCloud platform."""
config_entry.add_to_hass(hass)
with patch("homeassistant.components.melcloud.PLATFORMS", platforms):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
+82
View File
@@ -0,0 +1,82 @@
"""Test helpers for MELCloud."""
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
import pymelcloud
import pytest
from homeassistant.components.melcloud.const import DOMAIN
from homeassistant.const import CONF_TOKEN
from tests.common import MockConfigEntry
MOCK_SERIAL = "ABC123456"
MOCK_MAC = "AA:BB:CC:DD:EE:FF"
def _build_mock_atw_device() -> MagicMock:
"""Build a mock AtwDevice with all properties."""
device = MagicMock()
device.device_id = "atw001"
device.building_id = "building001"
device.mac = MOCK_MAC
device.serial = MOCK_SERIAL
device.name = "Ecodan"
device.units = [{"model": "ATW-Unit", "serial": "unit-serial-1"}]
device.boiler_status = True
device.booster_heater1_status = False
device.booster_heater2_status = None
device.booster_heater2plus_status = None
device.immersion_heater_status = False
device.water_pump1_status = True
device.water_pump2_status = False
device.water_pump3_status = None
device.water_pump4_status = None
device.valve_3way_status = True
device.valve_2way_status = None
device.zones = []
device.update = AsyncMock()
return device
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Mock a MELCloud config entry."""
return MockConfigEntry(
domain=DOMAIN,
title="test-email@example.com",
data={
CONF_TOKEN: "test-token",
"username": "test-email@example.com",
},
entry_id="melcloud_test_entry",
unique_id="test-email@example.com",
)
@pytest.fixture
def mock_atw_device() -> MagicMock:
"""Return the mock ATW device for direct access in tests."""
return _build_mock_atw_device()
@pytest.fixture
def mock_get_devices(mock_atw_device: MagicMock) -> Generator[MagicMock]:
"""Mock pymelcloud.get_devices with a single ATW device."""
async def _get_devices(**kwargs):
return {
pymelcloud.DEVICE_TYPE_ATA: [],
pymelcloud.DEVICE_TYPE_ATW: [mock_atw_device],
}
with patch(
"homeassistant.components.melcloud.get_devices",
side_effect=_get_devices,
) as mock:
yield mock
@@ -0,0 +1,306 @@
# serializer version: 1
# name: test_all_entities[binary_sensor.ecodan_3_way_valve-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.ecodan_3_way_valve',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': '3-way valve',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': '3-way valve',
'platform': 'melcloud',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'valve_3way_status',
'unique_id': 'ABC123456-AA:BB:CC:DD:EE:FF-valve_3way_status',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[binary_sensor.ecodan_3_way_valve-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Ecodan 3-way valve',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.ecodan_3_way_valve',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_all_entities[binary_sensor.ecodan_boiler-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.ecodan_boiler',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Boiler',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.RUNNING: 'running'>,
'original_icon': None,
'original_name': 'Boiler',
'platform': 'melcloud',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'boiler_status',
'unique_id': 'ABC123456-AA:BB:CC:DD:EE:FF-boiler_status',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[binary_sensor.ecodan_boiler-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'running',
'friendly_name': 'Ecodan Boiler',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.ecodan_boiler',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_all_entities[binary_sensor.ecodan_booster_heater_1-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.ecodan_booster_heater_1',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Booster heater 1',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.RUNNING: 'running'>,
'original_icon': None,
'original_name': 'Booster heater 1',
'platform': 'melcloud',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'booster_heater_status',
'unique_id': 'ABC123456-AA:BB:CC:DD:EE:FF-booster_heater1_status',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[binary_sensor.ecodan_booster_heater_1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'running',
'friendly_name': 'Ecodan Booster heater 1',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.ecodan_booster_heater_1',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[binary_sensor.ecodan_immersion_heater-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.ecodan_immersion_heater',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Immersion heater',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.RUNNING: 'running'>,
'original_icon': None,
'original_name': 'Immersion heater',
'platform': 'melcloud',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'immersion_heater_status',
'unique_id': 'ABC123456-AA:BB:CC:DD:EE:FF-immersion_heater_status',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[binary_sensor.ecodan_immersion_heater-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'running',
'friendly_name': 'Ecodan Immersion heater',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.ecodan_immersion_heater',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_all_entities[binary_sensor.ecodan_water_pump_1-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.ecodan_water_pump_1',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Water pump 1',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.RUNNING: 'running'>,
'original_icon': None,
'original_name': 'Water pump 1',
'platform': 'melcloud',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'water_pump_status',
'unique_id': 'ABC123456-AA:BB:CC:DD:EE:FF-water_pump1_status',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[binary_sensor.ecodan_water_pump_1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'running',
'friendly_name': 'Ecodan Water pump 1',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.ecodan_water_pump_1',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_all_entities[binary_sensor.ecodan_water_pump_2-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.ecodan_water_pump_2',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Water pump 2',
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.RUNNING: 'running'>,
'original_icon': None,
'original_name': 'Water pump 2',
'platform': 'melcloud',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'water_pump_status',
'unique_id': 'ABC123456-AA:BB:CC:DD:EE:FF-water_pump2_status',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[binary_sensor.ecodan_water_pump_2-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'running',
'friendly_name': 'Ecodan Water pump 2',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.ecodan_water_pump_2',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
@@ -0,0 +1,24 @@
"""Test the MELCloud binary sensor platform."""
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 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 binary sensor entities with snapshot."""
await setup_platform(hass, mock_config_entry, [Platform.BINARY_SENSOR])
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)