diff --git a/homeassistant/components/pooldose/__init__.py b/homeassistant/components/pooldose/__init__.py index 454d411cf31..adc65094a9a 100644 --- a/homeassistant/components/pooldose/__init__.py +++ b/homeassistant/components/pooldose/__init__.py @@ -17,7 +17,12 @@ from .coordinator import PooldoseConfigEntry, PooldoseCoordinator _LOGGER = logging.getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.NUMBER, + Platform.SENSOR, + Platform.SWITCH, +] async def async_migrate_entry(hass: HomeAssistant, entry: PooldoseConfigEntry) -> bool: diff --git a/homeassistant/components/pooldose/icons.json b/homeassistant/components/pooldose/icons.json index 533a34a2e07..5d52b03e7db 100644 --- a/homeassistant/components/pooldose/icons.json +++ b/homeassistant/components/pooldose/icons.json @@ -68,6 +68,35 @@ } } }, + "number": { + "cl_target": { + "default": "mdi:pool" + }, + "ofa_cl_lower": { + "default": "mdi:arrow-down-bold" + }, + "ofa_cl_upper": { + "default": "mdi:arrow-up-bold" + }, + "ofa_orp_lower": { + "default": "mdi:arrow-down-bold" + }, + "ofa_orp_upper": { + "default": "mdi:arrow-up-bold" + }, + "ofa_ph_lower": { + "default": "mdi:arrow-down-bold" + }, + "ofa_ph_upper": { + "default": "mdi:arrow-up-bold" + }, + "orp_target": { + "default": "mdi:water-check" + }, + "ph_target": { + "default": "mdi:ph" + } + }, "sensor": { "cl": { "default": "mdi:pool" diff --git a/homeassistant/components/pooldose/number.py b/homeassistant/components/pooldose/number.py new file mode 100644 index 00000000000..0c89191af8b --- /dev/null +++ b/homeassistant/components/pooldose/number.py @@ -0,0 +1,142 @@ +"""Number entities for the Seko PoolDose integration.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any, cast + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, + EntityCategory, + UnitOfElectricPotential, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import PooldoseConfigEntry +from .entity import PooldoseEntity + +if TYPE_CHECKING: + from .coordinator import PooldoseCoordinator + +_LOGGER = logging.getLogger(__name__) + + +NUMBER_DESCRIPTIONS: tuple[NumberEntityDescription, ...] = ( + NumberEntityDescription( + key="ph_target", + translation_key="ph_target", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.PH, + ), + NumberEntityDescription( + key="orp_target", + translation_key="orp_target", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, + ), + NumberEntityDescription( + key="cl_target", + translation_key="cl_target", + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + ), + NumberEntityDescription( + key="ofa_ph_lower", + translation_key="ofa_ph_lower", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.PH, + ), + NumberEntityDescription( + key="ofa_ph_upper", + translation_key="ofa_ph_upper", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.PH, + ), + NumberEntityDescription( + key="ofa_orp_lower", + translation_key="ofa_orp_lower", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, + ), + NumberEntityDescription( + key="ofa_orp_upper", + translation_key="ofa_orp_upper", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.VOLTAGE, + native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, + ), + NumberEntityDescription( + key="ofa_cl_lower", + translation_key="ofa_cl_lower", + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + ), + NumberEntityDescription( + key="ofa_cl_upper", + translation_key="ofa_cl_upper", + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PooldoseConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up PoolDose number entities from a config entry.""" + if TYPE_CHECKING: + assert config_entry.unique_id is not None + + coordinator = config_entry.runtime_data + number_data = coordinator.data.get("number", {}) + serial_number = config_entry.unique_id + + async_add_entities( + PooldoseNumber(coordinator, serial_number, coordinator.device_info, description) + for description in NUMBER_DESCRIPTIONS + if description.key in number_data + ) + + +class PooldoseNumber(PooldoseEntity, NumberEntity): + """Number entity for the Seko PoolDose Python API.""" + + def __init__( + self, + coordinator: PooldoseCoordinator, + serial_number: str, + device_info: Any, + description: NumberEntityDescription, + ) -> None: + """Initialize the number.""" + super().__init__(coordinator, serial_number, device_info, description, "number") + 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 number attributes.""" + data = cast(dict, self.get_data()) + self._attr_native_value = data["value"] + self._attr_native_min_value = data["min"] + self._attr_native_max_value = data["max"] + self._attr_native_step = data["step"] + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + await self.coordinator.client.set_number(self.entity_description.key, value) + self._attr_native_value = value + self.async_write_ha_state() diff --git a/homeassistant/components/pooldose/strings.json b/homeassistant/components/pooldose/strings.json index fb6334f0637..de646f2f404 100644 --- a/homeassistant/components/pooldose/strings.json +++ b/homeassistant/components/pooldose/strings.json @@ -68,6 +68,35 @@ "name": "Auxiliary relay 3 status" } }, + "number": { + "cl_target": { + "name": "Chlorine target" + }, + "ofa_cl_lower": { + "name": "Chlorine overfeed alarm lower limit" + }, + "ofa_cl_upper": { + "name": "Chlorine overfeed alarm upper limit" + }, + "ofa_orp_lower": { + "name": "ORP overfeed alarm lower limit" + }, + "ofa_orp_upper": { + "name": "ORP overfeed alarm upper limit" + }, + "ofa_ph_lower": { + "name": "pH overfeed alarm lower limit" + }, + "ofa_ph_upper": { + "name": "pH overfeed alarm upper limit" + }, + "orp_target": { + "name": "ORP target" + }, + "ph_target": { + "name": "pH target" + } + }, "sensor": { "cl": { "name": "Chlorine" diff --git a/tests/components/pooldose/fixtures/instantvalues.json b/tests/components/pooldose/fixtures/instantvalues.json index 7be433dfe95..b8213698d1f 100644 --- a/tests/components/pooldose/fixtures/instantvalues.json +++ b/tests/components/pooldose/fixtures/instantvalues.json @@ -141,6 +141,48 @@ "min": 0, "max": 65535, "step": 0.01 + }, + "ofa_ph_lower": { + "value": 6, + "unit": null, + "min": 0, + "max": 14, + "step": 0.1 + }, + "ofa_ph_upper": { + "value": 8, + "unit": null, + "min": 0, + "max": 14, + "step": 0.1 + }, + "ofa_orp_lower": { + "value": 600, + "unit": "mV", + "min": 0, + "max": 1000, + "step": 1 + }, + "ofa_orp_upper": { + "value": 800, + "unit": "mV", + "min": 0, + "max": 1000, + "step": 1 + }, + "ofa_cl_lower": { + "value": 0.2, + "unit": "ppm", + "min": 0, + "max": 10, + "step": 0.1 + }, + "ofa_cl_upper": { + "value": 0.9, + "unit": "ppm", + "min": 0, + "max": 10, + "step": 0.1 } }, "switch": { diff --git a/tests/components/pooldose/snapshots/test_number.ambr b/tests/components/pooldose/snapshots/test_number.ambr new file mode 100644 index 00000000000..83da9ef9f7d --- /dev/null +++ b/tests/components/pooldose/snapshots/test_number.ambr @@ -0,0 +1,526 @@ +# serializer version: 1 +# name: test_all_numbers[number.pool_device_chlorine_overfeed_alarm_lower_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10, + 'min': 0, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.pool_device_chlorine_overfeed_alarm_lower_limit', + '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 overfeed alarm lower limit', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ofa_cl_lower', + 'unique_id': 'TEST123456789_ofa_cl_lower', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_all_numbers[number.pool_device_chlorine_overfeed_alarm_lower_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pool Device Chlorine overfeed alarm lower limit', + 'max': 10, + 'min': 0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'number.pool_device_chlorine_overfeed_alarm_lower_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.2', + }) +# --- +# name: test_all_numbers[number.pool_device_chlorine_overfeed_alarm_upper_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 10, + 'min': 0, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.pool_device_chlorine_overfeed_alarm_upper_limit', + '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 overfeed alarm upper limit', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ofa_cl_upper', + 'unique_id': 'TEST123456789_ofa_cl_upper', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_all_numbers[number.pool_device_chlorine_overfeed_alarm_upper_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pool Device Chlorine overfeed alarm upper limit', + 'max': 10, + 'min': 0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'number.pool_device_chlorine_overfeed_alarm_upper_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.9', + }) +# --- +# name: test_all_numbers[number.pool_device_chlorine_target-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 65535, + 'min': 0, + 'mode': , + 'step': 0.01, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.pool_device_chlorine_target', + '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 target', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cl_target', + 'unique_id': 'TEST123456789_cl_target', + 'unit_of_measurement': 'ppm', + }) +# --- +# name: test_all_numbers[number.pool_device_chlorine_target-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Pool Device Chlorine target', + 'max': 65535, + 'min': 0, + 'mode': , + 'step': 0.01, + 'unit_of_measurement': 'ppm', + }), + 'context': , + 'entity_id': 'number.pool_device_chlorine_target', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1', + }) +# --- +# name: test_all_numbers[number.pool_device_orp_overfeed_alarm_lower_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 1000, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.pool_device_orp_overfeed_alarm_lower_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'ORP overfeed alarm lower limit', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ofa_orp_lower', + 'unique_id': 'TEST123456789_ofa_orp_lower', + 'unit_of_measurement': , + }) +# --- +# name: test_all_numbers[number.pool_device_orp_overfeed_alarm_lower_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Pool Device ORP overfeed alarm lower limit', + 'max': 1000, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.pool_device_orp_overfeed_alarm_lower_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '600', + }) +# --- +# name: test_all_numbers[number.pool_device_orp_overfeed_alarm_upper_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 1000, + 'min': 0, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.pool_device_orp_overfeed_alarm_upper_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'ORP overfeed alarm upper limit', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ofa_orp_upper', + 'unique_id': 'TEST123456789_ofa_orp_upper', + 'unit_of_measurement': , + }) +# --- +# name: test_all_numbers[number.pool_device_orp_overfeed_alarm_upper_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Pool Device ORP overfeed alarm upper limit', + 'max': 1000, + 'min': 0, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.pool_device_orp_overfeed_alarm_upper_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '800', + }) +# --- +# name: test_all_numbers[number.pool_device_orp_target-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 850, + 'min': 400, + 'mode': , + 'step': 1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.pool_device_orp_target', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'ORP target', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'orp_target', + 'unique_id': 'TEST123456789_orp_target', + 'unit_of_measurement': , + }) +# --- +# name: test_all_numbers[number.pool_device_orp_target-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Pool Device ORP target', + 'max': 850, + 'min': 400, + 'mode': , + 'step': 1, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.pool_device_orp_target', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '680', + }) +# --- +# name: test_all_numbers[number.pool_device_ph_overfeed_alarm_lower_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 14, + 'min': 0, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.pool_device_ph_overfeed_alarm_lower_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'pH overfeed alarm lower limit', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ofa_ph_lower', + 'unique_id': 'TEST123456789_ofa_ph_lower', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_numbers[number.pool_device_ph_overfeed_alarm_lower_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'ph', + 'friendly_name': 'Pool Device pH overfeed alarm lower limit', + 'max': 14, + 'min': 0, + 'mode': , + 'step': 0.1, + }), + 'context': , + 'entity_id': 'number.pool_device_ph_overfeed_alarm_lower_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6', + }) +# --- +# name: test_all_numbers[number.pool_device_ph_overfeed_alarm_upper_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 14, + 'min': 0, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.pool_device_ph_overfeed_alarm_upper_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'pH overfeed alarm upper limit', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ofa_ph_upper', + 'unique_id': 'TEST123456789_ofa_ph_upper', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_numbers[number.pool_device_ph_overfeed_alarm_upper_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'ph', + 'friendly_name': 'Pool Device pH overfeed alarm upper limit', + 'max': 14, + 'min': 0, + 'mode': , + 'step': 0.1, + }), + 'context': , + 'entity_id': 'number.pool_device_ph_overfeed_alarm_upper_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8', + }) +# --- +# name: test_all_numbers[number.pool_device_ph_target-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 8, + 'min': 6, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.pool_device_ph_target', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'pH target', + 'platform': 'pooldose', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ph_target', + 'unique_id': 'TEST123456789_ph_target', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_numbers[number.pool_device_ph_target-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'ph', + 'friendly_name': 'Pool Device pH target', + 'max': 8, + 'min': 6, + 'mode': , + 'step': 0.1, + }), + 'context': , + 'entity_id': 'number.pool_device_ph_target', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '6.5', + }) +# --- diff --git a/tests/components/pooldose/test_number.py b/tests/components/pooldose/test_number.py new file mode 100644 index 00000000000..938741815e1 --- /dev/null +++ b/tests/components/pooldose/test_number.py @@ -0,0 +1,114 @@ +"""Tests for the Seko PoolDose number platform.""" + +from copy import deepcopy +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.number import DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE +from homeassistant.const import ATTR_ENTITY_ID, Platform +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.NUMBER] + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_all_numbers( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Pooldose numbers.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.usefixtures("init_integration") +async def test_number_entity_unavailable_no_coordinator_data( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_pooldose_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test number entity becomes unavailable when coordinator has no data.""" + # Verify entity has a state initially + ph_target_state = hass.states.get("number.pool_device_ph_target") + assert ph_target_state.state == "6.5" + + # 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 + ph_target_state = hass.states.get("number.pool_device_ph_target") + assert ph_target_state.state == "unavailable" + + +@pytest.mark.usefixtures("init_integration") +async def test_number_state_changes( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_pooldose_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test number state changes when coordinator updates.""" + # Initial state + ph_target_state = hass.states.get("number.pool_device_ph_target") + assert ph_target_state.state == "6.5" + + # Update coordinator data with number value changed + current_data = mock_pooldose_client.instant_values_structured.return_value[1] + updated_data = deepcopy(current_data) + updated_data["number"]["ph_target"]["value"] = 7.2 + + 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_target_state = hass.states.get("number.pool_device_ph_target") + assert ph_target_state.state == "7.2" + + +@pytest.mark.usefixtures("init_integration") +async def test_set_number_value( + hass: HomeAssistant, + mock_pooldose_client: AsyncMock, +) -> None: + """Test setting a number value.""" + # Verify initial state + ph_target_state = hass.states.get("number.pool_device_ph_target") + assert ph_target_state.state == "6.5" + + # Set new value + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "number.pool_device_ph_target", "value": 7.0}, + blocking=True, + ) + + # Verify API was called + mock_pooldose_client.set_number.assert_called_once_with("ph_target", 7.0) + + # Verify state updated immediately (optimistic update) + ph_target_state = hass.states.get("number.pool_device_ph_target") + assert ph_target_state.state == "7.0"