diff --git a/homeassistant/components/pooldose/__init__.py b/homeassistant/components/pooldose/__init__.py index adc65094a9a..12d55ed544f 100644 --- a/homeassistant/components/pooldose/__init__.py +++ b/homeassistant/components/pooldose/__init__.py @@ -20,6 +20,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.NUMBER, + Platform.SELECT, Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/pooldose/const.py b/homeassistant/components/pooldose/const.py index 7e205d418ea..c0a7949d71b 100644 --- a/homeassistant/components/pooldose/const.py +++ b/homeassistant/components/pooldose/const.py @@ -7,15 +7,16 @@ from homeassistant.const import UnitOfTemperature, UnitOfVolume, UnitOfVolumeFlo DOMAIN = "pooldose" MANUFACTURER = "SEKO" -# Mapping of device units (upper case) to Home Assistant units +# Unit mappings for select entities (water meter and flow rate) +# Keys match API values exactly: lowercase for m3/m3/h, uppercase L for L/L/s 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, + "m3/h": UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + "L/s": UnitOfVolumeFlowRate.LITERS_PER_SECOND, # Volume units "L": UnitOfVolume.LITERS, - "M3": UnitOfVolume.CUBIC_METERS, + "m3": UnitOfVolume.CUBIC_METERS, } diff --git a/homeassistant/components/pooldose/icons.json b/homeassistant/components/pooldose/icons.json index 5d52b03e7db..bd56bc4c283 100644 --- a/homeassistant/components/pooldose/icons.json +++ b/homeassistant/components/pooldose/icons.json @@ -97,6 +97,32 @@ "default": "mdi:ph" } }, + "select": { + "cl_type_dosing_method": { + "default": "mdi:beaker" + }, + "cl_type_dosing_set": { + "default": "mdi:pool" + }, + "flow_rate_unit": { + "default": "mdi:pipe-valve" + }, + "orp_type_dosing_method": { + "default": "mdi:beaker" + }, + "orp_type_dosing_set": { + "default": "mdi:water-check" + }, + "ph_type_dosing_method": { + "default": "mdi:beaker" + }, + "ph_type_dosing_set": { + "default": "mdi:ph" + }, + "water_meter_unit": { + "default": "mdi:water" + } + }, "sensor": { "cl": { "default": "mdi:pool" diff --git a/homeassistant/components/pooldose/select.py b/homeassistant/components/pooldose/select.py new file mode 100644 index 00000000000..4d0732e7d2b --- /dev/null +++ b/homeassistant/components/pooldose/select.py @@ -0,0 +1,160 @@ +"""Select entities for the Seko PoolDose integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging +from typing import TYPE_CHECKING, Any, cast + +from homeassistant.components.select import SelectEntity, SelectEntityDescription +from homeassistant.const import EntityCategory, UnitOfVolume, UnitOfVolumeFlowRate +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import PooldoseConfigEntry +from .const import UNIT_MAPPING +from .entity import PooldoseEntity + +if TYPE_CHECKING: + from .coordinator import PooldoseCoordinator + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class PooldoseSelectEntityDescription(SelectEntityDescription): + """Describes PoolDose select entity.""" + + use_unit_conversion: bool = False + + +SELECT_DESCRIPTIONS: tuple[PooldoseSelectEntityDescription, ...] = ( + PooldoseSelectEntityDescription( + key="water_meter_unit", + translation_key="water_meter_unit", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + options=[UnitOfVolume.LITERS, UnitOfVolume.CUBIC_METERS], + use_unit_conversion=True, + ), + PooldoseSelectEntityDescription( + key="flow_rate_unit", + translation_key="flow_rate_unit", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + options=[ + UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + UnitOfVolumeFlowRate.LITERS_PER_SECOND, + ], + use_unit_conversion=True, + ), + PooldoseSelectEntityDescription( + key="ph_type_dosing_set", + translation_key="ph_type_dosing_set", + entity_category=EntityCategory.CONFIG, + options=["alcalyne", "acid"], + ), + PooldoseSelectEntityDescription( + key="ph_type_dosing_method", + translation_key="ph_type_dosing_method", + entity_category=EntityCategory.CONFIG, + options=["off", "proportional", "on_off", "timed"], + entity_registry_enabled_default=False, + ), + PooldoseSelectEntityDescription( + key="orp_type_dosing_set", + translation_key="orp_type_dosing_set", + entity_category=EntityCategory.CONFIG, + options=["low", "high"], + entity_registry_enabled_default=False, + ), + PooldoseSelectEntityDescription( + key="orp_type_dosing_method", + translation_key="orp_type_dosing_method", + entity_category=EntityCategory.CONFIG, + options=["off", "proportional", "on_off", "timed"], + entity_registry_enabled_default=False, + ), + PooldoseSelectEntityDescription( + key="cl_type_dosing_set", + translation_key="cl_type_dosing_set", + entity_category=EntityCategory.CONFIG, + options=["low", "high"], + entity_registry_enabled_default=False, + ), + PooldoseSelectEntityDescription( + key="cl_type_dosing_method", + translation_key="cl_type_dosing_method", + entity_category=EntityCategory.CONFIG, + options=["off", "proportional", "on_off", "timed"], + entity_registry_enabled_default=False, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PooldoseConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up PoolDose select entities from a config entry.""" + if TYPE_CHECKING: + assert config_entry.unique_id is not None + + coordinator = config_entry.runtime_data + select_data = coordinator.data["select"] + serial_number = config_entry.unique_id + + async_add_entities( + PooldoseSelect(coordinator, serial_number, coordinator.device_info, description) + for description in SELECT_DESCRIPTIONS + if description.key in select_data + ) + + +class PooldoseSelect(PooldoseEntity, SelectEntity): + """Select entity for the Seko PoolDose Python API.""" + + entity_description: PooldoseSelectEntityDescription + + def __init__( + self, + coordinator: PooldoseCoordinator, + serial_number: str, + device_info: Any, + description: PooldoseSelectEntityDescription, + ) -> None: + """Initialize the select.""" + super().__init__(coordinator, serial_number, device_info, description, "select") + self._async_update_attrs() + + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._async_update_attrs() + super()._handle_coordinator_update() + + def _async_update_attrs(self) -> None: + """Update select attributes.""" + data = cast(dict, self.get_data()) + api_value = cast(str, data["value"]) + + # Convert API value to Home Assistant unit if unit conversion is enabled + if self.entity_description.use_unit_conversion: + # Map API value (e.g., "m3") to HA unit (e.g., "m³") + self._attr_current_option = UNIT_MAPPING.get(api_value, api_value) + else: + self._attr_current_option = api_value + + async def async_select_option(self, option: str) -> None: + """Change the selected option.""" + # Convert Home Assistant unit to API value if unit conversion is enabled + if self.entity_description.use_unit_conversion: + # Invert UNIT_MAPPING to get API value from HA unit + reverse_map = {v: k for k, v in UNIT_MAPPING.items()} + api_value = reverse_map.get(option, option) + else: + api_value = option + + await self.coordinator.client.set_select(self.entity_description.key, api_value) + self._attr_current_option = option + self.async_write_ha_state() diff --git a/homeassistant/components/pooldose/sensor.py b/homeassistant/components/pooldose/sensor.py index a18e200cd5a..6441581daa2 100644 --- a/homeassistant/components/pooldose/sensor.py +++ b/homeassistant/components/pooldose/sensor.py @@ -32,14 +32,14 @@ _LOGGER = logging.getLogger(__name__) class PooldoseSensorEntityDescription(SensorEntityDescription): """Describes PoolDose sensor entity.""" - use_dynamic_unit: bool = False + use_unit_conversion: bool = False SENSOR_DESCRIPTIONS: tuple[PooldoseSensorEntityDescription, ...] = ( PooldoseSensorEntityDescription( key="temperature", device_class=SensorDeviceClass.TEMPERATURE, - use_dynamic_unit=True, + use_unit_conversion=True, ), PooldoseSensorEntityDescription(key="ph", device_class=SensorDeviceClass.PH), PooldoseSensorEntityDescription( @@ -57,14 +57,14 @@ SENSOR_DESCRIPTIONS: tuple[PooldoseSensorEntityDescription, ...] = ( key="flow_rate", translation_key="flow_rate", device_class=SensorDeviceClass.VOLUME_FLOW_RATE, - use_dynamic_unit=True, + use_unit_conversion=True, ), PooldoseSensorEntityDescription( key="water_meter_total_permanent", translation_key="water_meter_total_permanent", device_class=SensorDeviceClass.VOLUME, state_class=SensorStateClass.TOTAL_INCREASING, - use_dynamic_unit=True, + use_unit_conversion=True, ), PooldoseSensorEntityDescription( key="ph_type_dosing", @@ -227,12 +227,12 @@ class PooldoseSensor(PooldoseEntity, SensorEntity): def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement.""" if ( - self.entity_description.use_dynamic_unit + self.entity_description.use_unit_conversion and (data := self.get_data()) is not None and (device_unit := data.get("unit")) ): - # Map device unit (upper case) to Home Assistant unit, return None if unknown - return UNIT_MAPPING.get(device_unit.upper()) + # 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 de646f2f404..5b02c7495fe 100644 --- a/homeassistant/components/pooldose/strings.json +++ b/homeassistant/components/pooldose/strings.json @@ -97,6 +97,62 @@ "name": "pH target" } }, + "select": { + "cl_type_dosing_method": { + "name": "Chlorine dosing method", + "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%]" + } + }, + "cl_type_dosing_set": { + "name": "Chlorine dosing set", + "state": { + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]" + } + }, + "flow_rate_unit": { + "name": "Flow rate unit" + }, + "orp_type_dosing_method": { + "name": "ORP dosing method", + "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%]" + } + }, + "orp_type_dosing_set": { + "name": "ORP dosing set", + "state": { + "high": "[%key:common::state::high%]", + "low": "[%key:common::state::low%]" + } + }, + "ph_type_dosing_method": { + "name": "pH dosing method", + "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%]" + } + }, + "ph_type_dosing_set": { + "name": "pH dosing set", + "state": { + "acid": "Acid (pH-)", + "alcalyne": "Alkaline (pH+)" + } + }, + "water_meter_unit": { + "name": "Water meter unit" + } + }, "sensor": { "cl": { "name": "Chlorine" diff --git a/tests/components/pooldose/conftest.py b/tests/components/pooldose/conftest.py index c593704a7ed..50995f7aa80 100644 --- a/tests/components/pooldose/conftest.py +++ b/tests/components/pooldose/conftest.py @@ -60,6 +60,7 @@ def mock_pooldose_client(device_info: dict[str, Any]) -> Generator[MagicMock]: ) client.set_switch = AsyncMock(return_value=RequestStatus.SUCCESS) + client.set_select = AsyncMock(return_value=RequestStatus.SUCCESS) client.is_connected = True yield client diff --git a/tests/components/pooldose/fixtures/instantvalues.json b/tests/components/pooldose/fixtures/instantvalues.json index b8213698d1f..e26a0068b62 100644 --- a/tests/components/pooldose/fixtures/instantvalues.json +++ b/tests/components/pooldose/fixtures/instantvalues.json @@ -18,7 +18,7 @@ }, "flow_rate": { "value": 150, - "unit": "l/s" + "unit": "L/s" }, "ph_type_dosing": { "value": "alcalyne", @@ -198,7 +198,28 @@ }, "select": { "water_meter_unit": { - "value": "m³" + "value": "m3" + }, + "flow_rate_unit": { + "value": "L/s" + }, + "ph_type_dosing_set": { + "value": "acid" + }, + "ph_type_dosing_method": { + "value": "proportional" + }, + "orp_type_dosing_set": { + "value": "low" + }, + "orp_type_dosing_method": { + "value": "on_off" + }, + "cl_type_dosing_set": { + "value": "high" + }, + "cl_type_dosing_method": { + "value": "timed" } } } diff --git a/tests/components/pooldose/snapshots/test_select.ambr b/tests/components/pooldose/snapshots/test_select.ambr new file mode 100644 index 00000000000..a33603463e8 --- /dev/null +++ b/tests/components/pooldose/snapshots/test_select.ambr @@ -0,0 +1,469 @@ +# serializer version: 1 +# name: test_all_selects[select.pool_device_chlorine_dosing_method-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': 'select', + 'entity_category': , + 'entity_id': 'select.pool_device_chlorine_dosing_method', + '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 dosing method', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cl_type_dosing_method', + 'unique_id': 'TEST123456789_cl_type_dosing_method', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_selects[select.pool_device_chlorine_dosing_method-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pool Device Chlorine dosing method', + 'options': list([ + 'off', + 'proportional', + 'on_off', + 'timed', + ]), + }), + 'context': , + 'entity_id': 'select.pool_device_chlorine_dosing_method', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'timed', + }) +# --- +# name: test_all_selects[select.pool_device_chlorine_dosing_set-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': 'select', + 'entity_category': , + 'entity_id': 'select.pool_device_chlorine_dosing_set', + '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 dosing set', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cl_type_dosing_set', + 'unique_id': 'TEST123456789_cl_type_dosing_set', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_selects[select.pool_device_chlorine_dosing_set-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pool Device Chlorine dosing set', + 'options': list([ + 'low', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.pool_device_chlorine_dosing_set', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'high', + }) +# --- +# name: test_all_selects[select.pool_device_flow_rate_unit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.pool_device_flow_rate_unit', + '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': 'Flow rate unit', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'flow_rate_unit', + 'unique_id': 'TEST123456789_flow_rate_unit', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_selects[select.pool_device_flow_rate_unit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pool Device Flow rate unit', + 'options': list([ + , + , + ]), + }), + 'context': , + 'entity_id': 'select.pool_device_flow_rate_unit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'L/s', + }) +# --- +# name: test_all_selects[select.pool_device_orp_dosing_method-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': 'select', + 'entity_category': , + 'entity_id': 'select.pool_device_orp_dosing_method', + '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': 'ORP dosing method', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'orp_type_dosing_method', + 'unique_id': 'TEST123456789_orp_type_dosing_method', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_selects[select.pool_device_orp_dosing_method-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pool Device ORP dosing method', + 'options': list([ + 'off', + 'proportional', + 'on_off', + 'timed', + ]), + }), + 'context': , + 'entity_id': 'select.pool_device_orp_dosing_method', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on_off', + }) +# --- +# name: test_all_selects[select.pool_device_orp_dosing_set-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': 'select', + 'entity_category': , + 'entity_id': 'select.pool_device_orp_dosing_set', + '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': 'ORP dosing set', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'orp_type_dosing_set', + 'unique_id': 'TEST123456789_orp_type_dosing_set', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_selects[select.pool_device_orp_dosing_set-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pool Device ORP dosing set', + 'options': list([ + 'low', + 'high', + ]), + }), + 'context': , + 'entity_id': 'select.pool_device_orp_dosing_set', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'low', + }) +# --- +# name: test_all_selects[select.pool_device_ph_dosing_method-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': 'select', + 'entity_category': , + 'entity_id': 'select.pool_device_ph_dosing_method', + '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': 'pH dosing method', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ph_type_dosing_method', + 'unique_id': 'TEST123456789_ph_type_dosing_method', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_selects[select.pool_device_ph_dosing_method-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pool Device pH dosing method', + 'options': list([ + 'off', + 'proportional', + 'on_off', + 'timed', + ]), + }), + 'context': , + 'entity_id': 'select.pool_device_ph_dosing_method', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'proportional', + }) +# --- +# name: test_all_selects[select.pool_device_ph_dosing_set-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'alcalyne', + 'acid', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.pool_device_ph_dosing_set', + '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': 'pH dosing set', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ph_type_dosing_set', + 'unique_id': 'TEST123456789_ph_type_dosing_set', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_selects[select.pool_device_ph_dosing_set-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pool Device pH dosing set', + 'options': list([ + 'alcalyne', + 'acid', + ]), + }), + 'context': , + 'entity_id': 'select.pool_device_ph_dosing_set', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'acid', + }) +# --- +# name: test_all_selects[select.pool_device_water_meter_unit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + , + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.pool_device_water_meter_unit', + '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': 'Water meter unit', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'water_meter_unit', + 'unique_id': 'TEST123456789_water_meter_unit', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_selects[select.pool_device_water_meter_unit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pool Device Water meter unit', + 'options': list([ + , + , + ]), + }), + 'context': , + 'entity_id': 'select.pool_device_water_meter_unit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'm³', + }) +# --- diff --git a/tests/components/pooldose/test_select.py b/tests/components/pooldose/test_select.py new file mode 100644 index 00000000000..4ca9e0c7640 --- /dev/null +++ b/tests/components/pooldose/test_select.py @@ -0,0 +1,242 @@ +"""Tests for the Seko PoolDose select platform.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from pooldose.request_status import RequestStatus +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.select import DOMAIN as SELECT_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_OPTION, + Platform, + UnitOfVolume, + UnitOfVolumeFlowRate, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [Platform.SELECT] + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_all_selects( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Pooldose select entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_select_entity_unavailable_no_coordinator_data( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_pooldose_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test select entity becomes unavailable when coordinator has no data.""" + # Verify entity has a state initially + water_meter_state = hass.states.get("select.pool_device_water_meter_unit") + assert water_meter_state.state == UnitOfVolume.CUBIC_METERS + + # Update coordinator data to None + mock_pooldose_client.instant_values_structured.return_value = (None, None) + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Check entity becomes unavailable + water_meter_state = hass.states.get("select.pool_device_water_meter_unit") + assert water_meter_state.state == "unavailable" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_select_state_changes( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_pooldose_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test select state changes when coordinator updates.""" + # Initial state + ph_method_state = hass.states.get("select.pool_device_ph_dosing_method") + assert ph_method_state.state == "proportional" + + # Update coordinator data with select value changed + current_data = mock_pooldose_client.instant_values_structured.return_value[1] + updated_data = current_data.copy() + updated_data["select"]["ph_type_dosing_method"]["value"] = "timed" + + mock_pooldose_client.instant_values_structured.return_value = ( + RequestStatus.SUCCESS, + updated_data, + ) + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Check state changed + ph_method_state = hass.states.get("select.pool_device_ph_dosing_method") + assert ph_method_state.state == "timed" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_select_option_unit_conversion( + hass: HomeAssistant, + mock_pooldose_client: AsyncMock, +) -> None: + """Test selecting an option with unit conversion (HA unit -> API value).""" + # Verify initial state is m³ (displayed as Unicode) + water_meter_state = hass.states.get("select.pool_device_water_meter_unit") + assert water_meter_state.state == UnitOfVolume.CUBIC_METERS + + # Select Liters option + await hass.services.async_call( + SELECT_DOMAIN, + "select_option", + { + ATTR_ENTITY_ID: "select.pool_device_water_meter_unit", + ATTR_OPTION: UnitOfVolume.LITERS, + }, + blocking=True, + ) + + # Verify API was called with "L" (not Unicode) + mock_pooldose_client.set_select.assert_called_once_with("water_meter_unit", "L") + + # Verify state updated to L (Unicode) + water_meter_state = hass.states.get("select.pool_device_water_meter_unit") + assert water_meter_state.state == UnitOfVolume.LITERS + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_select_option_flow_rate_unit_conversion( + hass: HomeAssistant, + mock_pooldose_client: AsyncMock, +) -> None: + """Test selecting flow rate unit with conversion.""" + # Verify initial state + flow_rate_state = hass.states.get("select.pool_device_flow_rate_unit") + assert flow_rate_state.state == UnitOfVolumeFlowRate.LITERS_PER_SECOND + + # Select cubic meters per hour + await hass.services.async_call( + SELECT_DOMAIN, + "select_option", + { + ATTR_ENTITY_ID: "select.pool_device_flow_rate_unit", + ATTR_OPTION: UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, + }, + blocking=True, + ) + + # Verify API was called with "m3/h" (not Unicode m³/h) + mock_pooldose_client.set_select.assert_called_once_with("flow_rate_unit", "m3/h") + + # Verify state updated to m³/h (with Unicode) + flow_rate_state = hass.states.get("select.pool_device_flow_rate_unit") + assert flow_rate_state.state == UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR + + +@pytest.mark.usefixtures("init_integration") +async def test_select_option_no_conversion( + hass: HomeAssistant, + mock_pooldose_client: AsyncMock, +) -> None: + """Test selecting an option without unit conversion.""" + # Verify initial state + ph_set_state = hass.states.get("select.pool_device_ph_dosing_set") + assert ph_set_state.state == "acid" + + # Select alkaline option + await hass.services.async_call( + SELECT_DOMAIN, + "select_option", + { + ATTR_ENTITY_ID: "select.pool_device_ph_dosing_set", + ATTR_OPTION: "alcalyne", + }, + blocking=True, + ) + + # Verify API was called with exact value + mock_pooldose_client.set_select.assert_called_once_with( + "ph_type_dosing_set", "alcalyne" + ) + + # Verify state updated + ph_set_state = hass.states.get("select.pool_device_ph_dosing_set") + assert ph_set_state.state == "alcalyne" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_select_dosing_method_options( + hass: HomeAssistant, + mock_pooldose_client: AsyncMock, +) -> None: + """Test selecting different dosing method options.""" + # Test ORP dosing method + orp_method_state = hass.states.get("select.pool_device_orp_dosing_method") + assert orp_method_state.state == "on_off" + + # Change to proportional + await hass.services.async_call( + SELECT_DOMAIN, + "select_option", + { + ATTR_ENTITY_ID: "select.pool_device_orp_dosing_method", + ATTR_OPTION: "proportional", + }, + blocking=True, + ) + + # Verify API call + mock_pooldose_client.set_select.assert_called_once_with( + "orp_type_dosing_method", "proportional" + ) + + # Verify state + orp_method_state = hass.states.get("select.pool_device_orp_dosing_method") + assert orp_method_state.state == "proportional" + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_select_dosing_set_high_low( + hass: HomeAssistant, + mock_pooldose_client: AsyncMock, +) -> None: + """Test selecting high/low dosing intensity.""" + # Chlorine dosing set starts as high in fixture + cl_set_state = hass.states.get("select.pool_device_chlorine_dosing_set") + assert cl_set_state.state == "high" + + # Change to low + await hass.services.async_call( + SELECT_DOMAIN, + "select_option", + { + ATTR_ENTITY_ID: "select.pool_device_chlorine_dosing_set", + ATTR_OPTION: "low", + }, + blocking=True, + ) + + # Verify API call + mock_pooldose_client.set_select.assert_called_once_with("cl_type_dosing_set", "low") + + # Verify state + cl_set_state = hass.states.get("select.pool_device_chlorine_dosing_set") + assert cl_set_state.state == "low"