From 61c70a2809a35e0cb640fc2c9b549ba086d7ae54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20Sva=C5=A1ek?= Date: Mon, 14 Sep 2026 18:38:06 +0200 Subject: [PATCH] Add number platform to NeoPool (#180415) --- homeassistant/components/neopool/const.py | 1 + .../components/neopool/coordinator.py | 3 + homeassistant/components/neopool/icons.json | 23 + homeassistant/components/neopool/number.py | 560 +++++++ homeassistant/components/neopool/strings.json | 32 + tests/components/neopool/conftest.py | 19 + .../neopool/snapshots/test_number.ambr | 606 +++++++ tests/components/neopool/test_number.py | 1434 +++++++++++++++++ 8 files changed, 2678 insertions(+) create mode 100644 homeassistant/components/neopool/number.py create mode 100644 tests/components/neopool/snapshots/test_number.ambr create mode 100644 tests/components/neopool/test_number.py diff --git a/homeassistant/components/neopool/const.py b/homeassistant/components/neopool/const.py index c8c0ed3a102f..37ed048f5ccc 100644 --- a/homeassistant/components/neopool/const.py +++ b/homeassistant/components/neopool/const.py @@ -9,6 +9,7 @@ PLATFORMS: list[Platform] = [ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.LIGHT, + Platform.NUMBER, Platform.SENSOR, Platform.SWITCH, ] diff --git a/homeassistant/components/neopool/coordinator.py b/homeassistant/components/neopool/coordinator.py index e39e26cae641..e3f66e086906 100644 --- a/homeassistant/components/neopool/coordinator.py +++ b/homeassistant/components/neopool/coordinator.py @@ -1,5 +1,6 @@ """Data update coordinator for the NeoPool integration.""" +import asyncio from datetime import timedelta import logging from typing import Any, override @@ -66,6 +67,8 @@ class NeoPoolCoordinator(DataUpdateCoordinator[dict[str, Any]]): self.client = client self._corrupted_gpio_state: frozenset[tuple[str, int]] | None = None self._follow_up_unsub: CALLBACK_TYPE | None = None + # Serializes masked read-modify-write across siblings sharing a register. + self.masked_write_lock = asyncio.Lock() def request_refresh_with_followup( self, delay: float = FOLLOW_UP_REFRESH_DELAY diff --git a/homeassistant/components/neopool/icons.json b/homeassistant/components/neopool/icons.json index 7400636644c4..b8b66c7ecdb0 100644 --- a/homeassistant/components/neopool/icons.json +++ b/homeassistant/components/neopool/icons.json @@ -16,6 +16,29 @@ } } }, + "number": { + "cl1": { + "default": "mdi:test-tube" + }, + "hidro": { + "default": "mdi:air-humidifier" + }, + "hidro_cover_reduction": { + "default": "mdi:pool" + }, + "hidro_shutdown_temperature": { + "default": "mdi:thermometer-alert" + }, + "rx1": { + "default": "mdi:gradient-vertical" + }, + "smart_temp_high": { + "default": "mdi:thermometer-chevron-up" + }, + "smart_temp_low": { + "default": "mdi:thermometer-chevron-down" + } + }, "sensor": { "filt_mode": { "default": "mdi:water-sync", diff --git a/homeassistant/components/neopool/number.py b/homeassistant/components/neopool/number.py new file mode 100644 index 000000000000..7d8d104f79cb --- /dev/null +++ b/homeassistant/components/neopool/number.py @@ -0,0 +1,560 @@ +"""Number platform for the NeoPool integration.""" + +import asyncio +from collections.abc import Callable +from contextlib import suppress +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import Any, override + +from neopool_modbus.capabilities import ( + has_heating_relay, + is_chlorine_module_present, + is_hydrolysis_present, + is_ph_module_present, + is_redox_module_present, + is_temperature_active, +) +from neopool_modbus.decoders import decode_masked_flag, is_hydrolysis_in_percent +from neopool_modbus.exceptions import NeoPoolError +from neopool_modbus.registers import MaskedFlag, SetpointKind, is_valid_relay_gpio + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, + NumberMode, +) +from homeassistant.const import ( + PERCENTAGE, + EntityCategory, + UnitOfElectricPotential, + UnitOfRatio, + UnitOfTemperature, +) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.event import async_call_later + +from .const import CONF_USE_COVER_SENSOR, DOMAIN +from .coordinator import NeoPoolConfigEntry, NeoPoolCoordinator +from .entity import NeoPoolEntity + +# The platform coalesces rapid writes per entity via a debounce timer and +# serializes the shared masked register with masked_write_lock, so a platform +# semaphore would add nothing but latency between independent UI interactions. +PARALLEL_UPDATES = 0 + +# Wait for the stepper to settle so only the final value hits the device's EEPROM. +WRITE_DELAY = timedelta(seconds=3) + + +@dataclass(frozen=True, kw_only=True) +class NeoPoolNumberEntityDescription(NumberEntityDescription): + """Describes a NeoPool number entity. + + Exactly one write target must be set: + + - ``setpoint``: write via ``client.async_set_setpoint(kind, value)`` + - ``masked_flag``: write via ``client.async_set_masked_register(flag, value)`` + """ + + setpoint: SetpointKind | None = None + masked_flag: MaskedFlag | None = None + data_key: str | None = None + scale: float = 1.0 + supported_fn: Callable[[dict[str, Any]], bool] | None = None + unit_fn: Callable[[dict[str, Any]], str | None] | None = None + max_fn: Callable[[dict[str, Any]], float | None] | None = None + step_fn: Callable[[dict[str, Any]], float | None] | None = None + + +def _support_heating_temp(data: dict[str, Any]) -> bool: + return has_heating_relay(data) and is_temperature_active(data) + + +def _support_ph_max(data: dict[str, Any]) -> bool: + """Require a pH module and a valid acid relay GPIO (or none reported).""" + return is_ph_module_present(data) and ( + "MBF_PAR_PH_ACID_RELAY_GPIO" not in data + or is_valid_relay_gpio(data["MBF_PAR_PH_ACID_RELAY_GPIO"] or 0) + ) + + +def _support_ph_min(data: dict[str, Any]) -> bool: + """Require a pH module and a valid base relay GPIO (or none reported).""" + return is_ph_module_present(data) and ( + "MBF_PAR_PH_BASE_RELAY_GPIO" not in data + or is_valid_relay_gpio(data["MBF_PAR_PH_BASE_RELAY_GPIO"] or 0) + ) + + +def _hidro_unit(data: dict[str, Any]) -> str: + """Surface the hydrolysis target unit dynamically: % or g/h.""" + return PERCENTAGE if is_hydrolysis_in_percent(data) else "g/h" + + +def _hidro_max(data: dict[str, Any]) -> float | None: + """Cap at 100 in percent mode; use the g/h nominal otherwise. + + Percent mode is decided independently of MBF_PAR_HIDRO_NOM, so gate on it + explicitly. Falls back to the static default when the nominal is missing. + """ + if is_hydrolysis_in_percent(data): + return 100.0 + hidro_nom = data.get("MBF_PAR_HIDRO_NOM") + return float(hidro_nom) if hidro_nom is not None else None + + +def _hidro_step(data: dict[str, Any]) -> float: + """Step is 1 in percent mode, 0.1 in g/h mode.""" + return 1.0 if is_hydrolysis_in_percent(data) else 0.1 + + +NUMBER_DESCRIPTIONS: dict[str, NeoPoolNumberEntityDescription] = { + "MBF_PAR_HIDRO": NeoPoolNumberEntityDescription( + key="MBF_PAR_HIDRO", + translation_key="hidro", + native_unit_of_measurement=PERCENTAGE, + native_min_value=0.0, + native_max_value=100.0, + native_step=1.0, + setpoint=SetpointKind.HIDRO, + scale=10.0, + entity_category=EntityCategory.CONFIG, + supported_fn=is_hydrolysis_present, + unit_fn=_hidro_unit, + max_fn=_hidro_max, + step_fn=_hidro_step, + ), + "MBF_PAR_PH1": NeoPoolNumberEntityDescription( + key="MBF_PAR_PH1", + translation_key="ph1", + device_class=NumberDeviceClass.PH, + native_min_value=0.0, + native_max_value=14.0, + native_step=0.1, + setpoint=SetpointKind.PH_MAX, + scale=100.0, + entity_category=EntityCategory.CONFIG, + supported_fn=_support_ph_max, + ), + "MBF_PAR_PH2": NeoPoolNumberEntityDescription( + key="MBF_PAR_PH2", + translation_key="ph2", + device_class=NumberDeviceClass.PH, + native_min_value=0.0, + native_max_value=14.0, + native_step=0.1, + setpoint=SetpointKind.PH_MIN, + scale=100.0, + entity_category=EntityCategory.CONFIG, + supported_fn=_support_ph_min, + ), + "MBF_PAR_RX1": NeoPoolNumberEntityDescription( + key="MBF_PAR_RX1", + translation_key="rx1", + native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT, + device_class=NumberDeviceClass.VOLTAGE, + native_min_value=0.0, + native_max_value=1000.0, + native_step=1.0, + setpoint=SetpointKind.REDOX, + scale=1.0, + entity_category=EntityCategory.CONFIG, + supported_fn=is_redox_module_present, + ), + "MBF_PAR_CL1": NeoPoolNumberEntityDescription( + key="MBF_PAR_CL1", + translation_key="cl1", + native_unit_of_measurement=UnitOfRatio.PARTS_PER_MILLION, + native_min_value=0.0, + native_max_value=10.0, + native_step=0.1, + setpoint=SetpointKind.CHLORINE, + scale=100.0, + entity_category=EntityCategory.CONFIG, + supported_fn=is_chlorine_module_present, + ), + "MBF_PAR_HEATING_TEMP": NeoPoolNumberEntityDescription( + key="MBF_PAR_HEATING_TEMP", + translation_key="heating_temp", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=NumberDeviceClass.TEMPERATURE, + native_min_value=0.0, + native_max_value=40.0, + native_step=1.0, + setpoint=SetpointKind.HEATING, + scale=1.0, + entity_category=EntityCategory.CONFIG, + supported_fn=_support_heating_temp, + ), + "MBF_PAR_SMART_TEMP_HIGH": NeoPoolNumberEntityDescription( + key="MBF_PAR_SMART_TEMP_HIGH", + translation_key="smart_temp_high", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=NumberDeviceClass.TEMPERATURE, + native_min_value=0.0, + native_max_value=40.0, + native_step=1.0, + setpoint=SetpointKind.SMART_TEMP_HIGH, + scale=1.0, + entity_category=EntityCategory.CONFIG, + supported_fn=is_temperature_active, + ), + "MBF_PAR_SMART_TEMP_LOW": NeoPoolNumberEntityDescription( + key="MBF_PAR_SMART_TEMP_LOW", + translation_key="smart_temp_low", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=NumberDeviceClass.TEMPERATURE, + native_min_value=0.0, + native_max_value=40.0, + native_step=1.0, + setpoint=SetpointKind.SMART_TEMP_LOW, + scale=1.0, + entity_category=EntityCategory.CONFIG, + supported_fn=is_temperature_active, + ), + "MBF_PAR_HIDRO_COVER_REDUCTION": NeoPoolNumberEntityDescription( + key="MBF_PAR_HIDRO_COVER_REDUCTION", + translation_key="hidro_cover_reduction", + native_unit_of_measurement=PERCENTAGE, + native_min_value=0.0, + native_max_value=100.0, + native_step=1.0, + masked_flag=MaskedFlag.HIDRO_COVER_REDUCTION_PERCENT, + data_key="MBF_PAR_HIDRO_COVER_REDUCTION", + scale=1.0, + entity_category=EntityCategory.CONFIG, + supported_fn=is_hydrolysis_present, + ), + "MBF_PAR_HIDRO_SHUTDOWN_TEMPERATURE": NeoPoolNumberEntityDescription( + key="MBF_PAR_HIDRO_SHUTDOWN_TEMPERATURE", + translation_key="hidro_shutdown_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=NumberDeviceClass.TEMPERATURE, + native_min_value=1.0, + native_max_value=40.0, + native_step=1.0, + masked_flag=MaskedFlag.HIDRO_SHUTDOWN_TEMPERATURE, + data_key="MBF_PAR_HIDRO_COVER_REDUCTION", + scale=1.0, + entity_category=EntityCategory.CONFIG, + supported_fn=lambda data: ( + is_hydrolysis_present(data) and is_temperature_active(data) + ), + ), +} + + +# Entities gated on a config-entry option (in addition to their supported_fn). +_ENTITY_OPTION_KEY: dict[str, str] = { + "MBF_PAR_HIDRO_COVER_REDUCTION": CONF_USE_COVER_SENSOR, + "MBF_PAR_HIDRO_SHUTDOWN_TEMPERATURE": CONF_USE_COVER_SENSOR, +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: NeoPoolConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up NeoPool number entities from a config entry.""" + coordinator = entry.runtime_data + options = entry.options + + async_add_entities( + NeoPoolNumber(coordinator, key, desc) + for key, desc in NUMBER_DESCRIPTIONS.items() + if ( + (option_key := _ENTITY_OPTION_KEY.get(key)) is None + or bool(options.get(option_key)) + ) + and (desc.supported_fn is None or desc.supported_fn(coordinator.data)) + ) + + +class NeoPoolNumber(NeoPoolEntity, NumberEntity): + """Representation of a NeoPool number entity.""" + + entity_description: NeoPoolNumberEntityDescription + _attr_mode = NumberMode.BOX + + def __init__( + self, + coordinator: NeoPoolCoordinator, + key: str, + description: NeoPoolNumberEntityDescription, + ) -> None: + """Initialize the NeoPool number entity.""" + super().__init__(coordinator) + self.entity_description = description + self._data_key = description.data_key or key + self._attr_unique_id = ( + f"{self.coordinator.config_entry.unique_id}_{key.lower()}" + ) + + self._write_unsub: CALLBACK_TYPE | None = None + self._pending_value: float | None = None + # Bumped per set_value; a flush clears only the value it queued. + self._pending_token = 0 + self._write_future: asyncio.Future[Exception | None] | None = None + self._flush_lock = asyncio.Lock() + self._flush_tasks: set[asyncio.Task[None]] = set() + self._removing = False + + def _decode_raw(self) -> float | None: + """Decode the current coordinator-data value for this entity.""" + if (flag := self.entity_description.masked_flag) is not None: + raw = decode_masked_flag(flag, self.coordinator.data) + else: + raw = self.coordinator.data.get(self._data_key) + return float(raw) if isinstance(raw, (int, float)) else None + + @override + async def async_added_to_hass(self) -> None: + """Clear transient write state, in case this entity is re-added. + + An entity-ID change removes and then re-adds the same object, so + async_will_remove_from_hass leaves _removing set and a cancelled + pending value behind. Reset both here, else every later flush aborts + and the stale optimistic value stays visible. + """ + self._removing = False + self._pending_value = None + await super().async_added_to_hass() + + @override + async def async_will_remove_from_hass(self) -> None: + """Cancel a pending write when removed, and stop any in-flight one.""" + self._removing = True + self._cancel_pending_write() + if self._write_future is not None and not self._write_future.done(): + # Awaiting callers treat cancellation as a clean exit. + self._write_future.cancel() + # A flush that already fired runs as its own task; cancel and await + # every in-flight one so no device call outlives removal and races the + # client close in async_unload_entry. Two set_value calls spaced beyond + # WRITE_DELAY can overlap, so more than one task may be active. + for task in list(self._flush_tasks): + task.cancel() + for task in list(self._flush_tasks): + with suppress(asyncio.CancelledError): + await task + await super().async_will_remove_from_hass() + + @callback + def _cancel_pending_write(self) -> None: + """Cancel a scheduled write, if any.""" + if self._write_unsub is not None: + self._write_unsub() + self._write_unsub = None + + @override + async def async_set_native_value(self, value: float) -> None: + """Set the native value of the number entity. + + The write is debounced so a rapid stepper settles into a single EEPROM + cycle. Callers in the same window await one shared future the coalesced + write resolves, so a blocking service call still sees the outcome. + """ + self._pending_value = value + # A later same-valued set_value takes a fresh token, so a flush clears + # exactly the value it queued, not a newer batch's identical one. + self._pending_token += 1 + self.async_write_ha_state() + self._cancel_pending_write() + if self._write_future is None or self._write_future.done(): + self._write_future = self.hass.loop.create_future() + future = self._write_future + self._write_unsub = async_call_later( + self.hass, WRITE_DELAY, self._schedule_flush + ) + try: + # Shield so cancelling one caller's task does not cancel the batch. + # The coalesced write never fails the future: cancelling any caller + # makes asyncio.shield attach its own logger to the shared future, + # which would report a later set_exception as an unretrieved error. + # So the write carries its outcome as the future's result instead: + # None on success, or the error to re-raise here. + outcome = await asyncio.shield(future) + except asyncio.CancelledError: + if self._removing: + return + raise + if outcome is not None: + raise outcome + + @callback + def _schedule_flush(self, _now: datetime) -> None: + """Run the debounced write as a tracked task so removal can await it.""" + self._write_unsub = None + # Detach this batch synchronously, before the task is scheduled: a + # set_value that runs before _async_flush must start a fresh future and + # its own timer, not reuse this batch or have its newer timer cleared by + # the coroutine. _pending_value stays put to back the optimistic value. + future = self._write_future + self._write_future = None + token = self._pending_token + pending = self._pending_value + task = self.coordinator.config_entry.async_create_background_task( + self.hass, + self._async_flush(future, pending, token), + name=f"{self._attr_unique_id}_flush", + ) + # Track every in-flight flush: a second set_value spaced beyond + # WRITE_DELAY can start a new task while an earlier one is still in its + # device call, and removal must cancel and await all of them. + self._flush_tasks.add(task) + task.add_done_callback(self._flush_tasks.discard) + + async def _async_flush( + self, + future: asyncio.Future[Exception | None] | None, + pending: float | None, + token: int, + ) -> None: + """Write the settled value, resolving the awaited coalesce future.""" + # False until a run reaches the end; the finally fails any earlier exit. + resolved = False + try: + if pending is None: # pragma: no cover - timer fires only when queued + return + async with self._flush_lock: + if self._abort_if_removing(future): + resolved = True + return + client = self.coordinator.client + desc = self.entity_description + raw = round(pending * desc.scale) + # Skip the EEPROM cycle if the device already holds this value. + if (current := self._decode_raw()) is not None and ( + round(current * desc.scale) == raw + ): + self._clear_pending_if_current(token) + if future is not None and not future.done(): + future.set_result(None) + resolved = True + return + try: + if desc.setpoint is not None: + await client.async_set_setpoint(desc.setpoint, raw) + overrides = {self._data_key: raw / desc.scale} + elif desc.masked_flag is not None: + # Serialize the read-modify-write against sibling writes. + async with self.coordinator.masked_write_lock: + overrides = await client.async_set_masked_register( + desc.masked_flag, raw + ) + else: # pragma: no cover - description validated upstream + return + except (NeoPoolError, OSError, TimeoutError) as err: + self._report_write_failure( + future, + token, + HomeAssistantError( + translation_domain=DOMAIN, + translation_key="modbus_communication_error", + translation_placeholders={"error": str(err)}, + ), + ) + resolved = True + return + except Exception as err: # noqa: BLE001 + # Surface unexpected errors unchanged, not as a comm error. + self._report_write_failure(future, token, err) + resolved = True + return + if self._abort_if_removing(future): # pragma: no cover + # Removal cancels every tracked flush task, so a batch + # waiting on the lock unwinds before it writes; this + # post-write removal check is a defensive guard. + resolved = True + return + try: + # Merge before clearing, else the stale register reading + # briefly surfaces as a rollback event. + self.coordinator.async_set_updated_data( + {**self.coordinator.data, **overrides} + ) + self._clear_pending_if_current(token) + self.coordinator.request_refresh_with_followup() + except Exception as err: # noqa: BLE001 + # Write succeeded; surface the merge error unchanged. + self._report_write_failure(future, token, err) + resolved = True + return + if future is not None and not future.done(): + future.set_result(None) + resolved = True + finally: + if not resolved and future is not None and not future.done(): + future.cancel() # pragma: no cover - task cancel is non-deterministic + + @callback + def _abort_if_removing( + self, future: asyncio.Future[Exception | None] | None + ) -> bool: + """Skip the write when removed, releasing the detached future cleanly.""" + if not self._removing: + return False + if future is not None and not future.done(): + future.cancel() + return True + + @callback + def _report_write_failure( + self, + future: asyncio.Future[Exception | None] | None, + batch_token: int, + exc: Exception, + ) -> None: + """Roll the optimistic value back and fail the awaiting caller.""" + self._clear_pending_if_current(batch_token) + if future is not None and not future.done(): + # Carry the error as the result, not via set_exception: a cancelled + # caller leaves asyncio.shield's logger on the shared future, which + # would report a set_exception as unretrieved. Surviving callers + # re-raise it after the shield returns. + future.set_result(exc) + + @callback + def _clear_pending_if_current(self, batch_token: int) -> None: + """Drop the optimistic value unless a newer set_value replaced it.""" + if self._pending_token == batch_token: + self._pending_value = None + self.async_write_ha_state() + + @property + @override + def native_value(self) -> float | None: + """Return the actual number value.""" + if self._pending_value is not None: + return self._pending_value + return self._decode_raw() + + @property + @override + def native_unit_of_measurement(self) -> str | None: + """Return the unit of measurement for the number value.""" + if (unit_fn := self.entity_description.unit_fn) is not None: + return unit_fn(self.coordinator.data) + return self.entity_description.native_unit_of_measurement + + @property + @override + def native_max_value(self) -> float: + """Return the maximum value for the number entity.""" + if (max_fn := self.entity_description.max_fn) is not None: + if (dynamic_max := max_fn(self.coordinator.data)) is not None: + return dynamic_max + return self.entity_description.native_max_value or super().native_max_value + + @property + @override + def native_step(self) -> float | None: + """Return the step value for the number entity.""" + if (step_fn := self.entity_description.step_fn) is not None: + return step_fn(self.coordinator.data) + return self.entity_description.native_step diff --git a/homeassistant/components/neopool/strings.json b/homeassistant/components/neopool/strings.json index 3002e901e68c..a64ffc78ce51 100644 --- a/homeassistant/components/neopool/strings.json +++ b/homeassistant/components/neopool/strings.json @@ -149,6 +149,38 @@ "name": "Pool light" } }, + "number": { + "cl1": { + "name": "Chlorine setpoint" + }, + "heating_temp": { + "name": "Temperature setpoint" + }, + "hidro": { + "name": "Hydrolysis target production level" + }, + "hidro_cover_reduction": { + "name": "Cover reduction (when covered)" + }, + "hidro_shutdown_temperature": { + "name": "Hydrolysis shutdown temp. threshold" + }, + "ph1": { + "name": "pH max limit" + }, + "ph2": { + "name": "pH min limit" + }, + "rx1": { + "name": "Redox setpoint" + }, + "smart_temp_high": { + "name": "Smart upper temperature" + }, + "smart_temp_low": { + "name": "Smart lower temperature" + } + }, "sensor": { "cell_runtime_part": { "name": "Cell runtime since reset" diff --git a/tests/components/neopool/conftest.py b/tests/components/neopool/conftest.py index 2026f242468d..318a3a3ab8f1 100644 --- a/tests/components/neopool/conftest.py +++ b/tests/components/neopool/conftest.py @@ -209,6 +209,25 @@ def mock_config_entry_switch() -> MockConfigEntry: ) +@pytest.fixture +def mock_config_entry_number() -> MockConfigEntry: + """Return a config entry with the options the number platform gates on.""" + return MockConfigEntry( + domain=DOMAIN, + title=MOCK_NAME, + unique_id=MOCK_SERIAL, + version=CURRENT_VERSION, + data={ + CONF_HOST: MOCK_HOST, + CONF_PORT: MOCK_PORT, + CONF_NAME: MOCK_NAME, + "unit_id": DEFAULT_UNIT_ID, + "modbus_framer": "tcp", + }, + options={CONF_USE_COVER_SENSOR: True}, + ) + + @pytest.fixture def mock_config_entry_binary_sensor() -> MockConfigEntry: """Return a config entry with the options the binary_sensor platform gates on.""" diff --git a/tests/components/neopool/snapshots/test_number.ambr b/tests/components/neopool/snapshots/test_number.ambr new file mode 100644 index 000000000000..5b2147b9a429 --- /dev/null +++ b/tests/components/neopool/snapshots/test_number.ambr @@ -0,0 +1,606 @@ +# serializer version: 1 +# name: test_all_entities[number.neopool_chlorine_setpoint-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 10.0, + : 0.0, + : , + : 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.neopool_chlorine_setpoint', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Chlorine setpoint', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Chlorine setpoint', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cl1', + 'unique_id': '1234567890_mbf_par_cl1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.neopool_chlorine_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'NeoPool Chlorine setpoint', + : 10.0, + : 0.0, + : , + : 0.1, + : , + }), + 'context': , + 'entity_id': 'number.neopool_chlorine_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[number.neopool_cover_reduction_when_covered-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100.0, + : 0.0, + : , + : 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.neopool_cover_reduction_when_covered', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Cover reduction (when covered)', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Cover reduction (when covered)', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hidro_cover_reduction', + 'unique_id': '1234567890_mbf_par_hidro_cover_reduction', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[number.neopool_cover_reduction_when_covered-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'NeoPool Cover reduction (when covered)', + : 100.0, + : 0.0, + : , + : 1.0, + : '%', + }), + 'context': , + 'entity_id': 'number.neopool_cover_reduction_when_covered', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25.0', + }) +# --- +# name: test_all_entities[number.neopool_hydrolysis_shutdown_temp_threshold-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 40.0, + : 1.0, + : , + : 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.neopool_hydrolysis_shutdown_temp_threshold', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hydrolysis shutdown temp. threshold', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hydrolysis shutdown temp. threshold', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hidro_shutdown_temperature', + 'unique_id': '1234567890_mbf_par_hidro_shutdown_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.neopool_hydrolysis_shutdown_temp_threshold-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'NeoPool Hydrolysis shutdown temp. threshold', + : 40.0, + : 1.0, + : , + : 1.0, + : , + }), + 'context': , + 'entity_id': 'number.neopool_hydrolysis_shutdown_temp_threshold', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '12.0', + }) +# --- +# name: test_all_entities[number.neopool_hydrolysis_target_production_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 100.0, + : 0.0, + : , + : 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.neopool_hydrolysis_target_production_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Hydrolysis target production level', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Hydrolysis target production level', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'hidro', + 'unique_id': '1234567890_mbf_par_hidro', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[number.neopool_hydrolysis_target_production_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'NeoPool Hydrolysis target production level', + : 100.0, + : 0.0, + : , + : 1.0, + : '%', + }), + 'context': , + 'entity_id': 'number.neopool_hydrolysis_target_production_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[number.neopool_ph_max_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 14.0, + : 0.0, + : , + : 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.neopool_ph_max_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'pH max limit', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'pH max limit', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ph1', + 'unique_id': '1234567890_mbf_par_ph1', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.neopool_ph_max_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'ph', + : 'NeoPool pH max limit', + : 14.0, + : 0.0, + : , + : 0.1, + }), + 'context': , + 'entity_id': 'number.neopool_ph_max_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[number.neopool_ph_min_limit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 14.0, + : 0.0, + : , + : 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.neopool_ph_min_limit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'pH min limit', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'pH min limit', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'ph2', + 'unique_id': '1234567890_mbf_par_ph2', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[number.neopool_ph_min_limit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'ph', + : 'NeoPool pH min limit', + : 14.0, + : 0.0, + : , + : 0.1, + }), + 'context': , + 'entity_id': 'number.neopool_ph_min_limit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[number.neopool_redox_setpoint-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 1000.0, + : 0.0, + : , + : 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.neopool_redox_setpoint', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Redox setpoint', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Redox setpoint', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'rx1', + 'unique_id': '1234567890_mbf_par_rx1', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.neopool_redox_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'voltage', + : 'NeoPool Redox setpoint', + : 1000.0, + : 0.0, + : , + : 1.0, + : , + }), + 'context': , + 'entity_id': 'number.neopool_redox_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[number.neopool_smart_lower_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 40.0, + : 0.0, + : , + : 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.neopool_smart_lower_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Smart lower temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Smart lower temperature', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'smart_temp_low', + 'unique_id': '1234567890_mbf_par_smart_temp_low', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.neopool_smart_lower_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'NeoPool Smart lower temperature', + : 40.0, + : 0.0, + : , + : 1.0, + : , + }), + 'context': , + 'entity_id': 'number.neopool_smart_lower_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[number.neopool_smart_upper_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 40.0, + : 0.0, + : , + : 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.neopool_smart_upper_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Smart upper temperature', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Smart upper temperature', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'smart_temp_high', + 'unique_id': '1234567890_mbf_par_smart_temp_high', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.neopool_smart_upper_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'NeoPool Smart upper temperature', + : 40.0, + : 0.0, + : , + : 1.0, + : , + }), + 'context': , + 'entity_id': 'number.neopool_smart_upper_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_all_entities[number.neopool_temperature_setpoint-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : 40.0, + : 0.0, + : , + : 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.neopool_temperature_setpoint', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Temperature setpoint', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature setpoint', + 'platform': 'neopool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'heating_temp', + 'unique_id': '1234567890_mbf_par_heating_temp', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[number.neopool_temperature_setpoint-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'NeoPool Temperature setpoint', + : 40.0, + : 0.0, + : , + : 1.0, + : , + }), + 'context': , + 'entity_id': 'number.neopool_temperature_setpoint', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/neopool/test_number.py b/tests/components/neopool/test_number.py new file mode 100644 index 000000000000..7e81a63e77e8 --- /dev/null +++ b/tests/components/neopool/test_number.py @@ -0,0 +1,1434 @@ +"""Tests for the NeoPool number platform.""" + +import asyncio +from datetime import timedelta +import gc +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +from freezegun.api import FrozenDateTimeFactory +from neopool_modbus.exceptions import NeoPoolConnectionError +from neopool_modbus.registers import MaskedFlag, SetpointKind +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_platform, entity_registry as er + +from . import setup_integration +from .conftest import MOCK_POOL_DATA + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +# Longer than the entity's settle delay so a single tick flushes the write. +FLUSH = timedelta(seconds=5) +# Shorter than the settle delay: advancing by this must NOT flush a pending write. +PARTIAL = timedelta(seconds=2) + + +def _number_entity_id( + hass: HomeAssistant, entry: MockConfigEntry, key_lower_suffix: str +) -> str: + registry = er.async_get(hass) + entries = [ + e + for e in er.async_entries_for_config_entry(registry, entry.entry_id) + if e.domain == NUMBER_DOMAIN and e.unique_id.endswith(f"_{key_lower_suffix}") + ] + assert entries, ( + f"no number entity ending in _{key_lower_suffix}, found: " + + ", ".join( + e.unique_id + for e in er.async_entries_for_config_entry(registry, entry.entry_id) + if e.domain == NUMBER_DOMAIN + ) + ) + return entries[0].entity_id + + +async def _set_value(hass: HomeAssistant, entity_id: str, value: float) -> None: + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {"entity_id": entity_id, ATTR_VALUE: value}, + blocking=True, + ) + + +def _set_value_nowait( + hass: HomeAssistant, entity_id: str, value: float +) -> asyncio.Task[None]: + """Start a blocking set_value as a task so the freezer can drive the flush. + + A blocking call now awaits the debounced write, which only runs on a + freezer tick. Awaiting it inline would deadlock, so callers schedule it, + flush, then await the task to observe the write's outcome. + """ + return hass.async_create_task(_set_value(hass, entity_id, value)) + + +async def _let_park(hass: HomeAssistant) -> None: + """Yield enough for a scheduled set_value task to reach its await point. + + ``async_block_till_done`` waits on hass-tracked tasks, so it would block on + a set_value task intentionally parked before the flush; plain event-loop + yields let the task register its optimistic state without that wait. + """ + for _ in range(3): + await asyncio.sleep(0) + + +async def _write( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + entity_id: str, + value: float, +) -> None: + """Set a value, flush the debounce, and await the write's outcome. + + Raises whatever the debounced write raises (a device error surfaces to the + blocking caller), matching how a ``blocking: true`` service call behaves. + """ + task = _set_value_nowait(hass, entity_id, value) + await _flush(hass, freezer) + await task + + +async def _flush(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: + """Advance past the debounce cooldown and let the pending write run.""" + freezer.tick(FLUSH) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + +async def _advance(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: + """Advance by less than the cooldown; a pending write must not fire yet. + + Uses event-loop yields rather than ``async_block_till_done`` so a set_value + task parked on the coalesce future does not stall the advance. + """ + freezer.tick(PARTIAL) + async_fire_time_changed(hass) + await _let_park(hass) + + +async def _poll( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_client: MagicMock, + data: dict[str, Any], +) -> None: + """Push a coordinator poll returning ``data``.""" + mock_client.async_read_all.return_value = data + freezer.tick(timedelta(seconds=60)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + +async def test_simple_number_writes_register_after_debounce( + hass: HomeAssistant, + mock_config_entry_number: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Setting a numeric value dispatches to the correct high-level API.""" + mock_neopool_client.async_set_setpoint = AsyncMock( + return_value={"MBF_PAR_PH1": 750} + ) + + await setup_integration(hass, mock_config_entry_number) + + ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1") + mock_neopool_client.async_set_setpoint.reset_mock() + + await _write(hass, freezer, ph1_entity_id, 7.5) + + mock_neopool_client.async_set_setpoint.assert_awaited_once_with( + SetpointKind.PH_MAX, 750 + ) + + smart_entity_id = _number_entity_id( + hass, mock_config_entry_number, "mbf_par_smart_temp_high" + ) + mock_neopool_client.async_set_setpoint.reset_mock() + + await _write(hass, freezer, smart_entity_id, 30.0) + + mock_neopool_client.async_set_setpoint.assert_awaited_once_with( + SetpointKind.SMART_TEMP_HIGH, 30 + ) + + +async def test_scaled_setpoint_optimistic_value_is_ui_scaled( + hass: HomeAssistant, + mock_config_entry_number: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Optimistic state after a scaled setpoint write is UI-scaled. + + The lib override carries the raw register value (7.5 pH -> 750), but the + entity state must read back the decoded value. Regression guard: the merged + optimistic value must surface as 7.5, not 750. + """ + mock_neopool_client.async_set_setpoint = AsyncMock( + return_value={"MBF_PAR_PH1": 750} + ) + + await setup_integration(hass, mock_config_entry_number) + + ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1") + await _write(hass, freezer, ph1_entity_id, 7.5) + + state = hass.states.get(ph1_entity_id) + assert state is not None + assert float(state.state) == 7.5 + + +async def test_pending_value_shown_optimistically_before_write( + hass: HomeAssistant, + mock_config_entry_number: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """State surfaces the requested value while the debounce is in flight. + + Until the debounced write merges the override, the coordinator still holds + the old register. The entity must report the requested value optimistically + so the UI does not snap back to the stale reading before the cooldown fires. + """ + mock_neopool_client.async_set_setpoint = AsyncMock( + return_value={"MBF_PAR_PH1": 750} + ) + await setup_integration(hass, mock_config_entry_number) + + ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1") + + task = _set_value_nowait(hass, ph1_entity_id, 7.5) + # Advance less than the cooldown: the optimistic state is set, no write yet. + await _advance(hass, freezer) + + state = hass.states.get(ph1_entity_id) + assert state is not None + assert float(state.state) == 7.5 + mock_neopool_client.async_set_setpoint.assert_not_awaited() + + # Let the queued write settle so the awaiting task finishes cleanly. + await _flush(hass, freezer) + await task + + +async def test_scaled_write_rounds_to_nearest_register_int( + hass: HomeAssistant, + mock_config_entry_number: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """The scaled register value is rounded, not truncated. + + Regression guard: ``4.1 * 100`` evaluates to ``409.999...`` in float, so a + plain ``int()`` would write 409. The write must round to 410. + """ + mock_neopool_client.async_set_setpoint = AsyncMock( + return_value={"MBF_PAR_PH1": 410} + ) + await setup_integration(hass, mock_config_entry_number) + + ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1") + mock_neopool_client.async_set_setpoint.reset_mock() + + await _write(hass, freezer, ph1_entity_id, 4.1) + + mock_neopool_client.async_set_setpoint.assert_awaited_once_with( + SetpointKind.PH_MAX, 410 + ) + + +async def test_heating_setpoint_writes_via_high_level_api( + hass: HomeAssistant, + mock_config_entry_number: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Writing the heating setpoint delegates to async_set_setpoint(HEATING).""" + mock_neopool_client.async_set_setpoint = AsyncMock( + return_value={"MBF_PAR_HEATING_TEMP": 28} + ) + + await setup_integration(hass, mock_config_entry_number) + entity_id = _number_entity_id( + hass, mock_config_entry_number, "mbf_par_heating_temp" + ) + mock_neopool_client.async_set_setpoint.reset_mock() + + await _write(hass, freezer, entity_id, 28.0) + + mock_neopool_client.async_set_setpoint.assert_awaited_once_with( + SetpointKind.HEATING, 28 + ) + + +@pytest.mark.parametrize( + ("suffix", "data_key", "value", "kind", "expected_raw"), + [ + pytest.param("mbf_par_ph1", "MBF_PAR_PH1", 7.2, SetpointKind.PH_MAX, 720), + pytest.param("mbf_par_ph2", "MBF_PAR_PH2", 7.0, SetpointKind.PH_MIN, 700), + pytest.param("mbf_par_rx1", "MBF_PAR_RX1", 700, SetpointKind.REDOX, 700), + pytest.param("mbf_par_cl1", "MBF_PAR_CL1", 1.5, SetpointKind.CHLORINE, 150), + pytest.param("mbf_par_hidro", "MBF_PAR_HIDRO", 50, SetpointKind.HIDRO, 500), + pytest.param( + "mbf_par_smart_temp_high", + "MBF_PAR_SMART_TEMP_HIGH", + 18, + SetpointKind.SMART_TEMP_HIGH, + 18, + ), + pytest.param( + "mbf_par_smart_temp_low", + "MBF_PAR_SMART_TEMP_LOW", + 12, + SetpointKind.SMART_TEMP_LOW, + 12, + ), + ], +) +async def test_setpoint_write_targets_map_to_kind_and_scale( + hass: HomeAssistant, + mock_config_entry_number: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, + suffix: str, + data_key: str, + value: float, + kind: SetpointKind, + expected_raw: int, +) -> None: + """Each setpoint entity writes to its own SetpointKind with the right scale. + + A wrong enum or scale in a single description would pass the snapshots and + the generic debounce tests, so exercise every write target explicitly. + """ + mock_neopool_client.async_set_setpoint = AsyncMock( + return_value={data_key: expected_raw} + ) + await setup_integration(hass, mock_config_entry_number) + + entity_id = _number_entity_id(hass, mock_config_entry_number, suffix) + mock_neopool_client.async_set_setpoint.reset_mock() + + await _write(hass, freezer, entity_id, value) + + mock_neopool_client.async_set_setpoint.assert_awaited_once_with(kind, expected_raw) + + +async def test_number_state_returns_raw_register_value( + hass: HomeAssistant, + mock_config_entry_number: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """The entity state reflects the register value the coordinator holds.""" + await setup_integration(hass, mock_config_entry_number) + await _poll( + hass, freezer, mock_neopool_client, {**MOCK_POOL_DATA, "MBF_PAR_PH1": 7.55} + ) + + ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1") + state = hass.states.get(ph1_entity_id) + assert state is not None + assert float(state.state) == 7.55 + + +@pytest.mark.parametrize( + ("visual_style", "expected_unit", "expected_step", "expected_max"), + [ + pytest.param(0x4000, PERCENTAGE, "1.0", 100, id="percent"), + pytest.param(0x2000, "g/h", "0.1", 85, id="grh"), + ], +) +async def test_hidro_units_follow_visual_style( + hass: HomeAssistant, + mock_config_entry_number: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, + visual_style: int, + expected_unit: str, + expected_step: str, + expected_max: int, +) -> None: + """MBF_PAR_HIDRO unit, step and max follow the reported percent/g-h mode. + + MBF_PAR_UICFG_MACH_VISUAL_STYLE forces percentage (0x4000) or g/h (0x2000). + Percent mode caps at 100; g/h mode uses the reported nominal + (MBF_PAR_HIDRO_NOM). The nominal is a non-100 value so percent fails if the + maximum falls back to reading it. + """ + await setup_integration(hass, mock_config_entry_number) + await _poll( + hass, + freezer, + mock_neopool_client, + { + **MOCK_POOL_DATA, + "MBF_PAR_HIDRO_NOM": 85, + "MBF_PAR_MODEL": 0x0002, + "MBF_PAR_UICFG_MACH_VISUAL_STYLE": visual_style, + }, + ) + + entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_hidro") + state = hass.states.get(entity_id) + assert state is not None + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == expected_unit + assert state.attributes["step"] == float(expected_step) + assert state.attributes["max"] == expected_max + + +async def test_masked_number_state_decodes_field( + hass: HomeAssistant, + mock_config_entry_number: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Masked compound numbers decode their field from the shared register. + + HIDRO_COVER_REDUCTION / SHUTDOWN_TEMPERATURE share register 0x042D: the + lower byte holds cover reduction %, the upper byte the shutdown temperature. + Each entity's state must isolate its own field. + """ + await setup_integration(hass, mock_config_entry_number) + await _poll( + hass, + freezer, + mock_neopool_client, + {**MOCK_POOL_DATA, "MBF_PAR_HIDRO_COVER_REDUCTION": 0x0C19}, + ) + + cover_id = _number_entity_id( + hass, mock_config_entry_number, "mbf_par_hidro_cover_reduction" + ) + shutdown_id = _number_entity_id( + hass, mock_config_entry_number, "mbf_par_hidro_shutdown_temperature" + ) + cover = hass.states.get(cover_id) + shutdown = hass.states.get(shutdown_id) + assert cover is not None and float(cover.state) == 25 + assert shutdown is not None and float(shutdown.state) == 12 + + +@pytest.mark.parametrize( + ("key_suffix", "flag", "value", "register_result"), + [ + ( + "mbf_par_hidro_cover_reduction", + MaskedFlag.HIDRO_COVER_REDUCTION_PERCENT, + 50, + 0x0C32, # shutdown temp 12 (0x0C) in the high byte, cover 50 (0x32) + ), + ( + "mbf_par_hidro_shutdown_temperature", + MaskedFlag.HIDRO_SHUTDOWN_TEMPERATURE, + 15, + 0x0F19, # shutdown temp 15 (0x0F) in the high byte, cover 25 (0x19) + ), + ], +) +async def test_masked_number_write_passes_field_value( + hass: HomeAssistant, + mock_config_entry_number: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, + key_suffix: str, + flag: MaskedFlag, + value: float, + register_result: int, +) -> None: + """Writing one masked number dispatches to async_set_masked_register. + + The read-modify-write that keeps the sibling byte intact is a lib concern + (``async_set_masked_register`` performs it internally). Each entity passes + its own *field value*, not the packed 16-bit register, tagged with its own + masked flag: the two share register 0x042D but map to distinct fields. + """ + mock_neopool_client.async_set_masked_register = AsyncMock( + return_value={"MBF_PAR_HIDRO_COVER_REDUCTION": register_result} + ) + + await setup_integration(hass, mock_config_entry_number) + await _poll( + hass, + freezer, + mock_neopool_client, + {**MOCK_POOL_DATA, "MBF_PAR_HIDRO_COVER_REDUCTION": 0x0C19}, + ) + + entity_id = _number_entity_id(hass, mock_config_entry_number, key_suffix) + mock_neopool_client.async_set_masked_register.reset_mock() + + await _write(hass, freezer, entity_id, value) + + mock_neopool_client.async_set_masked_register.assert_awaited_once_with(flag, value) + state = hass.states.get(entity_id) + assert state is not None + assert float(state.state) == value + + +async def test_write_works_after_entity_id_change( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry_number: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """A write still reaches the device after the entity is renamed. + + Changing the entity ID removes and re-adds the same object. If removal + leaves the removing flag set, every later flush aborts and the write never + reaches the device, so the write must run against the new entity ID. + """ + mock_neopool_client.async_set_setpoint = AsyncMock( + return_value={"MBF_PAR_PH1": 750} + ) + await setup_integration(hass, mock_config_entry_number) + + ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1") + new_entity_id = f"{ph1_entity_id}_renamed" + entity_registry.async_update_entity(ph1_entity_id, new_entity_id=new_entity_id) + await hass.async_block_till_done() + assert hass.states.get(new_entity_id) is not None + + mock_neopool_client.async_set_setpoint.reset_mock() + await _write(hass, freezer, new_entity_id, 7.5) + + mock_neopool_client.async_set_setpoint.assert_awaited_once_with( + SetpointKind.PH_MAX, 750 + ) + state = hass.states.get(new_entity_id) + assert state is not None + assert float(state.state) == 7.5 + + +async def test_masked_writes_are_serialized( + hass: HomeAssistant, + mock_config_entry_number: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Concurrent masked writes serialize through the coordinator lock. + + Cover reduction and shutdown temperature share one packed register, so + their read-modify-write must not overlap; otherwise the later write would + restore the sibling byte to a stale value. + """ + overlap = False + active = 0 + + async def _slow_masked(flag: MaskedFlag, value: int) -> dict[str, Any]: + nonlocal overlap, active + active += 1 + if active > 1: + overlap = True + await asyncio.sleep(0) + active -= 1 + return {"MBF_PAR_HIDRO_COVER_REDUCTION": 0x0C19} + + mock_neopool_client.async_set_masked_register = AsyncMock(side_effect=_slow_masked) + + await setup_integration(hass, mock_config_entry_number) + await _poll( + hass, + freezer, + mock_neopool_client, + {**MOCK_POOL_DATA, "MBF_PAR_HIDRO_COVER_REDUCTION": 0x0C19}, + ) + + cover_id = _number_entity_id( + hass, mock_config_entry_number, "mbf_par_hidro_cover_reduction" + ) + shutdown_id = _number_entity_id( + hass, mock_config_entry_number, "mbf_par_hidro_shutdown_temperature" + ) + + # Queue both writes so their debounced flushes race the shared register. + cover_task = _set_value_nowait(hass, cover_id, 50) + shutdown_task = _set_value_nowait(hass, shutdown_id, 20) + await _flush(hass, freezer) + await asyncio.gather(cover_task, shutdown_task) + + assert not overlap + assert mock_neopool_client.async_set_masked_register.await_count == 2 + + +@pytest.mark.parametrize( + "write_error", + [ + pytest.param(NeoPoolConnectionError("boom"), id="lib-connection-error"), + pytest.param(TimeoutError("boom"), id="timeout"), + pytest.param(OSError("boom"), id="os-error"), + ], +) +async def test_number_write_raises_communication_error( + hass: HomeAssistant, + mock_config_entry_number: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, + write_error: Exception, +) -> None: + """A device error in the debounced write surfaces to the blocking caller. + + The write is coalesced behind a shared future the blocking service call + awaits, so a communication failure raises HomeAssistantError (matching the + switch platform) instead of being swallowed. + """ + mock_neopool_client.async_set_setpoint = AsyncMock(side_effect=write_error) + await setup_integration(hass, mock_config_entry_number) + ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1") + + task = _set_value_nowait(hass, ph1_entity_id, 7.5) + await _flush(hass, freezer) + with pytest.raises(HomeAssistantError) as err: + await task + + assert err.value.translation_key == "modbus_communication_error" + mock_neopool_client.async_set_setpoint.assert_awaited_once() + + +async def test_optimistic_state_rolls_back_on_write_failure( + hass: HomeAssistant, + mock_config_entry_number: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """A failed write rolls the optimistic state back to the device reading. + + The requested value shows optimistically while the write is queued; when the + device rejects it, the state must snap back to the last known register value + rather than linger on the value that never took. + """ + await setup_integration(hass, mock_config_entry_number) + await _poll( + hass, freezer, mock_neopool_client, {**MOCK_POOL_DATA, "MBF_PAR_PH1": 7.0} + ) + + ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1") + mock_neopool_client.async_set_setpoint = AsyncMock( + side_effect=NeoPoolConnectionError("boom") + ) + + task = _set_value_nowait(hass, ph1_entity_id, 7.5) + await _let_park(hass) + + # Optimistic value is shown while the write is in flight. + state = hass.states.get(ph1_entity_id) + assert state is not None + assert float(state.state) == 7.5 + + await _flush(hass, freezer) + with pytest.raises(HomeAssistantError): + await task + + state = hass.states.get(ph1_entity_id) + assert state is not None + assert float(state.state) == 7.0 + + +async def test_coalesced_callers_all_succeed_together( + hass: HomeAssistant, + mock_config_entry_number: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Three quick set_value calls collapse to one write and all callers succeed. + + Every caller awaits the same coalesce future, so the single write of the + last value resolves them together. + """ + mock_neopool_client.async_set_setpoint = AsyncMock( + return_value={"MBF_PAR_PH1": 750} + ) + await setup_integration(hass, mock_config_entry_number) + + ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1") + mock_neopool_client.async_set_setpoint.reset_mock() + + tasks = [_set_value_nowait(hass, ph1_entity_id, value) for value in (7.0, 7.2, 7.5)] + await _flush(hass, freezer) + results = await asyncio.gather(*tasks) + + mock_neopool_client.async_set_setpoint.assert_awaited_once_with( + SetpointKind.PH_MAX, 750 + ) + assert results == [None, None, None] + + +async def test_coalesced_callers_all_raise_together( + hass: HomeAssistant, + mock_config_entry_number: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """A failed coalesced write raises for every caller in the debounce window. + + All three blocking callers await the shared future, so the single write's + device error surfaces to each of them. + """ + mock_neopool_client.async_set_setpoint = AsyncMock( + side_effect=NeoPoolConnectionError("boom") + ) + await setup_integration(hass, mock_config_entry_number) + + ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1") + mock_neopool_client.async_set_setpoint.reset_mock() + + tasks = [_set_value_nowait(hass, ph1_entity_id, value) for value in (7.0, 7.2, 7.5)] + await _flush(hass, freezer) + results = await asyncio.gather(*tasks, return_exceptions=True) + + mock_neopool_client.async_set_setpoint.assert_awaited_once_with( + SetpointKind.PH_MAX, 750 + ) + assert all(isinstance(r, HomeAssistantError) for r in results) + + +async def test_failed_write_survives_a_cancelled_coalesced_caller( + hass: HomeAssistant, + mock_config_entry_number: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """A failed coalesced write raises for the survivor and logs no warning. + + Two callers share one coalesce future; one is cancelled before the delayed + write fails. Cancelling a caller makes asyncio.shield attach its own logger + to the shared future, so failing it via set_exception would be reported as + an unretrieved error at teardown. The write instead carries its outcome as + the future's result, so the survivor still re-raises the device error and + no "exception in shielded future" warning is logged. + """ + in_write = asyncio.Event() + release = asyncio.Event() + + async def _blocking_boom(kind: SetpointKind, value: int) -> dict[str, Any]: + in_write.set() + await release.wait() + raise NeoPoolConnectionError("boom") + + mock_neopool_client.async_set_setpoint = AsyncMock(side_effect=_blocking_boom) + await setup_integration(hass, mock_config_entry_number) + + ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1") + + # Two callers coalesce onto one batch; the write enters the library call and + # blocks there, so both are parked on the shared shielded future. + victim = _set_value_nowait(hass, ph1_entity_id, 7.5) + survivor = _set_value_nowait(hass, ph1_entity_id, 7.5) + await _let_park(hass) + freezer.tick(FLUSH) + async_fire_time_changed(hass) + await in_write.wait() + + # Cancel one caller; the shield spares the batch, so the write still runs. + victim.cancel() + with pytest.raises(asyncio.CancelledError): + await victim + + # Let the shielded write finish and fail; the survivor observes the error. + release.set() + with pytest.raises(HomeAssistantError): + await survivor + await hass.async_block_till_done() + + # Force a GC pass so any unretrieved shielded future would surface a warning. + gc.collect() + await asyncio.sleep(0) + + assert "exception in shielded future" not in caplog.text + assert "exception was never retrieved" not in caplog.text + + +async def test_write_queued_during_flush_gets_its_own_outcome( + hass: HomeAssistant, + mock_config_entry_number: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """A set_value arriving mid-flush writes on its own, not the in-flight batch. + + While the first flush is awaiting device I/O, a new set_value must not reuse + the in-flight future: it would return the first write's outcome and its own + value would never reach the device. Each batch gets its own future and its + own serialized write. + """ + in_write = asyncio.Event() + release = asyncio.Event() + seen: list[int] = [] + + async def _gated_setpoint(kind: SetpointKind, value: int) -> dict[str, Any]: + seen.append(value) + if len(seen) == 1: + in_write.set() + await release.wait() + return {"MBF_PAR_PH1": value} + + mock_neopool_client.async_set_setpoint = AsyncMock(side_effect=_gated_setpoint) + await setup_integration(hass, mock_config_entry_number) + + ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1") + mock_neopool_client.async_set_setpoint.reset_mock() + seen.clear() + + # First write enters the library call and blocks there. + first = _set_value_nowait(hass, ph1_entity_id, 7.0) + freezer.tick(FLUSH) + async_fire_time_changed(hass) + await in_write.wait() + + # A second value arrives while the first write is still in flight. + second = _set_value_nowait(hass, ph1_entity_id, 8.0) + await _let_park(hass) + + # Release the first write, then flush the second batch. + release.set() + await first + await _flush(hass, freezer) + await second + + # Two distinct writes ran, each with its own value. + assert seen == [700, 800] + state = hass.states.get(ph1_entity_id) + assert state is not None + assert float(state.state) == 8.0 + + +async def test_same_value_queued_during_flush_still_resolves( + hass: HomeAssistant, + mock_config_entry_number: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """A second set_value of the same value mid-flush still gets an outcome. + + The two batches carry the same float value, so telling them apart by value + alone fails: the first flush would clear the second batch's pending value, + its own flush would then find nothing queued, and the newer caller would be + left cancelled. Each batch is tagged, so the newer caller resolves. + """ + in_write = asyncio.Event() + release = asyncio.Event() + seen: list[int] = [] + + async def _gated_setpoint(kind: SetpointKind, value: int) -> dict[str, Any]: + seen.append(value) + if len(seen) == 1: + in_write.set() + await release.wait() + return {"MBF_PAR_PH1": value} + + mock_neopool_client.async_set_setpoint = AsyncMock(side_effect=_gated_setpoint) + await setup_integration(hass, mock_config_entry_number) + + ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1") + mock_neopool_client.async_set_setpoint.reset_mock() + seen.clear() + + # First write enters the library call and blocks there. + first = _set_value_nowait(hass, ph1_entity_id, 7.0) + freezer.tick(FLUSH) + async_fire_time_changed(hass) + await in_write.wait() + + # The same value arrives again while the first write is still in flight. + second = _set_value_nowait(hass, ph1_entity_id, 7.0) + await _let_park(hass) + + # Release the first write, then flush the second batch. Both callers must + # observe an outcome; neither is left cancelled. + release.set() + await first + await _flush(hass, freezer) + await second + + # Both same-valued batches reached the client as distinct writes. + assert seen == [700, 700] + state = hass.states.get(ph1_entity_id) + assert state is not None + assert float(state.state) == 7.0 + + +async def test_unexpected_write_error_reaches_caller( + hass: HomeAssistant, + mock_config_entry_number: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """An unexpected write error still fails the blocking caller. + + A raise outside the device-error set must not resolve the coalesce future as + a success: the caller would otherwise see a successful service call while the + value never reached the device. It surfaces unchanged, not relabeled as a + communication error. + """ + mock_neopool_client.async_set_setpoint = AsyncMock(side_effect=ValueError("boom")) + await setup_integration(hass, mock_config_entry_number) + + ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1") + + task = _set_value_nowait(hass, ph1_entity_id, 7.5) + await _flush(hass, freezer) + with pytest.raises(ValueError, match="boom"): + await task + + +async def test_post_write_merge_failure_reaches_caller( + hass: HomeAssistant, + mock_config_entry_number: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """A raise after a successful device write still fails the caller. + + The device write succeeds, but merging the result into the coordinator + raises. The flush must not fall through to a success result: the failure + surfaces unchanged, not relabeled as a communication error, since the write + itself succeeded. + """ + mock_neopool_client.async_set_setpoint = AsyncMock( + return_value={"MBF_PAR_PH1": 750} + ) + await setup_integration(hass, mock_config_entry_number) + + ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1") + mock_neopool_client.async_set_setpoint.reset_mock() + + task = _set_value_nowait(hass, ph1_entity_id, 7.5) + with patch.object( + mock_config_entry_number.runtime_data, + "async_set_updated_data", + side_effect=RuntimeError("merge boom"), + ): + await _flush(hass, freezer) + with pytest.raises(RuntimeError, match="merge boom"): + await task + + +async def test_optimistic_value_survives_overlapping_flush( + hass: HomeAssistant, + mock_config_entry_number: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """A newer optimistic value is not clobbered by an in-flight older write. + + The first flush holds the write lock while its device call blocks. A second + debounce elapses and queues a newer value. When the first write commits, it + must keep showing the newer optimistic value, not republish its own older + value for the duration of the second write. + """ + release = asyncio.Event() + in_write = asyncio.Event() + seen: list[int] = [] + + async def _gated_setpoint(kind: SetpointKind, value: int) -> dict[str, Any]: + seen.append(value) + if len(seen) == 1: + in_write.set() + await release.wait() + return {"MBF_PAR_PH1": value} + + mock_neopool_client.async_set_setpoint = AsyncMock(side_effect=_gated_setpoint) + await setup_integration(hass, mock_config_entry_number) + + ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1") + mock_neopool_client.async_set_setpoint.reset_mock() + seen.clear() + + # First write enters the library call and blocks there. + first = _set_value_nowait(hass, ph1_entity_id, 7.0) + freezer.tick(FLUSH) + async_fire_time_changed(hass) + await in_write.wait() + + # A newer value arrives while the first write is still in flight. + second = _set_value_nowait(hass, ph1_entity_id, 8.0) + await _let_park(hass) + + # Release the first write; it commits its older value to the coordinator but + # must keep showing the newer optimistic value rather than republishing 7.0. + release.set() + await first + state = hass.states.get(ph1_entity_id) + assert state is not None + assert float(state.state) == 8.0 + + # The second write then runs and confirms the newer value on the device. + await _flush(hass, freezer) + await second + assert seen == [700, 800] + state = hass.states.get(ph1_entity_id) + assert state is not None + assert float(state.state) == 8.0 + + +async def test_repeated_set_value_writes_only_latest( + hass: HomeAssistant, + mock_config_entry_number: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Two quick set_value calls debounce to a single write of the last value.""" + mock_neopool_client.async_set_setpoint = AsyncMock( + return_value={"MBF_PAR_PH1": 750} + ) + await setup_integration(hass, mock_config_entry_number) + + ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1") + mock_neopool_client.async_set_setpoint.reset_mock() + + task1 = _set_value_nowait(hass, ph1_entity_id, 7.0) + task2 = _set_value_nowait(hass, ph1_entity_id, 7.5) + await _flush(hass, freezer) + await asyncio.gather(task1, task2) + + mock_neopool_client.async_set_setpoint.assert_awaited_once_with( + SetpointKind.PH_MAX, 750 + ) + + +async def test_stepper_settle_restarts_timer( + hass: HomeAssistant, + mock_config_entry_number: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Each click restarts the timer; the write fires after the last click. + + Two clicks 2s apart (each under the 3s settle delay) span 4s in total. A + fixed-window debounce anchored to the first click would have written the + intermediate value at 3s; the restart-on-click behavior must instead write + only the final value once the stepper settles. + """ + mock_neopool_client.async_set_setpoint = AsyncMock( + return_value={"MBF_PAR_PH1": 750} + ) + await setup_integration(hass, mock_config_entry_number) + + ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1") + mock_neopool_client.async_set_setpoint.reset_mock() + + task1 = _set_value_nowait(hass, ph1_entity_id, 7.0) + await _advance(hass, freezer) + task2 = _set_value_nowait(hass, ph1_entity_id, 7.5) + await _advance(hass, freezer) + + # 4s elapsed since the first click, but only 2s since the last: no write yet. + mock_neopool_client.async_set_setpoint.assert_not_awaited() + + await _flush(hass, freezer) + await asyncio.gather(task1, task2) + + mock_neopool_client.async_set_setpoint.assert_awaited_once_with( + SetpointKind.PH_MAX, 750 + ) + + +async def test_no_write_when_settled_value_unchanged( + hass: HomeAssistant, + mock_config_entry_number: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """A sweep that lands back on the current value writes nothing. + + Stepping away and back to the device's value must not burn an EEPROM cycle: + the settled raw value equals the coordinator's, so the write is skipped. + An off-step request that rounds to the same register must also drop the + optimistic state back to the device reading instead of lingering. + """ + mock_neopool_client.async_set_setpoint = AsyncMock( + return_value={"MBF_PAR_PH1": 750} + ) + await setup_integration(hass, mock_config_entry_number) + await _poll( + hass, freezer, mock_neopool_client, {**MOCK_POOL_DATA, "MBF_PAR_PH1": 7.5} + ) + + ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1") + mock_neopool_client.async_set_setpoint.reset_mock() + + task1 = _set_value_nowait(hass, ph1_entity_id, 8.0) + task2 = _set_value_nowait(hass, ph1_entity_id, 7.5) + await _flush(hass, freezer) + await asyncio.gather(task1, task2) + + mock_neopool_client.async_set_setpoint.assert_not_awaited() + state = hass.states.get(ph1_entity_id) + assert state is not None + assert float(state.state) == 7.5 + + # Off-step request rounding to the same register: no write, and the UI must + # snap back from the optimistic 7.504 to the device value. + await _write(hass, freezer, ph1_entity_id, 7.504) + + mock_neopool_client.async_set_setpoint.assert_not_awaited() + state = hass.states.get(ph1_entity_id) + assert state is not None + assert float(state.state) == 7.5 + + +async def test_pending_write_dropped_on_remove( + hass: HomeAssistant, + mock_config_entry_number: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Unloading cancels an un-elapsed write and releases the awaiting caller. + + The queued write must never fire, and the blocking caller awaiting the + coalesce future exits cleanly (cancellation, not an error) when the entity + is removed mid-wait. + """ + mock_neopool_client.async_set_setpoint = AsyncMock( + return_value={"MBF_PAR_PH1": 750} + ) + await setup_integration(hass, mock_config_entry_number) + + ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1") + task = _set_value_nowait(hass, ph1_entity_id, 7.5) + await _let_park(hass) + + # Unload before the cooldown elapses: the queued write must never fire. + await hass.config_entries.async_unload(mock_config_entry_number.entry_id) + await _flush(hass, freezer) + + # The awaiting caller returns without raising once the future is cancelled. + await task + mock_neopool_client.async_set_setpoint.assert_not_awaited() + + +async def test_external_cancel_propagates_but_spares_coalesced_caller( + hass: HomeAssistant, + mock_config_entry_number: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Cancelling one caller re-raises for it but spares the coalesced batch. + + Two blocking callers land in the same debounce window and share one + coalesce future. Cancelling one caller's service task must propagate the + cancellation to that caller alone: the shield keeps the shared future + alive, so the write still fires and the surviving caller observes its + outcome without a spurious cancellation. + """ + mock_neopool_client.async_set_setpoint = AsyncMock( + return_value={"MBF_PAR_PH1": 750} + ) + await setup_integration(hass, mock_config_entry_number) + + ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1") + mock_neopool_client.async_set_setpoint.reset_mock() + + victim = _set_value_nowait(hass, ph1_entity_id, 7.5) + survivor = _set_value_nowait(hass, ph1_entity_id, 7.5) + await _let_park(hass) + + # Cancel only the victim's task; its cancellation must propagate to it. + victim.cancel() + with pytest.raises(asyncio.CancelledError): + await victim + + # The batch is untouched: the queued write fires and the survivor, still + # awaiting the shielded future, returns cleanly with the write's outcome. + await _flush(hass, freezer) + await survivor + mock_neopool_client.async_set_setpoint.assert_awaited_once_with( + SetpointKind.PH_MAX, 750 + ) + + +async def test_inflight_write_skips_coordinator_on_remove( + hass: HomeAssistant, + mock_config_entry_number: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """A write already in flight when the entity is removed skips the coordinator. + + The scheduled call can only be cancelled before it fires. Once the write is + inside a library call and the entity is removed, it must not merge into or + refresh the coordinator whose client is being torn down. + """ + in_write = asyncio.Event() + release = asyncio.Event() + + async def _blocking_setpoint(kind: SetpointKind, value: int) -> dict[str, Any]: + in_write.set() + await release.wait() + return {"MBF_PAR_PH1": value} + + mock_neopool_client.async_set_setpoint = AsyncMock(side_effect=_blocking_setpoint) + await setup_integration(hass, mock_config_entry_number) + + ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1") + task = _set_value_nowait(hass, ph1_entity_id, 7.5) + await _let_park(hass) + + # Let the timer fire and the write enter the library call, then block there. + freezer.tick(FLUSH) + async_fire_time_changed(hass) + await in_write.wait() + + with patch.object( + mock_config_entry_number.runtime_data, "async_set_updated_data" + ) as mock_update: + await hass.config_entries.async_unload(mock_config_entry_number.entry_id) + release.set() + await hass.async_block_till_done(wait_background_tasks=True) + + await task + mock_neopool_client.async_set_setpoint.assert_awaited_once() + mock_update.assert_not_called() + + +async def test_client_close_waits_for_inflight_flush( + hass: HomeAssistant, + mock_config_entry_number: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """The client close waits for an in-flight flush, so no write outlives it. + + Removal cancels the flush task and awaits it before ``async_unload_entry`` + closes the client, so the device call has unwound by the time the client + goes away. A close reaching a live connection while the write is still in + the library call would race the teardown. + """ + in_write = asyncio.Event() + write_active = False + close_saw_write_active: bool | None = None + + async def _blocking_setpoint(kind: SetpointKind, value: int) -> dict[str, Any]: + nonlocal write_active + write_active = True + in_write.set() + try: + # Never released: removal must cancel this to let the unload finish. + await asyncio.Event().wait() + return {"MBF_PAR_PH1": value} + finally: + write_active = False + + async def _record_close() -> None: + nonlocal close_saw_write_active + close_saw_write_active = write_active + + mock_neopool_client.async_set_setpoint = AsyncMock(side_effect=_blocking_setpoint) + mock_neopool_client.close = AsyncMock(side_effect=_record_close) + await setup_integration(hass, mock_config_entry_number) + + ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1") + task = _set_value_nowait(hass, ph1_entity_id, 7.5) + await _let_park(hass) + + # Let the timer fire and the write enter the library call, then block there. + freezer.tick(FLUSH) + async_fire_time_changed(hass) + await in_write.wait() + + # Unload while the write is in flight. Removal cancels and awaits the flush + # task, unwinding the setpoint call, so the unload completes without a hang. + assert await hass.config_entries.async_unload(mock_config_entry_number.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + await task + mock_neopool_client.close.assert_awaited_once() + # The in-flight device call had unwound before the client was closed. + assert close_saw_write_active is False + + +async def test_client_close_waits_for_all_overlapping_flushes( + hass: HomeAssistant, + mock_config_entry_number: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Removal cancels every overlapping flush, not just the most recent one. + + Two set_value calls spaced beyond the settle delay start two flush tasks. + The first is inside the library call (holding the flush lock) when the + second fires and blocks on that lock. Tracking only the latest task would + leave the first device call live past removal, racing the client close. + """ + in_write = asyncio.Event() + write_active = False + close_saw_write_active: bool | None = None + + async def _blocking_setpoint(kind: SetpointKind, value: int) -> dict[str, Any]: + nonlocal write_active + write_active = True + in_write.set() + try: + # Never released: removal must cancel this to let the unload finish. + await asyncio.Event().wait() + return {"MBF_PAR_PH1": value} + finally: + write_active = False + + async def _record_close() -> None: + nonlocal close_saw_write_active + close_saw_write_active = write_active + + mock_neopool_client.async_set_setpoint = AsyncMock(side_effect=_blocking_setpoint) + mock_neopool_client.close = AsyncMock(side_effect=_record_close) + await setup_integration(hass, mock_config_entry_number) + + ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1") + + # First write enters the library call and blocks, holding the flush lock. + first = _set_value_nowait(hass, ph1_entity_id, 7.0) + await _let_park(hass) + freezer.tick(FLUSH) + async_fire_time_changed(hass) + await in_write.wait() + + # A second value's flush fires while the first is in flight; it blocks on + # the flush lock as a separate task, so two flush tasks are now active. + second = _set_value_nowait(hass, ph1_entity_id, 8.0) + await _let_park(hass) + freezer.tick(FLUSH) + async_fire_time_changed(hass) + await _let_park(hass) + + # Unload must cancel and await both tasks, including the first still in the + # library call, before the client closes. + assert await hass.config_entries.async_unload(mock_config_entry_number.entry_id) + await hass.async_block_till_done(wait_background_tasks=True) + + await first + await second + mock_neopool_client.close.assert_awaited_once() + assert close_saw_write_active is False + + +def _get_number_entity(hass: HomeAssistant, entity_id: str) -> Any: + """Return the live NeoPoolNumber object for entity_id.""" + for platform in entity_platform.async_get_platforms(hass, "neopool"): + if entity_id in platform.entities: + return platform.entities[entity_id] + raise AssertionError(f"no number entity {entity_id}") + + +async def test_flush_aborts_when_removed_while_holding_lock( + hass: HomeAssistant, + mock_config_entry_number: MockConfigEntry, + mock_neopool_client: MagicMock, + freezer: FrozenDateTimeFactory, +) -> None: + """A flush that finds the entity removed after winning the lock aborts. + + Removal cancels every tracked flush task, so a queued flush waiting on the + lock is normally canceled before it can write; the in-lock removal guard + only fires if a flush wins the lock in the same tick removal flips the flag. + Drive that directly: hold the flush lock from the test, flip removing, then + release the lock so the parked flush hits the guard before it touches the + device. + """ + mock_neopool_client.async_set_setpoint = AsyncMock( + return_value={"MBF_PAR_PH1": 750} + ) + await setup_integration(hass, mock_config_entry_number) + + ph1_entity_id = _number_entity_id(hass, mock_config_entry_number, "mbf_par_ph1") + entity = _get_number_entity(hass, ph1_entity_id) + + await entity._flush_lock.acquire() + task = _set_value_nowait(hass, ph1_entity_id, 7.5) + await _let_park(hass) + # Fire the debounce timer without async_block_till_done: the flush task + # parks on the lock we hold, so waiting on it here would deadlock. + freezer.tick(FLUSH) + async_fire_time_changed(hass) + await _let_park(hass) + + # The flush is parked on the lock the test holds. Flip removing, then hand + # the lock over so the flush enters and aborts at the in-lock guard. + entity._removing = True + entity._flush_lock.release() + await task + + # The guard aborted before any device write. + mock_neopool_client.async_set_setpoint.assert_not_awaited() + entity._removing = False + + +@pytest.mark.usefixtures("mock_neopool_client") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + mock_config_entry_number: MockConfigEntry, +) -> None: + """Snapshot every entity registered by the number platform.""" + with patch("homeassistant.components.neopool.PLATFORMS", [Platform.NUMBER]): + await setup_integration(hass, mock_config_entry_number) + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry_number.entry_id + ) + + +async def test_setup_when_modules_absent( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry_number: MockConfigEntry, + mock_neopool_client: MagicMock, + minimal_pool_data: dict[str, Any], +) -> None: + """No number entities register when no modules are present. + + Every number is gated on a module or relay, so a controller reporting + none registers nothing. In particular the cover-reduction number must + not appear without a hydrolysis module. + """ + mock_neopool_client.async_read_all.return_value = minimal_pool_data + with patch("homeassistant.components.neopool.PLATFORMS", [Platform.NUMBER]): + await setup_integration(hass, mock_config_entry_number) + number_entries = [ + e + for e in er.async_entries_for_config_entry( + entity_registry, mock_config_entry_number.entry_id + ) + if e.domain == NUMBER_DOMAIN + ] + assert number_entries == [] + + +@pytest.mark.usefixtures("mock_neopool_client") +async def test_cover_gated_numbers_absent_when_option_off( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """The cover-gated numbers are not created while their option is off. + + Both numbers gate on CONF_USE_COVER_SENSOR, so a config entry without it + must register neither, even though the hydrolysis module is present. + """ + await setup_integration(hass, mock_config_entry) + gated = [ + e + for e in er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + if e.domain == NUMBER_DOMAIN + and e.unique_id.endswith( + ("_mbf_par_hidro_cover_reduction", "_mbf_par_hidro_shutdown_temperature") + ) + ] + assert gated == []