Add sensor platform to eheimdigital (#138809)

* Add fan platform to eheimdigital

* Fix pylint

* Convert fan to sensor platform

* Remove unnecessary changes

* Add state update test

* Review

* Review

* Review
This commit is contained in:
Sid 2025-04-06 14:46:19 +02:00 committed by GitHub
parent 8aee79085a
commit b35a44a0e0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 416 additions and 3 deletions

View File

@ -9,7 +9,7 @@ from homeassistant.helpers.device_registry import DeviceEntry
from .const import DOMAIN
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
PLATFORMS = [Platform.CLIMATE, Platform.LIGHT]
PLATFORMS = [Platform.CLIMATE, Platform.LIGHT, Platform.SENSOR]
async def async_setup_entry(

View File

@ -0,0 +1,18 @@
{
"entity": {
"sensor": {
"current_speed": {
"default": "mdi:pump"
},
"service_hours": {
"default": "mdi:wrench-clock"
},
"error_code": {
"default": "mdi:alert-octagon",
"state": {
"no_error": "mdi:check-circle"
}
}
}
}
}

View File

@ -0,0 +1,114 @@
"""EHEIM Digital sensors."""
from collections.abc import Callable
from dataclasses import dataclass
from typing import Generic, TypeVar, override
from eheimdigital.classic_vario import EheimDigitalClassicVario
from eheimdigital.device import EheimDigitalDevice
from eheimdigital.types import FilterErrorCode
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
from homeassistant.components.sensor.const import SensorDeviceClass
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
from .entity import EheimDigitalEntity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
_DeviceT_co = TypeVar("_DeviceT_co", bound=EheimDigitalDevice, covariant=True)
@dataclass(frozen=True, kw_only=True)
class EheimDigitalSensorDescription(SensorEntityDescription, Generic[_DeviceT_co]):
"""Class describing EHEIM Digital sensor entities."""
value_fn: Callable[[_DeviceT_co], float | str | None]
CLASSICVARIO_DESCRIPTIONS: tuple[
EheimDigitalSensorDescription[EheimDigitalClassicVario], ...
] = (
EheimDigitalSensorDescription[EheimDigitalClassicVario](
key="current_speed",
translation_key="current_speed",
value_fn=lambda device: device.current_speed,
native_unit_of_measurement=PERCENTAGE,
),
EheimDigitalSensorDescription[EheimDigitalClassicVario](
key="service_hours",
translation_key="service_hours",
value_fn=lambda device: device.service_hours,
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.HOURS,
suggested_unit_of_measurement=UnitOfTime.DAYS,
entity_category=EntityCategory.DIAGNOSTIC,
),
EheimDigitalSensorDescription[EheimDigitalClassicVario](
key="error_code",
translation_key="error_code",
value_fn=(
lambda device: device.error_code.name.lower()
if device.error_code is not None
else None
),
device_class=SensorDeviceClass.ENUM,
options=[name.lower() for name in FilterErrorCode._member_names_],
entity_category=EntityCategory.DIAGNOSTIC,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: EheimDigitalConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the callbacks for the coordinator so lights can be added as devices are found."""
coordinator = entry.runtime_data
def async_setup_device_entities(
device_address: dict[str, EheimDigitalDevice],
) -> None:
"""Set up the light entities for one or multiple devices."""
entities: list[EheimDigitalSensor[EheimDigitalDevice]] = []
for device in device_address.values():
if isinstance(device, EheimDigitalClassicVario):
entities += [
EheimDigitalSensor[EheimDigitalClassicVario](
coordinator, device, description
)
for description in CLASSICVARIO_DESCRIPTIONS
]
async_add_entities(entities)
coordinator.add_platform_callback(async_setup_device_entities)
async_setup_device_entities(coordinator.hub.devices)
class EheimDigitalSensor(
EheimDigitalEntity[_DeviceT_co], SensorEntity, Generic[_DeviceT_co]
):
"""Represent a EHEIM Digital sensor entity."""
entity_description: EheimDigitalSensorDescription[_DeviceT_co]
def __init__(
self,
coordinator: EheimDigitalUpdateCoordinator,
device: _DeviceT_co,
description: EheimDigitalSensorDescription[_DeviceT_co],
) -> None:
"""Initialize an EHEIM Digital number entity."""
super().__init__(coordinator, device)
self.entity_description = description
self._attr_unique_id = f"{self._device_address}_{description.key}"
@override
def _async_update_attrs(self) -> None:
self._attr_native_value = self.entity_description.value_fn(self._device)

View File

@ -46,6 +46,22 @@
}
}
}
},
"sensor": {
"current_speed": {
"name": "Current speed"
},
"service_hours": {
"name": "Remaining hours until service"
},
"error_code": {
"name": "Error code",
"state": {
"no_error": "No error",
"rotor_stuck": "Rotor stuck",
"air_in_filter": "Air in filter"
}
}
}
}
}

View File

@ -4,9 +4,17 @@ from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl
from eheimdigital.classic_vario import EheimDigitalClassicVario
from eheimdigital.heater import EheimDigitalHeater
from eheimdigital.hub import EheimDigitalHub
from eheimdigital.types import EheimDeviceType, HeaterMode, HeaterUnit, LightMode
from eheimdigital.types import (
EheimDeviceType,
FilterErrorCode,
FilterMode,
HeaterMode,
HeaterUnit,
LightMode,
)
import pytest
from homeassistant.components.eheimdigital.const import DOMAIN
@ -59,9 +67,28 @@ def heater_mock():
return heater_mock
@pytest.fixture
def classic_vario_mock():
"""Mock a classicVARIO device."""
classic_vario_mock = MagicMock(spec=EheimDigitalClassicVario)
classic_vario_mock.mac_address = "00:00:00:00:00:03"
classic_vario_mock.device_type = EheimDeviceType.VERSION_EHEIM_CLASSIC_VARIO
classic_vario_mock.name = "Mock classicVARIO"
classic_vario_mock.aquarium_name = "Mock Aquarium"
classic_vario_mock.sw_version = "1.0.0_1.0.0"
classic_vario_mock.current_speed = 75
classic_vario_mock.is_active = True
classic_vario_mock.filter_mode = FilterMode.MANUAL
classic_vario_mock.error_code = FilterErrorCode.NO_ERROR
classic_vario_mock.service_hours = 360
return classic_vario_mock
@pytest.fixture
def eheimdigital_hub_mock(
classic_led_ctrl_mock: MagicMock, heater_mock: MagicMock
classic_led_ctrl_mock: MagicMock,
heater_mock: MagicMock,
classic_vario_mock: MagicMock,
) -> Generator[AsyncMock]:
"""Mock eheimdigital hub."""
with (
@ -77,6 +104,7 @@ def eheimdigital_hub_mock(
eheimdigital_hub_mock.return_value.devices = {
"00:00:00:00:00:01": classic_led_ctrl_mock,
"00:00:00:00:00:02": heater_mock,
"00:00:00:00:00:03": classic_vario_mock,
}
eheimdigital_hub_mock.return_value.main = classic_led_ctrl_mock
yield eheimdigital_hub_mock

View File

@ -0,0 +1,160 @@
# serializer version: 1
# name: test_setup_classic_vario[sensor.mock_classicvario_current_speed-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'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.mock_classicvario_current_speed',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Current speed',
'platform': 'eheimdigital',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'current_speed',
'unique_id': '00:00:00:00:00:03_current_speed',
'unit_of_measurement': '%',
})
# ---
# name: test_setup_classic_vario[sensor.mock_classicvario_current_speed-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Mock classicVARIO Current speed',
'unit_of_measurement': '%',
}),
'context': <ANY>,
'entity_id': 'sensor.mock_classicvario_current_speed',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_setup_classic_vario[sensor.mock_classicvario_error_code-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'no_error',
'rotor_stuck',
'air_in_filter',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.mock_classicvario_error_code',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': None,
'original_name': 'Error code',
'platform': 'eheimdigital',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'error_code',
'unique_id': '00:00:00:00:00:03_error_code',
'unit_of_measurement': None,
})
# ---
# name: test_setup_classic_vario[sensor.mock_classicvario_error_code-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'enum',
'friendly_name': 'Mock classicVARIO Error code',
'options': list([
'no_error',
'rotor_stuck',
'air_in_filter',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.mock_classicvario_error_code',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_setup_classic_vario[sensor.mock_classicvario_remaining_hours_until_service-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.mock_classicvario_remaining_hours_until_service',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor.private': dict({
'suggested_unit_of_measurement': <UnitOfTime.DAYS: 'd'>,
}),
}),
'original_device_class': <SensorDeviceClass.DURATION: 'duration'>,
'original_icon': None,
'original_name': 'Remaining hours until service',
'platform': 'eheimdigital',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'service_hours',
'unique_id': '00:00:00:00:00:03_service_hours',
'unit_of_measurement': <UnitOfTime.DAYS: 'd'>,
})
# ---
# name: test_setup_classic_vario[sensor.mock_classicvario_remaining_hours_until_service-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'duration',
'friendly_name': 'Mock classicVARIO Remaining hours until service',
'unit_of_measurement': <UnitOfTime.DAYS: 'd'>,
}),
'context': <ANY>,
'entity_id': 'sensor.mock_classicvario_remaining_hours_until_service',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---

View File

@ -0,0 +1,77 @@
"""Tests for the sensor module."""
from unittest.mock import AsyncMock, MagicMock, patch
from eheimdigital.types import EheimDeviceType, FilterErrorCode
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 .conftest import init_integration
from tests.common import MockConfigEntry, snapshot_platform
@pytest.mark.usefixtures("classic_vario_mock")
async def test_setup_classic_vario(
hass: HomeAssistant,
eheimdigital_hub_mock: MagicMock,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Test sensor platform setup for the filter."""
mock_config_entry.add_to_hass(hass)
with (
patch("homeassistant.components.eheimdigital.PLATFORMS", [Platform.SENSOR]),
patch(
"homeassistant.components.eheimdigital.coordinator.asyncio.Event",
new=AsyncMock,
),
):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"](
"00:00:00:00:00:03", EheimDeviceType.VERSION_EHEIM_CLASSIC_VARIO
)
await hass.async_block_till_done()
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_state_update(
hass: HomeAssistant,
eheimdigital_hub_mock: MagicMock,
mock_config_entry: MockConfigEntry,
classic_vario_mock: MagicMock,
) -> None:
"""Test the sensor state update."""
await init_integration(hass, mock_config_entry)
await eheimdigital_hub_mock.call_args.kwargs["device_found_callback"](
"00:00:00:00:00:03", EheimDeviceType.VERSION_EHEIM_CLASSIC_VARIO
)
await hass.async_block_till_done()
classic_vario_mock.current_speed = 10
classic_vario_mock.error_code = FilterErrorCode.ROTOR_STUCK
classic_vario_mock.service_hours = 100
await eheimdigital_hub_mock.call_args.kwargs["receive_callback"]()
assert (state := hass.states.get("sensor.mock_classicvario_current_speed"))
assert state.state == "10"
assert (state := hass.states.get("sensor.mock_classicvario_error_code"))
assert state.state == "rotor_stuck"
assert (
state := hass.states.get(
"sensor.mock_classicvario_remaining_hours_until_service"
)
)
assert state.state == str(round(100 / 24, 1))