diff --git a/homeassistant/components/pooldose/const.py b/homeassistant/components/pooldose/const.py index 7b8d978431aa0..b486a18d6dece 100644 --- a/homeassistant/components/pooldose/const.py +++ b/homeassistant/components/pooldose/const.py @@ -2,5 +2,17 @@ from __future__ import annotations +from homeassistant.const import UnitOfTemperature, UnitOfVolumeFlowRate + DOMAIN = "pooldose" MANUFACTURER = "SEKO" + +# Mapping of device units to Home Assistant units +UNIT_MAPPING: dict[str, str] = { + # Temperature units + "°C": UnitOfTemperature.CELSIUS, + "°F": UnitOfTemperature.FAHRENHEIT, + # Volume flow rate units + "m3/h": UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + "L/s": UnitOfVolumeFlowRate.LITERS_PER_SECOND, +} diff --git a/homeassistant/components/pooldose/icons.json b/homeassistant/components/pooldose/icons.json index 708db5a468ce5..bb6da13822e28 100644 --- a/homeassistant/components/pooldose/icons.json +++ b/homeassistant/components/pooldose/icons.json @@ -1,6 +1,15 @@ { "entity": { "sensor": { + "cl": { + "default": "mdi:pool" + }, + "cl_type_dosing": { + "default": "mdi:flask" + }, + "flow_rate": { + "default": "mdi:pipe-valve" + }, "ofa_orp_time": { "default": "mdi:clock" }, @@ -22,6 +31,9 @@ "orp_type_dosing": { "default": "mdi:flask" }, + "peristaltic_cl_dosing": { + "default": "mdi:pump" + }, "peristaltic_orp_dosing": { "default": "mdi:pump" }, diff --git a/homeassistant/components/pooldose/sensor.py b/homeassistant/components/pooldose/sensor.py index 9946277184f66..ca196e2f83b11 100644 --- a/homeassistant/components/pooldose/sensor.py +++ b/homeassistant/components/pooldose/sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass import logging from typing import TYPE_CHECKING @@ -10,36 +11,61 @@ SensorEntity, SensorEntityDescription, ) -from homeassistant.const import EntityCategory, UnitOfElectricPotential, UnitOfTime +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, + EntityCategory, + UnitOfElectricPotential, + UnitOfTime, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import PooldoseConfigEntry +from .const import UNIT_MAPPING from .entity import PooldoseEntity _LOGGER = logging.getLogger(__name__) -SENSOR_DESCRIPTIONS: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( + +@dataclass(frozen=True, kw_only=True) +class PooldoseSensorEntityDescription(SensorEntityDescription): + """Describes PoolDose sensor entity.""" + + use_dynamic_unit: bool = False + + +SENSOR_DESCRIPTIONS: tuple[PooldoseSensorEntityDescription, ...] = ( + PooldoseSensorEntityDescription( key="temperature", device_class=SensorDeviceClass.TEMPERATURE, - # Unit dynamically determined via API + use_dynamic_unit=True, ), - SensorEntityDescription(key="ph", device_class=SensorDeviceClass.PH), - SensorEntityDescription( + PooldoseSensorEntityDescription(key="ph", device_class=SensorDeviceClass.PH), + PooldoseSensorEntityDescription( key="orp", translation_key="orp", device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, ), - SensorEntityDescription( + PooldoseSensorEntityDescription( + key="cl", + translation_key="cl", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + ), + PooldoseSensorEntityDescription( + key="flow_rate", + translation_key="flow_rate", + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + use_dynamic_unit=True, + ), + PooldoseSensorEntityDescription( key="ph_type_dosing", translation_key="ph_type_dosing", entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.ENUM, options=["alcalyne", "acid"], ), - SensorEntityDescription( + PooldoseSensorEntityDescription( key="peristaltic_ph_dosing", translation_key="peristaltic_ph_dosing", entity_category=EntityCategory.DIAGNOSTIC, @@ -47,7 +73,7 @@ device_class=SensorDeviceClass.ENUM, options=["proportional", "on_off", "timed"], ), - SensorEntityDescription( + PooldoseSensorEntityDescription( key="ofa_ph_time", translation_key="ofa_ph_time", entity_category=EntityCategory.DIAGNOSTIC, @@ -55,7 +81,7 @@ entity_registry_enabled_default=False, native_unit_of_measurement=UnitOfTime.MINUTES, ), - SensorEntityDescription( + PooldoseSensorEntityDescription( key="orp_type_dosing", translation_key="orp_type_dosing", entity_category=EntityCategory.DIAGNOSTIC, @@ -63,7 +89,7 @@ device_class=SensorDeviceClass.ENUM, options=["low", "high"], ), - SensorEntityDescription( + PooldoseSensorEntityDescription( key="peristaltic_orp_dosing", translation_key="peristaltic_orp_dosing", entity_category=EntityCategory.DIAGNOSTIC, @@ -71,7 +97,23 @@ device_class=SensorDeviceClass.ENUM, options=["off", "proportional", "on_off", "timed"], ), - SensorEntityDescription( + PooldoseSensorEntityDescription( + key="cl_type_dosing", + translation_key="cl_type_dosing", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.ENUM, + options=["low", "high"], + ), + PooldoseSensorEntityDescription( + key="peristaltic_cl_dosing", + translation_key="peristaltic_cl_dosing", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.ENUM, + options=["off", "proportional", "on_off", "timed"], + ), + PooldoseSensorEntityDescription( key="ofa_orp_time", translation_key="ofa_orp_time", device_class=SensorDeviceClass.DURATION, @@ -79,7 +121,7 @@ entity_registry_enabled_default=False, native_unit_of_measurement=UnitOfTime.MINUTES, ), - SensorEntityDescription( + PooldoseSensorEntityDescription( key="ph_calibration_type", translation_key="ph_calibration_type", entity_category=EntityCategory.DIAGNOSTIC, @@ -87,7 +129,7 @@ device_class=SensorDeviceClass.ENUM, options=["off", "reference", "1_point", "2_points"], ), - SensorEntityDescription( + PooldoseSensorEntityDescription( key="ph_calibration_offset", translation_key="ph_calibration_offset", entity_category=EntityCategory.DIAGNOSTIC, @@ -96,7 +138,7 @@ entity_registry_enabled_default=False, native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, ), - SensorEntityDescription( + PooldoseSensorEntityDescription( key="ph_calibration_slope", translation_key="ph_calibration_slope", entity_category=EntityCategory.DIAGNOSTIC, @@ -105,7 +147,7 @@ entity_registry_enabled_default=False, native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, ), - SensorEntityDescription( + PooldoseSensorEntityDescription( key="orp_calibration_type", translation_key="orp_calibration_type", entity_category=EntityCategory.DIAGNOSTIC, @@ -113,7 +155,7 @@ device_class=SensorDeviceClass.ENUM, options=["off", "reference", "1_point"], ), - SensorEntityDescription( + PooldoseSensorEntityDescription( key="orp_calibration_offset", translation_key="orp_calibration_offset", entity_category=EntityCategory.DIAGNOSTIC, @@ -122,7 +164,7 @@ entity_registry_enabled_default=False, native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, ), - SensorEntityDescription( + PooldoseSensorEntityDescription( key="orp_calibration_slope", translation_key="orp_calibration_slope", entity_category=EntityCategory.DIAGNOSTIC, @@ -163,6 +205,8 @@ async def async_setup_entry( class PooldoseSensor(PooldoseEntity, SensorEntity): """Sensor entity for the Seko PoolDose Python API.""" + entity_description: PooldoseSensorEntityDescription + @property def native_value(self) -> float | int | str | None: """Return the current value of the sensor.""" @@ -175,9 +219,12 @@ def native_value(self) -> float | int | str | None: def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement.""" if ( - self.entity_description.key == "temperature" + self.entity_description.use_dynamic_unit and (data := self.get_data()) is not None + and (device_unit := data.get("unit")) ): - return data["unit"] # °C or °F + # Map device unit to Home Assistant unit, return None if unknown + return UNIT_MAPPING.get(device_unit) + # Fall back to static unit from entity description return super().native_unit_of_measurement diff --git a/homeassistant/components/pooldose/strings.json b/homeassistant/components/pooldose/strings.json index 422825391e2d8..28105f401e889 100644 --- a/homeassistant/components/pooldose/strings.json +++ b/homeassistant/components/pooldose/strings.json @@ -34,6 +34,19 @@ }, "entity": { "sensor": { + "cl": { + "name": "Chlorine" + }, + "cl_type_dosing": { + "name": "Chlorine dosing type", + "state": { + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]" + } + }, + "flow_rate": { + "name": "Flow rate" + }, "ofa_orp_time": { "name": "ORP overfeed alert time" }, @@ -64,6 +77,15 @@ "low": "[%key:common::state::low%]" } }, + "peristaltic_cl_dosing": { + "name": "Chlorine peristaltic dosing", + "state": { + "off": "[%key:common::state::off%]", + "on_off": "[%key:component::pooldose::entity::sensor::peristaltic_ph_dosing::state::on_off%]", + "proportional": "[%key:component::pooldose::entity::sensor::peristaltic_ph_dosing::state::proportional%]", + "timed": "[%key:component::pooldose::entity::sensor::peristaltic_ph_dosing::state::timed%]" + } + }, "peristaltic_orp_dosing": { "name": "ORP peristaltic dosing", "state": { diff --git a/tests/components/pooldose/fixtures/instantvalues.json b/tests/components/pooldose/fixtures/instantvalues.json index 5c2ef50e3e5f9..8cf027fd28d48 100644 --- a/tests/components/pooldose/fixtures/instantvalues.json +++ b/tests/components/pooldose/fixtures/instantvalues.json @@ -40,6 +40,14 @@ "value": "proportional", "unit": null }, + "cl_type_dosing": { + "value": "low", + "unit": null + }, + "peristaltic_cl_dosing": { + "value": "off", + "unit": null + }, "ofa_orp_time": { "value": 0, "unit": "min" diff --git a/tests/components/pooldose/snapshots/test_sensor.ambr b/tests/components/pooldose/snapshots/test_sensor.ambr index ca0686fe32f33..bd899aa5b2f7d 100644 --- a/tests/components/pooldose/snapshots/test_sensor.ambr +++ b/tests/components/pooldose/snapshots/test_sensor.ambr @@ -1,4 +1,226 @@ # serializer version: 1 +# name: test_all_sensors[sensor.pool_device_chlorine-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pool_device_chlorine', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Chlorine', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cl', + 'unique_id': 'TEST123456789_cl', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_all_sensors[sensor.pool_device_chlorine-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pool Device Chlorine', + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'sensor.pool_device_chlorine', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.2', + }) +# --- +# name: test_all_sensors[sensor.pool_device_chlorine_dosing_type-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'low', + 'high', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.pool_device_chlorine_dosing_type', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Chlorine dosing type', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cl_type_dosing', + 'unique_id': 'TEST123456789_cl_type_dosing', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_sensors[sensor.pool_device_chlorine_dosing_type-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Pool Device Chlorine dosing type', + 'options': list([ + 'low', + 'high', + ]), + }), + 'context': , + 'entity_id': 'sensor.pool_device_chlorine_dosing_type', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'low', + }) +# --- +# name: test_all_sensors[sensor.pool_device_chlorine_peristaltic_dosing-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'off', + 'proportional', + 'on_off', + 'timed', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.pool_device_chlorine_peristaltic_dosing', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Chlorine peristaltic dosing', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'peristaltic_cl_dosing', + 'unique_id': 'TEST123456789_peristaltic_cl_dosing', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_sensors[sensor.pool_device_chlorine_peristaltic_dosing-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Pool Device Chlorine peristaltic dosing', + 'options': list([ + 'off', + 'proportional', + 'on_off', + 'timed', + ]), + }), + 'context': , + 'entity_id': 'sensor.pool_device_chlorine_peristaltic_dosing', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_all_sensors[sensor.pool_device_flow_rate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.pool_device_flow_rate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Flow rate', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'flow_rate', + 'unique_id': 'TEST123456789_flow_rate', + 'unit_of_measurement': , + }) +# --- +# name: test_all_sensors[sensor.pool_device_flow_rate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_flow_rate', + 'friendly_name': 'Pool Device Flow rate', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.pool_device_flow_rate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '150', + }) +# --- # name: test_all_sensors[sensor.pool_device_orp-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/pooldose/test_sensor.py b/tests/components/pooldose/test_sensor.py index 00d557741c0f8..9797ff561f9d7 100644 --- a/tests/components/pooldose/test_sensor.py +++ b/tests/components/pooldose/test_sensor.py @@ -1,7 +1,6 @@ """Test the PoolDose sensor platform.""" from datetime import timedelta -import json from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory @@ -9,22 +8,16 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.pooldose.const import DOMAIN from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, STATE_UNAVAILABLE, Platform, - UnitOfTemperature, + UnitOfElectricPotential, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.common import ( - MockConfigEntry, - async_fire_time_changed, - async_load_fixture, - snapshot_platform, -) +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -93,20 +86,15 @@ async def test_no_data( assert hass.states.get("sensor.pool_device_ph").state == STATE_UNAVAILABLE -@pytest.mark.usefixtures("mock_pooldose_client") async def test_ph_sensor_dynamic_unit( hass: HomeAssistant, mock_config_entry: MockConfigEntry, - mock_pooldose_client, + mock_pooldose_client: AsyncMock, ) -> None: """Test pH sensor unit behavior - pH should not have unit_of_measurement.""" - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - # Mock pH data with custom unit (should be ignored for pH sensor) - instant_values_raw = await async_load_fixture(hass, "instantvalues.json", DOMAIN) - updated_data = json.loads(instant_values_raw) + current_data = mock_pooldose_client.instant_values_structured.return_value[1] + updated_data = current_data.copy() updated_data["sensor"]["ph"]["unit"] = "pH units" mock_pooldose_client.instant_values_structured.return_value = ( @@ -114,14 +102,13 @@ async def test_ph_sensor_dynamic_unit( updated_data, ) - # Trigger refresh by reloading the integration (blackbox approach) - await hass.config_entries.async_reload(mock_config_entry.entry_id) - await hass.async_block_till_done() + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() # pH sensor should not have unit_of_measurement (device class pH) ph_state = hass.states.get("sensor.pool_device_ph") - assert "unit_of_measurement" not in ph_state.attributes + assert ATTR_UNIT_OF_MEASUREMENT not in ph_state.attributes async def test_sensor_entity_unavailable_no_coordinator_data( @@ -156,39 +143,27 @@ async def test_sensor_entity_unavailable_no_coordinator_data( @pytest.mark.usefixtures("mock_pooldose_client") -async def test_temperature_sensor_dynamic_unit( +async def test_sensor_unit_fallback_to_description( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_pooldose_client: AsyncMock, - freezer: FrozenDateTimeFactory, ) -> None: - """Test temperature sensor uses dynamic unit from API data.""" + """Test sensor falls back to entity description unit when no dynamic unit.""" mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - # Verify initial Celsius unit - temp_state = hass.states.get("sensor.pool_device_temperature") - assert temp_state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS - - # Change to Fahrenheit via mock update - instant_values_raw = await async_load_fixture(hass, "instantvalues.json", DOMAIN) - updated_data = json.loads(instant_values_raw) - updated_data["sensor"]["temperature"]["unit"] = "°F" - updated_data["sensor"]["temperature"]["value"] = 77 - - mock_pooldose_client.instant_values_structured.return_value = ( - RequestStatus.SUCCESS, - updated_data, + # Test ORP sensor - should use static unit from entity description + orp_state = hass.states.get("sensor.pool_device_orp") + assert orp_state is not None + assert ( + orp_state.attributes[ATTR_UNIT_OF_MEASUREMENT] + == UnitOfElectricPotential.MILLIVOLT ) + assert orp_state.state == "718" - freezer.tick(timedelta(minutes=10)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - - # Check unit changed to Fahrenheit - temp_state = hass.states.get("sensor.pool_device_temperature") - # After reload, the original fixture data is restored, so we expect °C - assert temp_state.attributes["unit_of_measurement"] == UnitOfTemperature.CELSIUS - assert temp_state.state == "25.0" # Original fixture value + # Test pH sensor - should have no unit (None in description) + ph_state = hass.states.get("sensor.pool_device_ph") + assert ph_state is not None + assert ATTR_UNIT_OF_MEASUREMENT not in ph_state.attributes + assert ph_state.state == "6.8"